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,