From 3e1cb89c050d7b6df8c2c5d40d01f1a8fd46c84e Mon Sep 17 00:00:00 2001 From: m4bard Date: Fri, 14 Aug 2026 15:17:31 -0500 Subject: [PATCH 1/2] 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 0b9f65a131ec58ab443074ee1e66b86c21f2d7c7 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/2] 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]