feat(my-videos): storage reconciliation core (Phase 2, step 1)

Pure diff of the user's notes against their bucket contents: reconcileStorage
returns MISSING (an active hosted note whose primary video file is absent) and
ORPHAN keys (bucket objects no live note references — including a deleted video's
leftover file, the "deleted on Nostr but still in the bucket" case). Safety
guarantees: a file an active note still uses, plus its sibling .torrent and
thumbnail, is never reported as an orphan; a missing sibling does not mark a row
missing (only a missing primary video file does); external-URL videos can't be
"missing" but unrelated bucket files are still orphans.

Tests + mutation-verified: orphan = present minus referenced (not all present),
and the missing-primary check, are both independently guarded.

Build + full lint green. Next: r2Service.listVideoStorageObjects + wiring.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-20 14:48:54 -04:00
1 parent f847bce0d5
commit bbb8dd0fd8
2 files changed
+175

No files matched your search

+83
View File
@@ -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<object>} params.videos collapsed current-state videos
* @param {Array<string>} 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 };
}
+92
View File
@@ -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"]);
});