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,6 +91,13 @@ async function syncLibrary() { | ||
| 91 | // as-is (prompt/fullPrompt/videoUrl overrides included) instead of | 91 | // as-is (prompt/fullPrompt/videoUrl overrides included) instead of |
| 92 | // being rebuilt from the H3 job on every sync. | 92 | // being rebuilt from the H3 job on every sync. |
| 93 | if (old && old.edited) return old; | 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 | return { | 101 | return { |
| 95 | id: j.id, | 102 | id: j.id, |
| 96 | source: "h3console", | 103 | source: "h3console", |
| @@ -103,6 +110,7 @@ async function syncLibrary() { | @@ -103,6 +110,7 @@ async function syncLibrary() { | ||
| 103 | partition: j.partition, | 110 | partition: j.partition, |
| 104 | likes: (old && old.likes) || 0, | 111 | likes: (old && old.likes) || 0, |
| 105 | createdAt: Math.round(j.created_at * 1000), | 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 | +}); |