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]