Conversation
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>
…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>
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.
Manual import: admit an audio-only
.mp4Closes the behaviour half of #890, and is the content-probe option from #995.
The problem
An audiobook delivered in a bare
.mp4container cannot be imported..mp4is not blacklisted, and the bytes are frequently the same MP4/AAC an.m4bcarries, but every import and scan gate isPath.GetExtensionagainstFileUtils.AudioExtensionsand 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.m4bimports and registers, the.mp4is refused with "The file could not be published and registered safely." The bytes were never the problem.Why not add
.mp4toAudioExtensionsBecause
.mp4carries 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:
AmbiguousAudioExtensionsholds.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.MayBeAudioPendingProbeisIsAudioFilewidened by exactly the ambiguous tier, used only by the pre-filters that stand in front of the one gate.IsAudioFileis untouched,.mp4is still absent fromAudioExtensions, andScanFileDiscoverystill filters onIsAudioFile. The scanner remains extension-only and never probes.Cover art is the part that matters
ffprobe reports embedded artwork as a
codec_type=videostream. If that counted as video, this change would refuse most real audiobooks. Attached-picture streams (disposition.attached_pic = 1) are therefore excluded fromHasVideoStream, 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
.mp4before 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:
DownloadImportServicefilters onIsAudioFilebefore 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
a630572e9the 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
canaryas the before picture:canary.mp4.mp4with cover art only.mp4with a playable video stream.mp4with no audio stream.mp4.m4b.mkvEach 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
.mp4in a completed download is still refused, and that is for Fix weak-storage recovery and root confirmation #993's path to decide. (Read, fromDownloadImportService.cs:133.).mp4, but a genuinely non-audio file still leaves one. That deserves its own issue. (Measured, with the stock run as the control.)Disclosure: drafted with Claude Code at my direction; I read the cited code at the stated commit and reviewed this before posting.