diff --git a/CLAUDE.md b/CLAUDE.md index 1cbba11..6e8a6cf 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 94679a3..2a9c006 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 ec1f08f..1ef1463 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 8a66fb0..013b12c 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 2b13ff6..a9ce0da 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 35ab913..5156cda 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; }