Skip to content
74 changes: 49 additions & 25 deletions src/ArmRipper.Core/Rip/ArmRipperService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -198,8 +198,7 @@ public async Task<string> 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;
}
Expand All @@ -210,9 +209,9 @@ public async Task<string> 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);

Expand All @@ -227,28 +226,51 @@ public async Task<string> 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;
Expand Down Expand Up @@ -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)
{
Expand All @@ -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;
}
}
Expand Down
3 changes: 2 additions & 1 deletion src/ArmRipper.Core/Rip/FfmpegService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,8 @@ public async Task<CliResult> TranscodeAllAsync(Job job, string rawPath, string o

private async Task RunTranscodeAsync(string inputFile, string outputFile, Job job, int? totalSeconds, List<string> stdOut, List<string> stdErr, IProgress<int>? 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);

Expand Down
13 changes: 8 additions & 5 deletions src/ArmRipper.Core/Rip/HandBrakeService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ public async Task<CliResult> 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)
{
Expand Down Expand Up @@ -118,10 +119,11 @@ public async Task<CliResult> 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}";
Expand Down Expand Up @@ -191,10 +193,11 @@ public async Task<CliResult> 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}";
Expand Down Expand Up @@ -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<CliResult> RunHandBrakeCommandAsync(string cmd, CancellationToken ct, IProgress<int>? progress = null)
private async Task<CliResult> RunHandBrakeCommandAsync(string cmd, int maxConcurrent, CancellationToken ct, IProgress<int>? 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);

Expand Down
7 changes: 6 additions & 1 deletion src/ArmRipper.Core/Rip/ITranscodeSlotLimiter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,10 @@ namespace ArmRipper.Core.Rip;

public interface ITranscodeSlotLimiter
{
ValueTask<IAsyncDisposable> AcquireAsync(CancellationToken ct = default);
/// <summary>
/// Acquires a transcode slot, respecting the effective max-concurrent limit.
/// </summary>
/// <param name="maxConcurrent">Maximum concurrent transcodes allowed.
/// Use 0 or negative to disable limiting entirely.</param>
ValueTask<IAsyncDisposable> AcquireAsync(int maxConcurrent, CancellationToken ct = default);
}
11 changes: 8 additions & 3 deletions src/ArmRipper.Core/Rip/MakeMkvService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -409,10 +409,10 @@ private sealed record StreamAccum

public async Task RipTrackAsync(Job job, string trackNumber, string outputPath, string mkvArgs, int minLength, IProgress<int>? 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
Expand All @@ -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}\"";
Expand Down
108 changes: 89 additions & 19 deletions src/ArmRipper.Core/Rip/TranscodeSlotLimiter.cs
Original file line number Diff line number Diff line change
@@ -1,45 +1,115 @@
using ArmRipper.Core.Configuration;
using Microsoft.Extensions.Options;

namespace ArmRipper.Core.Rip;

/// <summary>
/// Global concurrency gate for transcode processes. Unlike a fixed
/// <see cref="SemaphoreSlim"/>, this limiter reads the effective
/// <c>MaxConcurrentTranscodes</c> from each caller at acquire time so
/// that UI-driven settings changes take effect without a restart.
/// </summary>
public sealed class TranscodeSlotLimiter : ITranscodeSlotLimiter
{
private readonly SemaphoreSlim? semaphore;
private readonly object _gate = new();
private int _activeCount;
private readonly Queue<Waiter> _waitQueue = new();

public TranscodeSlotLimiter(IOptions<ArmSettings> settings)
public ValueTask<IAsyncDisposable> 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<IAsyncDisposable>(NoopLease.Instance);

lock (_gate)
{
if (_activeCount < maxConcurrent)
{
_activeCount++;
return new ValueTask<IAsyncDisposable>(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<IAsyncDisposable>(waiter.Tcs.Task);
}
}

public async ValueTask<IAsyncDisposable> AcquireAsync(CancellationToken ct = default)
/// <summary>Called by <see cref="Lease.DisposeAsync"/> when a transcode finishes.</summary>
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<IAsyncDisposable> Tcs { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public CancellationTokenRegistration Cancellation;
/// <summary>
/// 0 = waiting, 1 = done (either fulfilled by ReleaseOne or cancelled).
/// </summary>
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;
}
}

private sealed class NoopLease : IAsyncDisposable
{
public static readonly NoopLease Instance = new();

public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}
8 changes: 7 additions & 1 deletion src/ArmRipper.WebUi/Views/Completed/Detail.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,13 @@

<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">@Model.FileName</h5>
<h5 class="mb-0">
@Model.FileName
@if (Model.SizeBytes < 2_000_000_000 && Model.RelativeDirectory.StartsWith("movies", StringComparison.OrdinalIgnoreCase))
{
<span class="badge badge-warning ml-1" title="File is unusually small for a movie (under 2 GB)">⚠️ Small file</span>
}
</h5>
<a href="/completed?refresh=true" class="btn btn-outline-secondary btn-sm">← Back</a>
</div>
<div class="card-body">
Expand Down
4 changes: 4 additions & 0 deletions src/ArmRipper.WebUi/Views/Completed/Index.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,10 @@ else
<tr>
<td class="text-truncate" title="@file.FileName">
<a href="/completed/probe?filePath=@Uri.EscapeDataString(file.FilePath)" class="font-weight-bold">@file.FileName</a>
@if (file.SizeBytes < 2_000_000_000 && file.RelativeDirectory.StartsWith("movies", StringComparison.OrdinalIgnoreCase))
{
<span class="badge badge-warning ml-1" title="File is unusually small for a movie (under 2 GB)">⚠️ Small file</span>
}
</td>
<td data-order="@file.LastModified.ToString("O")"><small class="text-muted">@file.LastModifiedFormatted</small></td>
<td data-order="@file.SizeBytes">@file.SizeFormatted</td>
Expand Down
Loading