diff --git a/listenarr.application/Audiobooks/Files/AudiobookFileService.MetadataExtraction.cs b/listenarr.application/Audiobooks/Files/AudiobookFileService.MetadataExtraction.cs index aafb6dbcb..f1f55e68c 100644 --- a/listenarr.application/Audiobooks/Files/AudiobookFileService.MetadataExtraction.cs +++ b/listenarr.application/Audiobooks/Files/AudiobookFileService.MetadataExtraction.cs @@ -93,4 +93,59 @@ public partial class AudiobookFileService return metadata; } + + /// + /// Resolve the length of the audio file a registration is about to record. + /// + /// + /// A lease's MetadataPath is a descriptor path on Linux and macOS + /// (/proc/<pid>/fd/<n> or /dev/fd/<n>), so stat'ing it reports the size + /// of the descriptor link rather than the length of the file the descriptor + /// pins. The lease's own read stream is served from that descriptor and does + /// report the pinned file's length, which is also how + /// FileRegistrationRecoveryService compares a published file against its + /// journalled length. Leases that expose no generation-bound read fall back + /// to the published path. + /// + private long? ResolveRegisteredFileLength( + IAudiobookFileRegistrationLease? registrationLease, + string filePath) + { + if (registrationLease != null) + { + try + { + using var metadataStream = registrationLease.OpenMetadataReadStream(); + if (metadataStream.CanSeek) + { + return metadataStream.Length; + } + } + catch (Exception exception) when (exception is + NotSupportedException or IOException or UnauthorizedAccessException + or ObjectDisposedException) + { + logger.LogDebug( + exception, + "The registration lease exposed no generation-bound length; falling back to the published path for {Path}", + LogRedaction.SanitizeFilePath(filePath)); + } + } + + try + { + return fileSystem.FileExists(filePath) + ? fileSystem.GetFileLength(filePath) + : null; + } + catch (Exception exception) when (exception is + IOException or UnauthorizedAccessException) + { + logger.LogDebug( + exception, + "Could not read the length of the registered audiobook file {Path}", + LogRedaction.SanitizeFilePath(filePath)); + return null; + } + } } diff --git a/listenarr.application/Audiobooks/Files/AudiobookFileService.cs b/listenarr.application/Audiobooks/Files/AudiobookFileService.cs index 823fe2f2c..04b3ccb9a 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 = ResolveRegisteredFileLength( + registrationLease, + filePath); fileRecord.Source = source; fileRecord.CreatedAt = DateTime.UtcNow; fileRecord.DurationSeconds = meta?.Duration.TotalSeconds; diff --git a/tests/Features/Application/Audiobooks/Files/AudioFileServiceTests.cs b/tests/Features/Application/Audiobooks/Files/AudioFileServiceTests.cs index e965adb8a..ec47063d3 100644 --- a/tests/Features/Application/Audiobooks/Files/AudioFileServiceTests.cs +++ b/tests/Features/Application/Audiobooks/Files/AudioFileServiceTests.cs @@ -1003,6 +1003,138 @@ public async Task EnsureAudiobookFileAsync_PersistsMetadataFromMetadataService() Assert.Equal(1, file.Channels); } + private const long DescriptorLinkReportedLength = 64; + + [Fact] + public async Task DivergentDescriptorFixture_ReportsDescriptorLengthRatherThanAudioLength() + { + var fixture = await CreateDivergentDescriptorFixtureAsync(); + + // Control for the two registration tests below. They only prove + // anything while stat'ing the descriptor path gives a different + // answer from stat'ing the audio file, so assert the divergence + // directly: if the fixture ever pointed both paths at the same + // file, those tests would pass without exercising the fault. + Assert.Equal( + DescriptorLinkReportedLength, + new FileInfo(fixture.DescriptorPath).Length); + Assert.Equal( + fixture.AudioLength, + new FileInfo(fixture.AudioPath).Length); + Assert.NotEqual( + new FileInfo(fixture.DescriptorPath).Length, + new FileInfo(fixture.AudioPath).Length); + } + + [Fact] + public async Task EnsureAudiobookFileAsync_LeaseExposingGenerationBoundRead_RecordsAudioFileSize() + { + var fixture = await CreateDivergentDescriptorFixtureAsync(); + _audiobook.BasePath = Path.GetDirectoryName(fixture.AudioPath); + await _audiobookRepository.UpdateAsync(_audiobook); + using var registrationLease = new DescriptorMetadataPathRegistrationLease( + fixture.AudioPath, + fixture.DescriptorPath, + "descriptor-metadata-generation", + exposesGenerationBoundRead: true); + + Assert.True(await _provider + .GetRequiredService() + .EnsureAudiobookFileAsync(_audiobook, registrationLease, "test")); + + var file = Assert.Single( + await _audiobookFileRepository.GetByAudiobookIdAsync(_audiobook.Id)); + Assert.Equal(fixture.AudioLength, file.Size); + } + + [Fact] + public async Task EnsureAudiobookFileAsync_LeaseWithoutGenerationBoundRead_RecordsAudioFileSize() + { + var fixture = await CreateDivergentDescriptorFixtureAsync(); + _audiobook.BasePath = Path.GetDirectoryName(fixture.AudioPath); + await _audiobookRepository.UpdateAsync(_audiobook); + using var registrationLease = new DescriptorMetadataPathRegistrationLease( + fixture.AudioPath, + fixture.DescriptorPath, + "descriptor-metadata-generation", + exposesGenerationBoundRead: false); + + Assert.True(await _provider + .GetRequiredService() + .EnsureAudiobookFileAsync(_audiobook, registrationLease, "test")); + + var file = Assert.Single( + await _audiobookFileRepository.GetByAudiobookIdAsync(_audiobook.Id)); + Assert.Equal(fixture.AudioLength, file.Size); + } + + private sealed record DivergentDescriptorFixture( + string AudioPath, + string DescriptorPath, + long AudioLength); + + private static async Task + CreateDivergentDescriptorFixtureAsync() + { + var directory = Directory + .CreateTempSubdirectory($"afs-descriptor-{Guid.NewGuid():N}") + .FullName; + var audioPath = Path.Combine(directory, "book.m4b"); + var audioBytes = new byte[12_345]; + await File.WriteAllBytesAsync(audioPath, audioBytes); + + // Stands in for the lease's MetadataPath on Linux and macOS, which is + // a descriptor path (/proc//fd/ or /dev/fd/). Stat'ing one + // reports the descriptor link's own size, 64 bytes, rather than the + // length of the file the descriptor pins. + var descriptorPath = Path.Combine(directory, "descriptor-link-stand-in"); + await File.WriteAllBytesAsync( + descriptorPath, + new byte[DescriptorLinkReportedLength]); + + return new DivergentDescriptorFixture( + audioPath, + descriptorPath, + audioBytes.Length); + } + + private sealed class DescriptorMetadataPathRegistrationLease( + string publicPath, + string metadataPath, + string physicalObjectIdentity, + bool exposesGenerationBoundRead) : IAudiobookFileRegistrationLease + { + public string PublicPath { get; } = publicPath; + public string MetadataPath { get; } = metadataPath; + public string PhysicalObjectIdentity { get; } = physicalObjectIdentity; + public string? SourcePhysicalObjectIdentity => null; + + // The pinned lease serves this from the descriptor it holds, so the + // stream reports the pinned file's length. Leases without a + // generation-bound read reproduce the interface default, which throws. + public Stream OpenMetadataReadStream() => + exposesGenerationBoundRead + ? File.OpenRead(PublicPath) + : throw new NotSupportedException( + "This registration lease does not expose generation-bound metadata reads."); + + public bool MatchesCurrentPublication() => true; + + public bool PrepareCleanupRecovery(int audiobookId) => true; + + public RegistrationPublicationCompletion CompletePublication() => + RegistrationPublicationCompletion.Completed; + + public Task MatchesContentAsync( + Stream candidateStream, + CancellationToken cancellationToken = default) => + Task.FromResult(true); + + public void Dispose() + { + } + } + private sealed class SequencedRegistrationLease( string path, string physicalObjectIdentity,