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)) { 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]