Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion publisher/src/actions/PublishClipAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -101,7 +109,7 @@ export class PublishClipAction extends BaseAction<PublishClipInput, PublishClipO

// Announced when the poster lands, or after a grace period without one,
// and only if this is news. See `services/discordWebhook.ts`.
notePublished(filename, hadSidecar);
if (input.announce !== false) notePublished(filename, hadSidecar);

return { filename, url };
}
Expand Down
4 changes: 4 additions & 0 deletions publisher/src/routes/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,16 @@ publishRouter.post('/', upload.single('file'), asyncHandler(async (req, res) =>
// 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);
}));
Expand Down
4 changes: 2 additions & 2 deletions publisher/src/services/clipsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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; }>
Expand Down
17 changes: 16 additions & 1 deletion scripts/discord-webhook-check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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');
Expand Down
7 changes: 7 additions & 0 deletions src/main/actions/RemotePublishAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -65,6 +71,7 @@ export class RemotePublishAction extends BaseAction<RemotePublishInput, RemotePu
// JSON in a form field: a multipart part is bytes with a name, and
// form-data has no notion of a nested array.
if (input.goodBits?.length) form.append('goodBits', JSON.stringify(input.goodBits));
if (input.announce === false) form.append('announce', '0');
const url = `${baseUrl}/api/publish`;
try {
return await this.send(url, form, input);
Expand Down
17 changes: 14 additions & 3 deletions src/main/actions/SyncPublisherAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,24 @@ export class SyncPublisherAction extends BaseAction<void, SyncPublisherOutput> {

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);
Expand Down
10 changes: 9 additions & 1 deletion src/main/services/publisherService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading