Commit 4ccccfc3593f64b4f34dda4af0aa950a1454184e
1 parent
584f831b
Fix: syncLibrary() was resetting playCount/lastPlayedAt every sync
The rebuild path for non-edited entries only carried `likes` forward from the old record; playCount and lastPlayedAt were dropped, so any clip not yet touched in admin.html had its play stats silently reset to "never played" on every periodic sync (server.js runs this every 5 min). Now carries both forward when present, and leaves them absent for a genuinely never-played clip rather than writing a false 0. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Deddn3sM3oQnmvqWSMb4y
Showing
2 changed files
with
114 additions
and
0 deletions
ingest.js
| ... | ... | @@ -91,6 +91,13 @@ async function syncLibrary() { |
| 91 | 91 | // as-is (prompt/fullPrompt/videoUrl overrides included) instead of |
| 92 | 92 | // being rebuilt from the H3 job on every sync. |
| 93 | 93 | if (old && old.edited) return old; |
| 94 | + // playCount/lastPlayedAt only exist once a clip has actually been | |
| 95 | + // played (markPlayed() in server.js), so only carry them forward when | |
| 96 | + // the old record had them — otherwise every 5-min sync silently reset | |
| 97 | + // a clip's play stats back to "never played". | |
| 98 | + const playStats = {}; | |
| 99 | + if (old && old.lastPlayedAt !== undefined) playStats.lastPlayedAt = old.lastPlayedAt; | |
| 100 | + if (old && old.playCount !== undefined) playStats.playCount = old.playCount; | |
| 94 | 101 | return { |
| 95 | 102 | id: j.id, |
| 96 | 103 | source: "h3console", |
| ... | ... | @@ -103,6 +110,7 @@ async function syncLibrary() { |
| 103 | 110 | partition: j.partition, |
| 104 | 111 | likes: (old && old.likes) || 0, |
| 105 | 112 | createdAt: Math.round(j.created_at * 1000), |
| 113 | + ...playStats, | |
| 106 | 114 | }; |
| 107 | 115 | }); |
| 108 | 116 | ... | ... |
ingest.test.js
0 → 100644
| 1 | +// Regression for a real incident (2026-09-02): syncLibrary()'s rebuild path | |
| 2 | +// only carried `likes` forward from the old record, silently resetting | |
| 3 | +// playCount/lastPlayedAt to "never played" on every periodic sync (every | |
| 4 | +// 5 min in server.js) for any clip that hadn't been touched in admin.html | |
| 5 | +// (i.e. wasn't `edited: true`). Run: node --test ingest.test.js | |
| 6 | +const test = require("node:test"); | |
| 7 | +const assert = require("node:assert/strict"); | |
| 8 | +const fs = require("node:fs"); | |
| 9 | +const path = require("node:path"); | |
| 10 | +const os = require("node:os"); | |
| 11 | +const http = require("node:http"); | |
| 12 | + | |
| 13 | +function startMockH3(jobs) { | |
| 14 | + return new Promise((resolve) => { | |
| 15 | + const server = http.createServer((req, res) => { | |
| 16 | + if (req.method === "GET" && req.url.startsWith("/v1/videos")) { | |
| 17 | + res.writeHead(200, { "Content-Type": "application/json" }); | |
| 18 | + res.end(JSON.stringify({ jobs })); | |
| 19 | + return; | |
| 20 | + } | |
| 21 | + res.writeHead(404); | |
| 22 | + res.end(); | |
| 23 | + }); | |
| 24 | + server.listen(0, () => resolve(server)); | |
| 25 | + }); | |
| 26 | +} | |
| 27 | + | |
| 28 | +test("syncLibrary: carries playCount/lastPlayedAt forward for non-edited clips, not just likes", async () => { | |
| 29 | + const job = { | |
| 30 | + id: "clip-1", | |
| 31 | + status: "done", | |
| 32 | + download: true, | |
| 33 | + created_at: 1700000000, | |
| 34 | + partition: "text", | |
| 35 | + req: { prompt: "a long enough real prompt that will not match the isTestJob short-circuit ".repeat(3), width: 480, height: 832, frames: 240 }, | |
| 36 | + }; | |
| 37 | + const mockH3 = await startMockH3([job]); | |
| 38 | + const mockH3Port = mockH3.address().port; | |
| 39 | + | |
| 40 | + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "ingest-test-")); | |
| 41 | + fs.writeFileSync( | |
| 42 | + path.join(cwd, "library.json"), | |
| 43 | + 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 }]) | |
| 44 | + ); | |
| 45 | + fs.copyFileSync(path.join(__dirname, "ingest.js"), path.join(cwd, "ingest.js")); | |
| 46 | + | |
| 47 | + process.env.H3_CONSOLE_BASE = `http://localhost:${mockH3Port}`; | |
| 48 | + process.env.H3_CONSOLE_TOKEN = "mock-token"; | |
| 49 | + delete require.cache[path.join(cwd, "ingest.js")]; | |
| 50 | + const { syncLibrary } = require(path.join(cwd, "ingest.js")); | |
| 51 | + | |
| 52 | + const cwdBefore = process.cwd(); | |
| 53 | + process.chdir(cwd); | |
| 54 | + try { | |
| 55 | + await syncLibrary(); | |
| 56 | + } finally { | |
| 57 | + process.chdir(cwdBefore); | |
| 58 | + mockH3.close(); | |
| 59 | + } | |
| 60 | + | |
| 61 | + const library = JSON.parse(fs.readFileSync(path.join(cwd, "library.json"), "utf8")); | |
| 62 | + const clip = library.find((c) => c.id === "clip-1"); | |
| 63 | + assert.equal(clip.likes, 3, "likes was already carried forward correctly"); | |
| 64 | + assert.equal(clip.lastPlayedAt, 123456, "lastPlayedAt must survive a sync, not reset to unplayed"); | |
| 65 | + assert.equal(clip.playCount, 7, "playCount must survive a sync, not reset to 0"); | |
| 66 | + | |
| 67 | + fs.rmSync(cwd, { recursive: true, force: true }); | |
| 68 | +}); | |
| 69 | + | |
| 70 | +test("syncLibrary: a never-played clip stays without playCount/lastPlayedAt fields (no false '0 plays')", async () => { | |
| 71 | + const job = { | |
| 72 | + id: "clip-2", | |
| 73 | + status: "done", | |
| 74 | + download: true, | |
| 75 | + created_at: 1700000000, | |
| 76 | + partition: "text", | |
| 77 | + req: { prompt: "a long enough real prompt that will not match the isTestJob short-circuit ".repeat(3), width: 480, height: 832, frames: 240 }, | |
| 78 | + }; | |
| 79 | + const mockH3 = await startMockH3([job]); | |
| 80 | + const mockH3Port = mockH3.address().port; | |
| 81 | + | |
| 82 | + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "ingest-test-")); | |
| 83 | + fs.writeFileSync(path.join(cwd, "library.json"), JSON.stringify([])); | |
| 84 | + fs.copyFileSync(path.join(__dirname, "ingest.js"), path.join(cwd, "ingest.js")); | |
| 85 | + | |
| 86 | + process.env.H3_CONSOLE_BASE = `http://localhost:${mockH3Port}`; | |
| 87 | + process.env.H3_CONSOLE_TOKEN = "mock-token"; | |
| 88 | + delete require.cache[path.join(cwd, "ingest.js")]; | |
| 89 | + const { syncLibrary } = require(path.join(cwd, "ingest.js")); | |
| 90 | + | |
| 91 | + const cwdBefore = process.cwd(); | |
| 92 | + process.chdir(cwd); | |
| 93 | + try { | |
| 94 | + await syncLibrary(); | |
| 95 | + } finally { | |
| 96 | + process.chdir(cwdBefore); | |
| 97 | + mockH3.close(); | |
| 98 | + } | |
| 99 | + | |
| 100 | + const library = JSON.parse(fs.readFileSync(path.join(cwd, "library.json"), "utf8")); | |
| 101 | + const clip = library.find((c) => c.id === "clip-2"); | |
| 102 | + assert.equal("lastPlayedAt" in clip, false); | |
| 103 | + assert.equal("playCount" in clip, false); | |
| 104 | + | |
| 105 | + fs.rmSync(cwd, { recursive: true, force: true }); | |
| 106 | +}); | ... | ... |