From c3bc76e2d568ea1fdac637045b6aa2593a66c647 Mon Sep 17 00:00:00 2001 From: m4bard Date: Fri, 14 Aug 2026 15:17:31 -0500 Subject: [PATCH 1/3] fix(scan): stop the Linux descriptor path leaking into metadata and size The registration lease deliberately separates stable byte access (ReadPath) from public media identity (PublicPath). On Linux the lease's metadata path is a /proc/{pid}/fd/{fd} descriptor link, and two consumers treat it as if it were the file. The scan's embedded-metadata pass called the single-path overload of ExtractFileMetadataAsync, which builds MetadataFileSource(path, path). The probe guard tests the public half for an audio extension, a descriptor link has none, and so the candidate was rejected before ffprobe ran. That pass is the fallback for candidates path attribution could not claim, so on Linux a correctly tagged file in an unrecognised folder shape could never be claimed by any route. The registered length was stat'ed from the same descriptor path. Stat on the link reports the length of the link rather than of its target, a constant 64 bytes, so every registered file on Linux recorded Size = 64. Reading the length from the pinned handle keeps the lease's generation guarantee, since it never consults the visible path. Refs #818 --- ...AudiobookFileService.MetadataExtraction.cs | 38 +++++++++++++++++++ ...AudiobookFileService.PhysicalGeneration.cs | 5 ++- .../Audiobooks/Files/AudiobookFileService.cs | 5 ++- .../Scanning/AudiobookScanService.Metadata.cs | 9 ++++- 4 files changed, 52 insertions(+), 5 deletions(-) diff --git a/listenarr.application/Audiobooks/Files/AudiobookFileService.MetadataExtraction.cs b/listenarr.application/Audiobooks/Files/AudiobookFileService.MetadataExtraction.cs index aafb6dbcb..a561b0643 100644 --- a/listenarr.application/Audiobooks/Files/AudiobookFileService.MetadataExtraction.cs +++ b/listenarr.application/Audiobooks/Files/AudiobookFileService.MetadataExtraction.cs @@ -5,6 +5,44 @@ namespace Listenarr.Application.Audiobooks.Files; public partial class AudiobookFileService { + /// + /// The byte length of the file a registration lease holds open. + /// + /// + /// A lease's metadata path is not always a path to the file. On Linux it is a + /// /proc/{pid}/fd/{fd} descriptor link, and stat'ing the link reports the length of + /// the link itself rather than of its target, which is a constant 64 bytes. Reading the + /// length from the pinned handle keeps the generation guarantee the lease exists to + /// provide, since it never consults the visible path, and reports the bytes the file + /// actually has. + /// + /// Leases that do not expose generation-bound reads fall back to the metadata path, which + /// is the public path for those callers. + /// + private static long? ResolveRegisteredLength( + IAudiobookFileRegistrationLease? registrationLease, + string metadataPath) + { + if (registrationLease != null) + { + try + { + using var stream = registrationLease.OpenMetadataReadStream(); + if (stream.CanSeek) + { + return stream.Length; + } + } + catch (NotSupportedException) + { + // The lease does not expose generation-bound reads; fall through to the path. + } + } + + var fileInfo = new FileInfo(metadataPath); + return fileInfo.Exists ? fileInfo.Length : null; + } + private async Task ExtractMetadataAsync( string metadataPath, string cacheIdentity, diff --git a/listenarr.application/Audiobooks/Files/AudiobookFileService.PhysicalGeneration.cs b/listenarr.application/Audiobooks/Files/AudiobookFileService.PhysicalGeneration.cs index 3dad98b99..fa333daf0 100644 --- a/listenarr.application/Audiobooks/Files/AudiobookFileService.PhysicalGeneration.cs +++ b/listenarr.application/Audiobooks/Files/AudiobookFileService.PhysicalGeneration.cs @@ -274,10 +274,11 @@ private static AudiobookFile CreatePhysicalGenerationSnapshot( string? source, bool replaceMetadata) { - var fileInfo = new FileInfo(registrationLease.MetadataPath); var replacement = AudiobookFile.CreateUnresolved(currentFile.Path); replacement.AudiobookId = currentFile.AudiobookId; - replacement.Size = fileInfo.Exists ? fileInfo.Length : currentFile.Size; + replacement.Size = ResolveRegisteredLength( + registrationLease, + registrationLease.MetadataPath) ?? currentFile.Size; replacement.DurationSeconds = replaceMetadata ? metadata?.Duration.TotalSeconds : Math.Abs(metadata?.Duration.TotalSeconds ?? 0) > double.Epsilon diff --git a/listenarr.application/Audiobooks/Files/AudiobookFileService.cs b/listenarr.application/Audiobooks/Files/AudiobookFileService.cs index 823fe2f2c..79f2126d0 100644 --- a/listenarr.application/Audiobooks/Files/AudiobookFileService.cs +++ b/listenarr.application/Audiobooks/Files/AudiobookFileService.cs @@ -327,10 +327,11 @@ private async Task EnsureAudiobookFileCoreAsync( cacheIdentity, filePath); - var fi = new FileInfo(metadataPath); var fileRecord = AudiobookFile.CreateUnresolved(filePath); fileRecord.AudiobookId = audiobook.Id; - fileRecord.Size = fi.Exists ? fi.Length : null; + fileRecord.Size = ResolveRegisteredLength( + registrationLease, + metadataPath); fileRecord.Source = source; fileRecord.CreatedAt = DateTime.UtcNow; fileRecord.DurationSeconds = meta?.Duration.TotalSeconds; diff --git a/listenarr.infrastructure/Library/Scanning/AudiobookScanService.Metadata.cs b/listenarr.infrastructure/Library/Scanning/AudiobookScanService.Metadata.cs index b581f87a4..0b069d74b 100644 --- a/listenarr.infrastructure/Library/Scanning/AudiobookScanService.Metadata.cs +++ b/listenarr.infrastructure/Library/Scanning/AudiobookScanService.Metadata.cs @@ -65,8 +65,15 @@ private async Task EnrichWithMetadataAsync( pinnedAuthority, discovery, candidate); + // The lease deliberately separates stable byte access from public media + // identity, and the single-path overload collapses the two. On Linux the + // metadata path is a /proc descriptor link with no extension, so collapsing + // it makes the probe's audio-extension guard reject the candidate before + // ffprobe runs. var metadata = await metadataService.ExtractFileMetadataAsync( - pinnedMetadataFile.MetadataPath); + new MetadataFileSource( + pinnedMetadataFile.MetadataPath, + candidate)); if (metadata != null && ScanFileDiscovery.MetadataMatchesAudiobook(metadata, audiobook)) { From ae14f3f2b88f9e0f14cba9e8e1e47ead01cf84d5 Mon Sep 17 00:00:00 2001 From: m4bard <304653687+m4bard@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:52:25 -0500 Subject: [PATCH 2/3] tests: follow the scan's metadata read onto the two-part file source Both of these mocked only the single-path overload of ExtractFileMetadataAsync, so once the scan routes through MetadataFileSource the strict mock saw no matching setup, the extractor returned nothing, and every file read as unreadable. That turned a passing suite into two failures that looked like behaviour regressions and were not. ScanAsync_CaseDistinctMetadataFolders_RemainConflicting just needed the overload. ScanAsync_MetadataReplacementAndRestore_ReadsPinnedFileGeneration needed the overload plus a decision about which half of the source its callback reads. It reads ReadPath, because the point of the test is that the scan sees the original generation even while the visible file is swapped underneath it. It now also asserts the other half. PublicPath must still be the candidate as a person sees it, extension included, because on Linux ReadPath is a /proc descriptor link with no extension and anything deriving media identity from it loses the extension entirely. Confirmed load-bearing by collapsing both halves onto the descriptor path: the assertion fails with the real path expected and /proc//fd/ observed, which is the defect this branch exists to fix, previously only demonstrable against a running container. --- ...udiobookScanServiceMetadataBoundaryTests.cs | 4 ++-- .../Scanning/AudiobookScanServiceTests.cs | 18 +++++++++++++++--- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/tests/Features/Infrastructure/Library/Scanning/AudiobookScanServiceMetadataBoundaryTests.cs b/tests/Features/Infrastructure/Library/Scanning/AudiobookScanServiceMetadataBoundaryTests.cs index 747565f30..25635cc47 100644 --- a/tests/Features/Infrastructure/Library/Scanning/AudiobookScanServiceMetadataBoundaryTests.cs +++ b/tests/Features/Infrastructure/Library/Scanning/AudiobookScanServiceMetadataBoundaryTests.cs @@ -24,7 +24,7 @@ public async Task ScanAsync_CaseDistinctMetadataFolders_RemainConflicting() var lowerFile = Path.Join(lowerDirectory, "part-b.m4b"); var metadata = new Mock(MockBehavior.Strict); metadata.Setup(service => service.ExtractFileMetadataAsync( - It.IsAny())) + It.IsAny())) .ReturnsAsync(MatchingMetadata()); Init(services => services.WithSingleton(metadata.Object)); Directory.CreateDirectory(upperDirectory); @@ -65,7 +65,7 @@ await _applicationSettingsRepository.SaveAsync( Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Code == "MetadataAttributionConflict"); metadata.Verify( - service => service.ExtractFileMetadataAsync(It.IsAny()), + service => service.ExtractFileMetadataAsync(It.IsAny()), Times.Exactly(2)); } diff --git a/tests/Features/Infrastructure/Library/Scanning/AudiobookScanServiceTests.cs b/tests/Features/Infrastructure/Library/Scanning/AudiobookScanServiceTests.cs index cf25bc59a..44e8c3d1c 100644 --- a/tests/Features/Infrastructure/Library/Scanning/AudiobookScanServiceTests.cs +++ b/tests/Features/Infrastructure/Library/Scanning/AudiobookScanServiceTests.cs @@ -141,11 +141,14 @@ public async Task ScanAsync_MetadataReplacementAndRestore_ReadsPinnedFileGenerat "unrelated.m4b", "Original Book"); var displaced = Path.Join(root, "original-generation.bin"); + var observedSources = new List(); var metadata = new Mock(MockBehavior.Strict); metadata.Setup(service => service.ExtractFileMetadataAsync( - It.IsAny())) - .Returns(async extractionPath => + It.IsAny())) + .Returns(async fileSource => { + observedSources.Add(fileSource); + var extractionPath = fileSource.ReadPath; var displacedOriginal = false; try { @@ -190,8 +193,17 @@ public async Task ScanAsync_MetadataReplacementAndRestore_ReadsPinnedFileGenerat await _audiobookFileRepository.GetByAudiobookIdAsync(audiobook.Id)); Assert.Equal("Original Book", await File.ReadAllTextAsync(candidate)); metadata.Verify( - service => service.ExtractFileMetadataAsync(It.IsAny()), + service => service.ExtractFileMetadataAsync(It.IsAny()), Times.Once); + + // The two halves of the source do different jobs and both matter here. ReadPath is the + // pinned generation, which is what the assertions above prove was read even while the + // visible file was swapped. PublicPath is the file as a person sees it, and it has to + // keep its real name: on Linux ReadPath is a /proc descriptor link with no extension, + // so anything deriving identity from it loses the extension entirely. + var observed = Assert.Single(observedSources); + Assert.Equal(candidate, observed.PublicPath); + Assert.Equal(".m4b", Path.GetExtension(observed.PublicPath)); } [Fact] From 2af63b9cff0851bda7b91de065f89db827cdc324 Mon Sep 17 00:00:00 2001 From: m4bard <304653687+m4bard@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:53:05 -0500 Subject: [PATCH 3/3] fix(scan): narrow to the metadata boundary, leave the size to #901 This PR fixed two things behind the same /proc descriptor link: the probe's extension guard rejecting the candidate, and FileInfo reporting the link's own 64 bytes instead of the file's length. #901 fixes the size half, and fixes it better. It opens the metadata path and takes the stream length unconditionally. The version here read the length through the lease's generation-bound stream but fell back to FileInfo on the metadata path when the lease did not expose one, which on Linux is the original 64-byte bug again. That half also had no test of its own here; both test files in this branch cover the scan boundary. So the size change is dropped and the two shared files go back to canary. What remains is the half #901 does not touch: the scan passed the lease's metadata path as both the byte source and the media identity, and on Linux that path is an extensionless /proc link, so the audio-extension guard rejected the candidate before ffprobe ever ran. Passing the candidate as the identity alongside the metadata path as the byte source keeps the guard working on the real filename. The two PRs no longer touch a file in common. --- ...AudiobookFileService.MetadataExtraction.cs | 38 ------------------- ...AudiobookFileService.PhysicalGeneration.cs | 5 +-- .../Audiobooks/Files/AudiobookFileService.cs | 5 +-- 3 files changed, 4 insertions(+), 44 deletions(-) diff --git a/listenarr.application/Audiobooks/Files/AudiobookFileService.MetadataExtraction.cs b/listenarr.application/Audiobooks/Files/AudiobookFileService.MetadataExtraction.cs index a561b0643..aafb6dbcb 100644 --- a/listenarr.application/Audiobooks/Files/AudiobookFileService.MetadataExtraction.cs +++ b/listenarr.application/Audiobooks/Files/AudiobookFileService.MetadataExtraction.cs @@ -5,44 +5,6 @@ namespace Listenarr.Application.Audiobooks.Files; public partial class AudiobookFileService { - /// - /// The byte length of the file a registration lease holds open. - /// - /// - /// A lease's metadata path is not always a path to the file. On Linux it is a - /// /proc/{pid}/fd/{fd} descriptor link, and stat'ing the link reports the length of - /// the link itself rather than of its target, which is a constant 64 bytes. Reading the - /// length from the pinned handle keeps the generation guarantee the lease exists to - /// provide, since it never consults the visible path, and reports the bytes the file - /// actually has. - /// - /// Leases that do not expose generation-bound reads fall back to the metadata path, which - /// is the public path for those callers. - /// - private static long? ResolveRegisteredLength( - IAudiobookFileRegistrationLease? registrationLease, - string metadataPath) - { - if (registrationLease != null) - { - try - { - using var stream = registrationLease.OpenMetadataReadStream(); - if (stream.CanSeek) - { - return stream.Length; - } - } - catch (NotSupportedException) - { - // The lease does not expose generation-bound reads; fall through to the path. - } - } - - var fileInfo = new FileInfo(metadataPath); - return fileInfo.Exists ? fileInfo.Length : null; - } - private async Task ExtractMetadataAsync( string metadataPath, string cacheIdentity, diff --git a/listenarr.application/Audiobooks/Files/AudiobookFileService.PhysicalGeneration.cs b/listenarr.application/Audiobooks/Files/AudiobookFileService.PhysicalGeneration.cs index fa333daf0..3dad98b99 100644 --- a/listenarr.application/Audiobooks/Files/AudiobookFileService.PhysicalGeneration.cs +++ b/listenarr.application/Audiobooks/Files/AudiobookFileService.PhysicalGeneration.cs @@ -274,11 +274,10 @@ private static AudiobookFile CreatePhysicalGenerationSnapshot( string? source, bool replaceMetadata) { + var fileInfo = new FileInfo(registrationLease.MetadataPath); var replacement = AudiobookFile.CreateUnresolved(currentFile.Path); replacement.AudiobookId = currentFile.AudiobookId; - replacement.Size = ResolveRegisteredLength( - registrationLease, - registrationLease.MetadataPath) ?? currentFile.Size; + replacement.Size = fileInfo.Exists ? fileInfo.Length : currentFile.Size; replacement.DurationSeconds = replaceMetadata ? metadata?.Duration.TotalSeconds : Math.Abs(metadata?.Duration.TotalSeconds ?? 0) > double.Epsilon diff --git a/listenarr.application/Audiobooks/Files/AudiobookFileService.cs b/listenarr.application/Audiobooks/Files/AudiobookFileService.cs index 79f2126d0..823fe2f2c 100644 --- a/listenarr.application/Audiobooks/Files/AudiobookFileService.cs +++ b/listenarr.application/Audiobooks/Files/AudiobookFileService.cs @@ -327,11 +327,10 @@ private async Task EnsureAudiobookFileCoreAsync( cacheIdentity, filePath); + var fi = new FileInfo(metadataPath); var fileRecord = AudiobookFile.CreateUnresolved(filePath); fileRecord.AudiobookId = audiobook.Id; - fileRecord.Size = ResolveRegisteredLength( - registrationLease, - metadataPath); + fileRecord.Size = fi.Exists ? fi.Length : null; fileRecord.Source = source; fileRecord.CreatedAt = DateTime.UtcNow; fileRecord.DurationSeconds = meta?.Duration.TotalSeconds;