Skip to content

Manual import: admit an audio-only .mp4 via a content probe - #999

Open
m4bard wants to merge 5 commits into
Listenarrs:canaryfrom
m4bard:fix/mp4-audio-import
Open

m4bard wants to merge 5 commits into
Listenarrs:canaryfrom
m4bard:fix/mp4-audio-import

Conversation

@m4bard

@m4bard m4bard commented Sep 18, 2026

Copy link
Copy Markdown

Manual import: admit an audio-only .mp4

Closes the behaviour half of #890, and is the content-probe option from #995.

The problem

An audiobook delivered in a bare .mp4 container cannot be imported. .mp4 is not blacklisted, and the bytes are frequently the same MP4/AAC an .m4b carries, but every import and scan gate is Path.GetExtension against FileUtils.AudioExtensions and nothing looks past it.

Measured on stock canary, a one-second AAC file and a byte-for-byte copy of it renamed to .m4b (proven identical by sha256): the .m4b imports and registers, the .mp4 is refused with "The file could not be published and registered safely." The bytes were never the problem.

Why not add .mp4 to AudioExtensions

Because .mp4 carries audiobooks and films with equal right, and the library scanner walks every file in every root folder. Trusting the extension there would pull stray video into the library; probing there would cost an ffprobe per file per scan. Neither is acceptable, which is why #995 framed this as a design question rather than a one-line patch.

So the extension set is unchanged and a second tier decides by content:

  • AmbiguousAudioExtensions holds .mp4: the extensions whose name does not settle the question.
  • IsProbedAudioContent(metadata) decides from the content, requiring an audio stream present and no playable video stream.
  • MayBeAudioPendingProbe is IsAudioFile widened by exactly the ambiguous tier, used only by the pre-filters that stand in front of the one gate.

IsAudioFile is untouched, .mp4 is still absent from AudioExtensions, and ScanFileDiscovery still filters on IsAudioFile. The scanner remains extension-only and never probes.

Cover art is the part that matters

ffprobe reports embedded artwork as a codec_type=video stream. If that counted as video, this change would refuse most real audiobooks. Attached-picture streams (disposition.attached_pic = 1) are therefore excluded from HasVideoStream, and the tests pin it with a pair whose ffprobe JSON is identical except for that one field and whose verdicts are opposite.

Where the gate sits

The probe runs in two places and both reach the same decision.

Manual import probes a selected .mp4 before it plans anything, so a film is refused with a reason the user can act on, the source stays where the user put it, and nothing is written into the library folder. That is one probe per user-selected item, not per file in a scan walk.

Registration holds the decision that actually admits the file, immediately after the metadata extraction it already performs for every file it records, so the probe costs nothing extra. Enforcing it only in the controller would leave the next caller to rediscover the rule.

Automatic import is unaffected: DownloadImportService filters on IsAudioFile before registration, so nothing in that path changes. That is deliberate while #993 is open; none of its files are touched here.

Validation

Twenty-four tests, each paired with a control that comes out differently. Six fail without the production change. Suite 3142 passed / 130 skipped / 0 failed, against 3118 / 130 / 0 before. Architecture tests unchanged and green.

Correcting two numbers I had wrong when I opened this. The test count read thirteen; the diff adds twenty-four. The baseline read 3129, which is this branch after an intermediate commit rather than the base it is measured against; on a630572e9 the suite is 3118 passed / 0 failed / 130 skipped.

Measured end to end against a container built from this branch, with the same fixtures run against stock canary as the before picture:

fixture stock canary this branch
audio-only .mp4 refused, stray file left behind imported and registered
.mp4 with cover art only refused, stray file left behind imported and registered
.mp4 with a playable video stream refused, stray file left behind, generic message refused cleanly, "carries video"
.mp4 with no audio stream refused, stray file left behind, generic message refused cleanly, "no audio stream"
empty .mp4 refused, stray file left behind refused cleanly
byte-identical file named .m4b imported imported
audio-only .mkv refused, stray file left behind refused, stray file left behind

Each case was judged on the API reply, the filesystem at source and destination, and the catalog file-row count, because a file sitting at the destination with no catalog row has not been imported.

A library scan after the import does not un-register the file: rows 1 to 1. The scan walk does not discover it, but reconciliation decides from a filesystem existence check rather than from discovery membership, so a tracked file the walk skipped is preserved.

What this does not claim

  • It does not change automatic import. A .mp4 in a completed download is still refused, and that is for Fix weak-storage recovery and root confirmation #993's path to decide. (Read, from DownloadImportService.cs:133.)
  • The stray file left at a prepared destination when registration refuses is pre-existing, reproduced on stock for every variant above. The gate moving earlier removes it for .mp4, but a genuinely non-audio file still leaves one. That deserves its own issue. (Measured, with the stock run as the control.)
  • Every behavioural claim above is measured on a running instance except the two marked Read.

Disclosure: drafted with Claude Code at my direction; I read the cited code at the stated commit and reviewed this before posting.

m4bard and others added 4 commits September 17, 2026 17:24
A download batch whose only file carries an extension outside
FileUtils.AudioExtensions plans zero audio imports, registers nothing, and
the processor fails the job without spending a retry, which surfaces as
Import Blocked on the first processing cycle. .mp4 is the extension the
reporters hit; it is not blacklisted and the bytes may be the same MP4/AAC a
.m4b would carry, but no gate looks past Path.GetExtension.

Four tests. One characterization test pinning the fourteen accepted
extensions, the absence of .mp4 and the case insensitivity of the lookup. One
end to end through ProcessQueueAsync asserting the exact job error and the
exact download block reason. One control, the same batch against an audiobook
that already holds an AudiobookFile row, which completes and moves rather
than blocking, because the failure is conditional on that count being zero.
One asserting the retry budget is untouched, which is what separates this
path from the retrying gates.

Tests only. Nothing here changes behaviour.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Listenarr#890: a release whose only payload is a .mp4 registers zero audio
files and lands in Import Blocked, because .mp4 is absent from
FileUtils.AudioExtensions and every gate is Path.GetExtension against that set.

Adding .mp4 to the set is the wrong fix. The set also gates the library
scanner (ScanFileDiscovery walks every file via IsAudioFile) and other
extension-only consumers, none of which can afford a content probe per file, so
a bare .mp4 in a library folder would scan as an audiobook. Unlike .m4a and
.m4b, which are always audio, a .mp4 can carry audio, video, or both. The *arr
family confirms the boundary: Readarr and Lidarr keep bare .mp4 out of their
audio set and never content-probe to decide candidacy.

So .mp4 goes into a separate tier, AmbiguousAudioExtensions, that the extension
set does not include. IsAudioFile and the scanner are unchanged. Admission of
an ambiguous container is decided by a content probe:

- AudioMetadata gains HasAudioStream / HasVideoStream.
- FfprobeMetadataMapper sets them from ffprobe -show_streams. A video stream
  flagged disposition.attached_pic = 1 is cover art, not playable video, so it
  does not count; otherwise most real audiobooks would be misread as video.
- FileUtils.IsProbedAudioContent(metadata) admits when audio is present and no
  playable video is. This is the gate the import path calls for a .mp4 candidate.
- RunFfprobeAsync no longer refuses an ambiguous container outright, so a .mp4
  can be probed at all.

The import path that consults this gate (DownloadImportService) is being
rewritten in PR Listenarrs#993, so this commit lands only the mechanism and its tests,
outside Listenarrs#993, ready for that rewrite to call. The existing Listenarrs#890 regression
tests are unchanged and still pass: IsAudioFile(".mp4") is still false, so an
empty .mp4 fixture still blocks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The ambiguous-container tier added in 1456761 had no production caller, so
nothing in the running application behaved differently. This gives it one.

Manual import now probes a selected .mp4 before it plans anything, and refuses
a container that carries playable video or no audio at all. The refusal happens
before the first filesystem mutation, so a film selected by mistake leaves the
source where the user put it and writes nothing into the library folder, and
the message says which of the two reasons applied instead of the generic
"could not be published and registered safely".

Registration holds the decision that actually admits the file. The extension
check in AudiobookFileService is now a pre-filter (MayBeAudioPendingProbe,
which is IsAudioFile widened by exactly the ambiguous tier), and the content
gate sits immediately after the metadata extraction that registration already
performs for every file it records, so the probe costs nothing extra. A gate
enforced only in one controller is a gate the next caller forgets.

The same pre-filter relaxation is applied to the claim authorization path and
to the in-place registration entry point, because both sit between manual
import and the content gate and both would otherwise drop the file on its
extension before the probe ran.

The scanner is untouched and still extension-only. IsAudioFile is unchanged,
.mp4 is still absent from AudioExtensions, and ScanFileDiscovery still filters
on IsAudioFile, so the scan walk never probes and never offers an ambiguous
container. That was the whole reason for a second tier rather than a fourteenth
extension. Reconciliation was read and does not un-register an imported .mp4:
it decides from PinnedFileExists rather than from discovery membership, and a
tracked file the walk skipped has no discovered identity, so it falls through
both removal branches.

Thirteen tests, each paired with a control that has to come out differently.
Six of them fail without this change. The pair that carries the design is the
cover-art case, run through the real ffprobe mapper rather than a hand-built
AudioMetadata: identical JSON, disposition.attached_pic 1 imports and 0 is
refused. If artwork counted as video, most real audiobooks would be refused.
The regression control is an .m4b registering on the exact probe result that
refuses an .mp4, which pins that an accepted extension never reaches the gate.
Suite: 3142 passed, 130 skipped, 0 failed, against 3129/130/0 before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The three Listenarrs#890 characterization tests were appended to
DownloadProcessingJobProcessorTests.cs, which is in upstream PR 993's diff. Our
own rule is not to patch into a file that is being actively rewritten, so this
branch was quietly breaking it.

Moved verbatim into DownloadProcessingJobProcessorAmbiguousExtensionTests.cs,
replicating only the gateway mock and the InitializeAsync setup the cases need.
The original file is now byte-identical to canary on this branch, so the branch
no longer touches anything PR 993 owns.

No test content changed. Full suite 3142 passed, 0 failed, 130 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@m4bard
m4bard requested a review from a team September 18, 2026 15:49
…ts beside Listenarrs#901

No behaviour change. The gate and its reasoning move from EnsureAudiobookFileCoreAsync
into QualifiesAsAudioContent in a new AudiobookFileService.ContentGate.cs, following the
six partials this class already uses. The call site is four lines.

Why it matters beyond tidiness. BackendArchitectureTests.ActiveProductionSourceFiles_RemainFocused
fails any production file over 500 lines. AudiobookFileService.cs is 469 on canary, so there
are 31 lines of headroom and two open PRs are spending them:

    canary                   469
    Listenarrs#901 alone               494   passes
    this branch before       487   passes
    both together            512   FAILS

Measured by merging this branch onto Listenarrs#901 head 58adddd: 3141 passed, 130 skipped, and that
one architecture test failed. Each PR passes the guard alone and the pair does not, so whoever
merged second would have hit a failure they could not see from their own branch.

This branch's footprint in the shared file drops from 18 lines to 5, which puts the combination
at 499. Suite unchanged at 3142 passed / 0 failed / 130 skipped, and the application project
rebuilds at zero warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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