Skip to content

fix: name media-server downloads from the server's Content-Disposition - #133

Merged
GianniCarlo merged 4 commits into
developfrom
fix/download-content-disposition
Sep 30, 2026
Merged

GianniCarlo merged 4 commits into
developfrom
fix/download-content-disposition

Conversation

@GianniCarlo

@GianniCarlo GianniCarlo commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

What was wrong

Media-server downloads (the Download button on an item's details and the multi-select Download) were saved under a name fixed before the request: originalFileName ?: "<title>.mp3".

  • Audiobookshelf list items are minified (no audioFiles), so originalFileName is always null and every ABS download got the .mp3 guess.
  • ABS answers api/items/{id}/download for a book in its own folder — the usual layout — with a zip (Content-Disposition: attachment; filename="<title>.zip").
  • Archive expansion (ImportArchiveUtils.isArchive) goes by the saved name, so the zip was never unpacked: the import produced a zip named .mp3, and playing it failed with "It was not possible to play the item from file system" (UnrecognizedInputFormatException, file starts with PK).

Jellyfin escaped it because its list items carry the real file name (from Path).

Change

The saved name now comes from the response's Content-Disposition when it names a file with an extension, else the caller's name — what iOS does through URLResponse.suggestedFilename:

  • DownloadFileName (:core, pure): RFC 5987 filename* first, then filename (quoted or bare); only the last path segment is kept, then FilenameUtils.sanitizeFilename.
  • Zip safety net: a download that starts with a zip signature under a non-archive name is renamed to .zip, so expansion still runs if a proxy drops the header.
  • A file-only re-download (an existing item whose file is missing) keeps the existing item's name, which the accept step matches it by.
  • Review round 1: the pre-request checks ran on the requested name only. When the server's name differs, the library checks rerun on it before the body is copied (already in the library → skipped; audio missing → restored file-only), archives still being checked per extracted entry in expandArchives. A server-chosen name gets its own staging file (atomic createNewFile over uniqueDestination), so two downloads can't share one and a file waiting in the import sheet is never truncated; the zip rename reserves its target the same way. Names with control characters are rejected.
  • Review rounds 2–3: a file-only restore found through the server's name keeps the item's exact name. It skips only when that name is taken by something current — a file staged in the import sheet, or a name another download in flight has claimed (checked and claimed on Main, released in finally); a file merely left over in BPBackup is replaced, as on the requested-name path.

Plain "Download from URL" imports go through the same path, so a URL like …/download?id=… now also gets the server's name.

Verification

Emulator (bp-api36, devDebug, app data cleared), throwaway servers:

Download Before After
AudiobookShelf 2.37, book in its own folder (Book Delta) Book Delta.mp3 = a zip, unplayable Book Delta.zip → unpacked → Book Delta.m4b, BOOK, duration 420 s, linked to its ABS item, plays
Jellyfin 12.1 (Jellyfin Twelve) Jellyfin Twelve.m4b unchanged

Tests. ./gradlew assembleDevDebug testDevDebugUnitTest :core:testDebugUnitTest lintDevDebug green (759 tests: 224 app, 68 wear, 467 core). DownloadFileNameTest pins the real ABS and Jellyfin headers, filename* decoding (non-ASCII), bare and escaped forms, path stripping, fallbacks (no header, no extension), sanitizing, and the zip safety net. Ignoring the header or disabling the zip check fails exactly the name-choice and zip tests. ImportManagerDownloadTest (Robolectric + MockWebServer) covers a server-named duplicate being skipped, an item without audio being restored, a staged file never being overwritten (for a fresh name or a restore), a leftover in BPBackup never blocking a restore, and an ABS zip being unpacked with or without Content-Disposition; removing the round-1 checks fails exactly those tests.

Found along the way (not in this PR)

  • AudiobookShelf streaming is broken for books in their own folder, on Android and iOS. Both stream from the same api/items/{id}/download URL (Android ExternalServiceUtils.downloadUrlFor, iOS buildAudiobookshelfDownloadUrl), which returns the zip. Reproduced on Android ("It was not possible to play the item from external resource"). The per-file endpoint api/items/{id}/file/{ino} serves the raw audio with range support (206); multi-file books need design.

Downloads were saved under a name fixed before the request: `originalFileName ?: "<title>.mp3"`.
Audiobookshelf list items are minified and carry no file name, so every ABS download got the
".mp3" guess, and ABS answers `api/items/{id}/download` for a book in its own folder with a zip.
Archive expansion goes by the saved name, so the zip was never unpacked and the import produced an
unplayable file ("It was not possible to play the item from file system").

The saved name now comes from the response's Content-Disposition (RFC 5987 `filename*` first, then
`filename`, last path segment only, sanitized) when it carries an extension, else the caller's name
— what iOS does through URLResponse.suggestedFilename. ABS books now arrive as "<title>.zip" and are
unpacked; Jellyfin's "<file>.m4b" is unchanged. A zip signature under a non-archive name (a proxy
that drops the header) is renamed to ".zip" so expansion still runs. A file-only re-download keeps
the existing item's name, which the accept step matches it by.
Comment thread app/src/main/java/com/tortugapower/audiobookplayer/logic/ImportManager.kt Outdated
@github-actions

github-actions Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

✅ Claude PR Review — PASS

The PR names URL and media-server downloads from the server's Content-Disposition header, using a new pure :core helper DownloadFileName. It also adds a safety net that renames a download starting with a zip signature to .zip, and reruns the library duplicate and restore checks on the server-chosen name before copying the body. A server-chosen name gets its own atomically created staging file. A restore found through the server's name keeps the item's exact name and is skipped only when that name is waiting in the import sheet or claimed by another download in flight. That addresses the earlier finding about leftover BPBackup files blocking restores, and a Robolectric test now covers it. The header parsing can't escape the staging folder: only the last path segment is kept, control characters are rejected and the name is sanitized. Unit tests and Robolectric MockWebServer tests cover the new behaviour. Two pre-existing behaviours are unchanged by this PR: a second restore of the same item under its requested name still overwrites a copy waiting in the import sheet, and the error path deletes the requested-name file even when the exception happens before this download wrote anything.

Findings: no findings

Previously raised

Finding Status
app/src/main/java/com/tortugapower/audiobookplayer/logic/ImportManager.kt:272 (warn) ⚠︎ moved ✅ verified fixed in 9dd20f6

Converged: nothing new this round, and every earlier finding is settled.

Model claude-opus-5-5 · run log · 0 new · 0 carried over · 1 verified closed · 0 resolved · advisory (a human should still review). Findings are de-duplicated across pushes; an earlier finding closes only when the verification pass judges it against the current code — fixed, no longer applicable, accepted by a maintainer, or a duplicate of a finding reported on this push.

Only the requested name was claimed and checked before the request, but the file is now written
under the name the server chose. When that name differs:
- the library checks rerun on it before the body is copied: an item that already has its audio is
  skipped, one whose audio is missing is restored (file-only) instead of duplicated. Archives are
  still checked per extracted entry;
- it gets a staging file of its own (an atomic createNewFile over uniqueDestination), so two downloads
  can't share one and a file already waiting in the import sheet is never truncated; the zip rename
  reserves its target the same way.
Content-Disposition names with control characters (a decoded %00 or %0A) are rejected, falling back
to the requested name. ImportManagerDownloadTest covers the skip, restore, staged-file, zip and
header-less zip paths against a MockWebServer.
A file-only restore found through the server's name must keep the item's exact name, so it couldn't
take a reserved "-1" name and was written unreserved. It is now created atomically under that name;
if the file already exists, a copy of the same file is already staged or in flight, and the download
is skipped as a duplicate instead of truncating it.
Comment thread app/src/main/java/com/tortugapower/audiobookplayer/logic/ImportManager.kt Outdated
Round 2 skipped a file-only restore whenever BPBackup already held a file with the item's name, but
BPBackup routinely keeps leftovers (the storage screen lists them as orphans), so one leftover could
block that restore for good while the requested-name path simply replaced it. The restore now skips
only when the name is taken by something current — a file staged in the import sheet, or a name
another download in flight has claimed — checked and claimed on Main, released in finally. A mere
leftover is replaced, as on the requested-name path.
@GianniCarlo
GianniCarlo merged commit e1b4654 into develop Sep 30, 2026
3 checks passed

This branch was successfully deployed

1 active deployment
reviewer — 9dd20f6f Deployed Sep 30, 2026 by GianniCarlo via review #452
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.

1 participant