Changelog¶
1.9.0 (2026-09-24)¶
Features¶
- Show how far the first scan has come on the "Scanning…" page. The page held the same sentence for the whole scan, so a large library looked like a hang for minutes. It now reads "Indexed 7 of 19 books." and updates on each of its own refreshes. The count appears once the scan knows its book list, and it counts every discovered book, including the ones that are already up to date and the ones a sidecar disables. (#267)
- Re-encode a folder book's tracks when
PODSPINE_TRANSCODEis on. A folder of FLAC, Ogg, or Opus tracks was served as it is, so a podcast app that does not play those formats could not play the book, although a single chapterless file of the same format was already re-encoded. Each such track is now re-encoded once at ingest into the data directory, and the book records the mode, so the server never rebuilds or evicts those files. A folder can hold both kinds: an MP3 track still streams in place from the library beside a re-encoded FLAC one. The re-encodes are all or none. A folder publishes new track files only after every one of them succeeds, so a track that fails to encode leaves every episode the feed already advertises exactly where it was. A disk fault in the middle of the publishing step is the one case no rename sequence can undo, and a later scan re-ingests that book when its length check notices the fault. (#271) - Read a folder of Ogg, Opus, or FLAC tracks as one book, the same rule a folder of
.mp3tracks has always had. Such a folder was skipped with a warning before, so those books never appeared. Folder tracks are served exactly as they are, so a podcast app that does not play Opus or FLAC still cannot play such a book. A folder of several.m4b/.m4afiles is still several books, one per file, because that is what an author folder usually is and every one of those books keeps its feed URL. Setfolder_is_one_book = truein a folder's.podspine.tomlwhen it is really one book split by disc: every audio file in it then becomes one track of one book, and the books of the old shape are retired on the next scan, which does retire their feed URLs. An MP3 folder keeps exactly its.mp3tracks, so a stray file of another format cannot renumber a feed that subscribers already hold. An edit to a.podspine.tomlnow starts a scan by itself, instead of waiting for another change or a restart. (#269)
Fixes¶
- Publish the guid and the pubDate that each episode recorded at ingest. The feed used to re-derive both from the book's source file mtime. A Refresh sets that mtime to a sentinel until the watcher re-ingests the book, so during that window the feed published a new guid for every episode and dates in 1969. A podcast app saw a whole new book, downloaded it, and downloaded it again when the real mtime returned. The feed now reads the stored per-episode guid and pubDate, and the channel date follows the newest episode. Chapter order and the guid formula are unchanged. (#266)
- Give a folder book's episodes an identity that follows the file. A track's guid was built from its position and the folder's newest track timestamp, so deleting one track renumbered every track after it and handed a later track the guid a subscriber already held, and their podcast app kept the audio it had downloaded under that guid. A track's guid is now built from the file's own name and its own timestamp, so adding, deleting, or replacing one track leaves every other guid alone. One cost on the first scan after the upgrade: every folder book's episodes get new guids once, so a podcast app downloads those books again. Chaptered books are unchanged. (#272)
- Keep a book whose folder went away instead of deleting it from the index. A library root can hold several shares. When one share unmounts, its mount point stays behind as an empty folder, so the root is still populated and the whole-library guard does not fire. Every book of that share then looked deleted, and a delete takes the book's feed URL with it, so a remount minted a new URL and broke every subscription to those books. A scan now keeps a book whose nearest surviving folder is unreadable, or holds nothing but housekeeping files such as
.DS_Storeor@eaDir, and warns instead. The cost: a book you delete from a folder that is now empty stays indexed until you remove that folder. (#265) - Refuse to serve a chapter whose size is not the length the feed publishes. In saver mode the server rebuilds a chapter from the source file on demand. If the source changed since the ingest, the rebuild no longer matches the published length. Such a request now answers 503 with Retry-After, so a podcatcher retries and keeps the subscription. The server checks the size of every file it serves, and refuses any file that disagrees with its feed. A scan now treats a file of the wrong size as out of date, so it re-ingests that book and the two agree again. (#262)
- Let the database hold the one-source-one-feed rule. A book's source path is now unique in the index, so nothing can add a second book, and a second feed URL, for audio that is already indexed. The scan already assigned ids so that this held, and the rule is now a floor under it rather than a promise. A database that already held two rows for one source keeps the older one, which is the feed its subscribers have held longest, and the other is removed when the server next starts. (#273)
Performance¶
- Scan books in parallel instead of one at a time. A library scan used to walk its books one at a time. So a first scan of a large library left most cores idle. Each book now splits into three steps. The index reads and the id assignment stay serial, in discovery order. The probe, split, and cover work runs on a worker pool. The index writes happen back on the scan thread, as each book finishes. The pool is the size of the existing process-wide ffmpeg gate. That gate still caps how many ffmpeg children run at once, so a scan does not swamp a small host. Measured on a 20-core host, a 200-book by 8-chapter library scanned about 4 times faster. Full mode went from 36.1s to 8.7s, and saver mode from 35s to 8.1s. Feeds are unchanged. Each book keeps its sequential pubDates. Each enclosure length is still the real output file size. Ids and guids stay stable across a rescan. (#260)
1.8.0 (2026-09-17)¶
Features¶
- Add a
--log-levelflag (PODSPINE_LOG_LEVELenv,log_levelTOML key) to set log verbosity withoutRUST_LOG. Accepted values areerror,warn,info(default),debug, andtrace. WhenRUST_LOGis set, it still wins and keeps its per-module directives such aspodspine_scanner=debug. If the level is unrecognized, Podspine usesinfoand logs a warning instead of aborting startup. (#225) - Add a per-book "Refresh" button on the book page. It forces a one-time re-ingest of that book: re-probe, re-split, and re-extract the cover. A changed title, chapters, or cover art is then picked up without editing the file or restarting the server. The button posts to
/book/{slug}/refresh, guarded to same-origin like the regenerate control. The handler invalidates the book in the index and asks the watcher (the single reconciler) to re-ingest it. The capability URL and episode guids stay stable. The re-split runs in the background, so reload the page to see the result. (#234) - Show the running Podspine version in a footer on every web page, including the first-run scanning page. An operator can now tell which build is deployed without inspecting the container image tag. The value is the compiled workspace version, so a
:nightlyimage reports the same version as the release it branches from. (#226)
Fixes¶
- Fix a per-book
.podspine.tomlthat setsmetrics_bindfailing the whole sidecar.metrics_bindis a server-global key. It is now accepted and ignored with a warning, like the other server-global keys, rather than rejected as an unknown field. A book withmetrics_bindin its sidecar keeps its other overrides instead of losing all of them. (#229) - Add per-stage timing to the library scan and a large-library profiling script. The scanner now logs each stage at the debug level under the
podspine::scan_timingtarget: probe, chapter resolve, split, cover, and index writes. It also logs a per-book total and a whole-scan total. The logs stay silent at the default level, so normal operation does not change. A newscripts/bench-scan.shbuilds a synthetic many-book library and runs the first scan with this timing on. It reports where the time goes and how serial the scan is. Seedocs/benchmarks.md. (#235) - Log the library watch event that triggers a rescan, at the debug level, so a surprise rescan is explainable. Each triggering event records its kind and paths, and the reconcile line now reports how many events were coalesced. Turn it on with
--log-level debug. It helps most where another app shares the library directory and keeps touching files. (#230)
Performance¶
- Split a saver-mode book's chapters in parallel, like full mode already does. Saver mode used to split each chapter one at a time. So a many-chapter book on a multi-core host ingested far slower than the same book in full mode. It now reuses the same parallel splitter, bounded by the existing process-wide ffmpeg gate. Measured on a 20-core host, a 200-book by 8-chapter library scanned about 4.8 times faster in saver mode (168s to 35s). That matches full mode. The one trade-off is transient disk. A book's whole split set now exists briefly before it is measured and deleted, rather than one chapter at a time. It lives in an isolated temp dir outside the served book directory, so no regenerated bytes appear at a serve path. Steady-state saver disk is unchanged. (#236)
1.7.2 (2026-08-27)¶
Fixes¶
- The Docker image now runs
apk upgradebefore installing runtime dependencies, so openssl (libcrypto3/libssl3) and other base packages get the patched release from the Alpine branch instead of the older version frozen in the pinned base image (#196)
1.7.1 (2026-08-20)¶
Fixes¶
- Remux-to-faststart now publishes atomically like every other producer, so a failed remux can no longer clobber a cached copy that is being served (#183)
- The stale-copy sweep now recognizes episode files beyond chapter 999, so books with 1000+ chapters no longer leave orphaned copies on disk after a container change (#183)
- Log every previously swallowed error — internal 500s, skipped cache evictions, failed disabled-book prunes, and skipped id-reuse scans now leave a diagnosable trail in the server log instead of failing silently (#183)
- Startup now fails loudly when the data dir or library root cannot be resolved, instead of coming up as a server that silently 404s every request (#183)
Performance¶
- Serve a small cover thumbnail to the browse-UI grid instead of full-resolution artwork: the scanner generates a
cover_thumb.jpg(long edge ≤400px) alongside each cover, and/cover/{id}/thumbserves it (falling back to the full cover when it's missing). Existing libraries are backfilled by the reconcile on the next scan — no re-ingest needed. The RSS feed and/coverkeep the full-size image for podcatchers (#180) - Split a book's chapters in parallel instead of one at a time, bounded by the existing CPU-sized ffmpeg gate, so a first scan of a chaptered library is much faster (measured ~9× on a 20-core host for a 40-chapter book) — episode order and per-chapter enclosure sizes are unchanged (#178)
1.7.0 (2026-08-18)¶
Features¶
- On the
/subscribepage, put each podcatcher's QR code behind its own collapsible section (an exclusive<details>accordion, no JavaScript), so only the app you expand is on screen to scan and a phone camera can't lock onto a neighbouring app's code (#162) - Bind the HTTP port immediately and run the initial library scan in the background, so a first boot serves a self-refreshing "Scanning…" page at
/and 503 +Retry-Afteron the/feed,/audioand/coverroutes instead of leaving a proxy or Funnel to return 502 for the minutes the first scan takes (#161) - Follow the operating system's light/dark preference automatically: a
prefers-color-scheme: darkoverride of the existing CSS variables (pluscolor-scheme: light darkso native controls adapt) gives the whole UI an AA-contrast dark palette with no JavaScript, while the QR codes keep their scannable white background (#172) - Add a light/dark theme picker to the header: it still defaults to following the operating system, but a visitor can now choose Auto, Light, or Dark and the choice persists in a first-party
themecookie the server reads to render the right theme — no JavaScript, no flash of the wrong theme (#176)
Fixes¶
- Cache cover images:
/covernow sends anETag(a hash of the image bytes) andCache-Control, and honoursIf-None-Matchwith a bodyless304, so a browser stops re-downloading every cover on each page refresh — a real speed-up over slow links like Tailscale where the grid was pulling several MB of images every time (#174) - Document that the first scan can take a while: the README and docs quick-start now explain that the initial run splits chaptered books into per-chapter episodes (whole-file books stream in place), so a large chaptered library takes minutes, shows a self-refreshing "Scanning…" page meanwhile, and answers 503 + Retry-After on feed/audio until it finishes — while a warm restart stays fast (#175)
- Stop the library watcher from re-scanning every few seconds while another app streams the files: watch events are now filtered to real library changes — reads (atime bumps) are ignored, as are podspine's own split output under the data dir and the dir names discovery already skips (dotdirs,
@eaDir,lost+found) — so an unrelated trickle of events no longer defeats the debounce (#173)
1.6.0 (2026-08-14)¶
Features¶
- Scan the library recursively, so an
Author/Title/book.m4blayout — what Audiobookshelf, Plex and Jellyfin produce — is indexed as-is instead of returning nothing; nested books are titled by their folder and slugged by their path, a folder of several.m4bfiles is now several books, and the walk skips dot-directories,@eaDir,lost+foundand a--data-dirplaced inside the library (#156)
1.5.0 (2026-08-14)¶
Features¶
- Add opt-in transcoding for sources podcatchers can't play:
PODSPINE_TRANSCODE=aac|mp3re-encodes FLAC/Vorbis/Opus/ALAC books to AAC 128k (or MP3 128k) at ingest, while MP3/AAC sources stay stream-copied. Default staysoff(copy-first); a transcoded book is always storedfulland never evicted, because a re-encode can't be regenerated byte-for-byte (#152)
1.4.1 (2026-07-25)¶
Fixes¶
- Harden the saver-mode chapter-regeneration path: the book source is now canonicalized and asserted to live under the library root before it can reach ffmpeg, matching the checks the serve-in-place and faststart-remux paths already had (#108)
1.4.0 (2026-07-25)¶
Features¶
- Expose optional Prometheus metrics on a separate
--metrics-bindlistener, covering books indexed, feeds served, chapter-split duration, and errors by kind (#104)
1.3.0 (2026-07-10)¶
Features¶
- Detect non-faststart whole-file mp4 (
moovaftermdat) at ingest and log a one-line callout; add opt-inPODSPINE_REMUX_NON_FASTSTARTto remux such books to faststart on demand — a cache-managed stream-copy (no re-encode) served from thesavercache and regenerated/evicted like a cached chapter, never a pinned duplicate — so podcast clients seek quickly. MP3/OGG/FLAC, already-faststart mp4, and chaptered books are unaffected. (#61) - Add per-book
.podspine.tomloverrides: a sidecar beside a single-file book (Author - Title.podspine.toml) or inside a folder book overrides settings for just that book —storage_mode,force_embedded_chapters,remux_non_faststart,default_cover_url, plus troubleshooting knobsdisabled,title,author, andforce_reingest— with precedence sidecar → CLI/env → global config → default. Server-wide keys placed in a per-book file are ignored with a warning. (#62) - Serve whole-file episodes — folder-of-MP3 tracks and chapterless single files — in place, streaming them directly from the read-only library instead of copying them under the data dir; this removes the silent duplication those books used to cost (an existing library reclaims the copies on its next re-scan). Chaptered books are unchanged (
full/saver). (#59) - Add an opt-in
saverstorage mode (PODSPINE_STORAGE_MODE=saver) that keeps chapters in a bounded on-demand cache (PODSPINE_CACHE_SIZE/PODSPINE_CACHE_TTL) instead of keeping every chapter split on disk — cutting the data-dir footprint for chaptered books (whole-file books such as folder-of-MP3 tracks stream in place regardless of storage mode) for a small first-play delay per chapter. Ingest still splits each chapter once to record its real byte length, sosaversaves disk, not ingest time. (#53)
1.2.0 (2026-07-07)¶
Features¶
- Add a
--version/-Vflag sopodspine --versionreports the version (used by the install scripts and package-manager smoke tests). (#36)
1.1.0 (2026-07-05)¶
Features¶
- Add an "Add to a podcast app" subscribe page: the book-page QR now opens
/subscribe/{feed_id}with one-tap "Open in…" deep links for Apple Podcasts, Overcast, Pocket Casts, Castro, AntennaPod, and Podcast Addict (per-app QRs behind an expander) instead of raw feed XML the iOS Camera couldn't open. (#22)
1.0.1 (2026-07-05)¶
Fixes¶
- Set the audio
Content-Typeon/audioresponses so Apple Podcasts and other iOS clients can play episodes (axum-range sets none, which made playback fail with "this episode can't be played on this device"). (#20)
1.0.0 (2026-07-05)¶
First tagged release: a zero-config, self-hosted server that turns a folder of audiobooks into per-chapter podcast RSS feeds any podcast app can play.
Added¶
- Per-chapter podcast feeds with correct ordering: sequential
pubDates (oldest = chapter 1),itunes:episode,itunes:duration, andenclosure lengthread from the real output file. A built-in self-check refuses to serve a malformed feed. - Copy-first chapter splitting via
ffmpegstream copy (no re-encode). - Multi-book library scanning: each top-level audio file or per-book subfolder becomes an independent feed, with collision-free slugs.
- Web UI: a browsable book grid with cover art, plus a per-book page with a copy-feed-URL control, a scannable QR code, and per-app "how to add this" help.
- Cover art: embedded covers extracted to
itunes:image, with an optional feed-level fallback (--default-cover-url). - MP3-folder audiobooks: a folder of per-chapter MP3s ingested as episodes, ordered by track number (falling back to filename order), no re-encode.
- Tier-2 input formats: Ogg Vorbis, Opus, and FLAC, stream-copied into a matching container.
- Chapter sidecars: a
.cue(75 fpsINDEX 01) or.ffmetasidecar is preferred over embedded chapters;--force-embedded-chaptersoverrides. - HTTP Range streaming for audio (seek/scrub), with correct MIME types.
- Zero-config Docker image (multi-arch amd64/arm64, non-root, ffmpeg bundled) and static musl binaries.
- Configuration via CLI flags, environment variables, or a TOML file, with an ffmpeg/ffprobe startup preflight.
Security¶
- Opaque book/episode ids resolved server-side; slugs validated against an allow-list charset and rejected with 404 (path-traversal guard), with the resolved path canonicalized under the data dir as defense-in-depth.
- Bounded
ffmpegconcurrency (semaphore) with a per-child timeout and kill; request concurrency, timeout, and body-size limits on the HTTP layer. - Error responses never leak filesystem paths or
ffmpegstderr. - DRM-free input only. DRM-protected files (
.aax/.aaxc/.aa/.odm) are skipped with a logged notice; Podspine ships no DRM circumvention.