QUILL Cast – Product Requirements

Version 2.0

1. Product statement

QUILL Cast is QUILL’s podcast environment, shipped as its own small Windows app for people who want their shows without loading a full writing environment. It is screen-reader-first, keyboard-complete, and deliberately small.

2. Architecture requirement: not a fork

3. Scope

In scope (all reused from upstream):

Out of scope, by decision (D-1, “basic level of functionality”):

4. Accessibility requirements

5. Packaging requirements

6. Update requirements

7. Non-goals

macOS/Linux standalone builds (upstream QUILL covers macOS; the tray-icon pattern does not exist there), silent or unattended background updating (an update is always offered and accepted before it is applied – see U-4), telemetry of any kind. Downloading Spotify audio, which is DRM-protected and play-only by design. A full DSP effects rack (reverb, tempo/pitch, spatial audio) – Sound Enhancements (§9) is a small, purpose-built three-band EQ, compressor, and Smart Speed, not a general effects rack. For private feeds: no OAuth/token/cookie auth schemes, no per-episode credentials, no cross-machine credential sync – one username/password per show, HTTP Basic only.

Also deliberately not built, and not to be relitigated without new information: CarPlay / Android Auto / AirPlay / lock-screen / Control Center – the tray plus global hotkeys plus media keys is the desktop answer, and it is complete. Paid tiers of any shape – subscription plus-tiers, a free-podcast-count limit, paywalls, StoreKit. Cloud sync, gpodder, or a hosted account – QUILL Sync is the family’s own answer and a separate programme (see the QUILL PRD). Dynamic Type, touch-target sizing, Reduce Motion, Material You – their desktop equivalents are the shell’s job, not this app’s. Cloud transcript generation – §3 puts audio-to-text in full QUILL, on the listener’s own machine.

8. Security requirements

9. Performance requirements (1.1)

A subscription list exported from another app after a decade of listening is routinely more than a thousand feeds. Every requirement here was written against a real one (1,307 feeds), refreshed to roughly 196,000 episodes.

10. Since 1.0

11. Since 1.0.7 (1.1.0)

Acquisition, the layer that was missing. Through 1.0.x QUILL Cast had a retention policy and no acquisition policy: it knew what to throw away and nothing about what to fetch. Auto-download (0/1/3/5/10/all, per podcast) plus Auto-Queue per show plus per-show new-episode announcements mean subscribing to a show and pressing play is now one step.

Sharing and audio export. “Share this” has no single desktop gesture, and inventing one would produce a menu item that opens a dialog nobody wants. The requirement is a file the listener can place and an address they can paste: Save Episode Audio As…, Copy Podcast Link, and Show in File Explorer. All three are Quick Actions entries, never hard-coded menu items, so they take the listener’s order like everything else on those menus.

The normative rule: saving copies, it never moves. QUILL Cast goes on managing its own downloaded copy – retention, the storage cap, resume, and Remove Downloaded Copy all still apply to it – and the saved copy is the listener’s, outside all of that. Moving the managed file would silently break resume and the download’s own bookkeeping. An episode that is not yet downloaded offers the download and returns, rather than blocking the UI thread behind a transfer of unknown length.

Episode notes reachable from the player. A timestamped note is made while listening, so requiring the listener to leave the player, locate the episode in the library tree and open a context menu to read notes back was the wrong shape. My Notes in This Episode... acts on whatever is playing; the Manager’s per-episode route stays, and both build the list from one implementation so their wording cannot drift. Copy Note carries the episode, the podcast, the timestamp, the note and the audio link together – a note’s own text alone is a fragment with no way back to the moment it marks.

Re-published episodes resurface. A publisher re-issuing an episode (a corrected file, a re-cut, one pulled and reissued) moves its published stamp forward; merge_episodes collects those guids at the only moment both stamps exist, and inbox.resurface_republished clears the trim marker so the episode returns to the Inbox. The exemptions are normative and are the same three trim_inbox applies – played, started (position_ms > 0), and queued – plus any hand-filed assignment, which is the listener’s own and is never overruled by a publisher. Announced as a re-publication rather than as a new episode: calling it new would misdescribe what the publisher did.

Queue Expiration + Recently Expired. Per-podcast, off by default, with a seven-day restorable hold. The one migration risk in the release – a queue saved before 1.1.0 has no timestamps – is handled by reading an unstamped slot as “added now” rather than “infinitely old”.

Listening statistics. Time listened, extra content bought by speed, episodes finished, per-podcast breakdown, CSV export, 90-day retention. The report is a read-only text field you arrow through, and durations are words (A-8). Time saved by Smart Speed is omitted rather than estimated (A-10).

Quick Actions. Three orderable action lists: a chosen default for Enter, a chosen menu order, and Ctrl+1..Ctrl+9 for the top nine.

Session control. Stop After This Episode; the continue-after-queue / continue-after-group pair (with both off, playback stops at the end of the current episode); speed as a real 0.5x-5.0x continuum with Speed Up / Speed Down / Reset commands; Mark All as Played; sleep timer “end of this episode” and Extend +5.

Inbox caps and storage management. Per-podcast Inbox count and age caps that trim without deleting and never touch anything played, started, or queued; a Downloads screen with usage, an age limit, a total cap, and Free Up Space, under the rule that a queued or part-played episode is never evicted.

Bulk OPML import. Threaded, deduplicating, and reportable at the scale a real subscription list actually reaches – with a pruning export that writes the source file back without the feeds that no longer answer (§9).

Winamp classic transport keys, shared with Quill Radio’s recordings player rather than reimplemented: Z X C V B, arrows to seek, J, Ctrl+J, T, L. On by default, one Preferences checkbox to turn off.

Two correctness fixes: finishing a mid-queue episode now continues from the slot after it instead of jumping back to the queue head, and chapter auto-skip carries a loop guard so a seek’s own position report cannot re-trigger the skip that caused it.

Onboarding and one-shot tips (core/podcasts/onboarding.py, ui/podcasts/first_run_dialog.py, persisted on PodcastLibrary.onboarding).

Three screens, not seven. Welcome, add your first podcast, you’re set. Cast has no account, no tracker and no cloud, so it does not need the privacy screens a phone app needs – and a first-run flow that pages somebody through consent they never gave anything is how people learn to dismiss dialogs unread. The screens are a read-only text area, arrowable and copyable, rather than a wall of labels: somebody who missed a sentence goes back over it at their own pace instead of asking the app to repeat itself. Skip is a first-class button, and skipping counts as completed – it was a choice, and re-showing the flow would be overriding it with a guess. needs_first_run is false for anybody who already has shows, however they got them (OPML import, restored backup, upgrade): explaining how to add a first podcast to somebody with two hundred says nobody checked.

Tips are one sentence, once ever. TIPS is a reviewable dict rather than strings at call sites, so the whole set can be audited in one place. Four rules: once ever (a tip that reappears is an interruption; one that appears once is a fact you now know); never modal and never focus-stealing – they ride the ordinary announcement path, so speech and braille both get them; only where they change what somebody can do, never to explain a button whose label already does; and off in one place, permanently, because somebody who does not want them should not have to dismiss each one to discover that. tip_for and mark_seen are deliberately separate calls, so a tip that could not actually be delivered is not recorded as shown.

State is a set of ids, not a version stamp: a tip added next year must fire for somebody who has used Cast for a year, and a version number would say they had already seen it. An unknown id from a newer build is kept rather than dropped, so moving between builds does not replay tips.

Prebuffering the next queue item (core/podcasts/prebuffer.py, PodcastSettings.prebuffer_next). Sample-accurate gapless playback is a property of the decoder and neither engine offers it; what is achievable, and what actually removes the wait, is having the next episode’s first seconds on disk before the current one ends – the switch then costs an open and a seek rather than a network round trip and a buffer fill.

A pure policy function with every input passed in (plan), so it is testable without a player, a queue or a network: the caller knows what is playing and what is next, this knows when. Four refusals carry the design: off unless asked for (speculative bytes are paid for by the megabyte on a metered connection), never for an episode already local (there is nothing to gain), never before the final LEAD_MS (thirty seconds – longer than a stream takes to open on a poor line, short enough that skipping around does not trigger it repeatedly), and never for a source with no known length, because a live item never becomes “nearly over” so there is no cue to fire on. What it fetches is a cache capped at PREBUFFER_BYTES, landing in the playback cache rather than the library, and a prune may take it. It announces nothing: a player narrating its own buffering is the wrong kind of feedback.

Chapter inference: scored answers, a budget, and titles that say what a section is about (core/podcasts/chapter_scoring.py, chapter_cascade.py, chapter_naming.py, show_note_chapters.py, inference_budget.py).

The five gaps this closes, in the order they mattered.

  1. Nothing named anything. Tier 3 titled a section with its literal opening words; tier 4 titled them Section 1..N. Neither says what the part is about, which is the entire point of a chapter list. chapter_naming closes it with one batched, text-only call that names every section at once – never one call per chapter, which would be N times the cost, N times the latency and N chances to leave a hole in the list. A section the model cannot summarise gets a single hyphen and keeps the title it had, because a plausible invention is worse than an honest gap.
  2. Tier 3 never fetched a transcript. It read the cache and gave up when it was cold, so an episode publishing a perfectly good podcast:transcript URL nobody had opened fell straight through to the slow audio scan. The best free answer available was routinely skipped; the budget now allows the fetch.
  3. Every knob was a hard-coded constant and none was reachable. Replaced by one control with three values – Quick, Thorough, Deep – from which every constant derives (InferenceBudget). Not because the knobs do not matter, but because nobody can reason about “silence threshold -35 dB” and everybody can reason about how long they are willing to wait. The advanced values stay adjustable in a settings file and are deliberately absent from the UI: the failure mode of exposing them is somebody nudging noise_db once and quietly getting worse chapters forever.
  4. First answer won, with no idea whether it was any good. Now every tier returns a scored ChapterAnswer, the cascade runs every tier the budget allows and keeps the best, and a low-confidence segmentation no longer suppresses a better scan. Below MIN_USEFUL_CONFIDENCE the honest answer stays “no chapters could be found”. Authored always beats inferred – published, file tags, show notes short-circuit outright, because a person wrote those titles and no heuristic produces titles worth more.
  5. A chapter was a start and a title. It now carries end_ms (so “3 of 12, four minutes long” is sayable and the last chapter has an honest end), source per chapter rather than only per set, confidence, and reason in words – which is what makes the “How were these found?” report possible at all.

The show-notes tier is the biggest unclaimed win in the whole cascade, and it costs nothing: a publisher who wrote timestamps has already done the work, and the words beside each one are an authored title. show_note_chapters reads what people actually write – 00:00, 1:02:03, 12.34, 1h05m, bracketed, bulleted and numbered forms, the timestamp at the end of the line, and HTML, since show notes usually arrive as markup – and refuses anything that does not look like a chapter list (out of order, one mark, starting an hour in, running past the end), because a page that merely contains times is not a chapter list and returning it would be a confident wrong answer.

Sampling is a cost-avoidance measure for transcription, never a quality choice. Where the text is already in hand – a published transcript, or one Deep just produced – the section’s whole text names it, because reading less would save nothing and lose accuracy. Only where naming would otherwise mean transcribing audio nobody asked to pay for does it sample, and even then the sample is the opening plus a probe from the middle: a section’s first minute is very often the tail of the previous topic, an ad read or throat-clearing, and naming a chapter after what the host was just finishing is exactly the confident-but-wrong output rule A-10 exists to prevent.

Nothing may interrupt. The work runs in the background with a real cancel; a cheap tier answering first never blocks a better one from replacing its answer; a published list is never overwritten; the menu item is disabled and renamed during a scan (working_label) so a screen reader reads the state as part of the item rather than having to discover it; opening Chapters mid-scan says so and returns rather than offering a spinner; and completion is announced politely, once, in one short sentence.

Settings, global and per show (PodcastSettings.chapters_*, resolved through effective_settings like everything else): when to run at all (off / when downloaded / always), the effort, each individual tier, whether to name sections with a model, which speech engine, and whether to announce. A tier switched off is disabled, not deprioritised – somebody who says “never scan the audio” has said something specific and must be obeyed at any effort level – and the whole feature is switchable off in one place, because somebody who does not want inferred chapters should never hear about them again.

Inbox opt-out mode (PodcastSettings.inbox_mode, inbox.in_inbox). The Inbox was opt-in only – a show is in it because it was marked. inbox_mode adds "exclude": every show is in the Inbox except the ones marked, which is a materially different object over a 1,300-show library and the shape somebody with a large subscription list actually wants.

One flag, read two ways. The existing PodcastShow.route_to_inbox is reused rather than a second per-show field being added, because two fields can disagree and a listener would have no way to tell which won. Every surface that asks “is this show in the Inbox?” goes through inbox.in_inbox, so the listing, the trim sweep, the republish sweep and auto-download can never diverge on what the mark means. The per-show menu label and the spoken confirmation both change with the mode, since “keep this one out” and “put this one in” are not the same instruction.

Global, not per show, deliberately: the mode answers which shows, and a per-show mode would be a question about a question. An unknown stored value reads as "include" – the direction that can only ever show fewer shows than expected, never sweep a whole library in by accident. And the Inbox caps that shipped in 1.1.0 came first for this reason: an opt-out Inbox is only survivable because they exist.

12. Transcripts: the foundation, and the surface that followed

Recorded together so that “built” and “usable” are never confused. This section was written when only the first half existed; the reading surface has since shipped, and the heading said otherwise for longer than it should have.

Transcripts keep their timings (quill/core/podcasts/transcripts.py). Cast could already fetch a feed-provided transcript, read it, cache it for offline search, and open it as a QUILL document – and the reader threw the timings away, which was exactly right for “open this as a document” and useless for anything that follows along. TranscriptCue, parse_transcript_cues, cues_to_text and a binary-search cue_at now parse WebVTT, SubRip, Podcasting 2.0 JSON and YouTube’s json3 (which arrives free with every YouTube resolve in Quill Radio and was being discarded). parse_transcript is redefined as the timed form with the timings removed, so there is one reader rather than two that drift apart, and Cast’s existing transcript tests are the regression gate and pass untouched.

And the reading surface now exists: quill/ui/transcript_reader.py, shared with Quill Radio rather than owned by either app, reached from Read Transcript… on the episode context menu (ui/podcasts/transcript_actions.py, extracted from manager_phase4.py, which was at its GATE-11 ceiling).

A read-only wx.TextCtrl on purpose: arrow keys, word and line movement, selection, the screen reader’s own review cursor and Find all come free and behave identically to everywhere else, where a custom list would have removed them and returned nothing. The timings sit alongside the text rather than in it – line_starts and cue_index_for_offset map character offsets to cues and back, which is what lets Enter on any line seek correctly however the caret got there (arrowed, clicked, searched, or moved by the review cursor).

Four rules it keeps: following is opt-in and reading wins (with Follow off, playback never moves the caret; with it on, the caret moves and says nothing, because a position announcement per line would be unusable); every position is spoken as words through bounded_playback_ui.spoken_duration, never as a timecode; a control that cannot work says why (jump needs a seekable player, and follow and jump are offered only while this episode is the one playing); and saving keeps the timingscues_to_vtt and cues_to_srt are asserted to round-trip through the parser, not merely to serialise. An automatic caption track is announced as automatic in the window’s heading.

13. The rest of the Podcasting 2.0 namespace

core/podcasts/feed_reader.py read podcast:chapters and podcast:transcript and discarded everything else in the namespace – tags real shows already publish, sitting in bytes Cast had already downloaded and parsed. All of it is now read (core/podcasts/namespace_tags.py), kept with the episode and the show, and surfaced through About This Episode… (core/podcasts/extras.py for the rows and the words, ui/podcasts/episode_extras_dialog.py for the window, ui/podcasts/extras_command.py for the three actions a row can take).

What each tag is for, and the decision that goes with it:

Value-for-value / cryptocurrency streaming remains out of scope, deliberately and permanently: Cast can claim meaningful Podcasting 2.0 support without it.

13.1 Soundbites as an authored chapter tier

A soundbite is an authored mark – a person chose the moment and wrote its title – so it belongs in the chapter cascade rather than in a side list nothing consults. It is added as SOURCE_SOUNDBITES in chapter_scoring.py (base confidence 0.85, inside is_authored) and as the last of the authored tiers in both chapter_sources.chapter_cascade and the scored chapter_cascade.run.

Last of the authored tiers, and the reason is the whole design: a highlight is not a partition. Two soundbites in an hour answers what is the good bit completely and how is this laid out barely at all. So they win only when nothing better was published; each chapter keeps the soundbite’s own end_ms rather than running on to the next mark, so the silence between two highlights stays silence instead of being absorbed into whichever came first; and the source is labelled Moments this podcast marked, so a set of highlights is never mistaken for a chapter list covering the episode.

One exception to the shape of the other tiers: the floor is one mark, not two. A single marked moment is still a place worth jumping to, and the honest label carries the meaning.

13.2 Reading, persistence and refusals

Read with regular expressions over the raw item fragment, matching how chapters and transcript are already read: the feed parser in use does not surface unknown namespaces, a second full XML parse of every feed on every refresh is real cost across a large library, and each of these is a shallow attribute grab. Every parser is tolerant – a malformed tag yields nothing rather than raising, because one bad tag must never cost somebody their whole feed.

The channel half is read from the feed text before the first item, so an episode’s guests are never credited to the podcast itself. Tags are persisted with the episode and the show, and only when non-empty, so a library of feeds that publish none of this pays nothing for the feature existing. A refresh brings in a credit added after publication; a feed that stops carrying them does not erase what it already said, because an empty replacement is far more often a partial feed than a retraction.

About This Episode... speaks a one-line summary before the window opens, builds a tab only when it has something in it, and still opens (saying so) when a podcast published none of it: this podcast publishes no extra details and QUILL Cast cannot read them are very different facts, and a greyed-out menu item would leave the listener unable to tell which. The action button is named from the highlighted row and disabled with Nothing to Open where there is nothing to do – a control that silently declines is worse than one not offered.

14. Triage, hand-off, and scanning

Three small things, each removing a reason somebody works around the app.

Bulk actions reached the Inbox. The episode list has allowed a multiple selection since 1.0 and gained bulk queue/download/played in 1.1.0, but the one surface where selecting forty episodes is the normal thing to do – the Inbox, whose entire job is triage – had only single-episode filing. File N Episodes to Inbox Folder… asks once which folder and files the lot; being asked the same question forty times is how a bulk action stops being one. The remembered-default rule is unchanged and still per show, so filing thirty episodes of one podcast sets its default once and says so once. Add N Episodes to Playlist… and Remove N Downloaded Copies came with it; removing downloads never removes episodes, because freeing space and unsubscribing are different things to want. The shared retention.remove_downloaded_copy is now the one implementation, so the single and bulk paths cannot drift.

The .opml association (core/podcasts/opml_cli.py, an opt-in [Tasks]/[Registry] pair in the installer). An OPML file is how one podcast app hands its whole subscription list to another, and Cast could only receive one through a file picker inside a dialog inside a menu. The task is unchecked by default – taking over a file type without being asked is how an installer earns a reputation – and uninstalling gives the extension back rather than leaving a dead handler. Only .opml is claimed, though the command line also accepts .xml: that extension belongs to no single application and claiming it would break unrelated files. The import is deferred with CallAfter so the window exists before a modal appears over it, or the app looks like it failed to start.

Hold-to-scan (core/podcasts/scan_hold.py, ui/podcasts/scan_hold_control.py). Skipping in fixed jumps answers “get me past this”; it does not answer “where does this bit end?”, which needs to hear the audio going past. Shift+Right held runs at 4x – fast enough to cover a minute in fifteen seconds, slow enough that speech is still recognisable – and release restores the exact prior speed, so somebody who listens at 1.5 gets 1.5 back. Both edges are announced, because a player stuck at 4x with no announcement is indistinguishable from a broken one.

Release is inferred from the auto-repeat stopping, not from a key-up event. A key-up can be missed outright when focus moves, a dialog opens, or the window is deactivated mid-hold, and every one of those would leave playback at 4x forever. Repeats that stop arriving cannot fail that way; the key-up is still honoured when it comes, so the drop back is immediate rather than up to the grace window late. Losing the window and closing the app both end a scan too.

15. The library is yours to arrange

Per-episode download from the tree (quill/apps/podcasts_library_actions.py, the extracted CastLibraryActionsMixin). An expanded show’s episode rows carry Play Episode and Download Episode on their context menu; the download goes through the one shared enqueue_episode_download helper, so the private-feed Authorization rule (same-host only) can never be forgotten at a new call site. Files are named for humans – <download root>/<show slug>/<episode slug><ext> – because a download whose name is a feed GUID is unfindable in Explorer.

Renamable pinned views (PodcastSettings.view_names; virtual_views.view_label/set_view_name/reset_view_name). The shipped views (Favorites, New Episodes, Continue Listening, Inbox) are the one kind of tree node the listener may rename – F2 or the context menu – because they belong to the app, not to a feed. Setting the shipped label or a blank IS the reset, so the settings file only ever stores genuine customizations; Reset Name appears on the menu only while a custom name exists. Shows and episodes refuse renaming with an explanation: an alias would silently stop matching what every other player, share link and search result calls them. The Manager reads the same names, so the two windows cannot drift.

Show ordering, including by hand (PodcastSettings.show_sort_mode; PodcastLibrary.move_show; sorting.SHOW_SORT_MODES). Ascending, descending, and custom – custom order is the shows list’s own order, maintained by one-step swaps among a show’s folder siblings only, so each folder keeps its own arrangement even though all shows live in one flat list. Taking manual control (Alt+Up/Down) is itself the act of choosing custom, and entering custom freezes the order currently on screen first – otherwise the first nudge would scramble everything visibly. The Subscriptions menu’s radio group and the Manager’s dropdown both reflect the live mode.

Counts that say what they count. A folder’s badge is its subtree’s podcast count; a show’s badge reads “(n unheard)” in words. Two bare numbers that read identically would make the listener remember which node kind they were on – the exact cost badges exist to remove.

Emptied search fields empty their results (quill/ui/search_reset.py). One shared binding backs every search surface with a separate results list, in Cast and across the family: when the query becomes empty (whitespace counts), the surface resets exactly as its own blank-search path would. A results list showing matches for text that no longer exists is stale state presented as current – invisible as such to a screen reader arrowing the list.

Show notes are paragraphs (show_notes.html_to_plain_text). Block elements now contribute a blank line, collapsed so empty tags can never stack more than one – a screen reader’s next-paragraph navigation needs a real boundary to land on, and a wall of single-spaced lines has none.

16. Reaching outside this machine (2.0)

Sections 12 to 15 all made QUILL Cast better at what it already did. This one is different in kind: four of its five parts are Cast talking to something that is not Cast. That is a class of feature the app had none of, and each part carries a rule about how far the reach goes.

16.1 Listening Places: a format, not a service

QUILL Sync already carries listening positions between two copies of QUILL, encrypted, over a folder the listener already syncs. It always will, and it will only ever work QUILL-to-QUILL.

Listening Places (core/sync/listening_places.py, format id listening-places/1, specified in docs/engineering/listening-places-spec.md) is the interchange half: a small plain-JSON format any podcast app can read and write, in the same folder, with no account, no server and no signup. It is a second, independent switch from the encrypted half and requires no recovery phrase – gating it behind one would mean a feature nobody can set up, which syncs nothing.

Four properties are requirements, not implementation details, and each one rules out a specific failure:

The episode adapter (core/podcasts/position_sync.py) closes a gap Cast owed itself regardless: position_ms lived inside the monolithic library file with no timestamp, so there was nothing to merge on. PodcastEpisode now carries position_updated_at, and every site that moves a position goes through position_sync – one site that forgot the stamp would be a device whose place silently stopped travelling.

Conformance fixtures live beside the spec and are executed by tests/unit/core/sync/test_conformance.py, so a change that breaks the other implementation fails a test rather than a user.

16.2 Sharing a moment

quill-cast://episode?feed=...&guid=...&t=<seconds>, registered by every Cast installer. Two rules:

16.3 A second directory

Podcast Index (core/podcasts/podcast_index.py) joins iTunes as an opt-in source, for its Podcasting 2.0 metadata. This reverses the 2026-08-13 decision not to integrate it; the reversal is recorded in the egress audit beside the call site so a stale rationale cannot keep asserting itself.

16.4 Folders as a listening lens, and the queue that follows

A folder is a place to listen from (core/podcasts/folder_actions.py). The subtree walk is one function everything else reuses, so no two folder actions can disagree about what a folder contains, and move_folder refuses to make a folder its own descendant – a ring is a tree nothing can render and nobody can undo.

Play All Unplayed means one episode per show. A folder of forty shows holds hundreds of unplayed episodes; a queue of hundreds is not a queue.

Folder settings apply at save time, not read time. Choosing a value writes it into every member show’s own override and the folder forgets it. One inheritance chain: what a show’s setting is remains what PodcastLibrary.effective_settings says. The cost – a show moved in later inherits nothing – is stated in the window. The alternative, resolving folder values at read time, means every consumer walks the tree and two shows in one folder can disagree about their own setting depending which code path asked.

queue.group_queue_by groups the Play Queue by nothing, podcast or folder. Grouping is presentation only: the play order is untouched, headers announce themselves as headers, and no action can act on one.

16.5 Rules that can express a disjunction

PlaylistRules ANDed everything, which cannot express “anything from these three shows or anything I have bookmarked”. It gains match_mode, folder_ids (subtree aware), download_state, has_note, text_contains, progress and item_limit.

Two rules govern the implementation. A rule left at its “does not narrow” value contributes no predicate at all – otherwise every any playlist would match everything. And item_limit applies after sorting, so “the ten newest” is the ten newest.

Scope (show_ids, folder_ids) is always AND, whatever match_mode says: “any of these rules, but only in this folder” is what naming a folder means.

The live “Matches N episodes right now” count is a requirement rather than a nicety. A filter set with no feedback is a guess somebody must save, close, reopen and read to check – and there is no list quietly filtering itself in the background to glance at.

16.6 Chapters: authored titles without a model

core/podcasts/note_anchors.py matches the running order publishers write in prose against where each topic’s distinctive words arrive in the transcript, aligned monotonically because notes are written in programme order. It is the only route to authored titles that involves no model at all. Two measured findings are recorded in the module and hold: anchor on onset, not density (a long interview mentions its guest most often in the middle); and where the notes describe two or more segments, use them and stop, because padding them out with lexical boundaries measurably made the list worse.

Thorough no longer offers the pause scan. It scored 0.06 against a 0.15 do-nothing floor – worse than dividing the episode by n. It remains available under Deep, where the listener has accepted a weak answer over none, and for a recording (inference_budget.for_recording), where there is nothing else.

Deep transcribes locally, and the engine ships in the box (~40 MB, CPU-only, in DEFAULT_BUNDLED_DEPENDENCY_GROUPS with its model staged by _stage_vosk_model). speech.service.preferred_chapter_provider_id is deliberately not the dictation ladder: dictation wants an engine that never invents text from silence; chapters want cue boundaries that fall on pauses. The engine that wins the second is a 40 MB model that beat one thirty-five times its size on measurement (0.372 against 0.316) at 4.7 times the speed.

Bundling is what makes the feature real: chapters have to answer the first time somebody asks, and an engine that must be downloaded first means the first answer is always “no chapters could be found”.

17. The columns are the sentence (2.0)

The counterpart of Quill Radio’s section 13, on the same shared machinery (core/media/list_columns.py) and for the same reason: an episode list is read out one column at a time, so the column set is not a display preference but a speech setting. Subscriptions > Choose Columns… (Ctrl+Alt+Shift+C) covers the episode list, Downloads, and Add Podcast’s search results.

The four properties are Radio’s, unchanged – hidden means absent rather than last, a hidden column keeps its place, one column per surface is pinned, and a saved layout is repaired against this build on every read. The window is the same two lists with the same live preview of the sentence a row will speak.

What Cast offers beyond its defaults, and why each is off to start with:

Applied while the window is open. Cast holds a live reference to its Manager so it can be refreshed in place, so a layout saved while the Manager is up takes effect there rather than next time. Radio’s two lists are modal windows opened from the menu bar the item lives on, so its cache is simply dropped – the next window built is the very next thing somebody does.

tests/unit/core/podcasts/test_podcast_list_columns.py fails the build if the catalogue offers a column no fill site produces.

18. What both listening apps owe a listener (2026-08-24)

Eleven requirements that arrived together because most of them are one answer shared between Quill Radio and QUILL Cast rather than two answers that happen to agree today. Where a section below says “shared”, the store, the module and the wording are literally one thing, and a change to either app changes both.

18.1 Nothing is a dead end

Requirement. A command that cannot run says why, at the moment somebody asks for it.

This family dims a great deal on purpose: Mark All as Played with nothing unheard is a state of a verb the row owns, which is exactly why it dims rather than vanishing – a verb that comes and goes teaches nobody anything. But a screen reader says “dimmed” and stops, and the item itself said nothing about what would un-dim it. That is a dead end you cannot see around.

Every dimmed action now carries a reason: one lower-case clause naming the condition, with a count wherever a count makes it concrete.

18.2 One step of undo, in preference to a prompt on every verb

Requirement. The last destructive action can be taken back, once, and the offer is spoken.

A confirmation prompt is the cheap answer and a poor one: it costs a keystroke and a sentence every single time, including the nine hundred times the listener meant it, and it still cannot help the one time the wrong row was focused. One step of undo is cheaper than a prompt on every verb and kinder than either.

18.3 A verb that touches many rows says how many

Requirement. Every action on more than one row ends by announcing exact eligible / done / skipped counts, with the skipped reason beside the number.

“Removed downloads” tells a listener who cannot see the list neither how many files went nor whether the ones it could not touch were mentioned.

18.4 A failure that was spoken once still exists

Requirement. Feeds, downloads, recordings and streams that fail are written down, with reason, time and a way to try again.

Announcements are transient by design, which is right until the sentence you needed went past while you were in another window. That was the one place this family was not screen-reader-first: a sighted listener still had a list to scroll back through and a listener who missed the speech had nothing at all.

18.5 Quiet hours, and the line they must not cross

Requirement. One window, shared by both apps, in which the apps stop speaking on their own.

Unprompted is the whole distinction, and it is a hard rule. Quiet hours must never silence the answer to a keypress: a listener who presses Play at three in the morning is entitled to hear what is playing, because they asked. What is held back is speech nobody asked for – check ticks, new-episode notices, download notices, reminders. This is why the implementation is a small vocabulary of kinds each call site opts into (quill/core/quiet_hours.py) rather than a gate around _announce: a gate would silence replies as well, and no amount of care at the call sites would make that safe.

18.6 The setup is portable, and says what it is not carrying

Requirement. One file carries everything this machine has been taught, out and back in.

OPML moves subscriptions and nothing else – which is to say it moves the part that was easy to standardise and leaves the part somebody actually built.

18.7 A place is a place, whichever app you were in

Requirement. An episode started in one app picks up in the other, on this machine, at the position the listener last decided on.

Listening Places already defined what a place is between devices. What was missing was one shared local store and the write on pause – Radio never wrote a position when you paused at all, so its handoff to Cast reported wherever you had last pressed Stop.

18.8 Adding something you already have

Requirement. Say so, name it, and move to the row that already exists.

The reason anybody adds a thing twice is that they could not find the first one, so a refusal that leaves the cursor where it was answers the wrong question. The root cause on the Radio side was a store that answered None whether or not it had added anything, so every caller announced success over nothing happening.

18.9 Find an episode inside one show

Requirement. Search the episodes of the show you are on, over titles and descriptions, composing with the state filter and the sort, announcing the match count on submit.

Between “filter by state” and a cross-library search there was nothing, so which episode of this show was the one about the harbour had no answer but arrowing two hundred rows. Descriptions are in scope because a show that numbers its episodes and puts the subject in the notes – most interview podcasts – is precisely where a title-only search finds nothing.

18.10 Timeline verbs mean the same thing in both players

Requirement. One parser and one dialog for “go to this position”, and one key for Skip Silence.

18.11 Every surface says what it is for

Requirement. F1 in QUILL Cast opens with an authored paragraph about the window, exactly as Quill Radio’s has since 3.0 – and a new window cannot ship without one.

The family-wide F1 engine had worked everywhere since 2026-08-23 and only Radio had authored purposes, so every Cast window answered with the generic sentence: true, and useless.

GATE-CAST-HELP (quill/tools/cast_help_audit.py) mirrors GATE-RADIO-HELP over the podcast UI. The scanner and snapshot rules both gates share now live in quill/tools/help_audit.py, so the two cannot drift.

19. The first launch (2026-08-24)

Requirement. A genuinely first launch shows three screens – welcome, add your first podcast, you’re set – and nothing else ever does.

The screens themselves shipped in 1.1. Nothing called them, for two releases, which is the failure Quill Radio’s equivalent carries a docstring about; a feature can be written, reviewed and fully unit-tested and still be entirely absent, because nothing in a test suite asks “and can anybody reach this?”.

Four rules the flow follows:

The state persists in PodcastHistory.onboarding, a nested record of ids rather than a version number – a tip added next year should fire for somebody who has used Cast for a year, and a version stamp would say they had seen it.

The wiring is what the tests assert. tests/unit/ui/test_podcast_first_run.py checks that the app actually calls the flow and that the launch shows its window first, alongside the behaviour: a test that only exercised the dialog would have passed throughout the two releases in which nobody could reach it.

20. Six answers Quill Radio already had (2026-08-24)

The two apps share an audience and a window model, and a feature built for one of them is usually right for the other. These six were Radio’s, and Cast had none of them. Where the machinery was genuinely the same it was extracted and is now shared; where the words were the product, each app keeps its own. That distinction is the requirement, not an implementation note: a shared dialog with the nouns swapped by parameter reads as neither app’s, and a second copy of a mechanism is how two apps come to disagree about it.

20.1 A sheet, not just an editor

Requirement. A listener must be able to find out which keys exist, not only change a key they can already name.

Help > Keyboard Shortcuts Sheet (Ctrl+Alt+Shift+K), filterable, generated by walking the live wx.MenuBar. Walking the menus rather than the keymap is the design: the sheet lists the keys this listener has, rebindings included, and cannot fall out of step with the menus because it is the menus. Radio’s implementation, called rather than copied – it asks its host for a frame, an announcer and a modal helper, all of which Cast has.

20.2 One key for every place

Requirement. Reaching a place must not require remembering which of a dozen chords opens it, and the numbering must never move.

Go To (Ctrl+G), ten places, 1-9 then 0. The machinery is quill/core/go_to_menu, shared; each app supplies its own catalogue (core/radio/go_to, core/podcasts/go_to) and its own file. The pool – every known destination not in the menu, derived rather than stored – is what makes the numbering permanent: a destination added by a later release can only land in the pool, so an upgrade cannot renumber what somebody has learned.

Cast’s previous “Go To” jumped to a time inside an episode. Two features with the same two words is worse than one missing feature, because the wrong one answers.

20.3 A word when a media tool is missing

Requirement. A capability the installation cannot deliver must be named once, in the listener’s words, and never mentioned again while it stays that way.

core/podcasts/media_health (pure) and ui/podcasts/media_preflight (the probe). Cast-specific rather than shared with Radio’s, because the sentences are the product and none of Radio’s survive the move – Cast does not record stations, and a listener told they had lost that would go looking for a feature that was never there.

The reason this matters more in Cast than in Radio: a missing playback engine announces itself, but every FFmpeg-dependent feature here fails by producing a plausible result. The download completes and is not trimmed; the chapter analysis finishes and finds nothing, which is exactly what an episode with no chapters looks like. Remembered by signature rather than a flag, so a machine repaired and later broken again is told again.

20.4 Closing means what you chose

Requirement. The titlebar X, Alt+F4 and Exit follow one persisted answer: ask, exit, or minimize to tray.

PodcastHistory.close_action, shipped as exit – what Cast has always done, because an upgrade that starts asking a question has changed somebody’s Alt+F4 under them. Ask only asks when something is at stake, and names it. The confirm never runs from inside EVT_CLOSE (AppShellFrame.handle_app_close vetoes and re-runs it deferred), because ShowModal from a close handler on wxMSW can return without displaying and silently veto the close.

20.5 A search box with a memory

Requirement. A search worth running twice must not be worth typing twice.

core/podcasts/search_history, on PodcastHistory.recent_searches, surfaced as the Search Everywhere box’s dropdown. One string rather than Radio’s three-field triple, because Cast searches one box across shows, episodes, notes and transcripts at once. It rides the history file the recently-played list already uses, so clearing that clears this – a second record of what somebody has been searching for, kept somewhere they did not know about, is the wrong answer for a list like this.

20.6 A backup you can restore

Requirement. A listener must be able to carry their library to another machine, and to get it back after losing one.

core/podcasts/backup, a .qcbackup zip with a manifest, restored in place with the running app reloaded afterwards. File-level – the JSON is copied verbatim rather than re-serialised – so a backup made by one version restores into another and a field this build has never heard of survives. Downloaded episodes are optional and off by default: they are tens of gigabytes and can be fetched again, where the 40 KB beside them cannot.

Export My Data is not this. It is a readable snapshot, deliberately, and it is kept.

20.7 A setting you can reach in one keystroke

Requirement. A per-show setting somebody changes often must be reachable without navigating a window of two dozen controls.

core/podcasts/single_settings (three descriptions, tested without a window) and ui/podcasts/single_setting_dialog (one control, focus on it). Episodes to Keep, Queue Expiry and Playback Speed, each also a Quick Action so the order is the listener’s. Every write goes through PodcastLibrary.apply_show_override, which writes only the field named as that podcast’s own opinion – setting a speed here cannot silently reset a retention rule set there, and cannot freeze one either. It cloned the whole effective record until section 22; that clone is the bug section 22 removes.

21. Rules for the episodes you did not want (2026-08-28)

Requirement. A podcast that publishes more than one thing must be followable without the parts you do not want arriving, being triaged by hand, and consuming an Inbox cap set for a different reason.

core/podcasts/models_filters (the record and its scopes), episode_filters (matching, preview, the save gate), episode_filter_speech (the reviewed word orders), episode_filter_maintenance (the library-facing half), and ui/podcasts/episode_filters_dialog + episode_filter_rule_dialog.

21.1 A rule set, and eight places to mean it

Per podcast: named rules, each independently switchable, each with an optional title criterion (wildcard or regular expression, case-insensitive by default) and an optional minimum duration. Within a rule the criteria are and-ed; across a configuration the rules are or-ed. Two modes – filter-matching and keep-matching.

The scopes are the half that matters: the Inbox, Auto-Queue, auto-download, the new-episode announcement, the podcast’s own episode list, the cross-show views, smart playlists, Search Everywhere. Ticked independently, so one feature answers “do not put it in front of me”, “do not spend my bandwidth on it” and “I never want to see it again” without any of them implying the others. A new filter ships with the four routing scopes on and the four hiding scopes off: declining to route is invisible and reversible, hiding changes what can be found.

21.2 Asked, not stamped

The verdict is computed at every point of use rather than written onto an episode at ingest. That is why unticking a scope takes effect on the next redraw with nothing to migrate and nothing to undo. Two things are still written down because they are events rather than opinions: a Play Queue slot removed by the explicit apply-to-existing pass, and the Needs review warning.

hide_predicate returns None for a podcast with no active filter in that scope, so the hot loops – the Inbox over a 1,300-podcast library, a smart playlist over every episode of every show – skip the question with one branch.

21.3 Fail open, four times over

An unusable rule never matches. A configuration with no usable enabled rule is not active. A configuration with no scope is not active. An unknown stored version, a malformed record or an unknown scope name reads as no filter. The cost of failing open is an episode you did not want; the cost of failing closed is a podcast that silently stops arriving and no message anywhere saying why.

21.4 The save is gated, and nothing hidden is unreachable

Refused: on with no enabled rule; a regular expression that will not compile (the compiler’s own message is quoted); every enabled rule empty; on with no scope; a minimum-duration rule where none of the newest 50 episodes publishes a duration. Asked, with exact counts: partial duration coverage, and any hiding scope – with the two ways back named in the question.

Preview evaluates the draft while the top-level switch is off and while no scope is ticked, because it answers “what do these rules catch?”, which is a question about the rules. The episode list’s state filter gains Filtered out, present for every podcast rather than only for filtered ones; and a per-episode exemption beats every rule in every scope and survives editing the rules.


22. A podcast can answer for itself (2026-08-29)

Requirement. Almost every complaint a podcast listener has is about one podcast behaving differently from the rest. A setting must be settable at the level it is actually meant at, and a shared default must keep reaching the podcasts that have no opinion about it.

22.1 Four levels, one resolver

shared default -> folder (outermost first) -> podcast, nearest wins, each level storing only the settings it has an opinion about (PodcastLibrary.scope_overrides; core/podcasts/settings_resolver).

Cast had three of these and they did not compose. A folder was not a level – Folder Settings wrote its values into each member podcast and forgot them, so a podcast filed there later inherited nothing. Worse, a per-show override was a complete copy of the settings record, which meant Cast could not tell “I have no opinion” from “I want exactly this”; once those are the same thing, changing a shared default silently stops reaching the podcasts most likely to need it.

PodcastLibrary.effective_settings keeps its signature – every existing caller gained the folder level unchanged – and its fast path returns the shared record by identity, because it is called per podcast per refresh and on some paths per episode. apply_show_override now writes exactly the fields named. Legacy whole-record overrides migrate at load by diffing against the shared default and keeping only what differs: a best-effort recovery, and deliberately the safer of the two readings.

22.2 A setting is described, not merely stored

SettingDef (core/podcasts/settings_types) carries the label, the house-rule help, the kind, the levels it may be set at, its choices, its search aliases, and the words its value reads back as. Definitions live beside the families they describe; settings_catalog assembles them.

Three things follow that were previously impossible: search that matches label, help, choice labels and synonyms somebody would really type (“wifi” finds the metered guard), ranked so a label match outranks a help match; a “what have I changed?” report, which is the question support always asks and the only honest basis for a reset; and generated controls, so a setting added to the catalogue appears in the per-podcast window with no UI code.

22.3 Provenance is a first-class answer

describe_provenance produces “Every 60 minutes, from the folder News.” An editor that shows 60 has told somebody the number; one that names the level has told them why, and where to change it for everything else in that folder too. It is in every control’s F1 help.

22.4 The twenty-five

Arrival: per-podcast check cadence; backfill on subscribe, distinct from the forward-looking download count; off-peak download windows; Auto-Queue from the oldest unplayed episode; transcript policy; permanent-redirect following; re-publish handling.

Playback: chapter policy surfaced per podcast; chapter-title skip patterns, exact where a seconds-based skip is a guess; silence-trim strength; a sleep-timer default; the session behaviour that was already overridable and unreachable.

Storage: a per-podcast disk budget; a never-evict pin, which exists because the only previous way to protect one archived podcast was to switch the sweeps off for everything; a preferred audio variant using the alternateEnclosure tags Cast already parsed and never used; a catalogue view limit for four-thousand-episode feeds – a view rather than a trim, because the obvious fix would have been the one thing here that destroys something.

Announcements: three-position notification priority replacing a boolean; a per-podcast quiet-hours exemption; a spoken-name override for titles TTS mangles; title-cleanup rules with a preview; a per-podcast earcon; the gone-quiet and failed-check notices; a global announcement budget; and per-podcast row speech.

Curation: season/episode sort read from itunes:season and itunes:episode; a default playlist; free-text labels usable as a smart-playlist rule; an artwork override.

22.5 What a row says

A screen reader reads every row of every list out loud, in full. Cast’s answer had been one boolean over a question with at least seven answers. core/podcasts/row_speech is the composer: a named order (title, podcast or date first) and independent switches for the podcast’s name, the date, the length or time remaining, the download state, the numbering, whether the episode has chapters or a transcript, and the description at off/brief/full. Per podcast as well as globally.

Two rules the composer keeps: nothing is said twice (the podcast’s name is dropped inside its own episode list), and every part is droppable with the row still parsing.

22.6 Where the numbering came from

PodcastEpisode gained season, episode_number and episode_type, all three of which were in the feed bytes all along and were being discarded. The numbering is the only dependable order a serial show has, because its published dates get re-stamped on a feed rebuild. A merge only ever takes them upward, so a partial feed cannot un-number episodes a sort is relying on.

22.7 The safety properties

Nothing added here deletes anything. Everything fails open – an unreadable value, an unknown choice, a clock that cannot be read all land on “carry on as before”, because these settings restrict and a restriction nobody chose is the worst kind. Both hiding settings name their way back in their own help. And every catalogue entry says what it does not do, enforced by a test.


See CHANGELOG.md for the full, versioned history.