From 76f943148b66c64213ce1c1105d2162a7402bb23 Mon Sep 17 00:00:00 2001
From: KodeStar
Date: Fri, 16 Oct 2026 14:04:12 +0100
Subject: [PATCH 1/5] Bridge 1c: generated descriptions in .audiosilo, /meta
and the console
description.json is a known attachment (checked against meta's
model.Description, 200-1500 code points, migration 0050) and rides in
/meta's new work.generated_description when the community work has no
community_description. New job kind sidecars.describe (strict empty
params, 409 generate_open while a generate job is open); the estimate
gains can_describe and has.*.description; the admin book detail carries
generated_description, shown on the book page's Community card with a
Write a description action.
---
CLAUDE.md | 66 +++-
admin-ui/src/api/types.ts | 15 +
admin-ui/src/features/book/book-aside.tsx | 4 +-
admin-ui/src/features/bridge/bridge-jobs.tsx | 6 +-
.../src/features/bridge/describe-section.tsx | 194 ++++++++++
.../src/features/bridge/describe.test.tsx | 278 +++++++++++++++
.../features/bridge/generate-model.test.ts | 100 ++++++
.../src/features/bridge/generate-model.ts | 73 +++-
.../src/features/bridge/generate-status.tsx | 45 ++-
admin-ui/src/features/bridge/use-generate.ts | 25 +-
admin-ui/src/i18n/locales/de.json | 19 +
admin-ui/src/i18n/locales/en.json | 19 +
admin-ui/src/i18n/locales/es.json | 19 +
admin-ui/src/i18n/locales/fr.json | 19 +
admin-ui/src/i18n/locales/it.json | 19 +
admin-ui/src/i18n/locales/pt.json | 19 +
admin-ui/src/lib/errors.ts | 3 +
admin-ui/src/lib/server-events.test.ts | 8 +
admin-ui/src/lib/server-events.ts | 16 +-
internal/api/bridge_attachments_test.go | 10 +-
internal/api/bridge_describe_test.go | 334 ++++++++++++++++++
internal/api/handlers_bridge.go | 18 +-
internal/api/handlers_bridge_estimate.go | 31 +-
internal/api/handlers_meta.go | 15 +-
internal/bridge/jobs.go | 40 ++-
internal/bridge/sidecars_test.go | 67 ++++
internal/catalog/attachments.go | 31 +-
internal/catalog/attachments_test.go | 29 +-
internal/catalog/bookdetail.go | 21 ++
internal/library/attachments.go | 11 +-
internal/library/attachments_test.go | 67 ++++
internal/meta/cachedenrich.go | 24 +-
internal/meta/cachedenrich_test.go | 14 +
internal/meta/generated.go | 52 ++-
internal/meta/generated_test.go | 96 ++++-
internal/meta/service.go | 4 +
internal/metadata/attachments.go | 110 ++++--
internal/metadata/attachments_test.go | 95 ++++-
internal/notify/notify.go | 8 +-
internal/notify/notify_test.go | 22 ++
internal/store/migration_0050_test.go | 33 ++
.../0050_book_attachments_description.sql | 8 +
42 files changed, 1925 insertions(+), 162 deletions(-)
create mode 100644 admin-ui/src/features/bridge/describe-section.tsx
create mode 100644 admin-ui/src/features/bridge/describe.test.tsx
create mode 100644 internal/api/bridge_describe_test.go
create mode 100644 internal/store/migration_0050_test.go
create mode 100644 internal/store/migrations/0050_book_attachments_description.sql
diff --git a/CLAUDE.md b/CLAUDE.md
index f33acd42..cdbefc56 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -107,10 +107,10 @@ internal/auth/ users, argon2id, opaque hashed tokens, auth codes; hash.go
internal/relpath/ library-relative path rules shared by catalog and auth: Clean (canonical form) and Within (the segment-boundary rule of share scopes and cast tokens)
internal/catalog/ libraries, access grants, books, FTS search, listening state (the data layer)
internal/library/ filesystem view (fsview.go: SafeJoin, and ContentPath/ContentJoin, the one hidden-segment rule) + background scanner (scanner.go: incl. IndexMoved and the .audiosilo/ walk); attachments.go (a book's Bridge attachments: signatures, RefreshAttachments); ebook.go (BookEbook, the Bridge's own .epub rule, for the estimate; a readable one only: a zip whose directory lists META-INF/container.xml, as the Bridge reads it)
-internal/metadata/ dhowden/tag + ffprobe extraction (a `series-part` tag "1-3" is a range: seriesrange.go, ParseSeriesRange, the one grammar of a range position, and ParseSeriesPosition, a number or a range; incl. ReleaseDate: a date tag as YYYY[-MM[-DD]], stored as books.released, never as published); DeriveFromPath (structural path parsing, the scan's baseline: the folder holding the book is the series and the one above it the author, but a lone folder is the author); layout.go: ReadPathLayout (author/series/book LAYOUT, the match's path facts) and FromPathLayout (a path-first library's values); absfile.go: ParseABSFile/ReadABSFile (an Audiobookshelf metadata.json, see Metadata overrides); attachments.go: AttachmentsDir `.audiosilo`, Read/ParseCharacters, Recaps and Provenance (the Bridge's generated files, checked against audiosilo-meta's formats; see Bridge attachments); titlepos.go: PositionInTitle (a series position read from a title that is the series' name then a number, "He Who Fights with Monsters 4: ..."; narrow: set off by a space or comma, 1-3 digits, then the end or a separator, never a range)
+internal/metadata/ dhowden/tag + ffprobe extraction (a `series-part` tag "1-3" is a range: seriesrange.go, ParseSeriesRange, the one grammar of a range position, and ParseSeriesPosition, a number or a range; incl. ReleaseDate: a date tag as YYYY[-MM[-DD]], stored as books.released, never as published); DeriveFromPath (structural path parsing, the scan's baseline: the folder holding the book is the series and the one above it the author, but a lone folder is the author); layout.go: ReadPathLayout (author/series/book LAYOUT, the match's path facts) and FromPathLayout (a path-first library's values); absfile.go: ParseABSFile/ReadABSFile (an Audiobookshelf metadata.json, see Metadata overrides); attachments.go: AttachmentsDir `.audiosilo`, Read/ParseCharacters, Recaps, Description and Provenance (the Bridge's generated files, checked against audiosilo-meta's formats; see Bridge attachments); titlepos.go: PositionInTitle (a series position read from a title that is the series' name then a number, "He Who Fights with Monsters 4: ..."; narrow: set off by a space or comma, 1-3 digits, then the end or a separator, never a range)
internal/media/ Range streaming, download, embedded cover extraction
internal/names/ reading people in an Author/Narrator credit: Split (the deliberately shy co-credit rule), Reversed ("Surname, Given"), SortKey (surname first; the catalog registers it as the SQL function name_sort for the admin list's surname sort)
-internal/meta/ Phase 1.5 community metadata lookup: HTTP client + Service (asin/isbn -> composed enrichment envelope) with a bounded TTL cache and its persistent SQLite level (store.go); the /meta bundle's previous works and spoiler gating (bundle.go); the admin console's match (match.go: metaserve works/match over tag + path facts, metadata.ReadPathLayout; works/search fallback for an older metaserve); community cover fetches (cover.go: public addresses only); owned books' work ids for the Series cards (workids.go); placing the caller's books on rails and the next rail entry (local.go); which of a work's series a book is in (seriesrules.go: the series rules below); generated content laid beside the community work (generated.go: `Answer`, `Unmatched`, `MetaGenerated`); cache-only reads for the Bridge's estimate (cachedenrich.go: `CachedEnrichment`, `Coverages`)
+internal/meta/ Phase 1.5 community metadata lookup: HTTP client + Service (asin/isbn -> composed enrichment envelope) with a bounded TTL cache and its persistent SQLite level (store.go); the /meta bundle's previous works and spoiler gating (bundle.go); the admin console's match (match.go: metaserve works/match over tag + path facts, metadata.ReadPathLayout; works/search fallback for an older metaserve); community cover fetches (cover.go: public addresses only); owned books' work ids for the Series cards (workids.go); placing the caller's books on rails and the next rail entry (local.go); which of a work's series a book is in (seriesrules.go: the series rules below); generated content laid beside the community work (generated.go: `Answer`, `Unmatched`, `MetaGenerated`, `MetaGeneratedDescription`); cache-only reads for the Bridge's estimate (cachedenrich.go: `CachedEnrichment`, `Coverages`, each with a community description flag)
internal/chapteralign/ fits a community recording's chapter list onto a book's own audio (pure, no I/O): anchors by title + time, places the rest in proportion, snaps each to its pause (an injected Prober); classifies fill/titles/refine/restructure/same or why not (length_mismatch, structure_mismatch, crosses_files). testdata/mythos is a real 34-vs-174-chapter golden case
internal/chaptercheck/ the community chapter check: a background pass (every 10 min, and on Catalog.OnBookChange) and on request (Start), meta.RecordingChapters -> chapteralign (ffmpeg silencedetect via media.DetectSilences) -> catalog.SaveCommunityChapters
internal/covercolors/ the background cover colour pass: Runner reads the colour of every book whose cover may have art and holds none for it (catalog.CoverColorsDue), one at a time, once the start or a burst of OnBookChange kicks has been quiet 30 s (<= 10 min), and hourly; the reading is api.colorCover (a cached thumbnail, else an UNCACHED 160 px one), handed in as its Colorer; art with no colour is recorded as such (cover_color = the version alone; CoverSource.ColorRead, while Colored stays "has a colour", so a thumbnail that decodes still records over it); art files that aren't there (an unmounted share) are covercolors.ErrArtMissing: left due, not a failure
@@ -125,7 +125,7 @@ internal/logring/ the admin console's log viewer: an slog handler teeing rec
internal/updates/ the update check: GitHub Releases' latest release, once a day while on (config update_check)
internal/backup/ database backups: VACUUM INTO the backups folder on a schedule or on request, retention, restore applied at the next start
internal/jsonfile/ the small JSON files beside the database (backup's restore marker/result, the mirror's state.json): Write is owner-only, temp file synced then renamed (all or nothing); Read[T] is nil without a file
-internal/bridge/ the Bridge's side on the server (BRIDGE-PLAN.md): its last heartbeat (status.go: validation, the 2-minute online window, features, the lenient `estimates`, Writable, CanContribute) and the job queue (jobs.go: kinds the server can check (`bridge.verify`, `sidecars.generate`), dedupe, priority claim with a lease, long-poll wake, lease expiry, held updates and parks, cooperative cancel, `OnTransition` / `Job.Notable`); estimate.go (EstimateSidecars: a job's cost from the heartbeat's figures); pairing and the bridge token are auth's (auth/bridge.go)
+internal/bridge/ the Bridge's side on the server (BRIDGE-PLAN.md): its last heartbeat (status.go: validation, the 2-minute online window, features, the lenient `estimates`, Writable, CanContribute) and the job queue (jobs.go: kinds the server can check (`bridge.verify`, `sidecars.generate`, `sidecars.describe`), dedupe, priority claim with a lease, long-poll wake, lease expiry, held updates and parks, cooperative cancel, `OnTransition` / `Job.Notable`); estimate.go (EstimateSidecars: a job's cost from the heartbeat's figures); pairing and the bridge token are auth's (auth/bridge.go)
internal/notify/ the event feed (the console's bell) and its deliveries to webhook / ntfy / Discord destinations (incl. the Bridge's `bridge_job_finished` / `bridge_job_failed`)
internal/api/ HTTP transport: routing (api.go), middleware, rate limiting, handlers_*.go
internal/server/ HTTP(S) server, TLS modes (off/selfsigned/autocert), home HTTP on the self-signed port, graceful shutdown
@@ -256,7 +256,8 @@ values; the lists canonical JSON), `books.genres_checked` (the genre tag's one-t
`genres`|`tags`, `label_key`, the book's spelling: derived, rebuilt by `refreshEffective`), recreates
`books_fts` with `genres, tags` columns (rows copied) and sets `abs_indexed_at` NULL on every book with
a metadata.json (`abs_sig <> ''`), so the next scan reads each again for the new fields; see Labels
-below.
+below. `0050` adds `book_attachments.description` (the Bridge's checked `description.json`, Phase 1c;
+`''` for none; derived like the rest of the row).
Routes `/me/queue`, `/me/collections/**`, `/me/share-targets` (`handlers_queue.go`,
`handlers_collections.go`; a stranger's collection id is 404, a viewer's write 403
@@ -506,9 +507,17 @@ future metadata site can attach enrichment without reshaping the schema. The
transport-only. Degradation: disabled -> 404 (and the `metadata` capability is
false, so clients hide the UI); no asin/isbn or no upstream match -> `200
{"matched": false}` (with a `work` of the book's own when the Bridge generated
- characters or recaps for it: `meta.Answer`); upstream unreachable -> 502, unless
+ characters, recaps or a description for it: `meta.Answer`); upstream unreachable -> 502, unless
the book has generated content (this server's own), answered as unmatched with
- it. Out of scope for now: no cover remote-fallback, no tag-based ASIN extraction.
+ it. **Generated description** (Phase 1c): a book's `description.json` rides in the additive
+ `work.generated_description: {text}` (`MetaGeneratedDescription`), NEVER in `community_description`
+ (a shipped 2.0 app shows that first and credits the community; it ignores the new field), and
+ `work.generated.description` (bool, always sent beside `characters`/`recaps`) marks it. It fills
+ only a work with no `community_description` (the core `description`, the publisher's kind, doesn't
+ block it: the player orders those), so `Fillable` is also true without one; `?spoilers=hide` keeps
+ it (spoiler-free by construction: the Bridge writes it from the opening chapters' notes only), and
+ attribution never counts it. An unmatched book with only a description answers `{matched: false,
+ work: {title, generated_description, generated: {description: true, ...}}}`. Out of scope for now: no cover remote-fallback, no tag-based ASIN extraction.
**Reading-order families** (metaserve schema_version 7): `seriesRails` collapses
each family (key `ordering_of || id`) into ONE rail whose top-level view is the
MAIN view - the ref with no `ordering_of` (the primary), else the first ref - so
@@ -898,12 +907,18 @@ future metadata site can attach enrichment without reshaping the schema. The
left as it was by the backfill and recorded as none by a re-index, so the next scan reads it
again (`readABS`' `read`); both log a warning. Known gap: a position read from an ABS title (`titlePosition`) shows as `tag`.
**Bridge attachments** (`metadata/attachments.go`, `library/attachments.go`, `catalog/attachments.go`,
- migration `0048`; BRIDGE-PLAN.md Phase 1a): the Bridge writes a book's generated `characters.json`,
- `recaps.json` and `bridge.json` (provenance) into `.audiosilo/` in a folder book's own folder, or beside
+ migrations `0048`, `0050`; BRIDGE-PLAN.md Phases 1a and 1c): the Bridge writes a book's generated `characters.json`,
+ `recaps.json`, `description.json` and `bridge.json` (provenance) into `.audiosilo/` in a folder book's own folder, or beside
a single-file book with its stem before each name (`Book.characters.json`). They are NOT a metadata
layer (nothing in the effective row reads them): the scan stores them checked (audiosilo-meta's
- `model.Characters` / `model.Recaps`, bounded, a regular file only, never through a link) in
- `book_attachments`, and `/meta` serves them (below). The discovery walk lists each `.audiosilo` folder
+ `model.Characters` / `model.Recaps` / `model.Description`, bounded, a regular file only, never through a link) in
+ `book_attachments`, and `/meta` serves them (below). A description (`metadata.ParseDescription`) is
+ its text trimmed, 200-1500 code points (meta's `description.schema.json` bounds,
+ `TestDescriptionBoundsMatchTheSchema` reads them from `meta.SchemaFS`), no control character but a
+ line break; a blank text is none, anything else fails the file. `AttachmentNames` lists it before
+ `bridge.json`, so a book without one keeps its stored signature byte for byte. bridge.json's
+ `description.generated_at` is read as `AttachmentProvenance.DescriptionAt` (the rest of that object
+ is the Bridge's). The discovery walk lists each `.audiosilo` folder
ONCE (never walks into it; no audio lives there), and a book's signature (`books.attachments_sig`:
name:size:mtime_ns of its own known names) is compared like `abs_sig`: an unchanged book whose
attachments are new, changed, gone or not read for this index is read without a re-index
@@ -913,7 +928,7 @@ future metadata site can attach enrichment without reshaping the schema. The
never taking a book's generated content away. `Scanner.RefreshAttachments` re-reads one indexed
book's attachments, never its audio (the Bridge's `POST /bridge/rescan` mode `attachments`). The
player's `Book.generated` (list, search, item, recent, the next book) is true when the signature names
- a characters or recaps file (`generatedSig`, the narrow column: no join), so the player asks `/meta`
+ a characters, recaps or description file (`generatedSig`, the narrow column: no join), so the player asks `/meta`
for a book with no ASIN/ISBN too. Derived from the files: rebuildable, never carried by a move.
**Series ranges** (an omnibus of books 1-3): the `series_index` field may hold a range,
canonical `"1-3"` (input `1 - 3` and the en dash too; `metadata.ParseSeriesRange`: 0 < start <
@@ -1617,7 +1632,7 @@ future metadata site can attach enrichment without reshaping the schema. The
`Retry-After` (the receiver reports a load error; the sender should say "busy, try again"); it
marks the SENDER's session transcoded (`StreamMarks.Note` keys on `Credential.DeviceID`, the
parent token, whose progress saves make the session).
-- **The Bridge (BRIDGE-PLAN.md Phases 0 and 1a, capability `bridge`, `auth/bridge.go`, `internal/bridge`,
+- **The Bridge (BRIDGE-PLAN.md Phases 0, 1a and 1c, capability `bridge`, `auth/bridge.go`, `internal/bridge`,
`api/handlers_bridge.go`, `handlers_bridge_estimate.go`; security-critical)**: the optional companion (repo `audiosilo-bridge`,
private) that writes to the libraries and runs the heavy jobs, so this server stays read-only. **The
server never connects to the Bridge**: it stores intents (jobs) and the Bridge's last heartbeat, and
@@ -1667,7 +1682,13 @@ future metadata site can attach enrichment without reshaping the schema. The
Phase 1a's `sidecars.generate` (a book `path` required, params `{source, contribute, work_id}`; it writes,
so it is refused 409 `library_not_writable` for a library the last heartbeat doesn't report mapped and
writable, and `contribute: "issue"` is refused 409 `cannot_contribute` unless the heartbeat's
- `estimates.sidecars.can_contribute` says the Bridge can; the open job is still returned).
+ `estimates.sidecars.can_contribute` says the Bridge can; the open job is still returned), and Phase 1c's
+ `sidecars.describe` (a description from the notes the Bridge kept from generating the book, no new
+ ASR: a book `path` required, params `{}` or none, any field 400 (`describeParams`), stored as `{}`;
+ it writes, so the same `library_not_writable`; refused 409 `generate_open` (`ErrGenerateOpen`, checked
+ inside Enqueue's transaction after the dedupe) while a `sidecars.generate` job is open for the book,
+ which writes the description itself, while a generate job is never refused for an open describe; its
+ own dedupe key; an older Bridge doesn't advertise it, so it is the usual 409 `kind_unavailable`).
States queued -> claimed -> running -> done | failed | needs_attention | cancelled; the Bridge PARKS
a job it keeps running (`needs_attention`, the park's code in `progress.park.code`, its message in
`error`) and unparks it (back to `running`) under the same claim: a held state, so its reports still
@@ -1700,7 +1721,7 @@ future metadata site can attach enrichment without reshaping the schema. The
`full` (or none) indexes it (`IndexPath`; with `from`, the book's path before a move the Bridge made,
`Scanner.IndexMoved` carries its durable state when the old book is gone from disk and the
fingerprints match, else ignores it), mode `attachments` re-reads only the book's `.audiosilo/` files
- (`Scanner.RefreshAttachments`, answering `{path, attachments: {characters, recaps, bridge}}`); 404
+ (`Scanner.RefreshAttachments`, answering `{path, attachments: {characters, recaps, description, bridge}}`); 404
`not_indexable`. Console: `GET /admin/bridge` (pairing, online, heartbeat, kinds, features, each
library with the Bridge's mapping, workers, `ui_url`), `GET`/`POST /admin/bridge/jobs`, `GET
/admin/bridge/jobs/{id}`, `GET /admin/bridge/estimate?library_id=&path=&kind=[&brief=1]` (the
@@ -1709,15 +1730,26 @@ future metadata site can attach enrichment without reshaping the schema. The
`agent_cost_usd` null while there is no history, `subscription`, `agent_ready`, `figures` false when
the Bridge sent none, so a false `agent_ready` means "not known"), `writable`, what the book already
has (`has.community` via `meta.Service.Coverages`, cache first and never a full Enrich per book,
- `community_unknown` when it couldn't be asked; `has.local` from `book_attachments`), up to 10 earlier
+ `community_unknown` when it couldn't be asked; `has.local` from `book_attachments`; each with
+ `characters`, `recaps` and, since Phase 1c, `description`: the community work's `community_description`
+ (`Coverage.Description`; the core description doesn't count) or a stored generated one), `can_describe`
+ (the Bridge is online and advertises `sidecars.describe`; brief too), up to 10 earlier
books of its series in the library with no recaps (`catalog.EarlierInSeries`, each costed), and any
open job; `brief=1` skips the folder look and the series scan, for a card or a batch); 409
`kind_unavailable`. With a `path`, `GET /bridge/libraries/{id}/book` also answers a `book` block
(title, authors and narrators split by `names.Split`, series, `series_index`, asin, isbn, duration,
and `work_id` from the metadata cache alone, `CachedWorkID`), never on the sample-book answer.
Settings > Bridge in `admin-ui` (`features/bridge/`: the topic shows the heartbeat's estimates and
- each generate job's status; the book page's aside and each series card generate characters and
- recaps, a series card queueing those of its books without recaps in reading order). Jobs are carried by
+ each generate or describe job's status; the book page's aside and each series card generate characters and
+ recaps, a series card queueing those of its books without recaps in reading order). The admin book
+ detail carries `generated_description: {text, generated_at?}` (omitted without one; `generated_at` is
+ bridge.json's description time), which the book page's Community card shows (`describe-section.tsx`:
+ a Description sub-part with the "Generated on this server" mark, the text clamped behind Show more;
+ "Write a description" confirms and queues `sidecars.describe` when `describeOffered`: `can_describe`,
+ local characters or recaps, no description here or from the community, no open generate or describe
+ job; the describe job's status through `GenerateStatus`, whose done line for a generate job also says
+ whether its description came (`result.description`, `description_error`)). Notifications word a
+ describe job as the description ("Description ready: "). Jobs are carried by
`bridge.MoveJobs`, which `catalog.moveBookState` calls in its transaction with the catalog's clock (an
open job colliding with an open one of its kind at the new path is cancelled; `DedupeKey` rebuilds the
key), and go with their library. **Hidden folders**: no route reaches content through a hidden
diff --git a/admin-ui/src/api/types.ts b/admin-ui/src/api/types.ts
index 39f77f00..361e4c71 100644
--- a/admin-ui/src/api/types.ts
+++ b/admin-ui/src/api/types.ts
@@ -1641,6 +1641,17 @@ export interface AdminBookDetail {
* the tags look swapped or junk (meta.SearchPrefill).
*/
match_query: string;
+ /**
+ * The description the Bridge generated for the book (its .audiosilo/description.json):
+ * absent when it has none. `generated_at` is when it was written, when known.
+ */
+ generated_description?: GeneratedDescription;
+}
+
+/** A book's generated description on its page (catalog.GeneratedDescription). */
+export interface GeneratedDescription {
+ text: string;
+ generated_at?: string;
}
/** Where a book's chapters come from: its own files, or a community list fitted onto them. */
@@ -2351,6 +2362,8 @@ export interface SidecarsResult {
export interface SidecarsCoverage {
characters: boolean;
recaps: boolean;
+ /** A description: generated here, or the community's community_description (Phase 1c). */
+ description?: boolean;
}
/** An earlier book of the series without recaps (the estimate's `earlier`). */
@@ -2388,6 +2401,8 @@ export interface BridgeEstimate {
figures: boolean;
/** The library is mapped and writable on the Bridge. */
writable: boolean;
+ /** The Bridge is online and runs sidecars.describe (a description from the notes it kept). */
+ can_describe?: boolean;
has: {
community: SidecarsCoverage;
local: SidecarsCoverage;
diff --git a/admin-ui/src/features/book/book-aside.tsx b/admin-ui/src/features/book/book-aside.tsx
index c041ce0e..65f91d49 100644
--- a/admin-ui/src/features/book/book-aside.tsx
+++ b/admin-ui/src/features/book/book-aside.tsx
@@ -8,12 +8,13 @@ import { Button } from '@/components/ui/button';
import { Card, CardHeader } from '@/components/ui/card';
import { formatNumber, formatPercent, formatRelative, progressFraction } from '@/lib/format';
import { cn } from '@/lib/utils';
+import { DescribeSection } from '@/features/bridge/describe-section';
import { GenerateSection } from '@/features/bridge/generate-section';
import { ProgressMenu } from '@/features/people/progress-actions';
/**
* The right-hand column: who's listening, community metadata (and generating
- * characters and recaps with the Bridge), and who can see the book.
+ * characters, recaps and a description with the Bridge), and who can see the book.
*/
export function BookAside({
detail,
@@ -193,6 +194,7 @@ function CommunityCard({
>
)}
+
);
}
diff --git a/admin-ui/src/features/bridge/bridge-jobs.tsx b/admin-ui/src/features/bridge/bridge-jobs.tsx
index ad0a38bf..2386cad7 100644
--- a/admin-ui/src/features/bridge/bridge-jobs.tsx
+++ b/admin-ui/src/features/bridge/bridge-jobs.tsx
@@ -22,7 +22,7 @@ import {
resultSummary,
stateLook,
} from './bridge-model';
-import { KIND_GENERATE } from './generate-model';
+import { wordsItsOwnStatus } from './generate-model';
import { GenerateStatus } from './generate-status';
import { useBridgeJobs } from './use-bridge';
@@ -134,8 +134,8 @@ function JobRow({
? ''
: (libraryNames.get(job.library_id) ??
t('bridge.jobs.libraryId', { id: formatNumber(job.library_id, lang) }));
- // A generate job words its progress and result its own way (GenerateStatus).
- const generate = job.kind === KIND_GENERATE;
+ // A generate or describe job words its progress and result its own way (GenerateStatus).
+ const generate = wordsItsOwnStatus(job.kind);
const progress: ReturnType = generate ? {} : progressOf(job.progress);
const summary = generate ? null : resultSummary(job.result);
const details = hasContent(job.result) || hasContent(job.params);
diff --git a/admin-ui/src/features/bridge/describe-section.tsx b/admin-ui/src/features/bridge/describe-section.tsx
new file mode 100644
index 00000000..35da4abc
--- /dev/null
+++ b/admin-ui/src/features/bridge/describe-section.tsx
@@ -0,0 +1,194 @@
+import { useId, useState } from 'react';
+import { useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { PenLine, X } from 'lucide-react';
+import { keys, useServerInfo } from '@/api/hooks';
+import type { AdminBook, GeneratedDescription } from '@/api/types';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Dialog, DialogBody, DialogContent, DialogFormFooter } from '@/components/ui/dialog';
+import { formatRelative } from '@/lib/format';
+import { cancelJob, queueJob } from './bridge-actions';
+import { isOpen, jobActions, stateLook } from './bridge-model';
+import {
+ clampsDescription,
+ describeOffered,
+ generateAvailable,
+ generateJobsKnown,
+ KIND_DESCRIBE,
+} from './generate-model';
+import { GenerateStatus } from './generate-status';
+import { useBookGenerateJob, useBridgeLink, useGenerateEstimate } from './use-generate';
+
+/**
+ * The Community card's description part (Bridge Phase 1c): the description the
+ * Bridge generated for the book (its text, clamped, with Show more), the
+ * describe job while one is open or how it failed, and "Write a description",
+ * which queues `sidecars.describe` after a confirmation. Offered only when the
+ * Bridge can write one (describeOffered). Renders nothing when there is nothing
+ * to show.
+ */
+export function DescribeSection({
+ book,
+ description,
+}: {
+ book: AdminBook;
+ description?: GeneratedDescription;
+}) {
+ const { t, i18n } = useTranslation();
+ const lang = i18n.resolvedLanguage ?? 'en';
+ const qc = useQueryClient();
+ const server = useServerInfo().data;
+ const available = generateAvailable(server);
+ const { job, jobs } = useBookGenerateJob(
+ book.library_id,
+ book.path,
+ generateJobsKnown(server),
+ KIND_DESCRIBE,
+ );
+ // The same brief estimate the characters-and-recaps part reads (one query).
+ const estimate = useGenerateEstimate(book.library_id, book.path, available, { brief: true });
+ const parked = job?.state === 'needs_attention';
+ const uiUrl = useBridgeLink(parked);
+ const [open, setOpen] = useState(false);
+ const [expanded, setExpanded] = useState(false);
+ const [cancelling, setCancelling] = useState(false);
+ const textId = useId();
+
+ const offer = available && describeOffered(estimate.data, jobs);
+ // A cancelled job says nothing; a finished one is the description itself once it shows.
+ const showJob = job && job.state !== 'cancelled' && !(job.state === 'done' && description);
+ if (!open && !showJob && !offer && !description) return null;
+
+ const look = job ? stateLook(job) : undefined;
+ const canCancel = job ? jobActions(job).cancel : false;
+ const clamps = description ? clampsDescription(description.text) : false;
+ const jobOpen = job ? isOpen(job.state) : false;
+
+ return (
+
+
+
+ {t('bridge.describe.title')}
+
+ {showJob && look ? {t(look.key)} : null}
+
+ {description ? (
+ <>
+
+ {description.generated_at
+ ? t('bridge.describe.noteAt', {
+ when: formatRelative(description.generated_at, lang),
+ })
+ : t('bridge.generate.localNote')}
+
+
+ {description.text}
+
+ {clamps ? (
+
+ setExpanded((v) => !v)}
+ >
+ {expanded ? t('bridge.describe.showLess') : t('bridge.describe.showMore')}
+
+
+ ) : null}
+ >
+ ) : null}
+ {showJob && job ? : null}
+ {(showJob && jobOpen && canCancel) || offer ? (
+
+ {offer ? (
+
setOpen(true)}>
+
+ {t('bridge.describe.action')}
+
+ ) : null}
+ {job && jobOpen && canCancel ? (
+
{
+ setCancelling(true);
+ void cancelJob(qc, job).finally(() => setCancelling(false));
+ }}
+ aria-label={t('bridge.describe.cancelAria')}
+ >
+
+ {t('bridge.jobs.cancel')}
+
+ ) : null}
+
+ ) : null}
+
+
+ );
+}
+
+/**
+ * The confirmation before `sidecars.describe`: what it does, what it reads (the
+ * notes the Bridge kept from generating this book, no new speech recognition),
+ * that it costs a little agent time, and where the result appears.
+ */
+function DescribeDialog({
+ open,
+ onOpenChange,
+ book,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ book: AdminBook;
+}) {
+ const { t } = useTranslation();
+ const qc = useQueryClient();
+ const [busy, setBusy] = useState(false);
+
+ const submit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (busy) return;
+ setBusy(true);
+ const ok = await queueJob(
+ qc,
+ { kind: KIND_DESCRIBE, library_id: book.library_id, path: book.path, params: {} },
+ { name: book.title || book.path, online: true },
+ );
+ setBusy(false);
+ // Whether a job is open changed with it.
+ void qc.invalidateQueries({ queryKey: keys.bridgeEstimate(book.library_id, book.path) });
+ if (ok) onOpenChange(false);
+ };
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/admin-ui/src/features/bridge/describe.test.tsx b/admin-ui/src/features/bridge/describe.test.tsx
new file mode 100644
index 00000000..a3451bfa
--- /dev/null
+++ b/admin-ui/src/features/bridge/describe.test.tsx
@@ -0,0 +1,278 @@
+import { screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { setToken } from '@/api/token';
+import type { BridgeEstimate, BridgeJob } from '@/api/types';
+import { mockFetch, type MockRequest, type MockRoute } from '@/test/fetch-mock';
+import { serverInfo } from '@/test/fixtures';
+import { bookDetail } from '@/test/library-fixtures';
+import { renderApp } from '@/test/render-app';
+import { signedInRoutes } from '@/test/routes';
+
+// The book page's description part (Bridge Phase 1c): the generated description
+// with its mark and Show more, "Write a description" and its confirmation, and
+// a describe job's status.
+
+const BOOK_PATH = 'Brandon Sanderson/The Stormlight Archive/01 - The Way of Kings';
+const URL = `/library/book?library=1&path=${encodeURIComponent(BOOK_PATH)}`;
+
+const bridgeOn = {
+ ...serverInfo,
+ capabilities: { ...serverInfo.capabilities, bridge: true },
+ bridge: { version: '0.17.0', features: ['bridge', 'sidecars'] },
+};
+
+const LONG_TEXT =
+ 'A storm-wracked land where soldiers fight over gemhearts on the Shattered Plains. ' +
+ 'A slave once a surgeon, a scholar with a secret debt and a highprince who sees ' +
+ 'visions are drawn toward the same old war, each asking what an oath is worth. ' +
+ 'The opening follows them as the first threads begin to cross.';
+
+/** A brief estimate of a book with generated characters and recaps, and no description. */
+function estimate(over: Partial = {}): BridgeEstimate {
+ return {
+ kind: 'sidecars.generate',
+ library_id: 1,
+ path: BOOK_PATH,
+ source: 'audio',
+ duration_seconds: 163800,
+ asr_hours: 11.4,
+ agent_cost_usd: 18.2,
+ subscription: false,
+ agent_ready: true,
+ can_contribute: false,
+ figures: true,
+ writable: true,
+ can_describe: true,
+ has: {
+ community: { characters: false, recaps: false, description: false },
+ local: { characters: true, recaps: true, description: false },
+ },
+ earlier: [],
+ ...over,
+ };
+}
+
+function job(over: Partial = {}): BridgeJob {
+ return {
+ id: 40,
+ kind: 'sidecars.describe',
+ library_id: 1,
+ path: BOOK_PATH,
+ params: {},
+ state: 'running',
+ progress: {},
+ result: {},
+ error: '',
+ requested_by: { id: 1, username: 'chris' },
+ priority: 0,
+ attempts: 1,
+ lease_until: null,
+ cancel_requested: false,
+ created_at: '2026-10-16T12:00:00Z',
+ updated_at: '2026-10-16T12:01:00Z',
+ claimed_at: '2026-10-16T12:00:05Z',
+ finished_at: null,
+ ...over,
+ };
+}
+
+function routes(over: Record = {}) {
+ return signedInRoutes({
+ 'GET /server': { body: bridgeOn },
+ 'GET /admin/libraries/1/book': { body: bookDetail() },
+ 'GET /admin/bridge/jobs': { body: { jobs: [] } },
+ 'GET /admin/bridge/estimate': { body: estimate() },
+ ...over,
+ });
+}
+
+const queued = (calls: MockRequest[]) =>
+ calls.filter((c) => c.method === 'POST' && c.path === '/admin/bridge/jobs');
+
+beforeEach(() => setToken('stored'));
+afterEach(() => vi.unstubAllGlobals());
+
+describe('the generated description', () => {
+ it('shows the text with its mark, clamped behind Show more', async () => {
+ const user = userEvent.setup();
+ mockFetch(
+ routes({
+ 'GET /admin/libraries/1/book': {
+ body: bookDetail({ generated_description: { text: LONG_TEXT } }),
+ },
+ 'GET /admin/bridge/estimate': {
+ body: estimate({
+ has: {
+ community: { characters: false, recaps: false, description: false },
+ local: { characters: true, recaps: true, description: true },
+ },
+ }),
+ },
+ }),
+ );
+ renderApp(URL);
+ const part = await screen.findByRole('region', { name: 'Description' });
+ expect(within(part).getByText(LONG_TEXT)).toHaveClass('line-clamp-4');
+ // The mark the characters-and-recaps part uses too.
+ expect(within(part).getByText('Generated on this server.')).toBeInTheDocument();
+ const more = within(part).getByRole('button', { name: 'Show more' });
+ expect(more).toHaveAttribute('aria-expanded', 'false');
+ await user.click(more);
+ expect(within(part).getByText(LONG_TEXT)).not.toHaveClass('line-clamp-4');
+ expect(within(part).getByRole('button', { name: 'Show less' })).toBeInTheDocument();
+ // It has one already: nothing to offer.
+ expect(within(part).queryByRole('button', { name: 'Write a description' })).toBeNull();
+ });
+
+ it('shows it even while the Bridge is away', async () => {
+ mockFetch(
+ routes({
+ 'GET /server': { body: serverInfo },
+ 'GET /admin/libraries/1/book': {
+ body: bookDetail({ generated_description: { text: 'Short enough to show whole.' } }),
+ },
+ }),
+ );
+ renderApp(URL);
+ const part = await screen.findByRole('region', { name: 'Description' });
+ expect(within(part).getByText('Short enough to show whole.')).not.toHaveClass('line-clamp-4');
+ expect(within(part).queryByRole('button', { name: 'Show more' })).toBeNull();
+ });
+});
+
+describe('write a description', () => {
+ it('confirms, then queues sidecars.describe with no params', async () => {
+ const user = userEvent.setup();
+ const calls = mockFetch(
+ routes({
+ 'POST /admin/bridge/jobs': { status: 201, body: job({ state: 'queued' }) },
+ }),
+ );
+ renderApp(URL);
+ await user.click(await screen.findByRole('button', { name: 'Write a description' }));
+ const dialog = await screen.findByRole('dialog', { name: 'Write a description' });
+ expect(within(dialog).getByText(/from the notes it kept/)).toBeInTheDocument();
+ expect(within(dialog).getByText(/No new speech recognition runs/)).toBeInTheDocument();
+ expect(within(dialog).getByText(/in the book's About in the player/)).toBeInTheDocument();
+ expect(queued(calls)).toHaveLength(0);
+ await user.click(within(dialog).getByRole('button', { name: 'Write description' }));
+ await waitFor(() => expect(queued(calls)).toHaveLength(1));
+ expect(queued(calls)[0].body).toEqual({
+ kind: 'sidecars.describe',
+ library_id: 1,
+ path: BOOK_PATH,
+ params: {},
+ });
+ });
+
+ it.each<[string, Partial]>([
+ ['an older Bridge', { can_describe: undefined }],
+ ['a Bridge that cannot now', { can_describe: false }],
+ [
+ 'a community description',
+ {
+ has: {
+ community: { characters: false, recaps: false, description: true },
+ local: { characters: true, recaps: true, description: false },
+ },
+ },
+ ],
+ [
+ 'nothing generated to write from',
+ {
+ has: {
+ community: { characters: true, recaps: true, description: false },
+ local: { characters: false, recaps: false, description: false },
+ },
+ },
+ ],
+ ])('is not offered with %s', async (_name, over) => {
+ const calls = mockFetch(routes({ 'GET /admin/bridge/estimate': { body: estimate(over) } }));
+ renderApp(URL);
+ await screen.findByRole('heading', { level: 1, name: 'The Way of Kings' });
+ await waitFor(() => expect(calls.some((c) => c.path === '/admin/bridge/estimate')).toBe(true));
+ await new Promise((r) => setTimeout(r, 50));
+ expect(screen.queryByRole('button', { name: 'Write a description' })).toBeNull();
+ });
+
+ it('is not offered while a generate job is open (it writes one)', async () => {
+ mockFetch(
+ routes({
+ 'GET /admin/bridge/jobs': {
+ body: { jobs: [job({ id: 39, kind: 'sidecars.generate', params: {} })] },
+ },
+ }),
+ );
+ renderApp(URL);
+ // The generate job's own status shows; no describe action beside it.
+ expect(await screen.findByRole('button', { name: /cancel generating/i })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Write a description' })).toBeNull();
+ });
+
+ it("follows a describe job's stage, and says when it failed", async () => {
+ mockFetch(
+ routes({
+ 'GET /admin/bridge/jobs': {
+ body: {
+ jobs: [job({ progress: { step: 'describing', stage: 'describing', cost_usd: 0.12 } })],
+ },
+ },
+ }),
+ );
+ renderApp(URL);
+ const part = await screen.findByRole('region', { name: 'Description' });
+ expect(await within(part).findByText('Writing the description')).toBeInTheDocument();
+ expect(within(part).getByText(/\$0\.12 so far/)).toBeInTheDocument();
+ expect(
+ within(part).getByRole('button', { name: 'Cancel writing the description' }),
+ ).toBeInTheDocument();
+ expect(within(part).queryByRole('button', { name: 'Write a description' })).toBeNull();
+ });
+
+ it('shows a failed describe job with its error', async () => {
+ const error = "Couldn't write a description that passed the checks.";
+ mockFetch(
+ routes({
+ 'GET /admin/bridge/jobs': {
+ body: { jobs: [job({ state: 'failed', error, finished_at: '2026-10-16T12:05:00Z' })] },
+ },
+ }),
+ );
+ renderApp(URL);
+ const part = await screen.findByRole('region', { name: 'Description' });
+ expect(await within(part).findByText(error)).toBeInTheDocument();
+ // Not open any more: it can be asked again.
+ expect(within(part).getByRole('button', { name: 'Write a description' })).toBeInTheDocument();
+ });
+});
+
+describe("a generate job's description", () => {
+ it('says the description came with it, or why not', async () => {
+ mockFetch(
+ routes({
+ 'GET /admin/bridge/jobs': {
+ body: {
+ jobs: [
+ job({
+ id: 41,
+ kind: 'sidecars.generate',
+ state: 'done',
+ finished_at: '2026-10-16T14:00:00Z',
+ result: {
+ characters: 3,
+ recaps: 2,
+ description: false,
+ description_error: 'the agent is rate limited',
+ },
+ }),
+ ],
+ },
+ },
+ }),
+ );
+ renderApp(URL);
+ expect(
+ await screen.findByText('No description this time: the agent is rate limited'),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/admin-ui/src/features/bridge/generate-model.test.ts b/admin-ui/src/features/bridge/generate-model.test.ts
index 077aca89..22a01955 100644
--- a/admin-ui/src/features/bridge/generate-model.test.ts
+++ b/admin-ui/src/features/bridge/generate-model.test.ts
@@ -8,6 +8,9 @@ import {
batchCheck,
batchPlan,
bridgeJobLink,
+ clampsDescription,
+ DESCRIPTION_CLAMP_CHARS,
+ describeOffered,
destinationKey,
estimateView,
GENERATE_PARKED_POLL_MS,
@@ -19,10 +22,12 @@ import {
generatePollMs,
generateProgress,
generateResult,
+ KIND_DESCRIBE,
KIND_GENERATE,
KNOWN_PARKS,
KNOWN_STAGES,
latestGenerateJob,
+ latestJobOf,
parkKey,
planEstimate,
roundEta,
@@ -30,6 +35,7 @@ import {
settleLimited,
stageLabel,
titleCase,
+ wordsItsOwnStatus,
type BatchCheck,
} from './generate-model';
@@ -620,3 +626,97 @@ describe('the series batch', () => {
expect(started).toEqual([1, 2]);
});
});
+
+describe('a description (Phase 1c)', () => {
+ const has = (
+ local: Partial>,
+ community: Partial> = {},
+ ) => ({
+ community: { characters: false, recaps: false, description: false, ...community },
+ local: { characters: false, recaps: false, description: false, ...local },
+ });
+ const open = (kind: string, state: BridgeJobState = 'running') => ({ kind, state });
+
+ it('is offered for a book with generated content, no description and nothing open', () => {
+ expect(describeOffered({ can_describe: true, has: has({ characters: true }) }, [])).toBe(true);
+ expect(describeOffered({ can_describe: true, has: has({ recaps: true }) }, undefined)).toBe(
+ true,
+ );
+ // A finished or cancelled job is no reason to hold back.
+ expect(
+ describeOffered({ can_describe: true, has: has({ recaps: true }) }, [
+ open(KIND_DESCRIBE, 'failed'),
+ open(KIND_GENERATE, 'done'),
+ ]),
+ ).toBe(true);
+ });
+
+ it('is not offered without a Bridge that can, content to write from, or with one already', () => {
+ const local = has({ characters: true, recaps: true });
+ expect(describeOffered(undefined, [])).toBe(false);
+ expect(describeOffered({ has: local }, [])).toBe(false);
+ expect(describeOffered({ can_describe: false, has: local }, [])).toBe(false);
+ expect(describeOffered({ can_describe: true, has: has({}) }, [])).toBe(false);
+ expect(
+ describeOffered(
+ { can_describe: true, has: has({ characters: true, description: true }) },
+ [],
+ ),
+ ).toBe(false);
+ expect(
+ describeOffered(
+ { can_describe: true, has: has({ characters: true }, { description: true }) },
+ [],
+ ),
+ ).toBe(false);
+ });
+
+ it('is not offered while a generate or describe job is open', () => {
+ const e = { can_describe: true, has: has({ characters: true }) };
+ expect(describeOffered(e, [open(KIND_GENERATE)])).toBe(false);
+ expect(describeOffered(e, [open(KIND_DESCRIBE, 'needs_attention')])).toBe(false);
+ // Another kind (a verify) doesn't count.
+ expect(describeOffered(e, [open('bridge.verify')])).toBe(true);
+ });
+
+ it('polls a describe job like a generate one, and finds each kind', () => {
+ expect(generatePollMs([open(KIND_DESCRIBE)])).toBe(GENERATE_POLL_MS);
+ expect(generatePollMs([open(KIND_DESCRIBE, 'needs_attention')])).toBe(GENERATE_PARKED_POLL_MS);
+ const jobs = [open(KIND_DESCRIBE), open(KIND_GENERATE, 'done')];
+ expect(latestJobOf(jobs, KIND_GENERATE)).toBe(jobs[1]);
+ expect(latestJobOf(jobs, KIND_DESCRIBE)).toBe(jobs[0]);
+ expect(latestGenerateJob(jobs)).toBe(jobs[1]);
+ expect(wordsItsOwnStatus(KIND_DESCRIBE)).toBe(true);
+ expect(wordsItsOwnStatus('bridge.verify')).toBe(false);
+ });
+
+ it("reads a result's description outcome", () => {
+ expect(generateResult({ description: true, checked_against: 'none' })).toEqual({
+ description: true,
+ contributed: [],
+ });
+ expect(
+ generateResult({ characters: 1, description: false, description_error: ' rate limited ' }),
+ ).toEqual({
+ characters: 1,
+ description: false,
+ descriptionError: 'rate limited',
+ contributed: [],
+ });
+ // An error beside a written description says nothing.
+ expect(generateResult({ description: true, description_error: 'x' })?.descriptionError).toBe(
+ undefined,
+ );
+ expect(generateResult({ description: 'yes' })).toBeNull();
+ });
+
+ it('clamps a long description only', () => {
+ expect(clampsDescription('x'.repeat(DESCRIPTION_CLAMP_CHARS))).toBe(false);
+ expect(clampsDescription('x'.repeat(DESCRIPTION_CLAMP_CHARS + 1))).toBe(true);
+ });
+
+ it('labels the describing stage', () => {
+ expect(KNOWN_STAGES).toContain('describing');
+ expect(strings['bridge.stage.describing']).toBe('Writing the description');
+ });
+});
diff --git a/admin-ui/src/features/bridge/generate-model.ts b/admin-ui/src/features/bridge/generate-model.ts
index 9218aaf8..04e3702b 100644
--- a/admin-ui/src/features/bridge/generate-model.ts
+++ b/admin-ui/src/features/bridge/generate-model.ts
@@ -12,14 +12,20 @@ import { relBaseName } from '@/lib/paths';
import { spanIn } from '@/features/library/series/series-model';
import { isOpen, num, progressOf, safeHttpUrl, text } from './bridge-model';
-// Characters and recaps from the Bridge (BRIDGE-PLAN.md Phase 1a): the rules the
-// book page's Community card, its generate dialog, the series batch and the
-// Bridge page's job rows follow for `sidecars.generate`. Pure: no React, no i18n
+// Characters and recaps from the Bridge (BRIDGE-PLAN.md Phase 1a), and the
+// description written from their notes (Phase 1c): the rules the book page's
+// Community card, its generate dialog, the series batch and the Bridge page's
+// job rows follow for `sidecars.generate` and `sidecars.describe`. Pure: no React, no i18n
// instance; callers pass the keys these return to t().
/** The job kind, and the feature (job family) the Bridge advertises for it. */
export const KIND_GENERATE = 'sidecars.generate';
export const FEATURE_SIDECARS = 'sidecars';
+/** Phase 1c's description-only kind: a description from the notes the Bridge kept. */
+export const KIND_DESCRIBE = 'sidecars.describe';
+
+/** The kinds the book page follows: generate and describe (both in the sidecars family). */
+const BOOK_KINDS: readonly string[] = [KIND_GENERATE, KIND_DESCRIBE];
/**
* Whether the Generate action is offered at all: community metadata is on (the
@@ -52,9 +58,9 @@ export function generateAction(has: {
}
/**
- * How often a book's generate jobs are asked again: about every 10 s while one
- * is queued or working, every minute while one is parked (it waits on a person
- * or a reset), else not at all.
+ * How often a book's generate and describe jobs are asked again: about every
+ * 10 s while one is queued or working, every minute while one is parked (it
+ * waits on a person or a reset), else not at all.
*/
export const GENERATE_POLL_MS = 10_000;
export const GENERATE_PARKED_POLL_MS = 60_000;
@@ -62,16 +68,55 @@ export const GENERATE_PARKED_POLL_MS = 60_000;
export function generatePollMs(
jobs: readonly Pick[] | undefined,
): number | false {
- const open = jobs?.filter((j) => j.kind === KIND_GENERATE && isOpen(j.state)) ?? [];
+ const open = jobs?.filter((j) => BOOK_KINDS.includes(j.kind) && isOpen(j.state)) ?? [];
if (open.some((j) => j.state !== 'needs_attention')) return GENERATE_POLL_MS;
return open.length ? GENERATE_PARKED_POLL_MS : false;
}
-/** The book's newest generate job (the list is newest first), if any. */
+/** The book's newest job of a kind (the list is newest first), if any. */
+export function latestJobOf>(
+ jobs: readonly J[] | undefined,
+ kind: string,
+): J | undefined {
+ return jobs?.find((j) => j.kind === kind);
+}
+
+/** The book's newest generate job, if any. */
export function latestGenerateJob>(
jobs: readonly J[] | undefined,
): J | undefined {
- return jobs?.find((j) => j.kind === KIND_GENERATE);
+ return latestJobOf(jobs, KIND_GENERATE);
+}
+
+/** Whether a job's progress and result are worded by GenerateStatus (generate and describe). */
+export function wordsItsOwnStatus(kind: string): boolean {
+ return BOOK_KINDS.includes(kind);
+}
+
+/**
+ * Whether the card offers "Write a description": the Bridge can (online, runs
+ * sidecars.describe), the book has characters or recaps generated here (their
+ * notes are what the description is written from), it has no description yet,
+ * here or from the community, and no generate or describe job is open for it
+ * (a generate job writes one when it finishes).
+ */
+export function describeOffered(
+ e: Pick | undefined,
+ jobs: readonly Pick[] | undefined,
+): boolean {
+ if (!e?.can_describe) return false;
+ const { local, community } = e.has;
+ if (!(local.characters || local.recaps) || local.description || community.description) {
+ return false;
+ }
+ return !(jobs ?? []).some((j) => BOOK_KINDS.includes(j.kind) && isOpen(j.state));
+}
+
+/** A generated description long enough to clamp, with a Show more toggle (about four lines). */
+export const DESCRIPTION_CLAMP_CHARS = 240;
+
+export function clampsDescription(text: string): boolean {
+ return text.trim().length > DESCRIPTION_CLAMP_CHARS;
}
// The engine's stage names (state.State) and the Bridge's own steps, each with a
@@ -100,6 +145,7 @@ export const KNOWN_STAGES = [
'waiting',
'writing',
'rescanning',
+ 'describing',
] as const;
/** "qa_sweep" -> "Qa sweep": a name this console has no label for, made readable. */
@@ -216,11 +262,14 @@ export function generateProgress(progress: unknown): GenerateProgress {
return out;
}
-/** A finished generate job's result as the card words it. */
+/** A finished generate (or describe) job's result as the card words it. */
export interface GenerateResult {
characters?: number;
recaps?: number;
costUsd?: number;
+ /** The description was written (true) or not (false, with why); absent from an older Bridge. */
+ description?: boolean;
+ descriptionError?: string;
/** The community issues filed (http(s) links only), by what each offers. */
contributed: { what: string; url: string }[];
}
@@ -236,6 +285,9 @@ export function generateResult(result: unknown): GenerateResult | null {
if (recaps !== undefined) out.recaps = recaps;
const cost = amount(r.cost_usd);
if (cost !== undefined) out.costUsd = cost;
+ if (typeof r.description === 'boolean') out.description = r.description;
+ const why = text(r.description_error);
+ if (out.description === false && why) out.descriptionError = why;
if (r.contributed && typeof r.contributed === 'object') {
for (const [what, url] of Object.entries(r.contributed as Record)) {
const safe = typeof url === 'string' ? safeHttpUrl(url) : undefined;
@@ -246,6 +298,7 @@ export function generateResult(result: unknown): GenerateResult | null {
out.characters !== undefined ||
out.recaps !== undefined ||
out.costUsd !== undefined ||
+ out.description !== undefined ||
out.contributed.length > 0;
return any ? out : null;
}
diff --git a/admin-ui/src/features/bridge/generate-status.tsx b/admin-ui/src/features/bridge/generate-status.tsx
index 88cef639..9fa8268a 100644
--- a/admin-ui/src/features/bridge/generate-status.tsx
+++ b/admin-ui/src/features/bridge/generate-status.tsx
@@ -7,6 +7,7 @@ import {
bridgeJobLink,
generateProgress,
generateResult,
+ KIND_DESCRIBE,
parkKey,
roundEta,
stageLabel,
@@ -14,11 +15,12 @@ import {
import { isOpen } from './bridge-model';
/**
- * Where a `sidecars.generate` job stands, in a few lines: while it works, the
- * stage, the count, the ETA and the cost so far (with a bar when there is a
- * count); parked, why and the hint, with a link into the Bridge's own page;
- * done, what it made; failed, the error. The book page's Community card and the
- * Bridge page's job rows both show a job this way.
+ * Where a `sidecars.generate` (or `sidecars.describe`) job stands, in a few
+ * lines: while it works, the stage, the count, the ETA and the cost so far (with
+ * a bar when there is a count); parked, why and the hint, with a link into the
+ * Bridge's own page; done, what it made (a generate job's description outcome
+ * too); failed, the error. The book page's Community card and the Bridge page's
+ * job rows both show a job this way.
*/
export function GenerateStatus({ job, uiUrl }: { job: BridgeJob; uiUrl?: string }) {
const { t, i18n } = useTranslation();
@@ -27,17 +29,21 @@ export function GenerateStatus({ job, uiUrl }: { job: BridgeJob; uiUrl?: string
if (job.state === 'done') {
const r = generateResult(job.result);
- const made = [
- r?.characters !== undefined
- ? t('bridge.generate.characters', {
- count: r.characters,
- n: formatNumber(r.characters, lang),
- })
- : undefined,
- r?.recaps !== undefined
- ? t('bridge.generate.recaps', { count: r.recaps, n: formatNumber(r.recaps, lang) })
- : undefined,
- ].filter((s): s is string => Boolean(s));
+ const describe = job.kind === KIND_DESCRIBE;
+ const made = describe
+ ? [t('bridge.describe.done')]
+ : [
+ r?.characters !== undefined
+ ? t('bridge.generate.characters', {
+ count: r.characters,
+ n: formatNumber(r.characters, lang),
+ })
+ : undefined,
+ r?.recaps !== undefined
+ ? t('bridge.generate.recaps', { count: r.recaps, n: formatNumber(r.recaps, lang) })
+ : undefined,
+ r?.description ? t('bridge.generate.descriptionMade') : undefined,
+ ].filter((s): s is string => Boolean(s));
const facts = [
made.length ? formatList(made, lang) : t('bridge.generate.doneNoCounts'),
r?.costUsd !== undefined
@@ -53,6 +59,13 @@ export function GenerateStatus({ job, uiUrl }: { job: BridgeJob; uiUrl?: string
{facts.join(' · ')}
+ {!describe && r?.description === false ? (
+
+ {r.descriptionError
+ ? t('bridge.generate.noDescription', { reason: r.descriptionError })
+ : t('bridge.generate.noDescriptionPlain')}
+
+ ) : null}
{r?.contributed.map((c) => (
generatePollMs(q.state.data?.jobs),
});
- const job = latestGenerateJob(query.data?.jobs);
+ const job = latestJobOf(query.data?.jobs, kind);
const seen = useRef(undefined);
useEffect(() => {
const before = seen.current;
@@ -40,7 +47,7 @@ export function useBookGenerateJob(libraryId: number, path: string, enabled: boo
void qc.invalidateQueries({ queryKey: keys.book(libraryId, path), exact: true });
void qc.invalidateQueries({ queryKey: keys.bookMeta(libraryId, path) });
}, [job, qc, libraryId, path]);
- return { job };
+ return { job, jobs: query.data?.jobs };
}
/**
diff --git a/admin-ui/src/i18n/locales/de.json b/admin-ui/src/i18n/locales/de.json
index a7adad5a..186ac31b 100644
--- a/admin-ui/src/i18n/locales/de.json
+++ b/admin-ui/src/i18n/locales/de.json
@@ -1915,6 +1915,7 @@
"events.backup_failed.title": "Ein Backup ist fehlgeschlagen",
"events.bridge_job_finished.title": "Bridge-Aufgabe beendet: {{name}}",
"events.bridge_job_finished.generate": "Figuren und Zusammenfassungen bereit für {{name}}",
+ "events.bridge_job_finished.describe": "Beschreibung bereit für {{name}}",
"events.bridge_job_failed.title": "Bridge-Aufgabe fehlgeschlagen: {{name}}",
"events.bridge_job_failed.attention": "Bridge-Aufgabe braucht Aufmerksamkeit: {{name}}",
"events.unknown": "Es ist etwas passiert ({{kind}})",
@@ -2709,6 +2710,7 @@
"bridge.unpair.confirm": "Entkoppeln",
"bridge.unpair.done": "Bridge entkoppelt",
"bridge.kind.sidecars.generate": "Figuren und Zusammenfassungen",
+ "bridge.kind.sidecars.describe": "Beschreibung",
"bridge.stage.queued": "Wartet",
"bridge.stage.inspecting": "Dateien werden geprüft",
"bridge.stage.splitting": "In Kapitel aufteilen",
@@ -2732,6 +2734,7 @@
"bridge.stage.waiting": "Wartet, bis es dran ist",
"bridge.stage.writing": "Wird im Ordner des Buchs gespeichert",
"bridge.stage.rescanning": "Server wird aktualisiert",
+ "bridge.stage.describing": "Beschreibung wird geschrieben",
"bridge.park.agent_unavailable": "Der Agent ist nicht verfügbar. Die Aufgabe läuft weiter, sobald er es ist.",
"bridge.park.agent_rate_limited": "Der Agent hat sein Nutzungslimit erreicht. Die Aufgabe läuft weiter, sobald es zurückgesetzt ist.",
"bridge.park.agent_validation_exhausted": "Die Antworten des Agenten haben die Prüfungen immer wieder nicht bestanden.",
@@ -2823,6 +2826,21 @@
"bridge.generate.contributed": "Community-Issue für die {{what}} ansehen",
"bridge.generate.offered.characters": "Figuren",
"bridge.generate.offered.recaps": "Zusammenfassungen",
+ "bridge.generate.descriptionMade": "eine Beschreibung",
+ "bridge.generate.noDescription": "Diesmal keine Beschreibung: {{reason}}",
+ "bridge.generate.noDescriptionPlain": "Diesmal keine Beschreibung.",
+ "bridge.describe.title": "Beschreibung",
+ "bridge.describe.noteAt": "Auf diesem Server erstellt, {{when}}.",
+ "bridge.describe.showMore": "Mehr anzeigen",
+ "bridge.describe.showLess": "Weniger anzeigen",
+ "bridge.describe.action": "Beschreibung schreiben",
+ "bridge.describe.cancelAria": "Schreiben der Beschreibung abbrechen",
+ "bridge.describe.dialog": "Beschreibung schreiben",
+ "bridge.describe.dialogBody": "Die Bridge schreibt eine kurze Beschreibung dieses Buchs ohne Spoiler, aus den Notizen, die sie beim Erstellen der Figuren und Zusammenfassungen behalten hat. Es läuft keine neue Spracherkennung.",
+ "bridge.describe.dialogCost": "Das kostet etwas Agentenzeit, weit weniger als das Erstellen.",
+ "bridge.describe.dialogWhere": "Die Beschreibung bleibt auf diesem Server und erscheint im Player unter Über das Buch.",
+ "bridge.describe.submit": "Beschreibung schreiben",
+ "bridge.describe.done": "Beschreibung geschrieben",
"bridge.series.action": "Für die Reihe erstellen",
"bridge.series.title": "Für {{name}} erstellen",
"bridge.series.description": "Figuren und Zusammenfassungen für jedes Buch ohne Zusammenfassungen, in Lesereihenfolge.",
@@ -2859,6 +2877,7 @@
"errors.jobFinished": "Diese Aufgabe ist schon beendet.",
"errors.libraryNotWritable": "Die Bridge kann nicht in diese Bibliothek schreiben. Prüfe ihre Zuordnung unter Einstellungen > Bridge.",
"errors.cannotContribute": "Die Bridge kann gerade nichts zur Community beitragen. Behalte die Ergebnisse auf diesem Server oder richte das Beitragen auf der Bridge ein.",
+ "errors.generateOpen": "Für dieses Buch läuft noch ein Erstellungsauftrag. Er schreibt die Beschreibung, wenn er fertig ist.",
"audit.area.bridge": "Bridge",
"audit.action.bridge.code": "Bridge-Kopplungscode erstellt",
"audit.action.bridge.paired": "Eine Bridge wurde gekoppelt",
diff --git a/admin-ui/src/i18n/locales/en.json b/admin-ui/src/i18n/locales/en.json
index 4d50f6f4..7e723f38 100644
--- a/admin-ui/src/i18n/locales/en.json
+++ b/admin-ui/src/i18n/locales/en.json
@@ -1915,6 +1915,7 @@
"events.backup_failed.title": "A backup failed",
"events.bridge_job_finished.title": "Bridge job finished: {{name}}",
"events.bridge_job_finished.generate": "Characters and recaps ready for {{name}}",
+ "events.bridge_job_finished.describe": "Description ready for {{name}}",
"events.bridge_job_failed.title": "Bridge job failed: {{name}}",
"events.bridge_job_failed.attention": "Bridge job needs attention: {{name}}",
"events.unknown": "Something happened ({{kind}})",
@@ -2709,6 +2710,7 @@
"bridge.unpair.confirm": "Unpair",
"bridge.unpair.done": "Bridge unpaired",
"bridge.kind.sidecars.generate": "Characters and recaps",
+ "bridge.kind.sidecars.describe": "Description",
"bridge.stage.queued": "Queued",
"bridge.stage.inspecting": "Inspecting the files",
"bridge.stage.splitting": "Splitting into chapters",
@@ -2732,6 +2734,7 @@
"bridge.stage.waiting": "Waiting its turn",
"bridge.stage.writing": "Saving to the book's folder",
"bridge.stage.rescanning": "Updating the server",
+ "bridge.stage.describing": "Writing the description",
"bridge.park.agent_unavailable": "The agent isn't available. The job carries on once it is.",
"bridge.park.agent_rate_limited": "The agent reached its usage limit. The job carries on once the limit resets.",
"bridge.park.agent_validation_exhausted": "The agent's answers kept failing the checks.",
@@ -2823,6 +2826,21 @@
"bridge.generate.contributed": "See the community issue for the {{what}}",
"bridge.generate.offered.characters": "characters",
"bridge.generate.offered.recaps": "recaps",
+ "bridge.generate.descriptionMade": "a description",
+ "bridge.generate.noDescription": "No description this time: {{reason}}",
+ "bridge.generate.noDescriptionPlain": "No description this time.",
+ "bridge.describe.title": "Description",
+ "bridge.describe.noteAt": "Generated on this server {{when}}.",
+ "bridge.describe.showMore": "Show more",
+ "bridge.describe.showLess": "Show less",
+ "bridge.describe.action": "Write a description",
+ "bridge.describe.cancelAria": "Cancel writing the description",
+ "bridge.describe.dialog": "Write a description",
+ "bridge.describe.dialogBody": "The Bridge writes a short, spoiler-free description of this book from the notes it kept when it generated the characters and recaps. No new speech recognition runs.",
+ "bridge.describe.dialogCost": "It uses a little agent time, far less than generating.",
+ "bridge.describe.dialogWhere": "The description stays on this server and appears in the book's About in the player.",
+ "bridge.describe.submit": "Write description",
+ "bridge.describe.done": "Description written",
"bridge.series.action": "Generate for the series",
"bridge.series.title": "Generate for {{name}}",
"bridge.series.description": "Characters and recaps for each book without recaps, in reading order.",
@@ -2859,6 +2877,7 @@
"errors.jobFinished": "That job has already finished.",
"errors.libraryNotWritable": "The Bridge can't write to this library. Check its mapping in Settings > Bridge.",
"errors.cannotContribute": "The Bridge can't contribute to the community right now. Keep the results on this server, or set up contributing on the Bridge.",
+ "errors.generateOpen": "A generate job is still running for this book. It writes the description when it finishes.",
"audit.area.bridge": "Bridge",
"audit.action.bridge.code": "Made a bridge pairing code",
"audit.action.bridge.paired": "A bridge paired",
diff --git a/admin-ui/src/i18n/locales/es.json b/admin-ui/src/i18n/locales/es.json
index 2cb9ae92..8ce04c4d 100644
--- a/admin-ui/src/i18n/locales/es.json
+++ b/admin-ui/src/i18n/locales/es.json
@@ -2047,6 +2047,7 @@
"events.backup_failed.title": "Ha fallado una copia de seguridad",
"events.bridge_job_finished.title": "Trabajo de la Bridge terminado: {{name}}",
"events.bridge_job_finished.generate": "Personajes y resúmenes listos para {{name}}",
+ "events.bridge_job_finished.describe": "Descripción lista para {{name}}",
"events.bridge_job_failed.title": "Trabajo de la Bridge fallido: {{name}}",
"events.bridge_job_failed.attention": "Trabajo de la Bridge que requiere atención: {{name}}",
"events.unknown": "Ha pasado algo ({{kind}})",
@@ -2854,6 +2855,7 @@
"bridge.unpair.confirm": "Desvincular",
"bridge.unpair.done": "Bridge desvinculada",
"bridge.kind.sidecars.generate": "Personajes y resúmenes",
+ "bridge.kind.sidecars.describe": "Descripción",
"bridge.stage.queued": "En cola",
"bridge.stage.inspecting": "Revisando los archivos",
"bridge.stage.splitting": "Dividiendo en capítulos",
@@ -2877,6 +2879,7 @@
"bridge.stage.waiting": "Esperando su turno",
"bridge.stage.writing": "Guardando en la carpeta del libro",
"bridge.stage.rescanning": "Actualizando el servidor",
+ "bridge.stage.describing": "Escribiendo la descripción",
"bridge.park.agent_unavailable": "El agente no está disponible. El trabajo sigue en cuanto lo esté.",
"bridge.park.agent_rate_limited": "El agente llegó a su límite de uso. El trabajo sigue cuando se restablezca.",
"bridge.park.agent_validation_exhausted": "Las respuestas del agente siguieron sin pasar las comprobaciones.",
@@ -2974,6 +2977,21 @@
"bridge.generate.contributed": "Ver la incidencia de la comunidad de los {{what}}",
"bridge.generate.offered.characters": "personajes",
"bridge.generate.offered.recaps": "resúmenes",
+ "bridge.generate.descriptionMade": "una descripción",
+ "bridge.generate.noDescription": "Esta vez sin descripción: {{reason}}",
+ "bridge.generate.noDescriptionPlain": "Esta vez sin descripción.",
+ "bridge.describe.title": "Descripción",
+ "bridge.describe.noteAt": "Generada en este servidor {{when}}.",
+ "bridge.describe.showMore": "Mostrar más",
+ "bridge.describe.showLess": "Mostrar menos",
+ "bridge.describe.action": "Escribir una descripción",
+ "bridge.describe.cancelAria": "Cancelar la escritura de la descripción",
+ "bridge.describe.dialog": "Escribir una descripción",
+ "bridge.describe.dialogBody": "La Bridge escribe una descripción breve y sin spoilers de este libro a partir de las notas que guardó al generar los personajes y resúmenes. No se ejecuta un nuevo reconocimiento de voz.",
+ "bridge.describe.dialogCost": "Usa un poco de tiempo del agente, mucho menos que generar.",
+ "bridge.describe.dialogWhere": "La descripción se queda en este servidor y aparece en Acerca de del libro en el reproductor.",
+ "bridge.describe.submit": "Escribir descripción",
+ "bridge.describe.done": "Descripción escrita",
"bridge.series.action": "Generar para la serie",
"bridge.series.title": "Generar para {{name}}",
"bridge.series.description": "Personajes y resúmenes para cada libro sin resúmenes, en orden de lectura.",
@@ -3017,6 +3035,7 @@
"errors.jobFinished": "Ese trabajo ya ha terminado.",
"errors.libraryNotWritable": "La Bridge no puede escribir en esta biblioteca. Revisa su asignación en Ajustes > Bridge.",
"errors.cannotContribute": "La Bridge no puede contribuir a la comunidad ahora. Guarda los resultados en este servidor o configura las contribuciones en la Bridge.",
+ "errors.generateOpen": "Todavía hay un trabajo de generación en curso para este libro. Escribe la descripción cuando termina.",
"audit.area.bridge": "Bridge",
"audit.action.bridge.code": "Creó un código de vinculación de bridge",
"audit.action.bridge.paired": "Se vinculó una bridge",
diff --git a/admin-ui/src/i18n/locales/fr.json b/admin-ui/src/i18n/locales/fr.json
index c702ce52..1f8cb089 100644
--- a/admin-ui/src/i18n/locales/fr.json
+++ b/admin-ui/src/i18n/locales/fr.json
@@ -2047,6 +2047,7 @@
"events.backup_failed.title": "Une sauvegarde a échoué",
"events.bridge_job_finished.title": "Tâche de la Bridge terminée : {{name}}",
"events.bridge_job_finished.generate": "Personnages et résumés prêts pour {{name}}",
+ "events.bridge_job_finished.describe": "Description prête pour {{name}}",
"events.bridge_job_failed.title": "Échec d'une tâche de la Bridge : {{name}}",
"events.bridge_job_failed.attention": "Tâche de la Bridge à examiner : {{name}}",
"events.unknown": "Un événement s'est produit ({{kind}})",
@@ -2854,6 +2855,7 @@
"bridge.unpair.confirm": "Dissocier",
"bridge.unpair.done": "Bridge dissociée",
"bridge.kind.sidecars.generate": "Personnages et résumés",
+ "bridge.kind.sidecars.describe": "Description",
"bridge.stage.queued": "En file",
"bridge.stage.inspecting": "Examen des fichiers",
"bridge.stage.splitting": "Découpage en chapitres",
@@ -2877,6 +2879,7 @@
"bridge.stage.waiting": "En attente de son tour",
"bridge.stage.writing": "Enregistrement dans le dossier du livre",
"bridge.stage.rescanning": "Mise à jour du serveur",
+ "bridge.stage.describing": "Rédaction de la description",
"bridge.park.agent_unavailable": "L'agent n'est pas disponible. La tâche reprend dès qu'il l'est.",
"bridge.park.agent_rate_limited": "L'agent a atteint sa limite d'utilisation. La tâche reprend quand elle est réinitialisée.",
"bridge.park.agent_validation_exhausted": "Les réponses de l'agent échouaient toujours aux vérifications.",
@@ -2974,6 +2977,21 @@
"bridge.generate.contributed": "Voir le ticket communautaire des {{what}}",
"bridge.generate.offered.characters": "personnages",
"bridge.generate.offered.recaps": "résumés",
+ "bridge.generate.descriptionMade": "une description",
+ "bridge.generate.noDescription": "Pas de description cette fois : {{reason}}",
+ "bridge.generate.noDescriptionPlain": "Pas de description cette fois.",
+ "bridge.describe.title": "Description",
+ "bridge.describe.noteAt": "Générée sur ce serveur {{when}}.",
+ "bridge.describe.showMore": "Afficher plus",
+ "bridge.describe.showLess": "Afficher moins",
+ "bridge.describe.action": "Rédiger une description",
+ "bridge.describe.cancelAria": "Annuler la rédaction de la description",
+ "bridge.describe.dialog": "Rédiger une description",
+ "bridge.describe.dialogBody": "La Bridge rédige une courte description sans divulgâchage de ce livre à partir des notes qu'elle a gardées en générant les personnages et résumés. Aucune nouvelle reconnaissance vocale n'est lancée.",
+ "bridge.describe.dialogCost": "Cela utilise un peu de temps d'agent, bien moins qu'une génération.",
+ "bridge.describe.dialogWhere": "La description reste sur ce serveur et apparaît dans la section À propos du livre dans le lecteur.",
+ "bridge.describe.submit": "Rédiger la description",
+ "bridge.describe.done": "Description rédigée",
"bridge.series.action": "Générer pour la série",
"bridge.series.title": "Générer pour {{name}}",
"bridge.series.description": "Personnages et résumés pour chaque livre sans résumés, dans l'ordre de lecture.",
@@ -3017,6 +3035,7 @@
"errors.jobFinished": "Cette tâche est déjà terminée.",
"errors.libraryNotWritable": "La Bridge ne peut pas écrire dans cette bibliothèque. Vérifiez sa correspondance dans Réglages > Bridge.",
"errors.cannotContribute": "La Bridge ne peut pas contribuer à la communauté pour l'instant. Gardez les résultats sur ce serveur, ou configurez les contributions sur la Bridge.",
+ "errors.generateOpen": "Une tâche de génération est encore en cours pour ce livre. Elle rédige la description à la fin.",
"audit.area.bridge": "Bridge",
"audit.action.bridge.code": "A créé un code d'association de bridge",
"audit.action.bridge.paired": "Une bridge a été associée",
diff --git a/admin-ui/src/i18n/locales/it.json b/admin-ui/src/i18n/locales/it.json
index 8f55323e..c95a1d26 100644
--- a/admin-ui/src/i18n/locales/it.json
+++ b/admin-ui/src/i18n/locales/it.json
@@ -2047,6 +2047,7 @@
"events.backup_failed.title": "Un backup non è riuscito",
"events.bridge_job_finished.title": "Lavoro della Bridge finito: {{name}}",
"events.bridge_job_finished.generate": "Personaggi e riassunti pronti per {{name}}",
+ "events.bridge_job_finished.describe": "Descrizione pronta per {{name}}",
"events.bridge_job_failed.title": "Lavoro della Bridge non riuscito: {{name}}",
"events.bridge_job_failed.attention": "Lavoro della Bridge richiede attenzione: {{name}}",
"events.unknown": "È successo qualcosa ({{kind}})",
@@ -2854,6 +2855,7 @@
"bridge.unpair.confirm": "Scollega",
"bridge.unpair.done": "Bridge scollegata",
"bridge.kind.sidecars.generate": "Personaggi e riassunti",
+ "bridge.kind.sidecars.describe": "Descrizione",
"bridge.stage.queued": "In coda",
"bridge.stage.inspecting": "Esame dei file",
"bridge.stage.splitting": "Divisione in capitoli",
@@ -2877,6 +2879,7 @@
"bridge.stage.waiting": "In attesa del suo turno",
"bridge.stage.writing": "Salvataggio nella cartella del libro",
"bridge.stage.rescanning": "Aggiornamento del server",
+ "bridge.stage.describing": "Scrittura della descrizione",
"bridge.park.agent_unavailable": "L'agente non è disponibile. Il lavoro riprende appena lo è.",
"bridge.park.agent_rate_limited": "L'agente ha raggiunto il limite di utilizzo. Il lavoro riprende quando il limite si azzera.",
"bridge.park.agent_validation_exhausted": "Le risposte dell'agente continuavano a non superare i controlli.",
@@ -2974,6 +2977,21 @@
"bridge.generate.contributed": "Vedi la segnalazione nella community per i {{what}}",
"bridge.generate.offered.characters": "personaggi",
"bridge.generate.offered.recaps": "riassunti",
+ "bridge.generate.descriptionMade": "una descrizione",
+ "bridge.generate.noDescription": "Nessuna descrizione questa volta: {{reason}}",
+ "bridge.generate.noDescriptionPlain": "Nessuna descrizione questa volta.",
+ "bridge.describe.title": "Descrizione",
+ "bridge.describe.noteAt": "Generata su questo server {{when}}.",
+ "bridge.describe.showMore": "Mostra di più",
+ "bridge.describe.showLess": "Mostra meno",
+ "bridge.describe.action": "Scrivi una descrizione",
+ "bridge.describe.cancelAria": "Annulla la scrittura della descrizione",
+ "bridge.describe.dialog": "Scrivi una descrizione",
+ "bridge.describe.dialogBody": "La Bridge scrive una breve descrizione senza spoiler di questo libro dalle note che ha conservato generando personaggi e riassunti. Non viene eseguito un nuovo riconoscimento vocale.",
+ "bridge.describe.dialogCost": "Usa un po' di tempo dell'agente, molto meno della generazione.",
+ "bridge.describe.dialogWhere": "La descrizione resta su questo server e compare nelle informazioni del libro nel player.",
+ "bridge.describe.submit": "Scrivi la descrizione",
+ "bridge.describe.done": "Descrizione scritta",
"bridge.series.action": "Genera per la serie",
"bridge.series.title": "Genera per {{name}}",
"bridge.series.description": "Personaggi e riassunti per ogni libro senza riassunti, in ordine di lettura.",
@@ -3017,6 +3035,7 @@
"errors.jobFinished": "Quel lavoro è già finito.",
"errors.libraryNotWritable": "La Bridge non può scrivere in questa libreria. Controlla l'associazione in Impostazioni > Bridge.",
"errors.cannotContribute": "La Bridge non può contribuire alla community ora. Tieni i risultati su questo server o configura i contributi sulla Bridge.",
+ "errors.generateOpen": "Per questo libro è ancora in corso un lavoro di generazione. Scrive la descrizione quando finisce.",
"audit.area.bridge": "Bridge",
"audit.action.bridge.code": "Ha creato un codice di abbinamento della bridge",
"audit.action.bridge.paired": "Una bridge è stata abbinata",
diff --git a/admin-ui/src/i18n/locales/pt.json b/admin-ui/src/i18n/locales/pt.json
index fe7f340d..6b75b86a 100644
--- a/admin-ui/src/i18n/locales/pt.json
+++ b/admin-ui/src/i18n/locales/pt.json
@@ -2047,6 +2047,7 @@
"events.backup_failed.title": "Uma cópia de segurança falhou",
"events.bridge_job_finished.title": "Trabalho da Bridge terminado: {{name}}",
"events.bridge_job_finished.generate": "Personagens e resumos prontos para {{name}}",
+ "events.bridge_job_finished.describe": "Descrição pronta para {{name}}",
"events.bridge_job_failed.title": "Trabalho da Bridge falhou: {{name}}",
"events.bridge_job_failed.attention": "Trabalho da Bridge requer atenção: {{name}}",
"events.unknown": "Aconteceu algo ({{kind}})",
@@ -2854,6 +2855,7 @@
"bridge.unpair.confirm": "Desemparelhar",
"bridge.unpair.done": "Bridge desemparelhada",
"bridge.kind.sidecars.generate": "Personagens e resumos",
+ "bridge.kind.sidecars.describe": "Descrição",
"bridge.stage.queued": "Em fila",
"bridge.stage.inspecting": "A examinar os ficheiros",
"bridge.stage.splitting": "A dividir em capítulos",
@@ -2877,6 +2879,7 @@
"bridge.stage.waiting": "À espera da sua vez",
"bridge.stage.writing": "A guardar na pasta do livro",
"bridge.stage.rescanning": "A atualizar o servidor",
+ "bridge.stage.describing": "A escrever a descrição",
"bridge.park.agent_unavailable": "O agente não está disponível. O trabalho continua assim que estiver.",
"bridge.park.agent_rate_limited": "O agente atingiu o limite de utilização. O trabalho continua quando o limite for reposto.",
"bridge.park.agent_validation_exhausted": "As respostas do agente continuaram a falhar as verificações.",
@@ -2974,6 +2977,21 @@
"bridge.generate.contributed": "Ver o pedido na comunidade dos {{what}}",
"bridge.generate.offered.characters": "personagens",
"bridge.generate.offered.recaps": "resumos",
+ "bridge.generate.descriptionMade": "uma descrição",
+ "bridge.generate.noDescription": "Sem descrição desta vez: {{reason}}",
+ "bridge.generate.noDescriptionPlain": "Sem descrição desta vez.",
+ "bridge.describe.title": "Descrição",
+ "bridge.describe.noteAt": "Gerada neste servidor {{when}}.",
+ "bridge.describe.showMore": "Mostrar mais",
+ "bridge.describe.showLess": "Mostrar menos",
+ "bridge.describe.action": "Escrever uma descrição",
+ "bridge.describe.cancelAria": "Cancelar a escrita da descrição",
+ "bridge.describe.dialog": "Escrever uma descrição",
+ "bridge.describe.dialogBody": "A Bridge escreve uma descrição curta e sem spoilers deste livro a partir das notas que guardou ao gerar as personagens e os resumos. Não é feito um novo reconhecimento de voz.",
+ "bridge.describe.dialogCost": "Usa um pouco de tempo do agente, muito menos do que gerar.",
+ "bridge.describe.dialogWhere": "A descrição fica neste servidor e aparece em Sobre do livro no leitor.",
+ "bridge.describe.submit": "Escrever descrição",
+ "bridge.describe.done": "Descrição escrita",
"bridge.series.action": "Gerar para a série",
"bridge.series.title": "Gerar para {{name}}",
"bridge.series.description": "Personagens e resumos para cada livro sem resumos, por ordem de leitura.",
@@ -3017,6 +3035,7 @@
"errors.jobFinished": "Esse trabalho já terminou.",
"errors.libraryNotWritable": "A Bridge não consegue escrever nesta biblioteca. Verifique a associação em Definições > Bridge.",
"errors.cannotContribute": "A Bridge não pode contribuir para a comunidade agora. Mantenha os resultados neste servidor ou configure as contribuições na Bridge.",
+ "errors.generateOpen": "Ainda há uma tarefa de geração em curso para este livro. Escreve a descrição quando terminar.",
"audit.area.bridge": "Bridge",
"audit.action.bridge.code": "Criou um código de emparelhamento da bridge",
"audit.action.bridge.paired": "Uma bridge foi emparelhada",
diff --git a/admin-ui/src/lib/errors.ts b/admin-ui/src/lib/errors.ts
index 4d1d53fd..e9060971 100644
--- a/admin-ui/src/lib/errors.ts
+++ b/admin-ui/src/lib/errors.ts
@@ -74,6 +74,9 @@ const BY_CODE: Record = {
// Queueing sidecars.generate with contribute "issue" on a Bridge that last
// said it can't contribute (or sent no figures).
cannot_contribute: 'errors.cannotContribute',
+ // Queueing sidecars.describe while a sidecars.generate job is open for the
+ // book (it writes the description when it finishes).
+ generate_open: 'errors.generateOpen',
};
/** A failure as one sentence for a toast or a field. */
diff --git a/admin-ui/src/lib/server-events.test.ts b/admin-ui/src/lib/server-events.test.ts
index 0a4ffdaf..b9cdbdb2 100644
--- a/admin-ui/src/lib/server-events.test.ts
+++ b/admin-ui/src/lib/server-events.test.ts
@@ -80,6 +80,14 @@ describe('Bridge job events', () => {
});
});
+ it('words a finished description job as the description', () => {
+ expect(
+ describeEvent(
+ ev('bridge_job_finished', { ...job, job_kind: 'sidecars.describe', state: 'done' }),
+ ).title,
+ ).toEqual({ key: 'events.bridge_job_finished.describe', values: { name: 'The Way of Kings' } });
+ });
+
it('words another kind generically, naming the kind', () => {
const d = describeEvent(
ev('bridge_job_finished', { ...job, job_kind: 'bridge.verify', title: '', state: 'done' }),
diff --git a/admin-ui/src/lib/server-events.ts b/admin-ui/src/lib/server-events.ts
index a14ca43b..e6cf62c0 100644
--- a/admin-ui/src/lib/server-events.ts
+++ b/admin-ui/src/lib/server-events.ts
@@ -1,5 +1,5 @@
import type { ServerEvent, ServerEventKind } from '@/api/types';
-import { KIND_GENERATE, parkKey } from '@/features/bridge/generate-model';
+import { KIND_DESCRIBE, KIND_GENERATE, parkKey } from '@/features/bridge/generate-model';
import type { SettingsPage } from '@/features/settings/settings-model';
import { readStorage, writeStorage } from './storage';
@@ -145,12 +145,14 @@ function describeBridgeJob(
? { key: `bridge.kind.${jobKind}`, values: { defaultValue: jobKind } }
: undefined;
if (kind === 'bridge_job_finished') {
- return jobKind === KIND_GENERATE
- ? {
- title: { key: 'events.bridge_job_finished.generate', values: { name } },
- tone: 'info',
- link,
- }
+ const own =
+ jobKind === KIND_GENERATE
+ ? 'events.bridge_job_finished.generate'
+ : jobKind === KIND_DESCRIBE
+ ? 'events.bridge_job_finished.describe'
+ : undefined;
+ return own
+ ? { title: { key: own, values: { name } }, tone: 'info', link }
: {
title: { key: 'events.bridge_job_finished.title', values: { name } },
body: kindBody,
diff --git a/internal/api/bridge_attachments_test.go b/internal/api/bridge_attachments_test.go
index 02e3709b..d5bf8344 100644
--- a/internal/api/bridge_attachments_test.go
+++ b/internal/api/bridge_attachments_test.go
@@ -59,7 +59,7 @@ func TestBridgeRescanAttachments(t *testing.T) {
t.Fatal(err)
}
code, body := b.rescan(t, fmt.Sprintf(`{"library_id":%d,"path":%q,"mode":"attachments"}`, b.lib.ID, mistbornBook))
- want := fmt.Sprintf(`{"path":%q,"attachments":{"characters":true,"recaps":false,"bridge":true}}`, mistbornBook) + "\n"
+ want := fmt.Sprintf(`{"path":%q,"attachments":{"characters":true,"recaps":false,"description":false,"bridge":true}}`, mistbornBook) + "\n"
if code != http.StatusOK || body != want {
t.Fatalf("attachments rescan = %d %s, want %s", code, body, want)
}
@@ -70,6 +70,14 @@ func TestBridgeRescanAttachments(t *testing.T) {
if a, err := b.cat.BookAttachments(context.Background(), book.ID); err != nil || a == nil || a.Characters == nil || a.Provenance.JobID != 3 {
t.Errorf("stored = %+v, %v", a, err)
}
+ // A description the Bridge wrote since is answered too (Phase 1c).
+ if err := os.WriteFile(filepath.Join(dir, "description.json"), []byte(fmt.Sprintf(`{"work":"w","text":%q}`, testDescriptionText)), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ code, body = b.rescan(t, fmt.Sprintf(`{"library_id":%d,"path":%q,"mode":"attachments"}`, b.lib.ID, mistbornBook))
+ if want := `"attachments":{"characters":true,"recaps":false,"description":true,"bridge":true}`; code != http.StatusOK || !strings.Contains(body, want) {
+ t.Fatalf("attachments rescan with a description = %d %s, want %s", code, body, want)
+ }
for _, p := range []string{"Nobody/Here", mistbornBook + "/.audiosilo", "../etc"} {
if code, body := b.rescan(t, fmt.Sprintf(`{"library_id":%d,"path":%q,"mode":"attachments"}`, b.lib.ID, p)); code != http.StatusNotFound {
t.Errorf("attachments rescan of %q = %d %s, want 404", p, code, body)
diff --git a/internal/api/bridge_describe_test.go b/internal/api/bridge_describe_test.go
new file mode 100644
index 00000000..631c57bc
--- /dev/null
+++ b/internal/api/bridge_describe_test.go
@@ -0,0 +1,334 @@
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/kodestar/audiosilo-meta/pkg/model"
+
+ "github.com/kodestar/audiosilo-server/internal/auth"
+ "github.com/kodestar/audiosilo-server/internal/bridge"
+ "github.com/kodestar/audiosilo-server/internal/catalog"
+ "github.com/kodestar/audiosilo-server/internal/metadata"
+)
+
+// Bridge Phase 1c: own-words descriptions. The sidecars.describe kind, the
+// estimate's can_describe and has.*.description, the console's book detail and
+// /meta's generated_description.
+
+// testDescriptionText is a description the server takes (200 to 1500 code
+// points, no control character but a line break).
+var testDescriptionText = strings.Repeat("A harbour town waits out a long winter. ", 6)
+
+// beatDescribe sends a heartbeat offering verify, generate and describe, the
+// env's library mapped and writable as given.
+func (b *bridgeEnv) beatDescribe(t *testing.T, writable bool) {
+ t.Helper()
+ body := fmt.Sprintf(`{"version":"0.17.0","kinds":["bridge.verify","sidecars.generate","sidecars.describe"],
+ "libraries":[{"library_id":%d,"mount_path":"/media/books","mapped":true,"writable":%t}]}`, b.lib.ID, writable)
+ if resp, out := b.do(t, "PUT", "/api/v1/bridge/status", b.bridgeTok, body); resp.StatusCode != http.StatusOK {
+ t.Fatalf("heartbeat: %d %s", resp.StatusCode, out)
+ }
+}
+
+func (b *bridgeEnv) queueDescribe(t *testing.T, tok, path, params string) (*http.Response, string) {
+ t.Helper()
+ body := fmt.Sprintf(`{"kind":"sidecars.describe","library_id":%d,"path":%q`, b.lib.ID, path)
+ if params != "" {
+ body += `,"params":` + params
+ }
+ return b.do(t, "POST", "/api/v1/admin/bridge/jobs", tok, body+"}")
+}
+
+// TestBridgeDescribeQueue: sidecars.describe takes no params ({} or none; any
+// field is a 400), needs a book, a Bridge that runs it (409 kind_unavailable)
+// and a library it can write to (409 library_not_writable), and is refused while
+// a sidecars.generate job is open for the book (409 generate_open), which writes
+// the description itself; a generate job is never refused for an open describe.
+// The route is the admin's: a user is 403, the bridge token 401.
+func TestBridgeDescribeQueue(t *testing.T) {
+ t.Parallel()
+ b := newBridgeEnv(t)
+ // Denied: a Bridge that doesn't run the kind (an older one).
+ b.beatWith(t, true, true, "")
+ if resp, body := b.queueDescribe(t, b.adminTok, mistbornBook, `{}`); resp.StatusCode != http.StatusConflict ||
+ !strings.Contains(body, `"code":"kind_unavailable"`) {
+ t.Fatalf("an older Bridge: %d %s, want 409 kind_unavailable", resp.StatusCode, body)
+ }
+ b.beatDescribe(t, true)
+ _, body := b.do(t, "GET", "/api/v1/server", "", "")
+ if !strings.Contains(body, `"features":["bridge","sidecars"]`) {
+ t.Fatalf("features = %s", body)
+ }
+
+ // Allowed: params absent or {}, stored as {}; a second press is the open job.
+ resp, body := b.queueDescribe(t, b.adminTok, mistbornBook, "")
+ var job bridge.Job
+ decodeInto(t, body, &job)
+ if resp.StatusCode != http.StatusCreated || job.Kind != bridge.KindDescribe || string(job.Params) != `{}` {
+ t.Fatalf("queue: %d %s", resp.StatusCode, body)
+ }
+ if resp, body := b.queueDescribe(t, b.adminTok, mistbornBook, `{}`); resp.StatusCode != http.StatusOK || !strings.Contains(body, fmt.Sprintf(`"id":%d,`, job.ID)) {
+ t.Fatalf("second press: %d %s, want the open job", resp.StatusCode, body)
+ }
+ for name, c := range map[string]struct {
+ body string
+ want int
+ }{
+ "a param": {fmt.Sprintf(`{"kind":"sidecars.describe","library_id":%d,"path":%q,"params":{"source":"audio"}}`, b.lib.ID, cradleBook), http.StatusBadRequest},
+ "not an object": {fmt.Sprintf(`{"kind":"sidecars.describe","library_id":%d,"path":%q,"params":[]}`, b.lib.ID, cradleBook), http.StatusBadRequest},
+ "no path": {fmt.Sprintf(`{"kind":"sidecars.describe","library_id":%d}`, b.lib.ID), http.StatusBadRequest},
+ "no such book": {fmt.Sprintf(`{"kind":"sidecars.describe","library_id":%d,"path":"Nobody/Nothing"}`, b.lib.ID), http.StatusNotFound},
+ } {
+ if resp, out := b.do(t, "POST", "/api/v1/admin/bridge/jobs", b.adminTok, c.body); resp.StatusCode != c.want {
+ t.Errorf("%s: %d %s, want %d", name, resp.StatusCode, out, c.want)
+ }
+ }
+ // Denied: a user, and the bridge token.
+ if resp, body := b.queueDescribe(t, b.userTok, cradleBook, `{}`); resp.StatusCode != http.StatusForbidden {
+ t.Errorf("a user: %d %s, want 403", resp.StatusCode, body)
+ }
+ if resp, body := b.queueDescribe(t, b.bridgeTok, cradleBook, `{}`); resp.StatusCode != http.StatusUnauthorized {
+ t.Errorf("the bridge token: %d %s, want 401", resp.StatusCode, body)
+ }
+
+ // A generate job is never refused for the open describe job (it supersedes it).
+ if resp, body := b.queueSidecars(t, b.lib.ID, mistbornBook, `{}`); resp.StatusCode != http.StatusCreated {
+ t.Fatalf("generate beside an open describe: %d %s", resp.StatusCode, body)
+ }
+ // Denied: a describe for a book whose generate job is open.
+ if resp, body := b.queueSidecars(t, b.lib.ID, cradleBook, `{}`); resp.StatusCode != http.StatusCreated {
+ t.Fatalf("generate: %d %s", resp.StatusCode, body)
+ }
+ if resp, body := b.queueDescribe(t, b.adminTok, cradleBook, `{}`); resp.StatusCode != http.StatusConflict ||
+ !strings.Contains(body, `"code":"generate_open"`) {
+ t.Fatalf("describe with a generate open: %d %s, want 409 generate_open", resp.StatusCode, body)
+ }
+ // Denied: a library the Bridge can't write to (the open job still answers).
+ b.beatDescribe(t, false)
+ if resp, body := b.queueDescribe(t, b.adminTok, mistbornBook, `{}`); resp.StatusCode != http.StatusOK {
+ t.Fatalf("the open job on a read-only library: %d %s", resp.StatusCode, body)
+ }
+ if _, err := b.api.bridge.Cancel(context.Background(), job.ID); err != nil {
+ t.Fatal(err)
+ }
+ if resp, body := b.queueDescribe(t, b.adminTok, mistbornBook, `{}`); resp.StatusCode != http.StatusConflict ||
+ !strings.Contains(body, `"code":"library_not_writable"`) {
+ t.Fatalf("a read-only library: %d %s, want 409 library_not_writable", resp.StatusCode, body)
+ }
+}
+
+// TestBridgeEstimateDescribe: the estimate (brief or full) says whether the
+// Bridge can write a description (online and running sidecars.describe) and
+// whether the book has one, generated (its attachments) or from the community
+// (community_description; the core description doesn't count).
+func TestBridgeEstimateDescribe(t *testing.T) {
+ t.Parallel()
+ b := newBridgeEnvOn(t, generatedMetaEnv(t, `{"id":"w","title":"Community Title","authors":[],"language":"en",
+ "description":"The publisher's blurb.","community_description":{"text":"A community description.","license":"CC-BY-SA-4.0"}}`))
+ ctx := context.Background()
+ for _, bk := range []catalog.Book{
+ {RelPath: "A/Matched", ASIN: "B0MATCHED"},
+ {RelPath: "A/Local"},
+ } {
+ bk.LibraryID, bk.Title, bk.AddedAt = b.lib.ID, bk.RelPath, "2020-01-01"
+ if _, err := b.cat.UpsertBook(ctx, &bk); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := b.cat.SetAttachments(ctx, b.lib.ID, map[string]catalog.AttachmentsReading{
+ "A/Local": {Sig: "description.json:1:1", Files: metadata.AttachmentFiles{
+ Description: &model.Description{Work: "w", Text: testDescriptionText, License: "CC-BY-SA-4.0"}}},
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ // An older Bridge: no description offered.
+ b.beatWith(t, true, true, "")
+ if est := b.briefEstimate(t, b.lib.ID, "A/Local"); est.CanDescribe || !est.Has.Local.Description {
+ t.Fatalf("older Bridge = %+v", est)
+ }
+ b.beatDescribe(t, true)
+ for name, extra := range map[string]string{"brief": "&brief=1", "full": ""} {
+ resp, est, body := b.estimateQuery(t, b.adminTok, b.lib.ID, "A/Local", extra)
+ if resp.StatusCode != http.StatusOK || !est.CanDescribe || !est.Has.Local.Description || est.Has.Community.Description ||
+ !strings.Contains(body, `"can_describe":true`) {
+ t.Fatalf("%s, local: %d %s", name, resp.StatusCode, body)
+ }
+ resp, est, body = b.estimateQuery(t, b.adminTok, b.lib.ID, "A/Matched", extra)
+ if resp.StatusCode != http.StatusOK || !est.Has.Community.Description || est.Has.Local.Description || est.Has.CommunityUnknown {
+ t.Fatalf("%s, matched: %d %s", name, resp.StatusCode, body)
+ }
+ }
+ // Offline: the kind is still advertised by the last heartbeat, but the
+ // Bridge couldn't run it now.
+ old := time.Now().Add(-bridge.OnlineWindow - time.Second).UTC().Format(time.RFC3339)
+ if _, err := b.db.ExecContext(ctx, `UPDATE bridge_status SET heartbeat_at = ?`, old); err != nil {
+ t.Fatal(err)
+ }
+ b.api.bridge.Forget()
+ if est := b.briefEstimate(t, b.lib.ID, "A/Local"); est.CanDescribe {
+ t.Fatalf("offline = %+v, want can_describe false", est)
+ }
+}
+
+// TestBridgeEstimateNoCommunityDescription: a community work with only the core
+// description has no community description (the console offers one).
+func TestBridgeEstimateNoCommunityDescription(t *testing.T) {
+ t.Parallel()
+ b := newBridgeEnvOn(t, generatedMetaEnv(t, `{"id":"w","title":"Community Title","authors":[],"language":"en",
+ "description":"The publisher's blurb."}`))
+ bk := catalog.Book{LibraryID: b.lib.ID, RelPath: "A/Matched", Title: "Matched", ASIN: "B0MATCHED", AddedAt: "2020-01-01"}
+ if _, err := b.cat.UpsertBook(context.Background(), &bk); err != nil {
+ t.Fatal(err)
+ }
+ b.beatDescribe(t, true)
+ if est := b.briefEstimate(t, b.lib.ID, "A/Matched"); est.Has.Community.Description || est.Has.CommunityUnknown {
+ t.Fatalf("core description only = %+v", est.Has)
+ }
+}
+
+// TestAdminBookGeneratedDescription: the console's book detail carries the
+// book's generated description with its own time, only when it has one; the
+// route is the admin's.
+func TestAdminBookGeneratedDescription(t *testing.T) {
+ t.Parallel()
+ b := newBridgeEnv(t)
+ ctx := context.Background()
+ if err := b.cat.SetAttachments(ctx, b.lib.ID, map[string]catalog.AttachmentsReading{
+ mistbornBook: {Sig: "description.json:1:1,bridge.json:1:1", Files: metadata.AttachmentFiles{
+ Description: &model.Description{Work: "w", Text: testDescriptionText, License: "CC-BY-SA-4.0"},
+ Provenance: &metadata.AttachmentProvenance{GeneratedAt: "2026-10-15T10:00:00Z", DescriptionAt: "2026-10-16T08:00:00Z"}}},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ detail := func(tok, path string) (*http.Response, string) {
+ return b.do(t, "GET", fmt.Sprintf("/api/v1/admin/libraries/%d/book?path=%s", b.lib.ID, url.QueryEscape(path)), tok, "")
+ }
+ resp, body := detail(b.adminTok, mistbornBook)
+ var d struct {
+ GeneratedDescription *struct {
+ Text string `json:"text"`
+ GeneratedAt string `json:"generated_at"`
+ } `json:"generated_description"`
+ }
+ decodeInto(t, body, &d)
+ if resp.StatusCode != http.StatusOK || d.GeneratedDescription == nil || d.GeneratedDescription.Text != testDescriptionText ||
+ d.GeneratedDescription.GeneratedAt != "2026-10-16T08:00:00Z" {
+ t.Fatalf("detail: %d %s", resp.StatusCode, body)
+ }
+ if resp, body := detail(b.adminTok, cradleBook); resp.StatusCode != http.StatusOK || strings.Contains(body, "generated_description") {
+ t.Fatalf("detail without one: %d %s", resp.StatusCode, body)
+ }
+ if resp, _ := detail(b.userTok, mistbornBook); resp.StatusCode != http.StatusForbidden {
+ t.Fatalf("a user: %d, want 403", resp.StatusCode)
+ }
+}
+
+// setDescription stores a generated description (alone, or beside what is
+// stored: files) for the book at path.
+func setDescription(t *testing.T, e *testEnv, libID int64, path, sig string, files metadata.AttachmentFiles) {
+ t.Helper()
+ files.Description = &model.Description{Work: "w", Text: testDescriptionText, License: "CC-BY-SA-4.0"}
+ if err := e.cat.SetAttachments(context.Background(), libID, map[string]catalog.AttachmentsReading{path: {Sig: sig, Files: files}}); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// descriptionBody is the slice of /meta the description tests read.
+type descriptionBody struct {
+ Matched bool `json:"matched"`
+ Work *struct {
+ Title string `json:"title"`
+ Description string `json:"description"`
+ CommunityDescription *json.RawMessage `json:"community_description"`
+ GeneratedDescription *struct {
+ Text string `json:"text"`
+ } `json:"generated_description"`
+ Attribution *json.RawMessage `json:"attribution"`
+ Generated *struct {
+ Characters bool `json:"characters"`
+ Recaps bool `json:"recaps"`
+ Description bool `json:"description"`
+ } `json:"generated"`
+ } `json:"work"`
+}
+
+func getDescription(t *testing.T, e *testEnv, libID int64, path, query, tok string) (descriptionBody, string) {
+ t.Helper()
+ resp, body := e.do(t, "GET", "/api/v1/libraries/"+strconv.FormatInt(libID, 10)+"/meta?path="+escape(path)+query, tok, "")
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("meta %s: %d %s", path, resp.StatusCode, body)
+ }
+ var g descriptionBody
+ decodeInto(t, body, &g)
+ return g, body
+}
+
+// TestMetaGeneratedDescriptionMatched: a matched work with no community
+// description takes the generated one in generated_description (never
+// community_description), beside its core description, marked generated, with
+// no attribution of its own; spoilers=hide keeps it. A work with a community
+// description ignores it.
+func TestMetaGeneratedDescriptionMatched(t *testing.T) {
+ t.Parallel()
+ e := generatedMetaEnv(t, `{"id":"w","title":"Community Title","authors":[],"language":"en","description":"The publisher's blurb."}`)
+ lib := seedChapteredBook(t, e, "A/Two", "B0TWO")
+ setDescription(t, e, lib.ID, "A/Two", "description.json:1:1", metadata.AttachmentFiles{})
+ tok, _ := e.auth.IssueToken(context.Background(), e.adminID, auth.KindSession, "t", 0)
+
+ for _, q := range []string{"", "&spoilers=hide"} {
+ g, body := getDescription(t, e, lib.ID, "A/Two", q, tok)
+ if !g.Matched || g.Work == nil || g.Work.GeneratedDescription == nil || g.Work.GeneratedDescription.Text != testDescriptionText ||
+ g.Work.CommunityDescription != nil || g.Work.Description != "The publisher's blurb." {
+ t.Fatalf("matched (%q) = %s", q, body)
+ }
+ if gen := g.Work.Generated; gen == nil || !gen.Description || gen.Characters || gen.Recaps || g.Work.Attribution != nil {
+ t.Errorf("matched (%q): generated = %+v, attribution = %v", q, gen, g.Work.Attribution != nil)
+ }
+ }
+
+ c := generatedMetaEnv(t, `{"id":"w","title":"Community Title","authors":[],"language":"en",
+ "community_description":{"text":"A community description.","license":"CC-BY-SA-4.0"}}`)
+ clib := seedChapteredBook(t, c, "A/Two", "B0TWO")
+ setDescription(t, c, clib.ID, "A/Two", "description.json:1:1", metadata.AttachmentFiles{})
+ ctok, _ := c.auth.IssueToken(context.Background(), c.adminID, auth.KindSession, "t", 0)
+ if g, body := getDescription(t, c, clib.ID, "A/Two", "", ctok); g.Work == nil || g.Work.GeneratedDescription != nil ||
+ g.Work.CommunityDescription == nil || g.Work.Generated != nil || g.Work.Attribution == nil {
+ t.Fatalf("community description = %s", body)
+ }
+}
+
+// TestMetaGeneratedDescriptionUnmatched: a book with no match and only a
+// generated description answers matched false with a work of the book's title
+// and the description, no attribution, kept under spoilers=hide; beside
+// generated characters the mark names both.
+func TestMetaGeneratedDescriptionUnmatched(t *testing.T) {
+ t.Parallel()
+ e := newMetaEnv(t, true, 0)
+ lib := seedChapteredBook(t, e, "A/Two", "")
+ setDescription(t, e, lib.ID, "A/Two", "description.json:1:1", metadata.AttachmentFiles{})
+ tok, _ := e.auth.IssueToken(context.Background(), e.adminID, auth.KindSession, "t", 0)
+ for _, q := range []string{"", "&spoilers=hide"} {
+ g, body := getDescription(t, e, lib.ID, "A/Two", q, tok)
+ if g.Matched || g.Work == nil || g.Work.Title != "Two" || g.Work.GeneratedDescription == nil || g.Work.Attribution != nil ||
+ g.Work.Generated == nil || !g.Work.Generated.Description || g.Work.Generated.Characters {
+ t.Fatalf("unmatched (%q) = %s", q, body)
+ }
+ if !strings.HasPrefix(body, `{"matched":false,"work":{`) {
+ t.Errorf("unmatched (%q) shape = %s", q, body)
+ }
+ }
+ setDescription(t, e, lib.ID, "A/Two", "characters.json:1:1,description.json:1:1", metadata.AttachmentFiles{
+ Characters: &model.Characters{Work: "w", Characters: []model.Character{{ID: "c", Name: "C", Reveal: model.Position{Chapter: 1}}}}})
+ if g, body := getDescription(t, e, lib.ID, "A/Two", "", tok); g.Work == nil || !g.Work.Generated.Characters || !g.Work.Generated.Description {
+ t.Fatalf("with characters = %s", body)
+ }
+}
diff --git a/internal/api/handlers_bridge.go b/internal/api/handlers_bridge.go
index 64e4e16f..4330d982 100644
--- a/internal/api/handlers_bridge.go
+++ b/internal/api/handlers_bridge.go
@@ -437,8 +437,9 @@ func (a *API) notifyBridgeJob(ctx context.Context, job *bridge.Job) {
}
// writeBridgeError maps a bridge error (400 invalid, 404 no job, 409 not held,
-// finished, a kind the Bridge doesn't run or a library it can't write to, else
-// 500) and reports whether there was none.
+// finished, a kind the Bridge doesn't run, a library it can't write to, a
+// contribution it can't make or a generate job open for a describe, else 500)
+// and reports whether there was none.
func (a *API) writeBridgeError(w http.ResponseWriter, err error, op string) bool {
switch {
case err == nil:
@@ -457,6 +458,9 @@ func (a *API) writeBridgeError(w http.ResponseWriter, err error, op string) bool
writeErrorCode(w, http.StatusConflict, "library_not_writable", "the bridge can't write to that library")
case errors.Is(err, bridge.ErrCannotContribute):
writeErrorCode(w, http.StatusConflict, "cannot_contribute", "the bridge can't contribute to the community")
+ case errors.Is(err, bridge.ErrGenerateOpen):
+ writeErrorCode(w, http.StatusConflict, "generate_open",
+ "a generate job is still open for this book; it writes the description when it finishes")
default:
a.log.Warn(op, "err", err)
writeError(w, http.StatusInternalServerError, "could not update the job")
@@ -644,10 +648,12 @@ func (a *API) handleAdminBridgeUnpair(w http.ResponseWriter, r *http.Request) {
// path?, params?}: queue a job (201) or return the open one already queued for
// that kind, library and path (200). The kind must be one the server knows (400)
// and the paired Bridge runs (409 code kind_unavailable; it may be offline: the
-// job waits), and a kind that writes (sidecars.generate) needs a library the
-// Bridge last reported mapped and writable (409 code library_not_writable). A
-// path names an indexed book (404 otherwise) and the job is about that book's
-// own path.
+// job waits), and a kind that writes (sidecars.generate, sidecars.describe)
+// needs a library the Bridge last reported mapped and writable (409 code
+// library_not_writable); a sidecars.describe job is refused while a
+// sidecars.generate job is open for the book (409 code generate_open). A path
+// names an indexed book (404 otherwise) and the job is about that book's own
+// path.
func (a *API) handleAdminBridgeQueue(w http.ResponseWriter, r *http.Request) {
var req struct {
Kind string `json:"kind"`
diff --git a/internal/api/handlers_bridge_estimate.go b/internal/api/handlers_bridge_estimate.go
index 7d2d9c03..d69c38a5 100644
--- a/internal/api/handlers_bridge_estimate.go
+++ b/internal/api/handlers_bridge_estimate.go
@@ -4,6 +4,7 @@ import (
"context"
"net/http"
"slices"
+ "time"
"github.com/kodestar/audiosilo-server/internal/bridge"
"github.com/kodestar/audiosilo-server/internal/catalog"
@@ -36,15 +37,18 @@ type bridgeEstimate struct {
Ebook string `json:"ebook,omitempty"`
DurationSeconds float64 `json:"duration_seconds"`
// Writable: the Bridge last reported the library mapped and writable.
- Writable bool `json:"writable"`
- Has estimateHas `json:"has"`
- Earlier []earlierBook `json:"earlier"`
- OpenJob *bridge.Job `json:"open_job,omitempty"`
+ Writable bool `json:"writable"`
+ // CanDescribe: the Bridge is online and runs sidecars.describe (a
+ // description from the notes it kept, Phase 1c); an older Bridge doesn't.
+ CanDescribe bool `json:"can_describe"`
+ Has estimateHas `json:"has"`
+ Earlier []earlierBook `json:"earlier"`
+ OpenJob *bridge.Job `json:"open_job,omitempty"`
}
// estimateHas is what a book already has: community content (its community
-// work, meta.Service.Coverages) and generated content (its indexed .audiosilo/
-// attachments). CommunityUnknown: the community couldn't be asked right now, so
+// work, meta.Service.Coverages: characters, recaps, a community description)
+// and generated content (its indexed .audiosilo/ attachments). CommunityUnknown: the community couldn't be asked right now, so
// community is no answer (never read it as "none").
type estimateHas struct {
Community hasContent `json:"community"`
@@ -53,8 +57,9 @@ type estimateHas struct {
}
type hasContent struct {
- Characters bool `json:"characters"`
- Recaps bool `json:"recaps"`
+ Characters bool `json:"characters"`
+ Recaps bool `json:"recaps"`
+ Description bool `json:"description"`
}
// earlierBook is an earlier book of the series with no recaps yet, with what
@@ -78,7 +83,9 @@ type earlierBook struct {
//
// brief=1 is the cheap answer for a label or a batch (the book card, a whole
// series): the same shape, but no look into the book's folder for an ebook
-// (source audio, ebook omitted) and no scan of the series (earlier []).
+// (source audio, ebook omitted) and no scan of the series (earlier []). Both
+// carry can_describe and has.*.description, which the book card's "Write a
+// description" reads.
func (a *API) handleAdminBridgeEstimate(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
kind := q.Get("kind")
@@ -112,7 +119,7 @@ func (a *API) handleAdminBridgeEstimate(w http.ResponseWriter, r *http.Request)
}
out := bridgeEstimate{Kind: kind, LibraryID: lib.ID, Path: book.RelPath, DurationSeconds: book.Duration,
- Writable: h.Writable(lib.ID), Earlier: []earlierBook{}}
+ Writable: h.Writable(lib.ID), CanDescribe: h.Online(time.Now()) && h.Offers(bridge.KindDescribe), Earlier: []earlierBook{}}
if !brief {
out.Ebook = library.BookEbook(lib.Root, book.RelPath, book.IsFolder)
}
@@ -153,7 +160,7 @@ func (a *API) communityContent(ctx context.Context, book *catalog.Book) (has has
if !c.Known && ctx.Err() == nil {
a.log.Warn("community lookup for the bridge estimate failed", "library", book.LibraryID, "path", book.RelPath)
}
- return hasContent{Characters: c.Characters, Recaps: c.Recaps}, !c.Known
+ return hasContent{Characters: c.Characters, Recaps: c.Recaps, Description: c.Description}, !c.Known
}
// localContent is what the book's generated attachments carry (a stored file is
@@ -163,7 +170,7 @@ func (a *API) localContent(ctx context.Context, book *catalog.Book) (hasContent,
if err != nil || att == nil {
return hasContent{}, err
}
- return hasContent{Characters: att.Characters != nil, Recaps: att.Recaps != nil}, nil
+ return hasContent{Characters: att.Characters != nil, Recaps: att.Recaps != nil, Description: att.Description != nil}, nil
}
// earlierWithoutRecaps is the books of the library before book in its series
diff --git a/internal/api/handlers_meta.go b/internal/api/handlers_meta.go
index e6ddfb13..7118c2cf 100644
--- a/internal/api/handlers_meta.go
+++ b/internal/api/handlers_meta.go
@@ -27,11 +27,12 @@ import (
// Every rail entry the caller owns carries `local`, the book to open for it
// (localRails), resolved for this caller on a copy of the rails.
//
-// The characters and recaps the Bridge generated for the book (its .audiosilo/
-// attachments, catalog.BookAttachments) fill what the community work lacks
-// (work.generated names them), and a book with no match but generated content
-// answers {"matched": false, "work": {...}}: meta.Answer, per request, after the
-// cache and `local`, never cached.
+// The characters, recaps and description the Bridge generated for the book (its
+// .audiosilo/ attachments, catalog.BookAttachments) fill what the community work
+// lacks (work.generated names them; the description rides in
+// work.generated_description, never community_description), and a book with no
+// match but generated content answers {"matched": false, "work": {...}}:
+// meta.Answer, per request, after the cache and `local`, never cached.
//
// Optional query params (the `meta_bundle` capability), each applied to a
// per-request copy of the matched envelope; unknown values are ignored, so a
@@ -116,10 +117,10 @@ func (a *API) generatedFor(ctx context.Context, book *catalog.Book, env *meta.En
}
return nil
}
- if att == nil || (att.Characters == nil && att.Recaps == nil) {
+ if att == nil || (att.Characters == nil && att.Recaps == nil && att.Description == nil) {
return nil
}
- g := &meta.Generated{Characters: att.Characters, Recaps: att.Recaps}
+ g := &meta.Generated{Characters: att.Characters, Recaps: att.Recaps, Description: att.Description}
if att.Provenance != nil {
g.GeneratedAt = att.Provenance.GeneratedAt
}
diff --git a/internal/bridge/jobs.go b/internal/bridge/jobs.go
index 839bac03..4a125fb1 100644
--- a/internal/bridge/jobs.go
+++ b/internal/bridge/jobs.go
@@ -59,6 +59,11 @@ const KindVerify = "bridge.verify"
// recaps and writes them into the book's .audiosilo/ folder.
const KindSidecars = "sidecars.generate"
+// KindDescribe is Phase 1c's description-only job: the Bridge writes a book's
+// own-words description from the notes it kept from generating its characters
+// and recaps (no new speech recognition), into the same .audiosilo/ folder.
+const KindDescribe = "sidecars.describe"
+
// bookRule is whether a kind's job names a book.
type bookRule int
@@ -86,6 +91,7 @@ type kindSpec struct {
var kinds = map[string]kindSpec{
KindVerify: {needsLibrary: true, book: bookOptional, params: verifyParams},
KindSidecars: {needsLibrary: true, book: bookRequired, writes: true, params: sidecarsParams},
+ KindDescribe: {needsLibrary: true, book: bookRequired, writes: true, params: describeParams},
}
// sidecars.generate's param values (auto and local are the defaults),
@@ -144,6 +150,16 @@ func contributes(params json.RawMessage) bool {
return json.Unmarshal(params, &p) == nil && p.Contribute == ContributeIssue
}
+// describeParams checks sidecars.describe's params: it takes none, so only an
+// empty object (or none) passes, and any field is refused.
+func describeParams(raw json.RawMessage) (json.RawMessage, error) {
+ var p struct{}
+ if err := decodeStrict(raw, &p); err != nil {
+ return nil, invalid("params: %v", err)
+ }
+ return json.RawMessage(`{}`), nil
+}
+
// verifyParams checks bridge.verify's params: an optional hold_seconds (0-600)
// the Bridge waits before finishing, so a test can cancel it or kill the Bridge
// mid-job.
@@ -316,6 +332,12 @@ var ErrKindUnavailable = errors.New("the paired bridge doesn't run that kind")
// all): a 409 library_not_writable.
var ErrLibraryNotWritable = errors.New("the bridge can't write to that library")
+// ErrGenerateOpen is a sidecars.describe job for a book with a sidecars.generate
+// job still open: that job writes the description when it finishes, so a second
+// writer is refused (a 409 generate_open). A generate job is never refused for
+// an open describe job: it supersedes it.
+var ErrGenerateOpen = errors.New("a generate job is open for the book")
+
// ErrCannotContribute is a sidecars.generate job asking to contribute its
// results (contribute "issue") to a Bridge whose last heartbeat doesn't say it
// can (estimates.sidecars.can_contribute; none sent counts as can't): a 409
@@ -365,9 +387,10 @@ func checkNew(j *NewJob) error {
// knows (ErrInvalid otherwise, as for bad params) and the paired Bridge runs
// (ErrKindUnavailable); it may be offline, and the job waits. A kind that writes
// into the library is queued only when the last heartbeat reports the library
-// mapped and writable (ErrLibraryNotWritable), and a sidecars.generate job that
-// contributes only when it says the Bridge can (ErrCannotContribute); the open
-// job is still returned either way.
+// mapped and writable (ErrLibraryNotWritable), a sidecars.generate job that
+// contributes only when it says the Bridge can (ErrCannotContribute), and a
+// sidecars.describe job only while no sidecars.generate job is open for the book
+// (ErrGenerateOpen); the open job is still returned either way.
// The caller has resolved the library and the book (j.Path is the book's
// indexed path).
func (s *Service) Enqueue(ctx context.Context, j NewJob) (job *Job, created bool, err error) {
@@ -409,6 +432,17 @@ func (s *Service) Enqueue(ctx context.Context, j NewJob) (job *Job, created bool
if refuse != nil {
return refuse
}
+ if j.Kind == KindDescribe {
+ var open int
+ err := tx.QueryRowContext(ctx, `SELECT 1 FROM bridge_jobs WHERE dedupe_key = ? AND state IN `+OpenStatesSQL,
+ DedupeKey(KindSidecars, j.LibraryID, j.Path)).Scan(&open)
+ if err == nil {
+ return ErrGenerateOpen
+ }
+ if !errors.Is(err, sql.ErrNoRows) {
+ return err
+ }
+ }
res, err := tx.ExecContext(ctx,
`INSERT INTO bridge_jobs(kind, library_id, rel_path, params, requested_by, priority, dedupe_key, created_at, updated_at)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`,
diff --git a/internal/bridge/sidecars_test.go b/internal/bridge/sidecars_test.go
index 9f30ee4f..a957f55a 100644
--- a/internal/bridge/sidecars_test.go
+++ b/internal/bridge/sidecars_test.go
@@ -400,3 +400,70 @@ func TestEstimateSidecars(t *testing.T) {
t.Fatalf("negative duration = %+v", c)
}
}
+
+func (q *testQueue) describe(params string, path *string) (*Job, bool, error) {
+ j := NewJob{Kind: KindDescribe, LibraryID: &q.lib, Path: path, RequestedBy: q.admin}
+ if params != "" {
+ j.Params = json.RawMessage(params)
+ }
+ return q.Enqueue(context.Background(), j)
+}
+
+// sidecars.describe takes no params (none or {} is stored as {}; anything else
+// is refused), needs a book, a Bridge that runs it and a writable library, and
+// is refused while a sidecars.generate job is open for the book (it writes the
+// description itself); a generate job is never refused for an open describe.
+func TestDescribeQueueRules(t *testing.T) {
+ t.Parallel()
+ q := newTestQueue(t)
+ q.beatLibrary(t, true, true, KindVerify, KindSidecars)
+ if _, _, err := q.describe("", ptr("Author/Book")); !errors.Is(err, ErrKindUnavailable) {
+ t.Fatalf("a Bridge without the kind: %v, want ErrKindUnavailable", err)
+ }
+ q.beatLibrary(t, true, true, KindVerify, KindSidecars, KindDescribe)
+ for _, params := range []string{"", `{}`, `null`} {
+ job, _, err := q.describe(params, ptr("Author/Book"))
+ if err != nil || string(job.Params) != `{}` {
+ t.Fatalf("params %q: %v, %+v", params, err, job)
+ }
+ }
+ for name, params := range map[string]string{
+ "a field": `{"source":"audio"}`,
+ "not an object": `[]`,
+ "trailing data": `{} {}`,
+ } {
+ if _, _, err := q.describe(params, ptr("Author/Other")); !errors.Is(err, ErrInvalid) {
+ t.Errorf("%s: %v, want ErrInvalid", name, err)
+ }
+ }
+ if _, _, err := q.describe("", nil); !errors.Is(err, ErrInvalid) {
+ t.Errorf("no path: %v, want ErrInvalid", err)
+ }
+ // A generate job beside the open describe job is queued.
+ if _, created, err := q.sidecars("", ptr("Author/Book")); err != nil || !created {
+ t.Fatalf("generate beside a describe: %v, %v", created, err)
+ }
+ // A describe for a book whose generate job is open is refused; once it ends,
+ // it is queued.
+ gen, _, err := q.sidecars("", ptr("Author/Third"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := q.describe("", ptr("Author/Third")); !errors.Is(err, ErrGenerateOpen) {
+ t.Fatalf("describe with a generate open: %v, want ErrGenerateOpen", err)
+ }
+ if _, err := q.Cancel(context.Background(), gen.ID); err != nil {
+ t.Fatal(err)
+ }
+ if _, created, err := q.describe("", ptr("Author/Third")); err != nil || !created {
+ t.Fatalf("describe once the generate ended: %v, %v", created, err)
+ }
+ // A library the Bridge can't write to.
+ q.beatLibrary(t, true, false, KindVerify, KindSidecars, KindDescribe)
+ if _, _, err := q.describe("", ptr("Author/Fourth")); !errors.Is(err, ErrLibraryNotWritable) {
+ t.Errorf("read-only library: %v, want ErrLibraryNotWritable", err)
+ }
+ if got := (Status{Kinds: []string{KindDescribe}}).Features(); !slices.Equal(got, []string{"sidecars"}) {
+ t.Errorf("features = %v", got)
+ }
+}
diff --git a/internal/catalog/attachments.go b/internal/catalog/attachments.go
index 4259df07..178c002e 100644
--- a/internal/catalog/attachments.go
+++ b/internal/catalog/attachments.go
@@ -15,9 +15,10 @@ import (
)
// A book's .audiosilo/ attachments (metadata.AttachmentsDir) are the files the
-// Bridge generates for it: characters, recaps and their provenance. They are not
+// Bridge generates for it: characters, recaps, a description and their
+// provenance. They are not
// a metadata layer: nothing in the effective row reads them. The scan stores
-// them checked in book_attachments (migration 0048), with the files' signature
+// them checked in book_attachments (migrations 0048, 0050), with the files' signature
// on the book row (attachments_sig) and the indexed_at they were read with
// (attachments_indexed_at), and /meta serves them beside the community data.
// Derived from the files, so a move never carries them and a re-index reads
@@ -49,13 +50,13 @@ type AttachmentsState struct {
}
// generatedSig reports whether a book's attachments signature (attachments_sig)
-// names a characters or recaps file: Book.Generated, read from the narrow column
-// so a list never joins book_attachments. A file that failed its checks still
+// names a characters, recaps or description file: Book.Generated, read from the
+// narrow column so a list never joins book_attachments. A file that failed its checks still
// counts (its signature is recorded so it isn't read again): a /meta read then
// finds nothing, which costs a request and no more.
func generatedSig(sig string) bool {
for _, name := range metadata.AttachmentSigNames(sig) {
- if name == metadata.AttachmentCharacters || name == metadata.AttachmentRecaps {
+ if name == metadata.AttachmentCharacters || name == metadata.AttachmentRecaps || name == metadata.AttachmentDescription {
return true
}
}
@@ -84,13 +85,17 @@ func writeAttachments(ctx context.Context, tx *sql.Tx, id int64, files metadata.
if err != nil {
return err
}
+ desc, err := encodeAttachment(files.Description)
+ if err != nil {
+ return err
+ }
prov, err := encodeAttachment(files.Provenance)
if err != nil {
return err
}
_, err = tx.ExecContext(ctx,
- `INSERT INTO book_attachments(book_id, characters, recaps, provenance, updated_at) VALUES(?,?,?,?,?)`,
- id, chars, recaps, prov, now)
+ `INSERT INTO book_attachments(book_id, characters, recaps, description, provenance, updated_at) VALUES(?,?,?,?,?,?)`,
+ id, chars, recaps, desc, prov, now)
return err
}
@@ -162,9 +167,10 @@ func (c *Catalog) SetAttachments(ctx context.Context, libraryID int64, readings
// inside the server only), or nil, nil when it has none. A column that no longer
// decodes reads as a file the book doesn't have.
func (c *Catalog) BookAttachments(ctx context.Context, bookID int64) (*metadata.AttachmentFiles, error) {
- var chars, recaps, prov string
+ var chars, recaps, desc, prov string
err := c.db.QueryRowContext(ctx,
- `SELECT characters, recaps, provenance FROM book_attachments WHERE book_id = ?`, bookID).Scan(&chars, &recaps, &prov)
+ `SELECT characters, recaps, description, provenance FROM book_attachments WHERE book_id = ?`, bookID).
+ Scan(&chars, &recaps, &desc, &prov)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
@@ -172,9 +178,10 @@ func (c *Catalog) BookAttachments(ctx context.Context, bookID int64) (*metadata.
return nil, err
}
a := &metadata.AttachmentFiles{
- Characters: decodeAttachment[model.Characters](chars),
- Recaps: decodeAttachment[model.Recaps](recaps),
- Provenance: decodeAttachment[metadata.AttachmentProvenance](prov),
+ Characters: decodeAttachment[model.Characters](chars),
+ Recaps: decodeAttachment[model.Recaps](recaps),
+ Description: decodeAttachment[model.Description](desc),
+ Provenance: decodeAttachment[metadata.AttachmentProvenance](prov),
}
if a.Empty() {
return nil, nil
diff --git a/internal/catalog/attachments_test.go b/internal/catalog/attachments_test.go
index 7f3c0c86..5096c255 100644
--- a/internal/catalog/attachments_test.go
+++ b/internal/catalog/attachments_test.go
@@ -11,8 +11,8 @@ import (
)
// Book.Generated comes from the attachments signature alone: a book whose
-// signature names characters.json or recaps.json has generated content, one
-// with only bridge.json or none hasn't (and the field is left off the wire), on
+// signature names characters.json, recaps.json or description.json has
+// generated content, one with only bridge.json or none hasn't (and the field is left off the wire), on
// a single read and on a list alike.
func TestBookGenerated(t *testing.T) {
t.Parallel()
@@ -23,6 +23,7 @@ func TestBookGenerated(t *testing.T) {
{RelPath: "Indexed", Attachments: AttachmentsReading{Sig: "characters.json:10:1,bridge.json:5:1",
Files: metadata.AttachmentFiles{Characters: chars}}},
{RelPath: "Refreshed"},
+ {RelPath: "Described"},
{RelPath: "Provenance"},
{RelPath: "None"},
} {
@@ -32,12 +33,15 @@ func TestBookGenerated(t *testing.T) {
}
}
if err := c.SetAttachments(ctx, lib.ID, map[string]AttachmentsReading{
- "Refreshed": {Sig: "recaps.json:7:1", Files: metadata.AttachmentFiles{Recaps: &model.Recaps{InShort: "Short."}}},
+ "Refreshed": {Sig: "recaps.json:7:1", Files: metadata.AttachmentFiles{Recaps: &model.Recaps{InShort: "Short."}}},
+ "Described": {Sig: "description.json:300:1,bridge.json:5:1", Files: metadata.AttachmentFiles{
+ Description: &model.Description{Work: "w", Text: "A described book.", License: "CC-BY-SA-4.0"},
+ Provenance: &metadata.AttachmentProvenance{Kind: "sidecars.describe", DescriptionAt: "2026-10-16T08:00:00Z"}}},
"Provenance": {Sig: "bridge.json:5:1", Files: metadata.AttachmentFiles{Provenance: &metadata.AttachmentProvenance{Kind: "k"}}},
}); err != nil {
t.Fatal(err)
}
- want := map[string]bool{"Indexed": true, "Refreshed": true, "Provenance": false, "None": false}
+ want := map[string]bool{"Indexed": true, "Refreshed": true, "Described": true, "Provenance": false, "None": false}
for path, generated := range want {
b, err := c.GetBookByPath(ctx, lib.ID, path)
if err != nil {
@@ -63,6 +67,23 @@ func TestBookGenerated(t *testing.T) {
if a, err := c.BookAttachments(ctx, b.ID); err != nil || a == nil || a.Characters == nil || a.Recaps != nil {
t.Fatalf("attachments = %+v, %v", a, err)
}
+ // A description round-trips, and the console's book page shows it with its
+ // own time.
+ described, _ := c.GetBookByPath(ctx, lib.ID, "Described")
+ if a, err := c.BookAttachments(ctx, described.ID); err != nil || a == nil || a.Description == nil ||
+ a.Description.Text != "A described book." || a.Characters != nil || a.Provenance.DescriptionAt != "2026-10-16T08:00:00Z" {
+ t.Fatalf("described attachments = %+v, %v", a, err)
+ }
+ d, err := c.AdminBookDetail(ctx, lib.ID, "Described")
+ if err != nil || d.GeneratedDescription == nil ||
+ *d.GeneratedDescription != (GeneratedDescription{Text: "A described book.", GeneratedAt: "2026-10-16T08:00:00Z"}) {
+ t.Fatalf("detail = %+v, %v", d.GeneratedDescription, err)
+ }
+ if d, err := c.AdminBookDetail(ctx, lib.ID, "Indexed"); err != nil || d.GeneratedDescription != nil {
+ t.Fatalf("detail without a description = %+v, %v", d.GeneratedDescription, err)
+ } else if raw, _ := json.Marshal(d); strings.Contains(string(raw), "generated_description") {
+ t.Errorf("detail without a description carries the field: %s", raw)
+ }
none, _ := c.GetBookByPath(ctx, lib.ID, "None")
if a, err := c.BookAttachments(ctx, none.ID); err != nil || a != nil {
t.Fatalf("no attachments = %+v, %v", a, err)
diff --git a/internal/catalog/bookdetail.go b/internal/catalog/bookdetail.go
index dd0ca0e2..94018280 100644
--- a/internal/catalog/bookdetail.go
+++ b/internal/catalog/bookdetail.go
@@ -37,6 +37,17 @@ type AdminBookDetail struct {
// (meta.SearchPrefill: the title and author, or the folders' when the tags
// look swapped or junk).
MatchQuery string `json:"match_query"`
+ // GeneratedDescription is the description the Bridge generated for the book
+ // (its .audiosilo/description.json, as stored), omitted when it has none.
+ GeneratedDescription *GeneratedDescription `json:"generated_description,omitempty"`
+}
+
+// GeneratedDescription is a book's generated description on the console's book
+// page: the text, and when it was written (bridge.json's description time;
+// omitted when unknown).
+type GeneratedDescription struct {
+ Text string `json:"text"`
+ GeneratedAt string `json:"generated_at,omitempty"`
}
// AdminChapter is a chapter with its edit state.
@@ -162,6 +173,16 @@ func (c *Catalog) AdminBookDetail(ctx context.Context, libraryID int64, relPath
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
+ att, err := c.BookAttachments(ctx, bookID)
+ if err != nil {
+ return nil, err
+ }
+ if att != nil && att.Description != nil {
+ d.GeneratedDescription = &GeneratedDescription{Text: att.Description.Text}
+ if att.Provenance != nil {
+ d.GeneratedDescription.GeneratedAt = att.Provenance.DescriptionAt
+ }
+ }
return d, nil
}
diff --git a/internal/library/attachments.go b/internal/library/attachments.go
index 210172b9..592cab05 100644
--- a/internal/library/attachments.go
+++ b/internal/library/attachments.go
@@ -169,6 +169,8 @@ func (s *Scanner) readAttachments(lib catalog.Library, b *catalog.Book, sig stri
files.Characters, err = metadata.ReadCharacters(p)
case metadata.AttachmentRecaps:
files.Recaps, err = metadata.ReadRecaps(p)
+ case metadata.AttachmentDescription:
+ files.Description, err = metadata.ReadDescription(p)
case metadata.AttachmentBridge:
files.Provenance, err = metadata.ReadProvenance(p)
}
@@ -193,9 +195,10 @@ type AttachmentsRefresh struct {
// AttachmentsHeld says which of a book's attachments are indexed.
type AttachmentsHeld struct {
- Characters bool `json:"characters"`
- Recaps bool `json:"recaps"`
- Bridge bool `json:"bridge"`
+ Characters bool `json:"characters"`
+ Recaps bool `json:"recaps"`
+ Description bool `json:"description"`
+ Bridge bool `json:"bridge"`
}
// RefreshAttachments reads the .audiosilo/ attachments of the indexed book at
@@ -232,7 +235,7 @@ func (s *Scanner) RefreshAttachments(ctx context.Context, lib catalog.Library, r
return nil, err
}
return &AttachmentsRefresh{Path: book.RelPath, Attachments: AttachmentsHeld{Characters: files.Characters != nil,
- Recaps: files.Recaps != nil, Bridge: files.Provenance != nil}}, nil
+ Recaps: files.Recaps != nil, Description: files.Description != nil, Bridge: files.Provenance != nil}}, nil
}
// attachmentsOfWalk sets each book's attachments signature from the .audiosilo
diff --git a/internal/library/attachments_test.go b/internal/library/attachments_test.go
index 3143bd42..141a2ba0 100644
--- a/internal/library/attachments_test.go
+++ b/internal/library/attachments_test.go
@@ -180,6 +180,73 @@ func TestScannerReadsAttachments(t *testing.T) {
}
}
+// testDescription is a description.json the server takes (200 code points or
+// more), and testShortDescription one it refuses (too short).
+var (
+ testDescription = `{"work":"w","text":"` + strings.Repeat("A harbour town waits. ", 12) + `","license":"CC-BY-SA-4.0","sources":[]}`
+ testShortDescription = `{"work":"w","text":"Too short.","license":"CC-BY-SA-4.0","sources":[]}`
+)
+
+// TestScannerReadsDescription: description.json is a known attachment for a
+// folder book and, by stem, for a single-file book; a book with only a
+// description has generated content (Book.Generated); one that fails its checks
+// is left out; the refresh answers it. A book without one keeps the signature it
+// had before the name was known (characters, recaps, bridge, in that order).
+func TestScannerReadsDescription(t *testing.T) {
+ t.Parallel()
+ ctx := context.Background()
+ db := storetest.Open(t)
+ cat := catalog.New(db, time.Now)
+ scanner := NewScanner(cat, "", slog.Default())
+ root := t.TempDir()
+ past := time.Now().Add(-time.Hour)
+ for _, p := range []string{"A/Book/audio.m4b", "A/Other/audio.m4b", "Loose.m4b", "Bad/Book/audio.m4b"} {
+ copyFixtureM4B(t, filepath.Join(root, filepath.FromSlash(p)))
+ }
+ writeAt(t, root, "A/Book/.audiosilo/description.json", testDescription, past)
+ writeAt(t, root, ".audiosilo/Loose.description.json", testDescription, past)
+ writeAt(t, root, "Bad/Book/.audiosilo/description.json", testShortDescription, past)
+ writeAt(t, root, "Bad/Book/.audiosilo/recaps.json", testRecaps, past)
+ for _, name := range []string{"characters.json", "recaps.json", "bridge.json"} {
+ body := map[string]string{"characters.json": testCharacters, "recaps.json": testRecaps, "bridge.json": testBridge}[name]
+ writeAt(t, root, "A/Other/.audiosilo/"+name, body, past)
+ }
+ lib, _ := cat.CreateLibrary(ctx, catalog.Library{Name: "L", Root: root})
+ if _, err := scanner.Scan(ctx, *lib); err != nil {
+ t.Fatal(err)
+ }
+ for _, rel := range []string{"A/Book", "Loose.m4b"} {
+ a := attachmentsAt(t, cat, lib.ID, rel)
+ if a == nil || a.Description == nil || a.Description.Work != "w" || a.Characters != nil || a.Recaps != nil {
+ t.Fatalf("%s = %+v, want the description alone", rel, a)
+ }
+ if b, err := cat.GetBookByPath(ctx, lib.ID, rel); err != nil || !b.Generated {
+ t.Errorf("%s: generated = %v, %v; want true for a description alone", rel, b != nil && b.Generated, err)
+ }
+ }
+ if a := attachmentsAt(t, cat, lib.ID, "Bad/Book"); a == nil || a.Description != nil || a.Recaps == nil {
+ t.Errorf("a refused description = %+v, want the recaps beside it only", a)
+ }
+ var sig string
+ if err := db.QueryRowContext(ctx, `SELECT attachments_sig FROM books WHERE rel_path = 'A/Other'`).Scan(&sig); err != nil {
+ t.Fatal(err)
+ }
+ if got := metadata.AttachmentSigNames(sig); !slices.Equal(got, []string{"characters.json", "recaps.json", "bridge.json"}) {
+ t.Errorf("signature without a description names %q", got)
+ }
+ writeAt(t, root, "A/Other/.audiosilo/description.json", testDescription, time.Now())
+ got, err := scanner.RefreshAttachments(ctx, *lib, "A/Other")
+ if err != nil || got.Attachments != (AttachmentsHeld{Characters: true, Recaps: true, Description: true, Bridge: true}) {
+ t.Fatalf("refresh = %+v, %v", got, err)
+ }
+ if err := db.QueryRowContext(ctx, `SELECT attachments_sig FROM books WHERE rel_path = 'A/Other'`).Scan(&sig); err != nil {
+ t.Fatal(err)
+ }
+ if got := metadata.AttachmentSigNames(sig); !slices.Equal(got, []string{"characters.json", "recaps.json", "description.json", "bridge.json"}) {
+ t.Errorf("signature with a description names %q", got)
+ }
+}
+
// TestScannerRefusesBadAttachments: a link (to a valid file) is never read, nor
// is a file over the size cap or one that fails its checks; the other files
// beside them still are. A link to a whole .audiosilo folder is never listed.
diff --git a/internal/meta/cachedenrich.go b/internal/meta/cachedenrich.go
index 0600b417..4c3ead42 100644
--- a/internal/meta/cachedenrich.go
+++ b/internal/meta/cachedenrich.go
@@ -36,11 +36,16 @@ func (s *Service) CachedEnrichment(ctx context.Context, asin, isbn string) *Enri
}
// Coverage is what a book's community work carries: characters, recaps (as
-// HasCharacters and HasRecaps read them), and whether that is known. Known is
-// false when it couldn't be had right now (a lookup or work fetch that failed):
-// unknown, not "none".
+// HasCharacters and HasRecaps read them), a community description, and whether
+// that is known. Known is false when it couldn't be had right now (a lookup or
+// work fetch that failed): unknown, not "none".
type Coverage struct {
- Characters, Recaps, Known bool
+ Characters, Recaps, Description, Known bool
+}
+
+// coverageOf is a matched envelope's Coverage (known).
+func coverageOf(e *Enrichment) Coverage {
+ return Coverage{Characters: e.HasCharacters(), Recaps: e.HasRecaps(), Description: e.HasCommunityDescription(), Known: true}
}
// Coverages answers Coverage for many books at once (the Bridge's estimate: a
@@ -58,7 +63,7 @@ func (s *Service) Coverages(ctx context.Context, books []BookIDs) []Coverage {
)
for i, b := range books {
if env := s.CachedEnrichment(ctx, b.ASIN, b.ISBN); env != nil {
- out[i] = Coverage{Characters: env.HasCharacters(), Recaps: env.HasRecaps(), Known: true}
+ out[i] = coverageOf(env)
continue
}
ask, at = append(ask, b), append(at, i)
@@ -81,8 +86,7 @@ func (s *Service) Coverages(ctx context.Context, books []BookIDs) []Coverage {
case errors.Is(err, ErrNotFound):
out[i] = Coverage{Known: true}
case err == nil:
- e := &Enrichment{Work: w}
- out[i] = Coverage{Characters: e.HasCharacters(), Recaps: e.HasRecaps(), Known: true}
+ out[i] = coverageOf(&Enrichment{Work: w})
}
})
}
@@ -100,3 +104,9 @@ func (e *Enrichment) HasCharacters() bool {
func (e *Enrichment) HasRecaps() bool {
return e != nil && e.Work != nil && (len(e.Work.Recaps) > 0 || e.Work.RecapSummary != nil)
}
+
+// HasCommunityDescription reports whether a matched envelope's work carries the
+// community's description (community_description).
+func (e *Enrichment) HasCommunityDescription() bool {
+ return e != nil && e.Work != nil && e.Work.CommunityDescription != nil
+}
diff --git a/internal/meta/cachedenrich_test.go b/internal/meta/cachedenrich_test.go
index 69424db6..fbcde5b8 100644
--- a/internal/meta/cachedenrich_test.go
+++ b/internal/meta/cachedenrich_test.go
@@ -107,3 +107,17 @@ func TestCachedEnrichment(t *testing.T) {
t.Fatal("a nil envelope has content")
}
}
+
+// A Coverage says whether the community work has a community description (the
+// Bridge's estimate: a generated description is offered only without one); the
+// core description doesn't count.
+func TestCoverageDescription(t *testing.T) {
+ t.Parallel()
+ with := &Enrichment{Work: &MetaWork{CommunityDescription: &MetaCommunityDescription{Text: "Community."}}}
+ if c := coverageOf(with); !c.Description || !c.Known || c.Characters || c.Recaps {
+ t.Errorf("with a community description = %+v", c)
+ }
+ if c := coverageOf(&Enrichment{Work: &MetaWork{Description: "Blurb."}}); c.Description || !c.Known {
+ t.Errorf("with a core description only = %+v", c)
+ }
+}
diff --git a/internal/meta/generated.go b/internal/meta/generated.go
index 75412436..99158d12 100644
--- a/internal/meta/generated.go
+++ b/internal/meta/generated.go
@@ -6,8 +6,9 @@ import (
"github.com/kodestar/audiosilo-meta/pkg/model"
)
-// Generated content is the characters and recaps the Bridge generated for a book
-// on this server (its .audiosilo/ attachments, BRIDGE-PLAN.md Phase 1a). /meta
+// Generated content is the characters, recaps and description the Bridge
+// generated for a book on this server (its .audiosilo/ attachments,
+// BRIDGE-PLAN.md Phases 1a and 1c). /meta
// serves it beside the community data, per request and never cached: community
// data wins per dimension, generated content fills what the community work
// lacks (or is the whole work of a book with no match), and it never carries the
@@ -23,18 +24,31 @@ import (
type Generated struct {
Characters *model.Characters
Recaps *model.Recaps
+ Description *model.Description
GeneratedAt string
}
// MetaGenerated says which parts of a work came from the server's generated
// files rather than the community (MetaWork.Generated); omitted when none did.
-// Recaps covers the recap list and the whole-book summary alike.
+// Recaps covers the recap list and the whole-book summary alike; Description
+// the work's generated_description.
type MetaGenerated struct {
Characters bool `json:"characters"`
Recaps bool `json:"recaps"`
+ Description bool `json:"description"`
GeneratedAt string `json:"generated_at,omitempty"`
}
+// MetaGeneratedDescription is a book's own-words description generated on this
+// server (MetaWork.GeneratedDescription). It is never the community's
+// community_description: a shipped 2.0 app shows that one first and credits it
+// to the community, so a generated text rides in its own field, which those apps
+// ignore. Spoiler-free by construction (written from the opening chapters'
+// notes only), so ?spoilers=hide keeps it.
+type MetaGeneratedDescription struct {
+ Text string `json:"text"`
+}
+
// Unmatched is /meta's answer for a book with no community match: matched false,
// with the generated work when the book has generated content. A shipped player
// reads only matched, so the work is additive.
@@ -50,11 +64,12 @@ type Place struct {
}
// Fillable reports whether generated content could fill anything in e's work
-// (parts): it lacks characters or recaps (or e has no match at all); a summary
-// is only ever taken with the recaps. When it can't, /meta needn't load the
-// book's generated content.
+// (parts): it lacks characters, recaps or a community description (or e has no
+// match at all); a summary is only ever taken with the recaps. When it can't,
+// /meta needn't load the book's generated content.
func (e *Enrichment) Fillable() bool {
- return e == nil || e.Work == nil || len(e.Work.Characters) == 0 || len(e.Work.Recaps) == 0
+ return e == nil || e.Work == nil || len(e.Work.Characters) == 0 || len(e.Work.Recaps) == 0 ||
+ e.Work.CommunityDescription == nil
}
// Answer is /meta's per-request answer for a book: env is its matched envelope
@@ -104,7 +119,9 @@ func Answer(env *Enrichment, title string, g *Generated, hide *Place) any {
// parts is the generated content community (the work as published, nil for
// none) takes, as a work of its own holding just those parts: the characters
// when community has none; the recaps when it has none, and then the summary
-// too when it has none. Nil when it takes nothing.
+// too when it has none; the description when it has no community description
+// (its core description, the publisher's kind, doesn't block it: the player
+// orders those). Nil when it takes nothing.
func (g *Generated) parts(community *MetaWork) *MetaWork {
if g == nil {
return nil
@@ -123,7 +140,10 @@ func (g *Generated) parts(community *MetaWork) *MetaWork {
w.RecapSummary = g.summary()
}
}
- if len(w.Characters) == 0 && len(w.Recaps) == 0 && w.RecapSummary == nil {
+ if c.CommunityDescription == nil {
+ w.GeneratedDescription = g.description()
+ }
+ if len(w.Characters) == 0 && len(w.Recaps) == 0 && w.RecapSummary == nil && w.GeneratedDescription == nil {
return nil
}
return w
@@ -143,7 +163,10 @@ func (gen *MetaWork) layInto(w *MetaWork, generatedAt string) {
if gen.RecapSummary != nil {
w.RecapSummary, mark.Recaps = gen.RecapSummary, true
}
- if mark.Characters || mark.Recaps {
+ if gen.GeneratedDescription != nil {
+ w.GeneratedDescription, mark.Description = gen.GeneratedDescription, true
+ }
+ if mark.Characters || mark.Recaps || mark.Description {
mark.GeneratedAt = generatedAt
w.Generated = &mark
}
@@ -188,3 +211,12 @@ func (g *Generated) summary() *MetaRecapSummary {
}
return &MetaRecapSummary{InShort: g.Recaps.InShort, Ending: g.Recaps.Ending}
}
+
+// description is g's description as the envelope carries it, nil when blank
+// (metadata.ParseDescription drops one).
+func (g *Generated) description() *MetaGeneratedDescription {
+ if g.Description == nil || strings.TrimSpace(g.Description.Text) == "" {
+ return nil
+ }
+ return &MetaGeneratedDescription{Text: g.Description.Text}
+}
diff --git a/internal/meta/generated_test.go b/internal/meta/generated_test.go
index 30f2194e..a712e628 100644
--- a/internal/meta/generated_test.go
+++ b/internal/meta/generated_test.go
@@ -2,6 +2,7 @@ package meta
import (
"encoding/json"
+ "strings"
"testing"
"github.com/kodestar/audiosilo-meta/pkg/model"
@@ -80,14 +81,21 @@ func TestAnswerFillsWhatTheCommunityLacks(t *testing.T) {
// nothing to load (Fillable).
both := communityWork()
both.Recaps = []MetaRecap{{Through: MetaPosition{Chapter: 1}, Text: "Community."}}
+ both.CommunityDescription = &MetaCommunityDescription{Text: "Community description."}
env = &Enrichment{Matched: true, Work: both}
- if out := matched(t, env, testGenerated(), nil); out != env || out.Work.Generated != nil {
- t.Errorf("both community = %+v", out.Work)
+ if out := matched(t, env, withDescription(testGenerated()), nil); out != env || out.Work.Generated != nil {
+ t.Errorf("all community = %+v", out.Work)
}
- // Characters and recaps but no summary: still nothing to fill, as a summary
- // only ever comes with the recaps.
+ // Characters, recaps and a description but no summary: still nothing to
+ // fill, as a summary only ever comes with the recaps.
if env.Fillable() {
- t.Error("a work with characters and recaps is fillable")
+ t.Error("a work with characters, recaps and a description is fillable")
+ }
+ // Without the community description there is one thing to fill.
+ noDesc := *both
+ noDesc.CommunityDescription = nil
+ if !(&Enrichment{Work: &noDesc}).Fillable() {
+ t.Error("a work without a community description can't be filled")
}
if !(&Enrichment{Work: &MetaWork{Characters: both.Characters}}).Fillable() || !(*Enrichment)(nil).Fillable() {
t.Error("a work without recaps, or no match, can't be filled")
@@ -102,6 +110,84 @@ func TestAnswerFillsWhatTheCommunityLacks(t *testing.T) {
}
}
+// testDescription is a generated description (its bounds are the parser's,
+// not Answer's).
+const testDescription = "A harbour town on the edge of a long winter."
+
+// withDescription is g with a generated description.
+func withDescription(g *Generated) *Generated {
+ g.Description = &model.Description{Work: "w", Text: testDescription, License: "CC-BY-SA-4.0"}
+ return g
+}
+
+// TestAnswerDescription: a generated description fills generated_description
+// (never community_description) only when the community work has no community
+// description; the core description doesn't block it; it survives
+// spoilers=hide (spoiler-free by contract); it never brings an attribution.
+func TestAnswerDescription(t *testing.T) {
+ t.Parallel()
+ // Matched, with community characters and recaps but no community
+ // description: the description alone is generated.
+ full := communityWork()
+ full.Recaps = []MetaRecap{{Through: MetaPosition{Chapter: 1}, Text: "Community."}}
+ full.Description = "The publisher's blurb."
+ env := &Enrichment{Matched: true, Work: full}
+ snapshot, _ := json.Marshal(env)
+ w := matched(t, env, withDescription(testGenerated()), nil).Work
+ if w.GeneratedDescription == nil || w.GeneratedDescription.Text != testDescription || w.CommunityDescription != nil ||
+ w.Description != "The publisher's blurb." {
+ t.Fatalf("description = %+v / %+v / %q", w.GeneratedDescription, w.CommunityDescription, w.Description)
+ }
+ if g := w.Generated; g == nil || !g.Description || g.Characters || g.Recaps {
+ t.Errorf("generated = %+v, want the description alone", g)
+ }
+ if w.Attribution == nil || len(w.Characters) != 1 || w.Characters[0].ID != "community-hero" {
+ t.Errorf("the community's parts changed: %+v", w)
+ }
+ if after, _ := json.Marshal(env); string(after) != string(snapshot) {
+ t.Errorf("the shared envelope changed:\n%s\n%s", snapshot, after)
+ }
+ raw, _ := json.Marshal(w)
+ if !strings.Contains(string(raw), `"generated_description":{"text":"`+testDescription+`"}`) ||
+ !strings.Contains(string(raw), `"generated":{"characters":false,"recaps":false,"description":true`) {
+ t.Errorf("wire = %s", raw)
+ }
+ // spoilers=hide keeps it, from the start.
+ if hidden := matched(t, env, withDescription(testGenerated()), &Place{}).Work; hidden.GeneratedDescription == nil ||
+ hidden.Generated == nil || !hidden.Generated.Description {
+ t.Errorf("hidden = %+v / %+v", hidden.GeneratedDescription, hidden.Generated)
+ }
+
+ // The community has a description: it wins, nothing generated in its place.
+ withCommunity := *full
+ withCommunity.CommunityDescription = &MetaCommunityDescription{Text: "Community description."}
+ cenv := &Enrichment{Matched: true, Work: &withCommunity}
+ if out := matched(t, cenv, withDescription(testGenerated()), nil); out != cenv || out.Work.GeneratedDescription != nil {
+ t.Errorf("community description = %+v", out.Work)
+ }
+
+ // Unmatched, a description only: matched false with a work of the title, the
+ // description, no attribution, kept under spoilers=hide.
+ for _, hide := range []*Place{nil, {}} {
+ u, ok := Answer(nil, "The Book", withDescription(&Generated{}), hide).(Unmatched)
+ if !ok || u.Work == nil || u.Work.Title != "The Book" || u.Work.GeneratedDescription == nil || u.Work.Attribution != nil ||
+ u.Work.Generated == nil || !u.Work.Generated.Description || u.Work.Generated.Characters || u.Work.Generated.Recaps {
+ t.Errorf("unmatched description only (hide %v) = %+v", hide, u.Work)
+ }
+ }
+ // A work of only a generated description carries no attribution, even
+ // matched to a community work with nothing of the CC BY-SA layer.
+ bare := &Enrichment{Matched: true, Work: &MetaWork{ID: "w", Title: "W", Description: "Blurb."}}
+ if w := matched(t, bare, withDescription(&Generated{}), nil).Work; w.Attribution != nil || w.GeneratedDescription == nil {
+ t.Errorf("bare work = %+v", w)
+ }
+ // A blank description is none.
+ blank := &Generated{Description: &model.Description{Text: " "}}
+ if u := Answer(nil, "The Book", blank, nil).(Unmatched); u.Work != nil {
+ t.Errorf("blank description = %+v", u.Work)
+ }
+}
+
// TestAnswerUnmatched: an unmatched book's work is its generated content alone,
// with the book's title and no attribution; spoiler gating applies to it.
func TestAnswerUnmatched(t *testing.T) {
diff --git a/internal/meta/service.go b/internal/meta/service.go
index 77ed0bd2..c6c16104 100644
--- a/internal/meta/service.go
+++ b/internal/meta/service.go
@@ -170,6 +170,10 @@ type MetaWork struct {
// the community (Answer); set per request on a copy, never on a work the cache
// holds, and omitted when nothing did.
Generated *MetaGenerated `json:"generated,omitempty"`
+ // GeneratedDescription is the book's description generated on this server,
+ // laid in by Answer only when the work has no CommunityDescription (see
+ // MetaGeneratedDescription); never on a work the cache holds.
+ GeneratedDescription *MetaGeneratedDescription `json:"generated_description,omitempty"`
}
// MetaRecording is the specific narration/production matched by the lookup.
diff --git a/internal/metadata/attachments.go b/internal/metadata/attachments.go
index a2f8c74d..eb4f93ca 100644
--- a/internal/metadata/attachments.go
+++ b/internal/metadata/attachments.go
@@ -8,6 +8,7 @@ import (
"io/fs"
"strings"
"time"
+ "unicode"
"unicode/utf8"
"github.com/kodestar/audiosilo-meta/pkg/model"
@@ -19,18 +20,21 @@ import (
// scan never takes it for a book, and only these known names are read from it.
const AttachmentsDir = ".audiosilo"
-// The files the server reads from an AttachmentsDir. characters.json and
-// recaps.json are audiosilo-meta's own sidecar formats (model.Characters,
-// model.Recaps); bridge.json says what made them.
+// The files the server reads from an AttachmentsDir. characters.json,
+// recaps.json and description.json are audiosilo-meta's own sidecar formats
+// (model.Characters, model.Recaps, model.Description); bridge.json says what
+// made them.
const (
- AttachmentCharacters = "characters.json"
- AttachmentRecaps = "recaps.json"
- AttachmentBridge = "bridge.json"
+ AttachmentCharacters = "characters.json"
+ AttachmentRecaps = "recaps.json"
+ AttachmentDescription = "description.json"
+ AttachmentBridge = "bridge.json"
)
// AttachmentNames are the known attachment names, in the order a signature
-// lists them.
-var AttachmentNames = []string{AttachmentCharacters, AttachmentRecaps, AttachmentBridge}
+// lists them. description.json sits before bridge.json, so a book without one
+// keeps the signature it had before the name was known.
+var AttachmentNames = []string{AttachmentCharacters, AttachmentRecaps, AttachmentDescription, AttachmentBridge}
// The bounds an attachment is checked against: the pipeline's real output is a
// few hundred KB at most, so these only stop a file that isn't the pipeline's
@@ -41,6 +45,10 @@ const (
maxAttachmentText = 20000 // bytes in any one string
maxAttachmentList = 100 // aliases of a character, sources of a file
maxProvenanceText = 200 // bytes in a bridge.json value
+ // A description's text, in code points once trimmed: audiosilo-meta's
+ // description.schema.json bounds (minLength, maxLength).
+ minDescriptionText = 200
+ maxDescriptionText = 1500
)
// ErrAttachmentInvalid marks an attachment that was there but can't be used (not
@@ -57,29 +65,33 @@ var (
// AttachmentProvenance is what bridge.json says made a book's attachments: the
// generator and its version, the job kind and id, when, from what source (audio
-// or ebook) and for which community work ("" when unresolved). Every value is
-// cleaned and bounded; a value that fails is left out, never the file.
+// or ebook) and for which community work ("" when unresolved), and when the
+// description was written (DescriptionAt, its own time: a description-only job
+// keeps the characters' run as the file's generated_at). Every value is cleaned
+// and bounded; a value that fails is left out, never the file.
type AttachmentProvenance struct {
- Generator string `json:"generator,omitempty"`
- Version string `json:"version,omitempty"`
- Kind string `json:"kind,omitempty"`
- GeneratedAt string `json:"generated_at,omitempty"`
- Source string `json:"source,omitempty"`
- WorkID string `json:"work_id,omitempty"`
- JobID int64 `json:"job_id,omitempty"`
+ Generator string `json:"generator,omitempty"`
+ Version string `json:"version,omitempty"`
+ Kind string `json:"kind,omitempty"`
+ GeneratedAt string `json:"generated_at,omitempty"`
+ Source string `json:"source,omitempty"`
+ WorkID string `json:"work_id,omitempty"`
+ JobID int64 `json:"job_id,omitempty"`
+ DescriptionAt string `json:"description_at,omitempty"`
}
// AttachmentFiles is what a book's attachments say once checked: nil for a file
// that isn't there, is empty, or can't be used.
type AttachmentFiles struct {
- Characters *model.Characters
- Recaps *model.Recaps
- Provenance *AttachmentProvenance
+ Characters *model.Characters
+ Recaps *model.Recaps
+ Description *model.Description
+ Provenance *AttachmentProvenance
}
// Empty reports whether there is nothing to store.
func (f AttachmentFiles) Empty() bool {
- return f.Characters == nil && f.Recaps == nil && f.Provenance == nil
+ return f.Characters == nil && f.Recaps == nil && f.Description == nil && f.Provenance == nil
}
// AttachmentSignature is one attachment's part of a book's signature: its known
@@ -121,6 +133,15 @@ func ReadRecaps(path string) (*model.Recaps, error) {
return ParseRecaps(data)
}
+// ReadDescription is ReadCharacters for a description.json (ParseDescription).
+func ReadDescription(path string) (*model.Description, error) {
+ data, err := readAttachment(path)
+ if err != nil {
+ return nil, err
+ }
+ return ParseDescription(data)
+}
+
// ReadProvenance is ReadCharacters for a bridge.json (ParseProvenance).
func ReadProvenance(path string) (*AttachmentProvenance, error) {
data, err := readAttachment(path)
@@ -223,8 +244,36 @@ func ParseRecaps(data []byte) (*model.Recaps, error) {
return &r, nil
}
+// ParseDescription decodes and checks a description.json: the work, license
+// and sources as the other sidecars, and a text of minDescriptionText to
+// maxDescriptionText code points once trimmed (stored trimmed) with no control
+// character but a line break. The text is shown as is in the player's About, so
+// anything else fails the file. nil, nil for a blank text.
+func ParseDescription(data []byte) (*model.Description, error) {
+ var d model.Description
+ if err := decodeAttachment(data, &d); err != nil {
+ return nil, err
+ }
+ if err := checkSidecar(d.Work, d.License, d.Sources); err != nil {
+ return nil, err
+ }
+ d.Text = strings.TrimSpace(d.Text)
+ if d.Text == "" {
+ return nil, nil
+ }
+ if n := utf8.RuneCountInString(d.Text); n < minDescriptionText || n > maxDescriptionText {
+ return nil, invalidAttachment("a description of %d characters", n)
+ }
+ for _, r := range d.Text {
+ if r != '\n' && unicode.IsControl(r) {
+ return nil, invalidAttachment("a control character in the description")
+ }
+ }
+ return &d, nil
+}
+
// bridgeJSON is the part of bridge.json the server reads (its inputs list is
-// the Bridge's own business).
+// the Bridge's own business; of its description object, only when).
type bridgeJSON struct {
Generator string `json:"generator"`
Version string `json:"version"`
@@ -233,6 +282,9 @@ type bridgeJSON struct {
GeneratedAt string `json:"generated_at"`
Source string `json:"source"`
WorkID string `json:"work_id"`
+ Description *struct {
+ GeneratedAt string `json:"generated_at"`
+ } `json:"description"`
}
// ParseProvenance decodes a bridge.json. Only a file that isn't a JSON object of
@@ -252,8 +304,9 @@ func ParseProvenance(data []byte) (*AttachmentProvenance, error) {
WorkID: cleanProvenance(b.WorkID),
JobID: max(b.JobID, 0),
}
- if at, err := time.Parse(time.RFC3339, strings.TrimSpace(b.GeneratedAt)); err == nil {
- p.GeneratedAt = at.UTC().Format(time.RFC3339)
+ p.GeneratedAt = cleanTime(b.GeneratedAt)
+ if b.Description != nil {
+ p.DescriptionAt = cleanTime(b.Description.GeneratedAt)
}
if p == (AttachmentProvenance{}) {
return nil, nil
@@ -261,6 +314,15 @@ func ParseProvenance(data []byte) (*AttachmentProvenance, error) {
return &p, nil
}
+// cleanTime is a bridge.json time as RFC3339 UTC, "" when it isn't RFC3339.
+func cleanTime(v string) string {
+ at, err := time.Parse(time.RFC3339, strings.TrimSpace(v))
+ if err != nil {
+ return ""
+ }
+ return at.UTC().Format(time.RFC3339)
+}
+
// cleanProvenance is a bridge.json value trimmed, or "" when it is longer than
// maxProvenanceText or carries a control character: these are shown in the
// console and logged, and a value is no reason to refuse the file.
diff --git a/internal/metadata/attachments_test.go b/internal/metadata/attachments_test.go
index e9c263f5..ce8c4d9f 100644
--- a/internal/metadata/attachments_test.go
+++ b/internal/metadata/attachments_test.go
@@ -1,12 +1,15 @@
package metadata
import (
+ "encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
+
+ metaschema "github.com/kodestar/audiosilo-meta"
)
// TestParseCharacters: a pipeline-shaped file is read (unknown fields allowed, a
@@ -66,18 +69,90 @@ func TestParseRecaps(t *testing.T) {
}
}
+// descriptionText is a description of n code points (an accented letter each,
+// so bytes and code points differ).
+func descriptionText(n int) string { return strings.Repeat("é", n) }
+
+// TestParseDescription: a pipeline-shaped file is read (unknown fields allowed,
+// a byte order mark skipped, the text trimmed); a text out of its bounds (in code
+// points), with a control character other than a line break, or a file that
+// isn't the format fails; a blank text is nothing.
+func TestParseDescription(t *testing.T) {
+ t.Parallel()
+ text := "A quiet harbour town.\n" + descriptionText(minDescriptionText)
+ d, err := ParseDescription([]byte("\xef\xbb\xbf" + fmt.Sprintf(`{"work":"the-way-of-kings","text":%q,"license":"CC-BY-SA-4.0",
+ "sources":[{"type":"bridge"}],"new_field":1}`, " "+text+"\n")))
+ if err != nil || d == nil || d.Work != "the-way-of-kings" || d.Text != text || d.License != "CC-BY-SA-4.0" || len(d.Sources) != 1 {
+ t.Fatalf("valid file = %+v, %v", d, err)
+ }
+ for name, n := range map[string]int{"shortest": minDescriptionText, "longest": maxDescriptionText} {
+ if d, err := ParseDescription([]byte(fmt.Sprintf(`{"text":%q}`, descriptionText(n)))); err != nil || d == nil {
+ t.Errorf("%s: %+v, %v", name, d, err)
+ }
+ }
+ for _, body := range []string{`{"work":"w","text":" "}`, `{"work":"w"}`} {
+ if d, err := ParseDescription([]byte(body)); d != nil || err != nil {
+ t.Errorf("blank %s = %+v, %v; want nil, nil", body, d, err)
+ }
+ }
+ for name, body := range map[string]string{
+ "not JSON": `not json`,
+ "not an object": `["text"]`,
+ "wrong type": `{"text":12}`,
+ "too short": fmt.Sprintf(`{"text":%q}`, descriptionText(minDescriptionText-1)),
+ "too long": fmt.Sprintf(`{"text":%q}`, descriptionText(maxDescriptionText+1)),
+ "a tab": fmt.Sprintf(`{"text":%q}`, descriptionText(minDescriptionText)+"\tx"),
+ "a return": fmt.Sprintf(`{"text":%q}`, descriptionText(minDescriptionText)+"\r\nx"),
+ "a bell": fmt.Sprintf(`{"text":%q}`, descriptionText(minDescriptionText)+"\u0007x"),
+ "a C1 control": fmt.Sprintf(`{"text":%q}`, descriptionText(minDescriptionText)+"\u0085x"),
+ "long work": fmt.Sprintf(`{"work":%q,"text":%q}`, strings.Repeat("w", maxProvenanceText+1), descriptionText(minDescriptionText)),
+ "many sources": fmt.Sprintf(`{"text":%q,"sources":[%s]}`, descriptionText(minDescriptionText), strings.TrimSuffix(strings.Repeat(`{"type":"x"},`, maxAttachmentList+1), ",")),
+ } {
+ if d, err := ParseDescription([]byte(body)); !errors.Is(err, ErrAttachmentInvalid) {
+ t.Errorf("%s: %+v, %v; want ErrAttachmentInvalid", name, d, err)
+ }
+ }
+}
+
+// TestDescriptionBoundsMatchTheSchema: the text bounds are audiosilo-meta's
+// (description.schema.json's minLength and maxLength), so a newer meta that
+// moves them shows up here rather than as files the server refuses.
+func TestDescriptionBoundsMatchTheSchema(t *testing.T) {
+ t.Parallel()
+ raw, err := metaschema.SchemaFS.ReadFile("schema/description.schema.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var schema struct {
+ Properties struct {
+ Text struct {
+ MinLength int `json:"minLength"`
+ MaxLength int `json:"maxLength"`
+ } `json:"text"`
+ } `json:"properties"`
+ }
+ if err := json.Unmarshal(raw, &schema); err != nil {
+ t.Fatal(err)
+ }
+ if got := schema.Properties.Text; got.MinLength != minDescriptionText || got.MaxLength != maxDescriptionText {
+ t.Fatalf("schema bounds = %d-%d, the server's = %d-%d", got.MinLength, got.MaxLength, minDescriptionText, maxDescriptionText)
+ }
+}
+
// TestParseProvenance: each value is cleaned on its own (a bad one is left out,
// never the file); the time is normalized to UTC.
func TestParseProvenance(t *testing.T) {
t.Parallel()
p, err := ParseProvenance([]byte(`{"generator":"audiosilo-bridge","version":"0.2.0","kind":"sidecars.generate","job_id":12,
- "generated_at":"2026-10-15T12:00:00+02:00","source":"audio","work_id":"","inputs":[{"path":"A/B/01.mp3","size":1,"mod_time":2}]}`))
+ "generated_at":"2026-10-15T12:00:00+02:00","source":"audio","work_id":"","inputs":[{"path":"A/B/01.mp3","size":1,"mod_time":2}],
+ "description":{"job_id":14,"generated_at":"2026-10-16T09:30:00+01:00","from":"opening_notes","chapters":3,"checked_against":"none"}}`))
want := AttachmentProvenance{Generator: "audiosilo-bridge", Version: "0.2.0", Kind: "sidecars.generate", JobID: 12,
- GeneratedAt: "2026-10-15T10:00:00Z", Source: "audio"}
+ GeneratedAt: "2026-10-15T10:00:00Z", Source: "audio", DescriptionAt: "2026-10-16T08:30:00Z"}
if err != nil || p == nil || *p != want {
t.Fatalf("provenance = %+v, %v; want %+v", p, err, want)
}
- p, err = ParseProvenance([]byte(`{"generator":"bad\u0007name","version":"` + strings.Repeat("v", maxProvenanceText+1) + `","job_id":-3,"generated_at":"yesterday","kind":"k"}`))
+ p, err = ParseProvenance([]byte(`{"generator":"bad\u0007name","version":"` + strings.Repeat("v", maxProvenanceText+1) + `","job_id":-3,"generated_at":"yesterday","kind":"k",
+ "description":{"generated_at":"today"}}`))
if err != nil || p == nil || *p != (AttachmentProvenance{Kind: "k"}) {
t.Errorf("cleaned = %+v, %v; want only the kind", p, err)
}
@@ -116,6 +191,20 @@ func TestReadAttachmentFiles(t *testing.T) {
if _, err := ReadRecaps(big); !errors.Is(err, ErrAttachmentInvalid) {
t.Errorf("too large: %v, want ErrAttachmentInvalid", err)
}
+ desc := filepath.Join(dir, "description.json")
+ if err := os.WriteFile(desc, []byte(fmt.Sprintf(`{"text":%q}`, descriptionText(minDescriptionText))), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if d, err := ReadDescription(desc); err != nil || d == nil {
+ t.Fatalf("description: %+v, %v", d, err)
+ }
+ descLink := filepath.Join(dir, "linked.description.json")
+ if err := os.Symlink(desc, descLink); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := ReadDescription(descLink); !errors.Is(err, ErrAttachmentInvalid) {
+ t.Errorf("a linked description: %v, want ErrAttachmentInvalid", err)
+ }
if _, err := ReadProvenance(filepath.Join(dir, "missing.json")); err == nil || errors.Is(err, ErrAttachmentInvalid) {
t.Errorf("missing: %v, want a read error that isn't ErrAttachmentInvalid", err)
}
diff --git a/internal/notify/notify.go b/internal/notify/notify.go
index bfa482aa..4146232f 100644
--- a/internal/notify/notify.go
+++ b/internal/notify/notify.go
@@ -398,9 +398,13 @@ func compose(ev catalog.ServerEvent, srv Server) message {
case KindBridgeJobFinished:
m.Title = bridgeJobLabel(str("job_kind")) + " finished: " + bridgeJobBook(ev)
m.Text = "The Bridge finished the job."
- if str("job_kind") == bridge.KindSidecars {
+ switch str("job_kind") {
+ case bridge.KindSidecars:
m.Title = "Characters and recaps ready: " + bridgeJobBook(ev)
m.Text = "Listeners see them in the player now."
+ case bridge.KindDescribe:
+ m.Title = "Description ready: " + bridgeJobBook(ev)
+ m.Text = "Listeners see it in the book's About in the player now."
}
m.Link, m.Tags = link("/server?topic=bridge"), []string{"white_check_mark"}
case KindBridgeJobFailed:
@@ -428,6 +432,8 @@ func bridgeJobLabel(kind string) string {
switch kind {
case bridge.KindSidecars:
return "Characters and recaps"
+ case bridge.KindDescribe:
+ return "Description"
case bridge.KindVerify:
return "Bridge check"
}
diff --git a/internal/notify/notify_test.go b/internal/notify/notify_test.go
index 07cfceda..c3b656e8 100644
--- a/internal/notify/notify_test.go
+++ b/internal/notify/notify_test.go
@@ -538,3 +538,25 @@ func TestSameOrigin(t *testing.T) {
}
}
}
+
+// A description-only job (sidecars.describe) is worded as the description,
+// finished or failed; a park still reads its code's sentence.
+func TestBridgeDescribeJobWording(t *testing.T) {
+ t.Parallel()
+ s, _, _ := newService(t)
+ event := func(kind, state string) catalog.ServerEvent {
+ return catalog.ServerEvent{Kind: kind, Data: map[string]any{"job_kind": "sidecars.describe", "state": state,
+ "title": "The Way of Kings", "code": "budget_exceeded"}}
+ }
+ if m := compose(event(KindBridgeJobFinished, "done"), s.server()); m.Title != "Description ready: The Way of Kings" ||
+ m.Text != "Listeners see it in the book's About in the player now." {
+ t.Errorf("finished = %+v", m)
+ }
+ if m := compose(event(KindBridgeJobFailed, "failed"), s.server()); m.Title != "Description failed: The Way of Kings" {
+ t.Errorf("failed = %+v", m)
+ }
+ if m := compose(event(KindBridgeJobFailed, "needs_attention"), s.server()); m.Title != "A Bridge job needs attention: The Way of Kings" ||
+ !strings.Contains(m.Text, "spending limit") {
+ t.Errorf("parked = %+v", m)
+ }
+}
diff --git a/internal/store/migration_0050_test.go b/internal/store/migration_0050_test.go
new file mode 100644
index 00000000..b0d20adf
--- /dev/null
+++ b/internal/store/migration_0050_test.go
@@ -0,0 +1,33 @@
+package store
+
+import (
+ "context"
+ "testing"
+)
+
+// TestMigration0050AttachmentsDescription: a book's stored attachments gain an
+// empty description and keep what they had.
+func TestMigration0050AttachmentsDescription(t *testing.T) {
+ t.Parallel()
+ ctx := context.Background()
+ dsn, exec, closeRaw := openBefore(t, "0050")
+ exec(`INSERT INTO libraries(id, name, root, created_at) VALUES(1, 'Books', '/l', 't')`)
+ exec(`INSERT INTO books(id, library_id, rel_path, is_folder, title, indexed_at, attachments_sig)
+ VALUES(7, 1, 'Pratchett/Mort', 1, 'Mort', 'x', 'characters.json:1:1')`)
+ exec(`INSERT INTO book_attachments(book_id, characters, updated_at) VALUES(7, '{"characters":[]}', 't')`)
+ closeRaw()
+
+ db, err := Open(ctx, dsn)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { db.Close() })
+ var characters, description string
+ if err := db.writer.QueryRow(`SELECT characters, description FROM book_attachments WHERE book_id = 7`).
+ Scan(&characters, &description); err != nil {
+ t.Fatal(err)
+ }
+ if characters != `{"characters":[]}` || description != "" {
+ t.Errorf("row = %q %q, want the characters kept and no description", characters, description)
+ }
+}
diff --git a/internal/store/migrations/0050_book_attachments_description.sql b/internal/store/migrations/0050_book_attachments_description.sql
new file mode 100644
index 00000000..38eac634
--- /dev/null
+++ b/internal/store/migrations/0050_book_attachments_description.sql
@@ -0,0 +1,8 @@
+-- A book's generated description (the Bridge's .audiosilo/description.json,
+-- Phase 1c; catalog: attachments.go), checked against audiosilo-meta's
+-- model.Description and stored as JSON like the other files ('' for none or a
+-- file that can't be used). Derived from the files like the rest of the row,
+-- so rebuildable. A book read before this has no description column to fill:
+-- its signature never named description.json (the name was unknown), so a book
+-- that has one now is read again by the next scan.
+ALTER TABLE book_attachments ADD COLUMN description TEXT NOT NULL DEFAULT '';
From b83bfed303cc9c039ababf0cb9eba61f90bb4fdf Mon Sep 17 00:00:00 2001
From: KodeStar
Date: Fri, 16 Oct 2026 14:29:27 +0100
Subject: [PATCH 2/5] Bridge 1c: simplify pass
---
CLAUDE.md | 10 +--
admin-ui/src/api/types.ts | 4 +-
admin-ui/src/features/bridge/bridge-jobs.tsx | 4 +-
.../src/features/bridge/describe-section.tsx | 48 ++++----------
.../src/features/bridge/describe.test.tsx | 3 +-
.../features/bridge/generate-model.test.ts | 65 +++++++++++--------
.../src/features/bridge/generate-model.ts | 31 +++++----
.../src/features/bridge/generate-parts.tsx | 65 ++++++++++++++++++-
.../src/features/bridge/generate-section.tsx | 39 +++--------
.../src/features/bridge/generate.test.tsx | 13 ++--
admin-ui/src/lib/server-events.ts | 13 ++--
internal/api/bridge_attachments_test.go | 3 +-
internal/api/bridge_describe_test.go | 29 +++++++++
internal/api/handlers_bridge_estimate.go | 10 ++-
internal/api/handlers_meta.go | 13 ++--
internal/bridge/jobs.go | 14 ++--
internal/catalog/attachments.go | 37 +++++++++--
internal/catalog/attachments_test.go | 59 +++++++++++++++--
internal/catalog/bookdetail.go | 4 +-
internal/library/attachments_test.go | 2 +-
internal/meta/generated.go | 26 +++++---
internal/meta/generated_test.go | 20 ++++--
internal/metadata/attachments.go | 29 +++++++--
internal/notify/notify.go | 29 ++++-----
24 files changed, 373 insertions(+), 197 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index cdbefc56..e8394577 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -514,7 +514,9 @@ future metadata site can attach enrichment without reshaping the schema. The
(a shipped 2.0 app shows that first and credits the community; it ignores the new field), and
`work.generated.description` (bool, always sent beside `characters`/`recaps`) marks it. It fills
only a work with no `community_description` (the core `description`, the publisher's kind, doesn't
- block it: the player orders those), so `Fillable` is also true without one; `?spoilers=hide` keeps
+ block it: the player orders those). `Fillable` says which parts a work leaves to fill
+ (`metadata.AttachmentParts`), and `generatedFor` reads only those columns (`catalog.BookAttachments`
+ with a parts mask: a work with community characters and recaps reads just the description); `?spoilers=hide` keeps
it (spoiler-free by construction: the Bridge writes it from the opening chapters' notes only), and
attribution never counts it. An unmatched book with only a description answers `{matched: false,
work: {title, generated_description, generated: {description: true, ...}}}`. Out of scope for now: no cover remote-fallback, no tag-based ASIN extraction.
@@ -928,7 +930,7 @@ future metadata site can attach enrichment without reshaping the schema. The
never taking a book's generated content away. `Scanner.RefreshAttachments` re-reads one indexed
book's attachments, never its audio (the Bridge's `POST /bridge/rescan` mode `attachments`). The
player's `Book.generated` (list, search, item, recent, the next book) is true when the signature names
- a characters, recaps or description file (`generatedSig`, the narrow column: no join), so the player asks `/meta`
+ a characters, recaps or description file (`generatedSig`: any known name but bridge.json; the narrow column, no join), so the player asks `/meta`
for a book with no ASIN/ISBN too. Derived from the files: rebuildable, never carried by a move.
**Series ranges** (an omnibus of books 1-3): the `series_index` field may hold a range,
canonical `"1-3"` (input `1 - 3` and the en dash too; `metadata.ParseSeriesRange`: 0 < start <
@@ -1730,7 +1732,7 @@ future metadata site can attach enrichment without reshaping the schema. The
`agent_cost_usd` null while there is no history, `subscription`, `agent_ready`, `figures` false when
the Bridge sent none, so a false `agent_ready` means "not known"), `writable`, what the book already
has (`has.community` via `meta.Service.Coverages`, cache first and never a full Enrich per book,
- `community_unknown` when it couldn't be asked; `has.local` from `book_attachments`; each with
+ `community_unknown` when it couldn't be asked; `has.local` from `book_attachments` (`catalog.BookAttachmentParts`: which columns are non-empty, nothing decoded); each with
`characters`, `recaps` and, since Phase 1c, `description`: the community work's `community_description`
(`Coverage.Description`; the core description doesn't count) or a stored generated one), `can_describe`
(the Bridge is online and advertises `sidecars.describe`; brief too), up to 10 earlier
@@ -1743,7 +1745,7 @@ future metadata site can attach enrichment without reshaping the schema. The
each generate or describe job's status; the book page's aside and each series card generate characters and
recaps, a series card queueing those of its books without recaps in reading order). The admin book
detail carries `generated_description: {text, generated_at?}` (omitted without one; `generated_at` is
- bridge.json's description time), which the book page's Community card shows (`describe-section.tsx`:
+ bridge.json's description time; read with the description and provenance columns only), which the book page's Community card shows (`describe-section.tsx`:
a Description sub-part with the "Generated on this server" mark, the text clamped behind Show more;
"Write a description" confirms and queues `sidecars.describe` when `describeOffered`: `can_describe`,
local characters or recaps, no description here or from the community, no open generate or describe
diff --git a/admin-ui/src/api/types.ts b/admin-ui/src/api/types.ts
index 361e4c71..d53be98d 100644
--- a/admin-ui/src/api/types.ts
+++ b/admin-ui/src/api/types.ts
@@ -2363,7 +2363,7 @@ export interface SidecarsCoverage {
characters: boolean;
recaps: boolean;
/** A description: generated here, or the community's community_description (Phase 1c). */
- description?: boolean;
+ description: boolean;
}
/** An earlier book of the series without recaps (the estimate's `earlier`). */
@@ -2402,7 +2402,7 @@ export interface BridgeEstimate {
/** The library is mapped and writable on the Bridge. */
writable: boolean;
/** The Bridge is online and runs sidecars.describe (a description from the notes it kept). */
- can_describe?: boolean;
+ can_describe: boolean;
has: {
community: SidecarsCoverage;
local: SidecarsCoverage;
diff --git a/admin-ui/src/features/bridge/bridge-jobs.tsx b/admin-ui/src/features/bridge/bridge-jobs.tsx
index 2386cad7..735fbc45 100644
--- a/admin-ui/src/features/bridge/bridge-jobs.tsx
+++ b/admin-ui/src/features/bridge/bridge-jobs.tsx
@@ -22,7 +22,7 @@ import {
resultSummary,
stateLook,
} from './bridge-model';
-import { wordsItsOwnStatus } from './generate-model';
+import { isBookKind } from './generate-model';
import { GenerateStatus } from './generate-status';
import { useBridgeJobs } from './use-bridge';
@@ -135,7 +135,7 @@ function JobRow({
: (libraryNames.get(job.library_id) ??
t('bridge.jobs.libraryId', { id: formatNumber(job.library_id, lang) }));
// A generate or describe job words its progress and result its own way (GenerateStatus).
- const generate = wordsItsOwnStatus(job.kind);
+ const generate = isBookKind(job.kind);
const progress: ReturnType = generate ? {} : progressOf(job.progress);
const summary = generate ? null : resultSummary(job.result);
const details = hasContent(job.result) || hasContent(job.params);
diff --git a/admin-ui/src/features/bridge/describe-section.tsx b/admin-ui/src/features/bridge/describe-section.tsx
index 35da4abc..cf936b26 100644
--- a/admin-ui/src/features/bridge/describe-section.tsx
+++ b/admin-ui/src/features/bridge/describe-section.tsx
@@ -1,15 +1,14 @@
import { useId, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
-import { PenLine, X } from 'lucide-react';
-import { keys, useServerInfo } from '@/api/hooks';
+import { PenLine } from 'lucide-react';
+import { useServerInfo } from '@/api/hooks';
import type { AdminBook, GeneratedDescription } from '@/api/types';
-import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Dialog, DialogBody, DialogContent, DialogFormFooter } from '@/components/ui/dialog';
import { formatRelative } from '@/lib/format';
-import { cancelJob, queueJob } from './bridge-actions';
-import { isOpen, jobActions, stateLook } from './bridge-model';
+import { queueJob } from './bridge-actions';
+import { jobActions } from './bridge-model';
import {
clampsDescription,
describeOffered,
@@ -17,6 +16,7 @@ import {
generateJobsKnown,
KIND_DESCRIBE,
} from './generate-model';
+import { JobCancelButton, JobSection } from './generate-parts';
import { GenerateStatus } from './generate-status';
import { useBookGenerateJob, useBridgeLink, useGenerateEstimate } from './use-generate';
@@ -37,7 +37,6 @@ export function DescribeSection({
}) {
const { t, i18n } = useTranslation();
const lang = i18n.resolvedLanguage ?? 'en';
- const qc = useQueryClient();
const server = useServerInfo().data;
const available = generateAvailable(server);
const { job, jobs } = useBookGenerateJob(
@@ -52,7 +51,6 @@ export function DescribeSection({
const uiUrl = useBridgeLink(parked);
const [open, setOpen] = useState(false);
const [expanded, setExpanded] = useState(false);
- const [cancelling, setCancelling] = useState(false);
const textId = useId();
const offer = available && describeOffered(estimate.data, jobs);
@@ -60,19 +58,15 @@ export function DescribeSection({
const showJob = job && job.state !== 'cancelled' && !(job.state === 'done' && description);
if (!open && !showJob && !offer && !description) return null;
- const look = job ? stateLook(job) : undefined;
const canCancel = job ? jobActions(job).cancel : false;
const clamps = description ? clampsDescription(description.text) : false;
- const jobOpen = job ? isOpen(job.state) : false;
return (
-
-
-
- {t('bridge.describe.title')}
-
- {showJob && look ? {t(look.key)} : null}
-
+
{description ? (
<>
@@ -109,7 +103,7 @@ export function DescribeSection({
>
) : null}
{showJob && job ? : null}
- {(showJob && jobOpen && canCancel) || offer ? (
+ {(showJob && canCancel) || offer ? (
{offer ? (
setOpen(true)}>
@@ -117,25 +111,13 @@ export function DescribeSection({
{t('bridge.describe.action')}
) : null}
- {job && jobOpen && canCancel ? (
- {
- setCancelling(true);
- void cancelJob(qc, job).finally(() => setCancelling(false));
- }}
- aria-label={t('bridge.describe.cancelAria')}
- >
-
- {t('bridge.jobs.cancel')}
-
+ {job && canCancel ? (
+
) : null}
) : null}
-
+
);
}
@@ -167,8 +149,6 @@ function DescribeDialog({
{ name: book.title || book.path, online: true },
);
setBusy(false);
- // Whether a job is open changed with it.
- void qc.invalidateQueries({ queryKey: keys.bridgeEstimate(book.library_id, book.path) });
if (ok) onOpenChange(false);
};
diff --git a/admin-ui/src/features/bridge/describe.test.tsx b/admin-ui/src/features/bridge/describe.test.tsx
index a3451bfa..f2ab2d1a 100644
--- a/admin-ui/src/features/bridge/describe.test.tsx
+++ b/admin-ui/src/features/bridge/describe.test.tsx
@@ -166,8 +166,7 @@ describe('write a description', () => {
});
it.each<[string, Partial]>([
- ['an older Bridge', { can_describe: undefined }],
- ['a Bridge that cannot now', { can_describe: false }],
+ ['a Bridge that does not offer it (an older one, or away)', { can_describe: false }],
[
'a community description',
{
diff --git a/admin-ui/src/features/bridge/generate-model.test.ts b/admin-ui/src/features/bridge/generate-model.test.ts
index 22a01955..9b133bbf 100644
--- a/admin-ui/src/features/bridge/generate-model.test.ts
+++ b/admin-ui/src/features/bridge/generate-model.test.ts
@@ -22,11 +22,11 @@ import {
generatePollMs,
generateProgress,
generateResult,
+ isBookKind,
KIND_DESCRIBE,
KIND_GENERATE,
KNOWN_PARKS,
KNOWN_STAGES,
- latestGenerateJob,
latestJobOf,
parkKey,
planEstimate,
@@ -35,7 +35,6 @@ import {
settleLimited,
stageLabel,
titleCase,
- wordsItsOwnStatus,
type BatchCheck,
} from './generate-model';
@@ -72,9 +71,10 @@ function estimate(over: Partial = {}): BridgeEstimate {
can_contribute: true,
figures: true,
writable: true,
+ can_describe: false,
has: {
- community: { characters: false, recaps: false },
- local: { characters: false, recaps: false },
+ community: { characters: false, recaps: false, description: false },
+ local: { characters: false, recaps: false, description: false },
},
earlier: [],
...over,
@@ -103,17 +103,23 @@ describe('the action gate', () => {
});
it('hides the action when the community has both, and says Regenerate over local data', () => {
- const none = { characters: false, recaps: false };
- const both = { characters: true, recaps: true };
+ const none = { characters: false, recaps: false, description: false };
+ const both = { characters: true, recaps: true, description: false };
expect(generateAction({ community: none, local: none })).toBe('generate');
expect(generateAction({ community: both, local: none })).toBe('hidden');
expect(generateAction({ community: both, local: both })).toBe('hidden');
- expect(generateAction({ community: { characters: true, recaps: false }, local: none })).toBe(
- 'generate',
- );
- expect(generateAction({ community: none, local: { characters: false, recaps: true } })).toBe(
- 'regenerate',
- );
+ expect(
+ generateAction({
+ community: { characters: true, recaps: false, description: false },
+ local: none,
+ }),
+ ).toBe('generate');
+ expect(
+ generateAction({
+ community: none,
+ local: { characters: false, recaps: true, description: false },
+ }),
+ ).toBe('regenerate');
});
});
@@ -149,8 +155,8 @@ describe('polling a book', () => {
job('done', { id: 11 }),
job('failed', { id: 10 }),
];
- expect(latestGenerateJob(jobs)?.id).toBe(11);
- expect(latestGenerateJob([])).toBeUndefined();
+ expect(latestJobOf(jobs, KIND_GENERATE)?.id).toBe(11);
+ expect(latestJobOf([], KIND_GENERATE)).toBeUndefined();
});
});
@@ -491,8 +497,8 @@ describe('the series batch', () => {
batchCheck(
estimate({
has: {
- community: { characters: false, recaps: true },
- local: { characters: false, recaps: false },
+ community: { characters: false, recaps: true, description: false },
+ local: { characters: false, recaps: false, description: false },
},
}),
),
@@ -504,8 +510,8 @@ describe('the series batch', () => {
batchCheck(
estimate({
has: {
- community: { characters: true, recaps: false },
- local: { characters: true, recaps: false },
+ community: { characters: true, recaps: false, description: false },
+ local: { characters: true, recaps: false, description: false },
},
}),
),
@@ -533,8 +539,8 @@ describe('the series batch', () => {
estimate({
writable: false,
has: {
- community: { characters: false, recaps: true },
- local: { characters: false, recaps: false },
+ community: { characters: false, recaps: true, description: false },
+ local: { characters: false, recaps: false, description: false },
},
}),
),
@@ -551,14 +557,18 @@ describe('the series batch', () => {
batchCheck(estimate({ open_job: job('running', { cancel_requested: true }) })).open,
).toBe(false);
const unasked = {
- community: { characters: false, recaps: false },
- local: { characters: false, recaps: false },
+ community: { characters: false, recaps: false, description: false },
+ local: { characters: false, recaps: false, description: false },
community_unknown: true,
};
expect(batchCheck(estimate({ has: unasked })).unknown).toBe(true);
// Recaps of its own make it covered, whatever the community says.
expect(
- batchCheck(estimate({ has: { ...unasked, local: { characters: false, recaps: true } } })),
+ batchCheck(
+ estimate({
+ has: { ...unasked, local: { characters: false, recaps: true, description: false } },
+ }),
+ ),
).toMatchObject({ covered: true, unknown: false });
});
@@ -652,9 +662,8 @@ describe('a description (Phase 1c)', () => {
});
it('is not offered without a Bridge that can, content to write from, or with one already', () => {
- const local = has({ characters: true, recaps: true });
+ const local = has({ characters: true, recaps: true, description: false });
expect(describeOffered(undefined, [])).toBe(false);
- expect(describeOffered({ has: local }, [])).toBe(false);
expect(describeOffered({ can_describe: false, has: local }, [])).toBe(false);
expect(describeOffered({ can_describe: true, has: has({}) }, [])).toBe(false);
expect(
@@ -685,9 +694,9 @@ describe('a description (Phase 1c)', () => {
const jobs = [open(KIND_DESCRIBE), open(KIND_GENERATE, 'done')];
expect(latestJobOf(jobs, KIND_GENERATE)).toBe(jobs[1]);
expect(latestJobOf(jobs, KIND_DESCRIBE)).toBe(jobs[0]);
- expect(latestGenerateJob(jobs)).toBe(jobs[1]);
- expect(wordsItsOwnStatus(KIND_DESCRIBE)).toBe(true);
- expect(wordsItsOwnStatus('bridge.verify')).toBe(false);
+ expect(isBookKind(KIND_DESCRIBE)).toBe(true);
+ expect(isBookKind(KIND_GENERATE)).toBe(true);
+ expect(isBookKind('bridge.verify')).toBe(false);
});
it("reads a result's description outcome", () => {
diff --git a/admin-ui/src/features/bridge/generate-model.ts b/admin-ui/src/features/bridge/generate-model.ts
index 04e3702b..81968e24 100644
--- a/admin-ui/src/features/bridge/generate-model.ts
+++ b/admin-ui/src/features/bridge/generate-model.ts
@@ -27,6 +27,21 @@ export const KIND_DESCRIBE = 'sidecars.describe';
/** The kinds the book page follows: generate and describe (both in the sidecars family). */
const BOOK_KINDS: readonly string[] = [KIND_GENERATE, KIND_DESCRIBE];
+/**
+ * Whether a kind is one the book page follows; its progress and result are
+ * worded by GenerateStatus, on the book page and the Bridge page's job rows.
+ */
+export function isBookKind(kind: string): boolean {
+ return BOOK_KINDS.includes(kind);
+}
+
+/** The book page's kinds' jobs that are still open. */
+function openBookJobs>(
+ jobs: readonly J[] | undefined,
+): J[] {
+ return jobs?.filter((j) => isBookKind(j.kind) && isOpen(j.state)) ?? [];
+}
+
/**
* Whether the Generate action is offered at all: community metadata is on (the
* generated content is served through /meta, which needs it) and an online
@@ -68,7 +83,7 @@ export const GENERATE_PARKED_POLL_MS = 60_000;
export function generatePollMs(
jobs: readonly Pick[] | undefined,
): number | false {
- const open = jobs?.filter((j) => BOOK_KINDS.includes(j.kind) && isOpen(j.state)) ?? [];
+ const open = openBookJobs(jobs);
if (open.some((j) => j.state !== 'needs_attention')) return GENERATE_POLL_MS;
return open.length ? GENERATE_PARKED_POLL_MS : false;
}
@@ -81,18 +96,6 @@ export function latestJobOf>(
return jobs?.find((j) => j.kind === kind);
}
-/** The book's newest generate job, if any. */
-export function latestGenerateJob>(
- jobs: readonly J[] | undefined,
-): J | undefined {
- return latestJobOf(jobs, KIND_GENERATE);
-}
-
-/** Whether a job's progress and result are worded by GenerateStatus (generate and describe). */
-export function wordsItsOwnStatus(kind: string): boolean {
- return BOOK_KINDS.includes(kind);
-}
-
/**
* Whether the card offers "Write a description": the Bridge can (online, runs
* sidecars.describe), the book has characters or recaps generated here (their
@@ -109,7 +112,7 @@ export function describeOffered(
if (!(local.characters || local.recaps) || local.description || community.description) {
return false;
}
- return !(jobs ?? []).some((j) => BOOK_KINDS.includes(j.kind) && isOpen(j.state));
+ return openBookJobs(jobs).length === 0;
}
/** A generated description long enough to clamp, with a Show more toggle (about four lines). */
diff --git a/admin-ui/src/features/bridge/generate-parts.tsx b/admin-ui/src/features/bridge/generate-parts.tsx
index b8ee0932..66dcac43 100644
--- a/admin-ui/src/features/bridge/generate-parts.tsx
+++ b/admin-ui/src/features/bridge/generate-parts.tsx
@@ -1,13 +1,72 @@
-import { useId } from 'react';
+import { useId, useState, type ReactNode } from 'react';
+import { useQueryClient } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
-import { LockKeyhole } from 'lucide-react';
+import { LockKeyhole, X } from 'lucide-react';
+import type { BridgeJob } from '@/api/types';
import { Notice } from '@/components/notice';
import { SettingRow } from '@/components/setting-row';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
+import { cancelJob } from './bridge-actions';
+import { stateLook } from './bridge-model';
import type { Contribute } from './generate-model';
-// The pieces the book's generate dialog and the series batch share.
+// The pieces the book's generate dialog and the series batch share, and the
+// Community card's generate and describe sections.
+
+/**
+ * A Community card section that follows a job: its heading, with the job's
+ * state badge when a job is passed (the caller passes one only while it shows).
+ */
+export function JobSection({
+ id,
+ title,
+ job,
+ children,
+}: {
+ id: string;
+ title: string;
+ job?: BridgeJob;
+ children: ReactNode;
+}) {
+ const { t } = useTranslation();
+ const look = job ? stateLook(job) : undefined;
+ return (
+
+
+
+ {title}
+
+ {look ? {t(look.key)} : null}
+
+ {children}
+
+ );
+}
+
+/** Cancels an open job, disabled while the request is out. */
+export function JobCancelButton({ job, ariaLabel }: { job: BridgeJob; ariaLabel: string }) {
+ const { t } = useTranslation();
+ const qc = useQueryClient();
+ const [cancelling, setCancelling] = useState(false);
+ return (
+ {
+ setCancelling(true);
+ void cancelJob(qc, job).finally(() => setCancelling(false));
+ }}
+ aria-label={ariaLabel}
+ >
+
+ {t('bridge.jobs.cancel')}
+
+ );
+}
/** The contribute choice: this server only (off, the default) or offered to the community too. */
export function ContributeSwitch({
diff --git a/admin-ui/src/features/bridge/generate-section.tsx b/admin-ui/src/features/bridge/generate-section.tsx
index 180c67b5..a37c9dd7 100644
--- a/admin-ui/src/features/bridge/generate-section.tsx
+++ b/admin-ui/src/features/bridge/generate-section.tsx
@@ -1,15 +1,13 @@
import { useState } from 'react';
-import { useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
-import { RotateCw, Sparkles, X } from 'lucide-react';
+import { RotateCw, Sparkles } from 'lucide-react';
import { useServerInfo } from '@/api/hooks';
import type { AdminBook } from '@/api/types';
-import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
-import { cancelJob } from './bridge-actions';
-import { isOpen, jobActions, stateLook } from './bridge-model';
+import { isOpen, jobActions } from './bridge-model';
import { GenerateDialog } from './generate-dialog';
import { generateAction, generateAvailable, generateJobsKnown } from './generate-model';
+import { JobCancelButton, JobSection } from './generate-parts';
import { GenerateStatus } from './generate-status';
import { useBookGenerateJob, useBridgeLink, useGenerateEstimate } from './use-generate';
@@ -23,7 +21,6 @@ import { useBookGenerateJob, useBridgeLink, useGenerateEstimate } from './use-ge
*/
export function GenerateSection({ book }: { book: AdminBook }) {
const { t } = useTranslation();
- const qc = useQueryClient();
const server = useServerInfo().data;
const available = generateAvailable(server);
const { job } = useBookGenerateJob(book.library_id, book.path, generateJobsKnown(server));
@@ -32,7 +29,6 @@ export function GenerateSection({ book }: { book: AdminBook }) {
const parked = job?.state === 'needs_attention';
const uiUrl = useBridgeLink(parked);
const [open, setOpen] = useState(false);
- const [cancelling, setCancelling] = useState(false);
const has = estimate.data?.has;
const action = has ? generateAction(has) : undefined;
@@ -46,17 +42,14 @@ export function GenerateSection({ book }: { book: AdminBook }) {
// An open dialog stays (it says when the Bridge went away), whatever else changed.
if (!open && !showJob && !offer && !generated) return null;
- const look = job ? stateLook(job) : undefined;
const canCancel = job ? jobActions(job).cancel : false;
return (
-
-
-
- {t('bridge.generate.title')}
-
- {showJob && look ? {t(look.key)} : null}
-
+
{generated && !jobOpen ? (
{t('bridge.generate.localNote')}
) : null}
@@ -76,19 +69,7 @@ export function GenerateSection({ book }: { book: AdminBook }) {
) : null}
{job && canCancel ? (
- {
- setCancelling(true);
- void cancelJob(qc, job).finally(() => setCancelling(false));
- }}
- aria-label={t('bridge.generate.cancelAria')}
- >
-
- {t('bridge.jobs.cancel')}
-
+
) : null}
) : null}
@@ -99,6 +80,6 @@ export function GenerateSection({ book }: { book: AdminBook }) {
regenerate={action === 'regenerate'}
offline={!available}
/>
-
+
);
}
diff --git a/admin-ui/src/features/bridge/generate.test.tsx b/admin-ui/src/features/bridge/generate.test.tsx
index e98e5ec9..ba307b38 100644
--- a/admin-ui/src/features/bridge/generate.test.tsx
+++ b/admin-ui/src/features/bridge/generate.test.tsx
@@ -35,9 +35,10 @@ function estimate(over: Partial = {}): BridgeEstimate {
can_contribute: true,
figures: true,
writable: true,
+ can_describe: false,
has: {
- community: { characters: false, recaps: false },
- local: { characters: false, recaps: false },
+ community: { characters: false, recaps: false, description: false },
+ local: { characters: false, recaps: false, description: false },
},
earlier: [],
...over,
@@ -105,8 +106,8 @@ describe('generate characters and recaps', () => {
'GET /admin/bridge/estimate': {
body: estimate({
has: {
- community: { characters: true, recaps: true },
- local: { characters: false, recaps: false },
+ community: { characters: true, recaps: true, description: false },
+ local: { characters: false, recaps: false, description: false },
},
}),
},
@@ -467,8 +468,8 @@ describe('generate characters and recaps', () => {
'GET /admin/bridge/estimate': {
body: estimate({
has: {
- community: { characters: false, recaps: false },
- local: { characters: true, recaps: true },
+ community: { characters: false, recaps: false, description: false },
+ local: { characters: true, recaps: true, description: false },
},
}),
},
diff --git a/admin-ui/src/lib/server-events.ts b/admin-ui/src/lib/server-events.ts
index e6cf62c0..2d0b346e 100644
--- a/admin-ui/src/lib/server-events.ts
+++ b/admin-ui/src/lib/server-events.ts
@@ -126,6 +126,12 @@ export function describeEvent(ev: ServerEvent): EventText {
};
}
+/** The kinds whose finished job has a title of its own (the rest name the kind in the body). */
+const FINISHED_OWN = new Map([
+ [KIND_GENERATE, 'events.bridge_job_finished.generate'],
+ [KIND_DESCRIBE, 'events.bridge_job_finished.describe'],
+]);
+
/**
* A Bridge job that ended (notify.BridgeJobEnded): characters and recaps ready
* for a book, another kind finished, or a job that failed or is parked waiting
@@ -145,12 +151,7 @@ function describeBridgeJob(
? { key: `bridge.kind.${jobKind}`, values: { defaultValue: jobKind } }
: undefined;
if (kind === 'bridge_job_finished') {
- const own =
- jobKind === KIND_GENERATE
- ? 'events.bridge_job_finished.generate'
- : jobKind === KIND_DESCRIBE
- ? 'events.bridge_job_finished.describe'
- : undefined;
+ const own = FINISHED_OWN.get(jobKind);
return own
? { title: { key: own, values: { name } }, tone: 'info', link }
: {
diff --git a/internal/api/bridge_attachments_test.go b/internal/api/bridge_attachments_test.go
index d5bf8344..9b4c0a41 100644
--- a/internal/api/bridge_attachments_test.go
+++ b/internal/api/bridge_attachments_test.go
@@ -13,6 +13,7 @@ import (
"github.com/kodestar/audiosilo-server/internal/auth"
"github.com/kodestar/audiosilo-server/internal/bridge"
"github.com/kodestar/audiosilo-server/internal/catalog"
+ "github.com/kodestar/audiosilo-server/internal/metadata"
)
const mistbornPart = mistbornBook + "/01 - The Final Empire.m4b"
@@ -67,7 +68,7 @@ func TestBridgeRescanAttachments(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if a, err := b.cat.BookAttachments(context.Background(), book.ID); err != nil || a == nil || a.Characters == nil || a.Provenance.JobID != 3 {
+ if a, err := b.cat.BookAttachments(context.Background(), book.ID, metadata.AllAttachmentParts); err != nil || a == nil || a.Characters == nil || a.Provenance.JobID != 3 {
t.Errorf("stored = %+v, %v", a, err)
}
// A description the Bridge wrote since is answered too (Phase 1c).
diff --git a/internal/api/bridge_describe_test.go b/internal/api/bridge_describe_test.go
index 631c57bc..39e61629 100644
--- a/internal/api/bridge_describe_test.go
+++ b/internal/api/bridge_describe_test.go
@@ -306,6 +306,35 @@ func TestMetaGeneratedDescriptionMatched(t *testing.T) {
}
}
+// TestMetaGeneratedDescriptionBesideCommunityContent: a matched work with
+// community characters and recaps but no community description takes only the
+// generated description (generatedFor reads just that part, Fillable): the
+// community's characters and recaps stay, and the mark names the description
+// alone.
+func TestMetaGeneratedDescriptionBesideCommunityContent(t *testing.T) {
+ t.Parallel()
+ e := generatedMetaEnv(t, `{"id":"w","title":"Community Title","authors":[],"language":"en",
+ "characters":[{"id":"community-hero","name":"Hero","reveal":{"chapter":1}}],
+ "recaps":[{"through":{"chapter":1},"text":"Community recap."}]}`)
+ lib := seedChapteredBook(t, e, "A/Two", "B0TWO")
+ setDescription(t, e, lib.ID, "A/Two", "characters.json:1:1,recaps.json:1:1,description.json:1:1", metadata.AttachmentFiles{
+ Characters: &model.Characters{Work: "w", Characters: []model.Character{{ID: "gen", Name: "Gen", Reveal: model.Position{Chapter: 1}}}},
+ Recaps: &model.Recaps{Work: "w", Recaps: []model.Recap{{Through: model.Position{Chapter: 1}, Text: "Gen recap."}}}})
+ tok, _ := e.auth.IssueToken(context.Background(), e.adminID, auth.KindSession, "t", 0)
+
+ g, body := getDescription(t, e, lib.ID, "A/Two", "", tok)
+ if g.Work == nil || g.Work.GeneratedDescription == nil || g.Work.GeneratedDescription.Text != testDescriptionText {
+ t.Fatalf("work = %s", body)
+ }
+ if gen := g.Work.Generated; gen == nil || !gen.Description || gen.Characters || gen.Recaps {
+ t.Errorf("generated = %+v", gen)
+ }
+ if !strings.Contains(body, `"community-hero"`) || !strings.Contains(body, "Community recap.") ||
+ strings.Contains(body, `"gen"`) || strings.Contains(body, "Gen recap.") {
+ t.Errorf("community content not kept: %s", body)
+ }
+}
+
// TestMetaGeneratedDescriptionUnmatched: a book with no match and only a
// generated description answers matched false with a work of the book's title
// and the description, no attribution, kept under spoilers=hide; beside
diff --git a/internal/api/handlers_bridge_estimate.go b/internal/api/handlers_bridge_estimate.go
index d69c38a5..16c34e76 100644
--- a/internal/api/handlers_bridge_estimate.go
+++ b/internal/api/handlers_bridge_estimate.go
@@ -164,13 +164,11 @@ func (a *API) communityContent(ctx context.Context, book *catalog.Book) (has has
}
// localContent is what the book's generated attachments carry (a stored file is
-// never empty: metadata.ParseCharacters and ParseRecaps drop one).
+// never empty: metadata.ParseCharacters, ParseRecaps and ParseDescription drop
+// one), read without decoding them.
func (a *API) localContent(ctx context.Context, book *catalog.Book) (hasContent, error) {
- att, err := a.cat.BookAttachments(ctx, book.ID)
- if err != nil || att == nil {
- return hasContent{}, err
- }
- return hasContent{Characters: att.Characters != nil, Recaps: att.Recaps != nil, Description: att.Description != nil}, nil
+ p, err := a.cat.BookAttachmentParts(ctx, book.ID)
+ return hasContent{Characters: p.Characters, Recaps: p.Recaps, Description: p.Description}, err
}
// earlierWithoutRecaps is the books of the library before book in its series
diff --git a/internal/api/handlers_meta.go b/internal/api/handlers_meta.go
index 7118c2cf..1e9ed415 100644
--- a/internal/api/handlers_meta.go
+++ b/internal/api/handlers_meta.go
@@ -102,22 +102,25 @@ func (a *API) handleMeta(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, meta.Answer(out, book.Title, gen, hide))
}
-// generatedFor is the book's generated content for /meta (nil for none, or when
-// env's community work leaves nothing for it to fill). It is an extra like
+// generatedFor is the book's generated content for /meta: the parts env's
+// community work leaves for it to fill (meta.Enrichment.Fillable), nil for none. It is an extra like
// `local`: a failure to read it is logged and the answer goes out without it.
// A book whose row names no generated file (Book.Generated) isn't looked up.
func (a *API) generatedFor(ctx context.Context, book *catalog.Book, env *meta.Enrichment) *meta.Generated {
- if !book.Generated || !env.Fillable() {
+ fill := env.Fillable()
+ if !book.Generated || !fill.Any() {
return nil
}
- att, err := a.cat.BookAttachments(ctx, book.ID)
+ // Only the parts the community work lacks: a work with characters and
+ // recaps of its own reads just the description.
+ att, err := a.cat.BookAttachments(ctx, book.ID, fill)
if err != nil {
if ctx.Err() == nil {
a.log.Warn("load generated content for meta failed", "err", err, "library", book.LibraryID, "path", book.RelPath)
}
return nil
}
- if att == nil || (att.Characters == nil && att.Recaps == nil && att.Description == nil) {
+ if att == nil || !att.Generated() {
return nil
}
g := &meta.Generated{Characters: att.Characters, Recaps: att.Recaps, Description: att.Description}
diff --git a/internal/bridge/jobs.go b/internal/bridge/jobs.go
index 4a125fb1..d78f2223 100644
--- a/internal/bridge/jobs.go
+++ b/internal/bridge/jobs.go
@@ -433,15 +433,15 @@ func (s *Service) Enqueue(ctx context.Context, j NewJob) (job *Job, created bool
return refuse
}
if j.Kind == KindDescribe {
- var open int
- err := tx.QueryRowContext(ctx, `SELECT 1 FROM bridge_jobs WHERE dedupe_key = ? AND state IN `+OpenStatesSQL,
- DedupeKey(KindSidecars, j.LibraryID, j.Path)).Scan(&open)
- if err == nil {
- return ErrGenerateOpen
- }
- if !errors.Is(err, sql.ErrNoRows) {
+ var open bool
+ if err := tx.QueryRowContext(ctx,
+ `SELECT EXISTS(SELECT 1 FROM bridge_jobs WHERE dedupe_key = ? AND state IN `+OpenStatesSQL+`)`,
+ DedupeKey(KindSidecars, j.LibraryID, j.Path)).Scan(&open); err != nil {
return err
}
+ if open {
+ return ErrGenerateOpen
+ }
}
res, err := tx.ExecContext(ctx,
`INSERT INTO bridge_jobs(kind, library_id, rel_path, params, requested_by, priority, dedupe_key, created_at, updated_at)
diff --git a/internal/catalog/attachments.go b/internal/catalog/attachments.go
index 178c002e..5dfc9660 100644
--- a/internal/catalog/attachments.go
+++ b/internal/catalog/attachments.go
@@ -51,12 +51,13 @@ type AttachmentsState struct {
// generatedSig reports whether a book's attachments signature (attachments_sig)
// names a characters, recaps or description file: Book.Generated, read from the
-// narrow column so a list never joins book_attachments. A file that failed its checks still
+// narrow column so a list never joins book_attachments. A signature lists only
+// known names, so any but bridge.json (provenance) is one. A file that failed its checks still
// counts (its signature is recorded so it isn't read again): a /meta read then
// finds nothing, which costs a request and no more.
func generatedSig(sig string) bool {
for _, name := range metadata.AttachmentSigNames(sig) {
- if name == metadata.AttachmentCharacters || name == metadata.AttachmentRecaps || name == metadata.AttachmentDescription {
+ if name != metadata.AttachmentBridge {
return true
}
}
@@ -164,12 +165,22 @@ func (c *Catalog) SetAttachments(ctx context.Context, libraryID int64, readings
}
// BookAttachments returns the stored attachments of book id (books.id, read
-// inside the server only), or nil, nil when it has none. A column that no longer
-// decodes reads as a file the book doesn't have.
-func (c *Catalog) BookAttachments(ctx context.Context, bookID int64) (*metadata.AttachmentFiles, error) {
+// inside the server only) with the generated parts want names, and its
+// provenance, or nil, nil when that leaves nothing. A part want leaves out is
+// never read (characters and recaps run to hundreds of KB). A column that no
+// longer decodes reads as a file the book doesn't have.
+func (c *Catalog) BookAttachments(ctx context.Context, bookID int64, want metadata.AttachmentParts) (*metadata.AttachmentFiles, error) {
+ // A part left out reads as none; the column names are constants.
+ col := func(name string, on bool) string {
+ if on {
+ return name
+ }
+ return "''"
+ }
var chars, recaps, desc, prov string
err := c.db.QueryRowContext(ctx,
- `SELECT characters, recaps, description, provenance FROM book_attachments WHERE book_id = ?`, bookID).
+ `SELECT `+col("characters", want.Characters)+`, `+col("recaps", want.Recaps)+`, `+
+ col("description", want.Description)+`, provenance FROM book_attachments WHERE book_id = ?`, bookID).
Scan(&chars, &recaps, &desc, &prov)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
@@ -188,3 +199,17 @@ func (c *Catalog) BookAttachments(ctx context.Context, bookID int64) (*metadata.
}
return a, nil
}
+
+// BookAttachmentParts reports which generated parts book id has stored, without
+// reading them (the Bridge estimate's has.local). A stored column is a checked
+// file the server wrote, so a non-empty one is a part the book has.
+func (c *Catalog) BookAttachmentParts(ctx context.Context, bookID int64) (metadata.AttachmentParts, error) {
+ var p metadata.AttachmentParts
+ err := c.db.QueryRowContext(ctx,
+ `SELECT characters <> '', recaps <> '', description <> '' FROM book_attachments WHERE book_id = ?`, bookID).
+ Scan(&p.Characters, &p.Recaps, &p.Description)
+ if errors.Is(err, sql.ErrNoRows) {
+ return metadata.AttachmentParts{}, nil
+ }
+ return p, err
+}
diff --git a/internal/catalog/attachments_test.go b/internal/catalog/attachments_test.go
index 5096c255..21d3a03e 100644
--- a/internal/catalog/attachments_test.go
+++ b/internal/catalog/attachments_test.go
@@ -64,13 +64,13 @@ func TestBookGenerated(t *testing.T) {
// The stored content is read by the book's id.
b, _ := c.GetBookByPath(ctx, lib.ID, "Indexed")
- if a, err := c.BookAttachments(ctx, b.ID); err != nil || a == nil || a.Characters == nil || a.Recaps != nil {
+ if a, err := c.BookAttachments(ctx, b.ID, metadata.AllAttachmentParts); err != nil || a == nil || a.Characters == nil || a.Recaps != nil {
t.Fatalf("attachments = %+v, %v", a, err)
}
// A description round-trips, and the console's book page shows it with its
// own time.
described, _ := c.GetBookByPath(ctx, lib.ID, "Described")
- if a, err := c.BookAttachments(ctx, described.ID); err != nil || a == nil || a.Description == nil ||
+ if a, err := c.BookAttachments(ctx, described.ID, metadata.AllAttachmentParts); err != nil || a == nil || a.Description == nil ||
a.Description.Text != "A described book." || a.Characters != nil || a.Provenance.DescriptionAt != "2026-10-16T08:00:00Z" {
t.Fatalf("described attachments = %+v, %v", a, err)
}
@@ -85,7 +85,7 @@ func TestBookGenerated(t *testing.T) {
t.Errorf("detail without a description carries the field: %s", raw)
}
none, _ := c.GetBookByPath(ctx, lib.ID, "None")
- if a, err := c.BookAttachments(ctx, none.ID); err != nil || a != nil {
+ if a, err := c.BookAttachments(ctx, none.ID, metadata.AllAttachmentParts); err != nil || a != nil {
t.Fatalf("no attachments = %+v, %v", a, err)
}
}
@@ -115,7 +115,7 @@ func TestSetAttachmentsCompareAndSet(t *testing.T) {
short := func() string {
t.Helper()
got, _ := c.GetBookByPath(ctx, lib.ID, "Book")
- a, err := c.BookAttachments(ctx, got.ID)
+ a, err := c.BookAttachments(ctx, got.ID, metadata.AllAttachmentParts)
if err != nil || a == nil || a.Recaps == nil {
t.Fatalf("attachments = %+v, %v", a, err)
}
@@ -141,3 +141,54 @@ func TestSetAttachmentsCompareAndSet(t *testing.T) {
t.Fatalf("after a current scan write: %q, sig %q", got, state().Sig)
}
}
+
+// BookAttachments reads only the parts asked for (provenance always), and
+// BookAttachmentParts says which parts are stored without reading them.
+func TestBookAttachmentsParts(t *testing.T) {
+ t.Parallel()
+ c, ctx := newTestCatalog(t)
+ lib, _ := c.CreateLibrary(ctx, Library{Name: "L", Root: "/tmp/l"})
+ all := Book{LibraryID: lib.ID, RelPath: "All", Title: "All", AddedAt: "2024-01-01T00:00:00Z",
+ Attachments: AttachmentsReading{Sig: "characters.json:1:1,recaps.json:1:1,description.json:1:1,bridge.json:1:1",
+ Files: metadata.AttachmentFiles{
+ Characters: &model.Characters{Characters: []model.Character{{ID: "hero", Name: "Hero"}}},
+ Recaps: &model.Recaps{InShort: "Short."},
+ Description: &model.Description{Work: "w", Text: "A described book.", License: "CC-BY-SA-4.0"},
+ Provenance: &metadata.AttachmentProvenance{Kind: "sidecars.generate"},
+ }}}
+ recapsOnly := Book{LibraryID: lib.ID, RelPath: "Recaps", Title: "Recaps", AddedAt: "2024-01-01T00:00:00Z",
+ Attachments: AttachmentsReading{Sig: "recaps.json:1:1",
+ Files: metadata.AttachmentFiles{Recaps: &model.Recaps{InShort: "Short."}}}}
+ none := Book{LibraryID: lib.ID, RelPath: "None", Title: "None", AddedAt: "2024-01-01T00:00:00Z"}
+ for _, b := range []*Book{&all, &recapsOnly, &none} {
+ if _, err := c.UpsertBook(ctx, b); err != nil {
+ t.Fatal(err)
+ }
+ }
+ allID, _ := c.GetBookByPath(ctx, lib.ID, "All")
+ recapsID, _ := c.GetBookByPath(ctx, lib.ID, "Recaps")
+ noneID, _ := c.GetBookByPath(ctx, lib.ID, "None")
+
+ // The description alone: the characters and recaps stored beside it are never read.
+ a, err := c.BookAttachments(ctx, allID.ID, metadata.AttachmentParts{Description: true})
+ if err != nil || a == nil || a.Description == nil || a.Characters != nil || a.Recaps != nil || a.Provenance == nil {
+ t.Fatalf("description only = %+v, %v", a, err)
+ }
+ // A part the book doesn't have, with no provenance: nothing.
+ if a, err := c.BookAttachments(ctx, recapsID.ID, metadata.AttachmentParts{Description: true}); err != nil || a != nil {
+ t.Fatalf("a part it lacks = %+v, %v", a, err)
+ }
+
+ for _, tc := range []struct {
+ id int64
+ want metadata.AttachmentParts
+ }{
+ {allID.ID, metadata.AllAttachmentParts},
+ {recapsID.ID, metadata.AttachmentParts{Recaps: true}},
+ {noneID.ID, metadata.AttachmentParts{}},
+ } {
+ if got, err := c.BookAttachmentParts(ctx, tc.id); err != nil || got != tc.want {
+ t.Errorf("parts of %d = %+v, %v; want %+v", tc.id, got, err, tc.want)
+ }
+ }
+}
diff --git a/internal/catalog/bookdetail.go b/internal/catalog/bookdetail.go
index 94018280..7f64c247 100644
--- a/internal/catalog/bookdetail.go
+++ b/internal/catalog/bookdetail.go
@@ -6,6 +6,7 @@ import (
"errors"
"path"
+ "github.com/kodestar/audiosilo-server/internal/metadata"
"github.com/kodestar/audiosilo-server/internal/relpath"
)
@@ -173,7 +174,8 @@ func (c *Catalog) AdminBookDetail(ctx context.Context, libraryID int64, relPath
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
- att, err := c.BookAttachments(ctx, bookID)
+ // The description and its provenance only, never the characters and recaps.
+ att, err := c.BookAttachments(ctx, bookID, metadata.AttachmentParts{Description: true})
if err != nil {
return nil, err
}
diff --git a/internal/library/attachments_test.go b/internal/library/attachments_test.go
index 141a2ba0..8ebd9848 100644
--- a/internal/library/attachments_test.go
+++ b/internal/library/attachments_test.go
@@ -43,7 +43,7 @@ func attachmentsAt(t *testing.T, cat *catalog.Catalog, libID int64, rel string)
if err != nil {
t.Fatal(err)
}
- a, err := cat.BookAttachments(t.Context(), b.ID)
+ a, err := cat.BookAttachments(t.Context(), b.ID, metadata.AllAttachmentParts)
if err != nil {
t.Fatal(err)
}
diff --git a/internal/meta/generated.go b/internal/meta/generated.go
index 99158d12..64ce0213 100644
--- a/internal/meta/generated.go
+++ b/internal/meta/generated.go
@@ -4,6 +4,8 @@ import (
"strings"
"github.com/kodestar/audiosilo-meta/pkg/model"
+
+ "github.com/kodestar/audiosilo-server/internal/metadata"
)
// Generated content is the characters, recaps and description the Bridge
@@ -63,13 +65,17 @@ type Place struct {
Finished bool
}
-// Fillable reports whether generated content could fill anything in e's work
-// (parts): it lacks characters, recaps or a community description (or e has no
-// match at all); a summary is only ever taken with the recaps. When it can't,
-// /meta needn't load the book's generated content.
-func (e *Enrichment) Fillable() bool {
- return e == nil || e.Work == nil || len(e.Work.Characters) == 0 || len(e.Work.Recaps) == 0 ||
- e.Work.CommunityDescription == nil
+// Fillable is which generated parts could fill e's work (parts): characters
+// when it has none, recaps when it has no recap list (a summary is only ever
+// taken with the recaps), a description when it has no community description;
+// every part when e has no match at all. /meta loads only those parts of the
+// book's generated content, and none when it is none.
+func (e *Enrichment) Fillable() metadata.AttachmentParts {
+ return metadata.AttachmentParts{
+ Characters: !e.HasCharacters(),
+ Recaps: e == nil || e.Work == nil || len(e.Work.Recaps) == 0,
+ Description: !e.HasCommunityDescription(),
+ }
}
// Answer is /meta's per-request answer for a book: env is its matched envelope
@@ -212,8 +218,10 @@ func (g *Generated) summary() *MetaRecapSummary {
return &MetaRecapSummary{InShort: g.Recaps.InShort, Ending: g.Recaps.Ending}
}
-// description is g's description as the envelope carries it, nil when blank
-// (metadata.ParseDescription drops one).
+// description is g's description as the envelope carries it, nil when blank.
+// metadata.ParseDescription already stores the text trimmed and drops a blank
+// one; the trim here is deliberate defence, as summary's is, so a Generated
+// built any other way can't send a blank description.
func (g *Generated) description() *MetaGeneratedDescription {
if g.Description == nil || strings.TrimSpace(g.Description.Text) == "" {
return nil
diff --git a/internal/meta/generated_test.go b/internal/meta/generated_test.go
index a712e628..2cdf79bd 100644
--- a/internal/meta/generated_test.go
+++ b/internal/meta/generated_test.go
@@ -6,6 +6,8 @@ import (
"testing"
"github.com/kodestar/audiosilo-meta/pkg/model"
+
+ "github.com/kodestar/audiosilo-server/internal/metadata"
)
func testGenerated() *Generated {
@@ -88,17 +90,21 @@ func TestAnswerFillsWhatTheCommunityLacks(t *testing.T) {
}
// Characters, recaps and a description but no summary: still nothing to
// fill, as a summary only ever comes with the recaps.
- if env.Fillable() {
- t.Error("a work with characters, recaps and a description is fillable")
+ if got := env.Fillable(); got.Any() {
+ t.Errorf("a work with characters, recaps and a description fills %+v", got)
}
- // Without the community description there is one thing to fill.
+ // Without the community description there is one thing to fill, and only
+ // that part is loaded.
noDesc := *both
noDesc.CommunityDescription = nil
- if !(&Enrichment{Work: &noDesc}).Fillable() {
- t.Error("a work without a community description can't be filled")
+ if got := (&Enrichment{Work: &noDesc}).Fillable(); got != (metadata.AttachmentParts{Description: true}) {
+ t.Errorf("a work without a community description fills %+v", got)
+ }
+ if got := (&Enrichment{Work: &MetaWork{Characters: both.Characters}}).Fillable(); got != (metadata.AttachmentParts{Recaps: true, Description: true}) {
+ t.Errorf("a work with characters alone fills %+v", got)
}
- if !(&Enrichment{Work: &MetaWork{Characters: both.Characters}}).Fillable() || !(*Enrichment)(nil).Fillable() {
- t.Error("a work without recaps, or no match, can't be filled")
+ if got := (*Enrichment)(nil).Fillable(); got != metadata.AllAttachmentParts {
+ t.Errorf("no match fills %+v", got)
}
// A community summary stays beside generated recaps, and still counts as
diff --git a/internal/metadata/attachments.go b/internal/metadata/attachments.go
index eb4f93ca..683c94ed 100644
--- a/internal/metadata/attachments.go
+++ b/internal/metadata/attachments.go
@@ -94,6 +94,29 @@ func (f AttachmentFiles) Empty() bool {
return f.Characters == nil && f.Recaps == nil && f.Description == nil && f.Provenance == nil
}
+// Generated reports whether f holds generated content: characters, recaps or a
+// description (provenance alone is none).
+func (f AttachmentFiles) Generated() bool {
+ return f.Characters != nil || f.Recaps != nil || f.Description != nil
+}
+
+// AttachmentParts names a book's generated parts: which ones a reader wants
+// (catalog.BookAttachments reads only those) or which ones a book has
+// (catalog.BookAttachmentParts).
+type AttachmentParts struct {
+ Characters bool
+ Recaps bool
+ Description bool
+}
+
+// AllAttachmentParts is every generated part.
+var AllAttachmentParts = AttachmentParts{Characters: true, Recaps: true, Description: true}
+
+// Any reports whether p names any part.
+func (p AttachmentParts) Any() bool {
+ return p.Characters || p.Recaps || p.Description
+}
+
// AttachmentSignature is one attachment's part of a book's signature: its known
// name, size and modification time (ns). The Bridge rewrites these files without
// touching the audio, so a scan compares them, not the book's own signature.
@@ -264,10 +287,8 @@ func ParseDescription(data []byte) (*model.Description, error) {
if n := utf8.RuneCountInString(d.Text); n < minDescriptionText || n > maxDescriptionText {
return nil, invalidAttachment("a description of %d characters", n)
}
- for _, r := range d.Text {
- if r != '\n' && unicode.IsControl(r) {
- return nil, invalidAttachment("a control character in the description")
- }
+ if strings.ContainsFunc(d.Text, func(r rune) bool { return unicode.IsControl(r) && r != '\n' }) {
+ return nil, invalidAttachment("a control character in the description")
}
return &d, nil
}
diff --git a/internal/notify/notify.go b/internal/notify/notify.go
index 4146232f..c5075d4c 100644
--- a/internal/notify/notify.go
+++ b/internal/notify/notify.go
@@ -398,13 +398,9 @@ func compose(ev catalog.ServerEvent, srv Server) message {
case KindBridgeJobFinished:
m.Title = bridgeJobLabel(str("job_kind")) + " finished: " + bridgeJobBook(ev)
m.Text = "The Bridge finished the job."
- switch str("job_kind") {
- case bridge.KindSidecars:
- m.Title = "Characters and recaps ready: " + bridgeJobBook(ev)
- m.Text = "Listeners see them in the player now."
- case bridge.KindDescribe:
- m.Title = "Description ready: " + bridgeJobBook(ev)
- m.Text = "Listeners see it in the book's About in the player now."
+ if w := bridgeKindWords[str("job_kind")]; w.ready != "" {
+ m.Title = w.ready + ": " + bridgeJobBook(ev)
+ m.Text = w.readyText
}
m.Link, m.Tags = link("/server?topic=bridge"), []string{"white_check_mark"}
case KindBridgeJobFailed:
@@ -427,17 +423,18 @@ func compose(ev catalog.ServerEvent, srv Server) message {
return m
}
+// bridgeKindWords is how a message words each Bridge job kind it knows: its
+// label in a title and, for a kind whose finished job leaves something for
+// listeners, what is ready (the title's start) and where they see it.
+var bridgeKindWords = map[string]struct{ label, ready, readyText string }{
+ bridge.KindSidecars: {"Characters and recaps", "Characters and recaps ready", "Listeners see them in the player now."},
+ bridge.KindDescribe: {"Description", "Description ready", "Listeners see it in the book's About in the player now."},
+ bridge.KindVerify: {label: "Bridge check"},
+}
+
// bridgeJobLabel names a Bridge job's kind in a message title.
func bridgeJobLabel(kind string) string {
- switch kind {
- case bridge.KindSidecars:
- return "Characters and recaps"
- case bridge.KindDescribe:
- return "Description"
- case bridge.KindVerify:
- return "Bridge check"
- }
- return "Bridge job"
+ return cmp.Or(bridgeKindWords[kind].label, "Bridge job")
}
// bridgeJobBook is the book a Bridge job event is about: its title, else its
From 9b1dd0a96fa393ed5f3ca402030ebd2605672462 Mon Sep 17 00:00:00 2001
From: KodeStar
Date: Fri, 16 Oct 2026 16:25:18 +0100
Subject: [PATCH 3/5] Bridge 1c: review fixes (describe offer guards, own
times, allow-listed parts, narrow reads)
---
CLAUDE.md | 54 +++++++----
admin-ui/src/api/types.ts | 14 ++-
admin-ui/src/features/book/book-aside.tsx | 6 +-
.../src/features/bridge/describe-section.tsx | 56 +++++++++---
.../src/features/bridge/describe.test.tsx | 85 +++++++++++++++++-
.../features/bridge/generate-model.test.ts | 50 +++++++----
.../src/features/bridge/generate-model.ts | 40 ++++++---
admin-ui/src/features/bridge/use-generate.ts | 5 +-
admin-ui/src/i18n/locales/de.json | 3 +-
admin-ui/src/i18n/locales/en.json | 3 +-
admin-ui/src/i18n/locales/es.json | 3 +-
admin-ui/src/i18n/locales/fr.json | 3 +-
admin-ui/src/i18n/locales/it.json | 3 +-
admin-ui/src/i18n/locales/pt.json | 3 +-
admin-ui/src/lib/errors.ts | 4 +-
admin-ui/src/lib/server-events.ts | 9 +-
internal/api/bridge_describe_test.go | 20 +++--
internal/api/handlers_bridge.go | 3 +-
internal/api/handlers_meta.go | 23 +++--
internal/bridge/jobs.go | 6 +-
internal/catalog/attachments.go | 39 +++++---
internal/catalog/attachments_test.go | 14 ++-
internal/catalog/bookdetail.go | 27 +++---
internal/catalog/books.go | 3 +-
internal/catalog/model.go | 11 ++-
internal/meta/generated.go | 90 +++++++++++--------
internal/meta/generated_test.go | 26 ++++++
internal/metadata/attachments.go | 46 ++++++----
internal/metadata/attachments_test.go | 17 +++-
internal/notify/notify.go | 2 +-
internal/notify/notify_test.go | 2 +-
31 files changed, 477 insertions(+), 193 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index e8394577..563dd7eb 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -110,7 +110,7 @@ internal/library/ filesystem view (fsview.go: SafeJoin, and ContentPath/Cont
internal/metadata/ dhowden/tag + ffprobe extraction (a `series-part` tag "1-3" is a range: seriesrange.go, ParseSeriesRange, the one grammar of a range position, and ParseSeriesPosition, a number or a range; incl. ReleaseDate: a date tag as YYYY[-MM[-DD]], stored as books.released, never as published); DeriveFromPath (structural path parsing, the scan's baseline: the folder holding the book is the series and the one above it the author, but a lone folder is the author); layout.go: ReadPathLayout (author/series/book LAYOUT, the match's path facts) and FromPathLayout (a path-first library's values); absfile.go: ParseABSFile/ReadABSFile (an Audiobookshelf metadata.json, see Metadata overrides); attachments.go: AttachmentsDir `.audiosilo`, Read/ParseCharacters, Recaps, Description and Provenance (the Bridge's generated files, checked against audiosilo-meta's formats; see Bridge attachments); titlepos.go: PositionInTitle (a series position read from a title that is the series' name then a number, "He Who Fights with Monsters 4: ..."; narrow: set off by a space or comma, 1-3 digits, then the end or a separator, never a range)
internal/media/ Range streaming, download, embedded cover extraction
internal/names/ reading people in an Author/Narrator credit: Split (the deliberately shy co-credit rule), Reversed ("Surname, Given"), SortKey (surname first; the catalog registers it as the SQL function name_sort for the admin list's surname sort)
-internal/meta/ Phase 1.5 community metadata lookup: HTTP client + Service (asin/isbn -> composed enrichment envelope) with a bounded TTL cache and its persistent SQLite level (store.go); the /meta bundle's previous works and spoiler gating (bundle.go); the admin console's match (match.go: metaserve works/match over tag + path facts, metadata.ReadPathLayout; works/search fallback for an older metaserve); community cover fetches (cover.go: public addresses only); owned books' work ids for the Series cards (workids.go); placing the caller's books on rails and the next rail entry (local.go); which of a work's series a book is in (seriesrules.go: the series rules below); generated content laid beside the community work (generated.go: `Answer`, `Unmatched`, `MetaGenerated`, `MetaGeneratedDescription`); cache-only reads for the Bridge's estimate (cachedenrich.go: `CachedEnrichment`, `Coverages`, each with a community description flag)
+internal/meta/ Phase 1.5 community metadata lookup: HTTP client + Service (asin/isbn -> composed enrichment envelope) with a bounded TTL cache and its persistent SQLite level (store.go); the /meta bundle's previous works and spoiler gating (bundle.go); the admin console's match (match.go: metaserve works/match over tag + path facts, metadata.ReadPathLayout; works/search fallback for an older metaserve); community cover fetches (cover.go: public addresses only); owned books' work ids for the Series cards (workids.go); placing the caller's books on rails and the next rail entry (local.go); which of a work's series a book is in (seriesrules.go: the series rules below); generated content laid beside the community work (generated.go: `Answer`, `Unmatched`, `MetaGenerated`, `MetaGeneratedDescription`); cache-only reads for the Bridge's estimate (cachedenrich.go: `CachedEnrichment`; `Coverages`, a `Coverage` per book whose `Description` is the community work's `community_description`)
internal/chapteralign/ fits a community recording's chapter list onto a book's own audio (pure, no I/O): anchors by title + time, places the rest in proportion, snaps each to its pause (an injected Prober); classifies fill/titles/refine/restructure/same or why not (length_mismatch, structure_mismatch, crosses_files). testdata/mythos is a real 34-vs-174-chapter golden case
internal/chaptercheck/ the community chapter check: a background pass (every 10 min, and on Catalog.OnBookChange) and on request (Start), meta.RecordingChapters -> chapteralign (ffmpeg silencedetect via media.DetectSilences) -> catalog.SaveCommunityChapters
internal/covercolors/ the background cover colour pass: Runner reads the colour of every book whose cover may have art and holds none for it (catalog.CoverColorsDue), one at a time, once the start or a burst of OnBookChange kicks has been quiet 30 s (<= 10 min), and hourly; the reading is api.colorCover (a cached thumbnail, else an UNCACHED 160 px one), handed in as its Colorer; art with no colour is recorded as such (cover_color = the version alone; CoverSource.ColorRead, while Colored stays "has a colour", so a thumbnail that decodes still records over it); art files that aren't there (an unmounted share) are covercolors.ErrArtMissing: left due, not a failure
@@ -514,12 +514,18 @@ future metadata site can attach enrichment without reshaping the schema. The
(a shipped 2.0 app shows that first and credits the community; it ignores the new field), and
`work.generated.description` (bool, always sent beside `characters`/`recaps`) marks it. It fills
only a work with no `community_description` (the core `description`, the publisher's kind, doesn't
- block it: the player orders those). `Fillable` says which parts a work leaves to fill
- (`metadata.AttachmentParts`), and `generatedFor` reads only those columns (`catalog.BookAttachments`
- with a parts mask: a work with community characters and recaps reads just the description); `?spoilers=hide` keeps
+ block it: the player orders those). `fillable` (generated.go) is the one statement of which parts a
+ work leaves to fill (`metadata.AttachmentParts`): `Fillable` loads by it and `parts` lays in by it.
+ `generatedFor` reads only the parts it names that the book's signature names too
+ (`Book.GeneratedParts`), plus the provenance (`catalog.BookAttachments` with a parts mask: a work with
+ community characters and recaps reads just the description and its provenance, and none for a book
+ without one); `?spoilers=hide` keeps
it (spoiler-free by construction: the Bridge writes it from the opening chapters' notes only), and
- attribution never counts it. An unmatched book with only a description answers `{matched: false,
- work: {title, generated_description, generated: {description: true, ...}}}`. Out of scope for now: no cover remote-fallback, no tag-based ASIN extraction.
+ attribution never counts it. `generated.generated_at` is bridge.json's `generated_at` (the
+ characters' and recaps' run), or its `description.generated_at` (`Generated.DescriptionAt`) when the
+ description is all the mark names: a description-only job keeps the run's time. An unmatched book
+ with only a description answers `{matched: false, work: {title, generated_description, generated:
+ {description: true, ...}, ...}}`. Out of scope for now: no cover remote-fallback, no tag-based ASIN extraction.
**Reading-order families** (metaserve schema_version 7): `seriesRails` collapses
each family (key `ordering_of || id`) into ONE rail whose top-level view is the
MAIN view - the ref with no `ordering_of` (the primary), else the first ref - so
@@ -916,11 +922,12 @@ future metadata site can attach enrichment without reshaping the schema. The
`model.Characters` / `model.Recaps` / `model.Description`, bounded, a regular file only, never through a link) in
`book_attachments`, and `/meta` serves them (below). A description (`metadata.ParseDescription`) is
its text trimmed, 200-1500 code points (meta's `description.schema.json` bounds,
- `TestDescriptionBoundsMatchTheSchema` reads them from `meta.SchemaFS`), no control character but a
- line break; a blank text is none, anything else fails the file. `AttachmentNames` lists it before
- `bridge.json`, so a book without one keeps its stored signature byte for byte. bridge.json's
- `description.generated_at` is read as `AttachmentProvenance.DescriptionAt` (the rest of that object
- is the Bridge's). The discovery walk lists each `.audiosilo` folder
+ `TestDescriptionBoundsMatchTheSchema` reads them from audiosilo-meta's embedded `SchemaFS`, imported
+ as `metaschema`), no control character but a line break; a blank text is none, anything else fails
+ the file. A signature lists only the names a book has, in `AttachmentNames` order, and adding the
+ name kept the other three in order, so a book without one keeps its stored signature byte for byte.
+ bridge.json's `description.generated_at` is read as `AttachmentProvenance.DescriptionAt` (the rest of
+ that object is the Bridge's; an object of another shape costs only that time, never the file). The discovery walk lists each `.audiosilo` folder
ONCE (never walks into it; no audio lives there), and a book's signature (`books.attachments_sig`:
name:size:mtime_ns of its own known names) is compared like `abs_sig`: an unchanged book whose
attachments are new, changed, gone or not read for this index is read without a re-index
@@ -930,7 +937,8 @@ future metadata site can attach enrichment without reshaping the schema. The
never taking a book's generated content away. `Scanner.RefreshAttachments` re-reads one indexed
book's attachments, never its audio (the Bridge's `POST /bridge/rescan` mode `attachments`). The
player's `Book.generated` (list, search, item, recent, the next book) is true when the signature names
- a characters, recaps or description file (`generatedSig`: any known name but bridge.json; the narrow column, no join), so the player asks `/meta`
+ a characters, recaps or description file (`sigParts`, an allow-list: bridge.json and any name a later
+ server adds are none; `Book.GeneratedParts`, off the wire, says which; the narrow column, no join), so the player asks `/meta`
for a book with no ASIN/ISBN too. Derived from the files: rebuildable, never carried by a move.
**Series ranges** (an omnibus of books 1-3): the `series_index` field may hold a range,
canonical `"1-3"` (input `1 - 3` and the en dash too; `metadata.ParseSeriesRange`: 0 < start <
@@ -1745,13 +1753,21 @@ future metadata site can attach enrichment without reshaping the schema. The
each generate or describe job's status; the book page's aside and each series card generate characters and
recaps, a series card queueing those of its books without recaps in reading order). The admin book
detail carries `generated_description: {text, generated_at?}` (omitted without one; `generated_at` is
- bridge.json's description time; read with the description and provenance columns only), which the book page's Community card shows (`describe-section.tsx`:
- a Description sub-part with the "Generated on this server" mark, the text clamped behind Show more;
- "Write a description" confirms and queues `sidecars.describe` when `describeOffered`: `can_describe`,
- local characters or recaps, no description here or from the community, no open generate or describe
- job; the describe job's status through `GenerateStatus`, whose done line for a generate job also says
- whether its description came (`result.description`, `description_error`)). Notifications word a
- describe job as the description ("Description ready: "). Jobs are carried by
+ bridge.json's description time; read with the description and provenance columns only, and only when
+ the signature names a description), which the book page's Community card shows (`describe-section.tsx`:
+ a Description sub-part with the "Generated on this server" mark, the text clamped behind Show more, and
+ a note when the player's About shows another description instead: the book's own, or the
+ community's; "Write a description" confirms and queues `sidecars.describe` when `describeOffered`:
+ `can_describe` and `writable`, local characters or recaps, no description here or from the community
+ (and `community_unknown` unset), no open generate (the estimate's `open_job` included) or describe
+ job; and the card itself adds no generated description shown, no description of the book's own, and
+ no estimate being fetched again; its dialog says the player shows it only when the book has no other
+ description, and says when the Bridge went away; the describe job's status through `GenerateStatus`
+ (a finished one hidden once a description shows), whose done line for a generate job also says
+ whether its description came (`result.description`, `description_error`), and whose stage for a
+ generate job's description step is `describing`). Notifications word a describe job as the
+ description ("Description ready: "). A generate job queued while a describe job is open
+ neither refuses nor cancels it (both stay queued). Jobs are carried by
`bridge.MoveJobs`, which `catalog.moveBookState` calls in its transaction with the catalog's clock (an
open job colliding with an open one of its kind at the new path is cancelled; `DedupeKey` rebuilds the
key), and go with their library. **Hidden folders**: no route reaches content through a hidden
diff --git a/admin-ui/src/api/types.ts b/admin-ui/src/api/types.ts
index d53be98d..5fe2384a 100644
--- a/admin-ui/src/api/types.ts
+++ b/admin-ui/src/api/types.ts
@@ -2325,9 +2325,13 @@ export interface SidecarsPark {
retry_at?: string;
}
-/** sidecars.generate's progress. */
+/** sidecars.generate's progress (and sidecars.describe's, step and stage "describing"). */
export interface SidecarsProgress {
- /** An engine stage name, or "waiting" | "writing" | "rescanning". */
+ /**
+ * An engine stage name, or "waiting" | "writing" | "rescanning" | "describing"
+ * (the description step, Phase 1c: a generate job's, whose stage is then the
+ * engine's "done", and a describe job's).
+ */
step: string;
message?: string;
done?: number;
@@ -2356,9 +2360,13 @@ export interface SidecarsResult {
work_id: string;
/** The community issues filed, by what they offer (contribute "issue" only). */
contributed?: Record;
+ /** Whether the run also wrote the book's description.json (Phase 1c); absent from an older Bridge. */
+ description?: boolean;
+ /** Why no description was written, in a few plain words; only when `description` is false. */
+ description_error?: string;
}
-/** Which characters and recaps a book has, from the community and from its own folder. */
+/** Which characters, recaps and description a book has, from the community and from its own folder. */
export interface SidecarsCoverage {
characters: boolean;
recaps: boolean;
diff --git a/admin-ui/src/features/book/book-aside.tsx b/admin-ui/src/features/book/book-aside.tsx
index 65f91d49..1d87823c 100644
--- a/admin-ui/src/features/book/book-aside.tsx
+++ b/admin-ui/src/features/book/book-aside.tsx
@@ -194,7 +194,11 @@ function CommunityCard({
>
)}
-
+
);
}
diff --git a/admin-ui/src/features/bridge/describe-section.tsx b/admin-ui/src/features/bridge/describe-section.tsx
index cf936b26..55ed97c6 100644
--- a/admin-ui/src/features/bridge/describe-section.tsx
+++ b/admin-ui/src/features/bridge/describe-section.tsx
@@ -1,14 +1,15 @@
import { useId, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
-import { PenLine } from 'lucide-react';
+import { PenLine, Unplug } from 'lucide-react';
import { useServerInfo } from '@/api/hooks';
import type { AdminBook, GeneratedDescription } from '@/api/types';
+import { Notice } from '@/components/notice';
import { Button } from '@/components/ui/button';
import { Dialog, DialogBody, DialogContent, DialogFormFooter } from '@/components/ui/dialog';
import { formatRelative } from '@/lib/format';
import { queueJob } from './bridge-actions';
-import { jobActions } from './bridge-model';
+import { isOpen, jobActions } from './bridge-model';
import {
clampsDescription,
describeOffered,
@@ -22,18 +23,23 @@ import { useBookGenerateJob, useBridgeLink, useGenerateEstimate } from './use-ge
/**
* The Community card's description part (Bridge Phase 1c): the description the
- * Bridge generated for the book (its text, clamped, with Show more), the
- * describe job while one is open or how it failed, and "Write a description",
- * which queues `sidecars.describe` after a confirmation. Offered only when the
- * Bridge can write one (describeOffered). Renders nothing when there is nothing
- * to show.
+ * Bridge generated for the book (its text, clamped, with Show more, and a note
+ * when the player's About shows another description instead), the describe job
+ * while one is open or how it failed, and "Write a description", which queues
+ * `sidecars.describe` after a confirmation. Offered only when the Bridge can
+ * write one (describeOffered) and nothing would be shown in its place: no
+ * description generated here (as this card shows it) and none of the book's
+ * own. Renders nothing when there is nothing to show.
*/
export function DescribeSection({
book,
description,
+ ownDescription = false,
}: {
book: AdminBook;
description?: GeneratedDescription;
+ /** The book has a description of its own, which the player's About shows before a generated one. */
+ ownDescription?: boolean;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.resolvedLanguage ?? 'en';
@@ -53,13 +59,25 @@ export function DescribeSection({
const [expanded, setExpanded] = useState(false);
const textId = useId();
- const offer = available && describeOffered(estimate.data, jobs);
- // A cancelled job says nothing; a finished one is the description itself once it shows.
- const showJob = job && job.state !== 'cancelled' && !(job.state === 'done' && description);
+ // Never beside a description the card shows, nor from an estimate being
+ // fetched again (a job that just ended changed what the book has).
+ const offer =
+ available &&
+ !description &&
+ !ownDescription &&
+ !estimate.isFetching &&
+ describeOffered(estimate.data, jobs);
+ // A cancelled job says nothing, and once a description shows neither does a
+ // finished one: a done job is the description itself, a failed one was made
+ // good since (a later generate job wrote one).
+ const showJob = job && job.state !== 'cancelled' && !(description && !isOpen(job.state));
if (!open && !showJob && !offer && !description) return null;
const canCancel = job ? jobActions(job).cancel : false;
const clamps = description ? clampsDescription(description.text) : false;
+ // The player's About shows another description first: the book's own, or the
+ // community's (which /meta serves instead).
+ const shownElsewhere = ownDescription || Boolean(estimate.data?.has.community.description);
return (
) : null}
+ {shownElsewhere ? (
+ {t('bridge.describe.notShown')}
+ ) : null}
>
) : null}
{showJob && job ? : null}
@@ -116,7 +137,7 @@ export function DescribeSection({
) : null}
) : null}
-
+
);
}
@@ -124,16 +145,20 @@ export function DescribeSection({
/**
* The confirmation before `sidecars.describe`: what it does, what it reads (the
* notes the Bridge kept from generating this book, no new speech recognition),
- * that it costs a little agent time, and where the result appears.
+ * that it costs a little agent time, and where the result appears. The Bridge
+ * going away while it is open is said, as the generate dialog says it: the job
+ * still queues, and waits.
*/
function DescribeDialog({
open,
onOpenChange,
book,
+ offline,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
book: AdminBook;
+ offline: boolean;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
@@ -146,7 +171,7 @@ function DescribeDialog({
const ok = await queueJob(
qc,
{ kind: KIND_DESCRIBE, library_id: book.library_id, path: book.path, params: {} },
- { name: book.title || book.path, online: true },
+ { name: book.title || book.path, online: !offline },
);
setBusy(false);
if (ok) onOpenChange(false);
@@ -162,6 +187,11 @@ function DescribeDialog({
>