diff --git a/docs-developers/architecture/cross-repo-contract.md b/docs-developers/architecture/cross-repo-contract.md index c045fbe0..98ac80ea 100644 --- a/docs-developers/architecture/cross-repo-contract.md +++ b/docs-developers/architecture/cross-repo-contract.md @@ -418,6 +418,10 @@ Since podcasts are shown only to an identified 2.2.0 or later player, the cross-origin dev loop shows none unless a development build opts in with `EXPO_PUBLIC_IDENTIFY_CROSS_ORIGIN=1` (never in a production build). +For an allowed origin the server also allows `Range` and exposes +`Retry-After` and the OPML export's `X-OPML-*` counts (Phase 2d-2), with what +an older server means for the player: [CORS](../server/api/index.md#cors). + **A change requires:** nothing structural - just remember self-signed TLS (the server default) needs trusting in the browser, or use `AUDIOSILO_TLS_MODE=off` for plain-HTTP local dev. @@ -823,7 +827,8 @@ security-critical: allowed and denied tests on the server. Phase 2c-2 extends the server/Bridge seam with episodes on disk, retention and on-demand fetching/status/Retry. Phase 2d-1 ships the player's views to -players from 2.2.0; discovery and OPML in the player are Phase 2d-2. +players from 2.2.0; Phase 2d-2 adds discovery, follow by URL, OPML and Check +for new episodes to the player. See [Podcasts](../bridge/podcasts.md) and the [API reference](../server/api/reference.md#podcasts) for exact fields. @@ -976,9 +981,13 @@ See [Podcasts](../bridge/podcasts.md) and the counts omissions in `X-OPML-Exported` / `X-OPML-Omitted-Private` / `X-OPML-Omitted-Unavailable`. An open subscribe is reused only at the same privacy (`409 privacy_conflict`). Optional flags `podcast_discovery` and - `podcast_opml`; exact shapes and the player rules are in the - [2d-2 handoff](../bridge/podcasts.md#player-handoff-discovery-and-opml-phase-2d-2-not-implemented) - and the [reference](../server/api/reference.md#podcast-discovery). + `podcast_opml`; exact shapes and the player's rules (search on submit, + artwork through the proxy only, the import waves, export with the + `Authorization` header) are in + [Player: discovery and OPML](../bridge/podcasts.md#player-discovery-and-opml-phase-2d-2) + and the [reference](../server/api/reference.md#podcast-discovery). CORS + exposes `Retry-After` and the `X-OPML-*` counts and allows `Range` for an + allowed origin ([CORS](../server/api/index.md#cors)). - **CHAP:** the [sidecar](../bridge/attachments.md#podcast-chap-attachments) is a version-1 stem-prefixed `chapters.json` using `from:id3_chap`, Fetch timestamp and one matching basename/size/duration. No ASR/agent/generated flag; server consumes valid diff --git a/docs-developers/bridge/index.md b/docs-developers/bridge/index.md index dddc418f..1651bb0d 100644 --- a/docs-developers/bridge/index.md +++ b/docs-developers/bridge/index.md @@ -39,8 +39,10 @@ reserved download slot for play requests. **Phase 2c-3** adds server-side podcast [directory search and OPML import/export](podcasts.md#discovery-and-opml-transfer) with no Bridge change. **Phase 2d-1** adds the player's [podcast views](podcasts.md#player-phase-2d-1) (players from 2.2.0), again -with no Bridge change. Discovery and OPML in the player (Phase 2d-2), read-along, -Audible backups and file moves arrive later. +with no Bridge change, and **Phase 2d-2** its +[discovery, follow by URL and OPML screens](podcasts.md#player-discovery-and-opml-phase-2d-2) +(a CORS change on the server only). Read-along, Audible backups and file moves +arrive later. ::: ## What it is for diff --git a/docs-developers/bridge/podcasts.md b/docs-developers/bridge/podcasts.md index 9f2bfb3f..3cc08adc 100644 --- a/docs-developers/bridge/podcasts.md +++ b/docs-developers/bridge/podcasts.md @@ -1,6 +1,6 @@ --- title: "Podcasts" -description: "Podcast jobs, on-demand fetch/status/Retry, discovery and OPML, the player (2d-1) and its discovery handoff (2d-2), queue prefetch, pinned generations, guarded downloads, retention, publication recovery, CHAP attachments and private feed secrets." +description: "Podcast jobs, on-demand fetch/status/Retry, discovery and OPML, the player (2d-1) and its discovery and OPML screens (2d-2), queue prefetch, pinned generations, guarded downloads, retention, publication recovery, CHAP attachments and private feed secrets." --- ## Scope and ownership @@ -29,8 +29,9 @@ intents, status/Retry routes and a bounded stream wait. Phase 2c-3 adds server-side [directory search and OPML import/export](#discovery-and-opml-transfer) with no Bridge change. Phase 2d-1 adds the [player](#player-phase-2d-1) (shows, show and episode pages, following, Home's New episodes, on-demand -play) with no Bridge change; discovery, follow by URL and OPML in the player -are [Phase 2d-2](#player-handoff-discovery-and-opml-phase-2d-2-not-implemented). +play) with no Bridge change; Phase 2d-2 adds [discovery, follow by URL, OPML +and Check for new episodes](#player-discovery-and-opml-phase-2d-2) to the +player, with a CORS change on the server only. External chapters and transcripts arrive later. Players from **2.2.0** (`auth.PodcastPlayerMinVersion`), the admin console and `AudioSilo/dev` see podcast libraries; 2.1.x and older players, the @@ -395,7 +396,7 @@ the optional abort `signal`: |---|---| | `podcastShows(libraryId)` | `GET /libraries/{id}/podcasts`, unwrapped to `PodcastShow[]` | | `podcastEpisodes(libraryId, {show, order, limit?, cursor?})` | `GET /libraries/{id}/podcasts/episodes`, a `PodcastEpisodePage` | -| `myPodcasts()` | `GET /me/podcasts` (`requests` is unused until 2d-2) | +| `myPodcasts()` | `GET /me/podcasts` (its `requests` are the follow requests [2d-2](#player-discovery-and-opml-phase-2d-2) shows) | | `followPodcast(libraryId, path)` | `POST /podcasts/follow` with exactly `{library_id, path}`; never `private` beside a path | | `unfollowPodcast(libraryId, path)` | `DELETE /podcasts/follow?library_id=&path=`, `204` | | `podcastFetchStatus(libraryId, path)` | `GET /libraries/{id}/podcasts/fetch?path=`, observation only | @@ -409,8 +410,9 @@ and `retryAfterSeconds`: the `Retry-After` header when it reads as a non-negative number, else `fetch.retry_after_seconds`, else 2). A non-JSON 503 from a proxy stays a plain `ApiError`. `API_ERROR_CODES` gains `module_off`, `bridge_unavailable`, `fetch_limit`, `identity_changed` and `show_not_found`. -Cross-origin web dev can't read `Retry-After` ([CORS](../server/api/index.md#cors)), -so the body's value is what it uses there. +A cross-origin page reads `Retry-After` only from a server that exposes it +([CORS](../server/api/index.md#cors), Phase 2d-2); from an older one the +body's value is what it uses. **Hooks** (`src/api/hooks.ts`). Reads are core and never gated on `podcasts`: `usePodcastShows`, `usePodcastEpisodes` (an infinite query, 50 a page, @@ -472,8 +474,9 @@ An episode never opens the end credits. Kids mode lists no podcast library Phase 2c-3 adds two server-only flows, with **no Bridge change**: directory search, whose results are followed through the existing follow by URL, and -OPML import and export of subscription lists. Both are API only; the routes, -shapes and error codes are in the +OPML import and export of subscription lists. The player's screens for them +are [Phase 2d-2](#player-discovery-and-opml-phase-2d-2) (the console has +none); the routes, shapes and error codes are in the [API reference](../server/api/reference.md#podcast-discovery). - **Search runs on the server.** `internal/podcast/discovery` asks Apple's @@ -504,114 +507,173 @@ shapes and error codes are in the follow, an `invalid` row in an import), before anything is queued. The canonical hash bytes and the Bridge's URL vectors are unchanged. -### Player handoff: discovery and OPML (Phase 2d-2, not implemented) +### Player: discovery and OPML (Phase 2d-2) -The server side is implemented; **no player or console screen calls it yet**. -Players from 2.2.0 pass the podcast client gate (Phase 2d-1) but have no -search, follow-by-URL or OPML screen. These are proposed additions to the player's -`src/api/{types,client,hooks}.ts`, mirroring the reviewed wire. Keep every -new capability optional; every key inside a returned object is required, -nulls included: +Players from 2.2.0 search a directory, follow a feed by its address, import +and export OPML lists and ask a followed show to check for new episodes. The +screens are in [Frontend: Podcasts](../frontend/podcasts.md#finding-shows); +this section is the wire mirror and the rules the player keeps. The only +server change is CORS: an allowed origin may read `Retry-After` and the +`X-OPML-*` counts and send `Range` ([CORS](../server/api/index.md#cors)). No +Bridge, console or migration change. + +**Types** (`src/api/types.ts`). `Capabilities` gains the optional core flags +`podcast_discovery` and `podcast_opml` (not `ModuleFlag` entries). Every key +inside a returned object is always sent, nulls included: ```ts type PodcastDiscoveryProvider = 'itunes' | 'podcast_index'; -interface PodcastDiscoveryProviders { - providers: { id: PodcastDiscoveryProvider; available: boolean }[]; -} -interface PodcastDiscoveryResult { - title: string; - author: string; - feed_url: string; - artwork_url: string | null; // relative: /api/v1/podcasts/discovery/artwork?id=<64 hex> - provider_ids: { itunes: string | null; podcast_index: string | null }; - episode_count: number | null; - provider_url: string | null; -} -interface PodcastDiscoverySearch { - provider: PodcastDiscoveryProvider; - query: string; - country: string | null; // null for podcast_index +type PodcastDiscoveryProviders = { + providers: { id: PodcastDiscoveryProvider | (string & {}); available: boolean }[]; +}; +type PodcastDiscoveryResult = { + title: string; author: string; + feed_url: string; // normalized public address: what a follow sends + artwork_url: string | null; // relative: /api/v1/podcasts/discovery/artwork?id=<64 hex> + provider_ids: { itunes: string | null; podcast_index: string | null }; // strings, they exceed 2^53 + episode_count: number | null; provider_url: string | null; +}; +type PodcastDiscoverySearch = { + provider: PodcastDiscoveryProvider; query: string; + country: string | null; // null for podcast_index results: PodcastDiscoveryResult[]; -} -type PodcastTransferCapabilities = { - podcast_discovery?: boolean; - podcast_opml?: boolean; }; -// Merge these optional flags into Capabilities. They are core flags, -// not ModuleFlag entries. +type PodcastCheckResult = { request: { id: number; state: string } }; // POST /podcasts/check 202 +type OpmlImportResult = { library_id: number; rows: OpmlImportRow[] }; +type OpmlExport = { // client-side: the body and the X-OPML-* counts + text: string; exported: number | null; + omittedPrivate: number | null; omittedUnavailable: number | null; +}; ``` -Mirror `OpmlRowStatus`, `OpmlImportRow` and `OpmlImportResult` from the -[import reference](../server/api/reference.md#post-apiv1podcastsopmlimport), which is their one -written copy. - -What 2d-2 has to get right: - -- **Capability flags.** Treat an absent `podcast_discovery` or `podcast_opml` - as unsupported ([what they mean](../server/api/index.md#capability-flags---gate-your-features)). - Neither says the caller may use them: handle `403 kid_account`, - `403 demo_account`, `403 no_library_access` and `404 module_off` / - `discovery_off` from the routes. -- **Provider selection.** Read `GET /podcasts/discovery/providers` and let the - person pick; send `provider=podcast_index` only when it is `available` - (otherwise `409 provider_unavailable`). There is no fallback and no merged - list, so never retry a failed search on the other provider silently. Send - `country` only for iTunes (`400 invalid_country` for Podcast Index); do not - derive it from `metadata.region`. Keep provider ids as strings (they exceed - 2^53). Before building the UI, check each directory's current display, - attribution and quota terms; credit the provider and link `provider_url`. -- **Artwork.** `artwork_url` is relative to the server. Load it through the - normal media-token helper (the route takes a session or API key as - `?token=`, nothing else), use a placeholder for `null`, a `404 - artwork_not_found` (ids expire) or a `502`. The server fetches uncached - images only a few at a time, so a full results grid can get `429`/`503` - for some: load artwork lazily and retry those later rather than keeping the - placeholder. The existing CSP needs no change: images come only from the - server. -- **Rate limits.** The per-person search budget is small - ([budgets](../server/api/reference.md#podcast-discovery)): search on submit, not while typing, and - reuse the held results when the query, provider, country and limit are - unchanged. Honour `Retry-After` on `429` and `503`; cross-origin web dev - cannot read it ([CORS](../server/api/index.md#cors)), so fall back to a - fixed wait. -- **Following a result.** `POST /podcasts/follow` with `{library_id, - feed_url}`: `200 following` for a show the library has, `202 subscribing` - with a Request otherwise. The library must be one the person holds whole - and the Bridge must be online for a new feed. -- **Import waves.** Send the file's bytes as the raw body with - `Content-Type: text/x-opml` (or `application/xml`), no compression, to - `POST /podcasts/opml/import?library_id=`. Keep the uploaded bytes locally - for the screen's life: the server keeps nothing. A `200` holds one row per - subscription; show them by `outline_index`. A long list answers `limited` - past the [wave](../server/api/reference.md#post-apiv1podcastsopmlimport). The player may re-submit - the same bytes automatically: poll - [`GET /me/podcasts`](../server/api/reference.md#podcast-read-routes) every few seconds only while a - row's request is still open, then upload again once those end and the import - budget allows (about one upload every 30 seconds; `429 opml_rate_limited` - and `503 opml_busy` carry `Retry-After`). Stop when no row is `limited`, or - when a wave admits nothing new (offer a manual retry). Treat `unavailable` - rows (`bridge_unavailable`, `library_not_writable`, `module_off`) as needing - the person or admin, not an automatic loop. A `503 request timed out` or a - dropped connection may leave earlier rows committed: re-uploading is safe. -- **Per-row retry.** A `limited` or `unavailable` row is retried by a later - upload, a row in `error` with `privacy_conflict` after its open request - ends; `invalid` and `duplicate` rows never succeed by retrying. A queued - private row whose request later fails `request_expired` (server restart - before the claim) also recovers by uploading again. -- **Export.** `GET /podcasts/opml/export` with the `Authorization` header - (no `?token=` link exists), then save or share the body as - `audiosilo-subscriptions.opml`. Read the counts from `X-OPML-Exported`, - `X-OPML-Omitted-Private` and `X-OPML-Omitted-Unavailable` and tell the person - when shows were left out: **private feeds are never exported**, for anyone, - and there is no option to include them. Cross-origin web dev cannot read - these headers ([CORS](../server/api/index.md#cors)); the served `/web` - player and native apps can. A list too large to export is - `413 opml_export_limit`, never a truncated file. - -Copy the shared goldens into the player's tests when 2d-2 starts: the server -keeps them byte-identical in `internal/podcast/testdata/phase2c3/` -(`providers/*-normalized.json`, `opml/duplicates-private-expected.json`, -`opml/export-expected.opml`, `opml/export-expected-headers.json`, `wire.json`). +`OpmlRowStatus` and `OpmlImportRow` mirror the +[import reference](../server/api/reference.md#post-apiv1podcastsopmlimport), +their one written copy. + +**Client** (`src/api/client.ts`): + +| Method | Request | +|---|---| +| `podcastDiscoveryProviders()` | `GET /podcasts/discovery/providers` | +| `searchPodcastDirectory({q, provider, country?, limit?})` | `GET /podcasts/discovery/search`; `country` is sent only with `provider=itunes` | +| `discoveryArtworkUrl(rel)` | no request: the artwork route rebuilt from the result's 64-hex `id` with the media token (`?id=&token=`); `null`, an absolute URL, another route or a bad id gives `null` (the placeholder) | +| `followPodcastByUrl(libraryId, feedUrl, {private})` | `POST /podcasts/follow` with exactly `{library_id, feed_url}`, plus `private: true` only when set | +| `checkPodcast(libraryId, path)` | `POST /podcasts/check` with exactly `{library_id, path}` | +| `importOpml(libraryId, body)` | `POST /podcasts/opml/import?library_id=` with the raw bytes (a `Blob` on the web, a `Uint8Array` on a device; `send()`'s `raw` body), always `Content-Type: text/x-opml`, no compression, a timeout of at least 45 s (the client's own when longer; the server's import budget is 30 s) | +| `exportOpml()` | `GET /podcasts/opml/export` with the `Authorization` header (no `?token=` link exists); the counts read through `Headers.get`, so Go's `X-Opml-*` spelling matches, and a missing or non-numeric one is `null` | + +`ApiError` gains an optional `retryAfterSeconds`: `Retry-After` when it +reads as a non-negative number, on any status (a date, or a header the +browser can't read, is absent). `API_ERROR_CODES` adds `privacy_conflict`, +`too_many_requests`, `checked_recently`, `invalid_feed_url`, +`no_library_access`, `not_podcast_library`, the discovery codes +(`discovery_off`, `provider_unavailable`, `invalid_query`, `invalid_country`, +`discovery_rate_limited`, `provider_rate_limited`, `discovery_busy`, +`discovery_timeout`, `provider_rejected`, `discovery_unavailable`) and the +OPML codes (`opml_rate_limited`, `opml_busy`, `opml_limit`, `invalid_opml`, +`unsupported_opml_type`, `opml_export_limit`). + +**Hooks** (`src/api/hooks.ts`): + +- `usePodcastDiscoveryProviders` and `usePodcastSearch(params | null)` are + `useCapabilityQuery('podcast_discovery', ...)` with `staleTime` 15 minutes + (the server's own cache) and `retry: false`; `null` params ask nothing. Keys + `['discoveryProviders', cid]` and + `['discoverySearch', cid, provider, q, country, limit]`, so an unchanged + repeat reuses the held results. +- `useFollowPodcastByUrl` and `useCheckPodcast` are + `useCapabilityMutation('podcasts', ...)`; `useExportOpml` is + `useCapabilityMutation('podcast_opml', ...)`. A `200 following` goes into + the cached show list (`storeFollowing`; the list is read again when it lacks + the show) and `['myPodcasts', cid]` is read again; a `202 subscribing` + request goes to the head of the cached follow requests (`storeAnswer`, so a + poll already in flight can't land over it). +- `useFollowRequests(libraryId, {active})` selects one library's + `GET /me/podcasts` `requests`, open first then ended, newest first + (`libraryRequests` in `src/lib/podcast-requests.ts`). It reads again every + 5 s (`FOLLOW_REQUEST_POLL_MS`) only while one of them is open (`queued`, + `claimed`, `running` or `needs_attention`: `isOpenRequest`, the Bridge + job's open states), its screen is focused (`active`) and the app is in the + foreground, so nothing polls once they have all ended. `myPodcastsQuery` + compares each answer with the last: a request it saw open that now reads + `done` reads its library's show list again, whichever screen asked. + +**The rules the player keeps:** + +- **Flags.** An absent `podcast_discovery` or `podcast_opml` reads as + unsupported. Adding shows (the page and its entry points) needs + `podcasts === true` and is never offered to a kid's account or the demo; + inside, Search needs `podcast_discovery`, Import needs `podcast_opml`, the + feed address is always there. Export needs `podcast_opml` and not the demo, + and works with the module off. Check for new episodes is for a followed + show under the same rule as adding. The flags don't describe the caller, so + the refusals (`kid_account`, `demo_account`, `no_library_access`, + `module_off`, `discovery_off`, `not_podcast_library`) still have their own + wording. +- **Search on submit only**, never while typing: the text trimmed with its + whitespace runs collapsed (as the server cleans it) and at least two code + points, `limit` 20 sent explicitly. The same search again reuses the held + results; it is asked again only after it failed. Apple Podcasts unless + `podcast_index` is `available`, when a picker chooses; a failed search is + never retried on the other provider. `country` is the device locale's + `regionCode` (expo-localization) when it is two letters A-Z, for iTunes + only, never `metadata.region`; otherwise it is left out (the server's + default, US). The list is captioned with the provider ("Results from Apple + Podcasts") and each result links its `provider_url`. A `429` shows its + `Retry-After` wait when it has one. +- **Artwork** loads only through the server's proxy (`discoveryArtworkUrl`), + lazily as the list windows rows in. An image can't see the status and the + route refuses any extra parameter (no cache buster), so a failed load (the + server fetches uncached images a few at a time: `429`/`503`) mounts the + image again with the same URL after 3, 10 and 30 seconds + (`artworkRetryDelay`, four tries in all), then keeps the placeholder (an + expired id is a `404` every time). +- **Follow by address** checks the address first (http or https with a host, + at most 2,048 bytes), so a bad one sends nothing. A result's Follow is the + same call with its `feed_url`. +- **Import waves** (`OpmlWaves`, `src/components/podcasts/opml-waves.ts`, + framework-free with its own timers; `useOpmlImport` keeps it, and the + picked bytes, for the Find shows page's life). A file over 1 MiB is refused + before any upload. Each upload's rows merge by `outline_index` (the latest + answer wins). No `limited` row left: `done`. Otherwise the next upload of + the same bytes waits until every request this import queued (`queued` and + `already_queued` rows' `request_id`) is no longer open in + `GET /me/podcasts` (absent counts as ended; read every 5 s), fewer than five + of the caller's requests are open, and 30 seconds have passed since the last + upload. `unavailable` rows never start a wave: a `done` import with some + left offers a manual Try again (`canRetryImport`), which uploads once more. + The panel reads each queued row's request live, so the import reads done + ("All done.") only once none is still being added. A wave that moves no row out + of `limited` stops as `stalled` (Try again). `429 opml_rate_limited` and + `503 opml_busy` wait their `Retry-After` (else 30 s and 2 s); five such + answers in a row stop it. Any other refusal stops as `failed` with its code, + and a network failure or a timeout as `failed` with none; Try again + re-uploads, which is safe (rows already done answer `already_following` or + `already_queued`). The controller holds its timers in the background and + looks again at once on the way back, checks `mayActFor` before each upload, + and sends nothing more after Stop or once the page is gone. +- **Export** reads the counts from the `X-OPML-*` headers; a count that + reads `null` gives "Exported your shows", and `413 opml_export_limit` has + its own message. How it is saved: + [Entry points](../frontend/podcasts.md#entry-points). +- **Check for new episodes** refreshes once after the `202`, with no polling, + since a check is not a follow request; `429 checked_recently` and + `bridge_unavailable` have their own wording. The button and its timer: + [Check for new episodes](../frontend/podcasts.md#check-for-new-episodes). +- **Copy** never shows the server's English and never says "Bridge": + refusals read through `find-shows-model.ts`, and a failed request's + `error.code` through `requestReasonKey` (`podcast-model.ts`: `dns`, + `timeout`, `tls`, `network` could not reach the feed; `not_a_feed`, + `not_a_podcast`; `gone` and `http_*`; `request_expired` and + `feed_secret_missing` ask to follow again), all six locales. + +**Goldens.** The player keeps the server's +`internal/podcast/testdata/phase2c3/` files it reads byte-identical in +`src/testing/podcast-fixtures/phase2c3/` (`wire.json`, +`providers/*-normalized.json`, `opml/duplicates-private-expected.json`, +`opml/export-expected.opml`, `opml/export-expected-headers.json`, +`opml/queue-cap.opml`), and `podcast-fixtures-2c3.test.ts` checks each one's +SHA-256 against the copied `manifest.json`. ## Retention policy and deletion authority diff --git a/docs-developers/contributing/documentation.md b/docs-developers/contributing/documentation.md index 4c18de90..1bd931e3 100644 --- a/docs-developers/contributing/documentation.md +++ b/docs-developers/contributing/documentation.md @@ -92,8 +92,8 @@ mappings: | Community chapters (`internal/chapteralign`, `internal/chaptercheck`, `catalog/communitychapters.go`, the Chapters card's panel) | [server/community-chapters.md](../server/community-chapters.md) + the Community chapters section of `/users/admin/books` (and `/users/admin/health` for the Health rows) | | Auth, invites, shares | [server/auth-and-security.md](../server/auth-and-security.md) + `/users/admin/users-and-invites`, `/users/admin/sharing` | | The Bridge: the server's side (`auth/bridge.go`, `internal/bridge`, `handlers_bridge.go`, `handlers_bridge_estimate.go`, the `bridge` capability, the console's `features/bridge/`) or `audiosilo-bridge` (its config, images, compose example, mapping checks, job runner, page, the agent container and its credential hand-off, `sidecars.generate`, `sidecars.describe`, `sidecars.contribute`, `meta.correct`, the setup checklist, the Accounts tab and its GitHub sign-in) | [the Bridge developer pages](../bridge/index.md) (index, pairing and jobs, mappings, characters and recaps, the .audiosilo folder, the agent container, contributions, configuration, development) + the [reference](../server/api/reference.md#the-bridge) + [api/index.md](../server/api/index.md#capability-flags---gate-your-features) (the capability, the codes, the bridge token) + [auth-and-security.md](../server/auth-and-security.md#bridge-tokens) + [data-model.md](../server/data-model.md#the-bridge) + [backups-and-notifications.md](../server/backups-and-notifications.md#events) (the `bridge_job_*` events) + [web-ui.md](../server/web-ui.md) (the console's Settings > Bridge and the book page's Bridge parts) + the [cross-repo contract](../architecture/cross-repo-contract.md#16-the-bridge) (after the workspace `CROSS-REPO.md` §23) + `/users/admin/bridge`, `/users/admin/generating-characters-and-recaps`, `/users/getting-started/running-a-bridge` (its steps follow the setup checklist's order), `/users/getting-started/bridge-accounts` and `/users/community/contributing-from-your-server` + [meta/contributing-data.md](../meta/contributing-data.md#issues-filed-through-the-audiosilo-app) (what the Bridge files, and how meta routes it) + the `admin/settings-bridge*.png`, `admin/bridge-setup.png`, `admin/bridge-pair.png`, `admin/book-generate.png`, `admin/generate-dialog.png` and `admin/book-description.png` screenshots | -| Podcasts: library kind, `internal/podcast`, catalog/handlers, the Bridge's `podcastjob`, `netguard`, feed/Fetch files, downloads, on-demand/status/Retry and queue prefetch, listener fetch limits, claim priority/reserved capacity, retention aggregates/policies, generations/ETags, publication journals, CHAP or root markers; directory search and OPML (`internal/podcast/{discovery,opml,quota}`, `follow.go`, `opmlimport.go`, `export.go`, `podcasts.discovery.enabled`, the Podcast Index variables) | [bridge/podcasts.md](../bridge/podcasts.md) (with its [discovery and OPML](../bridge/podcasts.md#discovery-and-opml-transfer) section, its [player](../bridge/podcasts.md#player-phase-2d-1) section and the 2d-2 handoff) + [server/modules.md](../server/modules.md), [configuration.md](../server/configuration.md#managed-modules-modules) (and its [podcast discovery](../server/configuration.md#podcast-discovery-podcastsdiscovery) section), [API reference](../server/api/reference.md#podcasts) (including admin storage and Bridge retention), [API flags](../server/api/index.md#capability-flags---gate-your-features), [data-model.md](../server/data-model.md#podcasts), [scanner.md](../server/scanner.md#podcast-libraries), [auth-and-security.md](../server/auth-and-security.md#podcast-visibility), [bridge/configuration.md](../bridge/configuration.md#podcasts), [attachments.md](../bridge/attachments.md#podcast-chap-attachments), [mappings.md](../bridge/mappings.md#podcast-libraries-the-marker), [pairing-and-jobs.md](../bridge/pairing-and-jobs.md#podcast-jobs), [bridge/index.md](../bridge/index.md), [cross-repo-contract.md](../architecture/cross-repo-contract.md#17-podcast-shows-feeds-and-client-visibility) + `/users/admin/podcasts`, `/users/admin/libraries`, `/users/admin/bridge`, `/users/admin/server#modules`, `/users/getting-started/running-a-bridge`, `/users/faq` (what a directory search sends) + `admin/libraries.png`, `admin/library-add.png`, `admin/library-add-podcasts.png`, `admin/library-edit.png` and `admin/settings-modules.png` | -| The player's podcasts: `src/components/podcasts/`, `src/playback/episode-fetch.ts`, Home's New episodes (`new-episodes-*`, `use-new-episodes.ts`), the `shows` Library mode, `src/lib/library-kind.ts`, the podcast client gate (`auth.PodcastPlayerMinVersion`, `app.json` `expo.version`, `client-id.ts`), the gated whole-list replace (`catalog.GatedScopes`) | [frontend/podcasts.md](../frontend/podcasts.md) + [bridge/podcasts.md](../bridge/podcasts.md#player-phase-2d-1) (the wire and the controller's rules) + [auth-and-security.md](../server/auth-and-security.md#podcast-visibility) + the [reference](../server/api/reference.md#podcasts) (the gate, the replace rules) + [api/index.md](../server/api/index.md#client-identification-x-audiosilo-client) + the lines in [playback.md](../frontend/playback.md), [state-and-data.md](../frontend/state-and-data.md), [offline.md](../frontend/offline.md), [chromecast.md](../frontend/chromecast.md), [end-of-book.md](../frontend/end-of-book.md) and [kids-mode.md](../frontend/kids-mode.md) + `/users/listening/podcasts` (and the podcast lines in `browsing`, `up-next`, `end-of-book`, `offline-downloads`, `chromecast`, `kids-mode`, `mobile-apps`, `/users/troubleshooting`, `/users/admin/podcasts`) + the `web-player/library-shows.png`, `podcast-show.png`, `home-new-episodes.png`, `episode-fetching.png` and `phone-podcast-show.png` screenshots (the podcast seed in `run.sh`) | +| Podcasts: library kind, `internal/podcast`, catalog/handlers, the Bridge's `podcastjob`, `netguard`, feed/Fetch files, downloads, on-demand/status/Retry and queue prefetch, listener fetch limits, claim priority/reserved capacity, retention aggregates/policies, generations/ETags, publication journals, CHAP or root markers; directory search and OPML (`internal/podcast/{discovery,opml,quota}`, `follow.go`, `opmlimport.go`, `export.go`, `podcasts.discovery.enabled`, the Podcast Index variables) | [bridge/podcasts.md](../bridge/podcasts.md) (with its [discovery and OPML](../bridge/podcasts.md#discovery-and-opml-transfer) section, its [player](../bridge/podcasts.md#player-phase-2d-1) and [player discovery and OPML](../bridge/podcasts.md#player-discovery-and-opml-phase-2d-2) sections) + [server/modules.md](../server/modules.md), [configuration.md](../server/configuration.md#managed-modules-modules) (and its [podcast discovery](../server/configuration.md#podcast-discovery-podcastsdiscovery) section), [API reference](../server/api/reference.md#podcasts) (including admin storage and Bridge retention), [API flags](../server/api/index.md#capability-flags---gate-your-features), [data-model.md](../server/data-model.md#podcasts), [scanner.md](../server/scanner.md#podcast-libraries), [auth-and-security.md](../server/auth-and-security.md#podcast-visibility), [bridge/configuration.md](../bridge/configuration.md#podcasts), [attachments.md](../bridge/attachments.md#podcast-chap-attachments), [mappings.md](../bridge/mappings.md#podcast-libraries-the-marker), [pairing-and-jobs.md](../bridge/pairing-and-jobs.md#podcast-jobs), [bridge/index.md](../bridge/index.md), [cross-repo-contract.md](../architecture/cross-repo-contract.md#17-podcast-shows-feeds-and-client-visibility) + `/users/admin/podcasts`, `/users/admin/libraries`, `/users/admin/bridge`, `/users/admin/server#modules`, `/users/getting-started/running-a-bridge`, `/users/faq` (what a directory search sends) + `admin/libraries.png`, `admin/library-add.png`, `admin/library-add-podcasts.png`, `admin/library-edit.png` and `admin/settings-modules.png` | +| The player's podcasts: `src/components/podcasts/`, `src/playback/episode-fetch.ts`, Home's New episodes (`new-episodes-*`, `use-new-episodes.ts`), the `shows` Library mode, `src/lib/library-kind.ts`, the podcast client gate (`auth.PodcastPlayerMinVersion`, `app.json` `expo.version`, `client-id.ts`), the gated whole-list replace (`catalog.GatedScopes`); finding shows (`find-shows-*`, `directory-search.tsx`, `discovery-*`, `follow-by-url.tsx`, `follow-requests.tsx`, `opml-*`, `use-opml-import.ts`, `shows-actions.tsx`, `check-episodes-button.tsx`, `artwork-retry.ts`, `src/lib/podcast-requests.ts`, `findShowsHref`, the `podcast_discovery` / `podcast_opml` client methods and hooks), the server's CORS lists (`corsAllowHeaders`, `corsExposeHeaders`) | [frontend/podcasts.md](../frontend/podcasts.md) (and its [Finding shows](../frontend/podcasts.md#finding-shows)) + [bridge/podcasts.md](../bridge/podcasts.md#player-phase-2d-1) (the wire and the controller's rules; [discovery and OPML](../bridge/podcasts.md#player-discovery-and-opml-phase-2d-2) for 2d-2) + [api/index.md CORS](../server/api/index.md#cors) and [cross-repo-contract.md §12](../architecture/cross-repo-contract.md#12-cors-for-web-development) + [auth-and-security.md](../server/auth-and-security.md#podcast-visibility) + the [reference](../server/api/reference.md#podcasts) (the gate, the replace rules) + [api/index.md](../server/api/index.md#client-identification-x-audiosilo-client) + the lines in [playback.md](../frontend/playback.md), [state-and-data.md](../frontend/state-and-data.md), [offline.md](../frontend/offline.md), [chromecast.md](../frontend/chromecast.md), [end-of-book.md](../frontend/end-of-book.md) and [kids-mode.md](../frontend/kids-mode.md) + `/users/listening/podcasts` (and the podcast lines in `browsing`, `up-next`, `end-of-book`, `offline-downloads`, `chromecast`, `kids-mode`, `mobile-apps`, `/users/troubleshooting`, `/users/admin/podcasts`, `/users/admin/server#modules`, `/users/faq`) + the `web-player/library-shows.png`, `podcast-show.png`, `home-new-episodes.png`, `episode-fetching.png`, `phone-podcast-show.png`, `find-shows-search.png`, `find-shows-address.png`, `find-shows-import.png` and `phone-find-shows.png` screenshots (the podcast seed in `run.sh` and its `directory` fixture; Find shows' stand-in routes in `capture-web.mjs`) | | Managed modules: the server's `internal/modules` (its `Runner`, the `builtin` registry), `api/modules.go`, a `modules.` setting, the console's Settings > Modules (`features/settings/modules-topic.tsx`), the player's `src/modules/` registry and slots | [server/modules.md](../server/modules.md) (+ the adding-a-module steps) + [configuration.md](../server/configuration.md#managed-modules-modules) (the key, the variable, the runtime table) + the [reference](../server/api/reference.md#get-apiv1adminmodules) (`GET /admin/modules`, the settings envelope's `modules`, the module's flag on `GET /server`, the module's Bridge jobs) + [api/index.md](../server/api/index.md#capability-flags---gate-your-features) (the flag, `module_off`, `kid_account`, `demo_account`) + [bridge/pairing-and-jobs.md](../bridge/pairing-and-jobs.md#jobs-of-a-managed-module) (the jobs a module owns, held while it is off) + [web-ui.md](../server/web-ui.md) + [frontend/state-and-data.md](../frontend/state-and-data.md#player-modules-srcmodules) + the [cross-repo contract](../architecture/cross-repo-contract.md#8-capability-flags-get-apiv1server) + `/users/admin/server#modules` (the console's Learn more link opens it: keep the heading) + `/users/admin/bridge#when-the-contributions-module-is-off` + the `admin/settings-modules.png` screenshot | | Chapterless books: the Bridge's `internal/chapterdetect`, `chapter_detecting`, the windows manifest, `chapters.detect`; the server's `metadata/chapterlist.go`, `catalog/generatedchapters.go`, the console's Detect chapters (`health/detect-chapters-dialog.tsx`) and the Chapters card's source control; the player's generated caption | [bridge/chapters.md](../bridge/chapters.md) + [bridge/attachments.md](../bridge/attachments.md#chaptersjson) + [bridge/pairing-and-jobs.md](../bridge/pairing-and-jobs.md#kinds-are-allow-listed-twice) (the kinds table) + [server/community-chapters.md](../server/community-chapters.md#generated-chapters) + the [reference](../server/api/reference.md#post-apiv1adminbridgejobs) (`chapters_source`, `chapter_choice`, `generated_chapters_fit`, the `409` codes) + [api/index.md](../server/api/index.md#capability-flags---gate-your-features) (the `chapters` feature, the codes) + [data-model.md](../server/data-model.md) + [backups-and-notifications.md](../server/backups-and-notifications.md#events) (`chapters_source` on the event) + [web-ui.md](../server/web-ui.md) (Health, the Chapters card) + [frontend/book-page.md](../frontend/book-page.md#the-chapters-tab) (the caption) + `/users/admin/generating-characters-and-recaps#books-without-chapters`, `/users/admin/health`, `/users/admin/books#chapters-found-in-the-narration`, `/users/listening/book-page#chapters` + the `admin/health-detect-chapters.png`, `admin/detect-chapters-dialog.png` and `admin/book-chapters-generated.png` screenshots | | Generated characters, recaps and descriptions: `.audiosilo/` (the server's `library/attachments.go`, `metadata/attachments.go`, `catalog/attachments.go`, `meta/generated.go`; the Bridge's `internal/generate`, `internal/pipeline/describe_stage.go`), the console's Description part (`describe-section.tsx`) or the player's generate action, mark and About (`src/components/bridge/`, `GeneratedMark`, `aboutContent`) | [bridge/attachments.md](../bridge/attachments.md) + [bridge/generating.md](../bridge/generating.md) + [server/scanner.md](../server/scanner.md#the-bridges-attachments) + the `/meta` [generated content](../server/api/reference.md#generated-content) + [frontend/book-page.md](../frontend/book-page.md#the-primary-action-and-the-action-row) (and its About) + `/users/admin/generating-characters-and-recaps`, `/users/listening/recaps-and-characters`, `/users/listening/book-page` (About) + the `admin/book-description.png` and `web-player/book-about-generated.png` screenshots | diff --git a/docs-developers/frontend/podcasts.md b/docs-developers/frontend/podcasts.md index d07cab61..f368a101 100644 --- a/docs-developers/frontend/podcasts.md +++ b/docs-developers/frontend/podcasts.md @@ -1,11 +1,12 @@ --- title: Podcasts -description: "The player's podcast views (Bridge Phase 2d-1): who sees them, the Shows library mode, the show and episode pages, Follow, Home's New episodes, the episode-fetch controller that plays an episode not on the server yet, and what episodes never do." +description: "The player's podcast views (Bridge Phases 2d-1 and 2d-2): who sees them, the Shows library mode, the show and episode pages, Follow, Home's New episodes, the episode-fetch controller that plays an episode not on the server yet, finding shows (directory search, a feed address, OPML import and export, Check for new episodes), and what episodes never do." --- ## Scope -Bridge Phase 2d-1 shows a server's podcast libraries in the player. The server +Bridge Phase 2d-1 shows a server's podcast libraries in the player, and Phase +2d-2 adds [finding shows](#finding-shows). The server lists a podcast library (`Library.kind === 'podcasts'`) only to a client that passes its [podcast gate](../server/auth-and-security.md#podcast-visibility): the player from **2.2.0** (`app.json` `expo.version`, sent as @@ -19,13 +20,12 @@ unless a development build opts in with `EXPO_PUBLIC_IDENTIFY_CROSS_ORIGIN=1` The wire mirror (types, client methods, `PodcastFetchPendingError`, hooks and query keys) is documented once, with the server protocol it follows, in -[Bridge podcasts: Player (Phase 2d-1)](../bridge/podcasts.md#player-phase-2d-1). -This page covers the screens and the controller. The code is in +[Bridge podcasts: Player (Phase 2d-1)](../bridge/podcasts.md#player-phase-2d-1) +and [Player: discovery and OPML (Phase 2d-2)](../bridge/podcasts.md#player-discovery-and-opml-phase-2d-2). +This page covers the screens and the controllers. The code is in `src/components/podcasts/`, `src/playback/episode-fetch.ts`, -`src/components/home/{new-episodes-model,use-new-episodes,new-episodes-rail}.ts*` -and `src/lib/library-kind.ts`. Discovery, follow by URL and OPML are -[Phase 2d-2](../bridge/podcasts.md#player-handoff-discovery-and-opml-phase-2d-2-not-implemented) -and not in the player. +`src/components/home/{new-episodes-model,use-new-episodes,new-episodes-rail}.ts*`, +`src/lib/library-kind.ts` and `src/lib/podcast-requests.ts`. ## Telling an episode from a book @@ -55,6 +55,9 @@ for a whole list), and the `useIsPodcastLibrary` hook. `apiErrorKind` (`module_off`, `kid_account`, `demo_account`, `show_not_found`, which also refetches the shows), never the server's text. - The fetch status is `podcast_fetch`'s (`usePodcastFetchStatus`). +- Finding shows, follow by address and Check for new episodes are the + module's too (`podcasts`), search is `podcast_discovery`'s and OPML + `podcast_opml`'s; see [Finding shows](#finding-shows). ## Surfaces @@ -70,7 +73,9 @@ hooks there, so those book-only routes are never asked; the count is the number of shows. `library-picker.tsx` puts a `microphone` icon beside a podcast library. -`PodcastShowsMode` (`shows-mode.tsx`) is an All / Following chip row with +`PodcastShowsMode` (`shows-mode.tsx`) starts with its +[actions](#entry-points) (Add shows, the More show actions menu and a line +while shows are being added), then an All / Following chip row with counts over a cover grid of `ShowTile`s (`show-tile.tsx`: `BookCover` on the show's path with `cover_version`, the title, then `New episode {{when}}` when `latest_published` is in the last 14 days, else the author, and a "Private @@ -86,8 +91,9 @@ in `BROWSE_PATHS`). `PodcastShowScreen` (`podcast-show-screen.tsx`) names the browser tab and renders: - the hero: cover, title, author, a collapsible description, the episode - count, the Private feed badge, `FollowButton`, and "Last check failed" when - `status.error` is set (no codes for listeners); + count, the Private feed badge, `FollowButton` and + [`CheckEpisodesButton`](#check-for-new-episodes), and "Last check failed" + when `status.error` is set (no codes for listeners); - the order control: `defaultEpisodeOrder(show)` is `oldest` for a `serial` show and `newest` otherwise; the segmented control writes `?order=`. The client never re-sorts: the server orders by published, then path; @@ -203,6 +209,130 @@ Cancel or another start since then wins. A start that fails stops as `failed`. that is the existing "refusal is a pause" path, and the episode loads paused. +## Finding shows + +Phase 2d-2 lets a listener add shows to a podcast library from the player: +search a podcast directory, follow a feed by its address, import an OPML +list from another app, export the shows they follow, and ask a followed show +to check for new episodes. The wire rules (search on submit, artwork through +the proxy, the import waves, export with the `Authorization` header) are in +[Bridge podcasts](../bridge/podcasts.md#player-discovery-and-opml-phase-2d-2); +this section covers the screens. + +### The page + +Route `src/app/(app)/(home,library,search,offline,me)/find-shows.tsx`, URL +`/find-shows?connection=&library=[§ion=search|address|import]` +(`findShowsHref`, `parseFindShowsParams` in `src/lib/paths.ts`, `openFindShows` +in `src/lib/open.ts`; `/find-shows` is in `BROWSE_PATHS`). `FindShowsScreen` +(`find-shows-screen.tsx`) runs in `ContentScope`, so it is scoped to its own +`?connection=` like the show page, and its title (page and browser tab) is +**Add shows**. + +- **Access** is `useAddShowsAccess(cid)` (`shows-actions.tsx`): `kid` for a + kid's account, `demo` for the demo, `unknown` while `/server` isn't known, + then `allowed` only with `podcasts === true` (the module on and its Bridge + online), else `unavailable`. Anything but `allowed` is a sentence instead of + the page ("Ask a grown-up to add shows.", "The demo can't add shows.", + "Adding shows isn't available right now"). +- **Sections** come from the flags (`availableSections` in + `find-shows-model.ts`, in `FIND_SHOWS_SECTIONS` order): Search with + `podcast_discovery`, Feed address always, Import a list with `podcast_opml`. `resolveSection` takes + the link's `section` when offered, else the first; a `SegmentedControl` + (shown when more than one is offered) writes `?section=`. +- The import (`useOpmlImport`) is held at page level, so it carries on while + another section shows and ends with the page (or a switch to another + connection or library). + +### The sections + +- **Search** (`directory-search.tsx`): one `FlatList` holding the page head, + the form (a provider `SegmentedControl` only when Podcast Index is + available, the field and Search), the results and, as its footer, the + follow requests. Too short a search shows "Type at least 2 characters." and + sends nothing. The caption `Results from {{provider}}` credits the + directory. A refusal reads through `searchErrorCopy` (with the + `Retry-After` seconds when known) above **Search again**. + `DiscoveryResultRow` (`discovery-result-row.tsx`) shows the artwork + (`DiscoveryArtwork`, `discovery-artwork.tsx`, the retry schedule in + `artwork-retry.ts`), title, author, episode count, `View on {{provider}}` + (`provider_url`, opened outside the app) and **Follow**, which becomes + **Adding** (a `202` request) or **Following** with **Open show** (a `200`). + An Adding result reads its request live (`useFollowRequests`, `requestEnd` + in `find-shows-model.ts`): ended `done` with a show it turns Following with + Open show, ended otherwise it shows **Couldn't add** beside Follow again. + The country is `deviceCountry()` (`discovery-country.ts`). +- **Feed address** (`follow-by-url.tsx`): the address field with a hint, a + **Private feed** switch ("For a feed made just for you, like a paid or + member feed..."), **Follow**. `feedAddressProblem` refuses an empty, non + http(s) or over-2,048-byte address locally; a refusal from the server reads + through `followByUrlErrorKey`. A `200` says `You follow {{title}}.` with + Open show; a `202` says the show appears in Shows once it's ready. +- **Follow requests** (`follow-requests.tsx`, under Search and Feed address): + "Shows being added", every open request then the ten newest ended ones, + each with its redacted feed label, its state (`requestStateKey`: Waiting for + its turn, Adding, Added, Couldn't add, Cancelled), the Private feed badge, + the reason for a failed one (`requestReasonKey`) and Open show once done. It + polls through `useFollowRequests` only while one is open and the page is + focused. +- **Import a list** (`opml-import-panel.tsx`): an intro, **Choose a file** + (`pickOpml`: `opml-pick.web.ts` is a hidden file input accepting + `.opml,.xml,text/x-opml,text/xml,application/xml`, resolving null on its + `cancel` event; `opml-pick.ts` is expo-file-system's `File.pickFileAsync()` + with no type filter, since OPML has no system type on iOS, sending + `file.bytes()`), then the file name, the summary (`{{settled}} of {{total}} shows followed or being added`, + `importProgress`: duplicate and invalid rows + don't count), the status line (a countdown to the next batch, ticking with + `useNow` while the import runs), **Stop** while it runs, **Try again** once + it stopped, stalled or failed, or ended `done` with `unavailable` rows + (`canRetryImport`), and one row per subscription by + `outline_index` (`Entry {{index}}`, `importRowKey`: Following, Already + following, Being added, In the list twice, No usable feed address, Waiting + for the next batch, Can't be added right now, Couldn't add). The rows are + read live against the follow requests (`liveImportRows`): a queued row whose + request ended `done` reads Following, one that ended otherwise Couldn't + add. A `done` import's line is "The last shows are still being added." + while a row is still queued (`stillAdding`), then + `{{count}} shows can't be added right now. Try again later, or ask the person who runs your server.` + when `unavailable` rows remain (`unavailableRows`), else "All done.". A + row's Private feed badge shows only when it has a usable feed (not + `invalid` or `duplicate`, `feed` set), and a row with no label shows only + its entry and status. A file over 1 MiB or one that can't be read is a line + of its own, and nothing is sent. + +### Entry points + +- **Shows mode** (`shows-mode.tsx`) puts `ShowsActions` (`shows-actions.tsx`) + in the sub-nav row's end on tablet and desktop (`SubNavActions`, id + `shows-actions`) and above the grid on a phone: **Add shows** (when access + is `allowed`) and the **More show actions** menu with **Export my shows** + (`podcast_opml === true`, not the demo; it works with the module off, so it + isn't on the Find shows page). `FollowRequestsNotice` beside them reads + "Adding 2 shows" while this library's requests are open and opens the page. + A library with no shows gets an **Add shows** action in its empty state + ("Search a directory, paste a feed address or import a list from another + app.") when access is `allowed`. +- **Export** (`opml-export.ts`, `useExportShows`) saves + `audiosilo-subscriptions.opml` through the Journal's `saveExport` (mime + `text/x-opml`, UTI `public.xml`), then toasts `exportToast`'s counts + ("Exported 3 shows", "1 private show left out. Private feeds are never + exported.", shows without a public address left out); a refusal is a toast + by `exportErrorKey`. + +### Check for new episodes + +`CheckEpisodesButton` (`check-episodes-button.tsx`) sits in the show page's +hero beside Follow, only for a followed show and under `useAddShowsAccess`'s +`allowed`. A `202` toasts "Checking for new episodes" and, 15 seconds later +(`CHECK_REFRESH_MS`, the timer cleared on unmount), invalidates the show list +and the show's episodes once. Refusals read through `checkErrorKey` +("Checked less than a minute ago. Try again shortly." for +`checked_recently`). + +All copy for these screens is under `podcasts.find.*`, `podcasts.import.*`, +`podcasts.export.*`, `podcasts.check.*` and `podcasts.requests.*` in all six +locales; none of it says "Bridge" or shows the server's English. + ## What episodes never do - **Download** (decision 6.1). The downloads store refuses `book.episode` as @@ -249,3 +379,22 @@ removal, `fetch_limit`, `identity_changed`); `start-book.test.ts` and `player-route.test.tsx` cover the choke point; `podcast-model.test.ts`, `library-modes.test.ts` and `new-episodes-model.test.ts` the rules; component tests cover the show page, rows, the panel and strip, the Library and Home. + +Finding shows (Phase 2d-2): the server's goldens are copied byte-identical +into `src/testing/podcast-fixtures/phase2c3/` and read through +`src/testing/podcast-fixtures-2c3.ts` (the list and the SHA-256 check: +[Player: discovery and OPML](../bridge/podcasts.md#player-discovery-and-opml-phase-2d-2)). `client.test.ts` covers each new method's exact URL, body +and headers and the refusal codes through `apiErrorKind` (with +`retryAfterSeconds` read and absent); `hooks-podcasts.test.tsx` the search and +provider queries, the follow and check mutations' cache writes and +`useFollowRequests`' polling (only while open, stopping once all ended, the +show list read again at `done`); `opml-waves.test.ts` drives the wave +controller on fake timers (the queue-cap shape to `done`, `Retry-After` +honoured, stalled, `unavailable` with no loop, a failed private request +queued again by the next wave, stop and dispose sending nothing more, +polling stopping, the background pause) and `use-opml-import.test.tsx` its +hook; `find-shows-model.test.ts`, `podcast-requests.test.ts` and +`paths.test.ts` the rules; component tests cover the page and its gating, +the search and result rows, the artwork retry, the feed address, the follow +requests, the import panel, the web picker, export, the Shows mode's actions +and the check button. diff --git a/docs-developers/server/api/index.md b/docs-developers/server/api/index.md index 4942b589..bd8cf127 100644 --- a/docs-developers/server/api/index.md +++ b/docs-developers/server/api/index.md @@ -510,7 +510,9 @@ sent `false` otherwise. `podcast_opml` is always `true`: the server speaks the OPML import/export protocol, even offline or module-off (the import still needs the module, the export does not). Neither is a module flag, and neither describes the caller: grants, kid and demo refusals and the client gate are -still answered by the routes. See +still answered by the routes. The player (Phase 2d-2) offers its directory +search only with `podcast_discovery` and OPML import and export only with +`podcast_opml`. See [Podcast discovery](reference.md#podcast-discovery) and [OPML import and export](reference.md#opml-import-and-export). @@ -567,23 +569,31 @@ CORS is a strict allow-list driven by `cors_origins` in the server config: the player served at `/web`) are unaffected. - **Listed origins:** an exact-match `Origin` gets `Access-Control-Allow-Origin: ` (plus `Vary: Origin`), methods - `GET, POST, PUT, PATCH, DELETE, OPTIONS`, headers - `Authorization, Content-Type, X-AudioSilo-Client`, and - a 600 s preflight cache. `OPTIONS` requests short-circuit with 204. + `GET, POST, PUT, PATCH, DELETE, OPTIONS`, request headers + `Authorization, Content-Type, X-AudioSilo-Client, Range` + (`Access-Control-Allow-Headers`), the exposed response headers + `Retry-After, X-OPML-Exported, X-OPML-Omitted-Private, X-OPML-Omitted-Unavailable` + (`Access-Control-Expose-Headers`, on every allowed response, not only + preflights), and a 600 s preflight cache. `OPTIONS` requests short-circuit + with 204. - **`"*"`:** allows any origin (the request's own `Origin` is echoed back). For local player development against a dev server, set `cors_origins: ["http://localhost:8081"]`. -No `Access-Control-Expose-Headers` is sent, so a cross-origin page cannot read -response headers such as `Retry-After` or the OPML export's `X-OPML-*` counts -(the `/web` player and native apps are unaffected). The player falls back to -the status's `retry_after_seconds` for `Retry-After`. Its episode probe sends -`Range: bytes=0-0` with its token in `Authorization`, so cross-origin it is -always preflighted. Browsers that safelist a single byte range leave `Range` -out of the preflight's `Access-Control-Request-Headers`, so it passes; -`Allow-Headers` does not list `Range` yet (planned with Phase 2d-2's CORS -change), so a browser that doesn't safelist it fails the probe there. +The two lists are the consts `corsAllowHeaders` and `corsExposeHeaders` +(`internal/api/middleware.go`). `Range` is there for a script's fetch of a +stream with the `Authorization` header: the player's episode probe sends +`Range: bytes=0-0` with its token, so cross-origin it is always preflighted +(an `