Implement MP3 (chapter-split) output format and add per-book convert action#4
Merged
Conversation
The output_format setting was persisted to the DB and stored on the DownloadManager but never read during processing, so every book was written as a single m4b regardless of the user's choice. The ffmpeg helpers for splitting were already implemented; this hooks them in. - DownloadManager: add SetOutputFormat/OutputFormat with mutex so the setting can be applied at runtime (no restart required). - handleProcessStage: when format is "mp3" and chapter data is available, run SplitChapters into a staging dir and hand it to a new PlexOrganizer.OrganizeMultiFile which moves the per-chapter files into the canonical Plex book folder. Falls back to single m4b when chapter data is missing. - Settings handler: apply output_format via SetOutputFormat instead of marking the change as restart-required.
Adds a Convert button on the book detail panel for completed books.
For an m4b book, splits it into per-chapter mp3 files in place; for
a chapter-split mp3 book, concatenates the chapters back into a
single m4b. Both paths reuse the existing PlexOrganizer naming and
update the book's FilePath/FileSize record.
- ConcatToM4B helper in audio package using the ffmpeg concat
demuxer + AAC re-encode.
- DownloadManager.ConvertBook orchestrates either direction with
audnexus metadata refresh for chapter marks and Plex scan
triggering on completion.
- POST /api/books/:id/convert?format={m4b|mp3} endpoint.
- hasSuffix template func and a confirm-prompted button on
book_detail_panel.
…kend Upstream commit 1120f7b extracted the Plex helpers from internal/library into a new internal/mediaserver package with a Backend interface (implemented by Plex and Emby). The earlier commits on this branch (d9986ea, 77e7141) still called the removed dm.triggerPlexScanForBook and dm.addBookToSeriesCollection methods directly, breaking the build. Update the four call sites to go through dm.mediaServer.TriggerScanForBook and dm.mediaServer.EnsureBookInSeriesCollection, mirroring the pattern already in handleProcessStage's single-file branch. The convert paths also now refresh the series collection on completion so converted books benefit from the same backend behavior as freshly downloaded ones.
Six fixes from the upstream PR review: - web: parse :id with strconv.ParseInt instead of fmt.Sscanf so trailing junk like "123abc" is rejected rather than silently truncated. - web: validate output_format (lowercase + allowlist) before persisting so the DB never stores a value SetOutputFormat would silently ignore on the next restart. - library/convert: ConvertBook is now idempotent — when the book is already in the target format, return nil instead of an error so a retried request succeeds. - library/convert: add per-ASIN locking via DownloadManager.convertingASINs so concurrent convert requests for the same book return ErrConvertInProgress (HTTP 409) rather than racing on shared staging directories. - library/convert: replace os.Rename with a moveFileCrossFS helper that falls back to copy+delete on EXDEV, matching PlexOrganizer's pattern, so convert works when downloads and library are on different mounts. - library/pipeline_stages: RemoveAll the chapter staging dir before MkdirAll so leftovers from a prior run can't leak into the final book folder when chapter counts change.
Contributor
Author
|
Addressed all of them in 1 commit |
Three independent improvements bundled:
1. Faststart on decrypt.
Audible delivers AAX/AAXC with the MP4 moov atom (index) at the
END of the file. Stream-copy decrypt preserved that layout, so
any later ffmpeg invocation that opened the m4b had to scan
significant chunks of the file looking for moov before it could
start producing output. On a 573 MB / 10-hour book that scan
was holding 9+ GB of RSS — the host went unresponsive during
chapter-split mp3 transcodes. Adding -movflags +faststart to
the decrypt args relocates moov to the front; downstream reads
are constant-memory regardless of book length.
2. Cancellable in-flight pipeline items + stall watchdog.
Previously a stuck download (network parked in io.Copy) left no
user lever to recover — the queue cancel only flips a DB row.
Add per-ASIN context registration with generation tokens so
late defers from prior stages can't clobber the current stage's
cancel slot. Expose a Cancel button on active pipeline cards
and POST /api/downloads/cancel-active/:asin. Add a 2-minute
stall watchdog on the download stage that auto-cancels when
bytesSnapshot stops advancing — TCP keepalive can take 2h to
fire, this gives a bounded recovery time.
Pipeline stages keep parentCtx alongside the per-item ctx so
failItem cleanup writes still commit when the user-cancelled
ctx is dead.
3. UI polish.
- Distinct \"transcoding\" pipeline stage so the badge reads
\"transcoding chapters\" while ffmpeg is splitting, instead
of \"moving file\" which only applied to the file-move step
that follows. Updates JS allowlist / badgeClass / stageLabel /
cancellable flag / progress-bar render.
- Per-chapter logging in SplitChapters so a long-book split
shows steady progress in the log.
- Decoupled parent-ctx from HTTP request ctx for ConvertBook
so closing the browser tab doesn't kill an in-flight convert.
The Cancel button still works because it targets the itemCtx
via the same active-cancel endpoint.
- Convert error path emits a failed SSE event so stuck cards
clear immediately on error.
- Convert ordering: UpsertBook commits before deleting source
so a crash mid-convert leaves a recoverable state on disk.
- Cross-FS-safe move helper for convert chapter files (EXDEV
fallback) when downloads and library are on different mounts.
Also strips ffmpeg stderr buffering: -hide_banner -nostats
-loglevel error on transcode invocations + a 64 KB tail buffer
for error reporting. ffmpeg's default progress chatter would
otherwise accumulate gigabytes in the parent's CombinedOutput
capture during a long re-encode.
Bumps Alpine 3.19 -> 3.21 for newer libs.
Contributor
Author
|
2835ce8 addresses excessive host RAM during chapter-split transcodes on long audiobooks: the decrypted m4b had its MP4 index at the end of the file, so each chapter encode scanned constantly looking for it. The fix adds |
…ormat persistence
jesposito
pushed a commit
to jesposito/audplexus
that referenced
this pull request
May 10, 2026
…ert, faststart) Conflict resolution: internal/audio/decrypt.go Combine -movflags: +faststart is always-on (upstream PR mstrhakr#4 — moov-to-front for downstream ffmpeg memory). use_metadata_tags is additive when AudiobookRich profile is active. Single -movflags arg with both values. internal/library/download.go Drop my inline cover+meta block in DecryptAndOrganize; use upstream's metadataWithOptionalCover helper. Move profile resolution (audio.ResolveTagProfile) INTO the helper so all 4 callers (download.DecryptAndOrganize, convert m4b<->mp3, pipeline chapter-split) pick up the AudiobookRich tag profile uniformly. internal/library/{pipeline_stages,convert}.go Upstream's new chapter-split + m4b<->mp3 convert paths still called the removed Backend interface (TriggerScanForBook, EnsureBookInSeriesCollection). Migrate them to the new fan-out: - Extract dm.fanOutPostOrganize() helper from the standard path. - Standard, chapter-split, convert m4b->mp3, convert mp3->m4b all now route through it. Multi-destination fan-out + legacy fallback apply uniformly to every "book is on disk" code path. internal/audio/tag_test.go Update assertions: AudiobookRich now expects both +faststart and use_metadata_tags. Basic gets +faststart only (no historical drift — Basic was no-flags before, but +faststart is now upstream-mandatory). go test ./...: green.
jesposito
pushed a commit
to jesposito/audplexus
that referenced
this pull request
May 10, 2026
7 findings, all addressed.
P1 — firstboot path semantics inversion:
internal/library/firstboot_destinations.go
plex_section_path is the SERVER-SIDE library path used by
PlexBackend.resolveScanPath as the translation TARGET. Synthesizing it
into LibraryDestination.AudiobookPath (the audplexus-side SOURCE)
inverted the semantics — fresh upgrades from v0.2.x would have shown
the server-side path in the "Audplexus-side audiobook path" Settings
field. Same inversion for emby_library_path. Both now persist to
DestinationPath; AudiobookPath stays empty so the global libraryDir
is the source. Tests updated to assert correct field placement.
P2 — server-side validation on create:
internal/web/destinations_handlers.go
HTML `required` is the only gate on plex_token / api_key on the
create path. A bypassing client posting empty strings would land NULL
via nullableStr → DB CHECK violation → 500. Now: explicit
empty-string check on create returns a user-facing 400 with reason.
Edit path unchanged (caller carries saved secret over).
P2 — plex resolveScanPath short-circuit:
internal/mediaserver/plex.go
resolveScanPath was called before LocalPath empty check. With empty
path, filepath.Dir("") returns "." and triggers an unnecessary Plex
section-path GET + cache write, only to fail. Re-ordered: empty
LocalPath fails immediately with no side effects.
P3 — go.mod indirect marker:
go.mod
golang.org/x/sync is a direct import (DestinationManager uses
semaphore). go mod tidy moved it to the direct requirements block.
P3 — DB CHECK constraint hardening:
internal/database/migrations{,_postgres}/005_library_destinations.up.sql
Original IS NOT NULL passes empty strings via direct SQL.
length(trim(coalesce(col,''))) > 0 rejects both NULL and empty.
The coalesce is load-bearing: SQL CHECK passes on NULL, and
length(trim(NULL)) is NULL — so without coalesce, NULL columns
would silently insert. New test
TestCreateRejectsWhitespaceOnlyValues guards against future drift
(e.g. someone replacing nullableStr with passthrough).
go test ./... green. PR mstrhakr#4 merge resolution and these fixes will
ship as a single push to origin/feat/multi-destination → PR mstrhakr#6
auto-updates.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The "MP3 (Chapter Split)" option in Settings was a no-op. The setting was persisted to the DB and stored on
DownloadManagerbut never read by the processing pipeline, so every book was written as a single.m4bregardless of the user's choice. This PR finishes the wiring using theSplitChaptersffmpeg helper that already existed ininternal/audio, and adds a convert action on the book detail page so existing libraries can be migrated either direction without a re-download.Changes
Fix: MP3 chapter-split output format now works (
3662d05)DownloadManager: addSetOutputFormat/OutputFormat(mutex-guarded) following the existingSetEmbedCoverpattern, so the setting applies at runtime.handleProcessStage: when format ismp3and audnexus chapter data is available, runSplitChaptersinto a staging dir underdownloadDirand hand it to a newPlexOrganizer.OrganizeMultiFilewhich moves per-chapter files into the canonical Plex book folder. Falls back to single-filem4b(with a warning log) when chapter data is missing.output_formatviaSetOutputFormatinstead of marking the changerestartRequired.PlexOrganizer.OrganizeMultiFile: new entry point for multi-file output. ReusesbuildFilenameBase/buildBookDirectoryName/sanitizePath, writes the same.plexmatchand.chapters.txtcompanion files when those settings are on, and records the book directory asFilePathwith cumulativeFileSize.Feature: per-book convert action (
561d0a3)POST /api/books/:id/convert?format={m4b|mp3}endpoint.DownloadManager.ConvertBookauto-detects current layout (file vs directory + extension), refreshes metadata via audnexus for chapter marks, and either splits the m4b into chapter mp3s or concatenates the chapter mp3s back into a single m4b. Staging goes throughdownloadDirso a failure leaves the existing layout untouched. Triggers a Plex scan on completion and updates the book record.audio.FFmpeg.ConcatToM4B: new helper using the ffmpeg concat demuxer + AAC re-encode.hasSuffixtemplate func and a confirm-prompted button onbook_detail_panel.html(visible only for completed books). Label flips based on current on-disk format.Notes / decisions
output_formatis now applied without a restart, matching howembed_cover/chapter_filealready worked. This felt more consistent with the existing toggles.FilePath: for the chapter-split layout the book'sFilePathis set to the directory rather than a single file, andFileSizeto the sum of chapter sizes. The existing redownload handler already tolerates this (it usesfilepath.Dirand removes the parent if empty), but reviewers may want to check other consumers I didn't audit.audnexus.EnrichMetadataon every convert so chapter marks / canonical title are fresh, falling back to the DB row on failure. This matches the pattern used elsewhere in the pipeline.