diff --git a/js/ui/profileModal/myVideosReconcile.js b/js/ui/profileModal/myVideosReconcile.js new file mode 100644 index 00000000..2fc337b3 --- /dev/null +++ b/js/ui/profileModal/myVideosReconcile.js @@ -0,0 +1,83 @@ +// Phase 2 reconciliation core for the "My Videos" tab: compare what the user's +// video notes reference against what actually lives in their storage bucket, to +// surface (a) MISSING files — an active note whose hosted file is gone — and +// (b) ORPHAN objects — bucket files no live note references, including the +// "deleted on Nostr but the folder is still in the bucket" case. Pure/sync so +// it's deterministic and testable; the controller supplies the bucket listing. + +import { collectVideoStorageKeys } from "../../utils/storagePointer.js"; + +function trimStr(value) { + return typeof value === "string" ? value.trim() : ""; +} + +// Derive the bucket KEY for a hosted URL (mirrors collectVideoStorageKeys' +// internal keyFromUrl). Used to identify the PRIMARY video object so a missing +// main file is flagged, without conflating it with a missing sibling .torrent. +function keyFromUrl(url, publicBaseUrl) { + const u = trimStr(url); + const base = trimStr(publicBaseUrl).replace(/\/+$/, ""); + if (!u || !base) { + return ""; + } + const prefix = `${base}/`; + if (!u.startsWith(prefix)) { + return ""; + } + let rel = u.slice(prefix.length).split(/[?#]/)[0]; + try { + rel = rel.split("/").map((seg) => decodeURIComponent(seg)).join("/"); + } catch (_) { + // keep raw if not valid percent-encoding + } + return rel.replace(/^\/+/, ""); +} + +/** + * Reconcile the user's videos against their bucket contents. + * + * @param {object} params + * @param {Array} params.videos collapsed current-state videos + * @param {Array} params.bucketKeys keys present in the bucket (ListObjectsV2) + * @param {string} params.publicBaseUrl the user's storage public base URL + * @returns {{ + * missing: Array<{ video: object, key: string }>, + * orphanKeys: string[], + * }} + * missing: active hosted notes whose primary file is absent from the bucket + * orphanKeys: bucket objects not referenced by any live (non-deleted) note + */ +export function reconcileStorage({ videos = [], bucketKeys = [], publicBaseUrl = "" } = {}) { + const present = new Set( + (Array.isArray(bucketKeys) ? bucketKeys : []).filter( + (k) => typeof k === "string" && k, + ), + ); + const referenced = new Set(); + const missing = []; + + for (const video of Array.isArray(videos) ? videos : []) { + if (!video || typeof video !== "object") { + continue; + } + // A deleted note references nothing (its URL was scrubbed by the tombstone), + // so any file left behind for it correctly falls through to orphanKeys. + if (video.deleted === true) { + continue; + } + // Whole bundle (video + sibling .torrent + thumbnail) counts as "referenced" + // so none of those are mistaken for orphans. + for (const key of collectVideoStorageKeys({ videos: [video], publicBaseUrl })) { + referenced.add(key); + } + // Missing status keys off the PRIMARY video object only (a missing .torrent + // or thumbnail is minor and shouldn't paint the row red). + const primary = keyFromUrl(video.url, publicBaseUrl); + if (primary && !present.has(primary)) { + missing.push({ video, key: primary }); + } + } + + const orphanKeys = [...present].filter((key) => !referenced.has(key)); + return { missing, orphanKeys }; +} diff --git a/tests/my-videos-reconcile.test.mjs b/tests/my-videos-reconcile.test.mjs new file mode 100644 index 00000000..0194a48e --- /dev/null +++ b/tests/my-videos-reconcile.test.mjs @@ -0,0 +1,92 @@ +// Phase 2 reconciliation: diff the user's notes against their bucket contents. +// Must (a) flag a live note whose hosted file is gone as MISSING, (b) flag bucket +// objects no live note references as ORPHANS — crucially including a deleted +// video's leftover file (the "deleted on Nostr, still in the bucket" case) — and +// (c) NEVER flag a file an active note still uses (incl. its .torrent/thumbnail). + +import assert from "node:assert/strict"; +import test from "node:test"; +import { reconcileStorage } from "../js/ui/profileModal/myVideosReconcile.js"; + +const BASE = "https://cdn.example.com"; +const key = (k) => `${BASE}/${k}`; + +test("a deleted video's leftover file is reported as an orphan", () => { + const { orphanKeys, missing } = reconcileStorage({ + // tombstone scrubbed the URL, so the note references nothing + videos: [{ deleted: true, url: "", magnet: "" }], + bucketKeys: ["u/npub1/abc/video.mp4", "u/npub1/abc/video.torrent"], + publicBaseUrl: BASE, + }); + assert.deepEqual(orphanKeys.sort(), [ + "u/npub1/abc/video.mp4", + "u/npub1/abc/video.torrent", + ]); + assert.equal(missing.length, 0); +}); + +test("a file an active note still uses (and its siblings) is never an orphan", () => { + const { orphanKeys } = reconcileStorage({ + videos: [ + { + url: key("u/npub1/abc/video.mp4"), + thumbnail: key("u/npub1/abc/thumb.jpg"), + }, + ], + bucketKeys: [ + "u/npub1/abc/video.mp4", + "u/npub1/abc/video.torrent", + "u/npub1/abc/thumb.jpg", + ], + publicBaseUrl: BASE, + }); + assert.deepEqual(orphanKeys, [], "the whole referenced bundle is protected"); +}); + +test("an active note whose hosted file is gone is reported as missing", () => { + const { missing } = reconcileStorage({ + videos: [{ id: "v1", url: key("u/npub1/abc/video.mp4") }], + bucketKeys: ["u/npub1/other/thing.mp4"], + publicBaseUrl: BASE, + }); + assert.equal(missing.length, 1); + assert.equal(missing[0].video.id, "v1"); + assert.equal(missing[0].key, "u/npub1/abc/video.mp4"); +}); + +test("a missing sibling .torrent does NOT mark the row missing (primary present)", () => { + const { missing } = reconcileStorage({ + videos: [{ id: "v1", url: key("u/npub1/abc/video.mp4") }], + bucketKeys: ["u/npub1/abc/video.mp4"], // present, but no .torrent + publicBaseUrl: BASE, + }); + assert.equal(missing.length, 0, "only a missing primary video file counts"); +}); + +test("external-URL videos never produce missing or referenced keys", () => { + const { missing, orphanKeys } = reconcileStorage({ + videos: [{ id: "v1", url: "https://third-party.example/v.mp4" }], + bucketKeys: ["u/npub1/abc/video.mp4"], + publicBaseUrl: BASE, + }); + assert.equal(missing.length, 0, "can't be missing — not hosted by us"); + assert.deepEqual( + orphanKeys, + ["u/npub1/abc/video.mp4"], + "the unrelated bucket file is still an orphan", + ); +}); + +test("mixed library: one healthy, one missing, one orphan from a delete", () => { + const { missing, orphanKeys } = reconcileStorage({ + videos: [ + { id: "ok", url: key("u/n/ok/v.mp4") }, + { id: "gone", url: key("u/n/gone/v.mp4") }, + { id: "del", deleted: true, url: "" }, + ], + bucketKeys: ["u/n/ok/v.mp4", "u/n/del/v.mp4"], + publicBaseUrl: BASE, + }); + assert.deepEqual(missing.map((m) => m.video.id), ["gone"]); + assert.deepEqual(orphanKeys, ["u/n/del/v.mp4"]); +});