From c42cd350a67d88194b99e713a264697149152dd8 Mon Sep 17 00:00:00 2001 From: dim145 Date: Mon, 26 Oct 2026 03:01:03 +0100 Subject: [PATCH 1/4] Make the WebP copies a choice, off until it is made, ask before deleting a format's copies, and let a reader keep the posters for adults shown with their account The WebP copies are off until media.webp is set: the pages are given the pictures themselves, with an AVIF source over them where the night's copies reach as wide, and an address of a WebP copy answers the copy kept or sends to the original, making none. The placeholders are still worked out as a picture is stored; media.variants makes the first copies only while WebP copies are on. GET /api/v1/auth/options says whether they are, so a page draws the right sources from its first picture. Turning the WebP or the AVIF copies off while some are kept asks whether to delete them too. DELETE /api/v1/media/variants/{format} deletes every copy of a format once it is off, five hundred at a time in the background, under a lock shared among instances; the Media page follows it. A Reading panel on the profile page keeps whether the posters of works for adults are shown with the account (reader.adultPosters), so the choice follows it from one browser to the next; the bar's Language and reading menu is the same choice for whoever is signed in, and stays the browser's for a visitor. The settings reference gives the AVIF copies' background widths as made, 1280 and 1920. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 19 ++ docs/media.md | 101 +++++++-- docs/settings.md | 17 +- frontend/e2e/adult-posters.spec.ts | 142 ++++++++++++ frontend/e2e/media.serial.spec.ts | 37 +--- frontend/e2e/support/png.ts | 42 ++++ frontend/e2e/webp.serial.spec.ts | 207 ++++++++++++++++++ frontend/openapi.json | 95 +++++++- frontend/src/App.tsx | 143 ++++++------ frontend/src/components/PublicShell.tsx | 17 +- frontend/src/components/media.tsx | 34 ++- frontend/src/lib/adultPosters.test.ts | 22 ++ frontend/src/lib/adultPosters.ts | 86 ++++++++ frontend/src/lib/api.gen.ts | 108 +++++++++ frontend/src/lib/hooks.ts | 12 +- frontend/src/lib/lang/en.ts | 36 ++- frontend/src/lib/lang/fr.ts | 36 ++- frontend/src/lib/media.test.ts | 100 ++++++--- frontend/src/lib/media.ts | 88 ++++++-- frontend/src/lib/preferences.ts | 11 +- frontend/src/lib/prefs.test.ts | 35 +++ frontend/src/lib/prefs.ts | 37 +++- frontend/src/lib/types.ts | 6 + frontend/src/routes/Account.tsx | 36 ++- frontend/src/routes/admin/Media.tsx | 160 +++++++++++++- src/api/native/media.rs | 280 +++++++++++++++++++++++- src/api/native/signup.rs | 28 +++ src/db/repo/asset.rs | 100 +++++++++ src/jobs/media.rs | 270 +++++++++++++++++++++-- src/media/serve.rs | 220 ++++++++++++++++++- src/media/variants.rs | 50 +++-- src/media/worker.rs | 25 ++- src/settings/reference.rs | 17 +- src/settings/registry.rs | 8 + 34 files changed, 2355 insertions(+), 270 deletions(-) create mode 100644 frontend/e2e/adult-posters.spec.ts create mode 100644 frontend/e2e/support/png.ts create mode 100644 frontend/e2e/webp.serial.spec.ts create mode 100644 frontend/src/lib/adultPosters.test.ts create mode 100644 frontend/src/lib/adultPosters.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b68539a..f5e3539 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,25 @@ may change what the API or the configuration means, and says so here. is made with wants some 4 GB at its last step, more than a Docker VM shared with other containers may have. A build from source takes the same variable; CI and the published image are unchanged. +- A **Reading** panel on the profile page: whether the posters of works for + adults are shown as they are or veiled, kept with the account + (`reader.adultPosters`) so the choice follows it from one browser to the + next. The switch in the bar's *Language and reading* menu is the same + choice for whoever is signed in, and stays this browser's for a visitor. +- Turning the WebP or the AVIF copies off, while some are kept, asks whether + to delete them too. `DELETE /api/v1/media/variants/{format}` deletes every + copy of a format once it is off, in the background, which the Media page + follows (`purging` in `GET /api/v1/media/status`). + +### Changed + +- The WebP copies are off until `media.webp` is set: the pages are given the + pictures themselves — with an AVIF `` over them where the night's + copies reach as wide — and an address of a WebP copy answers the copy kept + or sends to the original, making none. A server that wants the lighter + pages turns them on, on the Media page. `media.variants` prepares the + placeholders, and the first copies only while the WebP copies are on; + `GET /api/v1/auth/options` says whether they are (`webp`). ## [0.10.0] — 2026-10-25 diff --git a/docs/media.md b/docs/media.md index 6eae129..ab581ff 100644 --- a/docs/media.md +++ b/docs/media.md @@ -35,9 +35,10 @@ default: nothing is kept until it is asked for. pictures as they were fetched — JPEG and PNG, whole — at the copies' addresses when `AMS_PUBLIC_URL` is set, and at the providers' when it is not, since only this server's own pages can follow a path. This server's - own pages are given smaller copies instead: see below. A bucket's media - go through this server, or, with **Sent to the bucket's own address**, a - reader is redirected to a presigned address good for an hour. + own pages are given the same pictures, or smaller copies once those are + turned on: see below. A bucket's media go through this server, or, with + **Sent to the bucket's own address**, a reader is redirected to a + presigned address good for an hour. - **By hand.** From a work's page, a picture or a theme can be uploaded — read for what it is, never for what it is called; SVG is refused, since a document that can carry a script must not be served from here — and a @@ -66,10 +67,10 @@ default: nothing is kept until it is asked for. A poster kept is two to three thousand pixels tall and a background up to four thousand wide: what a client that keeps one picture wants, and ten -times what a card or a page draws. So this server's own pages are given -copies, made of each picture at the widths it is drawn at, once and twice -over for a dense screen, never wider than the picture and never wider than -1920 pixels: +times what a card or a page draws. So this server can give its own pages +WebP copies instead, made of each picture at the widths it is drawn at, +once and twice over for a dense screen, never wider than the picture and +never wider than 1920 pixels: | A picture drawn as | Widths | |---|---| @@ -80,6 +81,14 @@ over for a dense screen, never wider than the picture and never wider than | an episode's still | 320, 640 | | a photograph of the cast | 120, 240 | +They are off until somebody turns them on — **WebP copies for the pages** +on the Media page, the setting `media.webp` — and until then the pages are +given the pictures themselves, whole, with the AVIF copies over them where +the night made some (see [AVIF](#avif)): no WebP copy is made. A page +learns which with the site's other options (`webp` in +`GET /api/v1/auth/options`) — the catalogue's pages before they draw +anything — and asks for the right files from its first picture. + - **WebP.** Lossy, at quality 80, through libwebp's encoder — a poster's colours converted with its sharper method, so that thin lettering keeps its edges, and a transparency kept exact beside the colours; lossless @@ -92,13 +101,22 @@ over for a dense screen, never wider than the picture and never wider than the original. - **Made once, when first asked for.** A copy is named by its picture's hash and its width — `/media/-480.webp` — and kept beside the - picture in the store, in a directory of its own. The copy a page asks for - first is made as the picture is stored: a poster's 480, a background's - 1280. Any other is made the first time a page asks for it — a few hundred - milliseconds for a large background — and served from the store after. - A width no page asks for is refused, so a picture has a dozen copies at - the most. The thumbnails earlier versions made are still served; none is - made any more. + picture in the store, in a directory of its own. While the copies are + on, the copy a page asks for first is made as the picture is stored: a + poster's 480, a background's 1280. Any other is made the first time a + page asks for it — a few hundred milliseconds for a large background — + and served from the store after. A width no page asks for is refused, so + a picture has a dozen copies at the most. The thumbnails earlier versions + made are still served; none is made any more. +- **Turned on.** The pages ask for the copies at once, and the pictures + kept already have theirs made as they are asked for: a catalogue's first + pages after the switch are a little slower to fill. +- **Turned off.** The pages are given the pictures themselves again, and + no copy is made. A page opened before, which still asks for one, is + given the copy where it is kept, and sent to the picture where it is not, + for that time only. The copies made stay in the store, ready should they + be turned back on, until they are deleted: see + [Deleting the copies of a format](#deleting-the-copies-of-a-format). - **Read safely.** A picture is decoded in Rust, never by libwebp: a picture past 16,384 pixels a side or 100 million pixels is not decoded at all, nor one whose pixels would take more than 256 MB, and a decoder @@ -121,14 +139,15 @@ proportions before a byte of it is there. ### Preparing what was kept before -Pictures stored before this version have neither a placeholder nor their -first copy. Their copies are made all the same, as pages ask for them; the -task **Prepare the pictures** (`media.variants`, on **Tasks** and on the -Media page, which says how many are left) works out their placeholders and -makes their first copies, a few at a time. It runs only when somebody runs -it — never at start: a catalogue's pictures are hours of decoding on a small -machine. It can be stopped; each picture is noted as it is done, so the -next run takes up where the last one stopped. +Pictures stored before this version have no placeholder, and no first +copy: a page draws them without one, and — while the WebP copies are on — +has their copies made as it asks for them. The task **Prepare the +pictures** (`media.variants`, on **Tasks** and on the Media page, which +says how many are left) works out their placeholders and, while the WebP +copies are on, makes their first copies, a few at a time. It runs only +when somebody runs it — never at start: a catalogue's pictures are hours of +decoding on a small machine. It can be stopped; each picture is noted as it +is done, so the next run takes up where the last one stopped. ### AVIF @@ -144,6 +163,13 @@ background's 1920 takes about a second and a half at night. No browser is asked what it reads: the addresses are fixed, and every browser the interface supports reads both. +With the WebP copies off, a page offers the same AVIF copies over the +picture itself, by the same rule: where they reach as wide as the role the +picture is drawn in, a browser takes the AVIF of the width it needs rather +than the whole picture, and where they do not, the picture. An AVIF whose +file has gone is answered with the WebP of its width while those are on, +and with the picture while they are off. + Measured on fifty posters from TMDB, on one core of an Apple M-series processor, at quality 68 and rav1e's speed 6: @@ -158,9 +184,36 @@ feature; NASM assembles it on x86_64); a build from source has it with `cargo build --release --features avif-asm`. Making them takes about 170 MB of memory at its peak. +### Deleting the copies of a format + +Turning the WebP or the AVIF copies off on the Media page while some are +kept asks what becomes of them, and says how many there are and what they +weigh. **Cancel** changes nothing. **Turn off and keep them** turns them +off and keeps the copies: a WebP copy is ready should they be turned back +on, and an AVIF copy is still given to the pages, though no other is made. +**Turn off and delete them** turns them off and deletes every copy of that +format, to have the space back. With none kept, the switch simply turns +off. + +The deletion runs in the background, a few hundred copies at a time — each +file taken from the store, then its row — and the Media page says +**Deleting copies now** until it is over. Turned on again meanwhile, the +setting stops it where it is. The pictures themselves are never touched, +nor the squares Plex is given. + +Through the API, an administrator's: `DELETE /api/v1/media/variants/webp` +(or `avif`), once that format's setting is off. It answers at once, `202`, +with how many copies there were and what they weighed (`copies`, `bytes`), +and the store's status says `purging` while it runs +(`GET /api/v1/media/status`). It is refused with `409` while the setting +is still on, while the AVIF copies are being made, or while another +deletion runs, and with `503` when no store is configured. Of several +instances, the one asked may not have heard yet that the setting is off: +the Media page asks once more a second later. + The providers' terms still apply to what is kept: TMDB, TheTVDB and Fanart.tv each say what may be done with their pictures. The switches of the **Media** page — store, how a bucket's copies are served, the cast's -photographs, the themes, the AVIF copies — are settings: see [Media](settings.md#media) in -the settings reference. +photographs, the themes, the WebP copies, the AVIF copies — are settings: see +[Media](settings.md#media) in the settings reference. diff --git a/docs/settings.md b/docs/settings.md index 023f715..4b91156 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -630,6 +630,7 @@ On the **Media** page, and read only when a store is configured (`AMS_MEDIA_STOR | [`media.serve`](#mediaserve) | `proxy` or `redirect` | `proxy` | server | | [`media.people`](#mediapeople) | `true` or `false` | `true` | server | | [`media.audio`](#mediaaudio) | `true` or `false` | `true` | server | +| [`media.webp`](#mediawebp) | `true` or `false` | unset | server | | [`media.avif`](#mediaavif) | `true` or `false` | unset | server | ### `media.store` @@ -650,12 +651,20 @@ The cast's photographs too: one per person, many per work. The themes: a Fan-Kai's, a few megabytes each. +### `media.webp` + +Make the WebP copies the pages ask for, at the widths they ask for them — the first as a +picture is stored, any other the first time a page asks — and give the pages those rather +than the pictures themselves. Off until it is set, and the pages are given the originals, +whole; the copies made while it was on are kept until an administrator deletes them. See +[The copies the pages are given](media.md#the-copies-the-pages-are-given). + ### `media.avif` -Make an AVIF copy of every poster, at 480 and 960 pixels, and of every background, at 1280, -each night at 03:00 UTC — the task `media.avif`, also run by hand from **Tasks**. A third -lighter than the WebP the interface is given otherwise, and a second or two of a processor -each to make: off until it is set. See [AVIF](media.md#avif). +Make an AVIF copy of every poster, at 480 and 960 pixels, and of every background, at 1280 +and 1920, each night at 03:00 UTC — the task `media.avif`, also run by hand from **Tasks**. +A third lighter than a WebP copy of the same width, and a second or two of a processor each +to make: off until it is set. See [AVIF](media.md#avif). ## Cache diff --git a/frontend/e2e/adult-posters.spec.ts b/frontend/e2e/adult-posters.spec.ts new file mode 100644 index 0000000..898a753 --- /dev/null +++ b/frontend/e2e/adult-posters.spec.ts @@ -0,0 +1,142 @@ +import { expect, test, type Page } from '@playwright/test' +import { scan } from './support/axe' + +/** + * The posters of works for adults, as an account chooses them on its own + * page: veiled until it asks, then shown — in every browser it signs in on, + * not only the one it asked in — and veiled again from the bar's reading + * menu, which holds the same choice. + * + * The administrator's own choice, which the desk alone turns: both projects + * run at once, and would turn the one account's choice under each other. + * What the account kept before is put back afterwards, whatever happened. + */ + +const USERNAME = process.env.AMS_E2E_USER +const PASSWORD = process.env.AMS_E2E_PASSWORD + +/** Where the account keeps the choice (`adultPosters.ts`). */ +const PREFERENCE = '/api/v1/account/preferences/reader.adultPosters' + +async function signIn(page: Page) { + await page.goto('/login') + await page.getByLabel(/username|identifiant/i).fill(USERNAME!) + await page.getByLabel(/password|mot de passe/i).fill(PASSWORD!) + await page.getByRole('button', { name: /sign in|se connecter/i }).click() + await page.waitForURL('**/admin') +} + +interface Work { + id: string + isAdult?: boolean + contentRating?: string | null + images?: { coverType: string }[] +} + +/** + * Whether the interface veils a work's poster for a reader who has not asked + * to see it, as `forAdults` decides: marked for adults, or rated eighteen or + * more, or in one of the few ratings said in letters. + */ +function forAdults(work: Work): boolean { + const rating = work.contentRating?.trim().toUpperCase() ?? '' + const age = Number(/\d+/.exec(rating)?.[0] ?? Number.NaN) + return work.isAdult === true || ['NC-17', 'X', 'XXX', 'AO', 'ADULTS ONLY'].includes(rating) || (age >= 18 && age <= 21) +} + +/** What the account keeps of the choice, if anything. */ +async function kept(page: Page): Promise { + const answer = await page.request.get('/api/v1/account/preferences') + return ((await answer.json()) as { values: Record }).values['reader.adultPosters'] +} + +/** The choice as the page sends it, a moment after it is made. */ +function sent(page: Page) { + return page.waitForResponse( + (response) => response.url().endsWith(PREFERENCE) && response.request().method() === 'PUT', + ) +} + +/** A work's plate: its poster, and the veil over it, which is a button. */ +function plate(page: Page) { + const header = page.locator('main article > header') + return { + poster: header.getByRole('img', { name: /^(poster for|affiche de) /i }), + veil: header.getByRole('button', { name: /for adults|pour adultes/i }), + } +} + +test.describe('the posters of works for adults', () => { + test.skip(!USERNAME || !PASSWORD, 'needs AMS_E2E_USER and AMS_E2E_PASSWORD') + + test('are shown once the account asks on its own page, in every browser, and veiled again from the menu', async ({ + page, + browser, + isMobile, + }) => { + test.skip(isMobile, 'the choice is the account’s: the desk alone turns it') + test.slow() + await signIn(page) + + const listed = await page.request.get('/api/v1/items?includeAdult=true&limit=100') + const { items } = (await listed.json()) as { items: Work[] } + const work = items.find((item) => forAdults(item) && item.images?.some((image) => image.coverType === 'poster')) + test.skip(!work, 'the catalogue holds no work for adults with a poster') + + const before = await kept(page) + try { + // Whatever the account chose before is set aside, and this browser has + // chosen nothing: the poster is veiled. + expect((await page.request.delete(PREFERENCE)).ok()).toBe(true) + await page.goto(`/work/${work!.id}`) + await expect(plate(page).poster).toBeVisible() + await expect(plate(page).veil).toBeVisible() + + // Asked for on the account's page, by the switch its words name. + await page.goto('/account') + const choice = page.getByRole('switch', { + name: /^(show the posters of works for adults|montrer les affiches des œuvres pour adultes)$/i, + }) + await expect(choice).toHaveAttribute('aria-checked', 'false') + await page.waitForLoadState('networkidle') + await scan(page, 'the account page') + const on = sent(page) + await choice.click() + await expect(choice).toHaveAttribute('aria-checked', 'true') + expect((await on).ok()).toBe(true) + expect(await kept(page)).toBe(true) + + await page.goto(`/work/${work!.id}`) + await expect(plate(page).poster).toBeVisible() + await expect(plate(page).veil).toHaveCount(0) + + // Another browser, which kept nothing of it: the account brings it. + const elsewhere = await browser.newContext() + try { + const other = await elsewhere.newPage() + await signIn(other) + await other.goto(`/work/${work!.id}`) + await expect(plate(other).poster).toBeVisible() + await expect(plate(other).veil).toHaveCount(0) + } finally { + await elsewhere.close() + } + + // The bar's reading menu holds the same choice: turned off there, the + // poster is veiled at once, and the account keeps that too. + await page.getByRole('button', { name: /^(language and reading|langue et lecture)/i }).click() + const menu = page.getByRole('switch', { name: /^(show adult posters|montrer les affiches pour adultes)$/i }) + await expect(menu).toHaveAttribute('aria-checked', 'true') + const off = sent(page) + await menu.click() + await expect(menu).toHaveAttribute('aria-checked', 'false') + await expect(plate(page).veil).toBeVisible() + expect((await off).ok()).toBe(true) + expect(await kept(page)).toBe(false) + } finally { + // The account's choice as it was found. + if (before === undefined) await page.request.delete(PREFERENCE) + else await page.request.put(PREFERENCE, { data: { value: before } }) + } + }) +}) diff --git a/frontend/e2e/media.serial.spec.ts b/frontend/e2e/media.serial.spec.ts index 519a81d..979e98c 100644 --- a/frontend/e2e/media.serial.spec.ts +++ b/frontend/e2e/media.serial.spec.ts @@ -1,7 +1,7 @@ -import { deflateSync } from 'node:zlib' - import { expect, test, type Page } from '@playwright/test' +import { png } from './support/png' + /** * The media kept: a picture put on a work by hand is stored and served from * this server, a work's pictures are read from the copies kept, and a copy @@ -23,39 +23,6 @@ async function signIn(page: Page) { await page.waitForURL('**/admin') } -/** A red 600×900 PNG, made here: nothing to fetch. */ -function png(width: number, height: number): Buffer { - const crc = (buf: Buffer) => { - let c = ~0 - for (const byte of buf) { - c ^= byte - for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1 - } - return (~c >>> 0) - } - const chunk = (type: string, data: Buffer) => { - const length = Buffer.alloc(4) - length.writeUInt32BE(data.length) - const body = Buffer.concat([Buffer.from(type, 'ascii'), data]) - const sum = Buffer.alloc(4) - sum.writeUInt32BE(crc(body)) - return Buffer.concat([length, body, sum]) - } - const header = Buffer.alloc(13) - header.writeUInt32BE(width, 0) - header.writeUInt32BE(height, 4) - header[8] = 8 - header[9] = 2 - const row = Buffer.concat([Buffer.from([0]), Buffer.alloc(width * 3, 0).map((_, i) => (i % 3 === 0 ? 180 : 40))]) - const raw = Buffer.concat(Array.from({ length: height }, () => row)) - return Buffer.concat([ - Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), - chunk('IHDR', header), - chunk('IDAT', deflateSync(raw)), - chunk('IEND', Buffer.alloc(0)), - ]) -} - test.describe('the media kept', () => { test.skip(!USERNAME || !PASSWORD, 'needs AMS_E2E_USER and AMS_E2E_PASSWORD') diff --git a/frontend/e2e/support/png.ts b/frontend/e2e/support/png.ts new file mode 100644 index 0000000..f7f3704 --- /dev/null +++ b/frontend/e2e/support/png.ts @@ -0,0 +1,42 @@ +/** + * A picture made here, for a spec that uploads one: nothing to fetch. + * + * The store files a picture by its content, so a spec gives its picture a + * size of the run's own — a run that failed before taking its picture away + * would otherwise leave the next one sharing the file, and its copies. + */ + +import { deflateSync } from 'node:zlib' + +/** A red PNG of the size asked. */ +export function png(width: number, height: number): Buffer { + const crc = (buf: Buffer) => { + let c = ~0 + for (const byte of buf) { + c ^= byte + for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1 + } + return (~c >>> 0) + } + const chunk = (type: string, data: Buffer) => { + const length = Buffer.alloc(4) + length.writeUInt32BE(data.length) + const body = Buffer.concat([Buffer.from(type, 'ascii'), data]) + const sum = Buffer.alloc(4) + sum.writeUInt32BE(crc(body)) + return Buffer.concat([length, body, sum]) + } + const header = Buffer.alloc(13) + header.writeUInt32BE(width, 0) + header.writeUInt32BE(height, 4) + header[8] = 8 + header[9] = 2 + const row = Buffer.concat([Buffer.from([0]), Buffer.alloc(width * 3, 0).map((_, i) => (i % 3 === 0 ? 180 : 40))]) + const raw = Buffer.concat(Array.from({ length: height }, () => row)) + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk('IHDR', header), + chunk('IDAT', deflateSync(raw)), + chunk('IEND', Buffer.alloc(0)), + ]) +} diff --git a/frontend/e2e/webp.serial.spec.ts b/frontend/e2e/webp.serial.spec.ts new file mode 100644 index 0000000..97b917a --- /dev/null +++ b/frontend/e2e/webp.serial.spec.ts @@ -0,0 +1,207 @@ +import { expect, test, type APIRequestContext, type Page } from '@playwright/test' + +import { scan } from './support/axe' +import { png } from './support/png' +import { escaped, pattern } from './support/regex' + +/** + * The copies of the pictures kept that the pages are given: none until the + * WebP copies are turned on, the pictures themselves meanwhile; on, the + * pages ask for them; turned off while some are kept, the Media page asks + * what becomes of them, and keeps them or deletes them as it is told. + * + * Needs a store, as the media spec does: without one the test says so and + * skips. Server-wide, because the setting is the whole server's and a + * deletion takes every WebP copy kept. It puts the setting back as it found + * it, and takes its picture away. + */ + +const USERNAME = process.env.AMS_E2E_USER +const PASSWORD = process.env.AMS_E2E_PASSWORD + +test.describe.configure({ mode: 'serial' }) + +async function signIn(page: Page) { + await page.goto('/login') + await page.getByLabel(/username|identifiant/i).fill(USERNAME!) + await page.getByLabel(/password|mot de passe/i).fill(PASSWORD!) + await page.getByRole('button', { name: /sign in|se connecter/i }).click() + await page.waitForURL('**/admin') +} + +interface Status { + backend: string + webp: boolean + purging: boolean + variants: { webp: number } +} + +async function status(request: APIRequestContext): Promise { + const answer = await request.get('/api/v1/media/status') + expect(answer.ok()).toBe(true) + return (await answer.json()) as Status +} + +/** The server's own value of a setting, or nothing where it is not set. */ +async function settingNow(request: APIRequestContext, key: string): Promise { + const answer = await request.get('/api/v1/settings/server/-') + expect(answer.ok()).toBe(true) + return ((await answer.json()) as { key: string; value: string }[]).find((setting) => setting.key === key)?.value +} + +/** A setting given a value, or — `null` — not set at all, as it starts. */ +async function setting(request: APIRequestContext, key: string, value: string | null) { + const answer = await request.put('/api/v1/settings/server/-', { data: { key, value } }) + expect(answer.ok(), `${key}=${value}`).toBe(true) +} + +/** A film whose poster nobody chose or added by hand: one added now leads it. */ +async function plainFilm(request: APIRequestContext): Promise<{ id: string; title: string } | undefined> { + const list = (await (await request.get('/api/v1/items?kind=movie&limit=40')).json()) as { items: { id: string }[] } + for (const { id } of list.items) { + const work = (await (await request.get(`/api/v1/items/${id}`)).json()) as { + title: string + primaryImages?: { poster?: string } + images?: { coverType: string; isManual: boolean }[] + } + if (work.primaryImages?.poster) continue + if ((work.images ?? []).some((image) => image.coverType === 'poster' && image.isManual)) continue + return { id, title: work.title } + } + return undefined +} + +test.describe('the copies the pages are given', () => { + test.skip(!USERNAME || !PASSWORD, 'needs AMS_E2E_USER and AMS_E2E_PASSWORD') + + test('are the pictures themselves until the WebP copies are on, and are kept or deleted when they go off', async ({ + page, + browser, + }) => { + test.slow() + await signIn(page) + test.skip((await status(page.request)).backend === 'off', 'no media store is configured on this server') + + const film = await plainFilm(page.request) + test.skip(!film, 'no film whose poster nobody chose') + const { id } = film! + + const was = await settingNow(page.request, 'media.webp') + let assetId: string | undefined + try { + await setting(page.request, 'media.webp', 'false') + + // A poster of this run's own, put on the film by hand: it leads it. + const uploaded = await page.request.post(`/api/v1/items/${id}/media`, { + multipart: { + file: { name: 'poster.png', mimeType: 'image/png', buffer: png(600, 900 + (Date.now() % 89)) }, + coverType: 'poster', + }, + }) + expect(uploaded.status()).toBe(201) + const sent = (await uploaded.json()) as { assetId: string; url: string } + assetId = sent.assetId + const { url } = sent + expect(url).toMatch(/^\/media\/[0-9a-f]{64}\.png$/) + const stem = url.replace(/\.png$/, '') + const copy = pattern(`${escaped(stem)}-\\d+\\.webp$`) + + // Off: a visitor's page draws the picture itself, at no other width. + const before = await browser.newContext() + try { + const them = await before.newPage() + const asked: string[] = [] + them.on('request', (request) => { + if (copy.test(new URL(request.url()).pathname)) asked.push(request.url()) + }) + await them.goto(`/work/${id}`) + const poster = them.locator(`img[src^="${stem}"]`).first() + await expect(poster).toHaveAttribute('src', url) + expect(await poster.getAttribute('srcset')).toBeNull() + await expect.poll(() => poster.evaluate((img) => (img as HTMLImageElement).naturalWidth)).toBe(600) + expect(asked, 'no WebP copy is asked for while they are off').toEqual([]) + } finally { + await before.close() + } + + // On: a page opened now asks for the copies, from its first picture — + // never for the picture itself — and each is made as it is asked for. + await setting(page.request, 'media.webp', 'true') + const after = await browser.newContext() + try { + const them = await after.newPage() + const whole: string[] = [] + them.on('request', (request) => { + if (new URL(request.url()).pathname === url) whole.push(request.url()) + }) + const made = them.waitForResponse((response) => copy.test(new URL(response.url()).pathname)) + await them.goto(`/work/${id}`) + const answer = await made + expect(answer.status()).toBe(200) + expect(answer.headers()['content-type']).toBe('image/webp') + const poster = them.locator(`img[src^="${stem}"]`).first() + await expect(poster).toHaveAttribute('src', `${stem}-480.webp`) + await expect(poster).toHaveAttribute('srcset', pattern(`${escaped(stem)}-480\\.webp 480w`)) + expect(whole, 'the picture itself is not asked for').toEqual([]) + } finally { + await after.close() + } + await expect.poll(async () => (await status(page.request)).variants.webp).toBeGreaterThan(0) + + // Off again from the Media page, with copies kept: it asks first. + await page.goto('/admin/media') + const webp = page.getByRole('switch', { name: /^(webp copies for the pages|copies webp pour les pages)$/i }) + const dialog = page.getByRole('dialog', { name: /turn off the webp copies|désactiver les copies webp/i }) + await expect(webp).toBeChecked() + await webp.click() + await expect(dialog).toBeVisible() + await expect(dialog).toContainText(/webp cop(y|ies) (is|are) kept|copies? webp (est|sont) stockées?/i) + await scan(page, 'the Media page, asking what becomes of the WebP copies') + + // Cancelled: nothing changes. + await dialog.getByRole('button', { name: /^(cancel|annuler)$/i }).click() + await expect(dialog).toBeHidden() + await expect(webp).toBeChecked() + expect((await status(page.request)).webp).toBe(true) + + // Turned off, the copies kept. + const kept = (await status(page.request)).variants.webp + await webp.click() + await dialog.getByRole('button', { name: /turn off and keep|désactiver et (la|les) garder/i }).click() + await expect(dialog).toBeHidden() + await expect(webp).not.toBeChecked() + const off = await status(page.request) + expect(off.webp).toBe(false) + expect(off.variants.webp).toBe(kept) + + // On again — no question asked — then off, the copies deleted: every + // one of them, in the background, the picture itself kept. + await webp.click() + await expect(webp).toBeChecked() + expect((await status(page.request)).webp).toBe(true) + await webp.click() + await expect(dialog).toBeVisible() + await dialog.getByRole('button', { name: /turn off and delete|désactiver et (la|les) supprimer/i }).click() + await expect(dialog).toBeHidden() + await expect(page.getByRole('status').filter({ hasText: /^(deleting|suppression de) .*webp/i })).toBeVisible() + await expect(webp).not.toBeChecked() + await expect + .poll( + async () => { + const now = await status(page.request) + return !now.purging && now.variants.webp === 0 + }, + { timeout: 30_000 }, + ) + .toBe(true) + expect((await page.request.get(url)).status()).toBe(200) + // Asked for anyway, a copy no longer kept is the picture, for now. + const gone = await page.request.get(`${stem}-480.webp`, { maxRedirects: 0 }) + expect(gone.status()).toBe(302) + expect(gone.headers()['location']).toMatch(pattern(`${escaped(url)}$`)) + } finally { + if (assetId) await page.request.delete(`/api/v1/items/${id}/media/${assetId}`) + await setting(page.request, 'media.webp', was ?? null) + } + }) +}) diff --git a/frontend/openapi.json b/frontend/openapi.json index cb3d9df..e6019c8 100644 --- a/frontend/openapi.json +++ b/frontend/openapi.json @@ -7196,6 +7196,50 @@ } } }, + "/api/v1/media/variants/{format}": { + "delete": { + "tags": [ + "Catalogue" + ], + "summary": "Delete every copy of a format: the WebP copies once `media.webp` is off,\nthe AVIF copies once `media.avif` is — the space they took, back.", + "description": "Answered at once, with what there was, and done in the background: each\ncopy's file taken from the store, then its row; the status says\n`purging` until it is over. The pictures themselves are not touched,\nnor the squares Plex is given. Turned on again meanwhile, the setting\nstops it where it is.", + "operationId": "media_delete_variants", + "parameters": [ + { + "name": "format", + "in": "path", + "description": "The format whose copies are deleted", + "required": true, + "schema": { + "$ref": "#/components/schemas/MediaVariantFormat" + } + } + ], + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VariantsDeleted" + } + } + } + }, + "400": { + "description": "Not a format whose copies are deleted: `webp` or `avif`" + }, + "403": { + "description": "The caller is not an administrator" + }, + "409": { + "description": "The format's copies are still made — its setting is on — or the AVIF copies are being made, or copies are being deleted already" + }, + "503": { + "description": "No media store is configured" + } + } + } + }, "/api/v1/media/works": { "get": { "tags": [ @@ -11093,7 +11137,8 @@ "required": [ "site", "registration", - "passwordLogin" + "passwordLogin", + "webp" ], "properties": { "site": { @@ -11117,6 +11162,10 @@ "type": "null" } ] + }, + "webp": { + "type": "boolean", + "description": "Whether the pages are given WebP copies of the pictures this server\nkeeps (`media.webp`), rather than the pictures themselves: said here,\nwhere every page reads it before it draws one." } } }, @@ -17376,10 +17425,12 @@ "counts", "variants", "unprepared", + "webp", "avif", "fetching", "sweeping", - "preparing" + "preparing", + "purging" ], "properties": { "backend": { @@ -17415,6 +17466,10 @@ "format": "int64", "description": "The pictures kept that this version has not prepared — no\nplaceholder, no first copy — which the task `media.variants` does." }, + "webp": { + "type": "boolean", + "description": "Whether WebP copies are made and given to the pages (`media.webp`);\noff, the pages are given the pictures themselves." + }, "avif": { "type": "boolean", "description": "Whether the night's task makes AVIF copies (`media.avif`)." @@ -17429,9 +17484,21 @@ "preparing": { "type": "boolean", "description": "Whether the pictures are being prepared, or their AVIF copies made." + }, + "purging": { + "type": "boolean", + "description": "Whether every copy of a format is being deleted." } } }, + "MediaVariantFormat": { + "type": "string", + "description": "A format the copies of the pictures are written as, whose copies can be\ndeleted once they are no longer made.", + "enum": [ + "webp", + "avif" + ] + }, "MediumStatus": { "type": "string", "description": "What is kept of one address a work points at: where its asset stands,\nor that it has none.", @@ -23593,6 +23660,30 @@ } } }, + "VariantsDeleted": { + "type": "object", + "description": "What there was of a format's copies as their deletion began.", + "required": [ + "format", + "copies", + "bytes" + ], + "properties": { + "format": { + "$ref": "#/components/schemas/MediaVariantFormat" + }, + "copies": { + "type": "integer", + "format": "int64", + "description": "How many copies there were." + }, + "bytes": { + "type": "integer", + "format": "int64", + "description": "What they weighed, in bytes." + } + } + }, "WantedWork": { "type": "object", "description": "One work wanted, by its id at a source.", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c5532da..ff7d0a2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import { BrowserRouter, Navigate, Route, Routes, useLocation, type NavigateOptio import { PageBoundary } from './components/PageBoundary' import { PublicShell } from './components/PublicShell' import { Spinner } from './components/ui' +import { useAdultPostersFromAccount } from './lib/adultPosters' import { ApiError, api } from './lib/api' import { useAuthOptions } from './lib/hooks' import { I18nProvider } from './lib/i18n' @@ -128,73 +129,87 @@ function Router() { options.data?.site === 'private' && me.error instanceof ApiError && me.error.isUnauthorized return ( - }> - - : locked ? : }> - } /> - } /> - } /> - } /> - } /> - {/* One page for a person, by their record or by TMDB's id, as the - pages were addressed before there were records. */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - {/* Said, not redirected: see `NotFound`. */} - } /> - - - } /> - } /> - {Styleguide ? } /> : null} - - {/* The shell is the guard: it asks who is signed in once, and sends - anyone who may not write to the door before a screen mounts. */} - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - + <> + + }> + + : locked ? : }> + } /> + } /> + } /> + } /> + } /> + {/* One page for a person, by their record or by TMDB's id, as the + pages were addressed before there were records. */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + {/* Said, not redirected: see `NotFound`. */} + } /> + + + } /> + } /> + {Styleguide ? } /> : null} + + {/* The shell is the guard: it asks who is signed in once, and sends + anyone who may not write to the door before a screen mounts. */} + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + ) } +/** + * The choice of the account signed in about the posters of works for adults, + * put where every card reads it, on either side of the site. A component of + * its own, drawing nothing: the router, holding the hook, would draw every + * page again each time any preference of the account changed. + */ +function AdultPostersFromAccount() { + useAdultPostersFromAccount() + return null +} + /** To the sign-in page, and back here once through it. */ function ToSignIn() { const location = useLocation() diff --git a/frontend/src/components/PublicShell.tsx b/frontend/src/components/PublicShell.tsx index da36ccb..1b28faa 100644 --- a/frontend/src/components/PublicShell.tsx +++ b/frontend/src/components/PublicShell.tsx @@ -18,6 +18,7 @@ import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-quer import { createContext, Fragment, use, useEffect, useId, useLayoutEffect, useState } from 'react' import { Link, Outlet, useLocation, useNavigate, useSearchParams } from 'react-router' +import { useAdultPosters } from '../lib/adultPosters' import { api, query } from '../lib/api' import { useSettled } from '../lib/debounce' import { feeds, webcal } from '../lib/feeds' @@ -29,13 +30,7 @@ import { LanguageToggle } from './LanguageToggle' import { AdminLink, BrowseTab, Tab, TabBar } from './nav' import { lookup, providerName } from '../lib/labels' import { poster } from '../lib/media' -import { - TEXT_LANGUAGES, - setTextLanguage, - showAdultPosters, - useAdultPostersShown, - useTextLanguage, -} from '../lib/prefs' +import { TEXT_LANGUAGES, setTextLanguage, useTextLanguage } from '../lib/prefs' import type { ItemPage, Me, Sources } from '../lib/types' import { CommandPalette, paletteShortcut } from './CommandPalette' import { KeysHelp, useShortcuts } from './KeysHelp' @@ -240,7 +235,9 @@ function Wordmark() { /** * How the catalogue is read here: the interface's language, the language of - * its texts, and whether the posters of works for adults are shown. + * its texts, and whether the posters of works for adults are shown — the one + * of the three kept with the account of whoever is signed in, as their own + * page has it too (see `adultPosters.ts`). * * Two languages, because they are two questions. The interface speaks the * few languages it is written in; the texts — titles, synopses, taglines — @@ -255,7 +252,7 @@ function ReaderMenu() { const { t, lang, locale } = useI18n() const queryClient = useQueryClient() const texts = useTextLanguage() - const adult = useAdultPostersShown() + const [adult, showAdult] = useAdultPosters() const id = useId() // Each language named in the interface's, and sorted as their names are, @@ -333,7 +330,7 @@ function ReaderMenu() {
{t.reader.adult} - +

{t.reader.adultHint}

diff --git a/frontend/src/components/media.tsx b/frontend/src/components/media.tsx index bd0493d..e7af0de 100644 --- a/frontend/src/components/media.tsx +++ b/frontend/src/components/media.tsx @@ -10,7 +10,7 @@ import { cn } from '../lib/cn' import * as fmt from '../lib/format' import { workQuery } from '../lib/hooks' import { useI18n } from '../lib/i18n' -import { hasSources, headlineRating, lookOf, poster, sized, type ImageRole, type Picture } from '../lib/media' +import { hasSources, headlineRating, lookOf, poster, sized, useWebpOffered, type ImageRole, type Picture } from '../lib/media' import { forAdults, unveil, useAdultPostersShown, useUnveiled } from '../lib/prefs' import { hasAlpha, thumbHashToDataURL } from '../lib/thumbhash' import { plainClick, useTransitionNavigate } from '../lib/transitions' @@ -172,12 +172,15 @@ export function placeholderStyle(picture: Picture | undefined, role: ImageRole): /** * An image, fetched at the size it is drawn at. * - * A copy this server keeps comes at the widths of its role, as WebP — under - * a `` offering AVIF first where the night made those — with its - * proportions given to the box and, until it arrives, its colour and its - * sketch under it (see `placeholderStyle`). What the server says of a - * picture comes with the work it is in, and is found by its address; a - * caller that holds it may pass it as `picture`. + * A picture this server keeps comes at the widths of its role, as WebP + * while the server makes those copies, and whole otherwise — either way + * under a `` offering AVIF first where the night made those — with + * its proportions given to the box and, until it arrives, its colour and + * its sketch under it (see `placeholderStyle`). Whether the WebP copies are + * on is the server's to say, and every picture is drawn again when it + * changes (see `useWebpOffered`). What the server says of a picture comes + * with the work it is in, and is found by its address; a caller that holds + * it may pass it as `picture`. * * Lazy unless told otherwise, because most of these are below the fold. A hero * backdrop is the exception and says so with `eager` — with `fetchPriority` @@ -214,7 +217,8 @@ export function Artwork({ picture?: Picture } & Omit, 'src' | 'srcSet' | 'sizes' | 'alt'>) { const look = picture ?? lookOf(url) - const image = sized(url, role, look) + const webp = useWebpOffered() + const image = sized(url, role, look, webp) // Each remembered by address, so a card that moves on to another work // starts afresh rather than wearing the last one's failure, or its load. const [retried, setRetried] = useState() @@ -225,7 +229,11 @@ export function Artwork({ return <>{fallback} } - const original = retried === url || image.src === image.original + // The original alone: once a copy has failed, and where nothing else is + // offered — an address with no copies, or a picture kept whose WebP + // copies are off and none of whose AVIF copies reaches as wide. + const original = retried === url || (image.src === image.original && !image.avif) + const src = original ? image.original : image.src const under = loaded === url ? undefined : placeholderStyle(look, role) const img = ( {alt} { diff --git a/frontend/src/lib/adultPosters.test.ts b/frontend/src/lib/adultPosters.test.ts new file mode 100644 index 0000000..448f8a4 --- /dev/null +++ b/frontend/src/lib/adultPosters.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' + +import { ADULT_POSTERS_PREFERENCE, readChoice } from './adultPosters' + +describe('the choice of the posters for adults, kept with the account', () => { + it('is kept under a name the server takes', () => { + // `clean_preference_key`, in src/api/native/account.rs: words of + // letters, digits and dashes joined by dots, the first starting with a + // small letter, 64 characters at most. A name it refuses would be a + // choice never kept, and sent again on every page. + expect(ADULT_POSTERS_PREFERENCE.length).toBeLessThanOrEqual(64) + expect(ADULT_POSTERS_PREFERENCE).toMatch(/^[a-z][A-Za-z\d-]*(?:\.[A-Za-z\d-]+)*$/) + }) + + it('reads as shown or veiled, and anything else as no choice at all', () => { + expect(readChoice(true)).toBe(true) + expect(readChoice(false)).toBe(false) + for (const raw of ['shown', 'true', 1, 0, null, undefined, {}, []]) { + expect(readChoice(raw), JSON.stringify(raw)).toBeUndefined() + } + }) +}) diff --git a/frontend/src/lib/adultPosters.ts b/frontend/src/lib/adultPosters.ts new file mode 100644 index 0000000..15b1351 --- /dev/null +++ b/frontend/src/lib/adultPosters.ts @@ -0,0 +1,86 @@ +/** + * Whether the posters of works for adults are shown as they are, chosen by + * whoever is signed in — on their own page (`/account`) or in the bar's + * reading menu — and kept with their account, so the choice follows them + * from one browser to the next. A visitor's is kept in this browser + * (`prefs.ts`), and so is the choice of an account that never made one. + * + * Every card reads the store in `prefs.ts`, not the account: the account's + * choice is put there once, from the top of the page + * (`useAdultPostersFromAccount`), so a card costs no more than it did. + */ + +import { hashKey, useQueryClient } from '@tanstack/react-query' +import { useCallback, useEffect, useReducer } from 'react' + +import { useMe } from './hooks' +import { usePreference } from './preferences' +import { adoptAdultPosters, showAdultPosters, useAdultPostersShown } from './prefs' + +/** The account's preference, under a name the server takes (`clean_preference_key`). */ +export const ADULT_POSTERS_PREFERENCE = 'reader.adultPosters' + +/** Who is signed in, as the cache holds the answer: under the key every page asks it by (`useMe`). */ +const ME = hashKey(['me']) + +/** What the account keeps, read as a choice: shown or veiled. Anything else is none. */ +export function readChoice(raw: unknown): boolean | undefined { + return typeof raw === 'boolean' ? raw : undefined +} + +/** + * Put the choice of the account signed in where every card reads it — and + * take it away when nobody is, or the account never chose, so that this + * browser's counts. Mounted once, above everything that shows a poster. + */ +export function useAdultPostersFromAccount() { + const queryClient = useQueryClient() + const account = useMe().data?.user?.id + const [kept] = usePreference(ADULT_POSTERS_PREFERENCE, undefined, readChoice) + const chosen = account ? kept : undefined + const [, reread] = useReducer((count: number) => count + 1, 0) + + useEffect(() => { + adoptAdultPosters(chosen) + }, [chosen]) + + // Signing out, or changing one's password, empties the whole cache + // (`SignOut`, `ChangePassword`) without a word to whoever reads it: the + // account's choice goes at once, before the page is drawn again, and the + // reads above are made afresh, so that whoever signs in next is the one + // whose choice is put here — not the account that left. + useEffect( + () => + queryClient.getQueryCache().subscribe((event) => { + if (event.type !== 'removed' || event.query.queryHash !== ME) return + adoptAdultPosters(undefined) + reread() + }), + [queryClient], + ) +} + +/** + * Whether the posters of works for adults are shown here, and the way to + * choose: for whoever is signed in, kept with their account — taking at + * once, and sent a moment after — and for a visitor, in this browser. + */ +export function useAdultPosters(): [boolean, (shown: boolean) => void] { + const shown = useAdultPostersShown() + const account = useMe().data?.user?.id + const [, keep] = usePreference(ADULT_POSTERS_PREFERENCE, undefined, readChoice) + + const choose = useCallback( + (next: boolean) => { + if (!account) { + showAdultPosters(next) + return + } + adoptAdultPosters(next) + keep(next) + }, + [account, keep], + ) + + return [shown, choose] +} diff --git a/frontend/src/lib/api.gen.ts b/frontend/src/lib/api.gen.ts index 44f924f..d0940e8 100644 --- a/frontend/src/lib/api.gen.ts +++ b/frontend/src/lib/api.gen.ts @@ -2958,6 +2958,31 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/media/variants/{format}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Delete every copy of a format: the WebP copies once `media.webp` is off, + * the AVIF copies once `media.avif` is — the space they took, back. + * @description Answered at once, with what there was, and done in the background: each + * copy's file taken from the store, then its row; the status says + * `purging` until it is over. The pictures themselves are not touched, + * nor the squares Plex is given. Turned on again meanwhile, the setting + * stops it where it is. + */ + delete: operations["media_delete_variants"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/media/works": { parameters: { query?: never; @@ -4494,6 +4519,12 @@ export interface components { /** @description Whether the password form is offered to everyone. */ passwordLogin: boolean; oidc?: components["schemas"]["OidcButton"] | null; + /** + * @description Whether the pages are given WebP copies of the pictures this server + * keeps (`media.webp`), rather than the pictures themselves: said here, + * where every page reads it before it draws one. + */ + webp: boolean; }; Authority: { subject: string; @@ -6906,6 +6937,11 @@ export interface components { * placeholder, no first copy — which the task `media.variants` does. */ unprepared: number; + /** + * @description Whether WebP copies are made and given to the pages (`media.webp`); + * off, the pages are given the pictures themselves. + */ + webp: boolean; /** @description Whether the night's task makes AVIF copies (`media.avif`). */ avif: boolean; /** @description Whether a fetch or a sweep is under way. */ @@ -6913,7 +6949,15 @@ export interface components { sweeping: boolean; /** @description Whether the pictures are being prepared, or their AVIF copies made. */ preparing: boolean; + /** @description Whether every copy of a format is being deleted. */ + purging: boolean; }; + /** + * @description A format the copies of the pictures are written as, whose copies can be + * deleted once they are no longer made. + * @enum {string} + */ + MediaVariantFormat: "webp" | "avif"; /** * @description What is kept of one address a work points at: where its asset stands, * or that it has none. @@ -9191,6 +9235,20 @@ export interface components { /** Format: int64 */ jpegBytes: number; }; + /** @description What there was of a format's copies as their deletion began. */ + VariantsDeleted: { + format: components["schemas"]["MediaVariantFormat"]; + /** + * Format: int64 + * @description How many copies there were. + */ + copies: number; + /** + * Format: int64 + * @description What they weighed, in bytes. + */ + bytes: number; + }; /** @description One work wanted, by its id at a source. */ WantedWork: { source: components["schemas"]["IdSource"]; @@ -16146,6 +16204,56 @@ export interface operations { }; }; }; + media_delete_variants: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The format whose copies are deleted */ + format: components["schemas"]["MediaVariantFormat"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["VariantsDeleted"]; + }; + }; + /** @description Not a format whose copies are deleted: `webp` or `avif` */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description The caller is not an administrator */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description The format's copies are still made — its setting is on — or the AVIF copies are being made, or copies are being deleted already */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description No media store is configured */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; media_by_work: { parameters: { query?: { diff --git a/frontend/src/lib/hooks.ts b/frontend/src/lib/hooks.ts index c847a7b..dcd394a 100644 --- a/frontend/src/lib/hooks.ts +++ b/frontend/src/lib/hooks.ts @@ -12,6 +12,7 @@ import { NavigationType, useLocation, useNavigationType } from 'react-router' import { api, query, segment } from './api' import { useI18n } from './i18n' +import { offerWebp } from './media' import type { AuthOptions, Collections, CuratedLists, MediaItem, Me, Orders, SearchHits } from './types' /** @@ -116,11 +117,20 @@ export function useMe() { /** * Whether the site is open, and whether one may sign up: readable before * anybody signs in, which is when it is needed. + * + * It says too whether the pages are given WebP copies of the pictures kept, + * which every picture is told before the answer is (see `offerWebp`): the + * public pages wait for it, and ask for the right files from their first + * picture. */ export function useAuthOptions() { return useQuery({ queryKey: ['auth', 'options'], - queryFn: () => api.get('/auth/options'), + queryFn: async () => { + const options = await api.get('/auth/options') + offerWebp(options.webp) + return options + }, retry: false, staleTime: 5 * 60_000, }) diff --git a/frontend/src/lib/lang/en.ts b/frontend/src/lib/lang/en.ts index faf00f7..04abe63 100644 --- a/frontend/src/lib/lang/en.ts +++ b/frontend/src/lib/lang/en.ts @@ -843,6 +843,9 @@ export const en = { editor: 'Also corrects the catalogue: fields, imports, refreshes, lists.', admin: 'Also settles everything else: accounts, access, keys, settings.', } as Record, + reading: 'Reading', + adultPosters: 'Show the posters of works for adults', + adultPostersHint: 'Off, they stay blurred until pressed. The choice follows your account from one browser to the next.', password: 'Password', passwordNone: 'This account signs in through its identity provider and has no password here.', sessions: 'Sessions', @@ -1704,9 +1707,30 @@ export const en = { peopleHint: 'One per person, and a work names dozens: the largest share of what is kept.', audio: 'The themes', audioHint: 'A Fan-Kai’s theme music, a few megabytes each.', + webp: 'WebP copies for the pages', + webpHint: + 'Each picture copied at the widths a page draws it, as it is stored or as a page first asks: a card’s copy weighs a tenth of the picture. Off, the pages are given the pictures themselves, whole.', avif: 'AVIF copies at night', avifHint: - 'An AVIF copy of every poster and background, made each night at 03:00 UTC: a third lighter than the WebP the pages are given otherwise, and a second or two of a processor each to make.', + 'An AVIF copy of every poster and background, made each night at 03:00 UTC and given to the pages wherever it exists: a third lighter than a WebP copy of the same width, and a second or two of a processor each to make.', + turnOff: { + title: (format: string) => `Turn off the ${format} copies`, + webp: (n: number, count: string, size: string) => + plural(n, { + one: `One WebP copy is kept, weighing ${size}. Delete it too? Kept, it is used again should the copies be turned back on; until then the pages are given the pictures themselves.`, + other: `${count} WebP copies are kept, weighing ${size}. Delete them too? Kept, they are used again should the copies be turned back on; until then the pages are given the pictures themselves.`, + }), + avif: (n: number, count: string, size: string) => + plural(n, { + one: `One AVIF copy is kept, weighing ${size}. Delete it too? Kept, it is still given to the pages, and no other is made.`, + other: `${count} AVIF copies are kept, weighing ${size}. Delete them too? Kept, they are still given to the pages, and no more are made.`, + }), + keep: (n: number) => plural(n, { one: 'Turn off and keep it', other: 'Turn off and keep them' }), + delete: (n: number) => plural(n, { one: 'Turn off and delete it', other: 'Turn off and delete them' }), + kept: (why: string) => `Turned off, and the copies are kept: ${why}`, + deleting: (n: number, count: string, format: string, size: string) => + plural(n, { one: `Deleting the ${format} copy (${size})…`, other: `Deleting ${count} ${format} copies (${size})…` }), + }, copies: 'Copies for the pages', copiesLine: (webp: string, avif: string, squares: string) => `WebP: ${webp} · AVIF: ${avif} · squares for Plex: ${squares}`, copiesOf: (n: string, size: string) => `${n} (${size})`, @@ -1715,9 +1739,15 @@ export const en = { one: 'One picture kept has no placeholder and no first copy yet. Prepare it now, or a page has its copies made as it asks for them.', other: `${n} pictures kept have no placeholder and no first copy yet. Prepare them now, or a page has its copies made as it asks for them.`, }), + unpreparedWhole: (n: number) => + plural(n, { + one: 'One picture kept has no placeholder yet. Prepare it now, or a page draws it with none until it arrives.', + other: `${n} pictures kept have no placeholder yet. Prepare them now, or a page draws them with none until they arrive.`, + }), allPrepared: 'Every picture kept is prepared.', prepareNow: 'Prepare the pictures', preparing: 'Preparing the pictures now', + purging: 'Deleting copies now', serve: 'How a bucket’s media are served', serveProxy: 'Through this server', serveRedirect: 'Sent to the bucket’s own address', @@ -3398,8 +3428,8 @@ export const en = { 'export.nfo': 'A .nfo document for every work, for a library Plex, Jellyfin or Kodi reads.', 'media.store': 'Every picture and theme the catalogue points at that is not kept yet, fetched now and kept, a few at a time. It can be stopped; the rest are fetched as they come up.', 'media.sweep': 'The media nobody points at any more — a work removed, a picture a provider dropped, an upload taken off — forgotten and deleted.', - 'media.variants': 'Every picture kept before this version — or before there were copies at all — prepared as one kept now is: the colour and the sketch a page draws while it loads worked out, and the copy its kind is first asked for made, a poster’s 480 pixels wide and a background’s 1280. A few at a time; it can be stopped, and the next run takes up where it stopped. Never run on its own: until it is, a page is given the copies it asks for, made as it asks.', - 'media.avif': 'An AVIF copy of every poster, 480 and 960 pixels wide, and of every background, 1280 — a third lighter than the WebP the interface is given otherwise, and a second or two of a processor each to make. At night, once “AVIF copies at night” is on, on the Media page. It can be stopped.', + 'media.variants': 'Every picture kept before this version — or before there were copies at all — prepared as one kept now is: the colour and the sketch a page draws while it loads worked out, and, while the WebP copies are on, the copy its kind is first asked for made, a poster’s 480 pixels wide and a background’s 1280. A few at a time; it can be stopped, and the next run takes up where it stopped. Never run on its own: until it is, a page draws those pictures with no placeholder.', + 'media.avif': 'An AVIF copy of every poster, 480 and 960 pixels wide, and of every background, 1280 and 1920 — a third lighter than a WebP copy of the same width, and a second or two of a processor each to make. At night, once “AVIF copies at night” is on, on the Media page. It can be stopped.', 'tls.renew': 'The certificate the clients’ door shows, looked at daily and issued anew by this server’s authority when fewer than thirty days remain — loaded without a restart.', 'update.check': 'GitHub asked for this project’s latest release, a minute after the start and then daily — one anonymous request, and only with the check turned on in the settings. A newer one is said on the dashboard, and told to the webhook once.', 'arr.seed': 'Every series your Sonarr holds and every film your Radarr holds, imported here when it is not yet, one after the other — before pointing them at this server, or again later for what came since. It can be stopped.', diff --git a/frontend/src/lib/lang/fr.ts b/frontend/src/lib/lang/fr.ts index c255a5a..0c9985c 100644 --- a/frontend/src/lib/lang/fr.ts +++ b/frontend/src/lib/lang/fr.ts @@ -832,6 +832,9 @@ export const fr: Dict = { editor: 'Corrige aussi le catalogue : champs, imports, rafraîchissements, listes.', admin: 'Règle aussi tout le reste : comptes, accès, clés, réglages.', }, + reading: 'Lecture', + adultPosters: 'Montrer les affiches des œuvres pour adultes', + adultPostersHint: 'Désactivé, elles restent floutées jusqu’à ce qu’on les touche. Le choix suit votre compte d’un navigateur à l’autre.', password: 'Mot de passe', passwordNone: 'Ce compte se connecte par son fournisseur d’identité et n’a pas de mot de passe ici.', sessions: 'Sessions', @@ -1695,9 +1698,30 @@ export const fr: Dict = { peopleHint: 'Une par personne, et une fiche en nomme des dizaines : la plus grosse part de ce qui est stocké.', audio: 'Les musiques', audioHint: 'La musique de générique d’un Fan-Kai, quelques mégaoctets chacune.', + webp: 'Copies WebP pour les pages', + webpHint: + 'Chaque image copiée aux largeurs où une page la dessine, à son stockage ou à la première demande d’une page\u202f: la copie d’une carte pèse le dixième de l’image. Désactivé, les pages reçoivent les images elles-mêmes, entières.', avif: 'Copies AVIF la nuit', avifHint: - 'Une copie AVIF de chaque affiche et de chaque fond, faite chaque nuit à 3 h UTC : un tiers plus légère que le WebP que les pages reçoivent sinon, et une seconde ou deux de processeur à faire chacune.', + 'Une copie AVIF de chaque affiche et de chaque fond, faite chaque nuit à 3 h UTC et donnée aux pages partout où elle existe\u202f: un tiers plus légère qu’une copie WebP de même largeur, et une seconde ou deux de processeur à faire chacune.', + turnOff: { + title: (format: string) => `Désactiver les copies ${format}`, + webp: (n: number, count: string, size: string) => + plural(n, { + one: `Une copie WebP est stockée, qui pèse ${size}. La supprimer aussi\u202f? Gardée, elle resservira si les copies sont réactivées\u202f; d’ici là, les pages reçoivent les images elles-mêmes.`, + other: `${count} copies WebP sont stockées, qui pèsent ${size}. Les supprimer aussi\u202f? Gardées, elles resserviront si les copies sont réactivées\u202f; d’ici là, les pages reçoivent les images elles-mêmes.`, + }), + avif: (n: number, count: string, size: string) => + plural(n, { + one: `Une copie AVIF est stockée, qui pèse ${size}. La supprimer aussi\u202f? Gardée, elle est toujours donnée aux pages, et aucune autre n’est faite.`, + other: `${count} copies AVIF sont stockées, qui pèsent ${size}. Les supprimer aussi\u202f? Gardées, elles sont toujours données aux pages, et aucune autre n’est faite.`, + }), + keep: (n: number) => plural(n, { one: 'Désactiver et la garder', other: 'Désactiver et les garder' }), + delete: (n: number) => plural(n, { one: 'Désactiver et la supprimer', other: 'Désactiver et les supprimer' }), + kept: (why: string) => `Désactivé, et les copies restent\u202f: ${why}`, + deleting: (n: number, count: string, format: string, size: string) => + plural(n, { one: `Suppression de la copie ${format} (${size})…`, other: `Suppression de ${count} copies ${format} (${size})…` }), + }, copies: 'Copies pour les pages', copiesLine: (webp: string, avif: string, squares: string) => `WebP : ${webp} · AVIF : ${avif} · carrés pour Plex : ${squares}`, copiesOf: (n: string, size: string) => `${n} (${size})`, @@ -1706,9 +1730,15 @@ export const fr: Dict = { one: 'Une image stockée n’a encore ni esquisse ni première copie. Préparez-la maintenant, ou une page fait faire ses copies à mesure qu’elle les demande.', other: `${n} images stockées n’ont encore ni esquisse ni première copie. Préparez-les maintenant, ou une page fait faire ses copies à mesure qu’elle les demande.`, }), + unpreparedWhole: (n: number) => + plural(n, { + one: 'Une image stockée n’a pas encore d’esquisse. Préparez-la maintenant, ou une page la dessine sans esquisse jusqu’à son arrivée.', + other: `${n} images stockées n’ont pas encore d’esquisse. Préparez-les maintenant, ou une page les dessine sans esquisse jusqu’à leur arrivée.`, + }), allPrepared: 'Toutes les images stockées sont préparées.', prepareNow: 'Préparer les images', preparing: 'Préparation des images en cours', + purging: 'Suppression des copies en cours', serve: 'Comment servir les médias d’un bucket', serveProxy: 'Par ce serveur', serveRedirect: 'Renvoyé vers l’adresse du bucket', @@ -3400,8 +3430,8 @@ export const fr: Dict = { 'export.nfo': 'Un document .nfo pour chaque œuvre, pour une bibliothèque que lisent Plex, Jellyfin ou Kodi.', 'media.store': 'Chaque image et chaque musique que le catalogue référence et qui n’est pas encore stockée, récupérée maintenant, quelques-unes à la fois. Peut être arrêtée ; le reste se récupère au fil de l’eau.', 'media.sweep': 'Les médias que plus rien ne référence — une fiche supprimée, une image abandonnée par sa source, un envoi retiré — oubliés et effacés.', - 'media.variants': 'Chaque image stockée avant cette version — ou avant qu’il y ait des copies — préparée comme l’est une image stockée aujourd’hui : la couleur et l’esquisse qu’une page affiche pendant le chargement calculées, et la copie que son type demande en premier faite, 480 pixels de large pour une affiche et 1280 pour un fond. Quelques-unes à la fois ; on peut l’arrêter, et le passage suivant reprend là où elle s’est arrêtée. Jamais lancée d’elle-même : d’ici là, une page reçoit les copies qu’elle demande, faites à la demande.', - 'media.avif': 'Une copie AVIF de chaque affiche, en 480 et 960 pixels de large, et de chaque fond, en 1280 — un tiers plus légère que le WebP que l’interface reçoit sinon, et une seconde ou deux de processeur à faire chacune. La nuit, une fois « Copies AVIF la nuit » activé sur la page Médias. Peut être arrêtée.', + 'media.variants': 'Chaque image stockée avant cette version — ou avant qu’il y ait des copies — préparée comme l’est une image stockée aujourd’hui\u202f: la couleur et l’esquisse qu’une page affiche pendant le chargement calculées, et, tant que les copies WebP sont activées, la copie que son type demande en premier faite, 480 pixels de large pour une affiche et 1280 pour un fond. Quelques-unes à la fois\u202f; on peut l’arrêter, et le passage suivant reprend là où elle s’est arrêtée. Jamais lancée d’elle-même\u202f: d’ici là, une page dessine ces images sans esquisse.', + 'media.avif': 'Une copie AVIF de chaque affiche, en 480 et 960 pixels de large, et de chaque fond, en 1280 et 1920 — un tiers plus légère qu’une copie WebP de même largeur, et une seconde ou deux de processeur à faire chacune. La nuit, une fois «\u202fCopies AVIF la nuit\u202f» activé sur la page Médias. Peut être arrêtée.', 'tls.renew': 'Le certificat que présente la porte des clients, examiné chaque jour et réémis par l’autorité de ce serveur quand il lui reste moins de trente jours — chargé sans redémarrage.', 'update.check': 'La dernière version de ce projet demandée à GitHub, une minute après le démarrage puis chaque jour — une requête anonyme, et seulement si la vérification est activée dans les réglages. Une version plus récente est signalée sur le tableau de bord, et annoncée une fois au webhook.', 'arr.seed': 'Chaque série de votre Sonarr et chaque film de votre Radarr, importés ici s’ils n’y sont pas encore, l’un après l’autre — avant de les tourner vers ce serveur, ou plus tard pour ce qui est arrivé depuis. Peut être arrêté.', diff --git a/frontend/src/lib/media.test.ts b/frontend/src/lib/media.test.ts index 4744769..f8573b7 100644 --- a/frontend/src/lib/media.test.ts +++ b/frontend/src/lib/media.test.ts @@ -11,12 +11,15 @@ import { latestEpisode, lookOf, nextEpisode, + offerWebp, pick, poster, ratingsOf, seasonName, seasonNumbers, sized, + type ImageRole, + type Picture, } from './media' describe('an image in a role', () => { @@ -44,35 +47,77 @@ describe('an image in a role', () => { const kept = `${stem}.png` const at = (widths: number[], format = 'webp') => widths.map((w) => `${stem}-${w}.${format} ${w}w`).join(', ') - it('is offered at its role’s widths, as WebP, the original kept to fall back on', () => { - const sourced = sized(kept, 'poster') - expect(sourced.src).toBe(`${stem}-480.webp`) - expect(sourced.srcSet).toBe(at([240, 480, 960])) - expect(sourced.avif).toBeUndefined() - expect(sourced.original).toBe(kept) - expect(sized(kept, 'backdrop').srcSet).toBe(at([640, 1280, 1920])) - expect(sized(kept, 'still').srcSet).toBe(at([320, 640])) - expect(sized(kept, 'headshot').srcSet).toBe(at([120, 240])) - }) + describe('with the WebP copies on', () => { + const webp = (role: ImageRole, look?: Picture) => sized(kept, role, look, true) - it('is never offered wider than 1920, the lightbox’s included', () => { - expect(sized(kept, 'full').srcSet).toBe(at([640, 1280, 1920])) - }) + it('is offered at its role’s widths, as WebP, the original kept to fall back on', () => { + const sourced = webp('poster') + expect(sourced.src).toBe(`${stem}-480.webp`) + expect(sourced.srcSet).toBe(at([240, 480, 960])) + expect(sourced.avif).toBeUndefined() + expect(sourced.original).toBe(kept) + expect(webp('backdrop').srcSet).toBe(at([640, 1280, 1920])) + expect(webp('still').srcSet).toBe(at([320, 640])) + expect(webp('headshot').srcSet).toBe(at([120, 240])) + }) + + it('is never offered wider than 1920, the lightbox’s included', () => { + expect(webp('full').srcSet).toBe(at([640, 1280, 1920])) + }) + + it('is never offered wider than it is, and offered at its own width', () => { + const sourced = webp('card', { width: 680, height: 1000 }) + expect(sourced.srcSet).toBe(at([240, 480, 680])) + expect([sourced.width, sourced.height]).toEqual([480, 706]) + expect(webp('card', { width: 200, height: 300 }).srcSet).toBe(at([200])) + }) - it('is never offered wider than it is, and offered at its own width', () => { - const sourced = sized(kept, 'card', { width: 680, height: 1000 }) - expect(sourced.srcSet).toBe(at([240, 480, 680])) - expect([sourced.width, sourced.height]).toEqual([480, 706]) - expect(sized(kept, 'card', { width: 200, height: 300 }).srcSet).toBe(at([200])) + it('offers its AVIF copies where they reach as wide as the WebP', () => { + const poster = { width: 2000, height: 3000, avif: [480, 960] } + expect(webp('card', poster).avif).toBe(at([480, 960], 'avif')) + expect(webp('thumb', poster).avif).toBe(at([480], 'avif')) + // A background's 1280 would draw a dense screen's 1920 soft. + expect(webp('backdrop', { width: 3840, height: 2160, avif: [1280] }).avif).toBeUndefined() + expect(webp('backdrop', { width: 1280, height: 720, avif: [1280] }).avif).toBe(at([1280], 'avif')) + }) }) - it('offers its AVIF copies where they reach as wide as the WebP', () => { - const poster = { width: 2000, height: 3000, avif: [480, 960] } - expect(sized(kept, 'card', poster).avif).toBe(at([480, 960], 'avif')) - expect(sized(kept, 'thumb', poster).avif).toBe(at([480], 'avif')) - // A background's 1280 would draw a dense screen's 1920 soft. - expect(sized(kept, 'backdrop', { width: 3840, height: 2160, avif: [1280] }).avif).toBeUndefined() - expect(sized(kept, 'backdrop', { width: 1280, height: 720, avif: [1280] }).avif).toBe(at([1280], 'avif')) + describe('with the WebP copies off', () => { + const whole = (role: ImageRole, look?: Picture) => sized(kept, role, look, false) + + it('is the picture itself, its own size giving the box its proportions', () => { + expect(whole('poster', { width: 2000, height: 3000 })).toEqual({ src: kept, width: 2000, height: 3000, original: kept }) + expect(whole('backdrop', { width: 3840, height: 2160 })).toEqual({ src: kept, width: 3840, height: 2160, original: kept }) + expect(whole('card')).toEqual({ src: kept, original: kept }) + }) + + it('is offered as AVIF over the picture where those copies reach as wide as its role is drawn', () => { + const poster = { width: 2000, height: 3000, avif: [480, 960] } + expect(whole('card', poster)).toEqual({ + src: kept, + sizes: sized(kept, 'card', poster, true).sizes, + avif: at([480, 960], 'avif'), + width: 2000, + height: 3000, + original: kept, + }) + expect(whole('thumb', poster).avif).toBe(at([480], 'avif')) + expect(whole('backdrop', { width: 1280, height: 720, avif: [1280] }).avif).toBe(at([1280], 'avif')) + // Not as wide: the picture alone, with no width to choose among. + const backdrop = whole('backdrop', { width: 3840, height: 2160, avif: [1280] }) + expect(backdrop).toEqual({ src: kept, width: 3840, height: 2160, original: kept }) + }) + + it('is what the server said last, where the caller does not say', () => { + expect(sized(kept, 'card').src).toBe(kept) + offerWebp(true) + try { + expect(sized(kept, 'card').srcSet).toBe(at([240, 480, 960])) + } finally { + offerWebp(false) + } + expect(sized(kept, 'card').srcSet).toBeUndefined() + }) }) it('is found by its address once a work has said what it looks like', () => { @@ -80,8 +125,9 @@ describe('an image in a role', () => { const item = work({ images: [image('p', { url: `${stem}.jpg` })], pictures: { [`${stem}.jpg`]: look } }) expect(poster(item)).toBe(`${stem}.jpg`) expect(lookOf(`${stem}.jpg`)).toEqual(look) - expect(sized(`${stem}.jpg`, 'card').srcSet).toBe(at([240, 480, 960])) - expect(sized(`${stem}.jpg`, 'poster').width).toBe(480) + expect(sized(`${stem}.jpg`, 'card', undefined, true).srcSet).toBe(at([240, 480, 960])) + expect(sized(`${stem}.jpg`, 'poster', undefined, true).width).toBe(480) + expect(sized(`${stem}.jpg`, 'poster', undefined, false).width).toBe(1000) }) }) diff --git a/frontend/src/lib/media.ts b/frontend/src/lib/media.ts index e0962ac..b6dd1d9 100644 --- a/frontend/src/lib/media.ts +++ b/frontend/src/lib/media.ts @@ -7,6 +7,8 @@ * three different ways in three components. */ +import { useSyncExternalStore } from 'react' + import * as fmt from './format' import type { Credit, Episode, Image, MediaItem, Rating } from './types' @@ -172,36 +174,86 @@ export function lookOf(url: string): Picture | undefined { return LOOKS.get(url) } +/** + * Whether the server gives the pages WebP copies of the pictures it keeps + * (`media.webp`), as it said last: its options say so, and are told here + * before they are answered (see `useAuthOptions`). Off until it says + * otherwise, as the setting is, and the pages are then given the pictures + * themselves. + */ +let webpOffered = false +const webpWatchers = new Set<() => void>() + +function watchWebp(watcher: () => void) { + webpWatchers.add(watcher) + return () => { + webpWatchers.delete(watcher) + } +} + +/** What the server says of its WebP copies, told to every picture drawn. */ +export function offerWebp(on: boolean) { + if (webpOffered === on) return + webpOffered = on + for (const watcher of webpWatchers) watcher() +} + +/** + * Whether the pictures kept are offered as WebP copies, for what draws one: + * drawn again when the server turns them on or off. + */ +export function useWebpOffered(): boolean { + return useSyncExternalStore(watchWebp, () => webpOffered, () => false) +} + /** * The right file for an image in a role. * - * A copy this server keeps is offered at the widths of its role's ladder, - * as WebP — and as AVIF where the night's copies reach as wide. TMDB gets - * its ladder. TheTVDB publishes a thumbnail beside each artwork — the same - * path with `_t` before the extension — which is used for the small roles, - * with the original kept as a fallback because an old upload is not - * guaranteed to have one. Anything else, including a URL somebody typed by - * hand, is used as it is. + * A picture this server keeps is offered at the widths of its role's + * ladder, as WebP while the server makes those copies (`webp`: as it said + * last, unless the caller says) — and otherwise as itself, whole, in its + * own proportions; either way as AVIF too where the night's copies reach + * as wide. TMDB gets its ladder. TheTVDB publishes a thumbnail beside each + * artwork — the same path with `_t` before the extension — which is used + * for the small roles, with the original kept as a fallback because an old + * upload is not guaranteed to have one. Anything else, including a URL + * somebody typed by hand, is used as it is. */ -export function sized(url: string, role: ImageRole, look: Picture | undefined = lookOf(url)): Sourced { +export function sized( + url: string, + role: ImageRole, + look: Picture | undefined = lookOf(url), + webp: boolean = webpOffered, +): Sourced { const kept = KEPT.exec(url) if (kept) { const stem = `${kept[1]}${kept[2]}` const widths = keptWidths(role, look?.width) - const srcSet = widths.map((width) => `${stem}-${width}.webp ${width}w`).join(', ') + // AVIF only where its copies reach as wide as the role is offered: a + // source the browser picks is the only one it looks at, and a narrower + // one would draw a dense screen's picture soft. + const largest = widths[widths.length - 1]! + const covered = (look?.avif ?? []).filter((width) => width <= largest) + const avif = covered.includes(largest) ? covered.map((width) => `${stem}-${width}.avif ${width}w`).join(', ') : undefined + + if (!webp) { + // The picture itself, its own size giving the box its proportions, + // under the AVIF copies where they reach as wide. + return { + src: url, + ...(avif ? { sizes: SIZES[role], avif } : {}), + ...(look?.width && look.height ? { width: look.width, height: look.height } : {}), + original: url, + } + } + // The middle of the ladder for a browser that reads no `srcset`. - const src = `${stem}-${widths[Math.min(1, widths.length - 1)]}.webp` const drawn = widths[Math.min(1, widths.length - 1)]! - // AVIF only where its copies reach as wide as the WebP ones: a source - // the browser picks is the only one it looks at, and a narrower one - // would draw a dense screen's picture soft. - const largest = widths[widths.length - 1]! - const avif = (look?.avif ?? []).filter((width) => width <= largest) return { - src, - srcSet, + src: `${stem}-${drawn}.webp`, + srcSet: widths.map((width) => `${stem}-${width}.webp ${width}w`).join(', '), sizes: SIZES[role], - avif: avif.includes(largest) ? avif.map((width) => `${stem}-${width}.avif ${width}w`).join(', ') : undefined, + avif, ...(look?.width && look.height ? { width: drawn, height: Math.max(1, Math.round((look.height * drawn) / look.width)) } : {}), diff --git a/frontend/src/lib/preferences.ts b/frontend/src/lib/preferences.ts index ab212ec..02f6e75 100644 --- a/frontend/src/lib/preferences.ts +++ b/frontend/src/lib/preferences.ts @@ -1,11 +1,12 @@ /** - * What a person keeps of their own way of working in the administration — + * What a person keeps of their own way of working — in the administration, * the views of the catalogue they saved, which columns each table shows them - * and how tightly it is set, whether the editor shows its preview — kept - * with their account, so it follows them from one browser to the next - * (`/account/preferences`). + * and how tightly it is set, whether the editor shows its preview; in the + * catalogue, whether the posters of works for adults are shown (see + * `adultPosters.ts`) — kept with their account, so it follows them from one + * browser to the next (`/account/preferences`). * - * The whole set is read once and held for the administration: each + * The whole set is read once and held for every page: each * preference is read from that one answer, its last value kept in this * browser as well, so a page is drawn as it was left before the answer is * in, and written back a moment after it changes — the last of several diff --git a/frontend/src/lib/prefs.test.ts b/frontend/src/lib/prefs.test.ts index 7f1229a..0611de7 100644 --- a/frontend/src/lib/prefs.test.ts +++ b/frontend/src/lib/prefs.test.ts @@ -112,4 +112,39 @@ describe('the posters of works for adults', () => { const prefs = await fresh() expect(prefs.adultPostersShown()).toBe(true) }) + + it('are as the account signed in chose, over this browser, until nobody is', async () => { + local.kept.set('ams.adultPosters', 'shown') + const prefs = await fresh() + + prefs.adoptAdultPosters(false) + expect(prefs.adultPostersShown()).toBe(false) + + prefs.adoptAdultPosters(undefined) + expect(prefs.adultPostersShown()).toBe(true) + }) + + it('are shown for an account that asked, in a browser that never did, and the browser keeps nothing of it', async () => { + const prefs = await fresh() + + prefs.adoptAdultPosters(true) + expect(prefs.adultPostersShown()).toBe(true) + expect(local.kept.has('ams.adultPosters')).toBe(false) + + // Signed out: whoever uses the browser next sees them veiled. + prefs.adoptAdultPosters(undefined) + expect(prefs.adultPostersShown()).toBe(false) + }) + + it('keep the browser’s own choice, made while an account’s counted, for when it is gone', async () => { + const prefs = await fresh() + + prefs.adoptAdultPosters(false) + prefs.showAdultPosters(true) + expect(prefs.adultPostersShown()).toBe(false) + expect(local.kept.get('ams.adultPosters')).toBe('shown') + + prefs.adoptAdultPosters(undefined) + expect(prefs.adultPostersShown()).toBe(true) + }) }) diff --git a/frontend/src/lib/prefs.ts b/frontend/src/lib/prefs.ts index 1df8395..3a1fad2 100644 --- a/frontend/src/lib/prefs.ts +++ b/frontend/src/lib/prefs.ts @@ -1,11 +1,14 @@ /** * The reader's own settings, kept in this browser as the interface's * language and the lamp are: the language the catalogue's texts are read in, - * and whether the posters of works for adults are shown as they are. + * and whether the posters of works for adults are shown as they are — which + * an account signed in may have chosen for itself, and then its choice is + * the one that counts (see `adultPosters.ts`). * * Neither leaves the browser but as what it changes: the language rides on * every read of the catalogue as its `?language=` (see `api.ts`), and the - * posters are the page's business alone. + * posters are the page's business alone — the account keeps its own choice, + * never this browser's. */ import { useSyncExternalStore } from 'react' @@ -123,24 +126,50 @@ export function useTextLanguage(): string | undefined { /* ── Posters for adults ───────────────────────────────────────────────────── */ +/** This browser's choice, as read from the storage once: `null` until it has been. */ let adultPosters: boolean | null = null +/** + * The choice of the account signed in, where it made one: nothing for a + * visitor, nor for an account that never chose. Held for the visit alone — + * the account keeps it, and puts it here again on the next. + */ +let accountPosters: boolean | undefined + /** * Whether the reader asked to see the posters of works for adults as they - * are. Not until they have: a catalogue shown on a living-room screen, or - * over a shoulder, was showing one to whoever looked. + * are: as the account signed in chose, else as this browser did. Not until + * one of them has: a catalogue shown on a living-room screen, or over a + * shoulder, was showing one to whoever looked. */ export function adultPostersShown(): boolean { + if (accountPosters !== undefined) return accountPosters if (adultPosters === null) adultPosters = read(ADULT_POSTERS_KEY) === 'shown' return adultPosters } +/** + * This browser's choice: a visitor's, and that of anybody signed in whose + * account has not made one. Kept in the storage, for the next visit. + */ export function showAdultPosters(shown: boolean) { adultPosters = shown write(ADULT_POSTERS_KEY, shown ? 'shown' : undefined) told() } +/** + * The choice of the account signed in, which counts over this browser's + * while it is there; `undefined` when nobody is signed in, or the account + * never chose, and this browser's counts again. Never written to the + * storage: whoever uses the browser next is not the account. + */ +export function adoptAdultPosters(shown: boolean | undefined) { + if (accountPosters === shown) return + accountPosters = shown + told() +} + export function useAdultPostersShown(): boolean { return useSyncExternalStore(subscribe, adultPostersShown, () => false) } diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 161be72..e4d5cce 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -317,6 +317,12 @@ export type SourceRule = Schema<'Laid'> /** How the media store stands. */ export type MediaStatus = Schema<'MediaStatus'> +/** A format whose copies can be deleted once they are no longer made: `webp` or `avif`. */ +export type MediaVariantFormat = Schema<'MediaVariantFormat'> + +/** What there was of a format's copies as their deletion began. */ +export type VariantsDeleted = Schema<'VariantsDeleted'> + /** One medium kept, or in line to be. */ export type MediaAsset = Schema<'Asset'> diff --git a/frontend/src/routes/Account.tsx b/frontend/src/routes/Account.tsx index 77f6ce3..38e950e 100644 --- a/frontend/src/routes/Account.tsx +++ b/frontend/src/routes/Account.tsx @@ -1,6 +1,7 @@ /** - * A person's own account: what they are called, how they sign in, where - * they are signed in, and the keys that act in their name. + * A person's own account: what they are called, how the catalogue is shown + * to them, how they sign in, where they are signed in, and the keys that act + * in their name. * * The same page in both shells: a member reaches it from the catalogue, an * editor or an administrator from the administration's sidebar. What it @@ -34,7 +35,9 @@ import { Segmented, Skeleton, Spinner, + Toggle, } from '../components/ui' +import { useAdultPosters } from '../lib/adultPosters' import { describeAgent } from '../lib/agent' import { ApiError, api } from '../lib/api' import { dateTime } from '../lib/format' @@ -83,6 +86,7 @@ export function Account() {
+ {me.hasPassword ? ( @@ -233,6 +237,34 @@ function Profile({ me }: { me: User }) { ) } +/* ── Reading ──────────────────────────────────────────────────────────────── */ + +/** + * How the catalogue is shown to them: the posters of works for adults, as + * they are or veiled. Kept with the account, as the bar's reading menu keeps + * it, and taken at once — not by the profile's Save, which waits for what is + * typed above it. + */ +function Reading() { + const { t } = useI18n() + const [adult, showAdult] = useAdultPosters() + + return ( + + +
+
+ +

{t.account.adultPostersHint}

+
+ +
+
+ ) +} + /* ── Sessions ─────────────────────────────────────────────────────────────── */ function Sessions() { diff --git a/frontend/src/routes/admin/Media.tsx b/frontend/src/routes/admin/Media.tsx index 162670b..0d5ad4f 100644 --- a/frontend/src/routes/admin/Media.tsx +++ b/frontend/src/routes/admin/Media.tsx @@ -40,9 +40,22 @@ import { type Column, } from '../../components/ui' import { ApiError, api, query } from '../../lib/api' +import { cn } from '../../lib/cn' import * as fmt from '../../lib/format' import { useI18n } from '../../lib/i18n' -import type { MediaAsset, MediaByWork, MediaStatus, TaskId } from '../../lib/types' +import type { MediaAsset, MediaByWork, MediaStatus, MediaVariantFormat, TaskId, VariantsDeleted } from '../../lib/types' + +/** The switches that have a format's copies made, which can be deleted once they are off. */ +const FORMAT_OF: Partial> = { 'media.webp': 'webp', 'media.avif': 'avif' } +const SETTING_OF: Record = { webp: 'media.webp', avif: 'media.avif' } +const FORMAT_NAME: Record = { webp: 'WebP', avif: 'AVIF' } + +/** How many copies of a format are kept, and what they weigh. */ +function copiesOf(variants: MediaStatus['variants'], format: MediaVariantFormat): { copies: number; bytes: number } { + return format === 'webp' + ? { copies: variants.webp, bytes: variants.webpBytes } + : { copies: variants.avif, bytes: variants.avifBytes } +} /** An address as a row shows it: its host, and the end of its path. */ function shortAddress(origin: string): string { @@ -63,12 +76,19 @@ export function Media() { // One line under the buttons says what the last action did. const [notice, setNotice] = useState(null) const [asking, setAsking] = useState(false) + // The format being turned off while copies of it are kept, as the page + // asks what becomes of them; and the deletion the last answer started. + const [turningOff, setTurningOff] = useState(null) + const [purged, setPurged] = useState(null) const status = useQuery({ queryKey: ['media', 'status'], queryFn: () => api.get('/media/status'), // Live while something runs: the counts are the progress. - refetchInterval: (q) => (q.state.data?.fetching || q.state.data?.sweeping || q.state.data?.preparing ? 3_000 : 30_000), + refetchInterval: (q) => { + const now = q.state.data + return now?.fetching || now?.sweeping || now?.preparing || now?.purging ? 3_000 : 30_000 + }, }) const on = status.data?.backend !== undefined && status.data.backend !== 'off' @@ -83,13 +103,77 @@ export function Media() { void queryClient.invalidateQueries({ queryKey: ['media'] }) void queryClient.invalidateQueries({ queryKey: ['tasks'] }) } + // What a switch changes: the figures here, and — the WebP copies' — what + // this browser's pages are given, which the server's options say. + const changed = (key: string) => { + invalidate() + if (key === 'media.webp') void queryClient.invalidateQueries({ queryKey: ['auth', 'options'] }) + } const set = useMutation({ mutationFn: ({ key, value }: { key: string; value: string }) => api.put('/settings/server/-', { key, value }), - onSuccess: invalidate, + onSuccess: (_, { key }) => changed(key), + }) + + // Every copy of a format deleted, in the background on the server, which + // answers at once with what there was. Asked once more a second later + // when it is refused for now (409): of several instances, the one asked + // may not have heard yet that the format was turned off. + const purge = useMutation({ + mutationFn: async (format: MediaVariantFormat) => { + const path = `/media/variants/${format}` + try { + return await api.delete(path) + } catch (error) { + if (!(error instanceof ApiError) || error.status !== 409) throw error + await new Promise((resolve) => window.setTimeout(resolve, 1_000)) + return await api.delete(path) + } + }, + onSuccess: (deleted) => { + setTurningOff(null) + setPurged(deleted) + invalidate() + }, }) + // A format turned off from the dialog — the server deletes none of its + // copies while it is on — and then, where that was the answer, its + // copies deleted. + const turnOff = useMutation({ + mutationFn: ({ format }: { format: MediaVariantFormat; purge: boolean }) => + api.put('/settings/server/-', { key: SETTING_OF[format], value: 'false' }), + onSuccess: (_, { format, purge: deleting }) => { + changed(SETTING_OF[format]) + if (deleting) purge.mutate(format) + else setTurningOff(null) + }, + }) + + // A switch pressed. Turning a format's copies off while some are kept + // asks first whether to delete them — counted again before it asks, as + // the pages have copies made between two looks at the store; anything + // else is done at once. + const flip = (key: string, next: boolean) => { + const format = FORMAT_OF[key] + if (!format || next) { + set.mutate({ key, value: String(next) }) + return + } + void status.refetch().then(({ data }) => { + if (data && copiesOf(data.variants, format).copies > 0) { + turnOff.reset() + purge.reset() + setTurningOff(format) + } else { + set.mutate({ key, value: 'false' }) + } + }) + } + const answering = turnOff.isPending || purge.isPending + const asked = turningOff && status.data ? { format: turningOff, ...copiesOf(status.data.variants, turningOff) } : null + const run = useMutation({ mutationFn: (id: TaskId) => api.post<{ jobId?: string }>(`/tasks/${id}/run`), onMutate: () => setNotice(null), @@ -191,11 +275,12 @@ export function Media() { )}

- {(status.data.fetching || status.data.sweeping || status.data.preparing || !status.data.publicUrl) ? ( + {(status.data.fetching || status.data.sweeping || status.data.preparing || status.data.purging || !status.data.publicUrl) ? (
{status.data.fetching ? {p.fetching} : null} {status.data.sweeping ? {p.sweeping} : null} {status.data.preparing ? {p.preparing} : null} + {status.data.purging ? {p.purging} : null} {!status.data.publicUrl ? (

@@ -229,6 +314,7 @@ export function Media() { ['media.store', p.storeOn, p.storeOnHint, status.data.storing], ['media.people', p.people, p.peopleHint, status.data.people], ['media.audio', p.audio, p.audioHint, status.data.audio], + ['media.webp', p.webp, p.webpHint, status.data.webp], ['media.avif', p.avif, p.avifHint, status.data.avif], ] as const ).map(([key, label, hint, checked]) => ( @@ -243,8 +329,8 @@ export function Media() { id={`media-${key}`} checked={checked} label={label} - disabled={set.isPending} - onChange={(next) => set.mutate({ key, value: String(next) })} + disabled={set.isPending || answering} + onChange={(next) => flip(key, next)} /> ))} @@ -267,6 +353,18 @@ export function Media() { ) : null} + {/* What the last deletion started on: the lamp above says + while it runs, and the copies' line counts it down. */} +

+ {purged + ? p.turnOff.deleting( + purged.copies, + fmt.count(purged.copies, locale), + FORMAT_NAME[purged.format], + fmt.bytes(purged.bytes, locale), + ) + : ''} +

{set.isError ? (

{set.error instanceof ApiError ? set.error.message : t.common.actionFailed} @@ -309,7 +407,11 @@ export function Media() {

{status.data.unprepared > 0 ? ( <> -

{p.unprepared(status.data.unprepared)}

+ {/* Without the WebP copies, preparing a picture is + working out its placeholder alone. */} +

+ {status.data.webp ? p.unprepared(status.data.unprepared) : p.unpreparedWhole(status.data.unprepared)} +

+ + + + ) : null + } + > + {asked + ? p.turnOff[asked.format](asked.copies, fmt.count(asked.copies, locale), fmt.bytes(asked.bytes, locale)) + : null} + {turnOff.isError ? ( +

+ {turnOff.error instanceof ApiError ? turnOff.error.message : t.common.actionFailed} +

+ ) : purge.isError ? ( +

+ {p.turnOff.kept(purge.error instanceof ApiError ? purge.error.message : t.common.actionFailed)} +

+ ) : null} + + OpenApiRouter { .routes(routes!(retry)) .routes(routes!(retry_all)) .routes(routes!(reset)) + .routes(routes!(delete_variants)) .routes(routes!(work_media)) .routes(routes!(by_work)) .routes(routes!(forget)) @@ -102,6 +103,9 @@ pub struct MediaStatus { /// The pictures kept that this version has not prepared — no /// placeholder, no first copy — which the task `media.variants` does. pub unprepared: i64, + /// Whether WebP copies are made and given to the pages (`media.webp`); + /// off, the pages are given the pictures themselves. + pub webp: bool, /// Whether the night's task makes AVIF copies (`media.avif`). pub avif: bool, /// Whether a fetch or a sweep is under way. @@ -109,6 +113,8 @@ pub struct MediaStatus { pub sweeping: bool, /// Whether the pictures are being prepared, or their AVIF copies made. pub preparing: bool, + /// Whether every copy of a format is being deleted. + pub purging: bool, } /// How the media store stands. @@ -136,10 +142,12 @@ async fn status( variants: repo::asset::variant_counts(&state.db).await?, unprepared: repo::asset::unprepared_count(&state.db, crate::media::variants::PREPARED) .await?, + webp: state.flag("media.webp", false), avif: state.flag("media.avif", false), fetching: crate::media::worker::is_storing(), sweeping: crate::media::worker::is_sweeping(), preparing: crate::jobs::media::is_busy(), + purging: crate::jobs::media::is_purging(), })) } @@ -267,6 +275,110 @@ async fn reset( Ok(Json(Forgotten { forgotten })) } +/// A format the copies of the pictures are written as, whose copies can be +/// deleted once they are no longer made. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, ToSchema)] +#[serde(rename_all = "lowercase")] +// Named explicitly: utoipa keys schemas on the leaf name alone. +#[schema(as = MediaVariantFormat)] +pub enum VariantFormat { + /// The copies the pages are given while `media.webp` is on. + Webp, + /// The night's copies, made while `media.avif` is on. + Avif, +} + +impl VariantFormat { + fn made_as(self) -> crate::media::variants::Format { + match self { + Self::Webp => crate::media::variants::Format::Webp, + Self::Avif => crate::media::variants::Format::Avif, + } + } + + fn name(self) -> &'static str { + match self { + Self::Webp => "WebP", + Self::Avif => "AVIF", + } + } +} + +/// What there was of a format's copies as their deletion began. +#[derive(Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct VariantsDeleted { + pub format: VariantFormat, + /// How many copies there were. + pub copies: i64, + /// What they weighed, in bytes. + pub bytes: i64, +} + +/// Delete every copy of a format: the WebP copies once `media.webp` is off, +/// the AVIF copies once `media.avif` is — the space they took, back. +/// +/// Answered at once, with what there was, and done in the background: each +/// copy's file taken from the store, then its row; the status says +/// `purging` until it is over. The pictures themselves are not touched, +/// nor the squares Plex is given. Turned on again meanwhile, the setting +/// stops it where it is. +#[utoipa::path( + delete, path = "/media/variants/{format}", tag = TAG, + operation_id = "media_delete_variants", + params(("format" = VariantFormat, Path, description = "The format whose copies are deleted")), + responses( + (status = 202, body = VariantsDeleted), + (status = 400, description = "Not a format whose copies are deleted: `webp` or `avif`"), + (status = 403, description = "The caller is not an administrator"), + (status = 409, description = "The format's copies are still made — its setting is on — or the AVIF copies are being made, or copies are being deleted already"), + (status = 503, description = "No media store is configured"), + ), +)] +async fn delete_variants( + State(state): State, + Extension(identity): Extension, + ip: ClientIp, + Path(format): Path, +) -> AppResult<(StatusCode, Json)> { + identity.require_admin()?; + // Read here rather than by the extractor, so that another name is + // refused in this server's words. + let format = match format.as_str() { + "webp" => VariantFormat::Webp, + "avif" => VariantFormat::Avif, + other => { + return Err(AppError::BadRequest(format!( + "{other} is no format whose copies are deleted: webp or avif" + ))); + } + }; + let there_was = crate::jobs::media::purge_now(&state, format.made_as()).await?; + audit::record( + &state, + Event { + identity: Some(&identity), + ip: &ip, + action: Action::MediaRemoved, + target: None, + detail: Some(&format!( + "every {} copy deleted ({} files)", + format.name(), + there_was.copies + )), + }, + ) + .await; + Ok(( + StatusCode::ACCEPTED, + Json(VariantsDeleted { + format, + copies: there_was.copies, + bytes: there_was.bytes, + }), + )) +} + /// A work's media, and whether anything is kept at all. #[derive(Serialize, ToSchema)] #[serde(rename_all = "camelCase")] @@ -710,9 +822,17 @@ async fn upload( Place::Picture { cover_type, .. } => Some(*cover_type), Place::Still { .. } | Place::Theme => None, }; - let kept = crate::media::worker::keep(store, key, sha256, bytes, &inspected, cover) - .await - .map_err(|e| AppError::Internal(e.context("could not keep the upload")))?; + let kept = crate::media::worker::keep( + store, + key, + sha256, + bytes, + &inspected, + cover, + state.flag("media.webp", false), + ) + .await + .map_err(|e| AppError::Internal(e.context("could not keep the upload")))?; let by = identity.label(); repo::asset::insert_upload( &state.db, @@ -1142,6 +1262,160 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + + async fn set(state: &AppState, key: &str, value: &str) { + state + .settings + .set(crate::settings::Scope::Server, "", key, value, None) + .await + .expect("set"); + } + + /// Once the copies being deleted in the background are. + async fn deleted_at_last() { + for _ in 0..3000 { + if !crate::jobs::media::is_purging() { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + panic!("the copies are still being deleted"); + } + + /// Every copy of a format is deleted once its setting is off — each + /// file, then the rows — in the background, and nothing else: not the + /// other format's, not Plex's square, not the picture. Refused for a + /// format that is none, to anybody but an administrator, and while the + /// format's copies are still made. + #[tokio::test] + async fn every_copy_of_a_format_is_deleted_once_it_is_off() { + use crate::db::repo::asset::{Thumb, Variant}; + + let dir = crate::testing::scratch_path("media-purge"); + let state = server(&dir).await; + let store = state.media.store().unwrap().clone(); + + // A picture kept, with copies of every kind beside it — one WebP + // noted whose file is gone already. + let sha = format!("{}c0ff", "c0ffee".repeat(10)); + let key = format!("{sha}.png"); + store + .put(&key, Bytes::from_static(b"picture"), "image/png") + .await + .unwrap(); + state.media.remember( + "https://images.example/kept.png", + &key, + Thumb::None, + "image/png", + ); + for (made, format, on_disk) in [ + ("240.webp", "webp", true), + ("480.webp", "webp", true), + ("960.webp", "webp", false), + ("480.avif", "avif", true), + ("square.jpg", "jpeg", true), + ] { + let copy = format!("{sha}-{made}"); + if on_disk { + store + .put(©, Bytes::from_static(b"copy"), "image/webp") + .await + .unwrap(); + } + repo::asset::add_variant( + &state.db, + &Variant { + key: copy, + stem: sha.clone(), + width: 480, + height: 720, + format: format.into(), + bytes: 4, + }, + ) + .await + .unwrap(); + } + state.media.add_avif(&sha, 480); + let on_disk = |made: &str| { + let store = store.clone(); + let copy = format!("{sha}-{made}"); + async move { store.exists(©).await.unwrap() } + }; + let delete = |format: &str, identity: Identity| { + delete_variants( + State(state.clone()), + Extension(identity), + ClientIp::NONE, + Path(format.to_string()), + ) + }; + + assert!(matches!( + delete("jpeg", Identity::Anonymous).await, + Err(AppError::BadRequest(_)) + )); + assert!(matches!( + delete("png", Identity::Anonymous).await, + Err(AppError::BadRequest(_)) + )); + assert!(matches!( + delete("webp", Identity::Visitor).await, + Err(AppError::Forbidden) + )); + + // Still made and given to the pages: refused, and nothing deleted. + set(&state, "media.webp", "true").await; + assert!(matches!( + delete("webp", Identity::Anonymous).await, + Err(AppError::Conflict(_)) + )); + assert_eq!( + repo::asset::variant_counts(&state.db).await.unwrap().webp, + 3 + ); + assert!(on_disk("240.webp").await); + + set(&state, "media.webp", "false").await; + let (answered, Json(deleted)) = + delete("webp", Identity::Anonymous).await.expect("accepted"); + assert_eq!(answered, StatusCode::ACCEPTED); + assert_eq!( + (deleted.format, deleted.copies, deleted.bytes), + (VariantFormat::Webp, 3, 12), + "what there was" + ); + deleted_at_last().await; + let counts = repo::asset::variant_counts(&state.db).await.unwrap(); + assert_eq!((counts.webp, counts.avif, counts.jpeg), (0, 1, 1)); + assert!(!on_disk("240.webp").await && !on_disk("480.webp").await); + assert!(on_disk("480.avif").await && on_disk("square.jpg").await); + assert!(store.exists(&key).await.unwrap(), "the picture is kept"); + assert_eq!(state.media.keyed(&key).unwrap().look.avif, [480]); + + // The AVIF copies too, off as they are until somebody sets them: + // gone, and no longer offered to a page. + let (_, Json(deleted)) = delete("avif", Identity::Anonymous).await.expect("accepted"); + assert_eq!( + (deleted.format, deleted.copies, deleted.bytes), + (VariantFormat::Avif, 1, 4) + ); + deleted_at_last().await; + let counts = repo::asset::variant_counts(&state.db).await.unwrap(); + assert_eq!((counts.webp, counts.avif, counts.jpeg), (0, 0, 1)); + assert!(!on_disk("480.avif").await); + assert!(on_disk("square.jpg").await, "Plex's square is kept"); + assert!(state.media.keyed(&key).unwrap().look.avif.is_empty()); + + let Json(now) = status(State(state.clone()), Extension(Identity::Anonymous)) + .await + .expect("the status"); + assert!(!now.webp && !now.avif && !now.purging); + assert_eq!((now.variants.webp, now.variants.avif), (0, 0)); + + let _ = std::fs::remove_dir_all(&dir); + } } #[cfg(test)] diff --git a/src/api/native/signup.rs b/src/api/native/signup.rs index 733b7be..74c0e92 100644 --- a/src/api/native/signup.rs +++ b/src/api/native/signup.rs @@ -257,6 +257,10 @@ pub struct AuthOptions { /// The identity provider's button, when signing in through one is on. #[serde(skip_serializing_if = "Option::is_none")] pub oidc: Option, + /// Whether the pages are given WebP copies of the pictures this server + /// keeps (`media.webp`), rather than the pictures themselves: said here, + /// where every page reads it before it draws one. + pub webp: bool, } #[derive(Serialize, ToSchema)] @@ -288,6 +292,7 @@ async fn options(State(state): State) -> Json { oidc: state.oidc_provider().map(|_| OidcButton { label: state.oidc_button(), }), + webp: state.flag("media.webp", false), }) } @@ -641,6 +646,29 @@ mod tests { assert!(take_place_in(counts, None, now, 5, 3).is_some()); } + /// The pages are told, before they draw a picture, whether to ask for + /// its WebP copies: not until an administrator turns them on. + #[tokio::test] + async fn the_pages_are_told_whether_they_are_given_webp_copies() { + let state = crate::api::native::testing::server().await; + let Json(before) = options(State(state.clone())).await; + assert!(!before.webp); + + state + .settings + .set( + crate::settings::Scope::Server, + "", + "media.webp", + "true", + None, + ) + .await + .expect("the WebP copies on"); + let Json(after) = options(State(state)).await; + assert!(after.webp); + } + #[test] fn an_ipv6_network_counts_as_one_address() { let a: IpAddr = "2001:db8:1:2::1".parse().unwrap(); diff --git a/src/db/repo/asset.rs b/src/db/repo/asset.rs index 57b495c..6432922 100644 --- a/src/db/repo/asset.rs +++ b/src/db/repo/asset.rs @@ -1163,6 +1163,45 @@ pub async fn delete_variant(db: &Db, key: &str) -> Result<()> { Ok(()) } +/// The copies written as `format` — `webp`, `avif`, `jpeg` — after `after` +/// in the order of their keys, `limit` of them at the most: what deleting +/// every copy of a format takes, a few hundred at a time, walking them once. +pub async fn variants_by_format( + db: &Db, + format: &str, + after: &str, + limit: i64, +) -> Result> { + let rows = sqlx::query(db.sql( + "SELECT key, stem, width, height, format, bytes FROM media_variant + WHERE format = ? AND key > ? + ORDER BY key + LIMIT ?", + )) + .bind(format) + .bind(after) + .bind(limit.clamp(1, 500)) + .fetch_all(db.pool()) + .await?; + rows.iter().map(map_variant).collect() +} + +/// Forget these copies at once: their files are gone. How many were noted. +pub async fn delete_variants(db: &Db, keys: &[String]) -> Result { + let mut deleted = 0; + // Asked in slices a bind list takes, as `by_origins` asks. + for slice in keys.chunks(400) { + let marks = vec!["?"; slice.len()].join(", "); + let mut query = + sqlx::query(db.sql(&format!("DELETE FROM media_variant WHERE key IN ({marks})"))); + for key in slice { + query = query.bind(key); + } + deleted += query.execute(db.pool()).await?.rows_affected(); + } + Ok(deleted) +} + /// Every AVIF copy made, by its picture's hash and its width: what the /// index in memory tells the pages, which offer one only where it exists. pub async fn avif_index(db: &Db) -> Result> { @@ -1764,6 +1803,67 @@ mod tests { assert!(avif_index(&db).await.unwrap().is_empty()); } + /// A format's copies are walked once, a few at a time in the order of + /// their keys, and forgotten together; another format's are left as + /// they are. + #[tokio::test] + async fn a_formats_copies_are_walked_once_and_forgotten_together() { + let db = db().await; + for (made, format) in [ + ("240.webp", "webp"), + ("480.webp", "webp"), + ("960.webp", "webp"), + ("480.avif", "avif"), + ("square.jpg", "jpeg"), + ] { + add_variant( + &db, + &Variant { + key: format!("{SHA}-{made}"), + stem: SHA.into(), + width: 480, + height: 720, + format: format.into(), + bytes: 10, + }, + ) + .await + .unwrap(); + } + let keys = |copies: &[Variant]| -> Vec { + copies.iter().map(|copy| copy.key.clone()).collect() + }; + + let first = variants_by_format(&db, "webp", "", 2).await.unwrap(); + assert_eq!( + keys(&first), + [format!("{SHA}-240.webp"), format!("{SHA}-480.webp")] + ); + let rest = variants_by_format(&db, "webp", &first[1].key, 2) + .await + .unwrap(); + assert_eq!(keys(&rest), [format!("{SHA}-960.webp")]); + assert!( + variants_by_format(&db, "webp", &rest[0].key, 2) + .await + .unwrap() + .is_empty(), + "walked once" + ); + + let webp = [keys(&first), keys(&rest)].concat(); + assert_eq!(delete_variants(&db, &webp).await.unwrap(), 3); + assert_eq!( + delete_variants(&db, &webp).await.unwrap(), + 0, + "forgotten already" + ); + assert_eq!(delete_variants(&db, &[]).await.unwrap(), 0); + let counts = variant_counts(&db).await.unwrap(); + assert_eq!((counts.webp, counts.avif, counts.jpeg), (0, 1, 1)); + assert_eq!(variants_of(&db, SHA).await.unwrap().len(), 2); + } + /// What a work has a picture as decides the copies made of it; the /// pictures this version has not prepared are listed until each is, so /// a run stopped partway is taken up where it stopped. diff --git a/src/jobs/media.rs b/src/jobs/media.rs index c803d37..66f1a44 100644 --- a/src/jobs/media.rs +++ b/src/jobs/media.rs @@ -1,19 +1,24 @@ -//! The two tasks that make copies of the pictures kept. +//! The two tasks that make copies of the pictures kept, and what deletes +//! every copy of a format once it is off. //! //! - `media.variants`, by hand only: every picture stored before this //! version — or before there were copies at all — prepared as one stored -//! now is: its placeholder worked out, and the copy its kind is first -//! asked for made, a poster's 480 and a background's 1280. A few at a -//! time, each picture noted on its row once done, so that a run stopped -//! — by hand, or by the server going down — is taken up by the next where -//! it stopped. Never at start: a catalogue's worth of pictures is hours -//! of decoding on a small machine, and when to spend them is the -//! administrator's choice. Until then a page is given what it asks for, -//! made as it asks. +//! now is: its placeholder worked out, and — only while the WebP copies +//! are on, `media.webp` — the copy its kind is first asked for made, a +//! poster's 480 and a background's 1280. A few at a time, each picture +//! noted on its row once done, so that a run stopped — by hand, or by the +//! server going down — is taken up by the next where it stopped. Never at +//! start: a catalogue's worth of pictures is hours of decoding on a small +//! machine, and when to spend them is the administrator's choice. Until +//! then a page is given what it asks for, made as it asks. //! - `media.avif`, at night once `media.avif` is on, and by hand: an AVIF //! copy of every poster at 480 and 960, and of every background at 1280 //! — a third lighter than the WebP, and seconds to make, one after //! another. A page offers one only where it exists. +//! +//! Once `media.webp` or `media.avif` is off, an administrator may delete +//! every copy of that format, to have the space back: no run of its own in +//! the history, but the media's status says so while it is under way. use std::{ sync::{Arc, LazyLock}, @@ -169,10 +174,11 @@ async fn prepare_all(state: &AppState, record: &str, flag: &cancel::Registered) ))) } -/// Prepare one picture: its placeholder and its first copy, noted on its -/// row. A picture the store does not have, or that cannot be read, is noted -/// all the same — another run would find it no better — and one the store -/// could not be asked for is left for the next run. +/// Prepare one picture: its placeholder and — while the WebP copies are +/// on, as `media.webp` says when the picture is taken — its first copy, +/// noted on its row. A picture the store does not have, or that cannot be +/// read, is noted all the same — another run would find it no better — and +/// one the store could not be asked for is left for the next run. async fn prepare_one(state: &AppState, store: &crate::media::store::Store, asset: &Asset) -> Done { let failed = |note: String| Done { outcome: Outcome::Failed, @@ -198,7 +204,8 @@ async fn prepare_one(state: &AppState, store: &crate::media::store::Store, asset let cover = repo::asset::cover_of(&state.db, &asset.origin) .await .unwrap_or(None); - let prepared = variants::off_thread(move || variants::prepare(&bytes, cover)).await; + let webp = state.flag("media.webp", false); + let prepared = variants::off_thread(move || variants::prepare(&bytes, cover, webp)).await; let prepared = match prepared { Ok(prepared) => prepared, Err(e) => { @@ -332,7 +339,8 @@ fn short(origin: &str) -> String { /// Once a run is over, done or stopped: the works and the pages of cards /// kept as they were read are read again, so that the placeholders and the -/// AVIF copies made are told to the pages — here and on every instance. +/// AVIF copies made — or deleted — are told to the pages, here and on every +/// instance. async fn drawn_again(state: &AppState) { for space in ["items", "searches"] { state.caches.flush(space).await; @@ -579,6 +587,175 @@ pub async fn run_nights(state: AppState) { } } +// ─── deleting every copy of a format ───────────────────────────────────────── + +/// What a deletion of copies is held under among several instances. +pub const PURGE: &str = "media.purge"; + +/// How many copies are deleted at a time. +const PURGE_BATCH: i64 = 500; + +static PURGING: LazyLock>> = + LazyLock::new(|| Arc::new(tokio::sync::Mutex::new(()))); + +/// Whether every copy of a format is being deleted on this instance. +pub fn is_purging() -> bool { + PURGING.try_lock().is_err() +} + +/// The setting that has a format's copies made: none for the square Plex +/// is given, which is made whatever either says, and never deleted here. +pub fn setting_of(format: Format) -> Option<&'static str> { + match format { + Format::Webp => Some("media.webp"), + Format::Avif => Some("media.avif"), + Format::Jpeg => None, + } +} + +/// How many copies of a format there were as their deletion began, and +/// what they weighed. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Tally { + pub copies: i64, + pub bytes: i64, +} + +/// Delete every copy written as `format` — WebP or AVIF — in the +/// background, and say what there was. Refused while the setting that +/// makes them is on, as the pages are given them; for the AVIF copies, +/// while the night's are being made, here or on another instance — they +/// would come back behind the deletion — and none is made until it is +/// over; and while another deletion runs. +pub async fn purge_now(state: &AppState, format: Format) -> AppResult { + let Some(setting) = setting_of(format) else { + return Err(AppError::BadRequest( + "the squares Plex is given are not deleted".into(), + )); + }; + if !state.media.is_on() { + return Err(media_off()); + } + let name = if format == Format::Avif { + "AVIF" + } else { + "WebP" + }; + if state.flag(setting, false) { + return Err(AppError::Conflict(format!( + "the {name} copies are still made and given to the pages: turn {setting} off first" + ))); + } + let held = PURGING + .clone() + .try_lock_owned() + .map_err(|_| AppError::Conflict("copies are being deleted already".into()))?; + let making = if format == Format::Avif { + if is_making_avif() { + return Err(AppError::Conflict( + "the AVIF copies are being made; wait for the run to end, or stop it".into(), + )); + } + Some( + state + .coord + .hold_for(AVIF, "the AVIF copies are being made") + .await?, + ) + } else { + None + }; + let shared = state + .coord + .hold_for(PURGE, "copies are being deleted") + .await?; + let counts = repo::asset::variant_counts(&state.db).await?; + let tally = match format { + Format::Webp => Tally { + copies: counts.webp, + bytes: counts.webp_bytes, + }, + Format::Avif => Tally { + copies: counts.avif, + bytes: counts.avif_bytes, + }, + Format::Jpeg => Tally { + copies: counts.jpeg, + bytes: counts.jpeg_bytes, + }, + }; + + let state = state.clone(); + tokio::spawn(async move { + let _held = held; + let _making = making; + let _shared = shared; + match purge_all(&state, format, setting).await { + Ok(summary) => tracing::info!(format = format.as_str(), summary, "copies deleted"), + Err(e) => tracing::warn!( + format = format.as_str(), + error = format_args!("{e:#}"), + "the copies could not all be deleted" + ), + } + drawn_again(&state).await; + }); + Ok(tally) +} + +/// The deletion itself, a few hundred copies at a time in the order of +/// their keys: each file taken from the store — one gone already is gone — +/// then the rows of those that went, and an AVIF no longer offered to a +/// page. A file the store would not let go of keeps its row, for the next +/// deletion to try again. Turned on again meanwhile, the setting stops it +/// where it is: what is left is the pages' again. +async fn purge_all(state: &AppState, format: Format, setting: &str) -> Result { + let store = state.media.store().context("no media store")?; + let (mut deleted, mut troubles) = (0u64, 0u64); + let mut after = String::new(); + loop { + if state.flag(setting, false) { + return Ok(format!( + "stopped, {setting} being on again: {deleted} deleted, {troubles} could not be" + )); + } + let line = repo::asset::variants_by_format(&state.db, format.as_str(), &after, PURGE_BATCH) + .await?; + let Some(last) = line.last() else { + break; + }; + after.clone_from(&last.key); + let mut gone = Vec::with_capacity(line.len()); + for copy in &line { + match store.delete(©.key).await { + Ok(()) => gone.push(copy), + Err(e) => { + tracing::warn!( + key = %copy.key, + error = format_args!("{e:#}"), + "could not delete a copy's file" + ); + troubles += 1; + } + } + } + let keys: Vec = gone.iter().map(|copy| copy.key.clone()).collect(); + deleted += repo::asset::delete_variants(&state.db, &keys).await?; + if format == Format::Avif { + for copy in gone { + if let Ok(width) = u32::try_from(copy.width) { + state.media.drop_avif(©.stem, width); + } + } + } + } + let mut summary = format!("{deleted} deleted"); + if troubles > 0 { + summary.push_str(&format!(", {troubles} could not be (see the log)")); + } + Ok(summary) +} + #[cfg(test)] mod tests { use std::io::Cursor; @@ -695,14 +872,29 @@ mod tests { origins } + async fn webp_on(state: &AppState) { + state + .settings + .set( + crate::settings::Scope::Server, + "", + "media.webp", + "true", + None, + ) + .await + .expect("the WebP copies on"); + } + /// The task prepares what no version prepared, a few at a time: a /// picture prepared before a run was stopped is not prepared again, the - /// copy a poster is first asked for is made and noted, and a page is - /// told what the picture looks like. + /// copy a poster is first asked for is made and noted — the WebP copies + /// being on — and a page is told what the picture looks like. #[tokio::test] async fn the_pictures_are_prepared_once_and_a_stopped_run_is_taken_up() { let dir = crate::testing::scratch_path("variants"); let state = server(&dir).await; + webp_on(&state).await; let store = state.media.store().unwrap().clone(); let origins = kept_as_before(&state, &[600, 620, 640]).await; assert_eq!( @@ -768,6 +960,50 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// While the WebP copies are off, as they are until somebody turns them + /// on, a picture is prepared all the same — its placeholder worked out, + /// and told to the pages — and no copy is made of it. + #[tokio::test] + async fn a_picture_is_prepared_without_a_copy_while_the_webp_copies_are_off() { + let dir = crate::testing::scratch_path("variants-off"); + let state = server(&dir).await; + let store = state.media.store().unwrap().clone(); + let origins = kept_as_before(&state, &[660]).await; + let asset = repo::asset::unprepared(&state.db, variants::PREPARED, "", 1) + .await + .unwrap() + .remove(0); + assert_eq!( + prepare_one(&state, &store, &asset).await.outcome, + Outcome::Written + ); + + let asset = repo::asset::by_origin(&state.db, &origins[0]) + .await + .unwrap() + .unwrap(); + assert!(asset.color.is_some() && asset.thumbhash.is_some()); + assert_eq!( + repo::asset::unprepared_count(&state.db, variants::PREPARED) + .await + .unwrap(), + 0, + "prepared, and not to be prepared again" + ); + let key = asset.key.unwrap(); + let stem = file::stem_of(&key); + assert!( + repo::asset::variants_of(&state.db, stem) + .await + .unwrap() + .is_empty() + ); + assert!(!store.exists(&format!("{stem}-480.webp")).await.unwrap()); + assert!(state.media.keyed(&key).unwrap().look.thumbhash.is_some()); + + let _ = std::fs::remove_dir_all(&dir); + } + /// The night's AVIF copies of a poster are made once — at 480 and 960, /// and at its own width where it is narrower: a small one here, as an /// unoptimised AVIF encoder takes seconds over a 480 — and offered from diff --git a/src/media/serve.rs b/src/media/serve.rs index 7af94b7..813beef 100644 --- a/src/media/serve.rs +++ b/src/media/serve.rs @@ -10,7 +10,10 @@ //! //! A copy of a picture — `-480.webp`, `-square.jpg` — is made //! the first time it is asked for and kept: see [`super::variants`]. An -//! AVIF is the night's to make, never a request's. +//! AVIF is the night's to make, never a request's. A WebP is made only +//! while `media.webp` is on: off, one kept already is served, and a page +//! that asks for one that is not is sent to the picture itself — for this +//! time only, as they may be turned on again. use std::{ops::Range, sync::LazyLock, time::Duration}; @@ -255,7 +258,8 @@ async fn copy( return not_modified(&etag); } // Sent to the bucket itself, when that is how it is set and the copy - // is there already; one that is not is made, and answered from here. + // is there already; one that is not is made — a WebP only while they + // are on — and answered from here. if state.text("media.serve").as_deref() == Some("redirect") && store.exists(&key).await.unwrap_or(false) && let Ok(Some(url)) = store.presign(&key, method.clone(), PRESIGNED_FOR).await @@ -263,6 +267,23 @@ async fn copy( return redirect(url.as_str(), "private, max-age=3000"); } + // The WebP copies off: one kept already is served, and none is made — + // the page is sent to the picture itself, and asks again next time. + if format == Format::Webp && !state.flag("media.webp", false) { + return match store.read(&key).await { + Ok(Some(bytes)) => whole(method, format_type(format), &key, &etag, bytes, IMMUTABLE), + Ok(None) => redirect(&state.media.url_for(&original), "no-cache"), + Err(e) => { + tracing::warn!( + key, + error = format_args!("{e:#}"), + "could not read a medium" + ); + redirect(&state.media.url_for(&original), "no-cache") + } + }; + } + match kept_or_made(state, store, &original, asked.stem, made, format, &key).await { Ok(bytes) => whole(method, format_type(format), &key, &etag, bytes, IMMUTABLE), Err(e) => { @@ -279,8 +300,9 @@ async fn copy( /// An AVIF copy: the night's task makes them, and a page is offered one /// only where it exists. One whose file has gone — a store moved, a restore -/// onto a new machine — is answered with the WebP of the same width, not -/// kept for long, and forgotten, so that the next night makes it again. +/// onto a new machine — is answered with the WebP of the same width, or, +/// while the WebP copies are off, with the picture itself; not kept for +/// long, and forgotten, so that the next night makes it again. #[allow(clippy::too_many_arguments)] async fn avif( state: &AppState, @@ -316,6 +338,9 @@ async fn avif( tracing::debug!(key, error = %e, "could not forget a copy whose file is gone"); } } + if !state.flag("media.webp", false) { + return redirect(&state.media.url_for(original), "no-cache"); + } let webp = file::made_key(stem, made, Format::Webp.ext()); match kept_or_made(state, store, original, stem, made, Format::Webp, &webp).await { Ok(bytes) => whole( @@ -720,4 +745,191 @@ mod tests { assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE); assert_eq!(response.headers()[header::CONTENT_RANGE], "bytes */1000"); } + + /// The whole server, on a database of its own, keeping its media in + /// `dir`. + async fn server(dir: &std::path::Path) -> AppState { + let mut config = crate::config::Config::from_env().expect("a configuration"); + config.mode = crate::config::Mode::Single; + config.database = crate::db::testing::database().await; + config.security.bootstrap_admin = None; + config.cache.redis_url = None; + config.media.storage = crate::config::MediaStorage::Filesystem; + config.media.dir = dir.to_path_buf(); + config.clients = None; + config.tmdb.api_key = None; + config.tvdb.api_key = None; + config.tvdb.enabled = false; + AppState::bootstrap(config).await.expect("a server") + } + + /// A poster kept, as the worker keeps one — in the store and in the + /// index — and no copy of it yet: its key and its hash. `shade` makes + /// it a picture of its own, whose copies no other test makes. + async fn kept(state: &AppState, shade: u8) -> (String, String) { + let mut bytes = Vec::new(); + image::RgbImage::from_fn(600, 900, |x, y| { + image::Rgb([(x % 256) as u8, (y % 256) as u8, shade]) + }) + .write_to( + &mut std::io::Cursor::new(&mut bytes), + image::ImageFormat::Png, + ) + .unwrap(); + let (key, sha) = file::key_for(&bytes, "png"); + state + .media + .store() + .unwrap() + .put(&key, Bytes::from(bytes), "image/png") + .await + .unwrap(); + state.media.remember( + &format!("https://images.example/{sha}.png"), + &key, + repo::asset::Thumb::None, + "image/png", + ); + (key, sha) + } + + async fn asked(state: &AppState, key: &str) -> Response { + get( + State(state.clone()), + Method::GET, + Path(key.to_string()), + HeaderMap::new(), + ) + .await + } + + async fn webp_copies(state: &AppState, sha: &str) -> Vec<(String, i32)> { + repo::asset::variants_of(&state.db, sha) + .await + .unwrap() + .into_iter() + .filter(|copy| copy.format == "webp") + .map(|copy| (copy.key, copy.width)) + .collect() + } + + async fn turn(state: &AppState, key: &str, on: bool) { + state + .settings + .set( + crate::settings::Scope::Server, + "", + key, + if on { "true" } else { "false" }, + None, + ) + .await + .unwrap(); + } + + /// While the WebP copies are off — as they are until somebody turns + /// them on — a page that asks for one is sent to the picture itself, + /// and asks again next time: nothing is made, and nothing noted. On, + /// one is made, kept and noted as it is asked for; off again, the copy + /// kept is still served, and no other is made. + #[tokio::test] + async fn a_webp_copy_is_made_only_while_they_are_on() { + let dir = crate::testing::scratch_path("serve-webp"); + let state = server(&dir).await; + let store = state.media.store().unwrap().clone(); + let (key, sha) = kept(&state, 31).await; + let copy = format!("{sha}-480.webp"); + + let answer = asked(&state, ©).await; + assert_eq!(answer.status(), StatusCode::FOUND); + assert_eq!(answer.headers()[header::LOCATION], format!("/media/{key}")); + assert_eq!(answer.headers()[header::CACHE_CONTROL], "no-cache"); + assert!(!store.exists(©).await.unwrap(), "nothing made"); + assert!(webp_copies(&state, &sha).await.is_empty(), "nothing noted"); + // A width no page asks for is refused all the same. + let refused = asked(&state, &format!("{sha}-481.webp")).await; + assert_eq!(refused.status(), StatusCode::NOT_FOUND); + + turn(&state, "media.webp", true).await; + let answer = asked(&state, ©).await; + assert_eq!(answer.status(), StatusCode::OK); + assert_eq!(answer.headers()[header::CONTENT_TYPE], "image/webp"); + assert_eq!(answer.headers()[header::CACHE_CONTROL], IMMUTABLE); + assert!(store.exists(©).await.unwrap(), "made and kept"); + assert_eq!(webp_copies(&state, &sha).await, [(copy.clone(), 480)]); + + turn(&state, "media.webp", false).await; + let answer = asked(&state, ©).await; + assert_eq!(answer.status(), StatusCode::OK, "the copy kept is served"); + assert_eq!(answer.headers()[header::CONTENT_TYPE], "image/webp"); + let other = format!("{sha}-240.webp"); + let answer = asked(&state, &other).await; + assert_eq!(answer.status(), StatusCode::FOUND); + assert_eq!(answer.headers()[header::LOCATION], format!("/media/{key}")); + assert!(!store.exists(&other).await.unwrap()); + assert_eq!(webp_copies(&state, &sha).await.len(), 1); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// An AVIF of 480 of the picture of `sha`, noted and offered, whose + /// file is not there: a store moved, say. Its key. + async fn avif_without_its_file(state: &AppState, sha: &str) -> String { + let avif = format!("{sha}-480.avif"); + repo::asset::add_variant( + &state.db, + &repo::asset::Variant { + key: avif.clone(), + stem: sha.to_string(), + width: 480, + height: 720, + format: "avif".into(), + bytes: 10, + }, + ) + .await + .unwrap(); + state.media.add_avif(sha, 480); + avif + } + + /// An AVIF whose file has gone is forgotten, and answered — while the + /// WebP copies are off — with the picture itself, not with a WebP made + /// for it; while they are on, with the WebP of its width, as before. + #[tokio::test] + async fn an_avif_gone_is_answered_with_the_picture_while_the_webp_copies_are_off() { + let dir = crate::testing::scratch_path("serve-avif"); + let state = server(&dir).await; + let store = state.media.store().unwrap().clone(); + let (key, sha) = kept(&state, 47).await; + + let avif = avif_without_its_file(&state, &sha).await; + assert_eq!(state.media.keyed(&key).unwrap().look.avif, [480]); + let answer = asked(&state, &avif).await; + assert_eq!(answer.status(), StatusCode::FOUND); + assert_eq!(answer.headers()[header::LOCATION], format!("/media/{key}")); + assert_eq!(answer.headers()[header::CACHE_CONTROL], "no-cache"); + assert!(state.media.keyed(&key).unwrap().look.avif.is_empty()); + assert!( + repo::asset::variants_of(&state.db, &sha) + .await + .unwrap() + .is_empty(), + "forgotten, and no WebP made for it" + ); + assert!(!store.exists(&format!("{sha}-480.webp")).await.unwrap()); + + turn(&state, "media.webp", true).await; + let avif = avif_without_its_file(&state, &sha).await; + let answer = asked(&state, &avif).await; + assert_eq!(answer.status(), StatusCode::OK); + assert_eq!(answer.headers()[header::CONTENT_TYPE], "image/webp"); + assert_eq!(answer.headers()[header::CACHE_CONTROL], "no-cache"); + assert_eq!( + webp_copies(&state, &sha).await, + [(format!("{sha}-480.webp"), 480)] + ); + + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/src/media/variants.rs b/src/media/variants.rs index 66a7bd9..5de20ca 100644 --- a/src/media/variants.rs +++ b/src/media/variants.rs @@ -9,6 +9,11 @@ //! one the interface asks for first — a poster's 480, a background's 1280 — //! as the picture is stored. A picture is never made larger than it is: a //! width past its own is its own width. +//! +//! The WebP copies are made only while `media.webp` is on: off, as it is +//! until somebody sets it, the pages are given the pictures themselves, and +//! no WebP is made — not as a picture is stored, not when one is asked for. +//! The square Plex is given is made whatever it says. use std::{ panic::AssertUnwindSafe, @@ -50,9 +55,9 @@ pub const AVIF_BACKDROP_WIDTHS: [u32; 2] = [1280, 1920]; pub const SQUARE_SIDE: u32 = 1080; /// What has been done to a picture as it was stored, by this version: its -/// placeholder worked out, and the copy its kind is first asked for made. -/// A picture prepared by an earlier version — or by none — is the task -/// `media.variants`' to prepare. +/// placeholder worked out, and — while the WebP copies are on — the copy +/// its kind is first asked for made. A picture prepared by an earlier +/// version — or by none — is the task `media.variants`' to prepare. pub const PREPARED: i64 = 1; /// The copy made of a picture as it is stored, by what the work it was @@ -353,23 +358,24 @@ pub fn make( } } -/// What preparing a picture as it is stored came to: its placeholder, the -/// copy its kind is first asked for, and its size as decoded. +/// What preparing a picture as it is stored came to: its placeholder, and +/// the copy its kind is first asked for, when one was made. pub struct Prepared { pub placeholder: Option, pub copies: Vec, } -/// Prepare a picture: its placeholder and, where its kind has one, its -/// first copy — the picture decoded once for both. `cover` is what the -/// picture is to its work, where that is known. -pub fn prepare(bytes: &[u8], cover: Option) -> Result { +/// Prepare a picture: its placeholder and, where its kind has one and +/// `webp` says the WebP copies are on (`media.webp`), its first copy — the +/// picture decoded once for both. `cover` is what the picture is to its +/// work, where that is known. +pub fn prepare(bytes: &[u8], cover: Option, webp: bool) -> Result { let decoded = Decoded::read(bytes)?; let placeholder = super::placeholder::of(&decoded) .inspect_err(|e| tracing::debug!(error = format_args!("{e:#}"), "no placeholder")) .ok(); let mut copies = Vec::new(); - if let Some(width) = eager_width(cover) { + if webp && let Some(width) = eager_width(cover) { copies.push(make(&decoded, Made::Width(width), Format::Webp, cover)?); } Ok(Prepared { @@ -582,22 +588,38 @@ mod tests { #[test] fn a_picture_is_prepared_with_the_copy_its_kind_is_first_asked_for() { let poster = png(1000, 1500); - let prepared = prepare(&poster, Some(CoverType::Poster)).unwrap(); + let prepared = prepare(&poster, Some(CoverType::Poster), true).unwrap(); assert!(prepared.placeholder.is_some()); assert_eq!(prepared.copies.len(), 1); assert_eq!( (prepared.copies[0].width, prepared.copies[0].height), (480, 720) ); + assert_eq!(prepared.copies[0].format, Format::Webp); let backdrop = png(1600, 900); - let prepared = prepare(&backdrop, Some(CoverType::Fanart)).unwrap(); + let prepared = prepare(&backdrop, Some(CoverType::Fanart), true).unwrap(); assert_eq!(prepared.copies[0].width, 1280); - let still = prepare(&png(640, 360), Some(CoverType::Screenshot)).unwrap(); + let still = prepare(&png(640, 360), Some(CoverType::Screenshot), true).unwrap(); assert!(still.copies.is_empty() && still.placeholder.is_some()); - assert!(prepare(b"not a picture", Some(CoverType::Poster)).is_err()); + assert!(prepare(b"not a picture", Some(CoverType::Poster), true).is_err()); + } + + /// With the WebP copies off, a picture is prepared all the same — its + /// placeholder worked out — and no copy is made of it. + #[test] + fn a_picture_is_prepared_without_a_copy_while_the_webp_copies_are_off() { + for (picture, cover) in [ + (png(1000, 1500), CoverType::Poster), + (png(1600, 900), CoverType::Fanart), + ] { + let prepared = prepare(&picture, Some(cover), false).unwrap(); + assert!(prepared.placeholder.is_some(), "{cover:?}"); + assert!(prepared.copies.is_empty(), "{cover:?}"); + } + assert!(prepare(b"not a picture", Some(CoverType::Poster), false).is_err()); } #[tokio::test] diff --git a/src/media/worker.rs b/src/media/worker.rs index 544421e..e5895bf 100644 --- a/src/media/worker.rs +++ b/src/media/worker.rs @@ -287,7 +287,16 @@ async fn store_one(state: &AppState, asset: &Asset) -> Result<(), Trouble> { }), Kind::Audio => None, }; - let kept = keep(store, key, sha256, fetched.bytes, &fetched.inspected, cover).await?; + let kept = keep( + store, + key, + sha256, + fetched.bytes, + &fetched.inspected, + cover, + state.flag("media.webp", false), + ) + .await?; // Forgotten meanwhile, it is not remembered either: the bytes are the // next sweep's to remove. if repo::asset::mark_stored(&state.db, &asset.id, &kept.as_row()).await? { @@ -365,11 +374,12 @@ impl Kept { } /// Put bytes in the store under their key — `file::key_for`'s — and, for a -/// picture, work out its placeholder and put beside it the copy its kind is -/// first asked for: a poster's 480, a background's 1280, by `cover`. Put -/// every time, though the same picture from two providers may be there -/// already: the same key is the same bytes, and a put is what tells a sweep -/// that looked a moment ago that they are wanted. +/// picture, work out its placeholder and, while the WebP copies are on +/// (`webp`, as `media.webp` says), put beside it the copy its kind is first +/// asked for: a poster's 480, a background's 1280, by `cover`. Put every +/// time, though the same picture from two providers may be there already: +/// the same key is the same bytes, and a put is what tells a sweep that +/// looked a moment ago that they are wanted. /// /// A picture the decoders here cannot read — or panic on, which is caught — /// is kept all the same, and served whole. @@ -380,6 +390,7 @@ pub async fn keep( bytes: Bytes, inspected: &file::Inspected, cover: Option, + webp: bool, ) -> Result { let size = i64::try_from(bytes.len()).unwrap_or(i64::MAX); store @@ -405,7 +416,7 @@ pub async fn keep( } let prepared = - super::variants::off_thread(move || super::variants::prepare(&bytes, cover)).await; + super::variants::off_thread(move || super::variants::prepare(&bytes, cover, webp)).await; // Done either way: a picture that cannot be read now never will be. kept.prepared = super::variants::PREPARED; match prepared { diff --git a/src/settings/reference.rs b/src/settings/reference.rs index d6cb4b7..192e56d 100644 --- a/src/settings/reference.rs +++ b/src/settings/reference.rs @@ -585,13 +585,22 @@ const GROUPS: &[Group] = &[ "media.audio", "The themes: a Fan-Kai's, a few megabytes each.", ), + set( + "media.webp", + "Make the WebP copies the pages ask for, at the widths they ask for them — the \ + first as a picture is stored, any other the first time a page asks — and give \ + the pages those rather than the pictures themselves. Off until it is set, and \ + the pages are given the originals, whole; the copies made while it was on are \ + kept until an administrator deletes them. See \ + [The copies the pages are given](media.md#the-copies-the-pages-are-given).", + ), set( "media.avif", "Make an AVIF copy of every poster, at 480 and 960 pixels, and of every \ - background, at 1280, each night at 03:00 UTC — the task `media.avif`, also run \ - by hand from **Tasks**. A third lighter than the WebP the interface is given \ - otherwise, and a second or two of a processor each to make: off until it is \ - set. See [AVIF](media.md#avif).", + background, at 1280 and 1920, each night at 03:00 UTC — the task `media.avif`, also run \ + by hand from **Tasks**. A third lighter than a WebP copy of the same width, and \ + a second or two of a processor each to make: off until it is set. See \ + [AVIF](media.md#avif).", ), ], }, diff --git a/src/settings/registry.rs b/src/settings/registry.rs index 367f146..5860977 100644 --- a/src/settings/registry.rs +++ b/src/settings/registry.rs @@ -496,6 +496,14 @@ pub const REGISTRY: &[Definition] = &[ kind: Kind::Bool, scopes: SERVER_ONLY, }, + Definition { + // The WebP copies of the pictures, made at the widths the pages ask + // for and given to them in the pictures' place: off until somebody + // turns them on, the pages being given the pictures themselves. + key: "media.webp", + kind: Kind::Bool, + scopes: SERVER_ONLY, + }, Definition { // The night's AVIF copies of the posters and the backgrounds: off // until somebody turns them on, as they cost seconds of a processor From 5e23c3a9b96939235f1eba8632d7264bec241f0b Mon Sep 17 00:00:00 2001 From: dim145 Date: Mon, 26 Oct 2026 13:29:23 +0100 Subject: [PATCH 2/4] Hand Sonarr and Radarr a number of their own for the works entered by hand, take the programmer's note button off the front page, and show a visitor at once after signing out A work entered by hand that TheTVDB, TMDB and Fankai do not name was sent to Sonarr and Radarr without an id they could add it by: Sonarr's searches left it out, and Radarr was given a film of TmdbId 0. It is now handed a number from 1,000,000,000 to 2,147,483,647, worked out from its catalogue id so that it never changes: Sonarr's searches (v1 and v2) list it under it, a lookup by it is answered from the catalogue alone, switched off or merged into another, without asking a source; Radarr takes it as the film's TmdbId, and the changed films, the refresh push, the scene mapping and the seeding from their libraries know it by it. Should two catalogue ids make one number, the work entered first keeps it and the other is left out of the searches; a work entered by hand now is given a catalogue id whose number no other holds. Fankai's range ends below it, at 999,999,999. Radarr's search leaves out the films it could not add, and a tvdb: term resolves every id the server hands out. Plex was given such a work whole already: docs/plex.md says why an agent that also lists Plex's own provider lays a guess by title over it, and what to do. The front page no longer offers whoever may edit a programmer's note to write under the work of the day; a written one is still shown there, and the administration's page and the other pages still offer one. Signing out, or changing one's password, resets every answer the account was given and tells whoever reads them (endSession), rather than emptying the cache without a word: the bar shows a visitor at once instead of Admin until a reload. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 19 + docs/editing.md | 8 + docs/plex.md | 33 +- docs/sonarr-radarr.md | 48 ++- frontend/e2e/editorial.serial.spec.ts | 35 +- frontend/e2e/pages.spec.ts | 7 +- frontend/e2e/sign-out.spec.ts | 82 ++++ frontend/openapi.json | 7 +- frontend/src/components/ProgrammersNote.tsx | 12 +- frontend/src/components/SignOut.tsx | 11 +- .../src/components/account/ChangePassword.tsx | 5 +- frontend/src/lib/adultPosters.ts | 29 +- frontend/src/lib/api.gen.ts | 17 +- frontend/src/lib/session.test.ts | 113 +++++ frontend/src/lib/session.ts | 31 ++ frontend/src/routes/Home.tsx | 6 +- src/api/by_hand_tests.rs | 393 ++++++++++++++++++ src/api/mod.rs | 2 + src/api/native/items.rs | 20 + src/api/radarr.rs | 32 +- src/api/sonarr.rs | 20 +- src/arr/push.rs | 45 +- src/arr/seed.rs | 69 +++ src/db/repo/item/read.rs | 39 ++ src/db/repo/scene.rs | 10 +- src/service/ids.rs | 138 +++++- src/service/merge.rs | 27 +- src/service/mod.rs | 224 ++++++++++ src/service/movie.rs | 37 +- src/service/scene.rs | 44 +- src/service/series.rs | 55 ++- src/wire/plex.rs | 6 +- src/wire/sonarr_v2.rs | 7 +- 33 files changed, 1533 insertions(+), 98 deletions(-) create mode 100644 frontend/e2e/sign-out.spec.ts create mode 100644 frontend/src/lib/session.test.ts create mode 100644 frontend/src/lib/session.ts create mode 100644 src/api/by_hand_tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index f5e3539..f91c0bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,25 @@ may change what the API or the configuration means, and says so here. pages turns them on, on the Media page. `media.variants` prepares the placeholders, and the first copies only while the WebP copies are on; `GET /api/v1/auth/options` says whether they are (`webp`). +- The front page no longer offers an administrator to write a programmer's + note there: the administration's page writes it, as do the pages of a + selection, a genre, a network, a year and a decade. +- Radarr's search leaves out the films it could not add, those without a + TMDB id that were not entered by hand. A `tvdb:` term from Sonarr resolves + every id this server gives, those made of a TMDB id or a Fankai one too. + +### Fixed + +- A work entered by hand reaches Sonarr and Radarr. It is given a number of + its own, from 1,000,000,000 up, worked out from its catalogue id so that it + never changes: Sonarr's searches list it under that number and its lookups + answer it, Radarr takes it as the film's `TmdbId`, and the change + notifications and the seeding find it by it. Fankai's numbers end at + 999,999,999. Plex was given such a work whole already: an agent that also + lists Plex's own provider lets Plex guess it by its title, which + [docs/plex.md](docs/plex.md) explains. +- Signing out, or changing one's password, shows a visitor at once: the bar + kept the account's *Admin* until the page was reloaded. ## [0.10.0] — 2026-10-25 diff --git a/docs/editing.md b/docs/editing.md index 13f84f0..848b062 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -450,6 +450,14 @@ edited. Its identifiers are what Sonarr and Radarr find it by — TheTVDB's for for a film — and where its links lead. An identifier that another entry already claims is refused with a `409`. +Without those identifiers it is found all the same. Sonarr and Radarr find it by its title +and keep it under a number this server makes of its id here, the same for good — see +[Works entered by hand](sonarr-radarr.md#works-entered-by-hand). Plex matches it by its title +and year, under the catalogue's own id; Plex's own provider, when the agent lists it beside +this one, can only guess at it by its title, and lays what it guessed over it — see +[A work entered by hand](plex.md#a-work-entered-by-hand). An identifier the work does have +elsewhere is worth giving it: Plex's provider is then given the work itself. + ## Duplicates Two records of one work turn up in a catalogue now and then: a film entered by hand before a diff --git a/docs/plex.md b/docs/plex.md index 01cff67..ef6649d 100644 --- a/docs/plex.md +++ b/docs/plex.md @@ -129,7 +129,8 @@ were fetched, JPEG and PNG, never the WebP copies this server's own pages are gi A rating key names a work by the identifier it is best known by, so that it survives the work being fetched again, or a new database: a film by TMDB's id, then IMDb's; a series by TheTVDB's, then TMDB's, then Fankai's; either by the -catalogue's own id when it has none of those, as an entry made by hand may. +catalogue's own id when it has none of those, as an entry made by hand may (see +[A work entered by hand](#a-work-entered-by-hand)). ``` movie-tmdb-603 show-tvdb-81189 @@ -172,6 +173,36 @@ not see. A season is matched by its series and its number; an episode by its series and its season and number, or by the day it aired. +### A work entered by hand + +A work [entered by hand](editing.md#entering-a-work-by-hand) is matched by its +title and year like any work the catalogue holds and, given none of the ids a +[rating key](#rating-keys) is made of, keyed by the catalogue's own id: +`show-id-01a17789-…`, `movie-id-…`, and its seasons' and episodes' keys after +it. Plex Media Server takes them like any other — on 1.43.5, matched when the +library is scanned and through *Fix Match*, with its record, its seasons and +episodes and its pictures. Its `Guid` lists the ids it does have elsewhere, and +nothing when it has none. + +With nothing to map it by, Plex's own provider cannot know it. An agent that +lists *Plex Series* or *Plex Movie* beside this provider — Plex suggests it for +the cast — has Plex look such a work up in its own catalogue by title and year +alone, and take what it finds: in one test a series entered by hand came back as +a talk show of a title close to its own, whose cast, studio, country, poster and +background were laid over it — and its title too, with Plex's provider listed +first. The item is linked to the work guessed, and an app that follows the link +asks Plex Media Server for a work its library does not hold: its log then says +`Invalid library metadata ID <24 hexadecimal digits> passed.`, the id being +Plex's own for the guess, never one this server hands out. Nothing in this +server's answer stops the guess — Plex maps +another provider's work by IMDb's, TMDB's and TheTVDB's ids alone, and a work +entered by hand has none of them to give. + +So give the work the ids it has elsewhere when it has some, and Plex's provider +is given that very work; or keep Plex's own provider out of the agent of a +library that holds works entered by hand. Once it is out, a refresh of the item +in Plex puts this provider's record back. + ## What Plex is given - **Language.** `X-Plex-Language` (a header, else a query parameter), then the diff --git a/docs/sonarr-radarr.md b/docs/sonarr-radarr.md index 2e85b38..9dec471 100644 --- a/docs/sonarr-radarr.md +++ b/docs/sonarr-radarr.md @@ -49,7 +49,8 @@ reading: the same series, with what v5 reads besides. use (`tvdbId` and `metadataId`). - **Who numbers it.** `metadataSource` is `tvdb`, or `tmdb` for a series TMDB numbers — one TheTVDB does not list, or one it stopped answering for (see - [Who numbers a series](#who-numbers-a-series)) — or `fankai`. + [Who numbers a series](#who-numbers-a-series)) — or `fankai`, or `local` for a + series [entered by hand](#works-entered-by-hand) that nothing elsewhere names. The language is three letters, as Sonarr writes it (`eng`, `fra`). The series is answered in it when the catalogue holds it so; otherwise in the caller's own @@ -133,6 +134,43 @@ Radarr is sent, on `/v1/movie/{id}`, `/v1/movie/imdb/{id}` and `/v1/movie/bulk`: `/v1/movie/changed` names the films changed here too — fetched again, a lock put on, changed or lifted — with or without TMDB. +### Works entered by hand + +A work [entered by hand](editing.md#entering-a-work-by-hand) is found by the ids +it was given elsewhere, as any other: TheTVDB's for a series — or a number made +of TMDB's or Fankai's, as for a series TheTVDB does not list — and TMDB's for a +film. One given none of them is handed an id of this server's own, and Sonarr +and Radarr find it by its title, add it and refresh it like any other work: + +- **The id** is a number from 1 000 000 000 to 2 147 483 647, made of the work's + id in this catalogue: the same at every answer, after a restart, on every + instance, whatever is edited on the work — and above every number TheTVDB's + series, TMDB's films and series, and this server's other ranges fill. Sonarr + keeps it as the series' TheTVDB id, Radarr as the film's TMDB id. Both refuse + an id of nought or below for a work they add, which is why it is not negative + as an episode's own is (see [Orders](#orders)). `tvdb:` or `tmdb:` and the + number find the work too. +- **Asked for by it**, on either edition of Sonarr's surface and on Radarr's + (`/v1/movie/{id}`, `/v1/movie/bulk`), the work is answered from the catalogue + alone, without asking a source, which none has: switched off or not, and + merged into another or not — then with the work it went into, as + [After a merge](#after-a-merge) says. A number whose work was deleted is a + `404` at once, as nothing will answer for it again; Sonarr and Radarr then take + it for deleted. +- Sonarr v5 is sent `metadataSource` `local`. A film entered by hand is named + under it among the films changed (`/v1/movie/changed`) once one of its fields + is edited, as any film is once a lock is put on it, so that Radarr's scheduled + refresh reads it again; with [Tell them of a change](#telling-them-of-a-change) + on, either is told under this number. +- Given an id elsewhere later — its TheTVDB entry, say — the work is handed that + one from then on, and still answered by the number to the client that added it + by it. + +Two works of one kind entered by hand could make one number: the one entered +first keeps it, and the other is left out of the searches rather than offered +under a number that would add the first. A work entered by hand now is given an +id here whose number no other holds. + ## Connecting your Sonarr and Radarr Open **Sonarr & Radarr** in the administration (`/admin/arr`; administrators @@ -194,7 +232,8 @@ is asked to read the work again at once, after: client has the answer already. The work is found there by the id it is served under — TheTVDB's, or the synthetic -id that a TMDB-only series or a Fan-Kai is handed, for Sonarr; TMDB's for Radarr — +id that a TMDB-only series or a Fan-Kai is handed, for Sonarr; TMDB's for Radarr; +the number a [work entered by hand](#works-entered-by-hand) is handed, for either — through `/api/v3/series?tvdbId=` or `/api/v3/movie?tmdbId=`. One command then asks for every work found, up to a hundred at a time: `RefreshSeries` with `seriesIds`, `RefreshMovie` with `movieIds` (`POST /api/v3/command`). @@ -242,6 +281,11 @@ imports what is missing the way the import page does: or one this server made for a TMDB-only series or a Fan-Kai — then by IMDb's; - from Radarr (`/api/v3/movie`): by TMDB's id, then by IMDb's. +A work held there under the number a [work entered by hand](#works-entered-by-hand) +is handed is looked for in this catalogue alone, since no source has it: *already +held here*, or *held there under no id to look it up by* once the catalogue no +longer holds it. + It starts by hand, from the panel *Fill the catalogue from their library* of the Sonarr & Radarr page or from the tasks on the **Jobs** page (`/admin/jobs`), and only while one of the two is connected. It imports one work after another, with half a second between two diff --git a/frontend/e2e/editorial.serial.spec.ts b/frontend/e2e/editorial.serial.spec.ts index 8e75abe..045430c 100644 --- a/frontend/e2e/editorial.serial.spec.ts +++ b/frontend/e2e/editorial.serial.spec.ts @@ -5,7 +5,8 @@ import { expect, test, type Page } from '@playwright/test' * The programmer's note: written in place by whoever maintains the catalogue, * in English and in French; read by a visitor in theirs, the other standing * in until it is written; listed in the administration, written again and - * taken away there. + * taken away there. The front page offers none to write: a note on its work + * of the day is begun in the administration, and read under the plate. * * Serial: a note belongs to the catalogue, and every page of the genre reads * it — the other specs' genre pages among them. Each test takes its notes @@ -129,4 +130,36 @@ test.describe('the programmer’s note', () => { await page.getByRole('dialog').getByRole('button', { name: 'Take it away' }).click() await expect(page.getByText('The last year of the century.')).toHaveCount(0) }) + + test('is not offered on the front page, and one begun in the administration is read there', async ({ page }) => { + await page.addInitScript(() => localStorage.setItem('ams.lang', 'en')) + await signIn(page) + + // The work of the day, by the way to its page; who is looking is known by + // then — the bar offers the administration — and nothing under the plate + // offers a note to write. + await page.goto('/') + const open = page.getByRole('link', { name: 'See the details' }) + await expect(open).toBeVisible() + await expect(page.getByRole('banner').getByRole('link', { name: 'Admin', exact: true })).toBeVisible() + await expect(page.getByRole('button', { name: 'Write a programmer’s note' })).toHaveCount(0) + const id = (await open.getAttribute('href'))?.replace(/^\/work\//, '') + expect(id).toBeTruthy() + + try { + await page.goto('/admin/editorial') + await page.getByRole('button', { name: 'Write a note' }).click() + const form = page.locator('form').filter({ has: page.getByRole('button', { name: 'Save the note' }) }) + await form.getByLabel('About').selectOption('work') + await form.getByLabel('Which one').fill(id!) + await form.getByLabel('The note', { exact: true }).fill('Why this one, *today*.') + await form.getByRole('button', { name: 'Save the note' }).click() + await expect(page.getByText('Why this one, today.')).toBeVisible() + + await page.goto('/') + await expect(page.getByRole('complementary', { name: 'Programmer’s note' }).locator('em')).toHaveText('today') + } finally { + await page.request.delete(`/api/v1/editorial/work/${encodeURIComponent(id!)}/en`) + } + }) }) diff --git a/frontend/e2e/pages.spec.ts b/frontend/e2e/pages.spec.ts index 799c0dc..70945ad 100644 --- a/frontend/e2e/pages.spec.ts +++ b/frontend/e2e/pages.spec.ts @@ -542,8 +542,13 @@ test.describe('related works', () => { await page.goto(`/work/${related!.id}`) await expect(page.getByText(/^(related works|œuvres liées)$/i)).toBeVisible() + // Looked for on the shelf of related works alone: an anime and the manga + // it was drawn from often share a title, and the work's own seasons, + // higher on the page, carry it too. The shelf keeps the order the work + // gives, so the first card of that name on it is the first relation's. const first = related!.relations[0]! - const card = page.getByRole('link', { name: first.title.slice(0, 24) }).first() + const shelf = page.getByRole('list', { name: /^(related works|œuvres liées)$/i }) + const card = shelf.getByRole('link', { name: first.title.slice(0, 24) }).first() await expect(card).toBeVisible() if (first.workId) { await expect(card).toHaveAttribute('href', `/work/${first.workId}`) diff --git a/frontend/e2e/sign-out.spec.ts b/frontend/e2e/sign-out.spec.ts new file mode 100644 index 0000000..c7243fd --- /dev/null +++ b/frontend/e2e/sign-out.spec.ts @@ -0,0 +1,82 @@ +import { expect, test, type Page } from '@playwright/test' + +/** + * Signing out of the catalogue's bar, and back in, with the page never loaded + * again: the bar says who is here at once — a visitor's way in the moment the + * session is over, the administrator's way to the administration once they + * are back. + * + * The cache used to be emptied on the way out without a word to whoever read + * it, and the bar, which is told who is looking from above every page, kept + * "Admin" and the way out until a reload (`endSession`). + * + * Signing out ends this page's own session and no other: every spec signs in + * on its own. + */ + +const USERNAME = process.env.AMS_E2E_USER +const PASSWORD = process.env.AMS_E2E_PASSWORD + +/** Through the sign-in page the browser is on, to the administration. */ +async function signIn(page: Page) { + await page.getByLabel(/username|identifiant/i).fill(USERNAME!) + await page.getByLabel(/password|mot de passe/i).fill(PASSWORD!) + await page.getByRole('button', { name: /sign in|se connecter/i }).click() + await page.waitForURL('**/admin') +} + +/** Whether this is still the page that was marked: one loaded again has lost the mark. */ +function sameVisit(page: Page) { + return page.evaluate(() => (window as Window & { sameVisit?: boolean }).sameVisit === true) +} + +test.describe('signing out', () => { + test.skip(!USERNAME || !PASSWORD, 'needs AMS_E2E_USER and AMS_E2E_PASSWORD') + + test('the bar offers a visitor’s way in at once, and the administration again once signed back in', async ({ + page, + isMobile, + }) => { + test.skip(isMobile, 'the bar holds the way out from `md` up; on a phone it is in the sheet along the foot') + await page.goto('/login') + await signIn(page) + + await page.goto('/') + const bar = page.getByRole('banner') + const admin = bar.getByRole('link', { name: /^(admin|administration)$/i }) + const wayIn = bar.getByRole('link', { name: /^(sign in|se connecter)$/i }) + const wayOut = bar.getByRole('button', { name: /^(sign out|se déconnecter)$/i }) + await expect(admin).toBeVisible() + await expect(wayOut).toBeVisible() + await page.evaluate(() => { + ;(window as Window & { sameVisit?: boolean }).sameVisit = true + }) + + // A page further on by a tab, so that there is one to come back to: the + // sign-in page's own ways back to the catalogue load the page anew. + await bar.getByRole('link', { name: /^(schedule|programme)$/i }).click() + await page.waitForURL('**/calendar') + + await wayOut.click() + await page.waitForURL('**/login') + await expect(page.getByRole('heading', { level: 1, name: /^(sign in|connexion)$/i })).toBeVisible() + await page.goBack() + await page.waitForURL((url) => url.pathname === '/') + await expect(wayIn).toBeVisible() + await expect(admin).toHaveCount(0) + await expect(wayOut).toHaveCount(0) + expect(await sameVisit(page)).toBe(true) + + // In again from the bar, and back to the catalogue by the administration's + // own way: the administrator's bar, not the visitor's it was just now. + await wayIn.click() + await page.waitForURL('**/login') + await signIn(page) + await page.getByRole('link', { name: /^(back to the catalogue|retour au catalogue)$/i }).click() + await page.waitForURL((url) => url.pathname === '/') + await expect(admin).toBeVisible() + await expect(wayOut).toBeVisible() + await expect(wayIn).toHaveCount(0) + expect(await sameVisit(page)).toBe(true) + }) +}) diff --git a/frontend/openapi.json b/frontend/openapi.json index e6019c8..c79fba4 100644 --- a/frontend/openapi.json +++ b/frontend/openapi.json @@ -9769,12 +9769,13 @@ "Radarr compatibility" ], "summary": "A movie by TMDB id, in Radarr's format.", + "description": "The id may also be the one a film entered by hand that TMDB does not name\nis handed in TMDB's stead, from 1 000 000 000: such a film is answered\nfrom the catalogue alone.", "operationId": "radarr_by_tmdb", "parameters": [ { "name": "tmdb_id", "in": "path", - "description": "TMDB movie id", + "description": "TMDB movie id, or the one a film entered by hand is handed", "required": true, "schema": { "type": "integer", @@ -9929,7 +9930,7 @@ "Sonarr compatibility" ], "summary": "A series by TVDB id, in Skyhook's format.", - "description": "The id may also be one this server synthesised for a work TMDB indexes and\nTVDB does not; the response echoes back whichever id was asked for, because\nSonarr has already stored it.\n\nA series the catalogue holds is answered from the catalogue, as it was,\nwhen the sources do not answer. An id nobody holds is a 404 only when every\nsource that could resolve it answered that there is no such series; when\none did not answer — a timeout, a 5xx, a refused connection — it is a 502,\nnever a 404: Sonarr takes a series for deleted on a 404, and keeps what it\nhas on anything else.", + "description": "The id may also be one this server synthesised for a work TMDB indexes and\nTVDB does not, or for a series entered by hand that nothing elsewhere\nnames; the response echoes back whichever id was asked for, because Sonarr\nhas already stored it.\n\nA series the catalogue holds is answered from the catalogue, as it was,\nwhen the sources do not answer. An id nobody holds is a 404 only when every\nsource that could resolve it answered that there is no such series; when\none did not answer — a timeout, a 5xx, a refused connection — it is a 502,\nnever a 404: Sonarr takes a series for deleted on a 404, and keeps what it\nhas on anything else.", "operationId": "sonarr_show", "parameters": [ { @@ -22302,7 +22303,7 @@ }, "metadataSource": { "type": "string", - "description": "Who numbers the episodes: `tvdb`; `tmdb` for a series TMDB numbers —\none TheTVDB does not list, under an id this server made for it (see\n[`ids`]), or one TheTVDB stopped answering for; `fankai` for a\nFan-Kai." + "description": "Who numbers the episodes: `tvdb`; `tmdb` for a series TMDB numbers —\none TheTVDB does not list, under an id this server made for it (see\n[`ids`]), or one TheTVDB stopped answering for; `fankai` for a\nFan-Kai; `local` for a series entered by hand that nothing elsewhere\nnames." } } }, diff --git a/frontend/src/components/ProgrammersNote.tsx b/frontend/src/components/ProgrammersNote.tsx index c400ccf..b25811e 100644 --- a/frontend/src/components/ProgrammersNote.tsx +++ b/frontend/src/components/ProgrammersNote.tsx @@ -8,7 +8,9 @@ * last; the server says which, and the text carries that language for a * screen reader. Whoever may edit the catalogue writes it where it is read: * the note opens as a field in place, with what it will look like beneath it, - * and is saved in the language of the interface they are using. + * and is saved in the language of the interface they are using. The front + * page offers none to write where none is (`offer`): its work changes every + * day, and a note on it is begun in the administration's page of notes. */ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' @@ -83,11 +85,14 @@ export function ProgrammersNote({ scope, id, note: given, + offer = true, className, }: { scope: EditorialScope id: string note?: EditorialNote | null + /** Whether whoever may edit is offered a note to write where none is written. */ + offer?: boolean className?: string }) { const { t, lang, locale } = useI18n() @@ -117,8 +122,9 @@ export function ProgrammersNote({ } if (!shown) { - // A visitor is shown nothing; whoever may edit is offered a note to write. - return editor ? ( + // A visitor is shown nothing; whoever may edit is offered a note to + // write, where the page offers one. + return editor && offer ? (
) : null @@ -383,6 +463,28 @@ function SessionsPanel({ ) : ( )} + + setAsking(false)} + footer={ + <> + + + + } + > + {t.admin.people.closeOwnBody} + {closeAll.isError ? ( +

+ {closeAll.error.message} +

+ ) : null} +
) }