Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,44 @@ namespace Listenarr.Application.Audiobooks.Files;

public partial class AudiobookFileService
{
/// <summary>
/// The byte length of the file a registration lease holds open.
/// </summary>
/// <remarks>
/// A lease's metadata path is not always a path to the file. On Linux it is a
/// <c>/proc/{pid}/fd/{fd}</c> 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.
/// </remarks>
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<AudioMetadata?> ExtractMetadataAsync(
string metadataPath,
string cacheIdentity,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -327,10 +327,11 @@ private async Task<bool> 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,15 @@ private async Task<ScanDiscoveryResult> 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))
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public async Task ScanAsync_CaseDistinctMetadataFolders_RemainConflicting()
var lowerFile = Path.Join(lowerDirectory, "part-b.m4b");
var metadata = new Mock<IMetadataService>(MockBehavior.Strict);
metadata.Setup(service => service.ExtractFileMetadataAsync(
It.IsAny<string>()))
It.IsAny<MetadataFileSource>()))
.ReturnsAsync(MatchingMetadata());
Init(services => services.WithSingleton<IMetadataService>(metadata.Object));
Directory.CreateDirectory(upperDirectory);
Expand Down Expand Up @@ -65,7 +65,7 @@ await _applicationSettingsRepository.SaveAsync(
Assert.Contains(result.Diagnostics, diagnostic =>
diagnostic.Code == "MetadataAttributionConflict");
metadata.Verify(
service => service.ExtractFileMetadataAsync(It.IsAny<string>()),
service => service.ExtractFileMetadataAsync(It.IsAny<MetadataFileSource>()),
Times.Exactly(2));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<MetadataFileSource>();
var metadata = new Mock<IMetadataService>(MockBehavior.Strict);
metadata.Setup(service => service.ExtractFileMetadataAsync(
It.IsAny<string>()))
.Returns<string>(async extractionPath =>
It.IsAny<MetadataFileSource>()))
.Returns<MetadataFileSource>(async fileSource =>
{
observedSources.Add(fileSource);
var extractionPath = fileSource.ReadPath;
var displacedOriginal = false;
try
{
Expand Down Expand Up @@ -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<string>()),
service => service.ExtractFileMetadataAsync(It.IsAny<MetadataFileSource>()),
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]
Expand Down