diff --git a/docs-developers/architecture/cross-repo-contract.md b/docs-developers/architecture/cross-repo-contract.md index 5190ae1c..c045fbe0 100644 --- a/docs-developers/architecture/cross-repo-contract.md +++ b/docs-developers/architecture/cross-repo-contract.md @@ -245,8 +245,8 @@ book is the only additive marker (mirrored as `Book.chapters_source` / page captions such chapters (an older app shows them as ordinary chapters), and a downloaded book replaces its saved chapters with a newer server answer while its audio files are unchanged (same paths, order and sizes; `startChapterRefresh`). Podcasts additionally -return `id3_chap` from the server's CHAP attachments; player type/UI support is -deferred with podcast player views. See [podcasts](#17-podcast-shows-feeds-and-client-visibility). +return `id3_chap` from the server's CHAP attachments; the player (2.2.0 and +later) captions it "Chapters from the episode file". See [podcasts](#17-podcast-shows-feeds-and-client-visibility). **A change requires:** any change to chapter normalization changes the queue builder and the timeline math with it. And always: **stream the file, not the @@ -311,7 +311,7 @@ and `bridge`, which follows the Bridge's heartbeat, [§16](#16-the-bridge)), one flag per [managed module](../server/modules.md) (`contributions` and `podcasts`: the module's toggle **and** its Bridge requirement met, sent `false` otherwise; the console reads it to offer contributing; `podcasts` needs `podcast` and gates actions, while -reads stay core and released players are still excluded), the independent +reads stay core and players older than 2.2.0 are excluded), the independent `podcast_fetch:true` protocol flag (even offline/module-off; older servers omit it, [§17](#17-podcast-shows-feeds-and-client-visibility)), the Phase 2c-3 core flags `podcast_discovery` (boot-only `podcasts.discovery.enabled` **and** the @@ -391,7 +391,10 @@ the web image must exist before the server image builds. `scripts/fetch-web-player.sh` embed the same pinned build into native binaries. **A change requires:** publish the web image **before** the server image on every -release. Full detail in [Release pipeline](release-pipeline.md); the operator +release. The served `/web` identifies as its `app.json` version, and the +server's podcast gate keys on it (2.2.0 and later see podcasts), so a server +release that bakes in an older web image serves a player that shows no +podcasts. Full detail in [Release pipeline](release-pipeline.md); the operator runbook is [Releasing](../contributing/releasing.md). ## 12. CORS for web development @@ -411,6 +414,9 @@ so the web player sends it **only same-origin** (`shouldIdentify` in `src/lib/client-id.ts`; native always sends it). Keep that rule when adding any custom request header (see [Client identification](../server/api/index.md#client-identification-x-audiosilo-client)). +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). **A change requires:** nothing structural - just remember self-signed TLS (the server default) needs trusting in the browser, or use `AUDIOSILO_TLS_MODE=off` @@ -816,7 +822,8 @@ security-critical: allowed and denied tests on the server. ## 17. Podcast shows, feeds and client visibility Phase 2c-2 extends the server/Bridge seam with episodes on disk, retention -and on-demand fetching/status/Retry. Released player views arrive later. +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. See [Podcasts](../bridge/podcasts.md) and the [API reference](../server/api/reference.md#podcasts) for exact fields. @@ -832,9 +839,16 @@ See [Podcasts](../bridge/podcasts.md) and the offline/module-off; older servers omit it. Off gates actions, stops the scheduler and holds queued jobs; reads stay core. - **Visibility:** `auth.SeesPodcasts(Credential.Client)` feeds `catalog.Caller.Podcasts`. - `AudioSilo Admin` qualifies; `AudioSilo` version `dev` qualifies. The released - minimum remains empty, so released players, unidentified clients and API - tools without a recognized client do not qualify, admins included. + `AudioSilo Admin` qualifies; `AudioSilo` version `dev` qualifies; a released + player qualifies from `auth.PodcastPlayerMinVersion` = `"2.2.0"` (numeric + `X.Y.Z`, a pre-release counts as its base version). Players 2.1.x and older, + unidentified clients and API tools without a recognized client do not + qualify, admins included. The served `/web` identifies as its `app.json` + version (2.2.0 from this phase), so release the 2.2.0 web image before the + server that bakes it in. + A whole-list replace (`PUT /me/queue`, `PUT /me/collections/{id}/items`) + from a caller the gate hides podcasts from keeps the stored episodes only + the gate hides (`catalog.GatedScopes`), so an old player can't delete them. Media tokens inherit the stored client when no valid request header exists. Private-show scopes apply after grants, only for followers matching the show's id; the admin catalog remains separate. An authorized library with @@ -938,12 +952,15 @@ See [Podcasts](../bridge/podcasts.md) and the work; the ordinary download lane drops to 1, keeping two enclosure downloads in total. An older server gets two ordinary download slots and no reserved claim. -- **Player handoff:** no player/cast/screens ship and the released-player gate - remains unset. Carry the [optional types, client/hook signatures and polling - rules](../bridge/podcasts.md#player-handoff-phase-2d-not-implemented) into 2d: - poll only queued/fetching every two seconds, explicit Retry for terminal - failure, refresh item/list/chapters at ready. Preserve generation warnings - without rescaling state and recognize `chapters_source:id3_chap`. +- **Player (Phase 2d-1):** the player mirrors the [types, client and + hooks](../bridge/podcasts.md#player-phase-2d-1) by hand and ships the shows, + show and episode pages, following, Home's New episodes and on-demand play + through one controller: probe `/stream` once, poll only queued/fetching every + `max(2, Retry-After)` seconds, explicit Retry for terminal failure, refresh + item/chapters (and mark the episode lists stale) at ready. It preserves + generation warnings without rescaling state and recognizes + `chapters_source:id3_chap`. No download, cast or kids-mode podcasts; no + server route changed. See [Frontend podcasts](../frontend/podcasts.md). - **Discovery and OPML (Phase 2c-3):** server and docs only; the Bridge and the player are unchanged. `GET /podcasts/discovery/providers|search|artwork` (adult, non-demo, module, client gate; artwork takes a media `?token=`) search @@ -960,7 +977,7 @@ See [Podcasts](../bridge/podcasts.md) and the `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 handoff](../bridge/podcasts.md#player-handoff-discovery-and-opml-phase-2d-not-implemented) + [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). - **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 diff --git a/docs-developers/bridge/index.md b/docs-developers/bridge/index.md index 663641a2..dddc418f 100644 --- a/docs-developers/bridge/index.md +++ b/docs-developers/bridge/index.md @@ -37,7 +37,9 @@ generations and MP3 CHAP attachments. **Phase 2c-2b** adds on-demand episode fetching on play, fetch status/Retry, one-ahead queue preparation and a 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. Podcast player views, read-along, +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. ::: diff --git a/docs-developers/bridge/podcasts.md b/docs-developers/bridge/podcasts.md index 5b930797..9f2bfb3f 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 handoffs, 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 handoff (2d-2), queue prefetch, pinned generations, guarded downloads, retention, publication recovery, CHAP attachments and private feed secrets." --- ## Scope and ownership @@ -27,10 +27,14 @@ Downloads are scheduled by policy, requested by an admin, or triggered by on-demand play and one-ahead queue preparation. Phase 2c-2b adds shared fetch 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. External chapters, transcripts and podcast player UI -arrive later. -Released players do not see podcast libraries; only the admin console lists -them for now. Read routes are core and stay available without the module or +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). +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 +manager, API keys and requests with no client header do not. Read routes are core and stay available without the module or Bridge, subject to the [client gate and private scope](../server/auth-and-security.md#podcast-visibility). Follow, unfollow and manual check are module actions for authenticated adults, not demo accounts. See the [API reference](../server/api/reference.md#podcasts). @@ -317,18 +321,31 @@ that same intent to 100. On demand permits this preparation, without feed-wide scheduled downloads. Demo requests start neither fetch nor prefetch; an existing shared fetch can still be promoted by a demo stream. -### Player handoff (Phase 2d, not implemented) +### Player (Phase 2d-1) -The server protocol is implemented; **the player does not show this yet**. -`auth.PodcastPlayerMinVersion` remains empty and released players stay hidden. -The following are proposed additions to `src/api/{types,client,hooks}.ts`, -mirroring the reviewed wire rather than describing shipped player exports. -Keep response extensions optional for older servers. Every key *inside* a -returned Status is required, including nulls: +Phase 2d-1 ships the player side of this protocol and sets +`auth.PodcastPlayerMinVersion` to `"2.2.0"`: players from 2.2.0 (`app.json` +`expo.version`, sent as `X-AudioSilo-Client: AudioSilo/2.2.0 ()`) see +podcast libraries; 2.1.x and older stay hidden +([client gate](../server/auth-and-security.md#podcast-visibility)). The +player-side walkthrough (screens, routes, the controller's wiring) is +[Frontend podcasts](../frontend/podcasts.md); this section is the wire and the +rules the player keeps. + +**Types** (`src/api/types.ts`). Every new field on an existing type is +optional, so an older server leaves books as they were; every key *inside* a +new object is required, nulls included, as the server writes it. Open unions +(`| (string & {})`) take a value a newer server adds: ```ts +type LibraryKind = 'books' | 'podcasts'; // Library.kind? +type ChaptersSource = 'community' | 'generated' | 'id3_chap'; +// Capabilities: podcasts?: boolean (the module), podcast_fetch?: boolean (core) +// Book: episode?: PodcastEpisodeInfo (only GET /item sends it) + +type PodcastFetchState = 'absent' | 'queued' | 'fetching' | 'ready' | 'failed' | 'unavailable'; type PodcastFetchStatus = { - state: 'absent' | 'queued' | 'fetching' | 'ready' | 'failed' | 'unavailable'; + state: PodcastFetchState | (string & {}); request_id: number | null; bytes_received: number; bytes_total: number | null; @@ -336,10 +353,8 @@ type PodcastFetchStatus = { error: { code: string; message: string } | null; }; type PodcastFetchResponse = { fetch: PodcastFetchStatus }; -type PodcastFetchPending = PodcastFetchResponse & { - error: string; - code: 'podcast_fetching' | 'podcast_fetch_unavailable' | 'podcast_fetch_failed'; -}; +type PodcastFetchPendingCode = + 'podcast_fetching' | 'podcast_fetch_unavailable' | 'podcast_fetch_failed'; type PodcastGeneration = { fetch?: PodcastFetchStatus; generation?: number; @@ -347,92 +362,111 @@ type PodcastGeneration = { timeline_changed?: boolean; }; type PodcastEpisodeInfo = PodcastGeneration & { - show_path: string; - published: string | null; - season: number | null; - episode: number | null; - episode_type: 'full' | 'trailer' | 'bonus'; - explicit: boolean; - downloaded: boolean; - duration_source: 'file' | 'feed' | 'none'; - removed: boolean; + show_path: string; published: string | null; season: number | null; + episode: number | null; episode_type: 'full' | 'trailer' | 'bonus' | (string & {}); + explicit: boolean; downloaded: boolean; + duration_source: 'file' | 'feed' | 'none' | (string & {}); removed: boolean; }; type PodcastEpisode = PodcastGeneration & { - path: string; - title: string; - published: string | null; - duration: number; - duration_source: 'file' | 'feed' | 'none'; - season: number | null; - episode: number | null; - episode_type: 'full' | 'trailer' | 'bonus'; - explicit: boolean; - description: string; - downloaded: boolean; - size: number; - removed: boolean; + path: string; title: string; published: string | null; duration: number; + duration_source: 'file' | 'feed' | 'none' | (string & {}); + season: number | null; episode: number | null; + episode_type: 'full' | 'trailer' | 'bonus' | (string & {}); + explicit: boolean; description: string; downloaded: boolean; size: number; removed: boolean; }; -type PodcastCapabilities = { podcast_fetch?: boolean }; -type PodcastBookFields = { episode?: PodcastEpisodeInfo }; -// Merge these optional fields into Capabilities and Book. +type PodcastEpisodePage = { episodes: PodcastEpisode[]; next_cursor: string | null }; +// PodcastShow, PodcastRequest, MyPodcasts and PodcastFollowResult mirror +// the read routes and POST /podcasts/follow in the API reference. ``` -Do not confuse public `fetch:PodcastFetchStatus` with the on-disk `fetch:Fetch` -pin. `generation`/`sha256`/`timeline_changed` are absent before a valid pin, -but may remain after deletion. Preserve an explicit false flag. A true flag -warns of changed bytes/timeline without resetting or rescaling progress, -bookmarks or finished state. Extend the open chapter source union with -`'id3_chap'` on both Book and ChaptersResponse; it is embedded MP3 metadata, -not generated/ASR chapters. Absence retains existing chapter-source behavior. +Do not confuse the public `fetch: PodcastFetchStatus` with the on-disk +`fetch: Fetch` pin. `generation`/`sha256`/`timeline_changed` are absent before +a valid pin and may remain after deletion; the player keeps an explicit +`timeline_changed: false`. A true flag only shows a notice on the episode page +(when the listener has progress): it never resets or rescales progress, +bookmarks or the finished mark. `id3_chap` is embedded MP3 metadata, not +generated chapters; the chapters tab captions it "Chapters from the episode +file". -Proposed client methods keep the server envelope and the existing optional -abort signal convention: +**Client** (`src/api/client.ts`). Each method but the two follow writes takes +the optional abort `signal`: -```ts -interface PodcastFetchClient { - podcastFetchStatus(libraryId: number, path: string, - signal?: AbortSignal): Promise; - retryPodcastFetch(libraryId: number, path: string, - signal?: AbortSignal): Promise; -} -``` - -The first uses GET with encoded `path`; the second POSTs only `{path}` to -`/libraries/{id}/podcasts/fetch`. Preserve `fetch` and `Retry-After` when -decoding the stream 503; today's `ApiError(status,message,code?)` discards them. -Suggested hooks follow `useBook`'s connection selection: - -```ts -declare function usePodcastFetchStatus( - libraryId: number, path: string, connectionId?: string, - options?: { enabled?: boolean }, -): UseQueryResult; -declare function useRetryPodcastFetch(connectionId?: string): - UseMutationResult; -// BookRef is { library_id: number; path: string }. -// Query key: ['podcastFetch', cid, libraryId, path]. -``` - -Unwrap `fetch` in the hooks. Gate new calls on `podcast_fetch === true` and use -the optional connection client so a removed connection disables reads. Do not -gate ready reads/status solely on the live `podcasts` flag. Episode-list -rows read the list's batched `fetch`; the per-path hook is for the item being -played or retried. After a media 503, poll GET every two seconds while -queued/fetching instead of repeating the media request, and request media -again at ready. Pause polling while the app is in the background. Stop polling at ready, failed or unavailable; absent -GET is also observation only. A terminal failure needs explicit POST Retry -or a later server-scheduled attempt, never a poll-triggered POST loop. -An unavailable status still has retry 2; that alone does not justify an -automatic Retry loop. Treat `409 fetch_limit` as a refusal to create work. - -Seed/update the status cache from a media 503 and Retry response. At ready, -refresh the item and its chapters/files before retrying media, and mark the -episode list stale rather than refetching it at once; -at failed/unavailable retain the safe error code for localized controls. -Cancel polling when the connection/path or consumer changes, without a -server job cancellation. `wire-2b.json` fixes fetching/ready/failed examples -and the 503 envelope; missing optional fields must leave existing books and -older-server behavior intact. No player, cast or screen changes ship here. +| Method | Request | +|---|---| +| `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) | +| `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 | +| `retryPodcastFetch(libraryId, path)` | `POST /libraries/{id}/podcasts/fetch` with exactly `{path}` | +| `prepareEpisodeStream(libraryId, path)` | `GET` the stream URL with `Range: bytes=0-0`, the token in `Authorization` (no `?token=`); a 2xx resolves | + +Every JSON route and the probe decode a non-2xx answer through one helper +(`apiErrorOf`): a **503** whose JSON has a `podcast_fetch*` code and an object +`fetch` becomes `PodcastFetchPendingError` (an `ApiError` with `code`, `fetch` +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. + +**Hooks** (`src/api/hooks.ts`). Reads are core and never gated on `podcasts`: +`usePodcastShows`, `usePodcastEpisodes` (an infinite query, 50 a page, +`next_cursor` as the page param), `myPodcastsQuery` and `latestEpisodesQuery` +(Home) ask only for a library whose `kind` is `podcasts`. `useFollowPodcast` +and `useUnfollowPodcast` are `useCapabilityMutation('podcasts', ...)`: they +patch `following` in the cached show list (an unfollowed private show leaves +it) and read `myPodcasts` again. `usePodcastFetchStatus` is a plain +`podcast_fetch` capability query with no interval. Query keys: +`['podcastShows', cid, lib]`, `['podcastEpisodes', cid, lib, show, order]` +(Home's one page is `[..., show, 'latest', limit]` under the same show prefix), +`['myPodcasts', cid]` and `['podcastFetch', cid, lib, path]`. There is no +retry hook: Try again is the controller's. + +**The episode-fetch controller** (`src/playback/episode-fetch.ts`) is the one +poller. The rules it keeps: + +- **The probe is the play trigger.** A start of an episode that is not + downloaded, on a server with `podcast_fetch === true`, goes to + `requestEpisodeFetch`, which asks `/stream` once (`prepareEpisodeStream`). + A play goes through `/stream` rather than POST because an ordinary stream + fetch respects the failure cooldown, while POST is reserved for the explicit + Try again (it bypasses it). The stream's two-second hold also serves an + episode that is about to be ready. +- **Polling.** While `queued` or `fetching`, it GETs the status every + `max(2, Retry-After)` seconds and seeds `['podcastFetch', ...]` from every + answer (the 503's `fetch`, the POST's, each GET's). A network error, a `429` + or a `5xx` while polling reschedules the next turn at 2 seconds. +- **Background.** It pauses while the app is not active and asks once at once + on the way back (`onForeground`; RN-web maps it to `visibilitychange`). +- **It never POSTs by itself.** It stops at `failed`, `unavailable`, or + `absent` after a trigger (shown as failed); only Try again + (`retryEpisodeFetch()`, or `requestEpisodeFetch(..., {retry: true})` from a + row or the episode page) sends the POST. `409 fetch_limit` is `refused`; + `module_off`, `bridge_unavailable`, `library_not_writable` and + `demo_account` are `unavailable` with that code; `identity_changed` + invalidates the item and the library's episode lists and stops as failed. +- **Abort, never cancel on the server.** One `AbortController` per run. Any + other start, a profile switch (`mayActFor`), a removed connection + (`resolveClient`) or the listener's Cancel ends the run; the server's shared + fetch carries on. There is no client time cap: the Bridge's size-scaled + ceiling ends a dead fetch as `failed`. +- **Refresh at ready.** It reads the item and its chapters again + (`staleTime: 0`), marks the show's episode lists stale, then starts the + episode in place with `{fetched: true}`, which plays only while the fetch is + still `starting` (a Cancel or another start since wins). + +**Exclusions.** Episodes are never downloaded in 2d-1 (decision 6.1): the +downloads store refuses `book.episode` as `unsupported`, the download control +and the tile menu leave it out, and auto-download and keep-ahead skip it (an +absent episode would answer 503 to the native download engines, and nothing +would clean the files up). The Cast button hides for an episode (cast tokens +never see podcasts; the server answers `403` to `POST /cast/token` for one). +An episode never opens the end credits. Kids mode lists no podcast library +(decision 6.2). Golden: the player keeps `wire-2b.json` byte-identical in +`src/testing/podcast-fixtures/`. ## Discovery and OPML transfer @@ -470,11 +504,11 @@ 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, not implemented) +### Player handoff: discovery and OPML (Phase 2d-2, not implemented) -The server side is implemented; **no player or console screen calls it yet**, -and `auth.PodcastPlayerMinVersion` stays empty, so released players remain -outside the podcast client gate. These are proposed additions to the player's +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: @@ -511,7 +545,7 @@ Mirror `OpmlRowStatus`, `OpmlImportRow` and `OpmlImportResult` from the [import reference](../server/api/reference.md#post-apiv1podcastsopmlimport), which is their one written copy. -What 2d has to get right: +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)). @@ -574,7 +608,7 @@ What 2d has to get right: 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 starts: the server +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`). diff --git a/docs-developers/contributing/documentation.md b/docs-developers/contributing/documentation.md index a080abf8..4c18de90 100644 --- a/docs-developers/contributing/documentation.md +++ b/docs-developers/contributing/documentation.md @@ -92,7 +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 and 2d 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` | +| 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`) | | 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/chromecast.md b/docs-developers/frontend/chromecast.md index 4bd431a8..9d9cc791 100644 --- a/docs-developers/frontend/chromecast.md +++ b/docs-developers/frontend/chromecast.md @@ -167,7 +167,9 @@ and in the docked player (only when the dock has room for all its actions, `dockLayout`'s `allActions`). It shows only while a book is loaded, on a device that can cast, and when the playing book's server has `cast` (an older server, or flags not known yet: no button); while a session is connected it shows whatever the book, so a cast can -always be stopped. Native renders the SDK's own `CastButton` in a 44 pt frame; the +always be stopped. It is not offered for a podcast episode (`episode` in the +model, from `NowPlaying.episode`): a cast token never covers podcasts, and the +server answers `403` to `POST /cast/token` for one. Native renders the SDK's own `CastButton` in a 44 pt frame; the web draws our own, which opens Chrome's Cast chooser. When `castBlockReason` blocks the book, the button explains why instead (a toast) rather than casting. Strings are in the `cast` namespace. diff --git a/docs-developers/frontend/end-of-book.md b/docs-developers/frontend/end-of-book.md index 459beebe..a3e8c221 100644 --- a/docs-developers/frontend/end-of-book.md +++ b/docs-developers/frontend/end-of-book.md @@ -11,6 +11,13 @@ When the last track finishes the engine reports `ended`, and the store (`snapshot.state === 'ended'`) exposes it; the final `haltAndPersist` on that transition records `finished: true`. +A podcast episode (`FinishedBook.episode`) never opens the end credits: +`BookEndedListener`'s `endEpisode` drops it from Up next and, with auto-play on +and the sleep timer not running, starts Up next's head only +(`resolveUpNext(..., {queueOnly: true})`); otherwise playback stops and a full +player left open on it closes once the app is active. See +[Podcasts](podcasts.md#what-episodes-never-do). + - **`finishBook()` (`store.ts`)** is the single "this book is done" action, used both by the natural end and the player's *Mark as Finished* menu item. It captures a `FinishedBook` identity (connection/library/path/title/author/cover), diff --git a/docs-developers/frontend/kids-mode.md b/docs-developers/frontend/kids-mode.md index 5006b79e..46cfe69c 100644 --- a/docs-developers/frontend/kids-mode.md +++ b/docs-developers/frontend/kids-mode.md @@ -84,6 +84,12 @@ the `kids` namespace. The rules are pure in `kids-model.ts`, read through `useKidsStories()`: +- **No podcasts** (`kidsLibraries`, `src/lib/library-kind.ts`): podcast libraries + are never a kids library, in the screen or the library picker, and a shared + collection's podcast items are left out (`kidsCollectionSections` with the + account's libraries; Bridge Phase 2d-1, decision 6.2). Collections mode waits + for the library list, and falls back to downloads or the error when it can't + be read, here and in the car (`car-controller.ts`). - **The connection** (`kidsConnectionId`): the picked library's connection while the profile still holds it, else the profile's first. - **The collections** (`kidsCollections`): for an account with diff --git a/docs-developers/frontend/offline.md b/docs-developers/frontend/offline.md index 43951674..2a6f0e00 100644 --- a/docs-developers/frontend/offline.md +++ b/docs-developers/frontend/offline.md @@ -122,6 +122,11 @@ Implementation notes worth knowing before touching it: ## The registry store (`src/downloads/store.ts`) +Podcast episodes are never downloaded (Bridge Phase 2d-1, decision 6.1): the +store answers `unsupported` for a `book.episode`, and auto-download and +keep-ahead skip episodes (keep-ahead by the entry's library kind). See +[Podcasts](podcasts.md#what-episodes-never-do). + `useDownloads` (Zustand) keeps a `Registry` of `DownloadEntry` keyed by `downloadKey(connectionId, libraryId, path)` (`"::"`), persisted as JSON under diff --git a/docs-developers/frontend/playback.md b/docs-developers/frontend/playback.md index 67b91ab7..849f6ce8 100644 --- a/docs-developers/frontend/playback.md +++ b/docs-developers/frontend/playback.md @@ -737,6 +737,10 @@ under the stats ("AC-3 audio is converted to MP3 for this browser"), under exact - [The end of a book](end-of-book.md): finishing a book, what plays next, the automatic download of the book you start. +- [Podcasts](podcasts.md): an episode not on its server yet starts through the + episode-fetch controller (`src/playback/episode-fetch.ts`), never `playBook`; + `NowPlaying.episode` (a view field from `nowPlayingOf`) marks an episode for + the cast, credits and download rules. - [The sleep timer](sleep-timer.md): the timer, drift-offs, the grace card. - [Player UI](player-ui.md): jump undo, time left, the companion, the controls, sheets, and the compact and full players. diff --git a/docs-developers/frontend/podcasts.md b/docs-developers/frontend/podcasts.md new file mode 100644 index 00000000..d07cab61 --- /dev/null +++ b/docs-developers/frontend/podcasts.md @@ -0,0 +1,251 @@ +--- +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." +--- + +## Scope + +Bridge Phase 2d-1 shows a server's podcast libraries in the player. 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 +`X-AudioSilo-Client: AudioSilo/2.2.0 ()` by `src/lib/client-id.ts`), the +admin console and `AudioSilo/dev`. 2.1.x and older builds see no podcasts. + +The served `/web` export sends the same header same-origin. The cross-origin +dev loop (`npm run web` on `:8081`) sends no header, so it shows no podcasts +unless a development build opts in with `EXPO_PUBLIC_IDENTIFY_CROSS_ORIGIN=1` +(`identifiesCrossOrigin` in `client-id.ts`, never true in a production build). + +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 +`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. + +## Telling an episode from a book + +An episode is an ordinary item: it lives at `/book`, plays through the normal +start, and keeps progress, bookmarks and Up next entries like a book. Only +`GET /item` sends `Book.episode` (`PodcastEpisodeInfo`); the list shapes (Up +next, Continue listening, history) leave it out. A framework-free reader tells +an episode by its library instead: `isPodcastLibrary(l)` (`library-kind.ts`; +an absent or unknown `kind` is books), `cachedPodcastLibrary(cid, libraryId)` +over the cached library list, `podcastLibraryIds(libraries)` (a set of ids +for a whole list), and the `useIsPodcastLibrary` hook. +`episodeAsBook(ep, show)` (`podcast-model.ts`) turns an episode row into the +`Book` a start, the action menu and the fetch take. + +## Reads are core, writes are the module's + +- Shows, episodes and `/me/podcasts` are core reads, never gated on a + capability flag. They are asked only for a library of the podcasts kind + (`usePodcastShows(..., {enabled})`), so a books library never asks. +- Follow and Unfollow are `useCapabilityMutation('podcasts', ...)`: the + module's flag, true only with the toggle on **and** its Bridge online. + `FollowButton` (`follow-button.tsx`) is interactive only then, and only for + a listener who may follow (not `isKidAccount(user)`, not `user.is_demo`). + Otherwise a followed show shows a read-only **Following** badge and others + show nothing. Unfollowing a private show opens a `ConfirmDialog` first, + since the show leaves the listener's lists. Errors become toasts by + `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`). + +## Surfaces + +### The Shows library mode + +`library-modes.ts` adds the mode `shows`. A podcast library offers +`['shows', 'folders']` only (`availableLibraryModes(caps, podcasts)`); a books +library never offers Shows. `resolveLibraryMode` maps any book mode to `shows` +on a podcast library (so a `?mode=authors` link lands on Shows) and `shows` to +`books` on a books library. `library-screen.tsx` reads the selected library's +kind and passes no connection to `useModeCounts`'s author, narrator and series +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 +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 +feed" badge). It is not `CoverTile`: a show has no book actions or progress. +Pressing a tile pushes `podcastShowHref`. + +### The show page + +Route `src/app/(app)/(home,library,search,offline,me)/podcast.tsx`, URL +`/podcast?connection=&library=&show=[&order=newest|oldest]` +(`podcastShowHref`, `parsePodcastParams` in `src/lib/paths.ts`; `/podcast` is +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); +- 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; +- `EpisodeList`, a FlashList over `usePodcastEpisodes` pages. + `visibleEpisodes` drops `removed && !downloaded` (a removed episode whose + file is kept stays, marked "No longer in the feed"). + +`EpisodeRow` (`episode-row.tsx`) shows the title, a meta line +(`episodeMetaLine`: date, duration when `duration_source !== 'none'`, +`episodeLabel`'s `S2 E5` / `E5`), Trailer / Bonus / Explicit marks, progress or +Finished, and the state line from `episodeRowState(ep, pending, canFetch)`: +Ready on your server, Plays on demand, Getting it (with a percentage when the +size is known), Couldn't get it, Not available now. The two stopped states +carry the server's `code`, and `rowStateReasonKey(state)` turns it into the +reason line under them (`fetchReasonKey`; a server without `podcast_fetch` +reads as `bridge_unavailable`, "Your server can't download episodes right +now."). The player's own fetch of that episode (`pending`) wins over the +list's batched `fetch`. The one button +is Play (Resume once started) through `usePlayBook`, or Try again after a +failure (`requestEpisodeFetch(..., {retry: true})`). Its menu is +`BookActionsButton` with Add to Up next and Mark as finished, never Download. +Tapping the row opens `bookHref`. + +### The episode page + +`book-page.tsx` renders `EpisodePage` (`episode-page.tsx`) for an item with +`book.episode` (or, before the item arrives, any item of a podcast library). +It has a hero with cover, title, a link to the show, date, duration and marks; +the primary Resume / Play / Try again; the description; the chapters through +`BookChaptersTab` (whose `id3_chap` caption reads "Chapters from the episode +file"); and `usePodcastFetchStatus` while the episode is not downloaded. A +stopped state's reason (`rowStateReasonKey`) sits beside the disabled primary +button. When +`timeline_changed === true` and the listener has a position, a Notice says the +audio changed and the place is kept: nothing is rescaled. There is no Generate, +community metadata, rating, series or Download, and the player's companion +chips and tabs are off for an episode (`useCompanionData` reads `book.episode` +as `off`). + +### Wording in the player and on Home + +The full player's progress reads `player.full.percentOfEpisode` +(`{{percent}}% of the episode`) for an episode, and the Now card `home.now.throughEpisode` +("through the episode", with `scaleLabelEpisode` for its scale). The Now card +skips the "Chapter 1 of 1" line for an episode with no chapters. + +### Home's New episodes + +`use-new-episodes.ts` reads `myPodcastsQuery` on each connection that lists a +podcast library, takes the followed shows that published in the last 14 days +(`recentShows`: at most `NEW_SHOWS` = 8, newest first), and asks each for its +newest `NEW_EPISODES_PER_SHOW` = 5 episodes (`latestEpisodesQuery`, five +minutes fresh). `newEpisodes` (`new-episodes-model.ts`) keeps those published +in the last `NEW_EPISODE_DAYS` = 14 days, drops removed episodes, trailers and +any started or finished (`skip`, Home's `skipNext`: Continue listening has the +started ones), sorts newest first then by +key and caps the shelf. `NewEpisodesRail` sits after "Next in your series": +title "New episodes", sub "From shows you follow", action "All shows" (selects +that server's podcast library, then opens `/library?mode=shows`), and +`CoverTile`s captioned `{{show}} · {{when}}`. It renders nothing when empty. +Podcasts stay off the book shelves (a server rule). + +## The episode-fetch controller + +`src/playback/episode-fetch.ts` is a framework-free zustand store +(`useEpisodeFetch`, one `pending` fetch: key, target, title, show, start +options, `phase`, `status`, `code`) plus functions. The player's internals +never see a fetch: they get an ordinary start at the end. + +**One choke point.** `startBookInPlace` (`start-book.ts`) and the player +route's auto-start (`src/app/player.tsx`) hand an episode that needs a fetch +(`needsEpisodeFetch(book, podcastFetch)`: an episode that is not +`downloaded`, on a server whose `podcast_fetch` is true) to +`requestEpisodeFetch`, never to `playBook`. A local (downloaded) source never +does. Any other start ends a fetch in flight first +(`cancelEpisodeFetchUnless`: the last tap wins), and a start that plays the +episode itself ends a fetch of it still on its way (`cancelEpisodeFetchOf`). +A start overtaken while it looked its episode up hands nothing on +(`isLatestStart`). Tapping the same episode again joins the fetch, taking the +newer start options. + +**Phases.** `probing` (the stream is being asked), `waiting` (queued or on its +way), `failed`, `unavailable`, `refused` (`409 fetch_limit`) and `starting`. +The wire rules (probe once, poll every `max(2, Retry-After)` seconds, pause in +the background, never POST by itself, abort without a server cancel, refresh +at ready) are in [Bridge podcasts](../bridge/podcasts.md#player-phase-2d-1). +Try again is `retryEpisodeFetch()` (the panel and strip) or +`requestEpisodeFetch(target, book, {}, {retry: true})` (a row, the episode +page): both POST once, then follow the same handling. + +**At ready** the controller reads the item and chapters again, marks the +show's episode lists stale, then calls +`startBookInPlace(target, opts, {fetched: true})`. A `fetched` start never goes back to the controller, and it +plays only while the fetch is still `starting` (`isEpisodeFetchStarting`), so a +Cancel or another start since then wins. A start that fails stops as `failed`. + +**UI.** + +- `EpisodeFetchPanel` takes the full player's place while `pending.key` is + the route's episode (the route never calls `playBook` then): cover, title, + show, the heading "Getting this episode" (once stopped, "Couldn't get this + episode" or "This episode can't be fetched now"), one line under it (the + body while probing, "Waiting for its turn" while queued, the progress + `{{received}} of {{total}}` or `{{received}} so far` with `formatBytes` + while fetching, the reason once stopped) with a bar, Cancel, and Try again + only when stopped. Reasons read through `fetchReasonKey(code)` with a + generic fallback. +- `EpisodeFetchStrip` is the compact form, in `DockedPlayer` (wide) and + `MiniPlayer` (phone, player closed). While another book plays it sits above + that book's controls, which keep playing until the episode replaces it at + ready (decision 6.4). The iOS 26 `AccessoryPlayer` has no strip yet. +- A start long after the tap may be refused by a browser's autoplay rule: + that is the existing "refusal is a pause" path, and the episode loads + paused. + +## What episodes never do + +- **Download** (decision 6.1). The downloads store refuses `book.episode` as + `unsupported`; `DownloadControl` and the tile menu leave it out; + `maybeAutoDownloadCurrent` and keep-ahead skip it (keep-ahead tells a queued + episode by its library, since the queue's list shape has no `episode`). See + [Offline](offline.md). +- **Cast.** `NowPlaying.episode` hides the Cast button + (`castButtonModel`), except while a session is already connected, so it can + be stopped. Cast tokens never cover podcasts. See + [Chromecast](chromecast.md). +- **Open the end credits.** `BookEndedListener` drops the episode from Up + next and, with auto-play on and not under the sleep timer, starts Up next's + head only (`resolveUpNext(..., {queueOnly: true})`), never a series or + folder next; otherwise playback stops and a full player left open on it + closes once the app is in the foreground. See [End of book](end-of-book.md). +- **Kids mode.** `kidsLibraries` drops podcast libraries from kids mode and + its library picker, and `kidsCollectionSections` leaves out any item of a + podcast library (or with `episode`) in a shared collection (decision 6.2). + Since a collection's items carry no `episode`, collections mode waits for + the library list and, when it can't be read, falls back as an unreadable + shelf does (downloads, else the error), on the kids screen and in the car. +- **Join Home's book shelves.** `bookRowTest(groups)` (`home-model.ts`) tells + a progress row's kind by its library: Next in your series (and the `/next` + questions behind it), the smart shelves and Recently finished hold books + only, and a server whose list is still loading counts as no books yet (one + whose list failed counts as books). The Now card, Continue listening and + Favourites show episodes like books; New episodes is their own rail. + +Listener copy never says "Bridge" and never shows the server's English: +refusals read through `fetchReasonKey` and the follow error keys, in all six +locales (`locales.test.ts` checks every `podcasts.fetch.reason.*` key). + +## Tests + +`src/testing/podcast-fixtures/wire-2b.json` is the server's +`internal/podcast/testdata/phase2c2/wire-2b.json`, byte-identical; +`src/testing/podcast-fixtures.ts` holds show and episode fixtures. +`client.test.ts` covers each method's request and the 503 decoding (header and +body `Retry-After`, an HTML 503, `fetch_limit`); `episode-fetch.test.ts` +drives the controller on fake timers (ready, polling, failed with no POST, one +POST on Try again, background pause, abort, profile switch and connection +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. diff --git a/docs-developers/frontend/state-and-data.md b/docs-developers/frontend/state-and-data.md index 4ba7a958..eb1e6895 100644 --- a/docs-developers/frontend/state-and-data.md +++ b/docs-developers/frontend/state-and-data.md @@ -709,7 +709,9 @@ The rule is the pure `communityCoverSource` (`community-cover.ts`), unit-tested: ### Home Home (`src/components/home/`) aggregates every connection and keeps its rules -pure: +pure (the podcast "New episodes" rail, `use-new-episodes.ts` and +`new-episodes-model.ts`, is in [Podcasts](podcasts.md#homes-new-episodes); +`bookRowTest` keeps episodes off the book shelves below, by library kind): - `home-model.ts`: `pickNowBook` (the loaded book, else the most recently played in progress), `syncPill` (told as it is: a save still in the offline queue, or an @@ -768,6 +770,16 @@ book that hasn't reached it. The counted remainder is the `hidden` number behind "2 more matches after your place in the book". The result carries the CC BY-SA `attributions` of the books its hits come from, rendered beside the group. +### Podcasts + +Podcast reads (`usePodcastShows`, `usePodcastEpisodes`, `myPodcastsQuery`, +`latestEpisodesQuery`) are core and asked only for a library whose `kind` is +`podcasts`; Follow and Unfollow are `useCapabilityMutation('podcasts', ...)`; +`usePodcastFetchStatus` is a `podcast_fetch` capability query the +episode-fetch controller seeds and is the only one to poll. Keys and shapes: +[Bridge podcasts](../bridge/podcasts.md#player-phase-2d-1); screens and the +controller: [Podcasts](podcasts.md). + ## Zustand stores ### Session (`src/stores/session.ts`) diff --git a/docs-developers/server/api/index.md b/docs-developers/server/api/index.md index 81a4c0c0..4942b589 100644 --- a/docs-developers/server/api/index.md +++ b/docs-developers/server/api/index.md @@ -153,11 +153,15 @@ Who sends it: **same-origin** with the page: a custom header makes a cross-origin request non-simple, so the browser sends a CORS preflight, and servers released before the header don't allow it (see [CORS](#cors)). The embedded `/web` player is - same-origin, so it identifies itself. + same-origin, so it identifies itself, with the version of the web build the + server bakes in (`AudioSilo/2.2.0 (web)` from Phase 2d-1). A development + build opts in cross-origin with `EXPO_PUBLIC_IDENTIFY_CROSS_ORIGIN=1`. - **The admin console** - always (it is same-origin). - Anything else may send it; nothing requires it. -The header also decides whether a client sees podcast libraries at all: see +The header also decides whether a client sees podcast libraries at all: the +console, `AudioSilo/dev` and players from **2.2.0** do, older players and +anything unidentified do not. See [Podcast visibility](../auth-and-security.md#podcast-visibility). ## Error envelope and status conventions @@ -495,7 +499,7 @@ server (a [managed module's](../modules.md#the-toggle-and-the-capability-flag) f even when the module is off or the Bridge is offline. An older server omits it. Keep it separate from the live `podcasts` module flag below. Ready media and status reads stay core; client/private scope still applies, including to -admins, and released players remain hidden. Episode lists and `Book.episode` +admins, and players older than 2.2.0 remain hidden. Episode lists and `Book.episode` may include optional `fetch:Status`; tolerate its absence on older servers. Phase 2c-3 adds two more optional core flags (absent on older servers, so @@ -518,7 +522,7 @@ needs; it is `false` (sent, not absent) otherwise. An older server omits it: | Flag | Gates | |---|---| -| `podcasts` | the Podcasts module: follow/unfollow/check, new downloads, retention writes and feed/download/retention scheduling. Needs the `podcast` Bridge feature; false unless the toggle is on and that Bridge is online. Reads and valid present-pin reuse stay core, with client/private scope on player content. Released players remain excluded; see [Podcasts](reference.md#podcasts) | +| `podcasts` | the Podcasts module: follow/unfollow/check, new downloads, retention writes and feed/download/retention scheduling. Needs the `podcast` Bridge feature; false unless the toggle is on and that Bridge is online. Reads and valid present-pin reuse stay core, with client/private scope on player content. Players older than 2.2.0 remain excluded; the player shows Follow only while it is `true`; see [Podcasts](reference.md#podcasts) | | `contributions` | the Contributions module: sending corrections and generated characters and recaps to the community database through the Bridge. Needs the Bridge's `contribute` feature. The console reads it (the generate dialog's contribute switch, Share with the community, Suggest corrections; with `meta` in `bridge.features` for corrections); the player never contributes and doesn't read it | Gate on the flag rather than on the server version: an older server answers @@ -573,7 +577,13 @@ For local player development against a dev server, set 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 `/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 contract is hand-mirrored diff --git a/docs-developers/server/api/reference.md b/docs-developers/server/api/reference.md index 4a068105..7740ebe6 100644 --- a/docs-developers/server/api/reference.md +++ b/docs-developers/server/api/reference.md @@ -1113,11 +1113,12 @@ Phase 2c-2 exposes feed following, indexed show/episode reads, scheduled and admin-requested downloads, retention, CHAP chapters and on-demand fetch/status/Retry. Phase 2c-3 adds [directory search](#podcast-discovery) and [OPML import and export](#opml-import-and-export), API only. -Released podcast player UI arrives later. The recognized -clients are the console's `AudioSilo Admin` or the player's `AudioSilo` with -version `dev`, through `X-AudioSilo-Client`; the released-player minimum is still -empty. A missing, other or released client is denied podcast content even for -an admin. See [visibility](../auth-and-security.md#podcast-visibility). +Phase 2d-1 adds the [player](../../bridge/podcasts.md#player-phase-2d-1) +(no new route). The recognized clients, through `X-AudioSilo-Client`, are the +console's `AudioSilo Admin`, the player's `AudioSilo` with version `dev`, and +released players from **`AudioSilo/2.2.0`** (`auth.PodcastPlayerMinVersion`). A +missing or other client, or a player older than 2.2.0, is denied podcast +content even for an admin. See [visibility](../auth-and-security.md#podcast-visibility). ### Podcast read routes @@ -1330,8 +1331,8 @@ cooldown; GET never retries a failed job. Clients repeat one request (GET status, or the media request) no sooner than every two seconds, and only while queued/fetching. Stop at ready/failed/unavailable; failure needs explicit POST Retry or a later scheduled attempt. An unavailable retry interval is not permission for a POST -loop. Optional player mirrors, hooks, cache refresh and generation/CHAP rules -are in the [Phase 2d handoff](../../bridge/podcasts.md#player-handoff-phase-2d-not-implemented). +loop. The player's mirrors, hooks, cache refresh and generation/CHAP rules +are in [Player (Phase 2d-1)](../../bridge/podcasts.md#player-phase-2d-1). ### Podcast follow requests @@ -1405,7 +1406,8 @@ sends a finished/failed notification. See [the Bridge implementation](../../brid shows by name; following one is the ordinary [`POST /podcasts/follow`](#podcast-actions) with `{library_id, feed_url}` (there is no provider-id follow and no new follow field). Nothing in the -console or a released player calls these routes yet. +console or a released player calls these routes yet (the player's screens are +[Phase 2d-2](../../bridge/podcasts.md#player-handoff-discovery-and-opml-phase-2d-2-not-implemented)). | Route | Query | Success | |---|---|---| @@ -3239,7 +3241,16 @@ picker, `ratings` for ratings. Rules shared by every route in this section: caller's current access. Any other entry is **skipped**, not an error, and the response is the stored result, so a client reconciles with what it gets back. Duplicates collapse (the first wins). It is a whole replace: every stored row - not in the list is deleted, **including** rows the caller can't currently see. + not in the list is deleted, **including** rows the caller can't currently see, + with one exception. **A caller the [podcast client gate](../auth-and-security.md#podcast-visibility) + hides podcasts from** (a 2.1.x or older player) keeps the stored podcast + episodes it could see if it passed the gate: each stays just after the listed + entry it followed, or all go after the new list, in their stored order, when + the new order would shuffle them. They count toward the list's cap (the + oldest of them go when the list and they together exceed it). Episodes hidden + for another reason (a revoked share, an unfollowed private show) are deleted as + before, and a replace from a podcast-seeing client deletes every unlisted row. + So an old device reordering Up next can't drop a newer player's episodes. A whole-list body may be up to 4 MiB (other bodies keep the default limit). - **Removing one entry is never scoped.** `DELETE …?library_id=&path=` removes only the caller's own row, so it needs no access check and a revoked path can @@ -3300,7 +3311,9 @@ picker, `ratings` for ratings. Rules shared by every route in this section: Entries follow the [replace rules](#up-next-collections-and-ratings): duplicates collapse, entries that aren't an indexed book in the caller's access are skipped. Every stored row not in the list is deleted, **including** rows the -caller can't currently see. Response `200`: the queue, as `GET`. +caller can't currently see, except the podcast episodes a gated (pre-2.2.0) +player keeps (see the [replace rules](#up-next-collections-and-ratings)). +Response `200`: the queue, as `GET`. | Status | Meaning | |---|---| @@ -3437,7 +3450,8 @@ collection is untouched. `204 No Content`; `404` for anyone else. *Session.* Replaces the collection's items with this order (owner only), body `{ "items": [ { "library_id": 1, "path": "…" } ] }`, by the [replace rules](#up-next-collections-and-ratings) of up next (a whole replace: -rows the owner can't currently see are deleted too). Response `200`: the +rows the owner can't currently see are deleted too, except the podcast episodes +a gated, pre-2.2.0 player keeps). Response `200`: the collection and its items, as `GET /me/collections/{id}`. Ownership is checked before the size and the entries, so a stranger gets `404` (and a viewer `403`) rather than `too many items`; only a malformed body or a missing `items` is diff --git a/docs-developers/server/auth-and-security.md b/docs-developers/server/auth-and-security.md index 92880361..c4b11b97 100644 --- a/docs-developers/server/auth-and-security.md +++ b/docs-developers/server/auth-and-security.md @@ -428,10 +428,14 @@ Podcast scope adds two checks after authentication: `api.callerOf` uses `Credential.Client`: this request's valid `X-AudioSilo-Client`, otherwise the token's stored client (including media `?token=`). `catalog.Caller.Podcasts` is true for `AudioSilo Admin` - with any/no version and `AudioSilo` with version `dev`. The released-player - minimum, `auth.PodcastPlayerMinVersion`, is still `""`, so no released player - qualifies. Once set, a numeric `X.Y.Z` at least that minimum qualifies - (suffixes after `-`/`+` ignored). Missing/malformed/other apps, API tools + with any/no version and `AudioSilo` with version `dev`. A released player + qualifies from `auth.PodcastPlayerMinVersion`, **`"2.2.0"`** (Bridge Phase + 2d-1, the first player with podcast views): a numeric `X.Y.Z` at least that + minimum, suffixes after `-`/`+` ignored (so `2.2.0-rc.1` counts as 2.2.0; + `2.2` and an empty version fail). `AudioSilo/2.1.x` and older stay hidden; + an empty minimum would hide every released player. The served `/web` + export sends its `app.json` version same-origin, so a `/web` build from + 2.2.0 passes and an older web image does not. Missing/malformed/other apps, API tools without a recognized client and the Bridge fail the gate. `callerByID` defaults it false; internal podcast scheduling/retention explicitly enables it before resolving actual follower grants. Admin role grants no @@ -453,6 +457,18 @@ path/show checks. A caller without a library grant or passing client remains denied. The admin catalog is the separate administrative view of the disk and can still see private show contents. +**A gated caller's whole-list replace keeps what the gate hides.** Up next and +collection items are path-keyed lists that can hold episodes. An add's +position already counts only the rows the caller sees. A whole-list replace (`PUT /me/queue`, +`PUT /me/collections/{id}/items`) from such a caller would otherwise delete the +episodes it was never shown, so `catalog.GatedScopes` passes the caller's +podcast scopes, as a podcast-seeing client would get them, as `keep`: the +stored entries those allow stay, each after the listed entry it followed, or +all after the new list in stored order when the new order would shuffle them. +They count toward the list's cap (the oldest kept rows go first). Entries +hidden for any other reason (a revoked share, an unfollowed private show) and +every replace from a podcast-seeing caller keep the ordinary delete rule. + Following a private URL uses its normalized current/previous SHA-256 hash as proof, before private-show filtering, and requires a grant on the whole library. Following by path cannot reveal an unfollowed private show. Follow, unfollow diff --git a/docs-users/admin/bridge.md b/docs-users/admin/bridge.md index ca966131..ad6555bb 100644 --- a/docs-users/admin/bridge.md +++ b/docs-users/admin/bridge.md @@ -9,8 +9,8 @@ to your library folders and run heavy work, so the server can stay read-only and safe to put on the internet. A Bridge is optional for listening to your existing books. -[Podcasts](podcasts.md) need one and the Podcasts module. Released player apps -[do not show podcasts yet](podcasts.md#what-is-available-now). +[Podcasts](podcasts.md) need one and the Podcasts module. Player apps from +2.2.0 [show podcasts](podcasts.md#what-is-available-now); older ones don't. :::info[What it does today] The Bridge is new. Today it @@ -270,8 +270,8 @@ no podcast follow, search, import or retention controls yet; see [Podcasts](podcasts.md). The Bridge also fetches a missing episode when a compatible client asks to -play it, ahead of waiting downloads. Released player apps do not show this -yet. See +play it, ahead of waiting downloads. The player (2.2.0 and later) shows +**Getting this episode** meanwhile. See [Playing an episode that isn't downloaded](podcasts.md#playing-an-episode-that-isnt-downloaded). **Cancel** stops a job. A job that is waiting is cancelled at once; one the Bridge diff --git a/docs-users/admin/libraries.md b/docs-users/admin/libraries.md index d30518c2..5e857432 100644 --- a/docs-users/admin/libraries.md +++ b/docs-users/admin/libraries.md @@ -9,8 +9,9 @@ You can have several - for example one for fiction, one for kids - and control per person which ones (or which parts of them) are visible via [shares](sharing.md). -[Podcasts](podcasts.md) need a Bridge and the Podcasts module. Released player -apps [do not show podcasts yet](podcasts.md#what-is-available-now). The book +[Podcasts](podcasts.md) need a Bridge and the Podcasts module. Player apps +from 2.2.0 [show podcasts](podcasts.md#what-is-available-now); older ones +don't. The book detection, book details and export tools described below apply to **Books** libraries. diff --git a/docs-users/admin/podcasts.md b/docs-users/admin/podcasts.md index 72bb76e9..2d8392b5 100644 --- a/docs-users/admin/podcasts.md +++ b/docs-users/admin/podcasts.md @@ -1,6 +1,6 @@ --- title: "Podcasts" -description: "Podcast libraries, episode downloads and per-show retention through the admin API, podcast search and OPML import/export, storage limits, private feeds, and why released player apps do not show podcasts yet." +description: "Podcast libraries, episode downloads and per-show retention through the admin API, podcast search and OPML import/export, storage limits, private feeds, and which player apps show podcasts (2.2.0 and later)." --- ## What is available now @@ -10,13 +10,18 @@ Podcasts need a [Bridge](bridge.md) and the **Podcasts** module, enabled in artwork, downloads episodes and removes eligible older downloads. The server reads what the Bridge saves. -:::info[Player views arrive later] -Released player apps, including current store builds, **do not show podcasts -yet**, even when you sign in as an admin. Podcast player views arrive in a later -release. **Only the admin console lists podcast libraries for now.** There is -no follow-by-URL, search or import screen in the released apps or the console -yet; following, checking feeds, searching podcast directories and OPML import -and export are available through the +:::info[Players 2.2.0 and later show podcasts] +The player shows podcast libraries from **version 2.2.0**: the shows, each +show's episodes, following, a New episodes shelf on Home and playing episodes, +including ones the Bridge downloads when someone presses Play (see +[Podcasts](../listening/podcasts.md) in the listening guide). **Older app +builds don't show podcasts at all**, even to an admin: update the app. The web +player your server serves is the one built into it, so it shows podcasts once +the server is up to date. + +There is no follow-by-URL, search or import screen in the player or the console +yet; following a new feed, checking feeds, searching podcast directories and +OPML import and export are available through the [developer API](/developers/server/api/reference#podcasts). ::: @@ -25,8 +30,8 @@ when an admin requests one through the API, or when a compatible client asks to play an episode whose audio is missing. It also saves supported MP3 chapter markers, and it can [search podcast directories and import or export OPML subscription lists](#finding-shows-and-moving-subscriptions). -External chapter files and transcripts arrive later. The player does not show -on-demand fetching, search or OPML yet. +External chapter files and transcripts arrive later. The player shows +on-demand fetching; it does not offer search or OPML yet. ## Set up a podcast library @@ -142,8 +147,9 @@ full contract. ### Playing an episode that isn't downloaded -When a compatible client asks to play an episode whose audio is missing, the -Bridge fetches it. Requests for the same episode share one download, and +When a compatible client (the player from 2.2.0) asks to play an episode whose +audio is missing, the Bridge fetches it and the player shows **Getting this +episode** until it is ready. Requests for the same episode share one download, and closing a request does not cancel it. Until the audio is saved, the client is told to try again shortly. Playback requests go ahead of waiting admin and scheduled downloads, and an up-to-date Bridge keeps one download slot free @@ -180,7 +186,9 @@ choosing Archive. ## Following and feed checks Through the API, an adult with access to the whole podcast library can follow -a feed by its URL. Following a visible show already in the library is immediate; +a feed by its URL. In the player (2.2.0 and later), anyone but a kid's account +or the demo can follow a show already in the library from its page, while the +module is on and the Bridge is online. Following a visible show already in the library is immediate; a new feed waits for the Bridge to validate and save it. Following also lets the server schedule downloads selected by the show's policy. Each person can have at most five follow requests open at once. Kids and demo accounts cannot @@ -197,6 +205,8 @@ Turning the Podcasts module off stops following, unfollowing and checking, automatic feed checks, downloads and retention scheduling. Work the Bridge already holds may finish. It leaves the indexed shows and files in place; reading them through a compatible client does not need the module or an online Bridge. +In the player, the Follow button disappears (a followed show keeps a Following +badge) and episodes already on the server still play. ## Finding shows and moving subscriptions diff --git a/docs-users/admin/server.md b/docs-users/admin/server.md index ba980b8c..78df9549 100644 --- a/docs-users/admin/server.md +++ b/docs-users/admin/server.md @@ -386,8 +386,8 @@ The modules today: It needs an online Bridge that supports podcasts. With it on, the server can also [search podcast directories (no Bridge needed) and import OPML](podcasts.md#finding-shows-and-moving-subscriptions) through the API; exporting OPML works with the module on or off. - Released player apps - [do not show podcasts yet](podcasts.md#what-is-available-now). Turning the + Player apps from 2.2.0 + [show podcasts](podcasts.md#what-is-available-now); older ones don't. Turning the module off stops follows and feed/download/retention scheduling, keeping the indexed shows and files. Work already held by the Bridge may finish; a compatible client can still read saved episodes without it. diff --git a/docs-users/getting-started/running-a-bridge.md b/docs-users/getting-started/running-a-bridge.md index 127231bb..66919488 100644 --- a/docs-users/getting-started/running-a-bridge.md +++ b/docs-users/getting-started/running-a-bridge.md @@ -23,7 +23,8 @@ With the Podcasts module on, it also fetches missing podcast episodes on play through a compatible client, downloads episodes selected by policy and checks retention by a per-show policy an admin sets through the API for now. See [Podcasts](../admin/podcasts.md#downloads-and-retention) for the default, -protections and the API calls. Released player apps do not show podcasts yet. +protections and the API calls. Player apps from 2.2.0 show podcasts; older +ones don't (see [Podcasts](../listening/podcasts.md)). The book-generation setup uses **two containers**: diff --git a/docs-users/listening/browsing.md b/docs-users/listening/browsing.md index 5cd4ca9a..dbb5c7ce 100644 --- a/docs-users/listening/browsing.md +++ b/docs-users/listening/browsing.md @@ -58,6 +58,7 @@ The home screen is built around what *you* are doing, not just what's on the ser - **This week** - a ring for the books you've finished this year against your yearly goal (or just how many you've finished, if you haven't set one), the hours you've listened in the last seven days, your listening streak, and a small bar for each of the last seven days. - **Continue listening** - the other books you've started, most recent first, each showing how much is left. Tap one to open it, or open its [book menu](#the-book-menu) to resume, mark it finished, and more. **All in progress** opens the Library with just those books. - **Next in your series** - what comes after the books you're on, or just finished, with a line saying why ("After The Way of Kings, which you're 40% into"). It includes the next book even when you don't have it yet: those show as a pale "ghost" cover marked **Not in your library**, and tapping one opens the series page. +- **New episodes** - recent episodes from the podcasts you follow, newest first, when your server has a podcast library. An episode you're listening to shows in the card for what you're on and in Continue listening, but never in the book shelves (Next in your series, the smart shelves, Recently finished). See [Podcasts](podcasts.md#new-episodes-on-home). - **Listening in the house** - when you and others on your server share what you're listening to, what they're on right now or listened to in the last 24 hours. It's off by default; see [Listening in the house](in-the-house.md). - **Smart shelves** - saved filters that keep themselves up to date: **Finish what you started**, **Short listens** (under 5 hours), **"Name" reads** for the narrator you've listened to most lately, and **Added this week**. Tap one to open the Library with that filter on (the narrator's shelf opens their page). They appear once there are at least two worth showing. - **Recently added**, **Favourites** (once you have some) and **Recently finished**, each with a **See all** link. @@ -72,6 +73,8 @@ The **Library** tab is everything on your server, sorted the way you want to loo ![The Library's Books section: the sections and controls along the top, and the cover grid with progress bars](/img/screenshots/web-player/library.png) +A **podcast library** has just two sections, **Shows** and **Folders**: see [Podcasts](podcasts.md#shows-in-the-library). It appears in the app from version 2.2.0, with a microphone beside its name in the picker. + Every section except Folders shows **one library at a time**. If you have more than one library (or more than one server), a **Library** picker sits with the controls, with your libraries grouped under each server's name; with a single library there's nothing to pick, so it doesn't appear. Your choice is remembered on that device. ### Books diff --git a/docs-users/listening/chromecast.md b/docs-users/listening/chromecast.md index 554e01cd..2671a8fd 100644 --- a/docs-users/listening/chromecast.md +++ b/docs-users/listening/chromecast.md @@ -22,6 +22,8 @@ It only appears while a book is loaded, and only when that book's server runs an On an iPhone, if AudioSilo hasn't asked before, the first tap brings up a request to use your **local network**: that's how it finds Cast devices. Allow it, or the app can't see any. +Podcast episodes don't cast yet: the Cast button isn't shown while an [episode](podcasts.md) plays. + ## What you need - **A Cast device on the same network** as your phone or computer. diff --git a/docs-users/listening/end-of-book.md b/docs-users/listening/end-of-book.md index a0adb606..d59c6d43 100644 --- a/docs-users/listening/end-of-book.md +++ b/docs-users/listening/end-of-book.md @@ -20,6 +20,8 @@ When a book reaches its end, AudioSilo marks it **finished** (it leaves your *Co You can open the end credits early from the full player's [three-dot menu](full-player.md#the-three-dot-menu) (**View end credits**, or **Mark as Finished**). Opened early with **View end credits**, the book keeps playing, and nothing starts by itself until it really ends. **Play now** there starts the next book straight away; the book you were in keeps your place, stays unfinished and keeps its download. Only a book that has actually ended (or that you marked finished) is finished when the next one starts. +A [podcast episode](podcasts.md#when-an-episode-ends) never opens the end credits: it leaves Up next, and with auto-play on the next item in Up next starts. + ## What plays next AudioSilo picks the next book the same way everywhere: diff --git a/docs-users/listening/kids-mode.md b/docs-users/listening/kids-mode.md index be4373de..3a24947c 100644 --- a/docs-users/listening/kids-mode.md +++ b/docs-users/listening/kids-mode.md @@ -26,7 +26,7 @@ Kids mode also works for a child who signs in with an account the server's admin Under the child's name on the Household page, with **Kids mode** on, pick: -- **Library**: the one library kids mode shows, when the child's account can see more than one. Left alone, it's the first one the account lists. For a kid in your household it's only there while you share no collections with them (*"Shown when Adam shares no collections"*): while you do, those collections are the shelf. +- **Library**: the one library kids mode shows, when the child's account can see more than one. Podcast libraries aren't offered: kids mode doesn't show podcasts. Left alone, it's the first one the account lists. For a kid in your household it's only there while you share no collections with them (*"Shown when Adam shares no collections"*): while you do, those collections are the shelf. - **Bedtime**: when bedtime starts, **7:30 PM** unless you change it (times are written the way the app's language writes them: 7:30 PM in English, 19:30 in the other languages). If you turn kids mode on while the child is the one listening, the app goes straight into kids mode. To pick the library and bedtime then, hold **Grown-ups**, choose yourself and open the Household page. @@ -42,7 +42,7 @@ It's a good idea to give the grown-ups' profiles a [PIN](profiles.md#a-pin-for-a - **"Hi Leo"** at the top, with the **Grown-ups** button. - **The bedtime card**: *"Bedtime is at 7:30 PM"*, and what happens then. At bedtime it says *"It's bedtime"*, and while the sleep timer runs, how many minutes it has left. - **The story playing now**, in a big player: the cover, the title, the chapter and the minutes left in it, a line showing how far through the chapter they are, and four big buttons: **previous chapter**, **go back 30 seconds**, **play/pause** and **next chapter**. No speed, no sleep menu, no bookmarks. Stories start at normal speed (1×), whatever the device's **Default speed** is, so a grown-up's faster speed never carries over to a child (a story that already has a speed saved for the child keeps it). -- **Your stories**: big cover tiles, the stories they're part-way through first, then every other book, finished ones too (children listen again). Tap one and it plays right there. For a kid in your household, the stories are the books in the collections you share with them, grouped under each collection's name when there are several; while those collections have no stories yet, it says *"Ask a grown-up to add stories."* Anyone else, and a kid you haven't shared a collection with yet, sees the one library chosen for them (if their account can see one). +- **Your stories**: big cover tiles, the stories they're part-way through first, then every other book, finished ones too (children listen again). Tap one and it plays right there. For a kid in your household, the stories are the books in the collections you share with them, grouped under each collection's name when there are several (podcast episodes in a shared collection are left out); while those collections have no stories yet, it says *"Ask a grown-up to add stories."* Anyone else, and a kid you haven't shared a collection with yet, sees the one library chosen for them (if their account can see one). ![Kids mode on a phone: the bedtime card, the four big player buttons and the story tiles in two columns](/img/screenshots/web-player/phone-kids.png) diff --git a/docs-users/listening/mobile-apps.md b/docs-users/listening/mobile-apps.md index 1e443abd..d475d075 100644 --- a/docs-users/listening/mobile-apps.md +++ b/docs-users/listening/mobile-apps.md @@ -14,6 +14,7 @@ AudioSilo has native apps for **iOS** and **Android**. They're the same player y - **CarPlay**: Apple has approved AudioSilo for CarPlay audio, but the App Store version (2.0.0) doesn't include it yet; it arrives with a later update. Until then, AudioSilo doesn't appear in CarPlay. - **Android Auto**: built into the Android app; see [In the car](in-the-car.md#android-auto) for getting it onto the car's screen. - **iPhone widgets and the sleep timer Live Activity**: not in the App Store version yet; they arrive with a later update. +- **Podcasts**: need **version 2.2.0** or later of the app. An older version doesn't show your server's podcast libraries at all, so update it if your server has podcasts and you don't see them. See [Podcasts](podcasts.md). And you don't need the app to listen on your phone: the **web player works great on mobile** and can be [installed to your home screen](offline-downloads.md), downloads included. diff --git a/docs-users/listening/offline-downloads.md b/docs-users/listening/offline-downloads.md index c885019e..ed898f65 100644 --- a/docs-users/listening/offline-downloads.md +++ b/docs-users/listening/offline-downloads.md @@ -17,6 +17,10 @@ If a download stops (the connection dropped, the phone slept at the wrong moment Audiobooks are big - often hundreds of megabytes each. Download on Wi‑Fi when you can, and keep an eye on the storage card on the Downloads page. ::: +### Podcast episodes + +[Podcast episodes](podcasts.md) can't be downloaded to your device yet: they stream from your server, and there's no download button for them. **Download automatically** and **Keep the next books ready** skip them too. + ## The Downloads page The **Downloads** tab is everything stored on the device you're using: diff --git a/docs-users/listening/podcasts.md b/docs-users/listening/podcasts.md new file mode 100644 index 00000000..d35745ce --- /dev/null +++ b/docs-users/listening/podcasts.md @@ -0,0 +1,171 @@ +--- +title: Podcasts +description: "Listening to podcasts in the player: shows in the Library, a show's page and its episode order, following a show, New episodes on Home, playing an episode the server hasn't downloaded yet, private feeds, kids, and what episodes don't do yet." +--- + +When your server has a podcast library, the player shows its podcasts beside your books: the +shows in the Library, each show's episodes, a **New episodes** shelf on Home for the shows you +follow, and episodes that play like any book. Podcasts need the **AudioSilo app 2.2.0 or later** +(the web player comes with your server and is updated with it). An older app doesn't +show podcasts at all; update it from the App Store or Google Play. + +Your admin sets podcasts up on the server: see [Podcasts](../admin/podcasts.md) in the admin +guide. Adding a new show, searching podcast directories and importing a list from another podcast +app aren't in the player yet; your admin can add shows for you. + +## Shows in the Library + +Pick a podcast library in the Library's **Library** picker (a podcast library has a microphone +beside its name). A podcast library has two sections, **Shows** and **Folders**; the book +sections (Books, Authors, Series, Narrators, Collections) belong to book libraries. + +![The Library's Shows section: the All and Following filters and a grid of show covers](/img/screenshots/web-player/library-shows.png) + +**Shows** is a grid of every show you can see in that library, in title order. Under each cover +is the show's title, then **New episode** with how long ago it came out when the show has had +one in the last 14 days, else who makes the show. A +**Private feed** badge marks a [private show](#private-feeds). The chips at the top switch +between **All** shows and the ones you're **Following**, with a count on each. + +A library with no shows says **No shows yet**: shows appear once someone follows them on your +server. Tap a show to open its page. + +## A show's page + +![A show's page: its cover, title, the Following badge and the episodes, newest first](/img/screenshots/web-player/podcast-show.png) + +The top of the page has the show's cover, title and maker, its description (**Show more** +opens the rest), how many episodes it has, the **Private feed** badge when it is one, and the +[Follow button](#following-a-show). **Last check failed** means the server couldn't read the +show's feed the last time it looked; the episodes it already has still play. + +**Episode order.** A show that is told in order (a serial) lists its **oldest episode first**, so +you start at the beginning; every other show lists its **newest first**. Switch with **Newest +first** / **Oldest first** at the top of the list. The list keeps loading as you scroll. + +Each episode row has: + +- the **title**, then the date it came out, its length, and its season and episode number + (**S2 E5**, or just **E5**); +- a **Trailer**, **Bonus** or **Explicit** badge when the show marks it so; +- a progress bar once you've started it, or **Finished**; +- where its audio is: **Ready on your server**, **Plays on demand** (the server downloads it + when you play it, see [below](#playing-an-episode)), **Getting it** with how far it has got, + **Couldn't get it**, or **Not available now**. The last two add a line saying why, such as + *"Your server can't download episodes right now."* (the same reasons as the + [table below](#playing-an-episode)); +- one button: **Play** (or **Resume** once you've started), or **Try again** after the server + couldn't get it. While an episode isn't available, **Play** is greyed out; +- a **...** menu with **Add to Up next** and **Mark as finished**. + +Tap the row itself to open the episode's own page: its cover, title, the show (tap it to go back +to the show), date, length and badges, the play button, its description and, when the episode +file has them, its chapters (**Chapters from the episode file**). When the episode can't be +played right now, the reason sits beside the greyed-out button. An episode page has no Who's who +or Story so far: those are for books. + +An episode that has left the show's feed stays listed while its file is still on the server, +marked **No longer in the feed**. Once the file is gone, the episode isn't listed any more. + +![A show's page on a phone](/img/screenshots/web-player/phone-podcast-show.png) + +## Following a show + +**Follow** on a show's page adds it to the shows you follow; press it again (it reads +**Following**) to unfollow. Following a show is what puts its new episodes on your +[Home](#new-episodes-on-home) and tells the server to keep its episodes coming. Everyone who +follows a show shares the same downloaded episodes. + +The button is there only when your server can fetch podcasts right now: the admin has turned +podcasts on and the server can download episodes. Otherwise a show you follow just shows a +**Following** badge, and you can still play everything that's there. Kids' accounts and the demo +can't follow shows. + +Unfollowing a [private feed](#private-feeds) asks first: the show disappears from your Library, +and you'd need the feed's address to follow it again. + +## New episodes on Home + +![Home's New episodes shelf: recent episodes from the shows you follow](/img/screenshots/web-player/home-new-episodes.png) + +**New episodes**, under **Next in your series** on Home, gathers recent episodes from the shows +you follow: ones that came out in the last 14 days, newest first, with the show's name and how +long ago under each. It leaves out trailers, episodes gone from the feed, and any episode you've +already started (those are in **Continue listening**) or finished. **All shows** opens the +Library's Shows. If you follow nothing, or nothing new came out, the shelf isn't shown. + +Podcasts stay off your book shelves: episodes never appear in **Next in your series**, **Recently +finished**, the smart shelves, **Recently added**, book search or the author and series pages. An +episode you're listening to does lead Home like a book: in the card for what you're on (*"40% +through the episode"*), in **Continue listening**, and in **Favourites** if you add it there. + +## Playing an episode + +An episode marked **Ready on your server** plays at once, like a book, and the player keeps your +place in it the same way. + +An episode marked **Plays on demand** isn't on your server yet. Press **Play** and the server +downloads it first: the player shows **Getting this episode** with the episode's cover and *"Your +server is downloading it. It starts as soon as it's ready."*; once the download is under way that +line shows how much has arrived (*"12 MB of 40 MB"*, or *"12 MB so far"* while the full size +isn't known). **Waiting for its turn** in its place means it is queued behind other downloads. +When the episode is ready it starts by itself. In the player, how far you are reads *"40% of the +episode"*. + +![The full player while the server gets an episode: Getting this episode, with the download's progress](/img/screenshots/web-player/episode-fetching.png) + +- With the full player closed, a compact strip shows the same progress: above the player bar on + a computer (or a wide tablet), and in the mini player on a phone. + **Whatever you were listening to keeps playing** until the episode is ready; then the episode + takes over. +- If the server can't get it, the strip shows the reason in place of the progress. +- **Cancel** stops waiting in the player. The server keeps downloading, so the episode will be + **Ready on your server** next time. +- Playing something else while you wait also stops waiting for it. +- Leave the app and come back: it checks again straight away. + +If the server can't get the episode, the player says why, with **Try again**: + +| Message | What it means | +|---|---| +| **Couldn't get this episode** | The download failed (the show's host didn't answer, for instance). **Try again** asks the server to download it again. | +| **This episode can't be fetched now** | The server can't download episodes right now, and the line under it says which: *"Podcasts are turned off on this server."*, *"Your server can't download episodes right now."* (the part of your server that downloads them is offline, or the server is too old to fetch episodes on demand), or *"Your server can't save episodes to this library."* Ask your admin. | +| **Five episodes are already on their way. Try again once one is ready.** | Each person can have at most five episodes downloading at once. Wait for one to finish. | +| **This episode is too large for your server to keep.** | The file is over the server's size limit. | +| **This episode changed in its feed. Open the show to find it again.** | The show replaced the episode; open the show's page for the new one. | +| **The demo can only play episodes already on the server.** | The demo account plays only episodes marked Ready. | + +The player never asks the server again on its own after a failure: only **Try again** does. + +### When the audio changes + +Some shows replace an episode's audio after it comes out (to change the ads, say). When that +happens to an episode you've started, its page says **The audio changed**: *"This episode's audio +changed since you started (ads can differ). Your place is kept as it was."* Your place, bookmarks +and finished mark stay exactly where they were, so you may land a little early or late. + +### When an episode ends + +An episode never opens the [end credits](end-of-book.md). It leaves your Up next, and with +**auto-play** on (and the sleep timer not running) the next item in [Up next](up-next.md) +starts; otherwise playback stops. An episode never plays on into a series or folder. + +## Private feeds + +Some shows are members-only: their feed address works like a password. A **Private feed** badge +marks them. Only the people who follow a private show can see it, admins included, so a library +can look empty to you while other people have shows in it. + +## Kids + +Kids mode doesn't show podcasts at all: no podcast libraries, and no episodes, even ones in a +collection you share with a child. A kid's account can't follow shows. See +[Kids mode](kids-mode.md). + +## Not for episodes yet + +- **Offline downloads.** Episodes can't be downloaded to your device, and automatic downloads and + keeping the next books ready skip them. They stream from your server. +- **Casting.** The Cast button doesn't appear while an episode plays. +- **Finding new shows.** Following a new feed by its address, searching podcast directories and + importing or exporting a list of shows aren't in the player yet. diff --git a/docs-users/listening/up-next.md b/docs-users/listening/up-next.md index 92a9a3c6..fe59872e 100644 --- a/docs-users/listening/up-next.md +++ b/docs-users/listening/up-next.md @@ -5,6 +5,8 @@ description: "Lining up the books you want to hear next: opening Up next on a co **Up next** is your queue: the books you'd like to hear after the one you're on, in the order you want them. It's kept on your server, so the same list follows you to every device you listen on. +[Podcast episodes](podcasts.md) can go in Up next too, from an episode's **...** menu. An app older than 2.2.0 doesn't show them in the list, but changing the list there keeps them on it. + :::note Up next needs a recent AudioSilo server. On an older server the Up next buttons don't appear at all, and everything else works as before. ::: diff --git a/docs-users/troubleshooting.md b/docs-users/troubleshooting.md index 98368363..6cdd3f50 100644 --- a/docs-users/troubleshooting.md +++ b/docs-users/troubleshooting.md @@ -210,6 +210,26 @@ server (spotty Wi‑Fi, mobile data dead zones). For commutes and travel, [download the book to your device](./listening/offline-downloads.md) and play it locally. +### A podcast episode won't start + +An episode marked **Plays on demand** isn't on your server yet: pressing +**Play** asks the server to download it first, and the player shows **Getting +this episode** until it's ready. A long episode can take a minute or two. If it +stops with a message instead, [Playing an episode](./listening/podcasts.md#playing-an-episode) +says what each one means. For **This episode can't be fetched now**, ask your +admin to check that **Podcasts** is on in **Server > Settings > Modules** and +that the [Bridge](./admin/bridge.md) is online and can write to the podcast +library. The same reason shows beside a greyed-out **Play** on the episode's +row and page before you press it: *"Your server can't download episodes right +now."* also appears when the server is too old to fetch episodes on demand. + +If you don't see your server's podcasts at all, check your app's version first: +an app older than 2.2.0 shows no podcasts, so update it. If it is up to date, +ask your admin whether you have access to the podcast library (and whether the +server itself is up to date). Kids mode never shows podcasts, and a +[private show](./listening/podcasts.md#private-feeds) appears only to the +people who follow it. + ### My book restarted from the beginning It shouldn't lose your place - the player has several protections stacked up: diff --git a/screenshots/README.md b/screenshots/README.md index 5da6ac42..03d7db19 100644 --- a/screenshots/README.md +++ b/screenshots/README.md @@ -329,6 +329,34 @@ signs in there, her book is loaded and paused, and the shot is clipped to the fu player's transport and action row. Nothing leaves the machine except the Cast sender script the button loads from `www.gstatic.com` (offline, the button still shows). +The podcast shots (`web-player/library-shows.png`, `podcast-show.png`, +`home-new-episodes.png`, `episode-fetching.png`, `phone-podcast-show.png`) need a +podcast library, which only a Bridge writes, and the demo account sees only the demo +library. No Bridge runs: `run.sh` (section 2c) has `seed-podcasts.mjs` write what a +Bridge leaves in a podcast library root into `.cache/podcasts`, rebuilt every run from +`fixtures/podcasts.json` (three made-up shows in own words: The Lantern Archive, a +serial with a trailer and S1 E1-E4; Field Notes at Dawn, a daily show of eight +episodes; The Slow Kitchen, weekly): per show a `feed.json` (v1) dated back from the +run, so the newest episodes are always within Home's 14 days, a `cover.jpg` (an ffmpeg +gradient with the name) and a short silent mp3 per episode, its fetch pin (generation +1, sha256, bytes, duration) read from that file. The serial's newest episode and the +daily show's have no file and no pin, so they are not on the server. Before the +household, `capture-web.mjs` turns the Podcasts module on (`PATCH /admin/settings`), adds +the folder as a `podcasts` library through the admin API, waits for its scan and, as the +admin, follows the two shows marked `follow` by path (a follow by path needs only the +module, not a Bridge), every call naming a 2.2.0 player in `X-AudioSilo-Client` (older +players never see a podcast library). The admin then signs in to a fresh browser by +password: Home scrolled to New episodes, Library > Shows chosen in the library picker, +The Lantern Archive's page (a read-only Following badge: the Follow button needs an +online Bridge), the same page at phone width, and, at phone width, Play on the daily +show's newest episode. Its fetch is the one thing stood in for: that page's requests for +that episode only are routed (Playwright), the stream probe answering the server's own +503 `podcast_fetching` and every status read a fetch a little further on, never finished, +so the full player shows Getting this episode with its progress. Cancel ends it. Then the +library is deleted, the module turned off, the player session signed out and this pass's +audit log and event feed rows deleted (sqlite3), so neither the connect shots nor the +admin console's show any of it. + The connect shots run last, in a fresh browser with nothing stored: `web-player/connect.png` types the server's own address and presses Continue (the probe card), then the run signs in as the **admin** with a username and password (a real diff --git a/screenshots/capture-web.mjs b/screenshots/capture-web.mjs index 7eb49870..e1ff4f36 100644 --- a/screenshots/capture-web.mjs +++ b/screenshots/capture-web.mjs @@ -9,6 +9,9 @@ // records (a place, a finish and rating, bookmarks, notes, history, a // sleep-timer drift-off) through the API, // 2. capture desktop, tablet and phone profiles into static/img/screenshots/web-player/, +// 2b. podcasts: run.sh's seeded shows added as a podcast library through the admin API +// (the Podcasts module on, two shows followed by path), shot as the admin in a fresh +// browser (the episode fetch stood in for by routes), then removed again, // 3. the household: three accounts made through the admin API (ADMIN_PASSWORD), and, // as one of them, a collection and the kid household member through the player's // own API (the grown-up member through the add dialog, or the API when only the @@ -26,6 +29,7 @@ import { CACHE, DAY, DESKTOP_CONTEXT, + FIXTURES, HOUR, MIN, addHousehold, @@ -143,15 +147,21 @@ const PRIMARY_PLAY = /^(start listening|resume( chapter \d+)?|listen again|liste // a bare web export, so it isn't read from there). const FELL_ASLEEP_NOTE = 'Fell asleep'; -// The demo user's token (the web player keeps it in localStorage under -// audiosilo.token.), for provisioning through the API. -async function demoToken(page) { - const token = await page.evaluate(() => { - const key = Object.keys(localStorage).find((k) => k.startsWith('audiosilo.token.')); - return key ? localStorage.getItem(key) : null; +// The signed-in player's connection id and token (the web player keeps the token in +// localStorage under audiosilo.token.), for provisioning through the API. +async function playerSession(page) { + const session = await page.evaluate(() => { + const prefix = 'audiosilo.token.'; + const key = Object.keys(localStorage).find((k) => k.startsWith(prefix)); + return key ? {cid: key.slice(prefix.length), token: localStorage.getItem(key)} : null; }); - if (!token) throw new Error('no demo token in localStorage'); - return token; + if (!session?.token) throw new Error('no player token in localStorage'); + return session; +} + +// The demo user's token. +async function demoToken(page) { + return (await playerSession(page)).token; } // The seeded library and a finder for its books by title substring. @@ -966,6 +976,230 @@ await capturePhone('phone', {width: 430, height: 932}, { settings: 'web-player/phone-settings.png', }); +// ── Podcasts: Shows, a show page, New episodes, an episode being fetched ──── +// A podcast library is only written by a Bridge, and the demo account sees only the +// demo library, so this pass has its own state (README.md, the podcast shots): run.sh +// seeds three made-up shows into PODCASTS_DIR (feed.json, cover and silent episodes, two +// of them not on the server); here the admin turns the Podcasts module on (a follow by +// path needs only that, no Bridge), adds the folder as a podcast library, follows the +// fixture's `follow` shows and signs in to a fresh browser by password. The player sees +// a podcast library only as 2.2.0 or later (`X-AudioSilo-Client`, which the served +// player sends), and so does every API call here. Afterwards the library is deleted, +// the module turned off and this pass's audit and event rows taken out again (sqlite3), +// so no later shot (the connect shots, the admin console's) shows any of it. +const PODCAST_SHOTS = { + shows: 'web-player/library-shows.png', + show: 'web-player/podcast-show.png', + home: 'web-player/home-new-episodes.png', + fetching: 'web-player/episode-fetching.png', + phoneShow: 'web-player/phone-podcast-show.png', +}; +const PODCASTS_DIR = path.resolve(process.env.PODCASTS_DIR || path.join(CACHE, 'podcasts')); +const PODCAST_LIBRARY = 'Podcasts'; +// A player that sees podcast libraries (the server's PodcastPlayerMinVersion). +const PODCAST_CLIENT = {'x-audiosilo-client': 'AudioSilo/2.2.0 (web)'}; +// The show on the show page shots (a serial, so oldest first with its trailer) and the +// show whose newest episode (not on the server) the fetching shot starts. +const PODCAST_SHOW = 'The Lantern Archive'; +const PODCAST_FETCH_SHOW = 'Field Notes at Dawn'; +const PHONE = {width: 430, height: 932}; + +// The fixture's shows, each with its feed.json as run.sh wrote it. +function podcastFixture() { + const {shows} = JSON.parse(readFileSync(path.join(FIXTURES, 'podcasts.json'), 'utf8')); + return shows.map((s) => ({...s, feed: JSON.parse(readFileSync(path.join(PODCASTS_DIR, s.folder, 'feed.json'), 'utf8'))})); +} + +// The newest id in a server feed table (sqlite3), so teardown removes only this pass's rows. +const lastId = (table) => Number(sqlite(`SELECT COALESCE(MAX(id), 0) FROM ${table};`).trim() || 0); + +async function provisionPodcasts(pod) { + if (!ADMIN_PASSWORD) throw new Error('ADMIN_PASSWORD not set: no podcast library to add'); + if (!existsSync(PODCASTS_DIR)) throw new Error(`no seeded shows at ${PODCASTS_DIR} (run.sh section 2c)`); + pod.shows = podcastFixture(); + pod.marks = {audit: lastId('audit_events'), events: lastId('server_events')}; + const {token} = await api(null, 'POST', '/auth/login', {username: 'admin', password: ADMIN_PASSWORD}, PODCAST_CLIENT); + pod.admin = token; + const call = (method, p, body) => api(token, method, p, body, PODCAST_CLIENT); + await call('PATCH', '/admin/settings', {modules: {podcasts: true}}); + pod.moduleOn = true; + const created = await call('POST', '/admin/libraries', {name: PODCAST_LIBRARY, root: PODCASTS_DIR, kind: 'podcasts'}); + pod.library = created?.library ?? created; + // The scan the create starts: done once the shows are listed. + const until = Date.now() + 60000; + let shows = []; + while (Date.now() < until) { + const libs = (await call('GET', '/admin/libraries'))?.libraries ?? []; + const lib = libs.find((l) => l.id === pod.library.id); + shows = (await call('GET', `/libraries/${pod.library.id}/podcasts`))?.shows ?? []; + if (!lib?.scan?.running && !lib?.scan?.queued && shows.length >= pod.shows.length) break; + await sleep(500); + } + if (shows.length < pod.shows.length) throw new Error(`the podcast scan listed ${shows.length} of ${pod.shows.length} shows`); + for (const s of pod.shows.filter((x) => x.follow)) { + await call('POST', '/podcasts/follow', {library_id: pod.library.id, path: s.folder}); + } + console.log(` ✓ podcast library "${PODCAST_LIBRARY}": ${shows.length} shows, following ${pod.shows.filter((x) => x.follow).map((x) => x.title).join(' and ')}`); +} + +async function unprovisionPodcasts(pod) { + const call = (method, p, body) => api(pod.admin, method, p, body, PODCAST_CLIENT); + if (pod.library) await call('DELETE', `/admin/libraries/${pod.library.id}`).catch((e) => console.log(` ! podcast library left: ${e.message}`)); + if (pod.moduleOn) await call('PATCH', '/admin/settings', {modules: {podcasts: false}}).catch((e) => console.log(` ! Podcasts module left on: ${e.message}`)); + await api(pod.admin, 'POST', '/auth/logout').catch(() => {}); + try { + sqlite(`DELETE FROM audit_events WHERE id > ${pod.marks.audit};\nDELETE FROM server_events WHERE id > ${pod.marks.events};`); + } catch (e) { + console.log(` ! this pass's audit and event rows left in the server's feeds: ${e.message}`); + } + console.log(' ✓ podcast library removed again'); +} + +// The fetching shot's answers for `relPath` (the server's own 503 and status shapes, +// internal/podcast/testdata/phase2c2/wire-2b.json, with a real-looking size): the stream +// probe gets the 503 the server sends while a Bridge fetches, and every status read a +// fetch a little further on, never finished, so the panel stays on its way. Routed on this +// page only, and only for that episode; undone by the returned function. +async function routeEpisodeFetch(page, libraryId, relPath) { + const total = 41_873_408; + let received = 13_107_200; + const status = () => { + received = Math.min(Math.round(total * 0.72), received + 1_572_864); + return {state: 'fetching', request_id: 81, bytes_received: received, bytes_total: total, retry_after_seconds: 2, error: null}; + }; + const forEpisode = (route) => new URL(route.request().url()).searchParams.get('path') === relPath; + const stream = new RegExp(`/api/v1/libraries/${libraryId}/stream\\?`); + const fetchStatus = new RegExp(`/api/v1/libraries/${libraryId}/podcasts/fetch\\?`); + const onStream = (route) => { + if (!forEpisode(route)) return route.continue(); + return route.fulfill({ + status: 503, + headers: {'retry-after': '2', 'cache-control': 'no-store'}, + contentType: 'application/json', + body: JSON.stringify({error: 'Episode is being fetched.', code: 'podcast_fetching', fetch: status()}), + }); + }; + const onStatus = (route) => { + if (!forEpisode(route) || route.request().method() !== 'GET') return route.continue(); + return route.fulfill({status: 200, contentType: 'application/json', body: JSON.stringify({fetch: status()})}); + }; + await page.route(stream, onStream); + await page.route(fetchStatus, onStatus); + return async () => { + await page.unroute(stream, onStream); + await page.unroute(fetchStatus, onStatus); + }; +} + +async function capturePodcasts(pod) { + const origin = new URL(BASE).origin; + const ctx = await newCaptureContext(); + let session = null; + try { + const page = await ctx.newPage(); + await page.goto(BASE + 'connect', {waitUntil: 'networkidle', timeout: 45000}); + await probeServer(page, origin); + await signInWithPassword(page, 'admin', ADMIN_PASSWORD); + await tid(page, 'ready-screen').waitFor({timeout: 15000}); + const player = await playerSession(page); + session = player.token; + const {cid} = player; + await firstVisible(page.getByRole('button', {name: 'Start listening'})).click({timeout: 8000}); + await tid(page, 'top-bar-(home)').waitFor({timeout: 15000}); + // The episode the fetching shot starts (not on the server; also the newest on Home). + const fetchEpisode = pod.shows.find((s) => s.folder === PODCAST_FETCH_SHOW)?.feed.episodes.find((e) => !e.fetch); + if (!fetchEpisode) throw new Error(`no episode of ${PODCAST_FETCH_SHOW} that is not on the server`); + // Let the covers settle, then shoot `file` with the pointer out of the way. + const settleShoot = async (p, file, ms = 1500) => { + await sleep(ms); + await p.mouse.move(0, 0); + await shoot(p, file); + }; + + if (wanted(PODCAST_SHOTS.home)) { + await step('podcasts: home', async () => { + await tid(page, 'top-bar-(home)').click({timeout: 8000}); + const heading = firstVisible(page.getByText('New episodes', {exact: true})); + await heading.waitFor({timeout: 30000}); + await firstVisible(page.getByText(fetchEpisode.title)).waitFor({timeout: 15000}); + await heading.evaluate((el) => el.scrollIntoView({block: 'start'})); + await page.mouse.wheel(0, -120); + await settleShoot(page, PODCAST_SHOTS.home); + }); + } + + if (wanted(PODCAST_SHOTS.shows, PODCAST_SHOTS.show)) { + await step('podcasts: shows', async () => { + await tid(page, 'top-bar-(library)').click({timeout: 8000}); + await sleep(1500); + await firstVisible(page.getByRole('combobox', {name: 'Library'}).or(page.getByRole('button', {name: 'Library'}))).click({timeout: 8000}); + await firstVisible(page.getByRole('option', {name: PODCAST_LIBRARY})).click({timeout: 8000}); + await firstVisible(page.getByTestId('show-tile')).waitFor({timeout: 15000}); + await settleShoot(page, PODCAST_SHOTS.shows); + if (!wanted(PODCAST_SHOTS.show)) return; + await firstVisible(page.getByTestId('show-tile').filter({hasText: PODCAST_SHOW})).click({timeout: 8000}); + await tid(page, 'podcast-show-hero').waitFor({timeout: 15000}); + await tid(page, 'podcast-following-badge').waitFor({timeout: 15000}); + await firstVisible(page.getByTestId('episode-row')).waitFor({timeout: 15000}); + await settleShoot(page, PODCAST_SHOTS.show); + }); + } + + // The phone shots, on this browser's storage (the same signed-in player). + if (wanted(PODCAST_SHOTS.phoneShow, PODCAST_SHOTS.fetching)) { + const phone = await sameDevice(ctx, {viewport: PHONE}); + try { + const p = await phone.newPage(); + const showUrl = (folder) => + `${BASE}podcast?connection=${encodeURIComponent(cid)}&library=${pod.library.id}&show=${encodeURIComponent(folder)}`; + if (wanted(PODCAST_SHOTS.phoneShow)) { + await step('podcasts: phone show', async () => { + await p.goto(showUrl(PODCAST_SHOW), {waitUntil: 'networkidle', timeout: 45000}); + await tid(p, 'podcast-following-badge').waitFor({timeout: 15000}); + await firstVisible(p.getByTestId('episode-row')).waitFor({timeout: 15000}); + await settleShoot(p, PODCAST_SHOTS.phoneShow); + }); + } + if (wanted(PODCAST_SHOTS.fetching)) { + await step('podcasts: episode fetching', async () => { + const unroute = await routeEpisodeFetch(p, pod.library.id, fetchEpisode.path); + try { + await p.goto(showUrl(PODCAST_FETCH_SHOW), {waitUntil: 'networkidle', timeout: 45000}); + await firstVisible(p.getByRole('button', {name: new RegExp(`^${escapeRe(fetchEpisode.title)}`)})).click({timeout: 15000}); + await tid(p, 'episode-page').waitFor({timeout: 15000}); + await sleep(SETTLE_MS); + await tid(p, 'episode-page-play').click({timeout: 8000}); + await tid(p, 'episode-fetch-panel').waitFor({timeout: 15000}); + await firstVisible(p.getByTestId('episode-fetch-line').getByText(/ of /)).waitFor({timeout: 15000}); + await settleShoot(p, PODCAST_SHOTS.fetching, 2500); // a status read or two, and the cover + await tid(p, 'episode-fetch-cancel').click({timeout: 8000}).catch(() => {}); + } finally { + await unroute(); + } + }); + } + } finally { + await phone.close(); + } + } + } finally { + // Sign this player session out again, whatever happened above. + if (session) await api(session, 'POST', '/auth/logout').catch(() => {}); + await ctx.close(); + } +} + +if (wanted(...Object.values(PODCAST_SHOTS))) { + console.log('== podcasts =='); + const pod = {admin: null, library: null, moduleOn: false, shows: [], marks: null}; + try { + await step('provision podcasts', () => provisionPodcasts(pod)); + if (pod.library) await step('podcasts', () => capturePodcasts(pod)); + } finally { + if (pod.admin) await step('remove podcasts', () => unprovisionPodcasts(pod)); + } +} + // The connect start screen, done once the probe card shows what it found. A served // player that asks its own server on open has no address field; one that has the field // gets the server's address typed and Continue pressed. diff --git a/screenshots/fixtures/podcasts.json b/screenshots/fixtures/podcasts.json new file mode 100644 index 00000000..542781e9 --- /dev/null +++ b/screenshots/fixtures/podcasts.json @@ -0,0 +1,54 @@ +{ + "_comment": "Made-up podcast shows for the podcast shots (run.sh section 2c, seed-podcasts.mjs). Each becomes //feed.json (feed.json v1) plus cover.jpg; every episode with a file gets a short silent mp3 and a fetch pin computed from it. `days_ago` is counted back from the run, `minutes` is the audio's length, `absent` leaves the file out (no fetch pin: not on the server), `follow` is followed by the listener in capture-web.mjs. All names and words here are invented.", + "shows": [ + { + "folder": "The Lantern Archive", + "title": "The Lantern Archive", + "author": "Saltmarsh Audio Theatre", + "description": "A new keeper inherits a lighthouse full of reel-to-reel tapes that nobody admits to recording. An audio mystery in weekly parts, best heard in order.", + "type": "serial", + "colors": ["0x16323f", "0x2f6f73"], + "follow": true, + "episodes": [ + {"title": "Trailer: Before the Light", "episode_type": "trailer", "days_ago": 41, "minutes": 2, "description": "Two minutes from the keeper's first night, and a voice on a tape that should be blank."}, + {"title": "The Inventory", "season": 1, "episode": 1, "days_ago": 34, "minutes": 24, "description": "Mara counts the tapes, the lamps and the logbooks, and finds one more of each than the handover list says."}, + {"title": "Reel Seventeen", "season": 1, "episode": 2, "days_ago": 27, "minutes": 27, "description": "A reel labelled with next week's date plays a storm that hasn't happened yet."}, + {"title": "Fog Signal", "season": 1, "episode": 3, "days_ago": 13, "minutes": 26, "description": "The horn sounds on a clear night, and a fishing boat answers in a code nobody has used for fifty years."}, + {"title": "The Keeper's Hand", "season": 1, "episode": 4, "days_ago": 6, "minutes": 29, "absent": true, "description": "The handwriting in the oldest logbook is Mara's own."} + ] + }, + { + "folder": "Field Notes at Dawn", + "title": "Field Notes at Dawn", + "author": "Hedgerow Radio", + "description": "Five quiet minutes from the countryside every morning: the weather, the birds, and one small thing worth noticing on the walk.", + "type": "episodic", + "colors": ["0x6b4a12", "0xd19a3a"], + "follow": true, + "episodes": [ + {"title": "The wren on the gatepost", "days_ago": 0.3, "minutes": 6, "absent": true, "description": "A very small bird with a very loud song, and why it sings hardest in the cold."}, + {"title": "Frost on the cabbages", "days_ago": 1.3, "minutes": 5, "description": "The first hard frost, and the allotment holders who were ready for it."}, + {"title": "A heron at the weir", "days_ago": 2.3, "minutes": 7, "description": "Patience, as practised by a grey heron standing very still in fast water."}, + {"title": "Rooks going home", "days_ago": 3.3, "minutes": 5, "description": "Hundreds of rooks crossing the valley at dusk, all talking at once."}, + {"title": "The last swallows", "days_ago": 4.3, "minutes": 6, "description": "The wires by the barn are empty this morning. Where they go, and how they find their way back."}, + {"title": "Fairy rings", "days_ago": 5.3, "minutes": 5, "description": "A circle of mushrooms on the cricket pitch, and the fungus under it that is older than the club."}, + {"title": "The beekeeper's last check", "days_ago": 6.3, "minutes": 6, "description": "Closing the hives for winter with a beekeeper who talks to every colony."}, + {"title": "Wind in the poplars", "days_ago": 7.3, "minutes": 5, "description": "Why one row of trees sounds like rain when there isn't a cloud in the sky."} + ] + }, + { + "folder": "The Slow Kitchen", + "title": "The Slow Kitchen", + "author": "Larder & Lamp", + "description": "Long recipes for short evenings: one dish a week that mostly cooks itself, and what to do while it does.", + "type": "episodic", + "colors": ["0x5a1f24", "0xb5523b"], + "follow": false, + "episodes": [ + {"title": "A stew for the first cold week", "days_ago": 9, "minutes": 18, "description": "Beans, a ham hock and four hours of not lifting the lid."}, + {"title": "Bread that waits for you", "days_ago": 16, "minutes": 21, "description": "An overnight loaf, mixed in five minutes before bed."}, + {"title": "The patience of onions", "days_ago": 23, "minutes": 16, "description": "Caramelising slowly, properly, and the soup it becomes."} + ] + } + ] +} diff --git a/screenshots/lib.mjs b/screenshots/lib.mjs index 3c1e4457..b5d4d98d 100644 --- a/screenshots/lib.mjs +++ b/screenshots/lib.mjs @@ -9,6 +9,8 @@ const here = path.dirname(fileURLToPath(import.meta.url)); // All captures land directly in the docs static dir. export const OUT_ROOT = path.resolve(here, '../static/img/screenshots'); export const CACHE = path.resolve(here, '.cache'); +// Fixture files the capture and seed scripts read (fixtures/*.json). +export const FIXTURES = path.resolve(here, 'fixtures'); export const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); diff --git a/screenshots/manifest.mjs b/screenshots/manifest.mjs index 9fbbdd76..617138e6 100644 --- a/screenshots/manifest.mjs +++ b/screenshots/manifest.mjs @@ -70,6 +70,13 @@ export const SHOTS = [ {file: 'web-player/home-house.png', capture: 'web', title: 'Web player - Listening in the house', hint: 'Ellen\'s Home scrolled to Listening in the house: jamie listening now and rosa paused (both share), the " · shared by choice" line and Sharing settings'}, {file: 'web-player/book-in-the-house.png', capture: 'web', title: 'Web player - In the house card', hint: "the page of the book jamie is listening to, opened from that house card: the aside's About and In the house cards"}, {file: 'web-player/player-cast.png', capture: 'web', title: 'Web player - Cast button', hint: 'the full player (book paused), clipped to the transport and the action row ending with the Cast button: an https page through a local proxy in Chromium, server with cast'}, + // Podcasts (capture-web.mjs's podcast pass: run.sh seeds three made-up shows, the pass adds + // them as a podcast library, the admin follows two by path, no Bridge; see README.md) + {file: 'web-player/library-shows.png', capture: 'web', title: 'Web player - Shows', hint: 'Library > Shows on the podcast library (picked in the library picker): Shows and Folders in the sub-nav, the All / Following chips (3 and 2) and the cover grid of show tiles with their new-episode captions'}, + {file: 'web-player/podcast-show.png', capture: 'web', title: 'Web player - Show page', hint: "The Lantern Archive's show page (a serial show, followed): the cover hero with the Following badge (no Follow button: no Bridge is online), the episode count and order (oldest first), then the trailer and S1 E1-E4, Ready on your server but the newest, which isn't on the server (Not available now, its Play dimmed: no Bridge is online to fetch it)"}, + {file: 'web-player/home-new-episodes.png', capture: 'web', title: 'Web player - New episodes', hint: "the admin's Home scrolled to New episodes (From shows you follow, All shows): the followed shows' episodes of the last 14 days, newest first, with no trailer"}, + {file: 'web-player/episode-fetching.png', capture: 'web', title: 'Web player - Getting an episode', hint: "phone-width full player for an episode not on the server (Field Notes at Dawn's newest), started from its episode page: the cover, Getting this episode with how far it is ( of ) and its progress bar, Cancel (the server's fetch answers are stood in for by the capture, no Bridge runs)"}, + {file: 'web-player/phone-podcast-show.png', capture: 'web', title: 'Phone - Show page', hint: "phone-width show page of The Lantern Archive: the cover over the title and author, the Following badge, the description, the Newest first / Oldest first switch and the episode list from the trailer, the tab bar"}, // ── Admin console (admin-ui, desktop 1440x900 dark unless noted) + public server pages {file: 'admin/login.png', capture: 'admin', title: 'Admin - Sign in', hint: 'the console sign-in page, signed out'}, diff --git a/screenshots/run.sh b/screenshots/run.sh index ad1294df..ad55dca2 100755 --- a/screenshots/run.sh +++ b/screenshots/run.sh @@ -3,6 +3,10 @@ # Regenerates the documentation screenshots end-to-end: # 1. builds the AudioSilo server from the sibling repo, # 2. seeds a small public-domain library (LibriVox; cached in .cache/library), +# the generated files of one book (2b) and three made-up podcast shows +# (2c, .cache/podcasts: feed.json, cover and short silent episodes, two +# of them left off the server; capture-web.mjs adds it as a podcast +# library for its podcast shots and removes it again), # 3. starts a demo-mode server (port 8790) serving the frontend's web export, # plus a second --setup instance (port 8791) for the first-run setup shots # (the --setup page and the console's "Set up your server" flow), @@ -142,6 +146,24 @@ else echo " ! no $GEN_BOOK: the generated content shots will fail" fi +# ── 2c. Podcast shows (Bridge Phase 2d-1) ───────────────────────────────── +# The web player's podcast shots (web-player/library-shows.png, podcast-show.png, +# home-new-episodes.png, episode-fetching.png, phone-podcast-show.png) need a +# podcast library, which is only ever written by a Bridge. No Bridge runs here: +# seed-podcasts.mjs writes what one leaves in a podcast library root from +# fixtures/podcasts.json (own words, made-up shows): per show a feed.json (v1), +# a cover.jpg and a short silent mp3 per episode with its fetch pin read from +# that file, dated back from now (so the newest episodes are always within +# Home's 14 days). Two episodes (the serial's newest and the daily show's) have +# no file and no pin, so they are not on the server and playing one asks for a +# fetch. Rebuilt on every run (ffmpeg/ffprobe); the server only reads it. +# capture-web.mjs adds it as a podcast library through the admin API (and +# deletes it again before any other shot could show it). +PODCASTS="$CACHE/podcasts" +echo "==> seeding podcast shows into $PODCASTS" +(cd "$HERE" && node seed-podcasts.mjs "$PODCASTS") \ + || echo " ! the podcast seed failed: the podcast shots will fail" + # ── 3. Servers ────────────────────────────────────────────────────────────── cleanup() { [ -n "${MAIN_PID:-}" ] && kill "$MAIN_PID" 2>/dev/null || true @@ -300,7 +322,8 @@ fi # ── 5. Captures ───────────────────────────────────────────────────────────── cd "$HERE" echo "==> capturing web player" -AS_BASE="http://127.0.0.1:$PORT/web/" ADMIN_PASSWORD="$ADMIN_PASSWORD" node capture-web.mjs +AS_BASE="http://127.0.0.1:$PORT/web/" ADMIN_PASSWORD="$ADMIN_PASSWORD" PODCASTS_DIR="$PODCASTS" \ + node capture-web.mjs if [ "${SKIP_ADMIN:-0}" != "1" ]; then echo "==> capturing admin console + public pages" diff --git a/screenshots/seed-podcasts.mjs b/screenshots/seed-podcasts.mjs new file mode 100644 index 00000000..30b8f095 --- /dev/null +++ b/screenshots/seed-podcasts.mjs @@ -0,0 +1,172 @@ +// Seeds the podcast library for the podcast shots (run.sh section 2c): every show in +// fixtures/podcasts.json becomes // with the files a Bridge leaves there, +// with no Bridge involved: +// - feed.json (feed.json v1, as docs-developers/bridge/podcasts.md describes it), dated +// back from now, so the newest episodes are always within Home's 14 days, +// - cover.jpg (a gradient with the show's name, ffmpeg), +// - one short silent mp3 per episode (ffmpeg), each with the fetch pin a Bridge writes +// once it has the file (generation 1, sha256, bytes and duration read from that file), +// except the episodes marked `absent`: no file and no pin, so the server lists them +// as not on the server and playing one asks for a fetch. +// The folder is rebuilt on every run (the dates move). The server only reads it. +// +// node seed-podcasts.mjs +import {createHash} from 'node:crypto'; +import {spawnSync} from 'node:child_process'; +import {existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync} from 'node:fs'; +import path from 'node:path'; + +import {DAY, FIXTURES, HOUR, ffmpeg, silence} from './lib.mjs'; + +const root = process.argv[2]; +if (!root) { + console.error('usage: node seed-podcasts.mjs '); + process.exit(1); +} +const {shows} = JSON.parse(readFileSync(path.join(FIXTURES, 'podcasts.json'), 'utf8')); + +const sha256 = (data) => createHash('sha256').update(data).digest('hex'); +const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); +// RFC 3339 without milliseconds, as the Bridge writes it. +const stamp = (ms) => new Date(ms).toISOString().replace(/\.\d+Z$/, 'Z'); + +// A font for the covers' text (drawtext needs a file on macOS); without one the cover +// is the gradient alone. +const FONT = [ + '/System/Library/Fonts/Supplemental/Arial Bold.ttf', + '/System/Library/Fonts/Supplemental/Georgia Bold.ttf', + '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', +].find((f) => existsSync(f)); + +// The title on a few short lines (drawtext doesn't wrap). +function wrap(text, width = 14) { + const lines = []; + for (const word of text.split(' ')) { + const last = lines.at(-1); + if (last && `${last} ${word}`.length <= width) lines[lines.length - 1] = `${last} ${word}`; + else lines.push(word); + } + return lines.join('\n'); +} + +function cover(file, show, dir) { + const [c0, c1] = show.colors; + const gradient = `gradients=s=600x600:c0=${c0}:c1=${c1}:x0=0:y0=0:x1=600:y1=600:nb_colors=2:speed=0`; + const args = ['-f', 'lavfi', '-i', gradient, '-frames:v', '1']; + if (FONT) { + // Text from files, so no character in a name needs drawtext escaping. + const titleFile = path.join(dir, '.cover-title.txt'); + const authorFile = path.join(dir, '.cover-author.txt'); + writeFileSync(titleFile, wrap(show.title)); + writeFileSync(authorFile, show.author.toUpperCase()); + const font = `fontfile='${FONT}'`; + args.push('-vf', [ + `drawtext=${font}:textfile='${titleFile}':fontcolor=white:fontsize=64:line_spacing=10:x=48:y=h-th-64`, + `drawtext=${font}:textfile='${authorFile}':fontcolor=white@0.8:fontsize=24:x=48:y=56`, + ].join(',')); + } + args.push('-q:v', '3', file); + const ok = ffmpeg(args); + for (const f of ['.cover-title.txt', '.cover-author.txt']) rmSync(path.join(dir, f), {force: true}); + if (!ok) throw new Error(`ffmpeg could not make ${file}`); +} + +// The audio's length as ffprobe reads it, in ms. +function durationMs(file) { + const r = spawnSync('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', file], { + encoding: 'utf8', + }); + const seconds = Number(r.stdout.trim()); + if (r.status !== 0 || !(seconds > 0)) throw new Error(`ffprobe could not read ${file}`); + return Math.round(seconds * 1000); +} + +const now = Date.now(); +rmSync(root, {recursive: true, force: true}); +let files = 0; +let absent = 0; +for (const show of shows) { + const dir = path.join(root, show.folder); + mkdirSync(dir, {recursive: true}); + const name = slug(show.title); + const feedUrl = `https://feeds.example.com/${name}.xml`; + const coverFile = path.join(dir, 'cover.jpg'); + cover(coverFile, show, dir); + const oldest = Math.max(...show.episodes.map((e) => e.days_ago)); + const subscribed = now - (oldest + 2) * DAY; + const episodes = show.episodes.map((e) => { + const published = now - e.days_ago * DAY; + const id = sha256(`${show.folder}\n${e.title}`).slice(0, 16); + const rel = `${show.folder}/${stamp(published).slice(0, 10)}-${id.slice(0, 10)}.mp3`; + let fetch = null; + let length = e.minutes * 60 * 16000; // what a 128 kbps enclosure would weigh + if (!e.absent) { + const file = path.join(root, rel); + if (!silence(file, e.minutes * 60, {title: e.title, artist: show.author, album: show.title})) { + throw new Error(`ffmpeg could not make ${file}`); + } + const bytes = statSync(file).size; + length = bytes; + fetch = { + generation: 1, + sha256: sha256(readFileSync(file)), + bytes, + duration_ms: durationMs(file), + content_type: 'audio/mpeg', + fetched_at: stamp(Math.min(now, published + HOUR)), + deleted_at: '', + timeline_changed: false, + codec: 'mp3', + }; + files++; + } else { + absent++; + } + return { + id, + guid: `urn:audiosilo-shots:${name}:${id}`, + path: rel, + title: e.title, + published: stamp(published), + description: e.description ?? '', + season: e.season ?? null, + episode: e.episode ?? null, + episode_type: e.episode_type ?? 'full', + explicit: false, + duration_seconds: e.minutes * 60, + enclosure: {url: `https://media.example.com/${name}/${id}.mp3`, type: 'audio/mpeg', length}, + chapters_url: '', + transcripts: [], + first_seen: stamp(Math.max(subscribed, published)), + removed_at: '', + fetch, + }; + }); + const feed = { + version: 1, + generator: 'audiosilo-docs/screenshots', + show_id: sha256(show.folder).slice(0, 16), + feed_url: feedUrl, + feed_url_sha256: sha256(feedUrl), + previous_feed_url_sha256: [], + private: false, + feed_host: 'feeds.example.com', + subscribed_at: stamp(subscribed), + updated_at: stamp(now), + show: { + title: show.title, + author: show.author, + description: show.description, + link: `https://example.com/${name}`, + language: 'en', + type: show.type, + explicit: false, + categories: [], + podcast_guid: '', + image: {file: 'cover.jpg', source_sha256: sha256(readFileSync(coverFile)), fetched_at: stamp(subscribed)}, + }, + episodes, + }; + writeFileSync(path.join(dir, 'feed.json'), JSON.stringify(feed, null, 2) + '\n'); +} +console.log(` ✓ ${shows.length} shows, ${files} episode files, ${absent} episodes not on the server`); diff --git a/sidebars-developers.ts b/sidebars-developers.ts index 0a018d88..4fcfafbe 100644 --- a/sidebars-developers.ts +++ b/sidebars-developers.ts @@ -49,6 +49,7 @@ const sidebars: SidebarsConfig = { 'frontend/sleep-timer', 'frontend/player-ui', 'frontend/book-page', + 'frontend/podcasts', 'frontend/annotations', 'frontend/journal', 'frontend/you-and-settings', diff --git a/sidebars-users.ts b/sidebars-users.ts index ae39e11f..2617e5a3 100644 --- a/sidebars-users.ts +++ b/sidebars-users.ts @@ -57,6 +57,7 @@ const sidebars: SidebarsConfig = { 'listening/sleep-timer', 'listening/up-next', 'listening/end-of-book', + 'listening/podcasts', 'listening/offline-downloads', 'listening/mobile-apps', 'listening/in-the-car', diff --git a/static/img/screenshots/web-player/episode-fetching.png b/static/img/screenshots/web-player/episode-fetching.png new file mode 100644 index 00000000..53414851 Binary files /dev/null and b/static/img/screenshots/web-player/episode-fetching.png differ diff --git a/static/img/screenshots/web-player/home-new-episodes.png b/static/img/screenshots/web-player/home-new-episodes.png new file mode 100644 index 00000000..ddc6b89e Binary files /dev/null and b/static/img/screenshots/web-player/home-new-episodes.png differ diff --git a/static/img/screenshots/web-player/library-shows.png b/static/img/screenshots/web-player/library-shows.png new file mode 100644 index 00000000..49ce8392 Binary files /dev/null and b/static/img/screenshots/web-player/library-shows.png differ diff --git a/static/img/screenshots/web-player/phone-podcast-show.png b/static/img/screenshots/web-player/phone-podcast-show.png new file mode 100644 index 00000000..5cbc1e0e Binary files /dev/null and b/static/img/screenshots/web-player/phone-podcast-show.png differ diff --git a/static/img/screenshots/web-player/podcast-show.png b/static/img/screenshots/web-player/podcast-show.png new file mode 100644 index 00000000..f2638bb3 Binary files /dev/null and b/static/img/screenshots/web-player/podcast-show.png differ