From 6c323560e9ea95e3181575296f3cc845cb7021f5 Mon Sep 17 00:00:00 2001 From: Darrell van Swinderen Date: Wed, 23 Sep 2026 09:47:33 +0200 Subject: [PATCH] fix(publisher): putting a lost clip back is not announced to Discord At every boot the desktop re-uploads any clip it holds as published that the publisher does not have (`SyncPublisherAction`). On a fresh or moved container that is the whole library at once, and every one of those arrived as a first publish, so a Discord channel got a post per clip. Found while standing up a local publisher for the hand test: twelve uploads in the first seconds of boot. The restore upload now carries `announce=0` and the publisher skips the card for it. A normal publish sends nothing new; a publisher older than this ignores the field. `discord-webhook-check.mjs` gained the case: a restore with its poster puts nothing in the channel. 18 checks, exit 0. `npm run check` 849. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 4 +++- publisher/src/actions/PublishClipAction.ts | 10 +++++++++- publisher/src/routes/publish.ts | 4 ++++ publisher/src/services/clipsService.ts | 4 ++-- scripts/discord-webhook-check.mjs | 17 ++++++++++++++++- src/main/actions/RemotePublishAction.ts | 7 +++++++ src/main/actions/SyncPublisherAction.ts | 17 ++++++++++++++--- src/main/services/publisherService.ts | 10 +++++++++- 8 files changed, 64 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1cbba113..6e8a6cf8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -862,7 +862,9 @@ card sent at publish time is pictureless for ever. `StoreThumbnailAction` fires poster never comes is announced after a grace period without one. **Only a first publish is news** (a sidecar that already existed means a re-upload), and **a takedown waits five minutes**, because a trim of a published clip unpublishes and publishes again within seconds and would otherwise post -two messages per trim. The webhook URL is a secret in the same class as `PUBLISH_TOKEN` and is never +two messages per trim. **A restore is not news either**: at every boot the desktop re-uploads +any clip it holds as published that the server lacks, which on a fresh or moved container is the +whole library at once, so that upload carries `announce=0` and posts nothing. The webhook URL is a secret in the same class as `PUBLISH_TOKEN` and is never logged; `scripts/discord-webhook-check.mjs` greps the log for it. **Rate limits key on a header, never on `req.ip`.** The publisher runs behind a reverse proxy and diff --git a/publisher/src/actions/PublishClipAction.ts b/publisher/src/actions/PublishClipAction.ts index 94679a3d..2a9c0061 100644 --- a/publisher/src/actions/PublishClipAction.ts +++ b/publisher/src/actions/PublishClipAction.ts @@ -18,6 +18,14 @@ export interface PublishClipInput { * chaptered player, which is a plain player and not a broken one. */ goodBits?: PublishedGoodBit[]; + /** + * False for an upload that puts back a clip the publisher lost, rather than + * one somebody just published. The desktop re-uploads every clip it holds as + * published but the server does not have, which is what happens once to a + * fresh or moved container, and every one of those looks like a first + * publish here: a Discord channel got one post per clip in the library. + */ + announce?: boolean; } export interface PublishClipOutput { @@ -101,7 +109,7 @@ export class PublishClipAction extends BaseAction // A multipart field, so a JSON string. Unparseable or absent is no marks, // which is what every desktop older than the chaptered player sends. const goodBits = parseGoodBits(req.body?.goodBits); + // `announce=0` is a restore, not news. Absent is a normal publish, which is + // what every desktop older than this sends. + const announce = req.body?.announce !== '0'; const result = await clipsService.publish( req.file.path, req.file.originalname, displayName, game, goodBits, + announce, ); res.json(result); })); diff --git a/publisher/src/services/clipsService.ts b/publisher/src/services/clipsService.ts index ec1f08f2..1ef14633 100644 --- a/publisher/src/services/clipsService.ts +++ b/publisher/src/services/clipsService.ts @@ -5,10 +5,10 @@ import { UnpublishClipAction } from '../actions/UnpublishClipAction.js'; import type { PublishedGoodBit } from '../utils/goodBits.js'; class ClipsService { - async publish(filePath: string, originalName: string, displayName?: string, game?: string, goodBits?: PublishedGoodBit[]): Promise<{ filename: string; url: string; }> + async publish(filePath: string, originalName: string, displayName?: string, game?: string, goodBits?: PublishedGoodBit[], announce = true): Promise<{ filename: string; url: string; }> { const action = new PublishClipAction(); - return await action.execute({ filePath, originalName, displayName, game, goodBits }); + return await action.execute({ filePath, originalName, displayName, game, goodBits, announce }); } async unpublish(filePath: string): Promise<{ removed: boolean; }> diff --git a/scripts/discord-webhook-check.mjs b/scripts/discord-webhook-check.mjs index 8a66fb0d..013b12c5 100644 --- a/scripts/discord-webhook-check.mjs +++ b/scripts/discord-webhook-check.mjs @@ -141,12 +141,13 @@ await new Promise((resolve, reject) => { const api = `http://127.0.0.1:${PORT}/api/publish`; const auth = { Authorization: `Bearer ${TOKEN}` }; -async function publish(name, displayName, goodBits = []) { +async function publish(name, displayName, goodBits = [], { announce = true } = {}) { const form = new FormData(); form.append('file', new Blob([Buffer.from('not really a video')]), name); form.append('displayName', displayName); form.append('game', 'Battlefield 6'); form.append('goodBits', JSON.stringify(goodBits)); + if (!announce) form.append('announce', '0'); const response = await fetch(api, { method: 'POST', headers: auth, body: form }); return response.ok; } @@ -233,6 +234,20 @@ await poster('triple.mp4'); await sleep(TAKEDOWN_GRACE_MS + POSTER_GRACE_MS + 400); ok('says nothing at all', received.length === 0, `${received.length} sent`); +/* ------------------------------------------------ a restore, not news */ + +/* + * The desktop re-uploads every clip it holds as published that the server + * lacks, at every boot. On a fresh or moved container that is the whole + * library at once, and each one used to be announced as new. + */ +console.log('\na restore, which is not news'); +received.length = 0; +await publish('restored.mp4', 'Put back', [], { announce: false }); +await poster('restored.mp4'); +await sleep(POSTER_GRACE_MS + 600); +ok('puts nothing in the channel, poster or no poster', received.length === 0, `${received.length} sent`); + /* ----------------------------------------------------------- a takedown */ console.log('\na real takedown'); diff --git a/src/main/actions/RemotePublishAction.ts b/src/main/actions/RemotePublishAction.ts index 2b13ff65..a9ce0da2 100644 --- a/src/main/actions/RemotePublishAction.ts +++ b/src/main/actions/RemotePublishAction.ts @@ -22,6 +22,12 @@ export interface RemotePublishInput { goodBits?: PublishedGoodBit[]; /** 0-1 of the bytes sent, when the size is known. */ onProgress?: (fraction: number) => void; + /** + * False when this puts back a clip the publisher lost rather than publishing + * one, so the publisher does not tell a Discord channel it is new. A + * publisher older than this ignores the field, as it does `goodBits`. + */ + announce?: boolean; } export interface RemotePublishOutput { @@ -65,6 +71,7 @@ export class RemotePublishAction extends BaseAction { const publicBase = (process.env.PUBLISHER_PUBLIC_BASE_URL || '').replace(/\/$/, ''); - // Upload any clips marked published but missing remotely + /* + * Put back any clip marked published that the publisher does not have. + * + * Quietly: this runs at every boot, so a fresh or moved container gets + * every published clip in the library at once, and each would otherwise + * reach the publisher as a first publish and be announced to Discord. + * Putting a link back where it was is not news. + */ for (const clip of allClips.filter(c => c.published && !remoteFiles.has(c.filename))) { const gameDisplayName = gameDisplayNameMap.get(clip.game) || clip.game; const result = await publisherService.publish( - clip.filePath, + clip.filePath, clip.displayName || clip.filename, - gameDisplayName + gameDisplayName, + undefined, + undefined, + undefined, + { announce: false }, ); clip.published = true; clip.publishedUrl = result.url || (publicBase ? `${publicBase}/${encodeURIComponent(clip.filename)}` : null); diff --git a/src/main/services/publisherService.ts b/src/main/services/publisherService.ts index 35ab913e..5156cdac 100644 --- a/src/main/services/publisherService.ts +++ b/src/main/services/publisherService.ts @@ -32,10 +32,18 @@ class PublisherService { onProgress?: (fraction: number) => void, posterPath?: string, goodBits?: PublishedGoodBit[], + options: { announce?: boolean } = {}, ): Promise<{ filename: string; url: string; }> { const action = new RemotePublishAction(); - const result = await action.execute({ filePath, displayName, game, onProgress, goodBits }); + const result = await action.execute({ + filePath, + displayName, + game, + onProgress, + goodBits, + announce: options.announce, + }); await this.sendPoster(result.filename, posterPath ?? (await posterForFile(filePath))); return result; }