Now-playing no longer lingers for the whole track after you stop or close. The "now playing" ping no longer sends the track duration. Neither Last.fm nor ListenBrainz offers a way to explicitly clear a now-playing status: each expires it after the duration you submit (ListenBrainz falls back to ~10 minutes when there is none). Conservatory had been sending the full track length, so closing the app partway through a song left it showing as "now playing" for the rest of the track. Dropping the duration caps that at the service's short fallback instead. The completed scrobble still carries the duration; only the ephemeral now-playing update omits it.
Fix: an accent flash across the whole window when pressing a bare modifier (e.g. switching workspaces on a tiling compositor). The keyboard-focus ring was defined on the universal * selector, so when GTK entered keyboard-focus-visible mode (which a bare modifier press like Fn+Win / Ctrl+Super triggers), it outlined every widget in the focus chain at once, flashing the accent across the window before it faded. The focus ring is now scoped to the discrete interactive controls (buttons, entries, switches, sliders); list rows already show their position through the selection highlight, so keyboard navigation stays clear. Purely visual; no behaviour changes.
Phase 9d: scrobbler parity, now-playing and the submission rule. Scrobbling grows up from "submit a track when it finishes" to what DeaDBeeF, foobar2000, and the rest actually do.
- Now playing. When a music track or podcast episode starts, Conservatory tells your history service what you are listening to (Last.fm
track.updateNowPlaying, ListenBrainzplaying_now). It is a live, best-effort ping: never queued, silent on failure, and sent only when scrobbling is on. Audiobooks are excluded, as they are from scrobbling generally. - The real submission rule. A play now scrobbles once it has earned it: a track longer than 30 seconds, played to at least half its length or four minutes (whichever comes first). This replaces the old "only a fully-played track scrobbles" behaviour, so a long track you skip near the end now counts, while a 15-second interlude played in full no longer does. A track that reaches its natural end still scrobbles on completion (subject to the 30-second floor).
- The listen is stamped with when the play began, not when it ended, matching what both services expect and how they de-duplicate.
- Under the hood the play's progress is tracked independently of the local play-count, which stays end-of-file-only: a "play" for your library is a finished track; a "listen" for a history service is the rule above. The scrobble is submitted on any way a play ends, a natural finish, a skip, a stop, a queue change, or quitting mid-track, so nothing that earned a listen is lost.
Phase 9c: Last.fm as the optional second scrobble target. The scrobble subsystem, ListenBrainz-only since Phase 9b, now speaks Last.fm too, behind the same off-by-default config, outbox, and Preferences page.
- Completed music tracks and podcast episodes submit through Last.fm's
track.scrobble, signed per request with anapi_sig(MD5 over the sorted params plus the shared secret; the newmd-5dependency). Last.fm reports success or failure in the response body rather than the HTTP status, so it gets its own error classifier: service-down / temporarily-unavailable / rate-limited retry with backoff, a bad session key parks, and nothing is ever dropped. The outbox'sservicecolumn already routed per listen, so both services can hold queued listens at once. - The Last.fm application key and shared secret are config-backed, in
[scrobble](lastfm_api_key/lastfm_api_secret), deliberately not baked into the binary: each user registers their own API account. The per-user session key is obtained through Last.fm's desktop web-auth flow and stored in libsecret, next to the ListenBrainz token. - Preferences → Sync grows a Last.fm group: Connect opens last.fm in your browser to approve access, Finish exchanges the approved token for a session key, and Disconnect clears it. With the app credentials missing from
config.toml, the group explains what to add. - The CLI
scrobbleverb gainsconnect(the two-step web-auth flow: run it once for the approval URL, then again with--token <token>to store the session), andflush/testnow work for Last.fm as well as ListenBrainz. - Scope is unchanged from 9b: audiobooks never scrobble, and with
[scrobble] enabled = false(the default) nothing is contacted.
Phase 19a-ii: the waveform seek bar. The Now-bar's seek slider is now the track's loudness envelope. Played audio is drawn in the album accent, the rest dimmed, mirrored about a centre line; a tap or drag anywhere on it seeks.
- The envelope (computed and cached in 19a-i) loads off the GTK thread, so a track change never stalls the interface, and it is applied only if that track is still playing when the decode finishes.
- Music tracks show the waveform; a podcast episode, an audiobook, or any item without a decodable local file falls back to a flat seek line, so the bar always works as a plain scrubber.
- The same widget is built to be reused at a larger size in the Now Playing drawer and, later, the full-screen surface.
Phase 19a-i: the waveform envelope, headless. The first slice of the immersive tier lands the whole-track loudness envelope that will back the GUI waveform seek bar, computed and cached off the GTK thread.
- A new core module decodes a track to a normalized peak-and-body envelope with an offline ffmpeg pass (the same external-tool approach as the integrity verifier and the ReplayGain scan; no new dependency). libmpv has no offline decode and the spectrum visualizer's PipeWire tap only sees audio as it plays, so a dedicated decode is the right tool for an envelope you need before playback reaches it.
- The result is cached under
~/.cache/conservatory/waveforms/, keyed by file path, modification time, bucket count, and format version, so a track is decoded once and re-read instantly until it changes. - A new
waveformCLI verb exercises the whole path headless: an ASCII sparkline per track by default, or the raw peak/RMS arrays with--json. - The GTK widget that draws this (replacing the Now-bar seek slider, accent-tinted, click-and-drag to seek) follows next in 19a-ii.
Phase 9b: scrobbling goes live in the app. The outbox that 9a built headless now fills from real playback and drains from the running GUI, still off by default and local-first.
- The player engine's play-completion path (a natural end-of-file) now enqueues a listen when scrobbling is on. The metadata is resolved once, atomically, off the single-writer connection and snapshotted into the outbox, so a later library rename can never corrupt what gets submitted. Scope is honoured: music tracks and podcast episodes scrobble; audiobooks never do (a 14-hour book is not a "listen"), enforced both at the engine and in the resolver.
- The GUI spawns the background submitter on its runtime (the same pattern as MPRIS), holding an abort handle so a settings change can restart it cleanly.
- A new Preferences "Sync" page: enable scrobbling, pick the service (ListenBrainz or Last.fm), and paste plus validate your ListenBrainz token. Validation resolves your user name so a bad paste is caught before a single play is sent. The token lives in libsecret, never in the config file; the enable and service settings apply when you close Preferences.
- ListenBrainz is the wired target here; Last.fm arrives next in 9c. The ephemeral "now playing" ping is deferred as a small follow-on (it is a live update, not part of the completed-play queue).
Phase 9a: the scrobble outbox and the ListenBrainz client (headless). The first sub-phase of the 0.4.0 "Immersive & history" milestone lands the local-first spine of listening-history sync (spec §14 carve-out), off by default and fully inert until enabled.
- A completed play is queued into a new
scrobble_outboxtable (migration 0020) that snapshots the listen metadata, so a rename can never corrupt history and submission needs no join. A background submitter drains the outbox and deletes on success, reschedules with exponential backoff on a transient failure (429 / 5xx / offline), and parks a permanent failure (a bad token) rather than losing the listen. Nothing is ever dropped. - ListenBrainz leads (open, self-hostable): a client builds the
submit-listenspayload and validates a token, pointed at the public instance or a self-hosted one. - A
[scrobble]config section (enabled = false,service = "listenbrainz"); the user token lives in libsecret, never the config file. - A
scrobbleCLI verb is the headless surface:status,token set/clear,flush(force a drain), andtest(validate the token). - Groundwork move: the
CredentialStore(libsecret viaoo7) is promoted fromconservatory-podcaststoconservatory-coreas the app-wide secret store, shared by the podcast Basic-auth credentials and the scrobble token. The podcast crate re-exports it, so its public API is unchanged.reqwestandoo7now build into core (scrobbling covers music, the native program, not just the podcast plugin). - Not yet wired: the engine's play-completion hook that enqueues real plays, the Preferences "Sync" group, and Last.fm as the optional second target. Those are Phases 9b and 9c.
The Hyprland-native milestone: Phase 26, de-adwaita, complete. Conservatory now runs the same feature set on plain GTK4 with zero libadwaita symbols in the workspace, wearing its own flat, tiling-first Kanagawa Dragon stylesheet on all three tabs.
- Every adwaita surface was replaced by owned widgets across v0.2.1 to v0.2.12: shared rows and groups, the alert dialog, status pages, the toast, the shortcuts reference, the Preferences window, the tab switcher with its 550px narrow collapse, and the window shell itself (flat titlebar, no window buttons; your compositor and Ctrl+Q own closing).
- The look is spec §2.4: flat, square, 1px hairlines, denser spacing, Dragon hexes baked into a generated sheet that outranks user CSS skins. The lifted cover cards deliberately keep their shadow; chrome is flat, content imagery stays lifted.
- Environment notes for a GNOME-free session (portals, Secret Service, waybar MPRIS, window rules) live in the new
docs/hyprland.md. - No feature regressed, the app runs unchanged under GNOME, and the memory budgets hold; a full re-theme with fewer linked libraries.
Milestone note: 0.3.0 was pulled forward from the original 2.2.0 slot once the Colophon pilot proved the patterns. Next on the runway is 0.4.0 (Phase 19 immersive polish plus Phase 9 scrobbling).
De-adwaita sub-phase 26m, the toolkit cut: libadwaita is gone. The dependency leaves the workspace and binary manifests, adw::Application becomes gtk::Application, and ForceDark retires (the owned sheet plus gtk-application-prefer-dark-theme carry dark-only now). meson and CI drop the libadwaita dev packages; meson's GTK floor aligns to the real 4.14 pin. Spec, keymap, and README sweep to the plain-GTK posture. cargo tree shows zero libadwaita under both feature sets. Same features, same look as v0.2.11.
De-adwaita sub-phase 26l, the visual flip: Conservatory now paints itself with its own generated stylesheet (theme.rs), Kanagawa Dragon baked in, installed above USER priority so neither the still-linked adwaita sheet nor a themed ~/.config/gtk-4.0/gtk.css can override it. The look is the spec §2.4 language: flat, square, 1px hairlines, denser spacing; buttons, switches, sliders, spin buttons, dropdowns, popovers, tooltips, scrollbars, selections, and the toast are all squared and Dragon-coloured. The lifted cover cards deliberately keep their radius and shadow (chrome is flat; content imagery stays lifted, and the accent ring layers onto that shadow). docs/theme.md rewritten to the owned posture.
De-adwaita sub-phase 26k2, the shell cut: the window is now a plain gtk::ApplicationWindow. The header becomes a real titlebar (gtk::HeaderBar) with no window buttons (the spec §2.4 tiling posture; Ctrl+Q and your compositor's binds close the window), and the AdwToolbarView becomes a plain vertical box (content, status bar, Now-bar, narrow switcher bar, preserving the §2.3 stacking rule). Zero adw widgets are constructed anywhere in the app now; only main.rs's application object and the stylesheet remain for the final cut.
De-adwaita sub-phase 26k1: the three-tab machinery goes plain GTK. The view stack is a gtk::Stack, the header switcher a text-only gtk::StackSwitcher (Music | Podcasts | Audiobooks; the tab icons retire, per the spec §2.4 posture), the adaptive bottom switcher bar a second switcher in its own bar, and the narrow-width collapse a hand-rolled width watcher at the same 550px threshold the AdwBreakpoint used. The lazily-built plugin tab hosts become plain boxes. Also fixes the music-only CI lane, red since v0.2.2 on a dead-code lint in the status-page composite.
De-adwaita sub-phase 26i, the riskiest step: the Preferences window is rebuilt as a plain modal window with a text page switcher (General | Library | Sound) over owned groups and rows, replacing adw::PreferencesDialog and the whole preference-row family. The DSP compressor / limiter / leveler become owned expanders whose enable switch reveals their settings. Every handler body is unchanged (containers only): live per-band EQ, the DSP rebuild path, backend/device/resampler live-apply, config save-on-close, and audio-state persist-on-close all wire exactly as before. The audible A/B acceptance pass is grouped into the phase's hands-on matrix.
De-adwaita sub-phase 26h: the Audiobooks tab is now adwaita-free. Remove-from-library, per-book playback settings, the bulk edit grid with its error-and-retry loop, and the re-shelve move confirm all run on the owned alert and rows. Only window.rs (shell + Preferences), sound.rs, and main.rs still name adw types.
De-adwaita sub-phase 26g: the Podcasts tab is now adwaita-free. The per-show settings dialog (speed, Smart Speed, Voice Boost, intro/outro skip, quick-seek overrides, inbox policy), the subscribe prompt with its error-preserving retry, the unsubscribe confirm, and the delete-downloads confirm all run on the owned alert and rows, including a new dropdown row for the inbox policy. Behaviour unchanged.
De-adwaita sub-phase 26f: the shared rows module grows switch and spin rows plus a preferences-group successor (heading + dim description over a boxed list). First consumers: the two Now-bar playing-settings dialogs (per-show and per-book speed / Smart Speed / Voice Boost, quick-seek overrides) now run entirely on owned widgets, including their alert shell from 26e. Live-apply on the playing item is unchanged.
De-adwaita sub-phase 26e: an owned modal alert dialog (ui/dialogs.rs) replaces adw::AlertDialog across thirteen window.rs prompts (EQ preset save, remove-from-library, bulk edit and its error loop, move-files confirm, embed-tags pair, the error dialog, Perspective save/delete, playlist create both flavours, playlist delete). Named responses, suggested/destructive button styling, Escape and window-close answering with the close response, and Enter confirming the default all behave as before. The two per-show/per-book playing-settings dialogs follow in 26f with the row rebuild they depend on.
De-adwaita sub-phase 26d: toasts are now an owned overlay (a crossfading auto-hide revealer over the content area) instead of adw::ToastOverlay. Same four-second, newest-wins behaviour: a burst of confirmations keeps the latest one up for its full window. Styled as a pill to match the adwaita look until the owned stylesheet lands.
De-adwaita sub-phase 26c: every empty state now renders through an owned composite (ui/status_page.rs) instead of adw::StatusPage. Same seven surfaces (empty track list, filtered no-matches, the podcast tab call-to-action, empty episode list, empty audiobook shelf, idle Now Playing drawer), same copy, same runtime title/description swaps.
The first step of Phase 26 (de-adwaita, the 0.3.0 milestone; sub-phase 26b). Conservatory is migrating from libadwaita to plain GTK4 with its own flat, tiling-first stylesheet; this release lays the plain-GTK foundations. Nothing changes visually yet: the adwaita stylesheet still themes everything until the owned sheet lands late in the phase.
- The keyboard-shortcuts reference (
F1) is now a hand-rolled plain-GTK window (ui/shortcuts.rs) over shared owned list rows (ui/rows.rs), with the same curated key table. Escape closes it via a new shared helper. - The About dialog is now
gtk::AboutDialog(a plain toplevel) instead of adwaita's in-window sheet. Same contents.
The first milestone on the road to 1.0: Phase 18, "grammar and columns." Accent-insensitive search shipped in v0.1.27; this release adds configurable browse columns and tags 0.2.0.
- Choose your browse columns. The track list is no longer a fixed set. A new "Browse columns" section in Preferences lets you show or hide Year, Track number, Bitrate, Format, Play count, Date added, and Last played alongside the usual Artist / Album / Genre / Title / Duration / Rating, each sortable by its header. An unconfigured library looks exactly as before; changes take effect on the next launch.
- The numeric and date columns render in the app's monospace face, so they line up cleanly.
- Under the hood this is deliberately a curated, built-in set rather than a free-form formatting language: every extra column is memory on every row of a large library, so the set stays practical. Reordering columns from the editor is a planned follow-on (the config file already accepts any order).
Milestone note: 0.2.0 is the "grammar and columns" capability tier complete. Next up on the runway to 1.0 is 0.3.0 (immersive polish plus optional scrobbling).
Accent-insensitive search (Phase 18a, the first step toward the 0.2.0 "grammar and columns" milestone).
- Searching
bjorknow findsBjörk. Substring, quoted, and fuzzy matches ignore diacritics, so you no longer have to type the accents (or know how to).Sigur Rós,Motörhead,Beyoncé,Antonín Dvořákall come up from their plain-ASCII spelling. Exact (=) and regex (~) matches stay literal, and folding only ever finds more, so it can never turn a search into an error. - It works the same whether the query runs on the fast database path or the in-memory path (bare-text search folds via the search index; the one place it does not yet fold is an accented value behind a specific field like
artist:bjorkon the fast path, noted in the grammar doc). - Housekeeping:
vl:NAMEwas already the "reference a saved search by name" feature the roadmap called for, so that part of the grammar work was already done; this release is the accent-folding half.
Queue-vs-playlist clarity (Phase 17d, closing the Phase 17 set).
- The track right-click menu now separates the queue from playlists. "Play", "Play Next", and "Add to Queue" sit under a "Play queue" heading (they all act on the now-playing queue); "Add to Playlist" sits under its own "Playlists" heading (a saved, named list). Previously "Add to Playlist" was tucked in next to Edit and Rating, where it read as just another flavour of "Add to Queue". A short "queue vs. playlists" explainer joins the keymap doc.
Context-aware ReplayGain (Phase 17c, the third of the Phase 17 set).
- ReplayGain now follows how you are listening. Playing an album in order uses album gain, which preserves the quiet-to-loud dynamics the artist intended across the record. Turn on shuffle and each track is judged on its own with track gain, so a quiet album cut does not arrive jarringly soft next to a loud single. The switch is instant: toggling shuffle re-levels the currently-playing track without a gap.
- This only applies in the default album ReplayGain mode; if you have pinned the mode to track or off, it stays exactly as set. Podcasts and audiobooks are unaffected (they carry no ReplayGain).
- A track that only has one of the two gains keeps using it in both contexts, so nothing ever loses normalization.
Shuffle (Phase 17b, the second of the Phase 17 "player table-stakes" set).
- A shuffle button on the Now-bar, and
Ctrl+K. Shuffle reorders the queue in place: the upcoming tail is shuffled while the played and currently-playing items stay put, so what you see in the queue is still exactly what plays (Conservatory keeps the queue as the source of truth, never a hidden play order behind it). Turning shuffle off leaves the order as-is; it just stops governing new plays. - Playing something new while shuffle is on plays it shuffled. Double-clicking a track, playing a facet, or playing a playlist puts the item you picked first and shuffles the rest.
- Shuffle composes with repeat-all: each lap around the queue is a fresh shuffle.
- It is remembered across restarts, on the same settings row as repeat. A resumed queue keeps its saved order (it is not reshuffled out from under you on launch); the flag simply governs the next thing you play.
- Under the hood, the database queue and the live engine queue always apply the same shuffle, so they never drift out of step; a stale reorder is ignored rather than scrambling anything.
Repeat comes to the player (Phase 17a, the first of the Phase 17 "player table-stakes" set).
- A repeat button on the Now-bar, and
Ctrl+R, cycling off → all → one. Repeat-all wraps the queue back to the top instead of stopping; repeat-one loops the current item. The button dims when repeat is off and shows a distinct glyph for repeat-one, so the mode reads at a glance. - The mode is remembered across restarts, stored on the same DB-backed audio settings row as ReplayGain and gapless.
- It plays nicely with the stop-at-a-boundary features. Stop-after-current and an end-of-item sleep timer still pause exactly where they should, even under repeat; an end-of-queue sleep timer still ends the queue rather than looping it forever. Repeat-one never prefetches the next track (there is nothing to hand off to when the current track is about to replay).
- Groundwork for shuffle (Phase 17b) rides along: the settings row gained a shuffle flag, unused until shuffle lands next.
Gapless playback is now actually gapless.
- Album transitions no longer gap.
gapless-audiowas set all along, but the engine advanced by reacting to end-of-file and issuing a fresh load, which tears the decoder down; mpv can only decode across a boundary it knows about in advance. The engine now prefetches: when the next queue item is also a gapless track, it is appended to mpv's internal playlist ahead of time, mpv crosses the boundary seamlessly, and the engine syncs its bookkeeping (play counts, cursor, Now-bar) at the event instead of reloading. The end-to-end engine test now drives three prefetched hand-offs across four codecs. - Everything that changes "what plays next" resyncs the prefetch. Play Next, queue reorders and removals, appends, stop-after-current, and the end-of-item sleep timer all drop and re-derive the appended entry; the stop-at-boundary features refuse the hand-off outright, so they still pause exactly at the boundary (verified by the existing boundary tests).
- Spoken word keeps the explicit load path. Episodes and audiobooks never hand off gaplessly: they carry listening sessions, resume seeks, and per-show chains, and gapless between speech items is meaningless anyway.
- One caveat, documented in
docs/libmpv-profiles.md: the per-track ReplayGain head swaps at the transition event, so with track gain mode a large gain step can bleed for the buffered fraction of a second. Album gain within one album is identical, so the common gapless case (an album played in order) is artifact-free.
Seeks are now sample-exact.
- Every seek lands where it says (
hr-seek=yeson the libmpv host). mpv's default seeks snap to the nearest seek point, which on hour-scale audiobook and podcast files can be seconds away: a chapter jump could land mid-sentence before the chapter, and the 15-second skip-back could overshoot. Chapter navigation, the Now-bar skip buttons, scrubbing, and resume-on-launch all become exact. Audio-only decode makes the accuracy essentially free.
Subscribing to a podcast no longer floods the Inbox with its back catalog.
- Only the newest episode enters the Inbox on subscribe (the Castro / Overcast convention). The rest of the back catalog still arrives as browsable rows under the show, but archived, so a decade-old feed adds one Inbox entry instead of four hundred, and the sidebar badge stays meaningful. Ties and date-less feeds fall back to feed order, so exactly one episode routes either way.
- Chapter prefetch skips the archived back catalog. Chapters were fetched "on first sight" for every new episode, which at subscribe time meant one HTTP request per back-catalog episode, sequentially, before the subscribe finished. Now only the routed episode prefetches; later refreshes fetch chapters for genuinely-new episodes exactly as before.
- Later refreshes are unchanged: a genuinely-new episode routes through the show's inbox policy (Inbox / queue / archive), and nothing ever re-routes an episode you have moved by hand.
The move journal gets an inspection surface, closing the sweep's second finding: a wedged move job could block every organize and import forever with no way out.
organize --jobs. Lists every move job, newest first, with its kind, mode, state, and applied/total operation counts, in all three output formats. Runs without the recovery gate, so it works precisely when recovery cannot.organize --cancel-job <ID>. The escape hatch for a job that can never roll forward (a source file gone with nothing at its destination): marks the stuck in-progress job failed, which recovery then skips. Touches no files and no database paths;organize --undo <ID>still reverts whatever the failed job had applied. This is the first use of thefailedjob state the schema has carried since migration 0002. Only an in-progress job can be cancelled; anything else refuses with an explanation.- Recovery failures now say what to do. The five CLI recovery gates point at
organize --jobsinstead of a bare "recovery" error, and the GUI no longer swallows a failed recovery silently: a path-affecting edit that cannot recover the journal toasts the error and leaves the files unmoved rather than journaling a new move on top of a wedged one.
A bug-sweep release: the queue can no longer drift out of sync after a library delete.
- Deleting library items now keeps the queue honest. Removing a track, a book, or a whole podcast subscription used to leave holes in the saved queue's position numbering (and a playlist's, if the track was in one). Every position-keyed operation after that (Play Next, removing or reordering a queue row) could then address the wrong row or silently do nothing, and the saved queue drifted out of lock-step with the live one, so the order visibly changed on the next launch. The delete and the renumbering now commit as one transaction, with a regression test asserting the queue and playlist positions stay dense.
- The live queue follows the library. Removing tracks or books from the library, or unsubscribing from a show, now also drops those entries from the playing queue instead of leaving ghosts the queue drawer could not see. Behavior change, deliberate: if the item you delete is the one playing, playback advances to the next queue entry rather than continuing to play something the library says is gone (v0.1.16 kept it playing).
- Internal:
insert_book's doc comment had been fused ontodelete_book; re-attached.
The chrome learns what tab you are on and what is playing. This closes Phase 16.5, the UX completeness pass.
- The window title follows playback. Playing anything, the title bar reads "Title - Artist - Conservatory" (a track, an episode's show, or a book's author), so the task switcher and window list finally say what is on. Idle reverts to plain "Conservatory".
- The status bar reports per tab. Music keeps its live count-and-playtime aggregate; the Podcasts tab now shows the triage totals ("5 in Inbox · 2 queued · 40 played") and the Audiobooks tab its shelf summary ("12 books · 3 finished"), each refreshed when you switch to it.
- The status bar's right side now ellipsizes like its left, so narrow windows never get forced wider by a long readout.
The book right-click menu reaches parity with the track menu.
- Play Next. Insert the selected books right after what is playing, with the engine and the saved queue moving in lock-step, exactly like tracks.
- Mark Finished / Unfinished. A quick toggle in the context menu and as a button in the detail pane. Finishing uses the same path a natural end-of-book does; unfinishing keeps your resume position and per-book playback settings. The shelf badge follows immediately.
- Reveal in Files. Open the book's folder in your file manager.
- Remove from Library. Behind the same destructive confirmation as tracks, with the same contract: the database rows go (chapters, progress, queue entries, search index, all via the schema's cascades), the files stay on disk, and a playing book keeps playing.
The audiobook shelf catches up to the rest of the app.
- Progress and finished state, visible on the shelf. A book you are partway through shows a thin progress bar under its cover; a finished book wears a small checkmark badge on the cover's corner. Until now both lived only in the detail pane, so the shelf could not answer "how far am I?" at a glance.
- The book editor gets the music editor's manners. Each field now has a checkbox and pre-fills the selection's shared value ("multiple values" when books differ); only ticked fields write. A bad year or rating no longer closes the dialog silently: the errors are listed and the editor reopens with everything you typed intact.
- A sort picker for the shelf. In-progress-first stays the default; Title, Author, and Recently Played join it.
- An empty shelf explains itself. A fresh library shows the exact import command; a filter that matches nothing says so instead of showing a blank grid.
- Book actions now toast. Edits, re-shelve moves, and playback-settings saves all report what happened, like their music counterparts.
Spoken word gets its transport conveniences.
- Skip back / skip forward on the Now-bar. Playing an episode or an audiobook, two new buttons flank play/pause: back 15 and forward 30 seconds by default, clamped so a skip never runs past the end. Each show can set its own amounts in its settings (the schema fields existed since Phase 6; the dialogs finally expose them), and the button labels follow.
- Show notes keep their links. Notes used to be flattened to plain text at ingest, destroying every link a feed put in them. The sanitizer now preserves links, bold, and italics (and nothing else); the detail pane renders them clickable. Episodes stored under the old format heal on their next feed refresh.
- The gear works for audiobooks too. The Now-bar's playback-settings button now appears while a book plays, opening its per-book speed / Smart Speed / Voice Boost dialog with live apply, the same as episodes; before this it meant a trip back to the shelf's detail pane.
- The Sound page says what applies to what. The equalizer and dynamics groups note they apply to everything; ReplayGain notes it is music-only (podcasts and audiobooks carry no ReplayGain).
Episode downloads move into the window.
- Download from the right-click menu. Episodes have always streamed, and the CLI could download them; now the episode context menu can too, for one episode or a whole selection. Downloads stay opt-in (no auto-download by default, as ever).
- Live progress. While a download runs, the episode's availability glyph turns into a syncing icon whose tooltip tracks the percentage; completion toasts and flips the row to Downloaded. Failures toast the actual error, and running the verb again retries.
- Delete Download. The counterpart verb removes the selected episodes' files behind a confirmation that says what it means: the files go, the episodes stay, streaming still works. It rides the same deletion path as the keep-N retention pruning, so there is exactly one way files get removed.
The episode list grows up: select many, sort anything, see where you left off.
- Multi-select triage. The episode list takes Ctrl+Click and Shift+Click; Mark Played, Archive, Star, and Add to Queue now act on the whole selection, with a toast reporting the batch ("Archived: 12 episodes"). Right-clicking inside a selection keeps it, so the context menu drives batches too.
- Sortable columns. Episode, Date, and Length headers now sort on click (and reverse on a second click); the default order stays what it was, newest first, or queue order in the Queue bucket. Double-click still plays exactly the row you hit, whatever the sort.
- Counts in the sidebar. Inbox, Queue, and Played show their episode counts, and each show carries its unplayed count; the numbers follow every triage action without disturbing your selection.
- Resume at a glance. A partly-heard episode's Length cell reads "43:10 · 29%", so the in-progress state glyph now comes with the actual position.
- Keyboard triage, wired.
Qqueues the selection,Imarks it unplayed (back to the Inbox), andCtrl+1/2/3jump between the triage buckets, all as keymap.md always promised. - Show settings from anywhere. The episode right-click menu gains "Show Settings…", opening the episode's show's settings without hunting for the show in the sidebar.
The podcast subscription lifecycle arrives in the window. Until now, subscribing, unsubscribing, refreshing, and OPML were all command-line only; the tab could only browse what the CLI had set up.
- Subscribe from the app. A
+button in the podcast sidebar's new footer toolbar opens a feed-URL dialog. Success lands you on the new show with a toast; a bad URL re-opens the dialog with your URL preserved and the actual error explained. - Unsubscribe, with a real confirm. The per-show settings dialog gains an Unsubscribe button behind a destructive confirmation that says exactly what happens: episodes leave the library and the queue, downloaded files stay on disk. A playing episode keeps playing.
- Refresh on demand. The footer's refresh button (or
R) refreshes the selected show, or every subscription when a bucket is selected, and reports a summary toast ("Refreshed 12 shows: 3 new episodes"). Each show's header now says when its feed was last refreshed. - OPML import and export. The footer menu (and
Ctrl+Shift+O) imports an OPML file, reports how many subscriptions were new, and refreshes the feeds so episodes arrive in one step; export writes your subscriptions (with tags) back out. - Empty states that point somewhere. A library with no subscriptions now shows a Subscribe / Import OPML call-to-action instead of bare headers, and an empty Inbox, Queue, or show list explains what would fill it.
- Show settings saves now toast, and all network work runs off the UI thread (the window stays responsive during a 50-feed refresh).
A round of small answers to "what is this, and why is nothing happening?"
- The filter bar explains itself. Hovering it shows a search-grammar primer (fields, operators, examples), and when a query degrades the yellow tint now comes with the parser's actual warnings in the tooltip instead of a colour and a shrug.
- An About dialog joins the header menu, and the empty-library screen now shows the exact import command instead of vaguely suggesting you import music.
- Buttons that need a selection say so. Edit and Embed grey out when nothing is selected rather than silently doing nothing, and the Embed tooltip now says it writes tags into the audio files on disk.
- Honest labels everywhere else. The properties inspector marks itself read-only (edit with Ctrl+E); an empty facet column explains that nothing matches the selection; long Perspective and playlist names get full-name tooltips; every config-backed Preferences group is labelled "takes effect on the next launch"; the Now-bar's streaming icon says the episode is simply not downloaded (it was reading like a stall warning); and opening the Now Playing drawer after the queue has finished shows the idle page instead of the last item dressed up as playing.
Deleting asks first, and a failed edit explains itself.
- Delete confirmations for Perspectives and playlists. The sidebar trash buttons now ask before deleting, naming what you are about to remove. Both deletions are permanent (a curated playlist is not reconstructible), so they get the same destructive-confirm treatment Remove from Library already had.
- The bulk editor surfaces bad values instead of swallowing them. Entering an invalid value (a non-numeric year, a rating above 5) used to close the dialog and silently apply nothing, with the reason buried in the terminal. Now a dialog lists exactly what was wrong, and the editor reopens with everything you typed and ticked still in place, so fixing one field never means re-entering seven.
- The bulk editor's checkboxes also explain themselves: the tooltip now spells out that a ticked field is written to every selected track, overwriting differing values.
Turn tabs off, and a header that follows the tab.
- Enable or disable the Music, Podcasts, and Audiobooks tabs. Preferences → General has a new Sections group. Turn a section off and, on the next launch, its tab is gone and none of its machinery starts (a disabled Podcasts section never spins up its feed workers, for instance), so the app carries no weight for parts you do not use. Music stays as a fallback so the window is never empty. This is separate from the build-time plugin switch; it is a per-launch preference over what is built in.
- The toolbar follows the active tab. The buttons that only make sense for music (Edit, Embed tags, and the track Properties panel) now hide themselves when you switch to the Podcasts or Audiobooks tab, and come back on Music. The playback, preferences, output, and menu controls stay put everywhere.
Playlists come to the window.
- A Playlists section in the sidebar. The left sidebar now has two labelled parts: Perspectives on top (saved searches, unchanged; the save button there saves your current filter as one) and Playlists underneath. Playlists list your static and smart playlists; clicking one plays it, filling the queue with its tracks in order.
- Create playlists from the app. A
+button offers New Static Playlist (a hand-curated list) and New Smart Playlist. The smart-playlist dialog pre-fills its query from whatever is in the filter bar, so it doubles as "save this search as a playlist", and lets you set an optional size cap and a prioritisation order (newest, highest-rated, least-recently-played, title, or artist). A trash button deletes the selected playlist. - Add to Playlist, by right-click. The track right-click menu gains an "Add to Playlist" submenu listing your static playlists; pick one to append the selected tracks. (Smart playlists are rules, so they are not shown there.)
Playlists arrive, from the command line first.
- Static and smart playlists (CLI). A new
playlistcommand family creates and manages two new kinds of playlist. A static playlist is a frozen, hand-ordered list; a smart playlist is a live rule (a search expression, with an optional size cap and a prioritisation order) that resolves fresh every time. These are deliberately distinct from Perspectives, which stay saved browse queries: a Perspective filters what you are looking at; a smart playlist is a queue source with its own limit and order. Order keys are added, rating, least-recently-played, title, and artist (a shuffle order arrives with the Phase 17 shuffle work). Useplaylist create-static,create-smart,add,list,show, anddelete. The graphical Playlists surface (a sidebar section and a visual rule builder) follows next. - Under the hood: a new database migration adds the playlist tables (core-owned, so they exist in every build), with the storage and a fast SQL order/limit primitive in the engine and the query evaluation in the CLI, keeping the core engine free of the search grammar by design. Covered by new tests.
The bulk metadata editor shows what you are editing.
- "Multiple values" in the bulk editor. Editing several tracks at once, each field now pre-fills the value the selection shares, and reads "multiple values" when they differ, so you can see the current state instead of a row of blank boxes. Each field has a checkbox: only ticked fields are written (and editing a field ticks it for you), so a shared value is never silently rewritten and you choose exactly what to change. Path-affecting edits still go through the same dry-run move preview.
Click a star to rate.
- Click-to-rate in the track list. The rating column's stars are now interactive: click where you want the rating to land and it fills to there (one through five), and clicking the star it already fills to clears the rating back to zero. The rating is written immediately, and only that one row repaints, so it stays instant on a large library. The properties inspector's rating follows along when it is open. The context-menu Rating submenu from v0.1.3 still works for whole selections.
Right-click context menus arrive, with Play Next and Remove from Library.
- Context menus everywhere you browse. Right-clicking (or long-pressing) a row now opens a menu on all five surfaces: the music track list (Play, Play Next, Add to Queue, Edit…, a Rating submenu, Reveal in Files, Remove from Library), the facet panes (Play / Play Next / Add to Queue over the facet's set), the queue drawer (Remove from Queue, Clear Queue), the podcast episode list (Play, Add to Queue, Mark Played/Unplayed, Star/Unstar, Archive), and the audiobook shelf (Play, Add to Queue, Edit…). Right-clicking a row that isn't already selected selects just it first, the familiar file-manager behaviour, and every verb reuses an existing keyboard or button path, so nothing is menu-only.
- Play Next. A new verb that drops the selection into the queue just after whatever is playing, rather than at the tail, so you can cue something up without disturbing the rest of the queue. The live player and the on-disk queue are inserted at the same spot, so they stay in step.
- Remove from Library. Removes tracks from the library from the track-list menu, behind a confirm. It is a database-only unlink: the files stay on disk (so the tracks are re-importable), and the removal cleans up after itself; the queue entries and the playback cursor drop away through the schema's cascades, and search stays in sync.
- Under the hood: a reusable right-click gesture for list rows, a new engine queue-insert command with a unit-tested index-adjust helper, and a matching database queue-insert plus a track-delete, all covered by new tests. Both the full and music-only builds are green.
Smart Speed gains a level.
- Smart Speed level (Gentle / Balanced / Aggressive). A new control on the Sound settings sets how aggressively Smart Speed trims dead air, so you can trade gentle cuts for more trimming. Gentle is the v0.1.1 tuning and stays the default; Balanced and Aggressive act on shorter, quieter pauses. Measured on a real episode with ffmpeg, the tiers remove roughly 0.3% / 1% / 3.5% of a tightly-produced podcast (more on looser, chattier shows). The per-show and per-book Smart Speed on/off is unchanged; this is the one global aggressiveness applied wherever Smart Speed is on, for podcasts and audiobooks alike. The level applies to the current episode live (the audio-filter chain rebuilds without a reload), persists in the audio settings (a new
audio_statecolumn, migration 0016), and is settable headless withconservatory-cli dsp smart-speed <gentle|balanced|aggressive>.
A polish and bug-fix pass over the player surface, plus spoken-word playback fixes.
- The Now Playing drawer is a full-bleed spectrum. The expanded drawer (Ctrl+I) drops the redundant second seekbar and the metadata grid; the visualizer now fills it edge to edge as the hero, with a minimal cover / title / artist chip over it. The analyzer is much denser (one thin bar per band, 320 bands) with gradient bars, slow-falling peak caps, and a soft reflection, and it updates live as the queue advances (item-change detection now keys on the queue slot, not just the track id, so it no longer goes stale between songs).
- The spectrum taps only Conservatory. It captured the whole output device before, so any other app's audio moved the bars. It now targets our own mpv output node by name (WirePlumber fans the node out to both the speakers and the capture), so it reacts to Conservatory alone; the tap runs only while playing, so it never falls back to the microphone.
- Side panels give their space back. Closing the Track Properties inspector or the queue, and the Now Playing drawer collapsing, now return the freed area to the browse, which fills both axes (a revealer was expanding when it should not have, so the browse sat parked in the top-left). The inspector no longer opens empty by default, and both side panels are a touch narrower.
- The CLI exits cleanly.
conservatory-cli playandaudiobook playnow respond to Ctrl-C and exit promptly, instead of ignoring the signal (libmpv sets it to ignore) and then hanging in libmpv / libpipewire teardown. - Audiobook chapters are playable. Activating a chapter row in the book detail pane now plays that book from the chapter's position; it was display-only before.
- Podcast Smart Speed / Voice Boost / speed by the transport. A per-show playback control sits next to the transport whenever a podcast episode is playing, so speed, Smart Speed, and Voice Boost are reachable where you reach for play/pause. Changes apply to the current episode live: mpv speed and the audio-filter chain rebuild without a reload, not only from the next episode.
- Smart Speed actually trims dead air now. Its silence gate was tuned to near-digital-silence (-40 dB over 1 s) and was measured (ffmpeg, real episodes) to remove nothing from produced podcasts; it is now -30 dB over 0.5 s, which triggers on real speech pauses. Modern loudness-normalized podcasts still have little removable silence, so a per-show aggressiveness level and a dynamic speed-up approach are planned.
The first tagged milestone. Conservatory is a daily-driver music player, a full podcast client, and an audiobook library in one native GNOME app, with a database-owned library and a trust-critical file mover (dry-run, undo, crash-safe replay).
This tag promotes the v0.0.91 readiness-gate work to the 0.1.0 release. No code changes since v0.0.91; what changed is the disposition of the gate. Flatpak/Meson packaging is not part of this tag (a later concern), and the real-library verification passes (idle memory at 50k tracks, a full-library move-safety run) are post-0.1.0 items rather than blockers. The synthetic gate passed and the one move-safety bug it found is fixed.
Known follow-ups: the spec §13 idle-memory budget is confirmed at 12k tracks but not yet at the 50k target; if a real-library measurement comes in over budget it becomes a 0.1.x fix.
Phase 15 work begins: the 0.1.0 readiness gate. This release is a verification pass (quality, move safety, memory budget) plus the one fix it turned up. No new features.
- Undo now restores the cover too. Verifying the move-safety undo path turned up a real gap: undoing an
organizemove put the track files back but left the album'scover.jpgstranded in the old destination, with the database'scover_pathpointing at the wrong folder. Undo now re-syncs covers the same way an apply does, so an undo is a byte-identical restoration of the whole album folder. A regression test (cover_resyncs_back_on_undo) locks it in. - Move safety, end to end. Against a real two-album working copy: the dry-run preview matched what
--applyactually moved, the undo round-trip restored the tree byte-for-byte, and the crash-mid-move roll-forward path stays covered by its existing test. - Memory, measured. A release build was sampled via
--debug: idle on a 12,000-track library sits at about 196 MB, and active playback holds around 187 MB (playback adds no measurable resident memory over the warm floor). Both are within the spec §13 budgets at this scale. The 50,000-track idle target is not yet confirmed (the synthetic fixture tops out at 12k) and is tracked as a pre-1.0 check. - Housekeeping. Removed a dead
id3entry from the workspace dependency catalog (it was never wired into any crate). The quality gate (format, clippy on both the default and music-only builds, the full test suite) is green.
Phase 14b: the --debug mode now sees file IO and network, completing the four channels.
conservatory::iolights up across every place the app touches the filesystem: the file mover (rename, cross-device copy + fsync + rename, revert), cover writes, tag write-back, APE stripping, the import scan, podcast downloads and retention deletes, and playlist / OPML export. A--debugimport now prints a line per file moved, with byte counts.conservatory::netcarries every HTTP request (there is no other network): feed fetches (GET / 304 / response), episode downloads, and chapter fetches, all on one filterable channel.- New
docs/debugging.mddocuments the flag, the four channels, andRUST_LOGnarrowing; the README points at it.
Phase 14a: a debug mode. Run either program with --debug for a verbose diagnostic stream on stderr.
conservatory --debugorconservatory-cli --debug <command>now print every SQL statement with its execution time, plus periodic resident-memory samples. The CLI gained the--debug/-dflag it was missing.- Four channels. Output is tagged
conservatory::sql,conservatory::io,conservatory::net, andconservatory::mem, so you can narrow it withRUST_LOG(for exampleRUST_LOG=conservatory::sql=debugfor SQL alone). The IO and network channels fill in next release. - Zero cost when off. Without
--debug, none of the hooks are installed and the programs behave exactly as before.
(Internal: the one timing-sensitive sleep-timer integration test, flaky under heavy build load, is now run on demand rather than in the default suite; its behaviour stays covered by deterministic unit tests.)
Phase 13e-iii: a keyboard shortcuts reference, closing Phase 13.
- Press F1 (or open "Keyboard Shortcuts" from the header menu) for a grouped, scrollable list of every shortcut that actually works: playback, browse and queue, panels and view. The list is curated to match what is wired, so it will not promise a key that does nothing.
- The keymap documentation is brought in line with reality: the playback keys are marked live, and the handful still on the drawing board (arrow-key seek, save-as-Perspective, remove-from-library) are flagged as such rather than implied to work.
Phase 13e-ii: keyboard shortcuts for playback. The app is now keyboard-drivable for daily listening without reaching for the mouse.
- Space plays / pauses, everywhere except when you are typing in the filter box.
- Ctrl+Right / Ctrl+Left skip to the next / previous track.
- Ctrl+Up / Ctrl+Down nudge the volume; Ctrl+0 mutes and unmutes.
- Ctrl+L clears the filter; Ctrl+Q quits.
Two keys were deliberately left out: bare-arrow seek (the arrow keys navigate the browse columns, so binding them to seek would break navigation) and a library-delete shortcut (deleting files needs a confirmation step, not a bare keypress).
Phase 13e-i: double-click a genre or artist to play it.
- Activate-to-play in the browse columns. Double-clicking (or pressing Enter on) a value in any browse column now plays that filtered set, the way deadbeef-cui and foobar2000 do. Double-click "Ambient" and it plays your ambient tracks; double-click the
[All]row and it plays everything under your other column selections. The track list already played on double-click; this brings the facet columns in line.
Phase 13d: typography. Conservatory now ships its own fonts and uses the right one for each part of the interface.
- Inter for the interface. The body, menus, and the dense track list and facet panes are set in Inter, a screen-optimized typeface with aligned (tabular) numbers, so the duration, year, and bitrate columns line up cleanly.
- Fraunces for headers. Titles and section headings are set in Fraunces, a warm display serif, giving the headings a bit of character against the neutral body.
- IBM Plex Mono for technical text. File paths, the status-bar codec and bitrate line, and MusicBrainz ids render in a monospace, so the technical bits read as technical and line up.
- Bundled, never assumed. All three fonts are open-licensed (SIL OFL) and ship with the app; nothing has to be installed on your system. If a font ever cannot be loaded, the text falls back to a sane default rather than breaking.
No behavior change beyond the look; the full test suite, clippy, and the music-only build are green.
Phase 13c: an internal tidy. No behavior change, no new features; the app does exactly what it did at v0.0.83.
- One accent helper. The Now Playing drawer and the Audiobooks shelf each carried their own copy of the "swap a display-wide CSS provider" dance for per-item accent colour. Both now route through the single shared helper the inspector and the Now-bar already use, so there is one implementation instead of three.
- One home for the metadata projections. The pure functions that turn a track, episode, or audiobook into labelled display rows (and the small
pushhelper that was duplicated between two files) moved into a singleui/fields.rsmodule, with their unit tests. The inspector, the Now Playing drawer, and the browse all read from the same place. - A clean bill on the rest. A code audit found the codebase otherwise in good shape (no dead code, no stale comments to speak of, consistent error handling), so this release does not churn it for its own sake.
The full test suite, clippy, and the music-only build are green; the moved field projections keep their headless tests as the guardrail.
Phase 13b: the structural part of the sleekness pass. Empty states, a grouped header, and toast confirmations.
- Empty states that say something useful. The browse area no longer shows a blank grid when there is nothing to show. An empty library invites you to import; a filter with no matches says so plainly. The Now Playing drawer shows a tidy "Nothing playing" panel when idle instead of a lone line of text.
- A grouped header. The header buttons are organized into clusters: the panel toggles (queue, properties, Now Playing) read as one segmented group, the edit and embed actions as another, with preferences, output, and the menu set apart. Less of a flat undifferentiated row.
- Toast confirmations. Actions that used to complete silently or behind a modal "Done" dialog now give a brief, non-modal toast: embedding tags into files and saving track edits both confirm themselves and get out of the way.
No data or playback behavior changed; this is presentation and feedback.
Phase 13a: the layout space-reclaim fix plus a round of low-risk polish. The first of a UI/UX sleekness pass.
- Closing a side panel gives the space back. Closing the track-properties inspector (or the queue) now flows the freed width back into the browse area instead of leaving a dead gap. The browse body is the horizontal expand-sink; before, no widget claimed the space a collapsed panel left behind.
- Smoother motion. The queue, inspector, and Now Playing drawers slide with a consistent, intentional duration rather than the default snap.
- Hover feedback. Track rows, sortable column headers, chapter rows, and the sleep-timer menu now respond to the pointer with a subtle, animated highlight.
- Breathing room. The track-properties and Now Playing detail grids, the chapter list, and the up-next list got their cramped 2px spacing opened up so label/value pairs read cleanly.
- Depth and accent. Album covers carry a softer layered drop shadow; text selection and the keyboard focus ring now use the Kanagawa accent; scrollbar sliders are gently rounded.
No behavior change beyond the layout fix; the dense browse table stays dense by design.
- Finer spectrum. The visualizer now draws as a dense field of thin vertical lines (192 of them) instead of chunky bars, with a finer FFT for the resolution to back it. A fine analyzer look rather than blocks.
Phase 12d: a real-time spectrum visualizer in the Now Playing drawer, closing Phase 12 (the visual overhaul).
- Frequency spectrum. Accent-coloured frequency bars that move with the music, the deadbeef spectrum brought across. They live in the Now Playing drawer (
Ctrl+I); open it while something plays and the bars dance. - How it works. libmpv exposes no way to read the audio it plays, so the visualizer taps the system audio at PipeWire (the default output's monitor) and runs its own FFT. The maths (windowing, the transform, the log-spaced bands, the fall-off smoothing) is in the engine core and unit-tested; the capture and the drawing are in the app.
- Smooth. The bars are drawn on the display's frame clock and smoothed with a fast rise and a slow fall, so they animate fluidly rather than at the player's slower polling rate. Capture runs only while the drawer is open, so it costs nothing when closed.
- Caveat. Because it taps the output device, the visualizer reacts to whatever your system is playing, not only Conservatory. If a browser tab is making noise, the bars will show it too.
- Tests. The band maths and the smoother are pure and unit-tested (a 1 kHz tone lands in the right band; silence is flat; the smoother rises fast and decays slow). The capture and the widget are verified by running. Full workspace suite + clippy
-D warnings+ fmt + the music-only build green. New dependencies:realfft(the FFT) andpipewire(the capture), both recorded in ATTRIBUTIONS.md; no schema change.
This closes Phase 12 (Visual identity & album-art-forward UI): the Kanagawa Dragon theme, album art across the browse, an enriched playback bar, and now a live spectrum.
Phase 12c: a richer Now-bar, the answer to "the playback bar should give more information, not just be a bigger play bar."
- Bigger, accent-framed cover. The Now-bar cover grows from 40 to 56px and sits in a frame tinted with the album's accent, the same colour the rest of the app draws from the artwork.
- Artist and album. The secondary line now reads
artist · album, folding the duplicate for a podcast (whose artist and album are both the show), so it never reads "Show · Show". - Accent seek. The filled portion of the seek slider takes the album's accent, so the progress bar belongs to what is playing.
- Now Playing drawer. The drawer's cover grows (132→160px) and its spacing opens up for a more composed feel, now that artwork and the Dragon palette are in place.
- Tests. The secondary-line folding is pure and unit-tested; the widgets are verified by building and by launching. Full workspace suite + clippy
-D warnings+ fmt + the music-only build green. No new dependency, no schema change (the playing item's accent rides the existing now-playing metadata read).
Phase 12b: album art in the music browse, the "more covers" half of the Phase 12 overhaul.
- Cover thumbnail per track. The track list gains a leading album-cover column, the deadbeef album-art-per-row look. Covers load lazily (only visible rows) and are cached downscaled, so scrolling a large library does not re-decode full-resolution artwork.
- A large cover panel. The properties inspector (the right-docked
coverart+ properties column) now opens by default with a bigger 300px cover, so album art is on screen from first launch.Ctrl+Pstill toggles it. - Read layer. The leaf query now projects each album's cover and accent onto its tracks; a single change point feeds both the fast SQL path and the in-memory filter path.
- Tests. A core read test asserts the cover/accent projection; the column, cache, and panel are verified by building and by launching. Full workspace suite + clippy
-D warnings+ fmt + the music-only build green. No new dependency (the downscaler is the GTK stack's own gdk-pixbuf), no schema change.
Phase 12a: a visual identity. The app now ships the Kanagawa Dragon theme instead of inheriting the flat grey system look, the first step of the Phase 12 UI overhaul.
- Kanagawa Dragon palette. The Dragon variant mapped onto libadwaita's named colours, with the dark scheme forced. Warm-dark chrome (
#181616ground,#c5c9c5text) and the dragonRed accent (#c4746e) on the rating stars, the playing row, and selection, in place of the system grey. The full palette-to-libadwaita mapping is documented indocs/theme.md. - Lifted cover cards. Album, inspector, Now Playing, book, and Now-bar covers get a rounded card with an Amberol-style drop shadow, so artwork reads as a raised object rather than a flat thumbnail. The previously styleless Now-bar cover class is filled in.
- Centralized accent. A shared
ui/accent.rshelper replaces the copy-pasted per-module accent providers with one technique: a 2px per-album accent ring layered over the cover's drop shadow. The properties inspector is migrated onto it; the browse covers, Now-bar, and Now Playing surfaces adopt it in the following sub-phases. - Tests. The accent helper's pure parts (the class name and the ring CSS) are unit-tested; the visual result is verified by building and by launching. Full workspace suite + clippy
-D warnings+ fmt + the music-only build green. No new dependency, no schema change.
This starts Phase 12 (Visual identity & album-art-forward UI). Still to come: album art in the music browse (a cover column and a large cover panel), an enriched playback bar, and a spectrum visualizer.
Phase 11d: transport conveniences, completing the Phase 11 Columns UI parity pass.
- Stop after current (
Ctrl+M). Arm it and the player finishes the current track / episode / book, then pauses at the boundary instead of playing on; it disarms itself once it fires. Also in the new header menu, with a checkmark when armed. - Jump to current track (
Ctrl+J). Select and scroll the browse list to the playing track. A no-op when the playing item is a podcast / audiobook or has been filtered out of the view. Also in the header menu. - Header menu. A primary menu in the header bar hosts both, so the conveniences are visible and not keyboard-only (spec §3.1).
- Tests. Stop-after-current is covered by an engine integration test (the queue pauses at the boundary, the next item never plays), beside the existing end-of-item sleep test; the jump's row-resolution is a pure unit. Full workspace suite + clippy
-D warnings+ fmt + the music-only build green. No new dependency, no schema change.
This completes Phase 11 (Browse & player polish). The music surface now matches the deadbeef daily-driver layout: configurable facet panes, a properties inspector, a status bar with the play-status glyph, an enriched Now Playing drawer, and the expected transport conveniences.
Phase 11c: a richer Now Playing drawer, the spec §3.6 surface filled out.
- Full-bleed cover. The Now Playing drawer (click the playback bar, or
Ctrl+I) now leads with a large album / show / book cover, tinted with the item's accent colour, the bigger sibling of the small playback-bar thumbnail. - Accent scrubber. An accent-tinted seek slider with a position / duration label sits under the title, draggable to seek, distinct from the playback bar's own scrubber.
- Up next. A short peek at the next few items in the queue, each badged with its kind, so you can see what is coming without opening the queue drawer. It stays in step with queue edits.
- Audio-engine line. For a playing track, a one-line readout of the active EQ preset, the enabled DSP modules, and the gapless state. (The drawer already showed chapters, the Smart Speed indicator, and the sleep timer, which arrived with the podcast engine.)
- Tests. The audio-engine line and the queue-tail slice are pure and unit-tested; the cover, scrubber, and up-next widgets are verified by building and by hand. Full workspace suite + clippy
-D warnings+ fmt + the music-only build green. No new dependency, no schema change.
Phase 11b: a status bar footer and the play-status glyph column, the next slice of the Columns UI parity pass.
- Status bar. A thin footer above the playback bar. On the left, the playing track's technical line (format, sample rate, channels, bitrate); on the right, the active view's "N tracks · total playtime", which switches to the selection's total when you select two or more rows.
- Play-status glyph. The leftmost column of the track list now shows a play or pause icon on the row that is the currently playing track, the deadbeef ♫ marker. It follows track changes and pause, and it survives filtering. This is the per-row status glyph owed since Phase 3c, now that playback state exists.
- Channels. Read live from the player (mpv's decoded channel count) rather than stored, so it shows while a track plays without a schema change or a re-import.
- Tests. The aggregate, the playtime and thousands formatting, the technical line, and the glyph-state selection are pure and unit-tested; the widgets are verified by building and by hand (the established pattern for GUI work). Full workspace suite + clippy
-D warnings+ fmt + the music-only build green. No new dependency, no schema change.
Phase 10c: configurable browse panes. The facet columns down the left of the music browser are no longer fixed at Genre, Album Artist, Album. This completes Phase 10 (Configuration & preferences).
- Pick your browse columns. Preferences → Library → Browse panes gives five ordered slots; each is a field or "(none)". Choose from Genre, Shelf Genre, Album Artist, Artist, Album, Year, and Format, in any order, one to five panes. The change saves to
config.tomland takes effect on the next launch. - Four new facet fields. Beyond the original three, the browser can now facet by Shelf Genre, Artist (the track artist, distinct from the album artist), Year, and Format, all from data already in the library.
- Safe by default. Unknown field names in the config are ignored, the list is capped at five, and an empty list falls back to the default Genre → Album Artist → Album, so the browser is never left without panes.
- Tests. The field parsing, the config resolution (skip unknown / cap / default), and the new facet queries (each partitions the library; the cascade still narrows) are tested; the editor and the config-driven build are verified by building and by hand. Full workspace suite + clippy
-D warnings+ fmt + the music-only build green. No new dependency, no schema change.
Phase 11a: a track properties inspector and a large cover panel, the first slice of the Columns UI parity pass.
- Properties inspector. A collapsible panel on the right of the browse window (a header button, or
Ctrl+P) showing the selected track's full detail: title, artist, album, year, genre, track and disc, duration, format, bitrate, sample rate, file size, ReplayGain, rating, play count, last played, date added, file location, MusicBrainz ids, and the cover file. Read-only, and it updates as you move the selection. Everything comes straight from the database or a quick file-size check. - Large cover. The album art at a readable size sits above the properties, tinted with the album's accent colour, distinct from the small thumbnail in the playback bar. A placeholder shows when a track has no cover.
- Notes. The panel shows the first selected track when several are selected, and it costs nothing while closed. Channels is the one deadbeef field not shown yet: it is not stored and would need decoding the file, so it is deferred.
- Tests. The field projection (track and album to the displayed rows) is pure and unit-tested; the panel itself is verified by building and by hand (the established pattern for GUI work). Full workspace suite + clippy
-D warnings+ fmt + the music-only build green. No new dependency, no schema change.
Phase 10b: a Preferences window. The settings introduced in 10a are now editable from the GUI instead of by hand-editing the config file.
- Preferences dialog. The former Sound dialog (still
Ctrl+,, now a preferences button in the header) becomes a full Preferences window with three pages: General, Library, and the existing Sound. - General page. Set the library root with a folder chooser, the music path template, the import mode (copy or move), whether edits are embedded back into files, and the fallback genre. Changing the library root takes effect on the next launch (the running session keeps the library it opened with), and the page says so.
- Library page. The podcast and audiobook defaults: their library subfolders, the audiobook path template, default playback speed, and the Smart Speed / Voice Boost toggles. The browse pane configuration will join this page in a later release.
- Where settings live. The General and Library pages read and write
config.toml; the Sound page keeps managing the audio engine (equalizer, ReplayGain, dynamics, output) in the database exactly as before. Nothing about the working audio settings changed. - Tests. The import-mode mapping is unit-tested; the dialog itself is verified by building and by hand (the established pattern for GUI work), and it saves through the same config writer covered by the 10a tests. Full workspace suite + clippy
-D warnings+ fmt + the music-only build green. No new dependency, no schema change.
Phase 10a: Conservatory's first config file. This is the foundation of Phase 10 (Configuration & preferences); it introduces ~/.config/conservatory/config.toml and makes the GTK app find its library from there instead of requiring a command-line argument.
config.toml. A new config file at$XDG_CONFIG_HOME/conservatory/config.toml(falling back to~/.config). It holds the app and library settings: the library root, the music/audiobook path templates, the import mode (copy or move), the genre fallback, the podcast and audiobook subdirectories and book defaults, and the browse pane layout. Every field has a sensible default, so a missing or partial file just works; only a genuinely malformed file is reported as an error rather than silently reset.- Library root from config. Launching the GTK app with no arguments now reads the library root from
config.toml. A path given on the command line still wins (handy for development), but it is no longer required. - What stays in the database. The audio engine settings (ReplayGain, equalizer, dynamics, output) deliberately stay in the database where the Sound dialog already manages them live; the config file does not duplicate them. This keeps the working audio settings untouched.
configCLI verb.conservatory-cli config pathprints the file location,config showprints the effective settings as TOML, andconfig initwrites a default file (without ever overwriting an existing one).- Still to come in Phase 10: a Preferences page to edit these settings in the GUI (10b), and configurable browse panes (10c).
- Tests. The config load/save/merge paths are unit-tested (default round-trip, partial-file merge, missing-file defaults, malformed-file error, XDG path resolution) along with the GTK root-precedence helper, and verified end to end via the
configverb. No new dependency (thetomlcrate was already in the workspace); no schema change. Full workspace suite + clippy-D warnings+ fmt + the music-only build green.
Phase 8d: playlist export and import as portable .m3u / .m3u8 files. This is the last slice of Phase 8, so library maintenance (verify, duplicates, audit, stats, APE strip, playlists) is now complete.
playlist export.conservatory-cli playlist export <db> '<expr>' <out.m3u>writes the tracks matching a search expression to an extended-M3U playlist (with#EXTINFduration andArtist - Titlelines), in album order. The selector is the full search grammar, and it acceptsvl:NAMEto export a saved Perspective. Paths are library-root-relative by default (portable when the.m3usits at the library root);--absolute(with--root) writes full paths instead.playlist import.conservatory-cli playlist import <db> <in.m3u>reads a playlist, resolves each path back to a managed track, and loads the play queue (appends by default;--replacereplaces it). Paths that match no track in the library are reported and skipped, never fatal, so a playlist that points partly outside the library still imports what it can. Pass--rootto map absolute playlist paths back to the library.vl:NAMEeverywhere. Wiring the Perspective resolver into the shared selector path meansvl:NAMEnow works not just inplaylist exportbut in every selector-taking verb (search,verify,duplicates,audit), where it previously fell back to a plain text search.- Tests. The
.m3ubuild/parse round-trip is unit-tested; verified end to end by exportingformat:mp3to a playlist (relative and absolute) and re-importing it to the exact same eight tracks, plus avl:NAMEexport and a missing-path import. Full workspace suite + clippy-D warnings+ fmt + the music-only build green. No new dependency, no schema change.
Phase 8c-iii (part 2 of 2): the apestrip command removes the stray APEv2 tags that audit --tier ape finds. This is the byte-level fix deferred since Phase 5b (lofty cannot remove an APE tag from an MP3). It mutates your files, so it is built with the full set of safeguards: dry-run by default, crash-safe writes, and a reversible undo. With this, Phase 8c (library health) is complete.
apestripCLI verb.conservatory-cli apestrip <db> --root <root>previews which MP3s carry a stray APE and changes nothing (dry-run).--applystrips them;--undoputs them back. A stray APE shadows the file's ID3 in foobar2000 / DeaDBeeF, so removing it lets your curated tags win.- Safe by construction. Every strip writes a sibling temp file, fsyncs it, decode-checks it (the result must still parse as valid audio), then atomically renames it over the original; a failure leaves the original untouched. Before any file is touched, the exact bytes being removed are recorded in the database, so
--undorestores the file precisely. Undo refuses a file that changed since the strip, rather than clobbering it. - Migration deferred. Optionally migrating APE fields into ID3 before stripping is left for a follow-up; Conservatory's database is the source of truth and already writes canonical ID3, so removing the shadow is the win here.
- Tests. The byte-level splice and the full strip-then-undo roundtrip on a real MP3 are tested, plus a database migration (0015) for the undo journal. Verified end to end (dry-run, apply to a byte-identical result that still decodes, and undo to the exact original). Full workspace suite + clippy
-D warnings+ fmt + the music-only build green.
Phase 8c-iii (part 1 of 2): the audit can now detect MP3s carrying a stray APEv2 tag. An APE tag sitting on an MP3 shadows the file's ID3 in players like foobar2000 and DeaDBeeF, silently defeating tag edits. This release reports them; the next will strip them.
audit --tier ape. A new audit tier (conservatory-cli audit <db> --tier ape --root <root>) flags every MP3 that carries a stray APEv2 tag. Like the cover-art checks it needs--root(it reads each file); it scans only the last 128 KB of each MP3, so it is cheap.- A careful byte parser. The detection is a hand-rolled APEv2 parser that anchors on the tag's footer and validates it (the spec-mandated reserved-zero bytes, and a header-consistency check), so a stray "APETAGEX" sequence inside the audio is not mistaken for a real tag.
- Tests. The parser is unit-tested against synthesized tags (with and without a header, with and without a trailing ID3v1, and the false-positive guard), plus a filesystem test for the audit tier. Verified end to end (a clean library reports none; a crafted APE tag is flagged). Full workspace suite + clippy
-D warnings+ fmt + the music-only build green.
Phase 8c-ii: a stats command that summarizes the library, the second slice of the Phase 8c maintenance work. It is a port of Lattice's statistics report, run over Conservatory's database. Read-only. No new dependency, no migration. With this, only the stray-APE detect + strip (8c-iii) remains in Phase 8c.
- What it reports. An overview (track / album / artist totals, total size, total duration, and the share of fully-tagged tracks), a per-format breakdown with sizes and percentages, a bitrate summary (average, range, and how many files fall below 192 kbps), the rating distribution as a small histogram, the genre distribution with a per-genre rating tally, and the top artists by track count.
- File sizes need
--root. The database stores everything except the size of each file on disk, so the size figures come from a quick pass over the files and need--root; without it the report prints sizes as "n/a" and still computes everything else. Rating 0 is treated as unrated (Conservatory's default), with 1 to 5 as stars. statsCLI verb.conservatory-cli stats <db> --root <root>prints the full report;--top Nsets how many genres and artists to list (default 15), and--format tsv|json|human(human default) picks the output.- Tests. The aggregation is unit-tested over planted rows (format counts, bitrate average / range / below-floor, the rating tally, genre and artist breakdowns, the fully-tagged predicate), with a filesystem test for the size pass. Verified end to end on real albums (14 tracks across two albums: 94.6 MB, 1h 5m, correct format split and bitrate range). Full workspace suite + clippy
-D warnings+ fmt + the music-only build green.
Phase 8c-i: an audit command that reports library health problems, the third Phase 8 audit (after integrity and duplicates). It is a faithful port of Lattice's tag / bitrate / ReplayGain / cover-art audits. Read-only: it reports, it never changes a file. No new dependency, no migration. Phase 8c is large, so it is sliced: this is the audits; library statistics (8c-ii) and the stray-APE detect + strip (8c-iii) follow.
- Five health checks. (1) Missing critical tags: title, artist, track number, or genre. (2) Low bitrate: lossy files below a floor (default 192 kbps); lossless formats are never flagged. (3) ReplayGain coverage per album: missing, partial, no-album-gain, or ok. (4) Missing cover art. (5) Low-resolution cover art below a pixel floor (default 500×500), read from the cover file's header.
- Opus R128 is understood. Opus files often carry loudness as the
R128_*convention rather than the standard ReplayGain tags. When you pass--root, the audit reads those tags directly for any Opus track the database does not already know a gain for, so a properly tagged Opus album is not falsely reported as missing ReplayGain. auditCLI verb.conservatory-cli audit <db> --root <root>prints a sectioned report;--tier tags|bitrate|replaygain|art|artreslimits which checks run,--bitrate-floorand--min-art-pxtune the thresholds, and--format tsv|json|human(human default) picks the output. The cover-file, art-resolution, and Opus R128 checks need--root; without it the audit says so rather than silently skipping. Always exits 0 (a deficiency is a report, not an error).- Tests. Each check is unit-tested against a planted set, with a filesystem test for the cover-art path using generated images. Verified end to end on real albums (it correctly caught an Opus track one kbps under the floor and recognized the R128 ReplayGain on the same album). Full workspace suite + clippy
-D warnings+ fmt + the music-only build green.
Phase 8b: a duplicates command that finds redundant copies in your library, the second Phase 8 audit. It is a faithful port of Lattice's duplicate detector. Read-only: it reports, it never deletes (cleanup goes through organize, with its dry-run and undo). One new dependency (unicode-normalization, for matching names that differ only in unicode form); no migration.
- Four kinds of duplicate. (1) The same album sitting in two folders. (2) The same track ripped to several formats in one album (flac + mp3). (3) Near-miss album names by the same artist, e.g. "Domestica" vs "Domestica (Deluxe Edition)", via a fuzzy match. (4) The same recording (artist + title) living in multiple albums, grouped by duration so a studio take and a live take of the same song are reported separately, not lumped together.
- Robust name matching. Names are normalized the way Lattice does before comparison: unicode NFKC folding, curly quotes and dashes folded to ASCII, whitespace and case collapsed; the fuzzy tier additionally strips "feat./ft." clauses and trailing parentheticals. The fuzzy similarity is a hand-rolled port of Python's difflib ratio, so the 0.85 threshold behaves exactly as it does in Lattice.
duplicatesCLI verb.conservatory-cli duplicates <db>prints a four-section report;--tier exact|multiformat|similar|trackslimits which sections run, and--format tsv|json|human(human default) picks the output. Always exits 0 (a duplicate is not an error).- Tests. Each tier is unit-tested against a planted set, plus the normalization vectors and the difflib-ratio parity. Verified end to end on real albums (a clean library reports nothing). Full workspace suite + clippy
-D warnings+ fmt + the music-only build green.
Phase 8a: a verify command that decode-checks your files for corruption. This is the first piece of Phase 8 (library maintenance and audits), modeled on Lattice's integrity tests. One database migration (0014); no new third-party Rust dependency (it shells out to flac and ffmpeg, which need to be on your PATH).
- Decode-verify with a four-tier verdict. Each file is test-decoded and sorted into OK, METADATA (audio fine, only a container/tag note), SUSPECT (decoded to the end but the tool flagged something), or CORRUPT (the decoder errored, or a FLAC came up short, i.e. truncation or bit-rot). FLAC goes through
flac -t, which MD5-verifies the decoded stream and so catches rot a lenient player would skip; every other format goes through a strictffmpegdecode. A benign-note allowlist keeps a clean mp3's routine "estimating duration" warning from flagging it. verifyCLI verb.conservatory-cli verify <db> '<search>' --root <root>checks the matched tracks in parallel and prints a per-tier summary;--verboselists each flagged file with the tool's message. The process exits non-zero only when CORRUPT files exist, so it drops straight into a cron or pre-backup hook.- Cached, so re-runs are cheap. Each verdict is stored keyed by path plus the file's size and modification time; a re-run skips files that have not changed (pass
--forceto re-check everything). The cache is path-keyed, so podcasts and audiobooks can be folded into the same verifier later with no schema change. - Migration 0014 adds the
verify_resultstable. - Tests. Pure classifier units for both tools (including the benign-note regression), an availability-gated integration test (a clean FLAC fixture verifies OK, a truncated copy verifies CORRUPT), and the cache round-trip through the worker. Verified end to end against real albums. Full workspace suite + clippy
-D warnings+ fmt + the music-only build green.
Phase 7c-iii, and with it Phase 7 is complete: audiobooks are now a full third media type. You can play a book from the shelf, see it on the Now Playing surfaces, control it from GNOME's media keys, and tune its playback per book. This is the last of three audiobook-playback commits; no migration, no new third-party dependency.
- Play from the shelf. Double-click (or press Enter on) a cover and the book, plus the rest of the shelf below it, starts playing in the one unified queue alongside music and podcasts. Ctrl+Enter appends the selected books to the queue tail instead.
- The book on the Now Playing surfaces. The Now-bar and the Now Playing drawer now show a book's title, author, series, duration, and cover, with a clickable chapter list that jumps anywhere in the book (across its files) and highlights the current chapter as it plays. The queue drawer shows books too, and a book left playing when you quit resumes on next launch.
- Media keys and the lock screen. MPRIS now reports the right metadata for whatever is playing, books included, so GNOME's media overlay and the keyboard media keys drive a book the same as a song.
- Per-book playback settings. A gear in the book's detail pane opens a small dialog for that book's speed, Smart Speed, and Voice Boost. Changing them never disturbs your saved place in the book.
- Tests. A pure
book_fieldsprojection (the Now Playing field list) is unit-tested for author / series / format / decimal series index; the play, resume, and profile logic is covered by the 7c-i / 7c-ii engine tests. Full workspace suite + clippy-D warnings+ fmt + the--no-default-featuresmusic-only build green.
Phase 7c-ii: a book now resumes exactly where you left off, honors its own playback settings, and skips chapters across files. This is the second of three audiobook-playback commits; the GTK surface, MPRIS, and a per-book settings dialog are 7c-iii. One database migration (0013); no new third-party dependency.
- One timeline across a book's files. The engine now speaks a "book-absolute" position: it maps each file's own clock onto a single timeline that runs from the start of the book to its end. Everything that reports or uses position (the seek slider, the resume write, the chapter highlight, the time-saved accounting) now speaks that one timeline, so a multi-file book behaves like a single long item.
- First-class resume. A book's absolute position is written on pause, on seek, and on the periodic insurance interval, and the transport cursor records which book was playing.
audiobook play --resumereopens a book and seeks straight back to where you stopped, to the second; a slider seek or a chapter skip that crosses a file boundary loads the right file and lands at the right offset. - Per-book playback settings. Audiobooks share the podcast spoken-word engine (variable speed, Smart Speed, Voice Boost), now resolved from each book's own overrides rather than a show's.
audiobook settings <db> <id> --speed 1.5 --smart-speed true --voice-boost truesets them; playback applies them. - Time saved counts for books too. The listening-session history table was rebuilt so a session can belong to a book as well as an episode (exactly one of the two), so a book's Smart Speed time-saved feeds the same
statstotals. - Migration 0013. Adds the book cursor column and rebuilds
listening_sessionswith a nullable book/episode owner and an exactly-one check. Existing episode history is copied forward unchanged. - Tests. Six integration tests now drive the real engine over a null audio output: the resume position and cursor persist mid-book, a cross-file seek reaches the third file, the chapter readout progresses across all three files, and a completed book writes exactly one book-keyed session, plus the
resolve_book_profiledefaults/overrides/clamp units. Full workspace suite + clippy-D warnings+ fmt + the--no-default-featuresmusic-only build green.
Phase 7c begins: audiobook playback. This first commit (7c-i) is the headless engine that makes a book play as one item in the unified queue. The novel piece is that a book's chapters can live in one M4B or across a folder of one-file-per-chapter, and either way the book is a single queue entry the engine plays through internally. No new third-party dependency, no schema change.
- A book is one playable item with a segment plan. A new pure module plans a book's chapters into ordered per-file "segments", each tagged with its cumulative offset across the whole book, and lifts the chapter marks onto one absolute book timeline. That plan is what lets the engine speak a single position from 0 to the end of the book regardless of how many files back it.
- The engine advances file to file internally. When one file ends, the engine loads the next file of the same book without advancing the queue, and only marks the book finished (clearing its resume position) at the last file's end. A multi-file book that reports "finished" is therefore proof every file played. An M4B is the one-file case: its chapters are seeks inside the single file, fully gapless.
- The gapless tradeoff, stated plainly. Multi-file books advance with one fresh load per file, so there is a brief gap at each file boundary (a natural chapter pause); M4B books have none. True cross-file gapless is a later refinement, recorded in the roadmap.
- CLI
audiobook play <db> <id> --rootplays a book headlessly through the libmpv engine, printing each chapter as it advances, with the shared--sleeptimer. The position-persistence resume and per-book speed / Smart Speed / Voice Boost land in 7c-ii; the shelf play button, Now Playing surface, and MPRIS in 7c-iii. - Tests. The segment math is unit-tested (single-M4B, multi-file, multi-file-with-internal-chapters, missing-duration degrade, position-to-segment locate). Two integration tests drive the real engine through a null audio output: a three-file book advances through every file and completes, and a single-file book with chapter marks completes. Full workspace suite + clippy
-D warnings+ fmt + the--no-default-featuresmusic-only build green.
Phase 7b-iii-b completes Phase 7b: the Audiobooks tab gets a bulk-edit dialog, the GTK front end for the editing engine that landed in 7b-iii-a. Select one or more books on the shelf, press Ctrl+E (or the pencil button), edit their fields, and a path-affecting change re-shelves the files behind a preview-and-confirm. No new third-party dependency.
- The shelf is multi-select now. A plain click still selects a single book (so the detail browse is unchanged, and the detail pane follows the first selected book), but Ctrl-click and Shift-click extend the selection for a bulk edit.
- The bulk-edit dialog. A pencil button on the filter bar and
Ctrl+E(scoped to the Audiobooks tab, so it never fights the Music tab's shortcuts) open an editor over the selection: author(s) and narrator(s) (semicolon-separated, replacing the credited set), series, series index, title, year, shelf genre, and rating, plus a "Standalone (no series)" checkbox. Blank fields are left unchanged; one bad value rejects the whole edit rather than applying it half-done (the Calibre / music-surface model). - Series clearing is a checkbox. Since blank means "unchanged", making a book standalone is the explicit "Standalone (no series)" toggle, which moves it to
Audiobooks/<author>/Standalone/…. - Path-affecting edits move files behind a confirm. After writing the metadata, the dialog aggregates the planned moves across every edited book into a single "Move N files?" prompt; confirm and each book re-shelves through the same journaled mover (dry-run preview, undo journal, crash-safe replay) the headless half built, then the shelf reloads. A non-path edit (rating, narrator, shelf genre) just reloads, no move.
- Tests. The edit and move logic is covered by the 7b-iii-a suite (the resolver units and the reorganize round-trip on real files); the dialog itself is verified by build plus manual launch, the documented GTK-view precedent. Full workspace suite + clippy
-D warnings+ fmt + the--no-default-featuresmusic-only build green.
Phase 7b is complete: the audiobook library is browsable, filterable, and editable. Next is Phase 7c, the last of Phase 7: audiobook playback on the unified queue, with chapter navigation, variable speed, the sleep timer, and first-class resume.
Phase 7b-iii-a: the headless half of audiobook bulk edit. You can now edit a book's metadata and, when a path-affecting field changes (author, series, series index, title, year), have its files re-shelved into the new folder through the same trust-critical, journaled mover that owns the music library. This sub-phase is the engine; the GTK multi-select dialog (7b-iii-b) is next. No new third-party dependency, no schema change.
- The book reorganize path. Until now the only way a book's files moved was the initial import. This adds the audiobook analogue of music's
organize: re-render the book's folder from its current (edited) database state and move the files there. The mover needed no audiobook-specific change. A move operation carrying a book id already rewrites every chapter row of that book and updates its folder, the same under a re-shelve as under an import, so a single M4B that backs many chapters still moves once and all its chapters follow. The dry-run preview, the undo journal, and crash-safe replay all apply unchanged (a move bug damages a real library; this is the headline risk). - Typed, shared edit logic. A pure
BookEditresolver classifies which fields are path-affecting (author, series, series index, title, year, the ones in the folder template) versus not (narrator, shelf genre, rating, starred). It is the single source the CLI and the coming GTK dialog both build their edits from, and it is unit-tested with no database. - Series can be cleared to standalone. Setting a series files the book under it; clearing the series moves the book to
Audiobooks/<author>/Standalone/…. Blank still means "leave unchanged", so clearing is an explicit action (--series ""on the CLI). - CLI
audiobook setgrew teeth. It now takes--title,--year,--author,--narrator,--series, and--series-indexalongside the existing rating/starred/shelf-genre. A path-affecting edit needs--root; without--applyit is a dry-run that shows the current and would-be folders and writes nothing. With--applyit writes the metadata and re-shelves. Undo is the existingorganize --undo <job>. - The cover follows. When a book moves, its
cover.jpgis carried into the new folder and the path updated (best-effort: covers re-derive and the accent already lives on the row, so a cover hiccup never fails the move). - Tests. The resolver's path-affecting matrix and value parsing; a reorganize round-trip suite against real files on disk (a multi-file book, a single-M4B book whose three chapters all follow the one moved file, undo restoring the tree and the rows, an in-place no-op, and a destination conflict being refused with nothing moved). Verified end-to-end against the committed import fixture. Full workspace suite + clippy
-D warnings+ fmt + the--no-default-featuresmusic-only build green.
Next: Phase 7b-iii-b, the GTK side — a multi-select shelf and a bulk-edit dialog (Ctrl+E) whose path-affecting edits re-shelve behind a move preview-and-confirm.
Phase 7b-ii: the Audiobooks shelf gains a filter bar. The same Calibre-style search grammar that drives the Music tab now sits above the cover shelf, with the audiobook fields wired in. This is the first non-music consumer of the shared grammar (the podcast tab never had a filter bar), so the four book fields joined the shared field set rather than forking it: one grammar, all three surfaces, as the spec intends. No new third-party dependency.
- The filter bar: an always-on search entry above the shelf, no separate search mode (the Music-surface idiom). Type
author:sanderson,series:"The Stormlight Archive",narrator:kramer, oris:finishedand the shelf narrows in place, keeping its in-progress-first order.is:starredand the shared numeric grammar (rating:>=4,year:2010,duration:) work on books too.Ctrl+Ffocuses the bar; it is scoped to the Audiobooks tab so it does not fight the window's global musicCtrl+F. A malformed expression tints the bar yellow rather than erroring (the forgiving parser). is:finished: a new state, the same shape asis:playedandis:starred. To find unfinished books, writeNOT is:finished. (The spec table'sis:finished falseexample predates the actualis:mechanics, where states take no value;NOTis the negation everywhere.)- Shared grammar, eval-only for books:
author:/narrator:/series:/is:finishedare now known on every surface, so a book field typed into the music bar simply matches nothing instead of being an unknown field. They never translate to the musictracksSQL: the translator returns "can't express this," which forces the whole query onto the in-memory path, where the audiobook shelf (small, already loaded whole) is matched directly. No SQL change, no schema change. - Headless and CLI-testable: the filter logic lives in a pure
book_query::filter_books(unit-tested: fielded matches,is:finished, bare-text author search, forgiving degrade), and theaudiobook listCLI verb gained an optional filter expression (conservatory-cli audiobook list <db> "author:sanderson AND NOT is:finished"), the headless twin of the shelf filter. - Tests: the grammar extension (the new fields parse, round-trip, eval correctly, and are excluded from SQL translation, keeping the music SQL path safe); the
book_queryfilter model (five cases); the CLI mapping; and the existing music search-parity suite still green (the sharedSearchItemchange is additive). Full workspace suite + clippy-D warnings+ fmt + the--no-default-featuresmusic-only build green. The widget wiring is verified by build plus manual launch (the documented GTK-view precedent).
Next: Phase 7b-iii (bulk edit across selected books, path-affecting edits through the mover), then 7c (playback, chapter navigation, and resume on the unified queue).
Phase 7b-i: the Audiobooks tab arrives, the third media surface. The shelf is a cover grid (the app's first GridView, every other browse being a column table) beside a detail pane, and it is the first place the median-cut cover accent is used in the GUI. This sub-phase is read-only browse; a book becomes playable at Phase 7c, so there is no play action yet (the same browse-before-playback split podcasts took). Filtering and bulk edit follow in 7b-ii/iii.
- The shelf: every audiobook as an accent-tinted cover tile, ordered in-progress first (then new, then finished), with the title and author beneath. Click a tile and the detail pane on the right fills with the cover, the title, an author · narrator · series · year line, a progress bar with the derived state, and the book's chapter list. A side-by-side
gtk::Paned, matching the Podcasts and Music tabs; the cover loads from the managedcover.jpg, falling back to a placeholder. - State derivation: New / In progress / Finished is derived (not stored) from the
book_playbackresume row, and the shelf surfaces in-progress books first (most recently played first). The derivation and the ordering are pure, tested functions in core, so the GUI shelf and the newaudiobook listCLI verb share one source of truth. - One core read for the shelf:
list_book_rowsreturns a denormalized row per book (author and narrator credits, series, summed chapter duration, and resume state) in a single query, the podcastEpisodeListRowprecedent, so the grid never does an N+1 of per-book lookups. - CLI:
conservatory-cli audiobook list <db>prints the shelf rows with their derived state, the headless view of the GUI shelf (the every-surface-CLI-testable rule). - Plumbing: the multi-view chrome (the header switcher, the adaptive bottom bar, the narrow breakpoint) moved out of the podcasts attach into a shared step, so podcasts and audiobooks are now independent compile-time plugins: either one alone still gets the switcher, and a music-only build is unchanged. The shell already reserved the Alt+3 slot; the Audiobooks page is built lazily on first view.
- Tests: the shelf read and ordering (denormalized credits / series / duration, and in-progress-first ordering beating alphabetical) and the
BookStatederivation in core; the tile row formatting (progress fraction, the "Author · Read by Narrator · Series · Year" line, the state label, decimal series numbers) asBookRowunit tests. The widget tree itself is verified by build plus manual launch (the documented GTK-view precedent). Manually checked against the two real test books (the Rothfuss series book and the Gaiman full-cast standalone) imported into a shelf. Full workspace suite + clippy-D warnings+ fmt + the--no-default-featuresmusic-only build (and the audiobooks-without-podcasts build) green.
Next: Phase 7b-ii, the filter bar and bucket sidebar wired to conservatory-search (author: / narrator: / series: / is:finished), then 7b-iii (bulk edit) and 7c (playback, chapters, resume).
Phase 7a-iii closes Phase 7a: the audiobook import pipeline. The 7a-ii reader turned files into a BookDraft; this sub-phase resolves that draft into database rows and moves the book's files into the managed Audiobooks/ tree through the same trust-critical, journaled file mover that owns the music library. Point the CLI at a folder or a single .m4b and you get an organized, database-owned audiobook with ordered chapters, headless. No new third-party dependency.
- The audiobook path template:
Audiobooks/{author}/{series}/{series_index:02}. {title} ({year}), with four new tokens ({author},{narrator},{series},{series_index}) added to the Phase 2a engine. A standalone book renders under the literalStandalonefolder, so every author folder is two levels deep; the index/year groups collapse when absent (a standalone drops theNN.prefix). The series index is decimal-aware: an integral1.0zero-pads to01, a fractional1.5renders as1.5. The render loop is now shared between music and books through a small internal trait, so music paths are byte-for-byte unchanged (the existing path tests are the guard). - The mover learns about books: books are owned and moved like music, not ephemeral like podcasts, so the move runs through the journaled mover with its dry-run preview, undo, and crash-safe roll-forward (a move bug damages a real library; this is the headline risk). The journal gains a
book_idcolumn (migration0012). One subtlety drove the design: a single M4B can back many chapters, so move operations are built per unique physical file, not per chapter, and completing a book op rewrites every chapter row of the book whose path matches the moved file. A per-chapter-file book and a single-M4B book both land correctly. - Import:
import_book(in theconservatory-audiobooksplugin, calling the core mover and worker) runs the music importer's two-pass shape. A pure resolve pass renders the folder and pre-checks the move for conflicts; only if the plan is clear does the persist pass create thebook/book_people/series/book_chaptersrows and run the move. A conflict (the folder already exists, or two files collide) refuses the whole import with nothing written. Scope is one book per call; a whole-Author/*-tree batch is a later add. - Cover and accent: the median-cut accent (the Hermitage path) into
books.accent_rgb, and the cover written into the moved book folder via the existing folder-based cover sync. A book with embedded cover art in its chapter files gets acover.jpgwritten beside them. - CLI:
conservatory-cli audiobook import <db> <source> <root>(copy by default,--moveto consume; a conflict exits nonzero with nothing written) andaudiobook set <db> <book_id>(rating, starred, shelf genre; path-affecting edits are deferred to the 7b bulk-edit surface). Undo is the existing media-agnosticorganize --undo <job>. - Tests: the book path render (the default template, the standalone fallback, integer and decimal indices, collapsing groups, per-component sanitization, and the unchanged music regression set); the mover book round-trip suite (multi-file and the single-M4B many-chapters case, undo restoring both tree and DB, and crash replay); and the end-to-end import of the committed multi-file fixture (the resolved rows, the moved files under the rendered folder, and a second import refused with no partial writes). Manually verified against the real Rothfuss series book: 94 chapters land under
Audiobooks/Rothfuss, Patrick/The Kingkiller Chronicle/01. The Name of the Wind (2009)/with the embedded cover extracted, and copy mode leaves the source intact. Full workspace suite + clippy-D warnings+ fmt + the--no-default-featuresmusic-only build green.
Next: Phase 7b, the Audiobooks GTK tab (the third view-stack page, a cover-grid shelf plus a book detail pane), then 7c (playback, chapter navigation, and resume on the unified queue).
Phase 7a-ii: the audiobook reader. The conservatory-audiobooks plugin crate, an empty stub until now, gets the piece that turns files on disk into a structured book: the tag and Audiobookshelf-sidecar reader and the chapter resolver. The output is a BookDraft (metadata plus an ordered chapter list); the database writes, file moves, and the audiobook import verb are the next sub-phase (7a-iii). No new third-party dependency.
- The reader (
read_book): point it at a book folder or a single audio file and it produces a draft. Metadata comes from three sources, merged per field by precedence sidecar > embedded tags > folder structure: the explicit Audiobookshelf sidecars win, the embedded tags are the common case, and the on-disk layout is a last-resort fallback. Every field is best-effort, so an untagged folder still yields a draft with its chapters. - Embedded tags (lofty): the audiobook convention, validated against real books. The book title is the album tag, the author is the album artist, the narrator is the composer (plus a custom
NARRATORframe), and series and sequence come from customSERIES/SERIES-PARTframes. Custom frames are read across both the ID3v2TXXXand MP4 freeform spellings, so one path covers mp3 and m4b. People are split out of a packed credit string (a full-cast book lists every narrator in one field) and sorted last-name-first ("Rothfuss, Patrick"), the Calibre author-sort convention. - Sidecars:
metadata.opf(Dublin Core plus Calibre series tags, viaquick-xml),desc.txt, andreader.txt, the Audiobookshelf conventions. - Folder inference:
Author/Titleis a standalone book,Author/Series/NN - Titlea series entry. Recognised conservatively, so a numerically-titled standalone (1984) is not mistaken for a series entry. - Chapters: a chapter addresses either a standalone per-chapter file or a span inside one M4B. One file with embedded markers reads them via an
ffprobe -show_chaptersshell-out (lofty cannot read MP4 chapter atoms; this avoids a Rust MP4 dependency, the rsgain precedent and the m4b-tool technique); one file without markers is a single whole-file chapter; a multi-file folder is one chapter per file, ordered by the part tag (a multi-part M4B numbers1/11 .. 11/11, so a lexical filename sort would put "Part 10" before "Part 2").ffprobeis best-effort: a missing binary degrades to a whole-file chapter, never an error. - The artifact:
conservatory-cli audiobook debug-read <path>prints a draft (title, authors and narrators with sort names, series and sequence, and the ordered chapter list). It reads the two real test books correctly: the Rothfuss series book (94 one-file-per-chapter parts, narrator Nick Podehl) and the Gaiman full-cast M4B standalone (11 parts ordered by tag, seven narrators). - Tests: the pure logic is unit-tested (the people splitter, the person sort name, the raw-chapter-to-draft mapping, the part ordering, the folder inference, the
.opfparse, the precedence merge); three fixture-backed integration tests coverread_bookend to end (a multi-file book, a single M4B with embedded chapters gated onffprobe, and a sidecar overriding the embedded tags), with the small fixtures generated bycargo run -p conservatory-audiobooks --example gen_fixtures. CI gains a best-effort ffmpeg install (the rsgain pattern) so the gated test runs there. Full workspace suite + clippy-D warnings+ fmt + the--no-default-featuresmusic-only build green.
Next: 7a-iii, the audiobook path-template tokens ({author} / {series} / {series_index}), the import pipeline that resolves a draft into rows and moves the book into the managed tree (the file mover with dry-run and undo), cover and accent, and the audiobook import / audiobook set CLI verbs.
Phase 7 begins: audiobooks, the third media type. This first sub-phase (7a-i) is the headless schema foundation, with no user-facing surface yet. It lands the audiobook tables in the core migration ledger so the reader, import, and browse work that follows has a database to write to. Nothing about music or podcasts changes.
- Migration
0011(spec §4.5): seven tables modeled on Audiobookshelf's relational shape and Cozy's Book → Chapter → file model.book_people(authors and narrators share one table, role-tagged by which link table credits them),series,books, thebook_authors/book_narratorslink tables,book_chapters(each row addresses either a standalone per-chapter file or a span inside one M4B, viafile_path+file_offset), andbook_playback(one row per book; first-class resume holding the absolute position, finished flag, and per-book speed / Smart Speed / Voice Boost overrides). The schema is core-owned even though audiobooks are a plugin: the boundary is code and dependencies, not the database (spec §2.2), so a music-only build still migrates to it with empty tables. book_fts(spec §4.4): full-text search over title, author, narrator, and series. Unlike the podcastepisode_fts, the author / narrator / series columns denormalize from the link tables rather than a single row, so the index is maintained by triggers onbook_authors/book_narrators(a space-joinedgroup_concatre-aggregated as links change) plus rename-propagation triggers onbook_peopleandseries(the musicartists_auprecedent).- The unified queue gains its last foreign key. Migration
0005parkedqueue.book_idas a plain column because SQLite refuses a foreign key to a table that does not exist yet;0011rebuildsqueueto add thebook_id REFERENCES books(id) ON DELETE CASCADEnow thatbooksis real (the same treatment0006gaveepisode_id). All three queue id columns now carry their cascade. - Core CRUD, headless and CLI-testable: the
Book/BookPerson/Series/BookChapter/BookPlaybackmodels, the single-writer write path (get-or-create people and series by their unique sort-name / name, insert a book, link authors and narrators idempotently, replace an ordered chapter set, upsert resume state, and the position / completion writes the Phase 7c player will use), and the read helpers (a book, its authors / narrators / chapters / series / resume row, and the book list). - Tests: a worker round-trip (people, series, a book, role links, ordered chapters, and resume state insert through the writer and read back through the pool intact, with sort-name / name dedup and idempotent links);
book_ftsdenormalization from the link tables, including a re-aggregation on a second author and a rename propagating back into the index; the position / completion writes (resume un-finishes a finished book); and the queuebook_idforeign key enforcing (a queue row pointing at a missing book is rejected). Full workspace suite + clippy-D warnings+ fmt + the--no-default-featuresmusic-only build green. No new dependency.
Next: 7a-ii, the tag and Audiobookshelf-sidecar reader plus the chapter resolver. That sub-phase carries the one real design decision of the phase: how to read embedded M4B chapter markers (an ffprobe shell-out versus a Rust MP4 crate).
Phase 6c-iii-d shipped: the sleep timer, the last piece of podcast parity. Set it to a duration or a boundary and playback pauses when you fall asleep; a fired duration timer keeps Castro's tap-to-extend trick alive. With this in, Phase 6c is complete and the absorbed Belfry podcast engine is at parity.
- The timer (
conservatory-core/src/player/sleep.rs, pure): aSleepClockthe engine ticks each loop turn, in three modes. A duration timer (15 / 30 / 45 / 60 minutes, or any value from the CLI) counts down only while actually playing, so a manual pause holds it rather than letting it expire silently; when it elapses, playback pauses. "End of item" pauses cued on the next item at the current item's end instead of advancing. "End of queue" lets the queue play out and disarms when it ends. The clock is transient per-session state, exactly like Castro / Overcast, so there is no database column and no migration. - Tap-to-extend (Belfry §3.6): when a duration timer fires it opens a 30 second window; pressing play within it re-arms the same interval instead of merely resuming. Outside the window, play just resumes.
- Available for any playing item, not episodes alone. Falling asleep to an album is a real use case and the engine is media-agnostic, so the timer is offered for music tracks too (a small broadening of spec §3.6, which scoped it to episodes). The menu's boundary row adapts to what is playing: "End of track", "End of episode", or "End of book".
- Where it lives: a moon menu button on the Now-bar (the output-menu popover idiom), shown whenever something is loaded, with the remaining
M:SSbeside the icon for a duration timer and an accent tint while armed. The Now Playing drawer carries a "Sleep · …" line that mirrors the state and invites the tap-to-extend once a timer has fired.Spops the menu (a window-local shortcut, so the bare letter does not fire while the filter bar has focus). Headless,conservatory-cli play <db> <root> --sleep <15|30|45|60|episode|queue>arms it and the run exits when a duration timer elapses. - Tests: the clock's unit tests (counts down only while playing, fires at zero, tap-to-extend re-arms inside the 30 second window and refuses outside it, the boundary modes carry no countdown); an engine null-output run where a duration timer fires and pauses mid-queue then tap-extends, and an "end of item" timer pauses at the first item's boundary without ever playing the second; the GUI label helpers (
fmt_sleep_remainingrounds the clock up,sleep_boundary_labelfollows the kind,sleep_drawer_text). Full workspace suite + clippy-D warnings+ fmt + the--no-default-featuresmusic-only build green. No new dependency, no new migration.
Next: with podcast parity reached, Belfry retires (archive the repo, update the ~/.gitrepos project map, spec §16.8), then Phase 7 (the Audiobooks tab) or Phase 8 (library audits).
The equalizer ships with built-in presets. Until now the only seeded preset was Flat, so the Sound dialog's preset dropdown was empty until you built your own. Migration 0010 stocks it with 16 starter curves that appear automatically in the dropdown and the eq preset CLI.
- The set: utility (Bass Boost, Bass Reducer, Treble Boost, Treble Reducer, Loudness, Vocal Boost), spoken-word (Spoken Word, Small Speakers, which matter for the podcast and audiobook tabs), and genre (Acoustic, Classical, Jazz, Rock, Pop, Electronic, Hip-Hop, Dance). Curves follow the classic iTunes/Winamp shapes, adapted to the peaking-band EQ and kept conservative (mostly within 6 dB) so octave-overlapping boosts do not stack into clipping. Your band layout (31 / 62 / 125 / 250 / 500 / 1k / 2k / 4k / 8k / 16k Hz) matches Apple's 10-band EQ, so the shapes map straight across.
- Respectful seeding: the built-ins are inserted with
INSERT OR IGNORE, a one-shot at migration time. A preset you already saved under one of these names keeps its values, and a built-in you delete does not come back on the next launch. Only Flat stays protected from deletion, as before. - No code change: the list / load / apply paths and the Sound dialog dropdown already existed; this is purely the seed migration. Tests: the built-ins load with the expected curve shapes and stay within a sane dB range; the seed-count assertions were updated. Full workspace suite + clippy
-D warnings+ fmt + the--no-default-featuresbuild green.
Bugfix: podcast show notes were missing for feeds that leave <description> empty and put the real notes in <content:encoded> (Cortex, and others on the same setup). The parser preferred the RSS summary and only fell back to content when the summary was absent, but feed-rs reports an empty <description/> as Some(""), so the fallback never fired and a blank string was stored. On a live Cortex pull this left the 33 newest episodes with no notes.
- Parse fallback (
conservatory-podcasts/src/parse.rs): an empty or whitespace-only summary is now treated as absent, so the notes fall through to<content:encoded>; a blank result either way collapses toNone(the ingest sanitize then no-ops). All 180 Cortex episodes now carry notes. - Readability (
conservatory-podcasts/src/notes.rs): the ingest sanitize now also breaks headings and list items (</h1>–</h6>,</li>,</ul>,</div>, …) into newlines, not just</p>/<br>, so a heading no longer runs into the paragraph that follows it. - Tests: an empty-
<description>-with-<content:encoded>parse case and a heading/list-break sanitize case. Full podcasts suite + clippy-D warnings+ fmt green. No new dependency, no migration, no schema change.
Note: chapters are unaffected. Cortex publishes no podcast:chapters, so its episodes have no chapter list (embedded ID3 chapters are a later fold-in).
Phase 6c-iii-c shipped: the Now Playing drawer is now a real episode surface. Open it on a podcast and you get a clickable chapter list that highlights the chapter you are in and follows the playhead, a Smart Speed line whose saved time ticks up as you listen, and show notes cleaned to readable text. This is the last of the surfacing work before the sleep timer (6c-iii-d) and Belfry's retirement.
- Show notes cleaned at ingest (
conservatory-podcasts/src/notes.rs): feed descriptions are HTML; they are now run throughammoniato plain readable text when an episode is stored, so the triage pane, the Now Playing drawer, and the CLI all read clean notes with no per-render cost.ammoniadoes the load-bearing work (it drops<script>/<style>bodies and copes with malformed markup); a small pass turns paragraph breaks into newlines and decodes the structural entities. Existing episodes clean on their next refresh. - Smart Speed reaches the UI: the player snapshot now carries
smart_speed_active(the current item's profile flag) andsmart_speed_saved(the live saved seconds from the open listening session). The drawer shows a "Smart Speed · saved m:ss" line for a smart-speed show, with the full figure in a tooltip; it is hidden for music and shows without it. - Chapter list (
now_playing_panel.rs): the drawer grows a "Chapters" list for a chaptered episode. Each row is the start time plus the title (or "Chapter N" when untitled); clicking a row seeks to it; the playing chapter is highlighted and re-highlights as the playhead crosses a boundary. A track or chapter-less episode hides the section. The current-chapter highlight and the Smart Speed line update from the same 250 ms poll that drives the Now-bar, so the per-tick cost is a class toggle, not a rebuild. - Tests: the
sanitize_notescases (tag stripping, entity decoding, paragraph breaks, dropped<script>bodies, malformed HTML, blank-line collapse); an engine assertion that the snapshot reports Smart Speed active for an episode whose profile has it on. Full suite + clippy-D warnings+ fmt + the--no-default-featuresmusic-only build green. No new migration;ammonia(workspace-approved, spec §11) activated in the podcasts crate.
Manual-launch check still owed (display-bound): open the drawer on a chaptered, smart-speed show and confirm the chapter list highlights and seeks, the saved time ticks up, and a track hides both.
Next: Phase 6c-iii-d (the sleep timer), then podcast parity is complete and Belfry retires.
Phase 6c-iii-b shipped: chapter navigation. Episodes with chapters can now be skipped chapter by chapter, from the Now-bar or the keyboard, and the engine swaps cleanly between a music track's filter chain and an episode's spoken-word profile mid-queue. The mechanism is built generic in the core player so the audiobook engine reuses it at Phase 7.
- The navigation math (
conservatory-core/src/player/chapters.rs, pure): a lightweightChapterMark(start time + title) and two helpers,current_chapter_at(which chapter the playhead is in) andneighbour_chapter(the absolute time to seek to for a next / previous skip). Forward stops at the last chapter (a no-op past the end); back restarts the current chapter when more than three seconds in, else steps to the previous one, clamped at the first. Unit-tested for every case. - The item carries its chapters:
PlayableItemgained achaptersfield, resolved at queue-build time (an episode's stored chapters, an audiobook's at Phase 7), so the engine navigates them without ever reading the database. The GUI attaches them after building a queue (attach_episode_chapters); a music-only queue simply has none. - Engine + transport: a new
SkipChaptercommand seeks to the neighbouring mark (and, like a user seek, is excluded from the Smart Speed time-saved accounting). The player snapshot now reports the current chapter and the chapter count. The Now-bar grows previous / next-chapter buttons that appear only for a chaptered item, andCtrl+Shift+→/Ctrl+Shift+←skip chapters from anywhere. - CLI: the
playverb now attaches an episode's chapters to its queue item, so chapter-skip works headless too. - Tests: the
chapters.rshelpers; an engine run that skips forward to a chapter boundary and back to the start (paused, so the fixture cannot end under it); and the roadmap-named filter-graph swap, a queue interleaving a music track and a podcast episode that plays both to completion (proving theaf-chain profile switch at the kind boundary, spec §16.9). Full suite + clippy-D warnings+ fmt + the--no-default-featuresmusic-only build green. No new dependency, no new migration.
Manual-launch check still owed (display-bound): confirm the Now-bar chapter buttons appear for a chaptered episode and the keybinding jumps chapters, and that a chapter-less item hides them.
Next: Phase 6c-iii-c/d (the Now Playing episode surface, then the sleep timer), the last of podcast parity before Belfry retires.
Phase 6c-iii-a shipped: podcast chapters are now fetched and stored. A feed's
podcast:chapters URL (parsed since 6a-ii, but dropped) is fetched on first sight
of an episode, parsed, and written to the chapters table, so chaptered shows
arrive with their chapter set. Headless; navigation and the Now Playing chapter
surface are 6c-iii-b and -c.
- Chapter fetch + parse (
conservatory-podcasts/src/chapters.rs):parse_chapters_json(pure) reads the Podcast Index chapters JSON ({ "chapters": [ {startTime, title, endTime, url, img}, … ] }) into coreChapterrows;fetch_chaptersis the thin GET wrapper, sharing the refresh fetcher's connection pool. Tolerant: a chapter-less or{}document is an empty set; blank optional strings becomeNone; the chapter image URL is kept verbatim (downloading it is deferred). - Wired into refresh (
refresh.rs::apply_feed): when a genuinely-new episode carries a chapters URL, its chapters are fetched and stored via the existingreplace_chaptersworker command. Best-effort: a fetch or parse failure is logged and never fails the refresh, and only new episodes fetch (a re-refresh does not re-hit every URL). The storage plumbing (table, write,list_chaptersread) already existed; only the fetch + parse + wiring is new. - CLI:
podcast chapters <db> <episode_id>lists an episode's stored chapters (index, start time, title), read-only. - Tests: the JSON parser (a full document, empty / missing-array, blank strings,
malformed); a wiremock refresh that serves a feed plus its chapters JSON and asserts
the chapters land.
serde/serde_jsonactivated in the podcasts crate (both already workspace dependencies). Full suite + clippy-D warnings+ fmt + the--no-default-featuresmusic-only build green. No new migration.
Next: Phase 6c-iii-b (chapter navigation: the shared skip-to-next/prev engine mechanism the audiobook engine reuses at 7c).
Phase 6c-ii shipped: time-saved accounting. The player now records an append-only listening session for every episode it plays, with the wall-clock time Smart Speed saved, and a CLI surfaces the running totals. Headless; the Now Playing readout is part of the 6c follow-on.
- The accounting math (
conservatory-core/src/player/session.rs, pure): aSessionAccumulatorsamples real (wall-clock) vs audio time each engine tick and reportssmart_speed_saved = max(0, audio_seconds / speed − real_seconds). The subtle part issilenceremove's non-linear timeline: when it drops dead air the playhead leaps forward, and those jumps are counted as covered audio (the listener got that audio for free), which is exactly what produces a positive saved figure. Variable speed alone nets to zero (at 2× the playhead advances twice as fast, so the two terms cancel); a user seek is excluded (it is a jump, not audio played); a pause accrues nothing. All five cases are unit-tested with synthetic tick sequences. - Engine wiring (
player/engine.rs): a session starts when an episode loads, samples once per ~10 Hz loop (resyncing rather than accruing while paused or ended, so idle time never inflates it), excludes user seeks (including the launch-resume jump to the saved offset), and closes by appending one row at every episode boundary: item change, end-of-file, stop, queue clear/replace, the removal of the playing item, and a mid-episode shutdown. The write is blocking, like the resume cursor, so the ledger row is guaranteed to land. - Storage: the
listening_sessionstable (seeded back in migration0006, never written until now) gets its insert (insert_listening_session, append-only) and an aggregate read (listening_totals: session count + summed real / audio / saved seconds). No new migration, no new dependency. - CLI:
podcast stats <db>prints the totals, formatted asH:MM:SS: sessions, time listened, audio covered, and the wall-clock Smart Speed saved. - Tests: the
session.rsaccumulator math (no-silence ≈ 0, a silence jump saves, variable-speed nets zero, a seek is excluded, a pause accrues nothing, a non-positive speed cannot divide by zero); alistening_sessionsappend round-trip through the worker (empty totals are zero, three appended sessions sum); an engine null-host run that plays an episode to EOF and lands exactly one session row with sane non-negative totals. Full suite + clippy-D warnings+ fmt + the--no-default-featuresmusic-only build green.
Next: Phase 6c-iii+ (chapters + the Now Playing episode additions), the last of podcast parity before Belfry retires.
Phase 6c-i shipped: Smart Speed and Voice Boost are real. The per-show toggles (saved since v0.0.37 but inert) now drive spoken-word stages on the shared 5.5 af-chain, so a show's episodes play through silence-trimming and voice-leveling. Headless; the time-saved accounting is 6c-ii.
- Spoken-word stages (
conservatory-core/src/player/spoken.rs, pure, sibling todsp.rs): Smart Speed is@ss:lavfi=[silenceremove=…](mid-stream dead-air removal,stop_periods=-1, tuned to leave a natural beat); Voice Boost is a fixed three-stage preset,@vbcomp(a gentleacompressorwith make-up gain) →@vbeq(low-cut + presence lift) →@vbnorm(livedynaudnorm, a tighter window than the music leveler). Both are presets on the existing chain, not a parallel path;build_af_chainappends them after the music stages, Smart Speed before Voice Boost so the compressor does not raise the noise floor before silence detection. - The profile carries the flags:
MusicProfilegainedsmart_speed/voice_boost.resolve_episode_profilereads them from the show's settings;resolve_music_profileleaves them false, so the music chain is byte-for-byte unchanged (the no-regression guard). A show with no saved settings resolves both to false (the feature applies to shows you have configured; the settings dialog defaults Smart Speed on, so saving opts in). - CLI:
podcast debug-chain <db> <episode_id>prints an episode's resolvedafchain (its spoken-word profile composed with the persisted EQ + DSP), so the@ss/@vb*stages are inspectable headless. - GUI: the per-show settings dialog drops its "audio processing arrives later" caption; Smart Speed / Voice Boost now describe what they do.
- Tests: the
spoken.rsbuilders (on → the expectedsilenceremove/acompressor/dynaudnormstrings, off → none);build_af_chainappends the spoken stages for an episode profile and emits the unchanged chain for music; the libmpvao=nullEOF run now sets both flags, proving mpv accepts thesilenceremove+ Voice Boost syntax and still decodes to end. Full suite + clippy-D warnings+ fmt + the--no-default-featuresmusic-only build green. No new dependency, no new migration.
Next: Phase 6c-ii (time-saved accounting via append-only listening_sessions).
Phase 5.5c-ii-b shipped, completing Phase 5.5c-ii and the music audio engine: the "Sound" preferences dialog (the equalizer's home since 5.5b) now holds the whole chain, and the persisted config finally drives playback. The music daily-driver feels complete; podcasts (Phase 6) are next.
- The consolidated "Sound" page (
conservatory/src/ui/window.rs,ui/sound.rs): three new groups join the equalizer. ReplayGain (mode / preamp / clip-guard); Dynamics (the compressor / limiter / leveler, each anadw::ExpanderRowwhose enable switch toggles the module and whose child rows tune it, the app's first use ofExpanderRow); Output (backend / device / resampler / gaplessComboRows + a switch). DSP and output changes drive the engine live (DSP is a structuralafrebuild, gap-acceptable; the backend reloads viaao-reload); ReplayGain / gapless changes are resolved per-queue, so they take effect on the next built queue. The wholeaudio_statepersists on dialog close (the EQ slider precedent). - The output device picker lives in two places now: the header
MenuButton(Phase 4c-ii, the quick-switch) and a write-throughComboRowin the Sound page's Output group, both populated from the engine's queried device list and both sendingset_audio_device. - Persisted config drives playback (
apply_persisted_audio): at startup the GUI pushes the stored DSP modules + output backend + resampler into the engine (which also fixes that 5.5c-i's DSP was stored but never applied in the GUI), mirroringapply_persisted_eq. The queue builders (build_play_queue/build_mixed_queue) now read the persisted playback defaults viaPlaybackConfig::from_audio_stateinstead ofPlaybackConfig::default(), so a saved ReplayGain mode / preamp / clip / gapless choice shapes the next queue. - Deferred and recorded (unchanged from the spec, not built): exclusive/bit-perfect output (ALSA
hw:+--audio-exclusive), LADSPA / raw-afhosting, nativecrossfeed, the parametricanequalizer, peak-aware ReplayGain attenuation. - Tests: the pure picker-mapping helpers in
ui/sound.rs(option_index/option_value/option_labelsfor the ReplayGain-mode, backend, and resampler tables, with the forgiving fallback and round-trip). The dialog widgets are verified by build + manual launch (the 3b/3c/5.5b-ii precedent). Full suite + clippy-D warnings+ fmt + the--no-default-featuresmusic-only build green. No new dependency, no new migration.
Next: Phase 6 (podcasts) continues; the shared af-chain engine is now ready for the Phase 6c spoken-word presets (Smart Speed / Voice Boost).
Phase 5.5c-ii-a shipped: the output backend and resampler are finally applied to the player, with a CLI to drive them (headless). Migration 0009 seeded output_backend and resampler_quality back at 5.5c-i but nothing consumed them; this closes that gap. The consolidated GTK "Sound" page is the next step (5.5c-ii-b).
- Output backend (
conservatory-core/src/player/host.rs):MpvHost::set_output_backendsets mpv'saodriver and reloads the output (ao-reload) so an in-session switch takes effect without waiting for the next track (gap-acceptable, theset_dspstructural-rebuild precedent).automaps to an emptyao(mpv's own driver autoprobe); a named backend (pipewire/pulse/alsa/jack) pins the driver. This is distinct from the device picker (audio-device, Phase 4c-ii): backend is the driver, device is the sink. - Resampler quality:
MpvHost::set_resamplerraises theaudio-resample-*knobs (filter-size,cutoff) forHighand restores mpv's defaults forDefault, re-asserted on each load. Avoid-resample stays the default either way:audio-samplerate/audio-formatare left unset, so a same-rate file is never resampled. - Engine:
SetOutputBackend/SetResamplerQualityplayer commands + the matchingPlayerHandlemethods (theSetDspshape).PlaybackConfig::from_audio_statemaps the persisted playback defaults (ReplayGain mode / preamp / clip, gapless) into the profile resolver; it lives inplayer/profile.rsso the db layer stays free of the player'sReplayGainenum (the one place the storedreplaygain_modestring becomes the enum, forgiving on an unknown value). - CLI
outputverb:output show(the active backend + resampler),output backend <auto|pipewire|pulse|alsa|jack>, andoutput resampler <default|high>(readget_audio_state, write through the worker, thedspverb precedent).debug-dspnow prints the resolved backend + resampler line. - Tests: the host null-AO integration sets the backend to
null(exercising theao+ao-reloadpath hermetically, since a real driver might fail to init in CI) and toggles the resampler, and the EOF smoke run now re-asserts aHighresampler throughload; thePlaybackConfig::from_audio_statemapping (each mode plus the forgiving fallback); the CLI verbs verified end-to-end against a fixture DB. Full suite + clippy-D warnings+ fmt + the--no-default-featuresmusic-only build green. No new dependency, no new migration.
Next: Phase 5.5c-ii-b (the consolidated GTK "Sound" preferences page).
Phase 5.5c-i shipped: the DSP modules, in the chain and the CLI (headless). The compressor, brick-wall limiter, and volume leveler join the af chain as toggleable stages; their settings persist; the output backend / resampler control and the consolidated GTK "Sound" page are the next step (5.5c-ii).
- DSP stages (
conservatory-core/src/player/dsp.rs):build_af_chainnow also renders the dynamics modules after the EQ, in signal-flow order:@comp(acompressor),@limit(alimiter),@boost(dynaudnorm). Each contributes a stage only when its module is enabled (all off is the no-op chain). The limiter runslevel=disabled, so it is a transparent peak catcher rather than a normalizer, which makes it a safe ReplayGain clip net (a positive net gain can never clip with the limiter on). User-facing dB knobs (the compressor threshold, the limiter ceiling) are converted to the ffmpeg filters' linear forms in the one place the stage is built.dynaudnormis used for live leveling rather thanloudnorm, whose accurate mode is two-pass / offline. - Modules keep their settings while off. Each module is an
enabledflag plus its parameters, stored independently, so toggling a tuned compressor off and back on restores it rather than resetting to defaults. - Persistence (migration
0009, theeq_stateprecedent): a singletonaudio_staterow holds the whole audio config: the playback defaults (ReplayGain mode / preamp / clip, gapless), the three DSP modules, and the output backend / resampler. TheAudioStatemodel round-trips through the single-writer worker; the read is forgiving (a bad stored value degrades to the default). The playback and output halves ship here but are consumed at 5.5c-ii, so that sub-phase needs no second migration. - Engine: a new
SetDspplayer command. The host holds the active DSP modules and applies them into the chain on the next load (a settings change rebuilds the chain structurally, which is fine for an explicit change; DSP has no per-slider live path like the EQ). - CLI
dspverb:dsp show(each module's state and the resolved@comp/@limit/@boostchain),dsp comp on|off [--threshold --ratio --attack --release],dsp limiter on|off [--ceiling], anddsp leveler on|off [--target --gausssize].debug-dspnow prints the DSP breakdown, andplayapplies the persisted modules. - Tests: the stage builders (off → no stage; on → the expected lavfi string with the correct dB-to-linear conversion); the full chain order (
@rg→@eq→@comp→@limit→@boost); theaudio_stateround-trip and the params-survive-an-off-toggle guard through the worker; the migration table-exists; and the libmpv EOF run now sets a real@comp/@limit/@boostchain, proving theacompressor/alimiter/dynaudnormmpv syntax decodes. Full suite + clippy-D warnings+ fmt + the--no-default-featuresmusic-only build green. No new dependency (the filters ride the linked libmpv/ffmpeg); one schema migration.
Next: Phase 5.5c-ii (the output backend / resampler control and the consolidated GTK "Sound" preferences page).
Phase 5.5b-ii shipped: the equalizer is now interactive. Open the new Sound preferences (the speaker-card button in the header, or Ctrl+,), drag a band, and hear it change live; presets are a click away. This completes Phase 5.5b.
- Live, gap-free per-band changes (
conservatory-core): dragging a band sends mpv anaf-commandto the namedequalizer@b<n>filter (ffmpeg'sequalizersupports a runtimegaincommand), so the change is instant with no click or gap in the audio. A structural chain rebuild is reserved for the moments that need it: switching presets, or crossing the flat↔non-flat boundary where the@eqstage appears or disappears. The host now remembers the playing item's profile so it can rebuild mid-track. New engine commandSetEqBand+PlayerHandle::set_eq_band; theaf-commandmapping is a pure, unit-tested helper. - The app's first preferences dialog (
conservatory/src/ui/sound.rs): a "Sound" page in anadw::PreferencesDialog(Phase 10's config work builds on this surface). An Equalizer group of 10 vertical sliders (−12 to +12 dB, with a detent at 0) under their ISO centre-frequency labels, plus a preset dropdown (your saved presets and a "Custom" marker) and Save as… / Delete / Reset. Dragging a slider applies live and marks the EQ "Custom"; choosing a preset or Reset moves the sliders and the sound together. Edits persist when you close the dialog; preset actions persist immediately. - The persisted EQ is now active from launch. The GUI never pushed the stored EQ to the engine at startup; it does now, so your equalizer applies from the first track, not only after you open the dialog.
- Opens via the header speaker-card button or Ctrl+,.
- Tests: the
eq_band_commandmapping (a band change maps toaf-command @eq gain <dB> b<n>, no chain rebuild); a null-output engine run that mutates bands live mid-playback and still reaches end-of-file (the real mpvaf-commandpath); thematch_presetprojection. Full suite + clippy-D warnings+ fmt + the--no-default-featuresmusic-only build green. No new dependency; no schema change.
Next: Phase 5.5c (the DSP modules — compressor, brick-wall limiter, dynaudnorm leveler — plus output backend / resampler control, and peak-aware ReplayGain clip-prevention), then Phase 6c (the spoken-word Smart Speed / Voice Boost presets on this engine).
Phase 5.5b-i shipped: the graphic equalizer, in the chain and the CLI (headless). A 10-band ISO-octave EQ now joins the af chain as the @eq stage; presets persist; the live sliders and the GTK "Sound" dialog are the next step (5.5b-ii).
@eqchain stage (conservatory-core/src/player/chain.rs):build_af_chainnow also renders the equalizer:@eq:lavfi=[equalizer@b0=f=31:t=o:w=1:g=…, … equalizer@b9=f=16000:…], a stack of namedequalizerpeaking bands at the ISO octave centres (31 / 62 / 125 / 250 / 500 / 1k / 2k / 4k / 8k / 16k Hz). A flat EQ contributes no stage (the no-op chain). Each band is namedequalizer@b<n>so the next sub-phase can mutate it live viaaf-commandwithout rebuilding the graph. The libmpv EOF test confirms the syntax decodes.- Persistence (migration
0008, theperspectivesprecedent):eq_presets(named, seeded withFlat) and the singletoneq_state(the live band values + the selected preset). TheEqStatemodel carriesbands: [f64; 10]+ the preset name; reads and worker writes round-trip through the single-writer worker. CSV-backed, forgiving (a bad stored row reads as flat rather than breaking playback). - Engine: a new
SetEqplayer command. The host holds the active EQ and applies it into the chain on the next loaded track. (Instant, gap-free per-band changes are 5.5b-ii.) - CLI
eqverb:eq show(each band's centre + gain, the active preset, and the resolved@eqchain),eq set <band 0-9> <gain dB>(clamped to ±24, marks the EQ a custom edit), andeq preset list | save <name> | load <name> | delete <name>(Flatis undeletable).playapplies the persisted EQ. - Tests: the
@eqbuilder (flat → no stage; a non-flat EQ → the named bands at the ISO centres;@rgprecedes@eq); the EqState + preset round-trips and the forgiving CSV parse; the migration table-exists; the libmpv EOF run now sets a real@eqchain. Full suite + clippy-D warnings+ fmt + the--no-default-featuresmusic-only build green. No new dependency (the EQ rides the linked libmpv/ffmpegequalizerfilter); one schema migration.
Next: Phase 5.5b-ii (live af-command slider mutation + the app's first GTK "Sound" preferences dialog).
Phase 5.5a shipped: the chain foundation and correct head-staged ReplayGain. This begins Phase 5.5 (the music DSP engine) and fixes a real ReplayGain bug, headless/core. It is the substrate the Phase 6c spoken-word chain (Smart Speed / Voice Boost) is built on.
- Labelled
af-chain builder (conservatory-core/src/player/chain.rs): the flat profile-to-properties application becomes a real mpvaffilter chain, built once per item with labelled stages. 5.5a emits the@rgReplayGain head stage; the@eq/@comp/@boostslots (equalizer, compressor/limiter, leveler) join in 5.5b/c.build_af_chainis pure and unit-tested. - ReplayGain moves to the chain head and is recomputed per track, fixing mpv #8267. ReplayGain is now an explicit
@rg:lavfi=[volume=<dB>]at the head of the chain, computed from the DB'sreplaygain_track/_album(read from tags at import or written by the rsgain scan). mpv's built-in--replaygainis dropped: it sat after theafchain (a boosting EQ would defeat clip-prevention) and was not re-applied per track across a gapless boundary, so a whole queue inherited the first track's gain (mpv bug #8267). Because the host rebuilds the chain from each item's profile on load, every track gets its own gain. - Preamp + clip-prevention. A
replaygain_preampoffset, and a clip guard: with no peak data stored, the safe default (replaygain_clip) clamps the net gain to attenuate-only (≤ 0 dB), which can never push a sample over full scale. The real brick-wall limiter and peak-aware attenuation arrive in 5.5c. - Gapless:
--gapless-audio=weakwhen gapless (preserves the source rate across a mixed-rate library),nofor single items. - Crossfade removed. The unused
crossfade_secondsfield and config key are gone (crossfade is impossible in a single libmpv instance and maintainer-rejected; the codebase never set it non-zero). Gapless-only, the path real mpv-based players take. - CLI
debug-dsp <db> [track_id]: prints the resolvedafchain plus the ReplayGain breakdown (mode, raw track/album gains, preamp, clip, net dB), gapless, and speed. The every-surface-CLI-testable rule; verified against the realtestdata/albums. - Tests: the chain builder (the
@rgstring; empty when off; different gains produce different chains, the per-track #8267 guard); profile resolution (mode downgrade, preamp, the clip clamp, off → none, episode → none); and the libmpv EOF test now plays a fixture with a real@rgchain set, proving theafsyntax is valid and does not break decode. Full suite + clippy-D warnings+ fmt + the--no-default-featuresmusic-only build green. No new dependency (every filter rides the already-linked libmpv/ffmpeg); no schema change.
Also corrected a stale phasing claim: spec §17 / the roadmap said Phase 5.5 "lands before podcasts," but 6a/6b (the podcast manager and triage) are independent of the audio engine and shipped first. The wording now says 5.5 lands before the Phase 6c spoken-word chain specifically, which is the piece that actually depends on it.
Next: Phase 5.5b (the graphic + parametric equalizer, the first GTK "Sound" preferences surface, and live af-command parameter mutation).
Playback feedback, diagnostics, and a Now Playing drawer. The player no longer runs silently: it tells you what it is doing, the two bugs from hands-on use are fixed, you can turn on logs, and clicking the Now-bar opens a details panel.
Bug fixes:
- The Now-bar no longer shows a stale song while a podcast plays. The engine snapshot now carries the current item's media kind, so the Now-bar reads the right metadata: a playing episode shows its title and show (and the show's art, or the placeholder, never the previous track's cover). The root cause was a kind-blind snapshot that always did a track-only lookup, which returned nothing for an episode and left the old cover up. A new
episode_metadataread resolves an episode to the same shape as a track. - A podcast episode now starts on the first press, no pause-then-play nudge.
engine.load_currentnow syncs mpv's pause state to "playing" after loading. mpv inherits the prior pause state across aloadfile, so an item loaded after a paused one (notably the launch-resume queue, which loads paused) came up paused while the UI thought it was playing. A regression test guards it.
Feedback the player was missing:
- Buffering indicator. A streamed episode can take many seconds to start while it buffers; the Now-bar now shows a spinner during that wait (mpv
core-idle), so the silence is explained. - Streaming vs downloaded. The Now-bar shows a streaming glyph for an undownloaded episode, and the Podcasts episode list gained a column marking each episode downloaded vs stream-only. ("Downloading" is not shown yet; there is no GUI-triggered download.)
- Podcast double-click plays just that episode. Double-clicking an episode used to dump the entire visible list into the queue (the album idiom, wrong for a 180-episode feed). It now plays only the episode you clicked; the queue is still built deliberately via triage or
Ctrl+Enter.
Diagnostics:
- Logging. Both binaries install a tracing subscriber, so the events wired through the worker, player engine, and podcast fetch/refresh actually surface. The GUI defaults to
infoand takes a--debugflag (raises our crates todebug: the player load / advance / buffering transitions); the CLI honoursRUST_LOG. Without this, all of it was a silent no-op, which is why the app gave no output. New player-transition log lines make "what is it doing" answerable.
New surface:
- Now Playing drawer. Click the Now-bar cover/title (or press
Ctrl+I) and a panel slides up from the bottom, the horizontal twin of the right-side queue drawer, showing the current item's full metadata: for a track, format / bitrate / sample rate / ReplayGain / path / rating / plays / album / year; for an episode, show / date / runtime / size / stream-or-local / notes. It updates as the queue advances. This is the lighter realization of the spec's Phase 11c Now Playing surface, and its content area is the intended home for the future spectrum visualizer.
Tests: the pause-sync regression (a fresh queue plays after a pause, via a null-output engine run); episode_metadata resolves the show title + cover and a missing episode reads as None; the Now Playing field projections for a track and an episode (pure, headless); the streaming flag through the queue builders. Full suite + clippy -D warnings + fmt + the --no-default-features music-only build green.
Phase 6b-ii-c-3-c shipped: per-show podcast settings in the GUI. Select a show in the Podcasts tab, click the gear in the detail pane, and set its playback speed, Smart Speed and Voice Boost, intro/outro skip, and what happens to new episodes (inbox, queue, or archive). This completes the per-show overrides and Phase 6b-ii-c.
- Settings gear (
conservatory/src/ui/podcasts.rs): when a show is the selected sidebar source, a gear button appears in the detail pane (hidden for the triage buckets and tags, where a single show does not apply). The detail header now shows the show's title when no episode is selected. - Settings dialog: the gear opens an
adw::AlertDialogwhose content is anadw::PreferencesGroupof rows, pre-populated from the show's stored overrides (or the schema defaults when it has none): a speed spin row (0.25x–4.0x, the same bounds the player clamps to), Smart Speed and Voice Boost switches, intro/outro skip spin rows, and a "New episodes" dropdown (Add to Inbox / Add to Queue / Archive, the inbox policy that drives v0.0.36's routing). Save writes through the single-writer worker (upsert_show_settings), the same path thepodcast settingsCLI uses. The dialog reuses the bulk-edit dialog idiom; the existing episode-detail and triage flows are untouched. - Honest about 6c: Smart Speed and Voice Boost are saved per show, but their audio processing (the
af-chain filters) lands at Phase 6c; the dialog says so, so the toggles persist intent without overstating what plays today. - No clobber: the panel does not expose the global-inherit
skip_forward/skip_backfields, so a save preserves whatever those were rather than resetting them. - Tests: the pure form mapping is unit-tested headless (the inbox-policy index round-trip and out-of-range degrade-to-inbox;
settings_from_formapplies the edited fields and preserves the skip-forward/back inherits; the default skeleton matches the schema). The dialog widget tree is build + manual (the 3b/3c GUI precedent). First use of the libadwaita preference-row widgets (SpinRow/SwitchRow/ComboRow); no new dependency. The--no-default-featuresmusic-only build stays green.
With this, Phase 6b-ii-c is complete (episode playback, resume, and per-show overrides), and so is Phase 6b-ii (the Podcasts triage panes). What remains for podcast parity is Phase 6c (the Smart Speed / Voice Boost af-chain, built on the Phase 5.5 engine, plus chapters and the now-playing additions).
Next: Phase 5.5 (the DSP af-chain engine that 6c builds on) or Phase 6c (the spoken-word profile), per the spec §17 ordering.
Phase 6b-ii-c-3-b shipped: inbox-policy routing and retention, the management half of per-show overrides. A show set to auto-queue drops its new episodes straight into the queue; one set to auto-archive keeps them out of your inbox; and a show with a keep limit prunes its oldest downloads so they do not pile up on disk.
- Inbox-policy routing (
conservatory-podcasts, on refresh):refresh::apply_feednow reads each show'sinbox_policyonce (the schema default, Inbox, when a show has no stored settings) and routes every genuinely-new episode through it:AlwaysQueueenqueues it into the unified queue,AlwaysArchivemarks itArchivedUnlistened,Inboxdoes nothing (the §4.2 derivation already puts a row-less, un-queued episode in the Inbox bucket). Only new episodes route, so a re-refresh never re-queues an episode you have since removed from the queue or un-archives one you archived by hand. - Retention (
conservatory-podcasts/src/retention.rs): prune downloaded episodes beyond a show'skeep_count(0 = keep all). The oldest downloads lose their audio file and theiraudio_path, reverting to stream-only; the row, triage state, and resume position survive, only the bytes go. It is a separate root-aware pass (it deletes files under the library root), splitplan→applyin the mover's dry-run-then-apply shape, and only ever touches files you actually downloaded (auto_downloadis off by default). The empty episode dir is removed best-effort. - Core: a new
clear_episode_audio_pathworker command (the counterpart toset_episode_audio_path, which can only set) reverts an episode to stream-only after its file is pruned. - CLI:
podcast prune <db> [show_id] --root <root> [--apply](one show or all). Dry-run by default: it lists the downloads it would delete;--applydoes the deletion. Routing needs no new verb (it ridespodcast refresh). - Tests: a new episode routes per each of the three policies and an already-seen one does not re-route (
refresh.rs); retention prunes the oldest downloads, keeps the newest, is a no-op atkeep_count = 0, and never counts stream-only episodes toward the cap (retention.rs). The--no-default-featuresmusic-only build stays green.
This is the second of the per-show overrides (c-3 split a/b/c): a was speed, b is this (inbox routing + retention), c is the GUI per-show settings panel. The Smart Speed / Voice Boost flags are stored but their filters remain Phase 6c.
Next: Phase 6b-ii-c-3-c (the GUI per-show settings panel), which surfaces speed, the Smart Speed / Voice Boost toggles, skip intro/outro, and the inbox policy in the Podcasts detail pane.
Phase 6b-ii-c-3-a shipped: per-show podcast playback speed. Set a show to play at 1.5x and its episodes play at that rate, with pitch held constant so faster speech still sounds natural, in the one unified queue alongside music.
- Profile carries speed (
conservatory-core): the per-item playback profile gainsspeed+pitch_correction.resolve_episode_profilenow reads the show'splayback_speed(clamped to a sane[0.25, 4.0], pitch correction on); music resolves to native speed.MpvHost::loadapplies mpv'sspeed+audio-pitch-correction(the built-in scaletempo2) before loading, so 1.0 / off is a no-op for tracks (the music path is unchanged). - Threaded through the queue builders: each episode's show settings reach the profile at enqueue.
EpisodeSource/MixedQueueRow/QueueDisplayRowcarryshow_id; a new coreshow_settings_mapbatch-reads the per-show overrides;build_episode_queue,build_mixed_queue(resume), and the CLIresolve_queue_itemsresolve each episode's speed from them. - CLI:
podcast settings <db> <show_id> [--speed N]views a show's settings, or sets the playback speed (preserving the other fields). The headless surface for the feature; the in-detail-pane GUI editor is c-3-c. - Tests: profile speed resolution + clamp; the queue builders apply per-show speed (and default to 1.0 without settings); a host integration test asserts
loadleaves mpv'sspeedat the profile value; the--no-default-featuresmusic-only build stays green. - This is the first of the per-show overrides (Phase 6b-ii-c-3 split into a/b/c): a is speed (this); b is inbox-policy routing + retention pruning on refresh; c is the GUI settings panel. The
smart_speed/voice_boostflags are stored and ride the settings, but their filters are Phase 6c (the Smart Speed / Voice Boostafchain).
Next: Phase 6b-ii-c-3-b (inbox-policy routing + retention) or c-3-c (the GUI per-show settings panel).
Phase 6b-ii-c-2 shipped: podcast episodes now resume. Close the app mid-episode and reopen it, and you pick up where you left off, to the second, downloaded or streamed, in the one unified queue alongside music. An episode's progress and played state persist on their own, so finishing or part-listening to an episode is reflected in the triage list across restarts.
- Per-kind transport cursor (migration
0007): the singletonplayback_statecursor gainskind+episode_id, so it records what kind of item was last playing. A restart reopens an episode (not just the last track) and seeks it to the saved position. The column adds are additive (kinddefaults to'track', the episode FK defaults NULL), so existing libraries migrate with no rewrite.docs/schema.mdupdated. - Episode persistence (
conservatory-core): two partial-upsert worker writes mirror the triage actions:set_episode_position(records the resume position + marks InProgress, preserving starred / play_count) on a playback tick, andcomplete_episode(marks PlayedFully, bumps play_count, rewinds position) on a natural end-of-file. The per-episode state lives in the podcastplaybacktable, so it survives the queue moving on to other items, distinct from the singleton cursor. - Engine per-kind dispatch (
player/engine.rs): the three persistence sites (tick flush, end-of-file, terminal end-of-queue) now branch onMediaKind. A track writes the music cursor +tracks.play_countas before; an episode writes the podcastplaybackrow + the episode-kinded cursor, and never an id into the music tables. The episode position write is synchronous and guarded on!ended, so it can never reorder past or clobber the terminal completion (both touchplayback.played). - Mixed-queue resume (CLI + GUI): the saved queue is no longer track-only on resume.
conservatory-cli play(resolve_queue_items) and the GUIresume_saved_queuerebuild an interleaved track + episode queue (build_mixed_queue) and resume at the cursor's(kind, id)+ position;load_queue_displaynow carries each episode's audio source so the GUI rebuilds without extra reads. - Refactor: the cursor write is bundled into a
PlaybackCursorstruct (the codebase's struct-passing idiom) rather than a seven-argument call. - Tests: episode persistence + cursor round-trip through the worker; an episode plays to EOF writing the podcast
playbackrow (PlayedFully + play_count) while leaving the music cursor and a colliding track untouched (the per-kind regression guard, strengthened from the c-1 guard test);build_mixed_queueinterleave / source-skip / cursor re-index; the--no-default-featuresmusic-only build stays green.
Next: Phase 6b-ii-c-3 (per-show overrides: speed, Smart Speed, Voice Boost, skip, retention, inbox policy).
A cleanup pass over the podcast subsystem: a real correctness fix, a robustness fix, and doc/comment tidying. No new features.
- Fix (the episode-id leak): the per-kind playback persistence guard from 6b-ii-c-1 was incomplete. The terminal cursor write in the engine's
advance_after_end(reached when the last queue item ends naturally) was unguarded, so finishing an episode-tailed queue wrote the episode id into the musicplayback_state.track_id. Because that column has an FK totracks(id), the write was silently dropped when no track shared the id, but in a populated library where an episode id collides with a track id, it persisted and would resume the wrong music track on the next launch. The terminal write now carries the samekind == Trackguard as the tick path. The engine null-sink test was strengthened to import a real track first (so the FK is satisfiable), which makes it actually exercise the leak path rather than masking it with an empty table. - Fix (429 cooldown): a
429 Too Many Requestswith a missing or non-numericRetry-After(the HTTP-date form) recorded no cooldown, so the next refresh cycle immediately re-hit the throttling host. It now falls back to a default backoff, so a throttling host is always cooled down. - Default change:
auto_downloadis now off. A new subscription no longer flags itself for auto-download (the schema default and theadd/import-opmlpaths now set itfalse). On a large subscription list, auto-downloading every episode fills the disk fast; downloads stay user-chosen (thepodcast downloadverb, spec §5.3). It is a stored per-show flag with no auto-download loop wired yet, so this only sets the future default; existing subscriptions keep their value. - Docs / comments: the Podcasts GUI module doc now describes the playback path (it still said playback was unshipped); the unified-queue write block comment and the
enqueue_episodes/replace_queue_with_episodesdocs were corrected (a doc comment had been misattached); theepisodes_in_bucketdoc now states that Queue and Played can overlap by design (only Inbox is exclusive); and a misleading "char boundary" note inslugifywas reworded (the slug is ASCII-only). - Verified the OPML round-trip on a real 72-feed Overcast export (import + export preserve every subscription and
applePodcastsID) and a single-feed refresh (215 episodes).
Known follow-ups (flagged, not done here): a few small refactors (a Show::skeleton constructor to dedupe the ~18-field skeleton built in three places; a shared element_name helper in namespace.rs; an EpisodeRow → EpisodeSource helper), and URL-decoding the download filename (kept percent-encoded for now, which is path-safe).
Phase 6b-ii-c-1 shipped: podcast episodes now play. Double-click an episode in the Podcasts list and it plays through the same libmpv engine, Now-bar, and queue drawer as music, streamed if it is not downloaded, from the local file if it is. The one unified queue now genuinely interleaves tracks and episodes.
- Episode queue writes (
conservatory-core):enqueue_episodes/replace_queue_with_episodes(mirroring the track variants; the queue schema already carriesepisode_id).load_queue_displaynow joins episodes, so a queued episode shows its title and its show in the drawer (QueueDisplayRowgainsepisode_id). - Queue builder (
conservatory/src/playqueue.rs):build_episode_queueturns episodes intoPlayableItems, using the downloaded file (root+audio_path) when present, else the enclosure URL (libmpv'sloadfilestreams a URL as-is). Source-less episodes are skipped. A basicresolve_episode_profile(no ReplayGain, no gapless; Smart Speed / Voice Boost is 6c). - Per-kind persistence guard (the load-bearing engine change,
player/engine.rs): the engine persists position + play counts only forMediaKind::Track. An episode plays to its end but does not yet write the musicplayback_statecursor or bumptracks.play_count, so an episode id can never leak into the music tables. Episode resume + per-kind persistence are 6b-ii-c-2. Music playback and resume are unchanged. - GUI: the Podcasts episode list gets double-click / Enter to play the visible list from that row, and
Ctrl+Enterto append, exactly the music leaf idiom.EpisodeListRow(and theEpisodeRowGObject) carryaudio_path/audio_urlso the view builds sources without a second read. - Tests: the episode queue write + display join (core);
build_episode_queuelocal-vs-stream-vs-skip (unit); and an engine null-sink test that plays an episode to EOF and asserts the guard held (no music cursor written). The existing track-playback test still passes, which is the music-regression check for the guard.
Deferred to 6b-ii-c-2: episode resume and per-kind persistence (write the podcast playback table on episode tick/EOF); to 6b-ii-c-3: per-show overrides.
Next: Phase 6b-ii-c-2 (episode resume + per-kind persistence).
Phase 6b-ii-b shipped: the Podcasts inbox is now actionable. Select an episode and mark it played, unplayed, or archived, or star it; the list's state glyph and the triage buckets update live. A Tags section joins the sidebar.
- Triage writes (
conservatory-core): two partial playback upserts so an action never clobbers its siblings:set_episode_played(episode_id, state, when)(preserves starred / play_count; marking unplayed rewinds the resume position) andset_episode_starred(episode_id, starred)(preserves played / position). New worker commands in the single-writer ledger. - Tag reads:
list_all_tags(every tag, for the sidebar) andepisodes_for_tag(episodes of shows carrying a tag, with triage state), reusing the 6b-ii-a episode-list projection. - CLI:
podcast mark <db> <episode_id> <played|unplayed|archived>andpodcast star <db> <episode_id> [--off], the headless surface. Verified end to end against a live feed (mark an episode played, watch it move to the Played bucket; star preserved across the mark). - GUI (
conservatory/src/ui/podcasts.rs): the detail pane gains a triage action bar (Mark played / unplayed, Archive, Star), enabled when an episode is selected, that writes through the single-writer worker (the music edit path'srt.block_on(worker.*)idiom) and re-loads the current source so the glyph and bucket counts refresh. The sidebar gains a Tags section. - Feature-gated as before; the
--no-default-featuresmusic-only build is unchanged and green. - Tests: the partial writes (mark-played keeps starred and vice-versa; mark-unplayed rewinds position; archived maps to ArchivedUnlistened) and the tag-filter read, as a core integration test; the bucket reads reflect the new state.
Deferred to 6b-ii-c: episode playback in the unified queue (stream or downloaded) and per-show overrides (speed / Smart Speed / Voice Boost / inbox policy). Those touch the player engine (per-kind persistence, a generalised queue item, resume), so they land as their own commit.
Next: Phase 6b-ii-c (episode playback + the unified queue + per-show overrides).
Phase 6b-ii-a shipped: the Podcasts tab can now browse your subscriptions. The empty placeholder from 6b-i is replaced with a three-pane triage browse (read-only); the triage actions and episode playback are the next step (6b-ii-b).
- Triage reads (
conservatory-core): a newEpisodeListRow(episode display fields + show title + the triage state joined fromplayback, defaulting to Unplayed, plus anin_queueflag) and two reads:episodes_for_show(a show's episodes, newest first) andepisodes_in_bucketfor the Inbox / Queue / Played buckets across every subscription. The buckets are derived, not stored (spec §4.2): Queue is unified-queue membership, Played isplayed >= PlayedFully, Inbox is the rest. - CLI:
podcast episodes <db> [--show <id> | --bucket inbox|queue|played](read-only;--tsv/--json/--human), the headless surface for the same data. Behind thepodcastsfeature. - Podcasts view (
conservatory/src/ui/podcasts.rs, GTK): a sidebar of the triage buckets and subscribed shows; an episode list (ColumnView) with a played-state glyph, title, date, and length; and a detail pane with the show notes. Built lazily on the page's first::map(the 6b-i slot) over the read pool. Notes are rendered as raw feed text for now (theammoniasanitize is deferred). The three panes are nestedgtk::Paned(matching the music browse); an adaptiveAdwNavigationSplitViewis a later refinement. - Feature-gated: the new
EpisodeRowGObject and the wholepodcastsUI module compile only with thepodcastsfeature; the--no-default-featuresmusic-only build is unchanged and stays green. - Tests: the bucket derivation is covered by a core integration test (an episode in the queue is Queue, a played one is Played, an untouched one is Inbox);
EpisodeRow's formatting is unit-tested; and the GTK view's construction is exercised by a display-guarded build test (runs under a session, skips on a headless CI).
Deferred to 6b-ii-b: the triage actions (mark played / unplayed / archived / starred), per-show overrides, episode-into-the-unified-queue insertion, and streaming-or-local episode playback. A Tags sidebar section also lands then (it needs a tag-filtered episode read).
Next: Phase 6b-ii-b (triage actions + episode playback).
Phase 6b-i shipped: the multi-view window shell. The GUI is no longer a single music window; it now has a top-level view switcher, with Music as the first tab and a Podcasts tab alongside it. This is the structural groundwork for the Podcasts UI (the triage panes land in 6b-ii); the Podcasts tab is an empty placeholder for now.
- View stack (
conservatory/src/ui/window.rs): the music browse (facet panes, filter bar, track list, queue drawer) is now one page of anAdwViewStack. The header carries anAdwViewSwitcher(policy = wide, the libadwaita 1.4+ idiom; the deprecatedAdwViewSwitcherTitleis not used). The always-on filter bar moved from a global top bar into the Music page, so a music filter no longer shows over the Podcasts tab; its behaviour (Ctrl+F, the grammar, the debounce) is unchanged. - Adaptive collapse: an
AdwBreakpoint(max-width 550sp) hides the header switcher and reveals a bottomAdwViewSwitcherBaron narrow widths. The Now-bar stays the stable innermost bottom bar; the switcher bar reveals beneath it (the spec §2.3 stacking call, no GNOME precedent to copy). - Lazy + feature-gated: the Podcasts page builds its child on first
::map, not at startup (it retains state once built). The whole multi-view chrome (switcher, breakpoint, bottom bar, Podcasts page) is behind#[cfg(feature = "podcasts")](the binary's first feature gates); a--no-default-featuresmusic-only build keeps a single-page stack with no switcher chrome, visually identical to before. - Keyboard:
Alt+1/Alt+2switch views (a globalShortcutController, theAdwTabViewAlt+Nconvention;Ctrl+1/2/3stays free for the 6b-ii triage lists).Alt+3is wired but inert until the Audiobooks tab (7b).docs/keymap.mdupdated. - Tests: the
Alt+N→ page-name mapping is a pure unit test; the widgets are verified by build + a launch smoke (the window constructs and runs cleanly with the new tree), the 3b/3c/4b precedent. The music-only build stays green.
No new dependencies; no schema, engine, or core changes (a pure GTK restructure). Next: Phase 6b-ii (the Podcasts triage panes: sidebar of triage lists / shows / tags, episode list, detail pane, with episodes flowing into the unified queue).
Phase 6a-iii-b shipped: private-feed credentials and episode download. This completes Phase 6a, the headless podcast subsystem; what remains for podcasts is the GUI (the Podcasts tab and triage, Phase 6b).
- Credential store (
conservatory-podcasts/src/credentials.rs): aCredentialStoreenum with a libsecret backend (viaoo7) and an in-memory backend for tests and headless environments. The password lives in the secret service; the database stores onlyshows.auth_userand an opaqueshows.auth_pass_reflookup key, never the password inline (spec §8). The enum (rather than adyntrait) keeps the async methods simple. - HTTP Basic auth wiring: the fetcher gained
fetch_authed, andrefresh_show/refresh_allnow resolve a show's stored credential and attachAuthorization: Basicfor a private feed. The anonymous path is unchanged; a missing secret service just leaves private feeds anonymous (a 401 then surfaces as that show'sFailedoutcome). - Episode download (
conservatory-podcasts/src/download.rs):download_episodestreams an episode's audio into<root>/<folder_path>/<filename>(the managed layout, spec §5.3) and records the relativeaudio_path. The write is crash-safe in themover::fsopsshape: stream to a sibling.partfile, fsync, then rename into place, so a partial download is never mistaken for a complete one. It reuses the fetcher's connection pool and carries the show's Basic-auth credentials. - Core: a new
set_episode_audio_pathworker command records the download path (upsert_episodedeliberately preservesaudio_pathacross a re-fetch, so it cannot set it), plus aget_episoderead by id. - CLI:
podcast download <db> <episode_id> --root <root>(resolves the episode + its show's credentials, downloads, records the path), behind thepodcastsfeature; the music-only build stays green. - Dependency:
oo7activated (signed off, spec §11; ATTRIBUTIONS.md), withdefault-features = false+ thetokioruntime (its default pulls async-std, which clashes with the workspace's tokio) andnative_crypto(pure-Rust file backend, no system OpenSSL). - Tests: credential round-trip + resolve rules (in-memory backend); a Basic-auth-gated download (401 without the credential, 200 with it flowing through the store); the download writes the file and sets
audio_path; filename derivation (URL basename else MIME-typed fallback). All hermetic (wiremock + a temp-DB worker).
Next: Phase 6b (the Podcasts tab: the window shell with an adaptive view switcher, then Belfry's Inbox → Queue → Played triage).
Phase 6a-iii-a shipped: OPML import and export. A subscription list round-trips, preserving tags and the Apple show id, so you can move in from another podcast app or back up your subscriptions.
- OPML module (
conservatory-podcasts/src/opml.rs):parse_opmlreads every<outline>carrying anxmlUrl(folder hierarchy is flattened, the Belfry tag-round-trip stance), pulling the feed URL, the title (title, elsetext, else the URL), the Pocket Castscategory="a,b"tags, andapplePodcastsID.write_opmlemits an OPML 2.0 document with XML-escaped attributes. The parser is forgiving in the house style: a malformed or foreign OPML yields whatever outlines parsed cleanly rather than erroring. - Import is network-free:
import_opmlcreates (or resolves) each subscription's show through the single-writer worker and applies its tags via theget_or_create_tag/set_show_tagsmethods from 6a-i;applePodcastsIDlands inshows.apple_podcasts_id. Episodes are not fetched here; a subsequentpodcast refreshpulls them (so importing dozens of feeds is instant).export_opmlreads the shows and their tags back out. - CLI:
import-opml <db> <file>(reports created vs already-subscribed) andexport-opml <db> [--out <file>](stdout by default), both behind thepodcastsfeature. The music-only build does not expose them and stays green. - Tests:
opml.rsunit tests (round-trip with escaping, forgiving parse of nested/foreign outlines, the title fallback chain) andtests/opml.rs(import through a real worker creates shows + tag links + the Apple id; a re-import is idempotent; export then re-parse returns the same subscription set). Hand-verified end to end through the CLI.
No new dependencies (quick-xml was already pulled at 6a-ii-b). Next: Phase 6a-iii-b (libsecret credentials via oo7 for HTTP Basic auth, and episode download into the managed tree).
Phase 6a-ii-b shipped: feed parsing and the refresh pipeline. A feed URL now becomes a subscribed show with its episodes, entirely headless. This completes the headless fetch-and-parse half of the podcast absorption (6a-ii); OPML, credentials, and downloads are 6a-iii.
- Parse (
conservatory-podcasts/src/parse.rs):parse_feedruns the body throughfeed-rsfor the RSS/Atom/JSON core and through the hand-rolled namespace pass, merging the two by item position with a guid cross-check. It yields a storage-agnosticParsedFeed(channel metadata + a flatVec<ParsedEpisode>), so it stays a pure, fixture-tested function; the refresh layer maps it into coreShow/Episoderows. Episode identity is(show_id, guid)(spec §8): the item-level<podcast:guid>when present, else feed-rs's entry id. The enclosure (URL / MIME / size) comes from feed-rs's media objects;itunes:durationgives the runtime. - Namespace handler (
conservatory-podcasts/src/namespace.rs): ported from Belfry'sfetch/namespace.rs(thequick-xmlevent walker for<podcast:guid>, season, episode, and the chapters URL), and extended to also readitunes:season/itunes:episode/itunes:episodeType.feed-rssurfaces none of those, and real Apple-style feeds carry season/episode/type in the iTunes namespace far more often than inpodcast:, so without this the columns would almost never populate.podcast:values win when both appear, regardless of element order. - Slugs (
conservatory-podcasts/src/slug.rs):slugifyandepisode_dirrender the managedPodcasts/<show-slug>/<YYYY-MM-DD>--<episode-slug>layout (spec §5.3), so each episode row is download-ready before any byte is fetched. - Refresh orchestration (
conservatory-podcasts/src/refresh.rs):add_show(unconditional fetch → create → upsert),refresh_show(conditional GET honouring the stored ETag / Last-Modified; a 304 just bumpslast_fetched), andrefresh_all(every subscription concurrently under aSemaphore, via aJoinSet, aggregating per-show outcomes). A refresh rewrites only the descriptive metadata and the HTTP validators; user-configured fields (priority, keep_count, auto_download, auth, cover/accent) are preserved. Triage (inbox policy, playback rows, queue insertion) is not here; that is Phase 6b. Re-adding an existing feed is idempotent (it just refreshes). - CLI (
conservatory-cli, behind#[cfg(feature = "podcasts")]):podcast add <db> <url>,podcast remove <db> <show_id>, andpodcast refresh <db> [show_id], with--tsv/--json/--humanoutput. The music-only build (--no-default-features) does not expose them and stays green. - Dependencies activated in
conservatory-podcasts:feed-rs(RSS/Atom/JSON core) andquick-xml(the namespace pass), both already in the workspace catalog; plus a path dependency onconservatory-coreso the plugin can drive the typed worker methods (the §2.2 boundary is code and dependencies, not the schema, and there is no cycle).ATTRIBUTIONS.mdrecords the sign-off and the Belfry namespace provenance. - Tests: parse unit tests (channel + episode extraction, guid precedence, enclosure, the podcast-vs-itunes precedence) and
tests/refresh.rs(wiremock + a real core worker on a temp DB):addlands both episodes, a secondrefreshdedups by(show_id, guid)and counts only the genuinely-new episode, and the conditional-GET round-trip stores an ETag onaddthen replays it for a 304 that leaves the episode set untouched. Two committed feed fixtures back the wiremock tests.
Next: Phase 6a-iii (OPML round-trip, libsecret credentials via oo7, and episode download into the managed tree).
Phase 6a-ii-a shipped: the RSS-catching layer. The conservatory-podcasts plugin crate gains a real HTTP client and a conditional-GET feed fetcher, both ported from Viaduct. Headless and wiremock-tested; no parsing or CLI yet (that is 6a-ii-b).
- HTTP client (
conservatory-podcasts/src/http.rs), ported from Viaduct'snetwork/http.rs(lineage NetNewsWire): rustls TLS, gzip + brotli,POOL_MAX_IDLE_PER_HOST = 4, 30 s idle/request and 10 s connect timeouts, a descriptiveConservatory/<version> (podcast client; +URL)User-Agent, and theACCEPT_FEEDheader.build_client(). - Conditional-GET fetcher (
conservatory-podcasts/src/fetcher.rs), ported from the network slice of Viaduct'snetwork/fetcher.rs. This is the heart of your "use Viaduct's method for RSS catching" steer: Belfry's fetch loop was only ever a stub, so the mature path wins.fetch(url, etag, last_modified)sendsIf-None-Match/If-Modified-Since, short-circuits a 304 with an empty body, extractsETag/Last-Modified/Cache-Control: max-agefrom a 2xx, and keeps a per-host 429 cooldown that honoursRetry-After(a host in cooldown short-circuits without a network hit).FetchErroris the typed error. - Deliberately simpler than Viaduct: the broadcast request-coalescing is dropped (each show has a distinct feed URL, so same-URL coalescing rarely helps) and the content-hash re-parse skip is deferred to the refresh orchestration at 6a-ii-b (where the stored hash will live). Documented in the module headers.
- Dependencies activated in
conservatory-podcasts:reqwest(rustls-tls + gzip + brotli),tokio,chrono,thiserror,tracing, pluswiremockas a dev-dep.bytesstays deferred (the body is aVec<u8>, so the crate never namesBytes).ATTRIBUTIONS.mdrecords the Viaduct/NNW provenance and the new deps. - Tests (
tests/fetcher.rs, wiremock, hermetic): a 200 returns the body and extracts the validators; a conditional request sendsIf-None-Matchand the server's 304 is handled; a 429 withRetry-AfterreturnsRateLimitedand the cooldown short-circuits the next fetch (asserted by anexpect(1)mock); an invalid URL is reported; plusmax-ageparse and client/UA unit smoke tests.
The music-only build is unaffected (the plugin is excluded under --no-default-features). Next: Phase 6a-ii-b (feed-rs + Belfry's namespace.rs parse, the refresh orchestration writing through the 6a-i worker, and the podcast add|remove|refresh CLI verbs).
Phase 6a-i shipped: the podcast schema and the core worker CRUD that backs it. Phase 6 (absorb Belfry) begins. This is the headless DB foundation; no network code yet (that is 6a-ii). The Belfry subsystem is being absorbed table by table into Conservatory's core-owned ledger.
- Migration
0006— the eight podcast tables, ported from Belfry (shows,episodes,playback,show_settings,listening_sessions,chapters,tags,show_tags), with one deliberate change (spec §4.2): triage Queue state lives in the unifiedqueuetable, soplaybackdrops Belfry'sin_queue/queue_positioncolumns. Inbox / Queue / Played derives fromplayback.playedplusqueuemembership.episode_fts/show_ftsjoin the FTS set as ordinary trigger-synced tables, matching the music FTS style in0001. - The unified queue gained its
episode_idforeign key. Migration0006rebuildsqueueto add the FK that was deferred at0005(withforeign_keys = ON, SQLite refused a child FK to the then-absentepisodestable).book_idstays plain untilbookslands at Phase 7. The saved playback queue is copied across the rebuild. - Core domain models + worker CRUD:
Show/Episode/Playback(+PlayedState) /ShowSettings(+InboxPolicy) /ListeningSession/Chapter/Tagindb/models.rs; podcast reads indb/reads.rs; and the worker write path (get_or_create_show,update_show— carrying the conditional-GET state the fetch loop will refresh —delete_show,upsert_episodeby(show_id, guid),upsert_playback,upsert_show_settings,replace_chapters,get_or_create_tag,set_show_tags). The schema is core-owned (the §2.2 boundary rule); theconservatory-podcastsplugin (6a-ii onward) consumes these typedWorkerHandlemethods.upsert_episodedeliberately never overwrites a downloadedaudio_pathon a re-fetch. - On the Viaduct/Belfry split (settled, lands at 6a-ii): RSS catching (the HTTP client + conditional-GET fetcher) ports from Viaduct (
network/http.rs+network/fetcher.rs), the mature, proven path; Belfry's fetch loop was only ever a planned stub. RSS parsing staysfeed-rsplus Belfry's hand-rolledpodcast:namespace handler (spec §8, §11). - Tests:
tests/podcasts.rs(9) covers show idempotency, episode upsert/dedup + download-path preservation, FTS sync across edit/delete, playback + settings round-trip, chapter replace, tag round-trip, and the queueepisode_idFK (viaPRAGMA foreign_key_list); the migration table-exists check is extended. The music-only build (--no-default-features) stays green: core is feature-free and the tables apply in every build.
No new dependencies (6a-i pulls none; the heavy podcast deps land with the fetcher at 6a-ii), so ATTRIBUTIONS.md is untouched. Next: Phase 6a-ii (the Viaduct-style fetcher + feed-rs/namespace parse + the refresh pipeline).
The default music layout gains a top-level Music/ folder, so a library root holds Music/, Audiobooks/, and Podcasts/ side by side (spec §5.1).
DEFAULT_MUSIC_TEMPLATEis nowMusic/{shelf_genre}/{albumartist}/{album} ({year})/{track:02} - {title}(was no prefix). New imports land underMusic/.- Existing managed libraries: running
organizere-shelves the music tree intoMusic/. The move is journaled and undoable like any other, but it relocates every album, so expect one big move the first time. - Docs: spec §5.1 + §5.7 +
docs/path-template.mdrecord the canonical per-media layout (audiobooks put standalone books under a literalStandalone/folder; podcasts already usedPodcasts/<show>/<episode>).
This is a docs-and-default change; no engine behaviour changed beyond the rendered path.
Phases 5c (ReplayGain scan) and 5d (cover art to disk) shipped together. Phase 5 — bulk editing, write-back, ReplayGain, and covers — is complete.
5c — ReplayGain scan (rsgain):
conservatory-core/src/replaygain.rsshellsrsgain(the Lattice invocation: album gain, write tags, clip-protect) to compute ReplayGain 2.0 for an album, then re-reads the written gains and refreshes the DBreplaygain_*columns the player's profile resolution consults. rsgain was chosen over theebur128Rust crate because the crate only measures decoded PCM and the pure-Rust decoder can't handle Opus (half the library); rsgain decodes every format itself. It is an external tool (ATTRIBUTIONS.md); spec §16.7 is settled.- CLI:
replaygain scan <db> <selector> --root <root> [--apply] [--target-lufs N](per-album; dry-run lists the albums,--applyscans and syncs the DB).
5d — cover art to disk:
conservatory-core/src/covers.rswrites each album'scover.jpg/.pnginto the managed folder and recordsalbums.cover_path. Import writes covers;organizeand path-affecting edits resync them (covers follow their album to the new folder, the stale one removed). The trust-critical mover is untouched: covers are derived, so they are synced idempotently rather than journaled.- CLI
set-cover <db> <album_id> <image> --rootsets an album's cover (and refreshes the accent). - The deferred Now-bar cover thumbnail and MPRIS
mpris:artUrlare now wired (the Now-bar shows the album art;mpris:artUrlisfile://<root>/<cover_path>). - Tests:
tests/replaygain.rs(hermetic DB-sync + a skip-if-absent rsgain scan over FLAC + Opus) andtests/covers.rs(import writes the cover; an edit moves it).
Still deferred: the APE-strip (Phase 8c byte-level pass), the in-dialog GUI cover field (the CLI set-cover covers it), online cover fetch.
Phase 5b-ii shipped: the GUI write-back action. Phase 5b (embedded-tag write-back) is complete.
- "Embed metadata into files" header button (the save icon): writes the database metadata into the selected files, behind a "Write tags to N file(s)?" confirm and a result dialog. Explicit, not automatic on every edit (the Calibre model); shares the v0.0.20
write_track_tagscore. Needs the library root (launch asconservatory <db> <root>).
Next: Phase 5c (ReplayGain scan, in-process via the ebur128 crate + lofty), then 5d (cover to disk + cover field).
Phase 5b-i shipped: embedded-tag write-back. The curated DB metadata can now be written back into the files, so the managed library is never a roach motel: walk away with the tree and the files describe themselves.
- Write-back core (
tags::write_track_tags, lofty): writes the format's canonical primary tag authoritatively (title, track artist + sort, album, album artist + sort, year, track/disc, raw multi-value genres), creating it if the file had none and dropping the legacy ID3v1. Only the rebuildable descriptive layer is written; the curated layer (rating, shelf genre, play counts, starred) stays DB-only per §5.6. A newdb::writeback_rowsjoin supplies the per-track data (display + sort names + group-concat genres). - CLI
embed-tags <db> <selector> --root <root> [--apply]: dry-run by default (shows the per-file field diffs, current tags vs DB);--applywrites. No undo journal: write-back is re-derivable from the DB (the source of truth), so re-running fixes any mistake. - Tests (
tests/writeback.rs): per-format round-trip (edit DB → embed → re-read the file) and the §5.6 re-import contract (embed → wipe DB → re-import → the edited album survives). Verified manually against thetestdata/albums.
Scope note — APE-strip deferred. The Lattice apestrip hygiene (stripping a stray APEv2 that shadows ID3 on MP3) is not in 5b: lofty reads APE on MPEG but neither writes nor removes it, so a reliable strip needs byte-level surgery (which is why apestrip.py is hand-rolled). It is deferred to a byte-level pass paired with the Phase 8c "detect stray APE" audit. embed-tags writes the canonical ID3v2 correctly; it just cannot remove a pre-existing APE shadow on MPEG.
Next: Phase 5b-ii (a GUI "Embed metadata into files" action), then 5c (ReplayGain scan).
Phase 5a-ii shipped: the GTK bulk-edit dialog. Phase 5a (bulk metadata editing) is complete.
- Bulk-edit dialog (
ui/window.rs): select tracks in the browser and press the header pencil button orCtrl+Eto open an edit dialog: one entry per field (album artist, album, year, shelf genre, track artist, title, raw genres, rating), blank means unchanged. Filled fields are parsed through the sharedcore::editresolver (a bad year/rating rejects the whole set), then applied across the selection through the single-writer worker. - Path-affecting edits are confirmed: changing album / album artist / year / shelf genre writes the values, then shows a "Move N files?" preview (the
mover::plandry-run) before relocating the touched albums with the Phase 2c mover (undoable). The browse refreshes after the edit. - Search-and-replace remains a headless verb (
tag replace, v0.0.18); the in-dialog replace mode is deferred. Live incremental refresh (the deferredLibraryChangesdelta) is still a full reload.
The dialog is GUI (build + manual verification); the edit and move logic it drives is covered by the v0.0.18 tests/edit.rs.
Next: Phase 5b (embedded-tag write-back) — write the curated DB metadata back into the files.
Phase 5a-i shipped: headless metadata editing. The library is no longer read-only after import; you can edit fields across a selection from the CLI, and path-affecting edits re-shelve files safely.
- Edit commands (
conservatory-core): new single-writer commandsupdate_track(title / rating / track artist),update_album(title / year / shelf genre / album artist), andset_track_genres(the raw multi-value side, §5.2).COALESCE-guarded so only changed fields move; setting an artist resolves it through get-or-create by derived sort name. The FTS index re-syncs automatically on every edit (the existing triggers), verified by test. - Pure resolver (
src/edit.rs): parsesfield=value, classifies track-level vs album-level and which edits are path-affecting (album / albumartist / year / shelfgenre), builds the typed edits, splits raw genres, and does literal search-and-replace. Unit-tested; shared by the CLI now and the GTK dialog at 5a-ii. - Path-affecting edits reuse the Phase 2c mover: an album / albumartist / year / shelf-genre change re-renders the touched albums and moves the files, with the same dry-run preview + undo journal as
organize. - CLI:
tag set <db> <selector> <field=value>...andtag replace <db> <selector> <field> <find> <replace>(selector is a full search expression;--root+--applydrive the move). - Tests:
tests/edit.rscovers field updates, FTS-follows-rename, genre relink (replace not append), and a year edit that re-renders, moves, and undoes. CI uses the committed fixtures; the realtestdata/albums are the manual corpus.
Also settled (recorded in the roadmap, deps added when those phases land): Phase 5c ReplayGain scanning will use the in-process ebur128 crate + lofty (no external binary); Phase 8a integrity verification will use flac -t + the ffmpeg CLI.
Next: Phase 5a-ii (the GTK bulk-edit dialog), then 5b (embedded write-back).
Phase 4c-ii shipped: the output-device picker. Phase 4 is complete — a daily-driver music player.
- Output devices (
player/host.rs):MpvHost::audio_devices()parses mpv'saudio-device-list(a node array of maps) intoAudioDevice { name, description }, andset_audio_device()sets theaudio-deviceproperty. The engine queries the list once at init and carries it (plus the current selection) on the snapshot; aSetAudioDevicecommand applies a switch through the engine thread. - Header picker (
ui/window.rs): aMenuButtonwhose popover is built fresh on each open from the snapshot — the sinks (plusauto), the current one checked; clicking one switches output. No D-Bus; mpv handles the device move live. - MSRV:
rust-versionbumped to 1.88 to match the let-chains already in use (introduced with the MPRIS module at 4c-i); CI builds on stable, so this is a documentation correction, not a behaviour change. - Tests: a host integration test (
audio_devices()includesauto,set_audio_device("auto")ok); the menu is verified by build + manual launch. - Fix — GUI playback: the GUI never actually played, because libmpv's
mpv_create()returns NULL unlessLC_NUMERIC = "C", and GTK sets the locale from the environment at startup (the CLI never does, so it was unaffected).MpvHost::buildnow callssetlocale(LC_NUMERIC, "C")(vialibc, signed off) before creating mpv. Also:scripts/demo.shnow passes the library root (conservatory <db> <root>), without which the GUI can browse but not play, and a missing-root launch logs a hint instead of failing silently.
With this, Phase 4 (libmpv playback, the unified queue, the GUI player + Now-bar + queue drawer, MPRIS2/media keys/inhibitor, and output selection) is done. Deferred polish carried forward: MPRIS Quit/Raise wired to the app, mpris:artUrl + a Now-bar cover (need covers on disk, §7.4), the audible within-album gapless prototype (§16.9), and in-window keyboard playback bindings. Next is Phase 5 — bulk editing + embedded-tag write-back.
Phase 4c-i shipped: MPRIS2 and a suspend inhibitor. The player is now a desktop citizen — media keys, the GNOME media overlay and lock screen, and don't-suspend-while-playing.
- MPRIS2 (
conservatory-core/src/mpris.rs, onzbus 5, signed off): servesorg.mpris.MediaPlayer2and…Playeron the session bus. Properties (PlaybackStatus, Metadata, Position, Volume, CanGoNext/Previous, …) and methods (Play/Pause/PlayPause/Next/Previous/Stop/Seek/SetPosition) drive thePlayerHandle.run(player, pool)polls the engine snapshot (~300 ms), emitsPropertiesChangedon change, and resolves the current track's metadata via a newtrack_metadataread (the snapshot carries only a track id). The GUI spawns it on its runtime; media keys and the GNOME overlay/lock screen come for free (GNOME routes them to MPRIS). - Suspend inhibitor: a logind
Inhibit("sleep", …, "block")proxy on the system bus, the FD held while playing and released on pause/stop. Best-effort: a missing system bus or logind disables the inhibitor without affecting MPRIS. - In core, not the GTK binary (spec §16.13): the whole surface is
conservatory-core, spawned by the GUI; no new widgets. The state→D-Bus mapping is pure, unit-tested helpers (PlaybackStatus, CanGoNext/Previous, wants_inhibit, volume/position conversions, metadata); atrack_metadataworker test covers the join. Live D-Bus is verified manually (playerctl,systemd-inhibit --list).
Deferred to 4c-ii: the PipeWire output-sink picker (mpv audio-device + a header menu). Also deferred: MPRIS Quit/Raise wired to the app, mpris:artUrl (needs covers on disk, §7.4), and the in-window keyboard playback bindings. After 4c-ii, Phase 4 — the daily-driver player — is complete.
Phase 4b-ii-c shipped: queue polish. The queue now survives a restart, and you can add to it from the browse list.
- Launch-resume: on startup
resume_saved_queueloads the saved DB queue into the engine paused at the cursor (a newpausedflag on the engine'sSetQueue, exposed asPlayerHandle::resume+ a seek to the saved offset), so reopening the app shows the last track in the Now-bar, paused, with the saved queue in the drawer; press play to continue. Opening makes no sound. Ctrl+Enterappend: appends the browse selection to the queue, both the DB tail (enqueue_tracks) and the live engine tail (the newAppendItemscommand, which starts playing if the queue was idle). Plain Enter / double-click still replaces the queue.- Tests: an engine null-host integration test covering append-to-idle (starts playing), a second append (extends the tail, current unchanged), and resume (a fresh engine loads the whole queue paused at the cursor). The GUI wiring is verified by build + manual launch.
Deferred: the Now-bar cover thumbnail (blocked until covers are written to disk, spec §7.4); the audible within-album gapless prototype (§16.9); the playback_state explicit queue-entry reference. Phase 4c is the system-integration finish (MPRIS2 + media keys + PipeWire sink picker + suspend inhibitor); the library root moves to config at Phase 10.
Phase 4b-ii-b shipped: a drag-and-drop queue drawer. The queue you're playing is now visible, reorderable, and editable, with the playing track highlighted. (Launch-resume, append, and a cover thumbnail are 4b-ii-c.)
- The drawer (
conservatory/src/ui/queue_panel.rs): a right-side slide-ingtk::Revealer(header toggle +Ctrl+U) holding aListViewof the queue, each row a kind icon over title/artist, the playing row accent-highlighted. Rows are drag-and-drop reorderable (the Atrium idiom: theDragSourcecarries the row's position, theDropTargetcomputes Above/Below from the cursor Y, both controllers torn down inunbindso they don't leak on recycling). Keyboard too:Alt+↑/↓reorder,Deleteremoves,Ctrl+Shift+Cclears. - Live engine mutation (
conservatory-core/src/player/): the engine gainedMoveItem/RemoveItem/ClearQueueso editing the queue never restarts the current track. Thecurrent_indexadjustment is pure and unit-tested (move_current_index/remove_current_index): the playing item follows a move, a remove-before shifts it down, removing the current item reloads what fell into its slot.MpvHost::stopunloads on clear. - DB queue is the source of truth (spec §4.3): double-click now writes the DB queue through (
replace_queue_with_tracks) before playing, and every drawer edit applies the identical(from, to)to bothworker.reorder_queueandplayer.move_item, so the DB position and the engine index stay aligned. New core readload_queue_display(queue ⋈ tracks ⋈ artists) backs the drawer; the playing-row highlight follows the engine via the 250 ms snapshot poll. - Tests: the index helpers (8),
drop_target_position(Above/Below, dragging up/down, end clamp), an engine null-host integration test that moves and removes queue items and assertscurrent_indextracks correctly without restarting the current track, and aload_queue_displayworker test. The widgets are verified by build + manual launch (the 3b/3c precedent).
Deferred to 4b-ii-c: launch-resume (load the saved queue paused at the cursor on startup), Ctrl+Enter append, a Now-bar cover thumbnail, the audible within-album gapless prototype (§16.9), and the playback_state queue-entry reference. MPRIS2 + media keys + inhibitor are Phase 4c; the library root moves to config at Phase 10.
Phase 4b-ii-a shipped: the browse window plays music. The threaded engine stands up in the GUI, a persistent Now-bar transport sits at the bottom, and double-clicking a track plays the list you're looking at. (The visible queue panel and drag-and-drop reorder are 4b-ii-b.)
- Engine in the GUI (
conservatory/src/ui/window.rs): thePlayeris spawned on the window's existing tokio runtime right after the worker; a libmpv init failure leaves it unset and the transport inert (browse is unaffected). The window now also holds the snapshot poll source, the playing queue's track-id → title/artist map, and the library root. - Now-bar (
conservatory/src/ui/now_bar.rs): a persistent bottom bar (attached withToolbarView::add_bottom_bar) showing title/artist, prev / play-pause / next (symbolic icons, no font assumption), a position label + seek slider, and a volume button. The transport buttons are non-blockingPlayerHandlesends; the seek slider drives playback throughchange-value(user drag only), so the 250 ms refresh's programmaticset_valuenever loops back into a seek. - Double-click / Enter plays the visible list (spec §3.6, the deadbeef idiom): the leaf's display order becomes the queue and the activated row is the start. A pure
playqueue::build_play_queue(headless-tested) turns the ordered ids + a batchTrackread into resolvedPlayableItems, preserving order, joining the library root onto the relative paths, resolving each profile, and re-indexing the start past any track that vanished between the read and the build. - Snapshot polling + teardown: a 250 ms
glib::timeout_add_localrefreshes the Now-bar (position/seek/icon every tick; labels only on track change). On window close the timer is removed first, then the player is shut down and joined (its terminal flush still has a live worker), then the worker/runtime drop — the order that keeps the final position write safe. - Core: one new reusable read,
get_tracks(a chunkedWHERE id IN (...)that survives a full-library activation). The GUI takes an optional library root as a second arg (conservatory <db> [root]) until Phase 10 config sources it. - Tests:
build_play_queue(order, root-join, start re-index, missing tracks) and time formatting as pure unit tests; aget_trackscross-chunk worker test. The widgets themselves are verified by build + manual launch (the Phase 3b/3c precedent).
Deferred to 4b-ii-b: the visible queue panel with drag-and-drop reorder (and Alt+↑/↓ / Delete / Ctrl+Shift+C), Ctrl+Enter append, GUI resume-from-cursor, a Now-bar cover thumbnail, the audible within-album gapless prototype (§16.9), and the library root from config. MPRIS2 + media keys + inhibitor remain Phase 4c.
Phase 4b-i shipped: the unified queue and the threaded player engine, headless. The libmpv host moves off the CLI loop onto its own thread behind a cross-thread handle, and a real queue drives it. (The GTK Now-bar and the drag-and-drop queue view are 4b-ii.)
- Unified queue (migration
0005, spec §4.3): thequeuetable lands with its full column set, but onlytrack_idcarries a foreign key for now. Withforeign_keys = ONSQLite refuses any DML on a child table whose parent does not exist yet, even for a NULL column, so theepisode_id/book_idforeign keys are added when theepisodes(Phase 6) andbooks(Phase 7) tables land. Positions stay contiguous (0..n-1), renumbered transactionally on the single writer. New worker commands: enqueue, replace, remove, reorder, clear;load_queuereads it back in order. - Threaded
Playerengine (conservatory-core/src/player/{engine,handle,item}.rs): a dedicatedstd::threadowns the!SendMpvHost(constructed there via amake_hostfactory, so it never crosses a boundary) behind aSend + ClonePlayerHandle. Commands (play_queue/toggle_pause/next/previous/seek/set_volume/stop/shutdown) flow out over a channel; state flows back through aPlayerSnapshotthe consumer polls. On advance the engine applies the next item's profile before loading (the spec §16.9 boundary switch, music profile); it advances on a natural end-of-file, skips an errored item, and ignores the self-initiated stop its own load emits. Persistence is split (spec §6.4): debounced ticks are fired and forgotten through the runtime, while the terminal writes (pause, seek, stop, shutdown, and the play-count bump + final cursor on end-of-file) block on the worker so they are guaranteed to land. is:queuedis live (was inert since 3a):conservatory-search's SQL path emitstracks.id IN (SELECT track_id FROM queue WHERE kind='track' ...); the eval path readsSearchRow.queued, anEXISTSagainst the queue computed insearch_rows.- CLI:
queue add | list | remove | clear, andplay <db> <root> [track_id]rewritten to drive the engine through the queue (the root resolves the relativefile_paths; with a track id it replaces the queue, else it plays the existing queue from the saved cursor), polling the snapshot until the queue ends. - Tests: queue position integrity (enqueue/remove/reorder stay a dense ordered range);
is:queuedmembership; and the headline engine test, which imports the committed fixtures into a managed tree, plays the whole queue through a null audio output, and asserts every play count incremented once and the cursor landing on the last item (tests/queue.rs).
Deferred to 4b-ii: the persistent Now-bar transport; the drag-and-drop reorderable queue view (with keyboard fallbacks); the audible within-album gapless prototype (mpv playlist append, §16.9); the library root from config (Phase 10) rather than a CLI arg. MPRIS2 + media keys + inhibitor remain Phase 4c.
Phase 4a shipped: the libmpv playback host and the music profile. The engine can play a track from the managed library (the first sound Conservatory makes), headless via the CLI, with the position persisted so a restart resumes.
- libmpv host (
conservatory-core/src/player/host.rs): a singlelibmpv2instance kept alive across items (MpvHost), withload/set_paused/seek_absolute/time_posand apumpthat maps libmpv events to a smallHostEvent. The host is thin glue, kept in core (spec §16.13), so the whole engine stays CLI-driveable.libmpv2 4.1was signed off over the alternatives and pulled into core; the systemlibmpvjoins GTK/libadwaita in CI. The threadedPlayerhandle and command channel are deferred to 4b, where the GTK Now-bar is the second consumer; building that plumbing now, with only the CLI loop to drive it, would be speculative. - Music profile (
player/profile.rs, pure + tested):resolve_music_profileturns a track + the[playback]config (spec §10 defaults) into the gapless flag, the ReplayGain mode, and the crossfade duration. ReplayGain uses mpv's nativereplaygainproperty (mpv reads the same file tagsloftystored at import); the DBreplaygain_*columns drive mode resolution, downgrading album→track→off by what the track actually carries. Settled for 4a: read-only ReplayGain (no in-app scan, §16.7) and no EQ/DSP (§16.6); both stay open. Crossfade is carried through but rendered at 4b with the queue. - State persistence (
player/state.rs, pure + tested; migration0004): a new singletonplayback_statetable is the transport cursor (what was playing and where).StateDebouncecoalesces the steady position stream to one write per 30 s insurance interval while flushing immediately on pause/seek/item-end/quit;EndReason::counts_as_playgates theplay_count+last_playedbump to a natural end-of-file. Saves go through the single-writer worker (save_playback_state/increment_play_count). - CLI:
play <db> [track_id]plays a track (gapless + ReplayGain), persisting position on the interval and on end; with no id it resumes the saved cursor. Read the track through the pool, write state through the worker, all on one current-thread runtime. - Tests: profile resolution + ReplayGain downgrade and the debounce/Eof logic as pure unit tests;
playback_stateround-trip and play-count increment through the worker; anao=nulllibmpv smoke test that decodes a committed fixture to EOF (tests/playback.rs).
Deferred: the threaded Player handle + unified queue + Now-bar transport (4b); MPRIS2 + media keys + suspend inhibitor (4c); crossfade rendering (4b); EQ/DSP and ReplayGain scanning (§16.6/§16.7, still open).
Phase 3c shipped: the browse window becomes a working library browser. A sortable, multi-select track list; the always-on filter bar wired to the grammar; and Perspectives (named saved searches) in a sidebar, persisted through the single-writer worker (its first appearance in the GUI).
- Track list (
conservatory/src/ui/track_list.rs): the full deadbeef columns (Artist | Album | Genre | Title | Duration | Rating). Click a header to sort; the comparison delegates tocore::cmp_tracks, so the GTK sort and the headlesssort_trackscan't drift. Multi-select (Ctrl/Shift) comes fromMultiSelection; rating renders as accent-tinted symbolic stars (icon-theme glyphs, no font assumption); rows lift on hover.TrackBriefgained a name-orderedgenresroll-up andrating. - Filter bar (spec §3.4): an always-on
SearchEntryunder the header;Ctrl+Ffocuses it; no separate search mode. Typing narrows the leaf through the full grammar, debounced, intersected with the facet selection ("the panes filter, the grammar searches, same surface"). Malformed input degrades to substring and tints the bar, never errors. The composition lives in a non-GTKquery.rs(headless-tested), keeping core runtime-search-free. - Perspectives (spec §3.4): migration
0003adds the core-ownedperspectivestable (saved searches as text, re-parsed on load). The sidebar lists Default + saved searches; Save names the current filter, clicking a row reloads it, Delete removes it.vl:NAMEnow resolves from storage, so a Perspective can reference another. Saves/deletes go through the single-writer worker (save_perspective/delete_perspective), which the browse window now stands up on a tokio runtime (the in-GUI writer, pulled forward from Phase 5a to back persistence). - Demo:
scripts/demo.sh's headless path now previews the filter-bar grammar (livesearchruns) alongside the facets; the GUI hint mentions sorting,Ctrl+F, and Perspectives.
Deferred: live BatchUpdate / library deltas (still Phase 5a); user-reconfigurable + persisted pane order (Phase 10); the per-row playing/status glyph (waits for playback state, Phase 4).
Phase 3b shipped: the first GTK4/libadwaita code. conservatory is now a launching app with the deadbeef-cui "Columns UI" faceted browse (spec §3.3).
- Facet logic (
conservatory-core/src/db/facets.rs, headless + tested):facet_rows(distinct values of Genre / Album Artist / Album withCOUNT(DISTINCT track), narrowed by upstream selections) andfacet_tracks(the leaf set). Genre is multi-valued: a track taggedElectronic; Ambientcounts under both rows (the §5.2 decoupling). The CLAUDE.md hard rule keeps the logic in core; the GTK binary only renders.debug-facets <db>exercises it headless. - GTK browse window (
conservatory/src/ui/, programmatic): anadw::ApplicationWindowlaid out like deadbeef Columns UI: a row of facet panes on top, the track table below (a draggable split). Each pane is aColumnViewwith a value column + right-alignedCountcolumn, sortable headers, grid lines, and an[All (N)]top row; the leaf is aColumnViewtrack table (Artist / Album / Title / Duration). Selecting facet rows narrows the downstream panes and the leaf (the cascade). A small CSS pass tightens the rows; richer track columns (rating, bitrate), sorting, and multi-select land at 3c. - Coalescing: ported Viaduct's
CoalescingQueue(interval + max-interval flush, dedup) to debounce selection changes into one cascade recompute per multi-select drag, never per row (spec §2.1). - CI: the
libgtk-4-dev/libadwaita-1-devinstall lands in both jobs.
Deferred: user-reconfigurable + persisted pane order (Phase 10 config); the sortable track list + filter bar (3c); BatchUpdate / live deltas (until an in-GUI writer, 5a).
Phase 3a shipped: the conservatory-search expression engine and a CLI search verb (the first piece of Phase 3, GTK browse).
- Grammar pipeline (
conservatory-search):lex→parse(typed AST + extractedsort:specs) →eval(in-memory) +sql_translate(all-or-nothing SQLWHERE, so the two paths never diverge), withrank(bm25 + recency). Structure ported fromatrium-search, semantics from CalibreQuarry, FTS plumbing from Viaduct; an independent implementation. Storage-agnostic (SqlValue, no rusqlite, no core); depsregex+chronoonly. - Grammar: the music field set (
artist/albumartist/album/title,genrevsshelfgenre,year/added,rating/bitrate/duration/format,is:played/is:starred/is:queued), match modifiers (substring/=/~regex/?fuzzy), boolean + ranges + date keywords/precision + presence,sort:as metadata. The parser is forgiving (degrades to substring, never errors).vl:perspectives expand at parse time with a cycle guard. - CLI:
search <db> '<expr>' [--format tsv|json|human]— SQL fast path when the whole expression translates, else the in-memory evaluator; bare-text hits ranked by bm25 + recency. New core readssearch_rows/search_track_ids/fts_rank(the consumer mapsSqlValue→ a coreSqlParam, keeping core search-free). - Tests: parse round-trip, per-field eval, per-node SQL,
vl:cycle guard, and SQL-vs-eval parity over a 2,000-track fixture; hand-verified against the real imported albums. - Deferred: persistent Perspective storage + UI (3c);
is:queuedmatches nothing until the queue table lands (4b); podcast/audiobook fields (6/7).
Phase 2d shipped: the import pipeline and real CLI verbs. The manager is usable headless (the Phase 2 exit): point the CLI at a folder and get an organized, database-owned library.
- Import pipeline (
src/import/): scan a folder → read tags → resolve artists/albums/genres → derive shelf genre + accent → render targets → move/copy into the managed tree. Runs in two passes: an in-memory resolution + conflict pre-check, then (only if clear) the persist + move, so a conflicting import leaves the database untouched. Import inserts at the source path and runs the journaled mover, so it is undoable and crash-safe like organize. - Resolver: album grouping by
(artist, title); album artist from the shared album-artist tag, else shared track artist, else Various Artists; artist identity bysort_name(embeddedARTISTSORTpreferred, else a leading-article derivation); album identity(album_artist_id, title)so re-imports reuse the album. - CLI:
import <db> <source> <root>(copies by default;--moveto consume),organize(re-render from the DB; dry-run/--apply/--undo),shelf-genre-set. Output--tsv(default) /--json/--human. The olddebug-organizeis promoted toorganize. - Worker:
get_or_create_artist/get_or_create_album/set_album_shelf_genre. The tag reader now also reads embedded sort-name tags. - Tests:
tests/import.rsend-to-end (import into a managed tree, copy-vs-move, re-import refusal, shelf-genre-set → organize) plus resolver/scan unit tests; hand-verified against two real albums (mp3 + opus).
Phase 2c shipped: the crash-safe file mover. This is the trust-critical, release-blocking subsystem (spec §5.4); moving the user's files is the headline risk.
- Mover engine (
src/mover/):plan(pure dry-run preview with conflict detection),apply(journal-first, then execute),undo(revert a completed job), andrecover(roll-forward replay of interrupted jobs at startup). The journal is a SQLite ledger (migration0002:move_jobs+move_operations), written before any file is touched and durable via WAL. Recovery rolls forward (completes the move the user asked for); replay is idempotent. - Per-file primitive (
mover::fsops): same-filesystemrenamefast path; cross-filesystem copy → fsync → verify → delete (modeled on Atrium's atomic write). Idempotent: a file already at its target is a no-op, which is what makes crash replay safe. - Conflict policy: duplicate targets, missing sources, and existing destinations refuse the whole job; nothing moves. Copy-vs-move is a per-job choice.
- DB consistency: completing an operation updates
tracks.file_pathandalbums.folder_pathin the same transaction as marking it done; undo reverts both the tree and the DB. - Worker + CLI: new journal commands on the single writer (file I/O stays off it);
debug-organize <db> <root> [--apply] [--copy] [--undo <id>]. - Tests: the release-blocking suite (
tests/mover.rs): move/undo round-trip, simulated mid-move crash rolling forward, conflict refusal, copy mode, tree↔DB consistency; plusfsopsunit tests.
Phase 2b shipped: the shelf-genre resolver that decides each album's filed-under genre.
- Resolver (
src/shelf_genre.rs):normalizesplits raw tags on;/,, case-folds for matching, and maps through the alias vocabulary, keeping canonical/original casing in the output.resolve_shelf_genreruns the spec §5.2 priority chain (manual override → single album-level tag → most-common normalized track genre, ties broken bygenre_priorityrank then first-seen →Unknown).resolve_albumis the DB-driven entry point; rawtrack_genresare read but never mutated (the §5.2 decoupling). - Genre vocabulary (spec §16.4, now settled): empty and user-built. Conservatory ships no default alias map or priority list; the schema can seed one (beets
lastgenreor MusicBrainz) later without a migration. - DB + CLI:
album_track_genresreads an album's per-track genres;debug-shelf-genre <db>derives and compares against the stored value (the headless usable artifact).
Phase 2a shipped: the path-template engine that renders the on-disk tree from the database.
- Path-template engine (
src/path_template.rs):PathTemplate::parsevalidates a template (unbalanced braces, unknown tokens, malformed format specs are errors);render(&TrackFields)is infallible once parsed. The default music template renders{shelf_genre}/{albumartist}/{album} ({year})/{track:02} - {title}. An album resolves to one path; compilations bucket under Various Artists (spec §5.1). Per-field fallbacks keep structural folders non-empty; optional pieces (year, track, disc) collapse with their surrounding literals. - Sanitization (docs/path-template.md): per-component path-separator replacement, reserved-device-name escaping, trailing dot/space trimming, whitespace collapse, and a per-component byte cap. Raw tags never reach the filesystem; the embedded tag keeps the true value (spec §5.5).
- Collision detection:
find_collisionsgroups tracks that render to the same target, for the Phase 2c mover to refuse or disambiguate. - DB + CLI:
track_render_rowsjoins tracks with their album/artist context;debug-paths <db>renders a whole library and reports collisions (the headless usable artifact).
Phase 1c shipped: the engine can read a real audio file.
- Tag reader (
src/tags.rs):read_trackreads embedded tags and audio properties into aTrackDraft(title, artists, album, track/disc numbers and totals, year, raw multi-value genres, ReplayGain, format, bitrate, sample rate, duration, embedded cover). Raw genres are kept verbatim, decoupled from the eventual shelf genre (spec §5.2). Built onlofty, signed off oversymphonia(spec §7.1) so one library also serves the Phase 5b write-back. - Cover accent (
src/accent.rs):compute_accentdecodes a cover and derives a packed-RGB accent via a median-cut quantizer ranked by vibrancy, a faithful port of Hermitage (spec §7.4, docs/accent.md).find_cover_bytesprefers the embedded picture, falling back to a sibling cover file.imagesigned off with jpeg + png features. - CLI:
debug-tags <file>reads a file into a draft and prints it with the accent (the headless usable artifact). - Tests + fixtures: per-format integration tests (flac/mp3/opus/m4a) over the first committed binary fixtures in the workspace, plus deterministic accent unit tests over in-memory covers. Fixtures regenerate via the
gen_audio_fixturesexample (ffmpeg + lofty); CI reads the committed files and stays hermetic.
Phase 1a + 1b shipped, and the workspace restructured around compile-time plugins.
- Phase 1a, the writer: single-writer SQLite worker (panic-catch-and-restart loop, per-op
oneshotreplies, ported in shape frombelfry-core/ Viaduct), read-only connection pool, WAL + pragma discipline, and the numbereduser_versionmigration runner. CI (fmt / clippy / test) landed with it. - Phase 1b, the music data model: migration
0001(artists / albums / tracks / genres /track_genres/genre_aliases/genre_priority, plustrack_ftsandalbum_ftswith sync triggers), the domain models, insert and read helpers, a synthetic fixture builder, and thedebug-roundtrip/debug-fixtureCLI smoke verbs. - Plugin restructure (spec §2.2, §16.13): music is the native program; podcasts and audiobooks become compile-time plugins. New feature-gated workspace crates
conservatory-podcasts(filled at Phase 6) andconservatory-audiobooks(filled at Phase 7), stubbed now so the wiring exists from day one. Both binaries gainpodcasts/audiobooksfeatures (default on) and report their enabled plugin set; CI gains a music-only (--no-default-features) job. The boundary rule: plugins are code and dependencies, not the database; all schema stays in core's single migration ledger and the unified queue stays a core commitment.
First commit. Project bootstrapped out of the design spec.
- Cargo workspace with the four planned crates (
conservatory-core,conservatory-search,conservatory-cli,conservatory), all building as dependency-light skeletons. - Portfolio scaffolding:
README.md,roadmap.md, this file,CLAUDE.md,ATTRIBUTIONS.md,VERSION, GPL-3.0-or-laterLICENSE,.gitignore, and a Meson packaging stub. - Build deferral lifted. The spec previously parked the build behind an Atrium shipping milestone; that decision was reversed and the build now proceeds concurrently with Atrium, with hard phasing as the mitigation (spec §16.1, §17).
- Belfry retirement remains gated on podcast parity (Phase 6); nothing in Belfry has been removed.