diff --git a/src/ArmRipper.Core/Rip/ArmRipperService.cs b/src/ArmRipper.Core/Rip/ArmRipperService.cs index 1023f58..df86928 100644 --- a/src/ArmRipper.Core/Rip/ArmRipperService.cs +++ b/src/ArmRipper.Core/Rip/ArmRipperService.cs @@ -198,8 +198,7 @@ public async Task RipVisualMediaAsync(Job job, string logFile, bool hasD Directory.CreateDirectory(makeMkvOutPath); var mkvArgs = job.Config?.MkvArgs ?? settings.Value.MkvArgs ?? ""; - var minLength = job.Config?.MinLength ?? settings.Value.MinLength; - await makeMkv.RipTrackAsync(job, "0", makeMkvOutPath, mkvArgs, minLength, MkvProgress(job, "Ripping track 0", ct), ct); + await makeMkv.RipTrackAsync(job, "0", makeMkvOutPath, mkvArgs, 0, MkvProgress(job, "Ripping track 0", ct), ct); logger.LogInformation("Ripped track 0 in test mode"); return makeMkvOutPath; } @@ -210,9 +209,9 @@ public async Task RipVisualMediaAsync(Job job, string logFile, bool hasD var minLengthCfg = config?.MinLength ?? settings.Value.MinLength; var maxLength = config?.MaxLength ?? settings.Value.MaxLength; - // When DiscDb is enabled, pass infoMinLength=0 so MakeMKV reports ALL tracks, - // including short extras that may match DiscDb entries. Our own minLengthCfg - // and DiscDb promotion logic will handle filtering and promotion. + // Use infoMinLength=0 when DiscDb is enabled so MakeMKV reports ALL tracks, + // including short extras that may match DiscDb entries. The normal + // minLengthCfg is only used for the rip phase, not the scan. var infoMinLength = settings.Value.DiscDbEnabled ? 0 : (int?)null; var tracks = await makeMkv.GetTrackInfoWithCacheAsync(job, jobTitle, infoMinLength, ct); @@ -227,28 +226,51 @@ public async Task RipVisualMediaAsync(Job job, string logFile, bool hasD await db.SaveChangesAsync(ct); await BroadcastJobUpdateAsync(job); - if (!Directory.Exists(makeMkvOutPath)) - Directory.CreateDirectory(makeMkvOutPath); - - var mkvArgs = config?.MkvArgs ?? settings.Value.MkvArgs ?? ""; - await makeMkv.RipAllTitlesAsync(job, makeMkvOutPath, mkvArgs, minLengthCfg, MkvProgress(job, "Ripping all titles", ct), ct); - logger.LogInformation("Ripped all titles from disc (0-track fallback)"); + // The info scan may have timed out with infoMinLength=0 on a + // damaged disc. Before falling back to RipAllTitles, try a second + // info scan with the normal configured minLength. If that succeeds, + // the normal track selection (MainFeature, etc.) will be applied. + // This prevents an identify-phase timeout from cascading into a rip + // that bypasses track selection and rips everything. + var retryTracks = await makeMkv.GetTrackInfoWithCacheAsync(job, jobTitle, + infoMinLength: null, ct); - if (!Directory.EnumerateFileSystemEntries(makeMkvOutPath).Any()) + if (retryTracks.Count > 0) { - var msg = "MakeMKV rip produced no output files"; - logger.LogError(msg); - throw new InvalidOperationException(msg); + tracks = retryTracks; + logger.LogInformation( + "0-track fallback: retry with normal minLength found {Count} tracks, " + + "proceeding with standard track selection", retryTracks.Count); } - - if (job.Config?.NotifyRip ?? settings.Value.NotifyRip) + else { - await notifications.NotifyAsync(job, NotificationService.NotifyTitle, - $"{job.Title} rip complete. Starting transcode.", ct); - } + if (!Directory.Exists(makeMkvOutPath)) + Directory.CreateDirectory(makeMkvOutPath); - logger.LogInformation("************* Ripping with MakeMKV completed *************"); - return makeMkvOutPath; + var mkvArgs = config?.MkvArgs ?? settings.Value.MkvArgs ?? ""; + await makeMkv.RipAllTitlesAsync(job, makeMkvOutPath, mkvArgs, minLengthCfg, MkvProgress(job, "Ripping all titles", ct), ct); + logger.LogInformation("Ripped all titles from disc (0-track fallback)"); + + if (!Directory.EnumerateFileSystemEntries(makeMkvOutPath).Any()) + { + var msg = "MakeMKV rip produced no output files"; + logger.LogError(msg); + throw new InvalidOperationException(msg); + } + + job.MarkStageComplete(RipStage.Rip); + await db.SaveChangesAsync(ct); + await BroadcastJobUpdateAsync(job); + + if (job.Config?.NotifyRip ?? settings.Value.NotifyRip) + { + await notifications.NotifyAsync(job, NotificationService.NotifyTitle, + $"{job.Title} rip complete. Starting transcode.", ct); + } + + logger.LogInformation("************* Ripping with MakeMKV completed *************"); + return makeMkvOutPath; + } } Track? longestTrack = null; @@ -414,9 +436,9 @@ await notifications.NotifyAsync(job, NotificationService.NotifyTitle, { var firstTrack = eligibleTracks.FirstOrDefault(); if (firstTrack is not null) - await makeMkv.RipTrackAsync(job, firstTrack.TrackNumber!, makeMkvOutPath, mkvArgs, minLengthCfg, MkvProgress(job, "Ripping track 0", ct), ct); + await makeMkv.RipTrackAsync(job, firstTrack.TrackNumber!, makeMkvOutPath, mkvArgs, 0, MkvProgress(job, "Ripping track 0", ct), ct); else - await makeMkv.RipTrackAsync(job, "0", makeMkvOutPath, mkvArgs, minLengthCfg, MkvProgress(job, "Ripping track 0", ct), ct); + await makeMkv.RipTrackAsync(job, "0", makeMkvOutPath, mkvArgs, 0, MkvProgress(job, "Ripping track 0", ct), ct); } else if (config?.MainFeature ?? settings.Value.MainFeature) { @@ -426,7 +448,9 @@ await notifications.NotifyAsync(job, NotificationService.NotifyTitle, var main = tracks.FirstOrDefault(t => t.MainFeature); if (main is not null) { - await makeMkv.RipTrackAsync(job, main.TrackNumber!, makeMkvOutPath, mkvArgs, minLengthCfg, MkvProgress(job, "Ripping main feature", ct), ct); + // We already identified the exact track (the longest one), so pass + // minLength=0 to prevent MakeMKV from filtering it out with --minlength. + await makeMkv.RipTrackAsync(job, main.TrackNumber!, makeMkvOutPath, mkvArgs, 0, MkvProgress(job, "Ripping main feature", ct), ct); ripCount = 1; } } diff --git a/src/ArmRipper.Core/Rip/FfmpegService.cs b/src/ArmRipper.Core/Rip/FfmpegService.cs index dd7d727..922ed6f 100644 --- a/src/ArmRipper.Core/Rip/FfmpegService.cs +++ b/src/ArmRipper.Core/Rip/FfmpegService.cs @@ -249,7 +249,8 @@ public async Task TranscodeAllAsync(Job job, string rawPath, string o private async Task RunTranscodeAsync(string inputFile, string outputFile, Job job, int? totalSeconds, List stdOut, List stdErr, IProgress? progress, CancellationToken ct) { - await using var slot = await transcodeSlotLimiter.AcquireAsync(ct); + var effectiveMax = job.Config?.MaxConcurrentTranscodes ?? settings.Value.MaxConcurrentTranscodes; + await using var slot = await transcodeSlotLimiter.AcquireAsync(effectiveMax, ct); var (ffPreArgs, ffPostArgs) = GetFfSettings(job); diff --git a/src/ArmRipper.Core/Rip/HandBrakeService.cs b/src/ArmRipper.Core/Rip/HandBrakeService.cs index 5622bd1..dda2b81 100644 --- a/src/ArmRipper.Core/Rip/HandBrakeService.cs +++ b/src/ArmRipper.Core/Rip/HandBrakeService.cs @@ -42,7 +42,8 @@ public async Task TranscodeMkvAsync(Job job, string rawPath, string o logger.LogInformation("Transcoding {File} to {Output}", file, outputFile); var cmd = BuildCommand(file, outputFile, job, trackNumber: null, mainFeature: false); - lastResult = await RunHandBrakeCommandAsync(cmd, ct, progress); + var effectiveMax = job.Config?.MaxConcurrentTranscodes ?? settings.Value.MaxConcurrentTranscodes; + lastResult = await RunHandBrakeCommandAsync(cmd, effectiveMax, ct, progress); if (lastResult.ExitCode != 0) { @@ -118,10 +119,11 @@ public async Task TranscodeMainFeatureAsync(Job job, string rawPath, logger.LogInformation("Ripping main feature to {Output}", outputFile); var cmd = BuildCommand(rawPath, outputFile, job, trackNumber: null, mainFeature: true); + var effectiveMax = job.Config?.MaxConcurrentTranscodes ?? settings.Value.MaxConcurrentTranscodes; try { - var result = await RunHandBrakeCommandAsync(cmd, ct, progress); + var result = await RunHandBrakeCommandAsync(cmd, effectiveMax, ct, progress); if (result.ExitCode != 0) { var err = $"HandBrake main feature transcoding failed with code {result.ExitCode}: {result.StdErr}"; @@ -191,10 +193,11 @@ public async Task TranscodeAllAsync(Job job, string rawPath, string o logger.LogInformation("Transcoding title {TrackNo} to {Output}", trackNo, outputFile); var cmd = BuildCommand(rawPath, outputFile, job, trackNo, mainFeature: false); + var effectiveMax = job.Config?.MaxConcurrentTranscodes ?? settings.Value.MaxConcurrentTranscodes; try { - lastResult = await RunHandBrakeCommandAsync(cmd, ct, progress); + lastResult = await RunHandBrakeCommandAsync(cmd, effectiveMax, ct, progress); if (lastResult.ExitCode != 0) { var err = $"HandBrake encoding of title {trackNo} failed with code {lastResult.ExitCode}: {lastResult.StdErr}"; @@ -277,9 +280,9 @@ private string BuildCommand(string inputPath, string outputPath, Job job, int? t return (settings.Value.HbPresetDvd, settings.Value.HbArgsDvd); } - private async Task RunHandBrakeCommandAsync(string cmd, CancellationToken ct, IProgress? progress = null) + private async Task RunHandBrakeCommandAsync(string cmd, int maxConcurrent, CancellationToken ct, IProgress? progress = null) { - await using var slot = await transcodeSlotLimiter.AcquireAsync(ct); + await using var slot = await transcodeSlotLimiter.AcquireAsync(maxConcurrent, ct); logger.LogInformation("HandBrake command: {Command}", cmd); diff --git a/src/ArmRipper.Core/Rip/ITranscodeSlotLimiter.cs b/src/ArmRipper.Core/Rip/ITranscodeSlotLimiter.cs index fba0ac7..cf5d7da 100644 --- a/src/ArmRipper.Core/Rip/ITranscodeSlotLimiter.cs +++ b/src/ArmRipper.Core/Rip/ITranscodeSlotLimiter.cs @@ -2,5 +2,10 @@ namespace ArmRipper.Core.Rip; public interface ITranscodeSlotLimiter { - ValueTask AcquireAsync(CancellationToken ct = default); + /// + /// Acquires a transcode slot, respecting the effective max-concurrent limit. + /// + /// Maximum concurrent transcodes allowed. + /// Use 0 or negative to disable limiting entirely. + ValueTask AcquireAsync(int maxConcurrent, CancellationToken ct = default); } diff --git a/src/ArmRipper.Core/Rip/MakeMkvService.cs b/src/ArmRipper.Core/Rip/MakeMkvService.cs index aa63813..b1c1ee6 100644 --- a/src/ArmRipper.Core/Rip/MakeMkvService.cs +++ b/src/ArmRipper.Core/Rip/MakeMkvService.cs @@ -409,10 +409,10 @@ private sealed record StreamAccum public async Task RipTrackAsync(Job job, string trackNumber, string outputPath, string mkvArgs, int minLength, IProgress? progress = null, CancellationToken ct = default) { - // Estimate expected file size from track info for progress monitoring + // Estimate expected file size from the track for progress monitoring. var expectedSize = job.Tracks - .Where(t => t.TrackNumber == trackNumber) - .Sum(t => t.FileSize ?? 0); + ?.FirstOrDefault(t => t.TrackNumber == trackNumber) + ?.FileSize ?? 0; var monitorCts = CancellationTokenSource.CreateLinkedTokenSource(ct); var monitorTask = expectedSize > 0 && progress is not null @@ -421,6 +421,11 @@ public async Task RipTrackAsync(Job job, string trackNumber, string outputPath, try { + // trackNumber is the 0-based TINFO index from MakeMKV's info scan. + // MakeMKV's mkv command uses TINFO indices: 0 = "all titles", + // 1 = first title, etc. SourceTitleId (field 24) is a different + // numbering scheme and must NOT be used here — it would select the + // wrong title on almost every disc. var args = $"--robot --messages=-stdout --progress=-stdout mkv --minlength={minLength} dev:{job.DevPath} {trackNumber} \"{outputPath}\""; if (!string.IsNullOrEmpty(mkvArgs)) args = $"--robot --messages=-stdout --progress=-stdout mkv {mkvArgs} --minlength={minLength} dev:{job.DevPath} {trackNumber} \"{outputPath}\""; diff --git a/src/ArmRipper.Core/Rip/TranscodeSlotLimiter.cs b/src/ArmRipper.Core/Rip/TranscodeSlotLimiter.cs index f0fe7b4..d2a0384 100644 --- a/src/ArmRipper.Core/Rip/TranscodeSlotLimiter.cs +++ b/src/ArmRipper.Core/Rip/TranscodeSlotLimiter.cs @@ -1,37 +1,108 @@ -using ArmRipper.Core.Configuration; -using Microsoft.Extensions.Options; - namespace ArmRipper.Core.Rip; +/// +/// Global concurrency gate for transcode processes. Unlike a fixed +/// , this limiter reads the effective +/// MaxConcurrentTranscodes from each caller at acquire time so +/// that UI-driven settings changes take effect without a restart. +/// public sealed class TranscodeSlotLimiter : ITranscodeSlotLimiter { - private readonly SemaphoreSlim? semaphore; + private readonly object _gate = new(); + private int _activeCount; + private readonly Queue _waitQueue = new(); - public TranscodeSlotLimiter(IOptions settings) + public ValueTask AcquireAsync(int maxConcurrent, CancellationToken ct = default) { - var max = settings.Value.MaxConcurrentTranscodes; - if (max > 0) - semaphore = new SemaphoreSlim(max, max); + // 0 or negative → no limiting + if (maxConcurrent <= 0) + return new ValueTask(NoopLease.Instance); + + lock (_gate) + { + if (_activeCount < maxConcurrent) + { + _activeCount++; + return new ValueTask(new Lease(this)); + } + + var waiter = new Waiter(); + _waitQueue.Enqueue(waiter); + + // Register cancellation: when the token fires, mark the waiter + // as done so ReleaseOne skips it. If ReleaseOne already claimed + // it, TrySetCanceled is a no-op. + if (ct.CanBeCanceled) + { + waiter.Cancellation = ct.Register(static state => + { + var w = (Waiter)state!; + // Atomically mark as done; if ReleaseOne hasn't claimed it yet, cancel the TCS. + if (Interlocked.Exchange(ref w.Done, 1) == 0) + w.Tcs.TrySetCanceled(); + }, waiter); + } + + return new ValueTask(waiter.Tcs.Task); + } } - public async ValueTask AcquireAsync(CancellationToken ct = default) + /// Called by when a transcode finishes. + private void ReleaseOne() { - if (semaphore is null) - return NoopLease.Instance; + while (true) + { + Waiter? next = null; + lock (_gate) + { + while (_waitQueue.Count > 0) + { + var w = _waitQueue.Dequeue(); + // Try to claim this waiter before it's cancelled + if (Interlocked.Exchange(ref w.Done, 1) == 0) + { + next = w; + break; + } + // Already cancelled — clean up its registration + w.Cancellation.Dispose(); + } - await semaphore.WaitAsync(ct); - return new SemaphoreLease(semaphore); + if (next is null) + { + // No waiters — just release the slot + _activeCount--; + return; + } + } + + // Dispose the cancellation registration now that we own the waiter + next.Cancellation.Dispose(); + + if (next.Tcs.TrySetResult(new Lease(this))) + return; + // If TrySetResult failed (extremely unlikely race), loop and try next waiter + } } - private sealed class SemaphoreLease(SemaphoreSlim semaphore) : IAsyncDisposable + private sealed class Waiter { - private readonly SemaphoreSlim semaphore = semaphore; - private int disposed; + public TaskCompletionSource Tcs { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public CancellationTokenRegistration Cancellation; + /// + /// 0 = waiting, 1 = done (either fulfilled by ReleaseOne or cancelled). + /// + public int Done; + } + + private sealed class Lease(TranscodeSlotLimiter limiter) : IAsyncDisposable + { + private int _disposed; public ValueTask DisposeAsync() { - if (Interlocked.Exchange(ref disposed, 1) == 0) - semaphore.Release(); + if (Interlocked.Exchange(ref _disposed, 1) == 0) + limiter.ReleaseOne(); return ValueTask.CompletedTask; } } @@ -39,7 +110,6 @@ public ValueTask DisposeAsync() private sealed class NoopLease : IAsyncDisposable { public static readonly NoopLease Instance = new(); - public ValueTask DisposeAsync() => ValueTask.CompletedTask; } } diff --git a/src/ArmRipper.WebUi/Views/Completed/Detail.cshtml b/src/ArmRipper.WebUi/Views/Completed/Detail.cshtml index 7ecb595..f529d6a 100644 --- a/src/ArmRipper.WebUi/Views/Completed/Detail.cshtml +++ b/src/ArmRipper.WebUi/Views/Completed/Detail.cshtml @@ -12,7 +12,13 @@
-
@Model.FileName
+
+ @Model.FileName + @if (Model.SizeBytes < 2_000_000_000 && Model.RelativeDirectory.StartsWith("movies", StringComparison.OrdinalIgnoreCase)) + { + ⚠️ Small file + } +
← Back
diff --git a/src/ArmRipper.WebUi/Views/Completed/Index.cshtml b/src/ArmRipper.WebUi/Views/Completed/Index.cshtml index f6eed81..18733bd 100644 --- a/src/ArmRipper.WebUi/Views/Completed/Index.cshtml +++ b/src/ArmRipper.WebUi/Views/Completed/Index.cshtml @@ -147,6 +147,10 @@ else @file.FileName + @if (file.SizeBytes < 2_000_000_000 && file.RelativeDirectory.StartsWith("movies", StringComparison.OrdinalIgnoreCase)) + { + ⚠️ Small file + } @file.LastModifiedFormatted @file.SizeFormatted diff --git a/src/ArmRipper.WebUi/Views/Jobs/JobDetail.cshtml b/src/ArmRipper.WebUi/Views/Jobs/JobDetail.cshtml index 7aa4e89..19faab9 100644 --- a/src/ArmRipper.WebUi/Views/Jobs/JobDetail.cshtml +++ b/src/ArmRipper.WebUi/Views/Jobs/JobDetail.cshtml @@ -394,11 +394,15 @@ else {
- + - @foreach (var track in Model.Tracks.OrderBy(t => t.TrackNumber)) - { + @{ + var orderedTracks = Model.Tracks.OrderBy(t => t.TrackNumber).ToList(); + for (var idx = 0; idx < orderedTracks.Count; idx++) + { + var track = orderedTracks[idx]; + + } }
#TitleLengthChaptersSizeFPSAspectSourceTypeRippedMainStatus
#MKV #TitleLengthChaptersSizeFPSAspectSourceTypeRippedMainStatus
@(idx + 1) @track.TrackNumber @if (!string.IsNullOrEmpty(track.EpisodeTitle)) @@ -445,6 +449,7 @@ else @(track.MainFeature ? "Yes" : "") @(track.Status ?? "—")