Skip to content

Implement MP3 (chapter-split) output format and add per-book convert action#4

Merged
mstrhakr merged 13 commits into
mstrhakr:masterfrom
Wutname1:master
May 10, 2026
Merged

Implement MP3 (chapter-split) output format and add per-book convert action#4
mstrhakr merged 13 commits into
mstrhakr:masterfrom
Wutname1:master

Conversation

@Wutname1

@Wutname1 Wutname1 commented May 9, 2026

Copy link
Copy Markdown
Contributor

The "MP3 (Chapter Split)" option in Settings was a no-op. The setting was persisted to the DB and stored on DownloadManager but never read by the processing pipeline, so every book was written as a single .m4b regardless of the user's choice. This PR finishes the wiring using the SplitChapters ffmpeg helper that already existed in internal/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: add SetOutputFormat / OutputFormat (mutex-guarded) following the existing SetEmbedCover pattern, so the setting applies at runtime.
  • handleProcessStage: when format is mp3 and audnexus chapter data is available, run SplitChapters into a staging dir under downloadDir and hand it to a new PlexOrganizer.OrganizeMultiFile which moves per-chapter files into the canonical Plex book folder. Falls back to single-file m4b (with a warning log) when chapter data is missing.
  • Settings handler: apply output_format via SetOutputFormat instead of marking the change restartRequired.
  • PlexOrganizer.OrganizeMultiFile: new entry point for multi-file output. Reuses buildFilenameBase / buildBookDirectoryName / sanitizePath, writes the same .plexmatch and .chapters.txt companion files when those settings are on, and records the book directory as FilePath with cumulative FileSize.

Feature: per-book convert action (561d0a3)

  • POST /api/books/:id/convert?format={m4b|mp3} endpoint.
  • DownloadManager.ConvertBook auto-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 through downloadDir so 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.
  • hasSuffix template func and a confirm-prompted button on book_detail_panel.html (visible only for completed books). Label flips based on current on-disk format.

Notes / decisions

  • Runtime vs. restart-required: output_format is now applied without a restart, matching how embed_cover / chapter_file already worked. This felt more consistent with the existing toggles.
  • Multi-file FilePath: for the chapter-split layout the book's FilePath is set to the directory rather than a single file, and FileSize to the sum of chapter sizes. The existing redownload handler already tolerates this (it uses filepath.Dir and removes the parent if empty), but reviewers may want to check other consumers I didn't audit.
  • Convert metadata refresh: I call audnexus.EnrichMetadata on 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.

Wutname1 added 3 commits May 9, 2026 15:31
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.

This comment was marked as outdated.

@mstrhakr mstrhakr self-assigned this May 9, 2026
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.
@Wutname1

Wutname1 commented May 9, 2026

Copy link
Copy Markdown
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.
@Wutname1

Copy link
Copy Markdown
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 -movflags +faststart to decrypt so downstream reads are constant-memory regardless of book length. Also bundles a Cancel button + stall watchdog for stuck pipeline items, a distinct "transcoding" stage badge with per-chapter progress, and tighter ordering so an interrupted convert leaves recoverable state on disk.

@mstrhakr
mstrhakr merged commit 5c1de6b into mstrhakr:master May 10, 2026
4 checks passed
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants