diff --git a/ingest.js b/ingest.js index 8807905..31f97eb 100644 --- a/ingest.js +++ b/ingest.js @@ -91,6 +91,13 @@ async function syncLibrary() { // as-is (prompt/fullPrompt/videoUrl overrides included) instead of // being rebuilt from the H3 job on every sync. if (old && old.edited) return old; + // playCount/lastPlayedAt only exist once a clip has actually been + // played (markPlayed() in server.js), so only carry them forward when + // the old record had them — otherwise every 5-min sync silently reset + // a clip's play stats back to "never played". + const playStats = {}; + if (old && old.lastPlayedAt !== undefined) playStats.lastPlayedAt = old.lastPlayedAt; + if (old && old.playCount !== undefined) playStats.playCount = old.playCount; return { id: j.id, source: "h3console", @@ -103,6 +110,7 @@ async function syncLibrary() { partition: j.partition, likes: (old && old.likes) || 0, createdAt: Math.round(j.created_at * 1000), + ...playStats, }; }); diff --git a/ingest.test.js b/ingest.test.js new file mode 100644 index 0000000..17f8ba9 --- /dev/null +++ b/ingest.test.js @@ -0,0 +1,106 @@ +// Regression for a real incident (2026-09-02): syncLibrary()'s rebuild path +// only carried `likes` forward from the old record, silently resetting +// playCount/lastPlayedAt to "never played" on every periodic sync (every +// 5 min in server.js) for any clip that hadn't been touched in admin.html +// (i.e. wasn't `edited: true`). Run: node --test ingest.test.js +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const os = require("node:os"); +const http = require("node:http"); + +function startMockH3(jobs) { + return new Promise((resolve) => { + const server = http.createServer((req, res) => { + if (req.method === "GET" && req.url.startsWith("/v1/videos")) { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ jobs })); + return; + } + res.writeHead(404); + res.end(); + }); + server.listen(0, () => resolve(server)); + }); +} + +test("syncLibrary: carries playCount/lastPlayedAt forward for non-edited clips, not just likes", async () => { + const job = { + id: "clip-1", + status: "done", + download: true, + created_at: 1700000000, + partition: "text", + req: { prompt: "a long enough real prompt that will not match the isTestJob short-circuit ".repeat(3), width: 480, height: 832, frames: 240 }, + }; + const mockH3 = await startMockH3([job]); + const mockH3Port = mockH3.address().port; + + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "ingest-test-")); + fs.writeFileSync( + path.join(cwd, "library.json"), + JSON.stringify([{ id: "clip-1", source: "h3console", prompt: "old", fullPrompt: "old", edited: false, width: 480, height: 832, duration: 10, partition: "text", likes: 3, createdAt: 1000, lastPlayedAt: 123456, playCount: 7 }]) + ); + fs.copyFileSync(path.join(__dirname, "ingest.js"), path.join(cwd, "ingest.js")); + + process.env.H3_CONSOLE_BASE = `http://localhost:${mockH3Port}`; + process.env.H3_CONSOLE_TOKEN = "mock-token"; + delete require.cache[path.join(cwd, "ingest.js")]; + const { syncLibrary } = require(path.join(cwd, "ingest.js")); + + const cwdBefore = process.cwd(); + process.chdir(cwd); + try { + await syncLibrary(); + } finally { + process.chdir(cwdBefore); + mockH3.close(); + } + + const library = JSON.parse(fs.readFileSync(path.join(cwd, "library.json"), "utf8")); + const clip = library.find((c) => c.id === "clip-1"); + assert.equal(clip.likes, 3, "likes was already carried forward correctly"); + assert.equal(clip.lastPlayedAt, 123456, "lastPlayedAt must survive a sync, not reset to unplayed"); + assert.equal(clip.playCount, 7, "playCount must survive a sync, not reset to 0"); + + fs.rmSync(cwd, { recursive: true, force: true }); +}); + +test("syncLibrary: a never-played clip stays without playCount/lastPlayedAt fields (no false '0 plays')", async () => { + const job = { + id: "clip-2", + status: "done", + download: true, + created_at: 1700000000, + partition: "text", + req: { prompt: "a long enough real prompt that will not match the isTestJob short-circuit ".repeat(3), width: 480, height: 832, frames: 240 }, + }; + const mockH3 = await startMockH3([job]); + const mockH3Port = mockH3.address().port; + + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "ingest-test-")); + fs.writeFileSync(path.join(cwd, "library.json"), JSON.stringify([])); + fs.copyFileSync(path.join(__dirname, "ingest.js"), path.join(cwd, "ingest.js")); + + process.env.H3_CONSOLE_BASE = `http://localhost:${mockH3Port}`; + process.env.H3_CONSOLE_TOKEN = "mock-token"; + delete require.cache[path.join(cwd, "ingest.js")]; + const { syncLibrary } = require(path.join(cwd, "ingest.js")); + + const cwdBefore = process.cwd(); + process.chdir(cwd); + try { + await syncLibrary(); + } finally { + process.chdir(cwdBefore); + mockH3.close(); + } + + const library = JSON.parse(fs.readFileSync(path.join(cwd, "library.json"), "utf8")); + const clip = library.find((c) => c.id === "clip-2"); + assert.equal("lastPlayedAt" in clip, false); + assert.equal("playCount" in clip, false); + + fs.rmSync(cwd, { recursive: true, force: true }); +});