Commit 4ccccfc3593f64b4f34dda4af0aa950a1454184e

Authored by yibo
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 +});