From 4542288cf70a2bd2fc06e03e16473ebc5e75472a Mon Sep 17 00:00:00 2001 From: ARM Dotnet Port Date: Thu, 16 Jul 2026 22:20:28 -0500 Subject: [PATCH 1/9] Omit --minlength when ripping a known track; add MKV title column to job detail page --- src/ArmRipper.Core/Rip/ArmRipperService.cs | 11 ++++++----- src/ArmRipper.Core/Rip/MakeMkvService.cs | 8 ++++++-- src/ArmRipper.WebUi/Views/Jobs/JobDetail.cshtml | 11 ++++++++--- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/ArmRipper.Core/Rip/ArmRipperService.cs b/src/ArmRipper.Core/Rip/ArmRipperService.cs index 1023f58..3bf86b6 100644 --- a/src/ArmRipper.Core/Rip/ArmRipperService.cs +++ b/src/ArmRipper.Core/Rip/ArmRipperService.cs @@ -198,8 +198,8 @@ 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); + // Test mode rips a specific track — no need for --minlength filter + 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; } @@ -414,9 +414,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 +426,8 @@ 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 know the exact main feature track — no need for --minlength filter + 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/MakeMkvService.cs b/src/ArmRipper.Core/Rip/MakeMkvService.cs index aa63813..da5c4e2 100644 --- a/src/ArmRipper.Core/Rip/MakeMkvService.cs +++ b/src/ArmRipper.Core/Rip/MakeMkvService.cs @@ -421,9 +421,13 @@ public async Task RipTrackAsync(Job job, string trackNumber, string outputPath, try { - var args = $"--robot --messages=-stdout --progress=-stdout mkv --minlength={minLength} dev:{job.DevPath} {trackNumber} \"{outputPath}\""; + var args = minLength > 0 + ? $"--robot --messages=-stdout --progress=-stdout mkv --minlength={minLength} dev:{job.DevPath} {trackNumber} \\\"{outputPath}\\\"" + : $"--robot --messages=-stdout --progress=-stdout mkv dev:{job.DevPath} {trackNumber} \\\"{outputPath}\\\""; if (!string.IsNullOrEmpty(mkvArgs)) - args = $"--robot --messages=-stdout --progress=-stdout mkv {mkvArgs} --minlength={minLength} dev:{job.DevPath} {trackNumber} \"{outputPath}\""; + args = minLength > 0 + ? $"--robot --messages=-stdout --progress=-stdout mkv {mkvArgs} --minlength={minLength} dev:{job.DevPath} {trackNumber} \\\"{outputPath}\\\"" + : $"--robot --messages=-stdout --progress=-stdout mkv {mkvArgs} dev:{job.DevPath} {trackNumber} \\\"{outputPath}\\\""; await foreach (var line in _runner.RunStreamingAsync("makemkvcon", args, ct: ct)) ParseAndReportProgress(line, progress); 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 ?? "—")
From 2f7774441652a56bf6a06943911f7435cb25ac41 Mon Sep 17 00:00:00 2001 From: ARM Dotnet Port Date: Thu, 16 Jul 2026 22:53:03 -0500 Subject: [PATCH 2/9] Use SourceTitleId (field 24) for MakeMKV mkv command to fix title selection; fix quoting --- ...0260717034034_AddSourceTitleId.Designer.cs | 912 ++++++++++++++++++ .../20260717034034_AddSourceTitleId.cs | 38 + .../Migrations/ArmDbContextModelSnapshot.cs | 6 + src/ArmRipper.Core/Models/DiscTrack.cs | 1 + src/ArmRipper.Core/Models/Track.cs | 3 + src/ArmRipper.Core/Rip/ArmRipperService.cs | 10 +- src/ArmRipper.Core/Rip/MakeMkvModels.cs | 1 + src/ArmRipper.Core/Rip/MakeMkvService.cs | 34 +- src/ArmRipper.WebUi/Hubs/NotificationHub.cs | 27 +- src/ArmRipper.WebUi/Views/Logs/Viewer.cshtml | 70 +- 10 files changed, 1056 insertions(+), 46 deletions(-) create mode 100644 src/ArmRipper.Core/Migrations/20260717034034_AddSourceTitleId.Designer.cs create mode 100644 src/ArmRipper.Core/Migrations/20260717034034_AddSourceTitleId.cs diff --git a/src/ArmRipper.Core/Migrations/20260717034034_AddSourceTitleId.Designer.cs b/src/ArmRipper.Core/Migrations/20260717034034_AddSourceTitleId.Designer.cs new file mode 100644 index 0000000..15069ab --- /dev/null +++ b/src/ArmRipper.Core/Migrations/20260717034034_AddSourceTitleId.Designer.cs @@ -0,0 +1,912 @@ +// +using System; +using ArmRipper.Core.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace ArmRipper.Core.Migrations +{ + [DbContext(typeof(ArmDbContext))] + [Migration("20260717034034_AddSourceTitleId")] + partial class AddSourceTitleId + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.8"); + + modelBuilder.Entity("ArmRipper.Core.Models.ConfigSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowDuplicates") + .HasColumnType("INTEGER"); + + b.Property("Apprise") + .HasColumnType("TEXT"); + + b.Property("ArmApiKey") + .HasColumnType("TEXT"); + + b.Property("AutoEject") + .HasColumnType("INTEGER"); + + b.Property("BashScript") + .HasColumnType("TEXT"); + + b.Property("CompletedPath") + .HasColumnType("TEXT"); + + b.Property("DbFile") + .HasColumnType("TEXT"); + + b.Property("DelRawFiles") + .HasColumnType("INTEGER"); + + b.Property("DestExt") + .HasColumnType("TEXT"); + + b.Property("DiscDbApiBaseUrl") + .HasColumnType("TEXT"); + + b.Property("DiscDbEnabled") + .HasColumnType("INTEGER"); + + b.Property("DiscDbMinConfidence") + .HasColumnType("REAL"); + + b.Property("DiscDbRequireConfirmation") + .HasColumnType("INTEGER"); + + b.Property("EmbyApiKey") + .HasColumnType("TEXT"); + + b.Property("EmbyPort") + .HasColumnType("INTEGER"); + + b.Property("EmbyRefresh") + .HasColumnType("INTEGER"); + + b.Property("EmbyServer") + .HasColumnType("TEXT"); + + b.Property("ExtrasSub") + .HasColumnType("TEXT"); + + b.Property("FfmpegCli") + .HasColumnType("TEXT"); + + b.Property("FfmpegPostFileArgs") + .HasColumnType("TEXT"); + + b.Property("FfmpegPreFileArgs") + .HasColumnType("TEXT"); + + b.Property("GetAudioTitle") + .HasColumnType("TEXT"); + + b.Property("GetVideoTitle") + .HasColumnType("INTEGER"); + + b.Property("HbArgsBd") + .HasColumnType("TEXT"); + + b.Property("HbArgsDvd") + .HasColumnType("TEXT"); + + b.Property("HbPresetBd") + .HasColumnType("TEXT"); + + b.Property("HbPresetDvd") + .HasColumnType("TEXT"); + + b.Property("IftttKey") + .HasColumnType("TEXT"); + + b.Property("InstallPath") + .HasColumnType("TEXT"); + + b.Property("JobId") + .HasColumnType("INTEGER"); + + b.Property("JsonUrl") + .HasColumnType("TEXT"); + + b.Property("LogPath") + .HasColumnType("TEXT"); + + b.Property("MainFeature") + .HasColumnType("INTEGER"); + + b.Property("ManualWait") + .HasColumnType("INTEGER"); + + b.Property("ManualWaitTime") + .HasColumnType("INTEGER"); + + b.Property("MaxConcurrentMakemkvInfo") + .HasColumnType("INTEGER"); + + b.Property("MaxConcurrentTranscodes") + .HasColumnType("INTEGER"); + + b.Property("MaxLength") + .HasColumnType("INTEGER"); + + b.Property("MetadataProvider") + .HasColumnType("TEXT"); + + b.Property("MinLength") + .HasColumnType("INTEGER"); + + b.Property("MkvArgs") + .HasColumnType("TEXT"); + + b.Property("NotifyRip") + .HasColumnType("INTEGER"); + + b.Property("NotifyTranscode") + .HasColumnType("INTEGER"); + + b.Property("OmdbApiKey") + .HasColumnType("TEXT"); + + b.Property("PbKey") + .HasColumnType("TEXT"); + + b.Property("PoUserKey") + .HasColumnType("TEXT"); + + b.Property("PreferWidescreen") + .HasColumnType("INTEGER"); + + b.Property("Prevent99") + .HasColumnType("INTEGER"); + + b.Property("RawPath") + .HasColumnType("TEXT"); + + b.Property("RipMethod") + .HasColumnType("TEXT"); + + b.Property("SkipTranscode") + .HasColumnType("INTEGER"); + + b.Property("TmdbApiKey") + .HasColumnType("TEXT"); + + b.Property("TranscodePath") + .HasColumnType("TEXT"); + + b.Property("UiBaseUrl") + .HasColumnType("TEXT"); + + b.Property("UseFfmpeg") + .HasColumnType("INTEGER"); + + b.Property("WebServerIp") + .HasColumnType("TEXT"); + + b.Property("WebServerPort") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("JobId") + .IsUnique(); + + b.ToTable("config", (string)null); + }); + + modelBuilder.Entity("ArmRipper.Core.Models.DiscDbMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ContentHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ImageUrl") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LastUsedAt") + .HasColumnType("TEXT"); + + b.Property("MediaSlug") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("MediaTitle") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("MediaType") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("MediaYear") + .HasMaxLength(4) + .HasColumnType("TEXT"); + + b.Property("TrackMappingsJson") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ContentHash") + .IsUnique(); + + b.ToTable("discdb_mappings", (string)null); + }); + + modelBuilder.Entity("ArmRipper.Core.Models.DiscMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DiscType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Fingerprint") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastUsedAt") + .HasColumnType("TEXT"); + + b.Property("SectorCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeLabel") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Fingerprint") + .IsUnique(); + + b.ToTable("disc_metadata", (string)null); + }); + + modelBuilder.Entity("ArmRipper.Core.Models.DiscTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AspectRatio") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Chapters") + .HasColumnType("INTEGER"); + + b.Property("DiscMetadataId") + .HasColumnType("INTEGER"); + + b.Property("FileName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("FileSize") + .HasColumnType("INTEGER"); + + b.Property("Fps") + .HasColumnType("REAL"); + + b.Property("Length") + .HasColumnType("INTEGER"); + + b.Property("Resolution") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("SourceTitleId") + .HasColumnType("INTEGER"); + + b.Property("TrackNumber") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DiscMetadataId"); + + b.ToTable("disc_tracks", (string)null); + }); + + modelBuilder.Entity("ArmRipper.Core.Models.DiscTrackStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelCount") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("DiscTrackId") + .HasColumnType("INTEGER"); + + b.Property("Forced") + .HasColumnType("INTEGER"); + + b.Property("LanguageCode") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("StreamIndex") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DiscTrackId"); + + b.ToTable("disc_track_streams", (string)null); + }); + + modelBuilder.Entity("ArmRipper.Core.Models.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ArmVersion") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("CompletedStages") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("CrcId") + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("DevPath") + .HasMaxLength(15) + .HasColumnType("TEXT"); + + b.Property("DiscDbHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("DiscFingerprint") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DiscType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Ejected") + .HasColumnType("INTEGER"); + + b.Property("Errors") + .HasColumnType("TEXT"); + + b.Property("HasNiceTitle") + .HasColumnType("INTEGER"); + + b.Property("HasTrack99") + .HasColumnType("INTEGER"); + + b.Property("ImdbId") + .HasMaxLength(15) + .HasColumnType("TEXT"); + + b.Property("ImdbIdAuto") + .HasMaxLength(15) + .HasColumnType("TEXT"); + + b.Property("ImdbIdManual") + .HasMaxLength(15) + .HasColumnType("TEXT"); + + b.Property("IsIso") + .HasColumnType("INTEGER"); + + b.Property("JobLength") + .HasMaxLength(12) + .HasColumnType("TEXT"); + + b.Property("Label") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LogFile") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ManualMode") + .HasColumnType("INTEGER"); + + b.Property("ManualStart") + .HasColumnType("INTEGER"); + + b.Property("ManualWaitResume") + .HasColumnType("INTEGER"); + + b.Property("MountPoint") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("NoOfTitles") + .HasColumnType("INTEGER"); + + b.Property("OriginalJobId") + .HasColumnType("INTEGER"); + + b.Property("OvidApiResponse") + .HasColumnType("TEXT"); + + b.Property("OvidFingerprint") + .HasColumnType("TEXT"); + + b.Property("OvidSubmitted") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Pid") + .HasColumnType("INTEGER"); + + b.Property("PidHash") + .HasColumnType("TEXT"); + + b.Property("PosterUrl") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PosterUrlAuto") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PosterUrlManual") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesTmdbId") + .HasColumnType("INTEGER"); + + b.Property("Stage") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("StageErrors") + .HasColumnType("TEXT"); + + b.Property("StageStartTime") + .HasColumnType("TEXT"); + + b.Property("StartTime") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("StopTime") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("TitleAuto") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("TitleManual") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("VideoType") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("VideoTypeAuto") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("VideoTypeManual") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Warnings") + .HasColumnType("TEXT"); + + b.Property("Year") + .HasMaxLength(4) + .HasColumnType("TEXT"); + + b.Property("YearAuto") + .HasMaxLength(4) + .HasColumnType("TEXT"); + + b.Property("YearManual") + .HasMaxLength(4) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("jobs", (string)null); + }); + + modelBuilder.Entity("ArmRipper.Core.Models.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EventType") + .HasColumnType("TEXT"); + + b.Property("Message") + .HasColumnType("TEXT"); + + b.Property("Read") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("notifications", (string)null); + }); + + modelBuilder.Entity("ArmRipper.Core.Models.RipperSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("SettingsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ripper_settings", (string)null); + }); + + modelBuilder.Entity("ArmRipper.Core.Models.SystemDrive", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DriveMode") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Firmware") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("JobIdCurrent") + .HasColumnType("INTEGER"); + + b.Property("JobIdPrevious") + .HasColumnType("INTEGER"); + + b.Property("Maker") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Mdisc") + .HasColumnType("INTEGER"); + + b.Property("Model") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Mount") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ReadBd") + .HasColumnType("INTEGER"); + + b.Property("ReadCd") + .HasColumnType("INTEGER"); + + b.Property("ReadDvd") + .HasColumnType("INTEGER"); + + b.Property("Serial") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SerialId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Stale") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("system_drives", (string)null); + }); + + modelBuilder.Entity("ArmRipper.Core.Models.SystemInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ArmVersion") + .HasColumnType("TEXT"); + + b.Property("CpuInfo") + .HasColumnType("TEXT"); + + b.Property("Hostname") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OsInfo") + .HasColumnType("TEXT"); + + b.Property("RamInfo") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("system_info", (string)null); + }); + + modelBuilder.Entity("ArmRipper.Core.Models.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AspectRatio") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("BaseName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Chapters") + .HasColumnType("INTEGER"); + + b.Property("ContentType") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DiscDbItemSlug") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("EpisodeNumber") + .HasColumnType("INTEGER"); + + b.Property("EpisodeTitle") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Error") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("FileSize") + .HasColumnType("INTEGER"); + + b.Property("Fps") + .HasColumnType("REAL"); + + b.Property("JobId") + .HasColumnType("INTEGER"); + + b.Property("Length") + .HasColumnType("INTEGER"); + + b.Property("MainFeature") + .HasColumnType("INTEGER"); + + b.Property("NewFileName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("OrigFileName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Process") + .HasColumnType("INTEGER"); + + b.Property("Ripped") + .HasColumnType("INTEGER"); + + b.Property("Source") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("SourceTitleId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("TrackNumber") + .HasMaxLength(4) + .HasColumnType("TEXT"); + + b.Property("TrackSeasonNumber") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("JobId"); + + b.ToTable("tracks", (string)null); + }); + + modelBuilder.Entity("ArmRipper.Core.Models.UiSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("IconStyle") + .HasColumnType("TEXT"); + + b.Property("RefreshRate") + .HasColumnType("INTEGER"); + + b.Property("Theme") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ui_settings", (string)null); + }); + + modelBuilder.Entity("ArmRipper.Core.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("ArmRipper.Core.Models.ConfigSnapshot", b => + { + b.HasOne("ArmRipper.Core.Models.Job", "Job") + .WithOne("Config") + .HasForeignKey("ArmRipper.Core.Models.ConfigSnapshot", "JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("ArmRipper.Core.Models.DiscTrack", b => + { + b.HasOne("ArmRipper.Core.Models.DiscMetadata", "DiscMetadata") + .WithMany("Tracks") + .HasForeignKey("DiscMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DiscMetadata"); + }); + + modelBuilder.Entity("ArmRipper.Core.Models.DiscTrackStream", b => + { + b.HasOne("ArmRipper.Core.Models.DiscTrack", "DiscTrack") + .WithMany("Streams") + .HasForeignKey("DiscTrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DiscTrack"); + }); + + modelBuilder.Entity("ArmRipper.Core.Models.Track", b => + { + b.HasOne("ArmRipper.Core.Models.Job", "Job") + .WithMany("Tracks") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("ArmRipper.Core.Models.DiscMetadata", b => + { + b.Navigation("Tracks"); + }); + + modelBuilder.Entity("ArmRipper.Core.Models.DiscTrack", b => + { + b.Navigation("Streams"); + }); + + modelBuilder.Entity("ArmRipper.Core.Models.Job", b => + { + b.Navigation("Config"); + + b.Navigation("Tracks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ArmRipper.Core/Migrations/20260717034034_AddSourceTitleId.cs b/src/ArmRipper.Core/Migrations/20260717034034_AddSourceTitleId.cs new file mode 100644 index 0000000..2842302 --- /dev/null +++ b/src/ArmRipper.Core/Migrations/20260717034034_AddSourceTitleId.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ArmRipper.Core.Migrations +{ + /// + public partial class AddSourceTitleId : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SourceTitleId", + table: "tracks", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "SourceTitleId", + table: "disc_tracks", + type: "INTEGER", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "SourceTitleId", + table: "tracks"); + + migrationBuilder.DropColumn( + name: "SourceTitleId", + table: "disc_tracks"); + } + } +} diff --git a/src/ArmRipper.Core/Migrations/ArmDbContextModelSnapshot.cs b/src/ArmRipper.Core/Migrations/ArmDbContextModelSnapshot.cs index 33654e3..ae27c25 100644 --- a/src/ArmRipper.Core/Migrations/ArmDbContextModelSnapshot.cs +++ b/src/ArmRipper.Core/Migrations/ArmDbContextModelSnapshot.cs @@ -321,6 +321,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(20) .HasColumnType("TEXT"); + b.Property("SourceTitleId") + .HasColumnType("INTEGER"); + b.Property("TrackNumber") .IsRequired() .HasMaxLength(4) @@ -773,6 +776,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(32) .HasColumnType("TEXT"); + b.Property("SourceTitleId") + .HasColumnType("INTEGER"); + b.Property("Status") .HasMaxLength(32) .HasColumnType("TEXT"); diff --git a/src/ArmRipper.Core/Models/DiscTrack.cs b/src/ArmRipper.Core/Models/DiscTrack.cs index 5ceee1a..86d1928 100644 --- a/src/ArmRipper.Core/Models/DiscTrack.cs +++ b/src/ArmRipper.Core/Models/DiscTrack.cs @@ -5,6 +5,7 @@ public class DiscTrack public int Id { get; init; } public int DiscMetadataId { get; set; } public string TrackNumber { get; set; } = ""; + public int? SourceTitleId { get; set; } public string? FileName { get; set; } public int? Length { get; set; } public int? Chapters { get; set; } diff --git a/src/ArmRipper.Core/Models/Track.cs b/src/ArmRipper.Core/Models/Track.cs index 4d20e17..a78c69f 100644 --- a/src/ArmRipper.Core/Models/Track.cs +++ b/src/ArmRipper.Core/Models/Track.cs @@ -36,6 +36,9 @@ public class Track /// Content type from TheDiscDb: "movie", "episode", "extra", "trailer", "commentary", etc. public string? ContentType { get; set; } + /// MakeMKV source title ID (field 24 of TINFO). Used in the mkv command instead of the sequential TrackNumber. + public int? SourceTitleId { get; set; } + /// Season number for this specific track (may differ from Job.SeasonNumber for multi-season discs). public int? TrackSeasonNumber { get; set; } diff --git a/src/ArmRipper.Core/Rip/ArmRipperService.cs b/src/ArmRipper.Core/Rip/ArmRipperService.cs index 3bf86b6..0f89027 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 ?? ""; - // Test mode rips a specific track — no need for --minlength filter - await makeMkv.RipTrackAsync(job, "0", makeMkvOutPath, mkvArgs, 0, MkvProgress(job, "Ripping track 0", ct), ct); + await makeMkv.RipTrackAsync(job, "0", makeMkvOutPath, mkvArgs, job.Config?.MinLength ?? settings.Value.MinLength, MkvProgress(job, "Ripping track 0", ct), ct); logger.LogInformation("Ripped track 0 in test mode"); return makeMkvOutPath; } @@ -414,9 +413,9 @@ await notifications.NotifyAsync(job, NotificationService.NotifyTitle, { var firstTrack = eligibleTracks.FirstOrDefault(); if (firstTrack is not null) - await makeMkv.RipTrackAsync(job, firstTrack.TrackNumber!, makeMkvOutPath, mkvArgs, 0, MkvProgress(job, "Ripping track 0", ct), ct); + await makeMkv.RipTrackAsync(job, firstTrack.TrackNumber!, makeMkvOutPath, mkvArgs, minLengthCfg, MkvProgress(job, "Ripping track 0", ct), ct); else - await makeMkv.RipTrackAsync(job, "0", makeMkvOutPath, mkvArgs, 0, MkvProgress(job, "Ripping track 0", ct), ct); + await makeMkv.RipTrackAsync(job, "0", makeMkvOutPath, mkvArgs, minLengthCfg, MkvProgress(job, "Ripping track 0", ct), ct); } else if (config?.MainFeature ?? settings.Value.MainFeature) { @@ -426,8 +425,7 @@ await notifications.NotifyAsync(job, NotificationService.NotifyTitle, var main = tracks.FirstOrDefault(t => t.MainFeature); if (main is not null) { - // We know the exact main feature track — no need for --minlength filter - await makeMkv.RipTrackAsync(job, main.TrackNumber!, makeMkvOutPath, mkvArgs, 0, MkvProgress(job, "Ripping main feature", ct), ct); + await makeMkv.RipTrackAsync(job, main.TrackNumber!, makeMkvOutPath, mkvArgs, minLengthCfg, MkvProgress(job, "Ripping main feature", ct), ct); ripCount = 1; } } diff --git a/src/ArmRipper.Core/Rip/MakeMkvModels.cs b/src/ArmRipper.Core/Rip/MakeMkvModels.cs index b4bfe97..d7a7312 100644 --- a/src/ArmRipper.Core/Rip/MakeMkvModels.cs +++ b/src/ArmRipper.Core/Rip/MakeMkvModels.cs @@ -12,6 +12,7 @@ public enum TrackId Chapters = 8, Duration = 9, Filesize = 11, + SourceTitleId = 24, Filename = 27 } diff --git a/src/ArmRipper.Core/Rip/MakeMkvService.cs b/src/ArmRipper.Core/Rip/MakeMkvService.cs index da5c4e2..b9911b4 100644 --- a/src/ArmRipper.Core/Rip/MakeMkvService.cs +++ b/src/ArmRipper.Core/Rip/MakeMkvService.cs @@ -149,6 +149,7 @@ public async Task> GetTrackInfoAsync(Job job, string baseName, int? var filesize = 0L; var streamType = 0; var resolution = ""; + var sourceTitleId = 0; var streamAccums = new Dictionary(); var lineCount = 0; @@ -215,8 +216,9 @@ public async Task> GetTrackInfoAsync(Job job, string baseName, int? case TInfo tinfo: if (currentTid >= 0 && tinfo.Tid != currentTid) - FinalizeTrack(job, baseName, tracks, discTracks, currentTid, ref seconds, ref aspect, ref fps, ref filename, ref chapters, ref filesize, ref streamType, ref resolution, streamAccums); + FinalizeTrack(job, baseName, tracks, discTracks, currentTid, ref seconds, ref aspect, ref fps, ref filename, ref chapters, ref filesize, ref streamType, ref resolution, ref sourceTitleId, streamAccums); currentTid = tinfo.Tid; + sourceTitleId = 0; switch ((TrackId)tinfo.Id) { case TrackId.Filename: @@ -231,6 +233,9 @@ public async Task> GetTrackInfoAsync(Job job, string baseName, int? case TrackId.Filesize: long.TryParse(tinfo.Value, out filesize); break; + case TrackId.SourceTitleId: + int.TryParse(tinfo.Value, out sourceTitleId); + break; } break; @@ -247,7 +252,7 @@ public async Task> GetTrackInfoAsync(Job job, string baseName, int? } if (currentTid >= 0) - FinalizeTrack(job, baseName, tracks, discTracks, currentTid, ref seconds, ref aspect, ref fps, ref filename, ref chapters, ref filesize, ref streamType, ref resolution, streamAccums); + FinalizeTrack(job, baseName, tracks, discTracks, currentTid, ref seconds, ref aspect, ref fps, ref filename, ref chapters, ref filesize, ref streamType, ref resolution, ref sourceTitleId, streamAccums); _logger.LogInformation("GetTrackInfo: {Lines} lines, {Tracks} tracks, lastTid={Tid}", lineCount, tracks.Count, currentTid); @@ -281,6 +286,7 @@ public async Task> GetTrackInfoWithCacheAsync(Job job, string baseNa { JobId = job.Id, TrackNumber = t.TrackNumber, + SourceTitleId = t.SourceTitleId, FileName = t.FileName, Length = t.Length, AspectRatio = t.AspectRatio, @@ -311,13 +317,14 @@ private static StreamAccum GetOrCreateAccum(Dictionary accums, private static void FinalizeTrack(Job job, string baseName, List tracks, List discTracks, int currentTid, ref int seconds, ref string aspect, ref double fps, ref string filename, ref int chapters, ref long filesize, ref int streamType, ref string resolution, - Dictionary streamAccums) + ref int sourceTitleId, Dictionary streamAccums) { - tracks.Add(CreateTrackObj(job, currentTid, baseName, seconds, aspect, fps, filename, chapters, filesize)); + tracks.Add(CreateTrackObj(job, currentTid, baseName, seconds, aspect, fps, filename, chapters, filesize, sourceTitleId)); var discTrack = new DiscTrack { TrackNumber = currentTid.ToString(), + SourceTitleId = sourceTitleId > 0 ? sourceTitleId : null, FileName = string.IsNullOrEmpty(filename) ? null : filename, Length = seconds > 0 ? seconds : null, Chapters = chapters > 0 ? chapters : null, @@ -350,7 +357,7 @@ private static void FinalizeTrack(Job job, string baseName, List tracks, discTracks.Add(discTrack); - seconds = 0; aspect = ""; fps = 0.0; filename = ""; chapters = 0; filesize = 0; streamType = 0; resolution = ""; + seconds = 0; aspect = ""; fps = 0.0; filename = ""; chapters = 0; filesize = 0; streamType = 0; resolution = ""; sourceTitleId = 0; streamAccums.Clear(); } @@ -421,13 +428,15 @@ public async Task RipTrackAsync(Job job, string trackNumber, string outputPath, try { - var args = minLength > 0 - ? $"--robot --messages=-stdout --progress=-stdout mkv --minlength={minLength} dev:{job.DevPath} {trackNumber} \\\"{outputPath}\\\"" - : $"--robot --messages=-stdout --progress=-stdout mkv dev:{job.DevPath} {trackNumber} \\\"{outputPath}\\\""; + // Use SourceTitleId (field 24 from MakeMKV info) when available — the mkv command + // expects the source title ID, not the sequential TINFO index (TrackNumber). + var titleId = job.Tracks + .Where(t => t.TrackNumber == trackNumber) + .Select(t => t.SourceTitleId?.ToString()) + .FirstOrDefault(); + var args = $"--robot --messages=-stdout --progress=-stdout mkv --minlength={minLength} dev:{job.DevPath} {titleId ?? trackNumber} \\\"{outputPath}\\\""; if (!string.IsNullOrEmpty(mkvArgs)) - args = minLength > 0 - ? $"--robot --messages=-stdout --progress=-stdout mkv {mkvArgs} --minlength={minLength} dev:{job.DevPath} {trackNumber} \\\"{outputPath}\\\"" - : $"--robot --messages=-stdout --progress=-stdout mkv {mkvArgs} dev:{job.DevPath} {trackNumber} \\\"{outputPath}\\\""; + args = $"--robot --messages=-stdout --progress=-stdout mkv {mkvArgs} --minlength={minLength} dev:{job.DevPath} {titleId ?? trackNumber} \\\"{outputPath}\\\""; await foreach (var line in _runner.RunStreamingAsync("makemkvcon", args, ct: ct)) ParseAndReportProgress(line, progress); @@ -559,12 +568,13 @@ private void ParseAndReportProgress(string line, IProgress? progress) } } - private static Track CreateTrackObj(Job job, int tid, string baseName, int seconds, string aspect, double fps, string filename, int chapters, long filesize) + private static Track CreateTrackObj(Job job, int tid, string baseName, int seconds, string aspect, double fps, string filename, int chapters, long filesize, int sourceTitleId = 0) { return new Track { JobId = job.Id, TrackNumber = tid.ToString(), + SourceTitleId = sourceTitleId > 0 ? sourceTitleId : null, Length = seconds, AspectRatio = string.IsNullOrEmpty(aspect) ? null : aspect, Fps = fps > 0 ? fps : null, diff --git a/src/ArmRipper.WebUi/Hubs/NotificationHub.cs b/src/ArmRipper.WebUi/Hubs/NotificationHub.cs index e889600..b1bba94 100644 --- a/src/ArmRipper.WebUi/Hubs/NotificationHub.cs +++ b/src/ArmRipper.WebUi/Hubs/NotificationHub.cs @@ -9,6 +9,15 @@ public class NotificationHub(IOptions settings) : Hub { private string LogPath => ArmPaths.GetLogPath(settings.Value); + /// + /// Streams log file content over SignalR. Yields in ~16 KB chunks so large + /// files don't exceed the default SignalR message size limit (32 KB). + /// + /// Log file name (e.g. "arm.log", "42.log"). + /// + /// "full" — send the entire file from the start, then stream new lines. + /// "tail" — send only the last ~4 KB, then stream new lines. + /// public async IAsyncEnumerable StreamLog( string fileName, string mode, @@ -22,21 +31,29 @@ public async IAsyncEnumerable StreamLog( if (!System.IO.File.Exists(fullPath)) yield break; - long lastPos = 0; + // Determine starting position based on mode + var fi = new FileInfo(fullPath); + long lastPos = mode == "tail" + ? Math.Max(0, fi.Length - 4096) // last ~4 KB for "tail" + : 0; // entire file for "full" + while (!cancellationToken.IsCancellationRequested) { - var fi = new FileInfo(fullPath); + fi.Refresh(); if (fi.Exists && fi.Length > lastPos) { await using var fs = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); fs.Seek(lastPos, SeekOrigin.Begin); using var reader = new StreamReader(fs); - var newText = await reader.ReadToEndAsync(cancellationToken); - if (newText.Length > 0) + // Yield in ~16 KB chunks to stay well within SignalR's 32 KB limit + var buffer = new char[16 * 1024]; + int bytesRead; + while ((bytesRead = await reader.ReadAsync(buffer, 0, buffer.Length)) > 0) { - yield return newText; + yield return new string(buffer, 0, bytesRead); } + lastPos = fi.Length; } diff --git a/src/ArmRipper.WebUi/Views/Logs/Viewer.cshtml b/src/ArmRipper.WebUi/Views/Logs/Viewer.cshtml index c761f1a..aaa654a 100644 --- a/src/ArmRipper.WebUi/Views/Logs/Viewer.cshtml +++ b/src/ArmRipper.WebUi/Views/Logs/Viewer.cshtml @@ -15,10 +15,8 @@ @section Scripts { } From 4a00e1d7c314a10dc2f9533aa56b665a71ea51bd Mon Sep 17 00:00:00 2001 From: ARM Dotnet Port Date: Thu, 16 Jul 2026 23:05:17 -0500 Subject: [PATCH 3/9] Pass SourceTitleId as parameter to RipTrackAsync instead of looking up from job.Tracks --- src/ArmRipper.Core/Rip/ArmRipperService.cs | 10 +++++----- src/ArmRipper.Core/Rip/IMakeMkvService.cs | 2 +- src/ArmRipper.Core/Rip/MakeMkvService.cs | 21 ++++++++++----------- 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/src/ArmRipper.Core/Rip/ArmRipperService.cs b/src/ArmRipper.Core/Rip/ArmRipperService.cs index 0f89027..f414b50 100644 --- a/src/ArmRipper.Core/Rip/ArmRipperService.cs +++ b/src/ArmRipper.Core/Rip/ArmRipperService.cs @@ -198,7 +198,7 @@ public async Task RipVisualMediaAsync(Job job, string logFile, bool hasD Directory.CreateDirectory(makeMkvOutPath); var mkvArgs = job.Config?.MkvArgs ?? settings.Value.MkvArgs ?? ""; - await makeMkv.RipTrackAsync(job, "0", makeMkvOutPath, mkvArgs, job.Config?.MinLength ?? settings.Value.MinLength, MkvProgress(job, "Ripping track 0", ct), ct); + await makeMkv.RipTrackAsync(job, "0", makeMkvOutPath, mkvArgs, job.Config?.MinLength ?? settings.Value.MinLength, MkvProgress(job, "Ripping track 0", ct), null, ct); logger.LogInformation("Ripped track 0 in test mode"); return makeMkvOutPath; } @@ -413,9 +413,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, minLengthCfg, MkvProgress(job, "Ripping track 0", ct), firstTrack.SourceTitleId?.ToString(), ct); else - await makeMkv.RipTrackAsync(job, "0", makeMkvOutPath, mkvArgs, minLengthCfg, MkvProgress(job, "Ripping track 0", ct), ct); + await makeMkv.RipTrackAsync(job, "0", makeMkvOutPath, mkvArgs, minLengthCfg, MkvProgress(job, "Ripping track 0", ct), null, ct); } else if (config?.MainFeature ?? settings.Value.MainFeature) { @@ -425,7 +425,7 @@ 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); + await makeMkv.RipTrackAsync(job, main.TrackNumber!, makeMkvOutPath, mkvArgs, minLengthCfg, MkvProgress(job, "Ripping main feature", ct), main.SourceTitleId?.ToString(), ct); ripCount = 1; } } @@ -447,7 +447,7 @@ await notifications.NotifyAsync(job, NotificationService.NotifyTitle, // configured minLength — we already decided to rip them, so tell MakeMKV // not to filter them out by passing minLength=0. var trackMinLength = !string.IsNullOrEmpty(track.EpisodeTitle) ? 0 : minLengthCfg; - await makeMkv.RipTrackAsync(job, track.TrackNumber!, makeMkvOutPath, mkvArgs, trackMinLength, MkvProgress(job, $"Ripping track {trackNum} of {eligibleTracks.Count}", ct), ct); + await makeMkv.RipTrackAsync(job, track.TrackNumber!, makeMkvOutPath, mkvArgs, trackMinLength, MkvProgress(job, $"Ripping track {trackNum} of {eligibleTracks.Count}", ct), track.SourceTitleId?.ToString(), ct); ripCount++; } } diff --git a/src/ArmRipper.Core/Rip/IMakeMkvService.cs b/src/ArmRipper.Core/Rip/IMakeMkvService.cs index 5e4bb2d..36ef2dc 100644 --- a/src/ArmRipper.Core/Rip/IMakeMkvService.cs +++ b/src/ArmRipper.Core/Rip/IMakeMkvService.cs @@ -12,6 +12,6 @@ public interface IMakeMkvService /// (used when DiscDb is enabled so short extras can be discovered and promoted). Task> GetTrackInfoAsync(Job job, string baseName, int? infoMinLength = null, CancellationToken ct = default); Task> GetTrackInfoWithCacheAsync(Job job, string baseName, int? infoMinLength = null, CancellationToken ct = default); - Task RipTrackAsync(Job job, string trackNumber, string outputPath, string mkvArgs, int minLength, IProgress? progress = null, CancellationToken ct = default); + Task RipTrackAsync(Job job, string trackNumber, string outputPath, string mkvArgs, int minLength, IProgress? progress = null, string? sourceTitleId = null, CancellationToken ct = default); Task RipAllTitlesAsync(Job job, string outputPath, string mkvArgs, int minLength, IProgress? progress = null, CancellationToken ct = default); } diff --git a/src/ArmRipper.Core/Rip/MakeMkvService.cs b/src/ArmRipper.Core/Rip/MakeMkvService.cs index b9911b4..164780b 100644 --- a/src/ArmRipper.Core/Rip/MakeMkvService.cs +++ b/src/ArmRipper.Core/Rip/MakeMkvService.cs @@ -414,12 +414,12 @@ private sealed record StreamAccum public bool Forced { get; set; } } - public async Task RipTrackAsync(Job job, string trackNumber, string outputPath, string mkvArgs, int minLength, IProgress? progress = null, CancellationToken ct = default) + public async Task RipTrackAsync(Job job, string trackNumber, string outputPath, string mkvArgs, int minLength, IProgress? progress = null, string? sourceTitleId = null, CancellationToken ct = default) { - // Estimate expected file size from track info for progress monitoring - var expectedSize = job.Tracks - .Where(t => t.TrackNumber == trackNumber) - .Sum(t => t.FileSize ?? 0); + // Estimate expected file size from the track for progress monitoring. + // job.Tracks may not be populated at this point (tracks are returned as a separate list from + // GetTrackInfoWithCacheAsync), so we check safely with ?.FirstOrDefault(). + var expectedSize = job.Tracks?.FirstOrDefault(t => t.TrackNumber == trackNumber)?.FileSize ?? 0; var monitorCts = CancellationTokenSource.CreateLinkedTokenSource(ct); var monitorTask = expectedSize > 0 && progress is not null @@ -430,13 +430,12 @@ public async Task RipTrackAsync(Job job, string trackNumber, string outputPath, { // Use SourceTitleId (field 24 from MakeMKV info) when available — the mkv command // expects the source title ID, not the sequential TINFO index (TrackNumber). - var titleId = job.Tracks - .Where(t => t.TrackNumber == trackNumber) - .Select(t => t.SourceTitleId?.ToString()) - .FirstOrDefault(); - var args = $"--robot --messages=-stdout --progress=-stdout mkv --minlength={minLength} dev:{job.DevPath} {titleId ?? trackNumber} \\\"{outputPath}\\\""; + // The caller resolves this from the in-memory tracks list since job.Tracks is not + // guaranteed to be populated at this point. + var titleId = sourceTitleId ?? trackNumber; + var args = $"--robot --messages=-stdout --progress=-stdout mkv --minlength={minLength} dev:{job.DevPath} {titleId} \"{outputPath}\""; if (!string.IsNullOrEmpty(mkvArgs)) - args = $"--robot --messages=-stdout --progress=-stdout mkv {mkvArgs} --minlength={minLength} dev:{job.DevPath} {titleId ?? trackNumber} \\\"{outputPath}\\\""; + args = $"--robot --messages=-stdout --progress=-stdout mkv {mkvArgs} --minlength={minLength} dev:{job.DevPath} {titleId} \"{outputPath}\""; await foreach (var line in _runner.RunStreamingAsync("makemkvcon", args, ct: ct)) ParseAndReportProgress(line, progress); From 83bdf638386345d334905af6cfa30408d9581be2 Mon Sep 17 00:00:00 2001 From: ARM Dotnet Port Date: Fri, 17 Jul 2026 00:05:25 -0500 Subject: [PATCH 4/9] Fix single-track rip: pass minLength=0 when track already identified MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the app already knows the exact track to rip (TestMode or MainFeature modes), --minlength is counterproductive — it causes MakeMKV to filter out the requested title if it's shorter than the threshold, resulting in exit code 12 and zero output. The info scan already handles min-length filtering. Also reverted unnecessary SourceTitleId parameter on RipTrackAsync — the sequential TINFO index (TrackNumber) is the correct argument for the mkv command, not the DVD VTS source title ID (field 24). --- src/ArmRipper.Core/Rip/ArmRipperService.cs | 12 +++++++----- src/ArmRipper.Core/Rip/IMakeMkvService.cs | 2 +- src/ArmRipper.Core/Rip/MakeMkvService.cs | 13 +++++-------- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/ArmRipper.Core/Rip/ArmRipperService.cs b/src/ArmRipper.Core/Rip/ArmRipperService.cs index f414b50..f2fbca6 100644 --- a/src/ArmRipper.Core/Rip/ArmRipperService.cs +++ b/src/ArmRipper.Core/Rip/ArmRipperService.cs @@ -198,7 +198,7 @@ public async Task RipVisualMediaAsync(Job job, string logFile, bool hasD Directory.CreateDirectory(makeMkvOutPath); var mkvArgs = job.Config?.MkvArgs ?? settings.Value.MkvArgs ?? ""; - await makeMkv.RipTrackAsync(job, "0", makeMkvOutPath, mkvArgs, job.Config?.MinLength ?? settings.Value.MinLength, MkvProgress(job, "Ripping track 0", ct), null, 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; } @@ -413,9 +413,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), firstTrack.SourceTitleId?.ToString(), 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), null, ct); + await makeMkv.RipTrackAsync(job, "0", makeMkvOutPath, mkvArgs, 0, MkvProgress(job, "Ripping track 0", ct), ct); } else if (config?.MainFeature ?? settings.Value.MainFeature) { @@ -425,7 +425,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), main.SourceTitleId?.ToString(), 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; } } @@ -447,7 +449,7 @@ await notifications.NotifyAsync(job, NotificationService.NotifyTitle, // configured minLength — we already decided to rip them, so tell MakeMKV // not to filter them out by passing minLength=0. var trackMinLength = !string.IsNullOrEmpty(track.EpisodeTitle) ? 0 : minLengthCfg; - await makeMkv.RipTrackAsync(job, track.TrackNumber!, makeMkvOutPath, mkvArgs, trackMinLength, MkvProgress(job, $"Ripping track {trackNum} of {eligibleTracks.Count}", ct), track.SourceTitleId?.ToString(), ct); + await makeMkv.RipTrackAsync(job, track.TrackNumber!, makeMkvOutPath, mkvArgs, trackMinLength, MkvProgress(job, $"Ripping track {trackNum} of {eligibleTracks.Count}", ct), ct); ripCount++; } } diff --git a/src/ArmRipper.Core/Rip/IMakeMkvService.cs b/src/ArmRipper.Core/Rip/IMakeMkvService.cs index 36ef2dc..5e4bb2d 100644 --- a/src/ArmRipper.Core/Rip/IMakeMkvService.cs +++ b/src/ArmRipper.Core/Rip/IMakeMkvService.cs @@ -12,6 +12,6 @@ public interface IMakeMkvService /// (used when DiscDb is enabled so short extras can be discovered and promoted). Task> GetTrackInfoAsync(Job job, string baseName, int? infoMinLength = null, CancellationToken ct = default); Task> GetTrackInfoWithCacheAsync(Job job, string baseName, int? infoMinLength = null, CancellationToken ct = default); - Task RipTrackAsync(Job job, string trackNumber, string outputPath, string mkvArgs, int minLength, IProgress? progress = null, string? sourceTitleId = null, CancellationToken ct = default); + Task RipTrackAsync(Job job, string trackNumber, string outputPath, string mkvArgs, int minLength, IProgress? progress = null, CancellationToken ct = default); Task RipAllTitlesAsync(Job job, string outputPath, string mkvArgs, int minLength, IProgress? progress = null, CancellationToken ct = default); } diff --git a/src/ArmRipper.Core/Rip/MakeMkvService.cs b/src/ArmRipper.Core/Rip/MakeMkvService.cs index 164780b..9bc9931 100644 --- a/src/ArmRipper.Core/Rip/MakeMkvService.cs +++ b/src/ArmRipper.Core/Rip/MakeMkvService.cs @@ -414,7 +414,7 @@ private sealed record StreamAccum public bool Forced { get; set; } } - public async Task RipTrackAsync(Job job, string trackNumber, string outputPath, string mkvArgs, int minLength, IProgress? progress = null, string? sourceTitleId = null, CancellationToken ct = default) + 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 the track for progress monitoring. // job.Tracks may not be populated at this point (tracks are returned as a separate list from @@ -428,14 +428,11 @@ public async Task RipTrackAsync(Job job, string trackNumber, string outputPath, try { - // Use SourceTitleId (field 24 from MakeMKV info) when available — the mkv command - // expects the source title ID, not the sequential TINFO index (TrackNumber). - // The caller resolves this from the in-memory tracks list since job.Tracks is not - // guaranteed to be populated at this point. - var titleId = sourceTitleId ?? trackNumber; - var args = $"--robot --messages=-stdout --progress=-stdout mkv --minlength={minLength} dev:{job.DevPath} {titleId} \"{outputPath}\""; + // trackNumber is the sequential TINFO index from info output, which maps to + // the 0-based index of valid titles after --minlength filtering. + 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} {titleId} \"{outputPath}\""; + args = $"--robot --messages=-stdout --progress=-stdout mkv {mkvArgs} --minlength={minLength} dev:{job.DevPath} {trackNumber} \"{outputPath}\""; await foreach (var line in _runner.RunStreamingAsync("makemkvcon", args, ct: ct)) ParseAndReportProgress(line, progress); From 42ecbaf8ff2fc82934d4ae8c89fc97c001974891 Mon Sep 17 00:00:00 2001 From: ARM Dotnet Port Date: Fri, 17 Jul 2026 00:12:51 -0500 Subject: [PATCH 5/9] Clean up SourceTitleId cruft and revert unrelated changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Removed AddSourceTitleId migration (DB column not needed) - Removed SourceTitleId property from Track, DiscTrack models - Removed SourceTitleId enum value from MakeMkvModels - Removed SourceTitleId parsing from MakeMkvService (field 24 is the DVD VTS title ID, not what makemkvcon mkv expects — it uses sequential index) - Reverted NotificationHub.cs and Viewer.cshtml changes (log streaming improvements unrelated to this branch) --- ...0260717034034_AddSourceTitleId.Designer.cs | 912 ------------------ .../20260717034034_AddSourceTitleId.cs | 38 - .../Migrations/ArmDbContextModelSnapshot.cs | 6 - src/ArmRipper.Core/Models/DiscTrack.cs | 1 - src/ArmRipper.Core/Models/Track.cs | 3 - src/ArmRipper.Core/Rip/MakeMkvModels.cs | 1 - src/ArmRipper.Core/Rip/MakeMkvService.cs | 20 +- src/ArmRipper.WebUi/Hubs/NotificationHub.cs | 27 +- src/ArmRipper.WebUi/Views/Logs/Viewer.cshtml | 70 +- 9 files changed, 34 insertions(+), 1044 deletions(-) delete mode 100644 src/ArmRipper.Core/Migrations/20260717034034_AddSourceTitleId.Designer.cs delete mode 100644 src/ArmRipper.Core/Migrations/20260717034034_AddSourceTitleId.cs diff --git a/src/ArmRipper.Core/Migrations/20260717034034_AddSourceTitleId.Designer.cs b/src/ArmRipper.Core/Migrations/20260717034034_AddSourceTitleId.Designer.cs deleted file mode 100644 index 15069ab..0000000 --- a/src/ArmRipper.Core/Migrations/20260717034034_AddSourceTitleId.Designer.cs +++ /dev/null @@ -1,912 +0,0 @@ -// -using System; -using ArmRipper.Core.Infrastructure.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace ArmRipper.Core.Migrations -{ - [DbContext(typeof(ArmDbContext))] - [Migration("20260717034034_AddSourceTitleId")] - partial class AddSourceTitleId - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.8"); - - modelBuilder.Entity("ArmRipper.Core.Models.ConfigSnapshot", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("AllowDuplicates") - .HasColumnType("INTEGER"); - - b.Property("Apprise") - .HasColumnType("TEXT"); - - b.Property("ArmApiKey") - .HasColumnType("TEXT"); - - b.Property("AutoEject") - .HasColumnType("INTEGER"); - - b.Property("BashScript") - .HasColumnType("TEXT"); - - b.Property("CompletedPath") - .HasColumnType("TEXT"); - - b.Property("DbFile") - .HasColumnType("TEXT"); - - b.Property("DelRawFiles") - .HasColumnType("INTEGER"); - - b.Property("DestExt") - .HasColumnType("TEXT"); - - b.Property("DiscDbApiBaseUrl") - .HasColumnType("TEXT"); - - b.Property("DiscDbEnabled") - .HasColumnType("INTEGER"); - - b.Property("DiscDbMinConfidence") - .HasColumnType("REAL"); - - b.Property("DiscDbRequireConfirmation") - .HasColumnType("INTEGER"); - - b.Property("EmbyApiKey") - .HasColumnType("TEXT"); - - b.Property("EmbyPort") - .HasColumnType("INTEGER"); - - b.Property("EmbyRefresh") - .HasColumnType("INTEGER"); - - b.Property("EmbyServer") - .HasColumnType("TEXT"); - - b.Property("ExtrasSub") - .HasColumnType("TEXT"); - - b.Property("FfmpegCli") - .HasColumnType("TEXT"); - - b.Property("FfmpegPostFileArgs") - .HasColumnType("TEXT"); - - b.Property("FfmpegPreFileArgs") - .HasColumnType("TEXT"); - - b.Property("GetAudioTitle") - .HasColumnType("TEXT"); - - b.Property("GetVideoTitle") - .HasColumnType("INTEGER"); - - b.Property("HbArgsBd") - .HasColumnType("TEXT"); - - b.Property("HbArgsDvd") - .HasColumnType("TEXT"); - - b.Property("HbPresetBd") - .HasColumnType("TEXT"); - - b.Property("HbPresetDvd") - .HasColumnType("TEXT"); - - b.Property("IftttKey") - .HasColumnType("TEXT"); - - b.Property("InstallPath") - .HasColumnType("TEXT"); - - b.Property("JobId") - .HasColumnType("INTEGER"); - - b.Property("JsonUrl") - .HasColumnType("TEXT"); - - b.Property("LogPath") - .HasColumnType("TEXT"); - - b.Property("MainFeature") - .HasColumnType("INTEGER"); - - b.Property("ManualWait") - .HasColumnType("INTEGER"); - - b.Property("ManualWaitTime") - .HasColumnType("INTEGER"); - - b.Property("MaxConcurrentMakemkvInfo") - .HasColumnType("INTEGER"); - - b.Property("MaxConcurrentTranscodes") - .HasColumnType("INTEGER"); - - b.Property("MaxLength") - .HasColumnType("INTEGER"); - - b.Property("MetadataProvider") - .HasColumnType("TEXT"); - - b.Property("MinLength") - .HasColumnType("INTEGER"); - - b.Property("MkvArgs") - .HasColumnType("TEXT"); - - b.Property("NotifyRip") - .HasColumnType("INTEGER"); - - b.Property("NotifyTranscode") - .HasColumnType("INTEGER"); - - b.Property("OmdbApiKey") - .HasColumnType("TEXT"); - - b.Property("PbKey") - .HasColumnType("TEXT"); - - b.Property("PoUserKey") - .HasColumnType("TEXT"); - - b.Property("PreferWidescreen") - .HasColumnType("INTEGER"); - - b.Property("Prevent99") - .HasColumnType("INTEGER"); - - b.Property("RawPath") - .HasColumnType("TEXT"); - - b.Property("RipMethod") - .HasColumnType("TEXT"); - - b.Property("SkipTranscode") - .HasColumnType("INTEGER"); - - b.Property("TmdbApiKey") - .HasColumnType("TEXT"); - - b.Property("TranscodePath") - .HasColumnType("TEXT"); - - b.Property("UiBaseUrl") - .HasColumnType("TEXT"); - - b.Property("UseFfmpeg") - .HasColumnType("INTEGER"); - - b.Property("WebServerIp") - .HasColumnType("TEXT"); - - b.Property("WebServerPort") - .HasColumnType("INTEGER"); - - b.HasKey("Id"); - - b.HasIndex("JobId") - .IsUnique(); - - b.ToTable("config", (string)null); - }); - - modelBuilder.Entity("ArmRipper.Core.Models.DiscDbMapping", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("ContentHash") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("ImageUrl") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("LastUsedAt") - .HasColumnType("TEXT"); - - b.Property("MediaSlug") - .HasMaxLength(128) - .HasColumnType("TEXT"); - - b.Property("MediaTitle") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("MediaType") - .HasMaxLength(20) - .HasColumnType("TEXT"); - - b.Property("MediaYear") - .HasMaxLength(4) - .HasColumnType("TEXT"); - - b.Property("TrackMappingsJson") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("ContentHash") - .IsUnique(); - - b.ToTable("discdb_mappings", (string)null); - }); - - modelBuilder.Entity("ArmRipper.Core.Models.DiscMetadata", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("DiscType") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("TEXT"); - - b.Property("Fingerprint") - .IsRequired() - .HasMaxLength(128) - .HasColumnType("TEXT"); - - b.Property("LastUsedAt") - .HasColumnType("TEXT"); - - b.Property("SectorCount") - .HasColumnType("INTEGER"); - - b.Property("VolumeLabel") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("Fingerprint") - .IsUnique(); - - b.ToTable("disc_metadata", (string)null); - }); - - modelBuilder.Entity("ArmRipper.Core.Models.DiscTrack", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("AspectRatio") - .HasMaxLength(20) - .HasColumnType("TEXT"); - - b.Property("Chapters") - .HasColumnType("INTEGER"); - - b.Property("DiscMetadataId") - .HasColumnType("INTEGER"); - - b.Property("FileName") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("FileSize") - .HasColumnType("INTEGER"); - - b.Property("Fps") - .HasColumnType("REAL"); - - b.Property("Length") - .HasColumnType("INTEGER"); - - b.Property("Resolution") - .HasMaxLength(20) - .HasColumnType("TEXT"); - - b.Property("SourceTitleId") - .HasColumnType("INTEGER"); - - b.Property("TrackNumber") - .IsRequired() - .HasMaxLength(4) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("DiscMetadataId"); - - b.ToTable("disc_tracks", (string)null); - }); - - modelBuilder.Entity("ArmRipper.Core.Models.DiscTrackStream", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("ChannelCount") - .HasColumnType("INTEGER"); - - b.Property("Codec") - .HasMaxLength(50) - .HasColumnType("TEXT"); - - b.Property("DiscTrackId") - .HasColumnType("INTEGER"); - - b.Property("Forced") - .HasColumnType("INTEGER"); - - b.Property("LanguageCode") - .HasMaxLength(10) - .HasColumnType("TEXT"); - - b.Property("StreamIndex") - .HasColumnType("INTEGER"); - - b.Property("StreamType") - .IsRequired() - .HasMaxLength(10) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("DiscTrackId"); - - b.ToTable("disc_track_streams", (string)null); - }); - - modelBuilder.Entity("ArmRipper.Core.Models.Job", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("ArmVersion") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("TEXT"); - - b.Property("CompletedStages") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("CrcId") - .HasMaxLength(63) - .HasColumnType("TEXT"); - - b.Property("DevPath") - .HasMaxLength(15) - .HasColumnType("TEXT"); - - b.Property("DiscDbHash") - .HasMaxLength(64) - .HasColumnType("TEXT"); - - b.Property("DiscFingerprint") - .HasMaxLength(128) - .HasColumnType("TEXT"); - - b.Property("DiscType") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("TEXT"); - - b.Property("Ejected") - .HasColumnType("INTEGER"); - - b.Property("Errors") - .HasColumnType("TEXT"); - - b.Property("HasNiceTitle") - .HasColumnType("INTEGER"); - - b.Property("HasTrack99") - .HasColumnType("INTEGER"); - - b.Property("ImdbId") - .HasMaxLength(15) - .HasColumnType("TEXT"); - - b.Property("ImdbIdAuto") - .HasMaxLength(15) - .HasColumnType("TEXT"); - - b.Property("ImdbIdManual") - .HasMaxLength(15) - .HasColumnType("TEXT"); - - b.Property("IsIso") - .HasColumnType("INTEGER"); - - b.Property("JobLength") - .HasMaxLength(12) - .HasColumnType("TEXT"); - - b.Property("Label") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("LogFile") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("ManualMode") - .HasColumnType("INTEGER"); - - b.Property("ManualStart") - .HasColumnType("INTEGER"); - - b.Property("ManualWaitResume") - .HasColumnType("INTEGER"); - - b.Property("MountPoint") - .HasMaxLength(20) - .HasColumnType("TEXT"); - - b.Property("NoOfTitles") - .HasColumnType("INTEGER"); - - b.Property("OriginalJobId") - .HasColumnType("INTEGER"); - - b.Property("OvidApiResponse") - .HasColumnType("TEXT"); - - b.Property("OvidFingerprint") - .HasColumnType("TEXT"); - - b.Property("OvidSubmitted") - .HasColumnType("INTEGER"); - - b.Property("Path") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("Pid") - .HasColumnType("INTEGER"); - - b.Property("PidHash") - .HasColumnType("TEXT"); - - b.Property("PosterUrl") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("PosterUrlAuto") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("PosterUrlManual") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("SeasonNumber") - .HasColumnType("INTEGER"); - - b.Property("SeriesTmdbId") - .HasColumnType("INTEGER"); - - b.Property("Stage") - .HasMaxLength(32) - .HasColumnType("TEXT"); - - b.Property("StageErrors") - .HasColumnType("TEXT"); - - b.Property("StageStartTime") - .HasColumnType("TEXT"); - - b.Property("StartTime") - .HasColumnType("TEXT"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - b.Property("StopTime") - .HasColumnType("TEXT"); - - b.Property("Title") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("TitleAuto") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("TitleManual") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("VideoType") - .HasMaxLength(20) - .HasColumnType("TEXT"); - - b.Property("VideoTypeAuto") - .HasMaxLength(20) - .HasColumnType("TEXT"); - - b.Property("VideoTypeManual") - .HasMaxLength(20) - .HasColumnType("TEXT"); - - b.Property("Warnings") - .HasColumnType("TEXT"); - - b.Property("Year") - .HasMaxLength(4) - .HasColumnType("TEXT"); - - b.Property("YearAuto") - .HasMaxLength(4) - .HasColumnType("TEXT"); - - b.Property("YearManual") - .HasMaxLength(4) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.ToTable("jobs", (string)null); - }); - - modelBuilder.Entity("ArmRipper.Core.Models.Notification", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("EventType") - .HasColumnType("TEXT"); - - b.Property("Message") - .HasColumnType("TEXT"); - - b.Property("Read") - .HasColumnType("INTEGER"); - - b.Property("Timestamp") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.ToTable("notifications", (string)null); - }); - - modelBuilder.Entity("ArmRipper.Core.Models.RipperSettings", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("SettingsJson") - .IsRequired() - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.ToTable("ripper_settings", (string)null); - }); - - modelBuilder.Entity("ArmRipper.Core.Models.SystemDrive", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("Description") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("DriveMode") - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.Property("Firmware") - .HasMaxLength(10) - .HasColumnType("TEXT"); - - b.Property("JobIdCurrent") - .HasColumnType("INTEGER"); - - b.Property("JobIdPrevious") - .HasColumnType("INTEGER"); - - b.Property("Maker") - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.Property("Mdisc") - .HasColumnType("INTEGER"); - - b.Property("Model") - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.Property("Mount") - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.Property("Name") - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.Property("ReadBd") - .HasColumnType("INTEGER"); - - b.Property("ReadCd") - .HasColumnType("INTEGER"); - - b.Property("ReadDvd") - .HasColumnType("INTEGER"); - - b.Property("Serial") - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.Property("SerialId") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.Property("Stale") - .HasColumnType("INTEGER"); - - b.HasKey("Id"); - - b.ToTable("system_drives", (string)null); - }); - - modelBuilder.Entity("ArmRipper.Core.Models.SystemInfo", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("ArmVersion") - .HasColumnType("TEXT"); - - b.Property("CpuInfo") - .HasColumnType("TEXT"); - - b.Property("Hostname") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("OsInfo") - .HasColumnType("TEXT"); - - b.Property("RamInfo") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.ToTable("system_info", (string)null); - }); - - modelBuilder.Entity("ArmRipper.Core.Models.Track", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("AspectRatio") - .HasMaxLength(20) - .HasColumnType("TEXT"); - - b.Property("BaseName") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("Chapters") - .HasColumnType("INTEGER"); - - b.Property("ContentType") - .HasMaxLength(32) - .HasColumnType("TEXT"); - - b.Property("DiscDbItemSlug") - .HasMaxLength(128) - .HasColumnType("TEXT"); - - b.Property("EpisodeNumber") - .HasColumnType("INTEGER"); - - b.Property("EpisodeTitle") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("Error") - .HasColumnType("TEXT"); - - b.Property("FileName") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("FileSize") - .HasColumnType("INTEGER"); - - b.Property("Fps") - .HasColumnType("REAL"); - - b.Property("JobId") - .HasColumnType("INTEGER"); - - b.Property("Length") - .HasColumnType("INTEGER"); - - b.Property("MainFeature") - .HasColumnType("INTEGER"); - - b.Property("NewFileName") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("OrigFileName") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("Process") - .HasColumnType("INTEGER"); - - b.Property("Ripped") - .HasColumnType("INTEGER"); - - b.Property("Source") - .HasMaxLength(32) - .HasColumnType("TEXT"); - - b.Property("SourceTitleId") - .HasColumnType("INTEGER"); - - b.Property("Status") - .HasMaxLength(32) - .HasColumnType("TEXT"); - - b.Property("TrackNumber") - .HasMaxLength(4) - .HasColumnType("TEXT"); - - b.Property("TrackSeasonNumber") - .HasColumnType("INTEGER"); - - b.HasKey("Id"); - - b.HasIndex("JobId"); - - b.ToTable("tracks", (string)null); - }); - - modelBuilder.Entity("ArmRipper.Core.Models.UiSettings", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("IconStyle") - .HasColumnType("TEXT"); - - b.Property("RefreshRate") - .HasColumnType("INTEGER"); - - b.Property("Theme") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.ToTable("ui_settings", (string)null); - }); - - modelBuilder.Entity("ArmRipper.Core.Models.User", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("IsAdmin") - .HasColumnType("INTEGER"); - - b.Property("PasswordHash") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("Username") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("Username") - .IsUnique(); - - b.ToTable("users", (string)null); - }); - - modelBuilder.Entity("ArmRipper.Core.Models.ConfigSnapshot", b => - { - b.HasOne("ArmRipper.Core.Models.Job", "Job") - .WithOne("Config") - .HasForeignKey("ArmRipper.Core.Models.ConfigSnapshot", "JobId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Job"); - }); - - modelBuilder.Entity("ArmRipper.Core.Models.DiscTrack", b => - { - b.HasOne("ArmRipper.Core.Models.DiscMetadata", "DiscMetadata") - .WithMany("Tracks") - .HasForeignKey("DiscMetadataId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("DiscMetadata"); - }); - - modelBuilder.Entity("ArmRipper.Core.Models.DiscTrackStream", b => - { - b.HasOne("ArmRipper.Core.Models.DiscTrack", "DiscTrack") - .WithMany("Streams") - .HasForeignKey("DiscTrackId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("DiscTrack"); - }); - - modelBuilder.Entity("ArmRipper.Core.Models.Track", b => - { - b.HasOne("ArmRipper.Core.Models.Job", "Job") - .WithMany("Tracks") - .HasForeignKey("JobId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Job"); - }); - - modelBuilder.Entity("ArmRipper.Core.Models.DiscMetadata", b => - { - b.Navigation("Tracks"); - }); - - modelBuilder.Entity("ArmRipper.Core.Models.DiscTrack", b => - { - b.Navigation("Streams"); - }); - - modelBuilder.Entity("ArmRipper.Core.Models.Job", b => - { - b.Navigation("Config"); - - b.Navigation("Tracks"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/ArmRipper.Core/Migrations/20260717034034_AddSourceTitleId.cs b/src/ArmRipper.Core/Migrations/20260717034034_AddSourceTitleId.cs deleted file mode 100644 index 2842302..0000000 --- a/src/ArmRipper.Core/Migrations/20260717034034_AddSourceTitleId.cs +++ /dev/null @@ -1,38 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace ArmRipper.Core.Migrations -{ - /// - public partial class AddSourceTitleId : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "SourceTitleId", - table: "tracks", - type: "INTEGER", - nullable: true); - - migrationBuilder.AddColumn( - name: "SourceTitleId", - table: "disc_tracks", - type: "INTEGER", - nullable: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "SourceTitleId", - table: "tracks"); - - migrationBuilder.DropColumn( - name: "SourceTitleId", - table: "disc_tracks"); - } - } -} diff --git a/src/ArmRipper.Core/Migrations/ArmDbContextModelSnapshot.cs b/src/ArmRipper.Core/Migrations/ArmDbContextModelSnapshot.cs index ae27c25..33654e3 100644 --- a/src/ArmRipper.Core/Migrations/ArmDbContextModelSnapshot.cs +++ b/src/ArmRipper.Core/Migrations/ArmDbContextModelSnapshot.cs @@ -321,9 +321,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(20) .HasColumnType("TEXT"); - b.Property("SourceTitleId") - .HasColumnType("INTEGER"); - b.Property("TrackNumber") .IsRequired() .HasMaxLength(4) @@ -776,9 +773,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(32) .HasColumnType("TEXT"); - b.Property("SourceTitleId") - .HasColumnType("INTEGER"); - b.Property("Status") .HasMaxLength(32) .HasColumnType("TEXT"); diff --git a/src/ArmRipper.Core/Models/DiscTrack.cs b/src/ArmRipper.Core/Models/DiscTrack.cs index 86d1928..5ceee1a 100644 --- a/src/ArmRipper.Core/Models/DiscTrack.cs +++ b/src/ArmRipper.Core/Models/DiscTrack.cs @@ -5,7 +5,6 @@ public class DiscTrack public int Id { get; init; } public int DiscMetadataId { get; set; } public string TrackNumber { get; set; } = ""; - public int? SourceTitleId { get; set; } public string? FileName { get; set; } public int? Length { get; set; } public int? Chapters { get; set; } diff --git a/src/ArmRipper.Core/Models/Track.cs b/src/ArmRipper.Core/Models/Track.cs index a78c69f..4d20e17 100644 --- a/src/ArmRipper.Core/Models/Track.cs +++ b/src/ArmRipper.Core/Models/Track.cs @@ -36,9 +36,6 @@ public class Track /// Content type from TheDiscDb: "movie", "episode", "extra", "trailer", "commentary", etc. public string? ContentType { get; set; } - /// MakeMKV source title ID (field 24 of TINFO). Used in the mkv command instead of the sequential TrackNumber. - public int? SourceTitleId { get; set; } - /// Season number for this specific track (may differ from Job.SeasonNumber for multi-season discs). public int? TrackSeasonNumber { get; set; } diff --git a/src/ArmRipper.Core/Rip/MakeMkvModels.cs b/src/ArmRipper.Core/Rip/MakeMkvModels.cs index d7a7312..b4bfe97 100644 --- a/src/ArmRipper.Core/Rip/MakeMkvModels.cs +++ b/src/ArmRipper.Core/Rip/MakeMkvModels.cs @@ -12,7 +12,6 @@ public enum TrackId Chapters = 8, Duration = 9, Filesize = 11, - SourceTitleId = 24, Filename = 27 } diff --git a/src/ArmRipper.Core/Rip/MakeMkvService.cs b/src/ArmRipper.Core/Rip/MakeMkvService.cs index 9bc9931..891a444 100644 --- a/src/ArmRipper.Core/Rip/MakeMkvService.cs +++ b/src/ArmRipper.Core/Rip/MakeMkvService.cs @@ -149,7 +149,6 @@ public async Task> GetTrackInfoAsync(Job job, string baseName, int? var filesize = 0L; var streamType = 0; var resolution = ""; - var sourceTitleId = 0; var streamAccums = new Dictionary(); var lineCount = 0; @@ -216,9 +215,8 @@ public async Task> GetTrackInfoAsync(Job job, string baseName, int? case TInfo tinfo: if (currentTid >= 0 && tinfo.Tid != currentTid) - FinalizeTrack(job, baseName, tracks, discTracks, currentTid, ref seconds, ref aspect, ref fps, ref filename, ref chapters, ref filesize, ref streamType, ref resolution, ref sourceTitleId, streamAccums); + FinalizeTrack(job, baseName, tracks, discTracks, currentTid, ref seconds, ref aspect, ref fps, ref filename, ref chapters, ref filesize, ref streamType, ref resolution, streamAccums); currentTid = tinfo.Tid; - sourceTitleId = 0; switch ((TrackId)tinfo.Id) { case TrackId.Filename: @@ -233,9 +231,6 @@ public async Task> GetTrackInfoAsync(Job job, string baseName, int? case TrackId.Filesize: long.TryParse(tinfo.Value, out filesize); break; - case TrackId.SourceTitleId: - int.TryParse(tinfo.Value, out sourceTitleId); - break; } break; @@ -252,7 +247,7 @@ public async Task> GetTrackInfoAsync(Job job, string baseName, int? } if (currentTid >= 0) - FinalizeTrack(job, baseName, tracks, discTracks, currentTid, ref seconds, ref aspect, ref fps, ref filename, ref chapters, ref filesize, ref streamType, ref resolution, ref sourceTitleId, streamAccums); + FinalizeTrack(job, baseName, tracks, discTracks, currentTid, ref seconds, ref aspect, ref fps, ref filename, ref chapters, ref filesize, ref streamType, ref resolution, streamAccums); _logger.LogInformation("GetTrackInfo: {Lines} lines, {Tracks} tracks, lastTid={Tid}", lineCount, tracks.Count, currentTid); @@ -286,7 +281,6 @@ public async Task> GetTrackInfoWithCacheAsync(Job job, string baseNa { JobId = job.Id, TrackNumber = t.TrackNumber, - SourceTitleId = t.SourceTitleId, FileName = t.FileName, Length = t.Length, AspectRatio = t.AspectRatio, @@ -317,14 +311,13 @@ private static StreamAccum GetOrCreateAccum(Dictionary accums, private static void FinalizeTrack(Job job, string baseName, List tracks, List discTracks, int currentTid, ref int seconds, ref string aspect, ref double fps, ref string filename, ref int chapters, ref long filesize, ref int streamType, ref string resolution, - ref int sourceTitleId, Dictionary streamAccums) + Dictionary streamAccums) { - tracks.Add(CreateTrackObj(job, currentTid, baseName, seconds, aspect, fps, filename, chapters, filesize, sourceTitleId)); + tracks.Add(CreateTrackObj(job, currentTid, baseName, seconds, aspect, fps, filename, chapters, filesize)); var discTrack = new DiscTrack { TrackNumber = currentTid.ToString(), - SourceTitleId = sourceTitleId > 0 ? sourceTitleId : null, FileName = string.IsNullOrEmpty(filename) ? null : filename, Length = seconds > 0 ? seconds : null, Chapters = chapters > 0 ? chapters : null, @@ -357,7 +350,7 @@ private static void FinalizeTrack(Job job, string baseName, List tracks, discTracks.Add(discTrack); - seconds = 0; aspect = ""; fps = 0.0; filename = ""; chapters = 0; filesize = 0; streamType = 0; resolution = ""; sourceTitleId = 0; + seconds = 0; aspect = ""; fps = 0.0; filename = ""; chapters = 0; filesize = 0; streamType = 0; resolution = ""; streamAccums.Clear(); } @@ -564,13 +557,12 @@ private void ParseAndReportProgress(string line, IProgress? progress) } } - private static Track CreateTrackObj(Job job, int tid, string baseName, int seconds, string aspect, double fps, string filename, int chapters, long filesize, int sourceTitleId = 0) + private static Track CreateTrackObj(Job job, int tid, string baseName, int seconds, string aspect, double fps, string filename, int chapters, long filesize) { return new Track { JobId = job.Id, TrackNumber = tid.ToString(), - SourceTitleId = sourceTitleId > 0 ? sourceTitleId : null, Length = seconds, AspectRatio = string.IsNullOrEmpty(aspect) ? null : aspect, Fps = fps > 0 ? fps : null, diff --git a/src/ArmRipper.WebUi/Hubs/NotificationHub.cs b/src/ArmRipper.WebUi/Hubs/NotificationHub.cs index b1bba94..e889600 100644 --- a/src/ArmRipper.WebUi/Hubs/NotificationHub.cs +++ b/src/ArmRipper.WebUi/Hubs/NotificationHub.cs @@ -9,15 +9,6 @@ public class NotificationHub(IOptions settings) : Hub { private string LogPath => ArmPaths.GetLogPath(settings.Value); - /// - /// Streams log file content over SignalR. Yields in ~16 KB chunks so large - /// files don't exceed the default SignalR message size limit (32 KB). - /// - /// Log file name (e.g. "arm.log", "42.log"). - /// - /// "full" — send the entire file from the start, then stream new lines. - /// "tail" — send only the last ~4 KB, then stream new lines. - /// public async IAsyncEnumerable StreamLog( string fileName, string mode, @@ -31,29 +22,21 @@ public async IAsyncEnumerable StreamLog( if (!System.IO.File.Exists(fullPath)) yield break; - // Determine starting position based on mode - var fi = new FileInfo(fullPath); - long lastPos = mode == "tail" - ? Math.Max(0, fi.Length - 4096) // last ~4 KB for "tail" - : 0; // entire file for "full" - + long lastPos = 0; while (!cancellationToken.IsCancellationRequested) { - fi.Refresh(); + var fi = new FileInfo(fullPath); if (fi.Exists && fi.Length > lastPos) { await using var fs = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); fs.Seek(lastPos, SeekOrigin.Begin); using var reader = new StreamReader(fs); + var newText = await reader.ReadToEndAsync(cancellationToken); - // Yield in ~16 KB chunks to stay well within SignalR's 32 KB limit - var buffer = new char[16 * 1024]; - int bytesRead; - while ((bytesRead = await reader.ReadAsync(buffer, 0, buffer.Length)) > 0) + if (newText.Length > 0) { - yield return new string(buffer, 0, bytesRead); + yield return newText; } - lastPos = fi.Length; } diff --git a/src/ArmRipper.WebUi/Views/Logs/Viewer.cshtml b/src/ArmRipper.WebUi/Views/Logs/Viewer.cshtml index aaa654a..c761f1a 100644 --- a/src/ArmRipper.WebUi/Views/Logs/Viewer.cshtml +++ b/src/ArmRipper.WebUi/Views/Logs/Viewer.cshtml @@ -15,8 +15,10 @@ @section Scripts { } From 898882ab662d8f3139bc822ed0588cd921860136 Mon Sep 17 00:00:00 2001 From: ARM Dotnet Port Date: Fri, 17 Jul 2026 14:45:11 -0500 Subject: [PATCH 6/9] Fix: decouple identify-phase errors from rip parameters - Re-add SourceTitleId (TINFO field 24) to fix MakeMKV mkv command title selection: the 0-based TINFO index was causing '0' to be passed to MakeMKV's mkv command, which MakeMKV interprets as 'rip all titles' instead of 'rip title 1'. Now we pass the actual 1-based title number. - 0-track fallback: retry info scan with normal minLength before falling back to RipAllTitles, so a DiscDb-enabled exhaustive scan timeout doesn't bypass MainFeature track selection. - Add MarkStageComplete(Rip) to the 0-track fallback path. - Add yellow 'Small file' warning on completed movie files under 2GB on both the Output list and detail pages. --- src/ArmRipper.Core/Models/DiscTrack.cs | 3 + src/ArmRipper.Core/Models/Track.cs | 5 ++ src/ArmRipper.Core/Rip/ArmRipperService.cs | 63 +++++++++++++------ src/ArmRipper.Core/Rip/MakeMkvModels.cs | 1 + src/ArmRipper.Core/Rip/MakeMkvService.cs | 37 ++++++++--- .../Views/Completed/Detail.cshtml | 8 ++- .../Views/Completed/Index.cshtml | 4 ++ 7 files changed, 91 insertions(+), 30 deletions(-) diff --git a/src/ArmRipper.Core/Models/DiscTrack.cs b/src/ArmRipper.Core/Models/DiscTrack.cs index 5ceee1a..a119148 100644 --- a/src/ArmRipper.Core/Models/DiscTrack.cs +++ b/src/ArmRipper.Core/Models/DiscTrack.cs @@ -5,6 +5,9 @@ public class DiscTrack public int Id { get; init; } public int DiscMetadataId { get; set; } public string TrackNumber { get; set; } = ""; + + /// Actual 1-based disc title number (TINFO field 24). + public int? SourceTitleId { get; set; } public string? FileName { get; set; } public int? Length { get; set; } public int? Chapters { get; set; } diff --git a/src/ArmRipper.Core/Models/Track.cs b/src/ArmRipper.Core/Models/Track.cs index 4d20e17..baadde9 100644 --- a/src/ArmRipper.Core/Models/Track.cs +++ b/src/ArmRipper.Core/Models/Track.cs @@ -11,6 +11,11 @@ public class Track /// Pre-parsed track number for efficient numeric access. [NotMapped] public int? TrackNumberInt => int.TryParse(TrackNumber, out var n) ? n : null; + + /// The actual 1-based title number on the disc (TINFO field 24). + /// Used when calling makemkvcon mkv to specify a single title, since + /// MakeMKV interprets 0 as 'all titles' and TrackNumber is 0-based. + public int? SourceTitleId { get; set; } public int? Length { get; set; } public string? AspectRatio { get; set; } public double? Fps { get; set; } diff --git a/src/ArmRipper.Core/Rip/ArmRipperService.cs b/src/ArmRipper.Core/Rip/ArmRipperService.cs index f2fbca6..df86928 100644 --- a/src/ArmRipper.Core/Rip/ArmRipperService.cs +++ b/src/ArmRipper.Core/Rip/ArmRipperService.cs @@ -209,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); @@ -226,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; diff --git a/src/ArmRipper.Core/Rip/MakeMkvModels.cs b/src/ArmRipper.Core/Rip/MakeMkvModels.cs index b4bfe97..d7a7312 100644 --- a/src/ArmRipper.Core/Rip/MakeMkvModels.cs +++ b/src/ArmRipper.Core/Rip/MakeMkvModels.cs @@ -12,6 +12,7 @@ public enum TrackId Chapters = 8, Duration = 9, Filesize = 11, + SourceTitleId = 24, Filename = 27 } diff --git a/src/ArmRipper.Core/Rip/MakeMkvService.cs b/src/ArmRipper.Core/Rip/MakeMkvService.cs index 891a444..702287f 100644 --- a/src/ArmRipper.Core/Rip/MakeMkvService.cs +++ b/src/ArmRipper.Core/Rip/MakeMkvService.cs @@ -149,6 +149,7 @@ public async Task> GetTrackInfoAsync(Job job, string baseName, int? var filesize = 0L; var streamType = 0; var resolution = ""; + var sourceTitleId = 0; var streamAccums = new Dictionary(); var lineCount = 0; @@ -215,7 +216,7 @@ public async Task> GetTrackInfoAsync(Job job, string baseName, int? case TInfo tinfo: if (currentTid >= 0 && tinfo.Tid != currentTid) - FinalizeTrack(job, baseName, tracks, discTracks, currentTid, ref seconds, ref aspect, ref fps, ref filename, ref chapters, ref filesize, ref streamType, ref resolution, streamAccums); + FinalizeTrack(job, baseName, tracks, discTracks, currentTid, ref seconds, ref aspect, ref fps, ref filename, ref chapters, ref filesize, ref streamType, ref resolution, streamAccums, ref sourceTitleId); currentTid = tinfo.Tid; switch ((TrackId)tinfo.Id) { @@ -231,6 +232,9 @@ public async Task> GetTrackInfoAsync(Job job, string baseName, int? case TrackId.Filesize: long.TryParse(tinfo.Value, out filesize); break; + case TrackId.SourceTitleId: + int.TryParse(tinfo.Value.Trim(), out sourceTitleId); + break; } break; @@ -247,7 +251,7 @@ public async Task> GetTrackInfoAsync(Job job, string baseName, int? } if (currentTid >= 0) - FinalizeTrack(job, baseName, tracks, discTracks, currentTid, ref seconds, ref aspect, ref fps, ref filename, ref chapters, ref filesize, ref streamType, ref resolution, streamAccums); + FinalizeTrack(job, baseName, tracks, discTracks, currentTid, ref seconds, ref aspect, ref fps, ref filename, ref chapters, ref filesize, ref streamType, ref resolution, streamAccums, ref sourceTitleId); _logger.LogInformation("GetTrackInfo: {Lines} lines, {Tracks} tracks, lastTid={Tid}", lineCount, tracks.Count, currentTid); @@ -311,13 +315,14 @@ private static StreamAccum GetOrCreateAccum(Dictionary accums, private static void FinalizeTrack(Job job, string baseName, List tracks, List discTracks, int currentTid, ref int seconds, ref string aspect, ref double fps, ref string filename, ref int chapters, ref long filesize, ref int streamType, ref string resolution, - Dictionary streamAccums) + Dictionary streamAccums, ref int sourceTitleId) { - tracks.Add(CreateTrackObj(job, currentTid, baseName, seconds, aspect, fps, filename, chapters, filesize)); + tracks.Add(CreateTrackObj(job, currentTid, baseName, seconds, aspect, fps, filename, chapters, filesize, sourceTitleId)); var discTrack = new DiscTrack { TrackNumber = currentTid.ToString(), + SourceTitleId = sourceTitleId > 0 ? sourceTitleId : null, FileName = string.IsNullOrEmpty(filename) ? null : filename, Length = seconds > 0 ? seconds : null, Chapters = chapters > 0 ? chapters : null, @@ -409,6 +414,17 @@ private sealed record StreamAccum public async Task RipTrackAsync(Job job, string trackNumber, string outputPath, string mkvArgs, int minLength, IProgress? progress = null, CancellationToken ct = default) { + // Look up the SourceTitleId (actual 1-based disc title number) from the track, + // so we pass the correct title to MakeMKV's mkv command. MakeMKV interprets 0 + // as "all titles", so the 0-based TINFO index from the info scan won't work. + var sourceTitleId = job.Tracks + ?.FirstOrDefault(t => t.TrackNumber == trackNumber) + ?.SourceTitleId; + + // Use SourceTitleId if available, otherwise fall back to trackNumber + // (which is the 0-based TINFO index — only correct when info was scanned + // with --minlength=0 so all titles are present). + var mkvTitleNum = sourceTitleId?.ToString() ?? trackNumber; // Estimate expected file size from the track for progress monitoring. // job.Tracks may not be populated at this point (tracks are returned as a separate list from // GetTrackInfoWithCacheAsync), so we check safely with ?.FirstOrDefault(). @@ -421,11 +437,13 @@ public async Task RipTrackAsync(Job job, string trackNumber, string outputPath, try { - // trackNumber is the sequential TINFO index from info output, which maps to - // the 0-based index of valid titles after --minlength filtering. - var args = $"--robot --messages=-stdout --progress=-stdout mkv --minlength={minLength} dev:{job.DevPath} {trackNumber} \"{outputPath}\""; + // Use the actual 1-based title number (SourceTitleId) instead of the + // 0-based TINFO index, because MakeMKV's mkv command interprets 0 as + // "all titles" — which would rip every eligible title instead of just + // the one we selected. + var args = $"--robot --messages=-stdout --progress=-stdout mkv --minlength={minLength} dev:{job.DevPath} {mkvTitleNum} \"{outputPath}\""; if (!string.IsNullOrEmpty(mkvArgs)) - args = $"--robot --messages=-stdout --progress=-stdout mkv {mkvArgs} --minlength={minLength} dev:{job.DevPath} {trackNumber} \"{outputPath}\""; + args = $"--robot --messages=-stdout --progress=-stdout mkv {mkvArgs} --minlength={minLength} dev:{job.DevPath} {mkvTitleNum} \"{outputPath}\""; await foreach (var line in _runner.RunStreamingAsync("makemkvcon", args, ct: ct)) ParseAndReportProgress(line, progress); @@ -557,12 +575,13 @@ private void ParseAndReportProgress(string line, IProgress? progress) } } - private static Track CreateTrackObj(Job job, int tid, string baseName, int seconds, string aspect, double fps, string filename, int chapters, long filesize) + private static Track CreateTrackObj(Job job, int tid, string baseName, int seconds, string aspect, double fps, string filename, int chapters, long filesize, int sourceTitleId) { return new Track { JobId = job.Id, TrackNumber = tid.ToString(), + SourceTitleId = sourceTitleId > 0 ? sourceTitleId : null, Length = seconds, AspectRatio = string.IsNullOrEmpty(aspect) ? null : aspect, Fps = fps > 0 ? fps : null, 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 From b07ff2c05eb8a13e630998fb6e5ad6b20dae01c3 Mon Sep 17 00:00:00 2001 From: ARM Dotnet Port Date: Fri, 17 Jul 2026 19:15:42 -0500 Subject: [PATCH 7/9] Remove SourceTitleId: it caused wrong-track rips on every DVD SourceTitleId (MakeMKV field 24) is the 1-based source title number on the disc. But MakeMKV's mkv command uses 0-based TINFO indices, so passing SourceTitleId=1 meant 'rip TINFO index 1' (the second track) instead of 'rip title 1' (the main feature). This caused every DVD to rip a short extra instead of the movie. Fully removed from: Track model, DiscTrack model, MakeMkvModels enum, GetTrackInfoAsync parsing, FinalizeTrack, CreateTrackObj, and RipTrackAsync (which now correctly passes trackNumber directly). --- src/ArmRipper.Core/Models/DiscTrack.cs | 3 -- src/ArmRipper.Core/Models/Track.cs | 5 --- src/ArmRipper.Core/Rip/MakeMkvModels.cs | 1 - src/ArmRipper.Core/Rip/MakeMkvService.cs | 46 ++++++++---------------- 4 files changed, 15 insertions(+), 40 deletions(-) diff --git a/src/ArmRipper.Core/Models/DiscTrack.cs b/src/ArmRipper.Core/Models/DiscTrack.cs index a119148..5ceee1a 100644 --- a/src/ArmRipper.Core/Models/DiscTrack.cs +++ b/src/ArmRipper.Core/Models/DiscTrack.cs @@ -5,9 +5,6 @@ public class DiscTrack public int Id { get; init; } public int DiscMetadataId { get; set; } public string TrackNumber { get; set; } = ""; - - /// Actual 1-based disc title number (TINFO field 24). - public int? SourceTitleId { get; set; } public string? FileName { get; set; } public int? Length { get; set; } public int? Chapters { get; set; } diff --git a/src/ArmRipper.Core/Models/Track.cs b/src/ArmRipper.Core/Models/Track.cs index baadde9..4d20e17 100644 --- a/src/ArmRipper.Core/Models/Track.cs +++ b/src/ArmRipper.Core/Models/Track.cs @@ -11,11 +11,6 @@ public class Track /// Pre-parsed track number for efficient numeric access. [NotMapped] public int? TrackNumberInt => int.TryParse(TrackNumber, out var n) ? n : null; - - /// The actual 1-based title number on the disc (TINFO field 24). - /// Used when calling makemkvcon mkv to specify a single title, since - /// MakeMKV interprets 0 as 'all titles' and TrackNumber is 0-based. - public int? SourceTitleId { get; set; } public int? Length { get; set; } public string? AspectRatio { get; set; } public double? Fps { get; set; } diff --git a/src/ArmRipper.Core/Rip/MakeMkvModels.cs b/src/ArmRipper.Core/Rip/MakeMkvModels.cs index d7a7312..b4bfe97 100644 --- a/src/ArmRipper.Core/Rip/MakeMkvModels.cs +++ b/src/ArmRipper.Core/Rip/MakeMkvModels.cs @@ -12,7 +12,6 @@ public enum TrackId Chapters = 8, Duration = 9, Filesize = 11, - SourceTitleId = 24, Filename = 27 } diff --git a/src/ArmRipper.Core/Rip/MakeMkvService.cs b/src/ArmRipper.Core/Rip/MakeMkvService.cs index 702287f..b1c1ee6 100644 --- a/src/ArmRipper.Core/Rip/MakeMkvService.cs +++ b/src/ArmRipper.Core/Rip/MakeMkvService.cs @@ -149,7 +149,6 @@ public async Task> GetTrackInfoAsync(Job job, string baseName, int? var filesize = 0L; var streamType = 0; var resolution = ""; - var sourceTitleId = 0; var streamAccums = new Dictionary(); var lineCount = 0; @@ -216,7 +215,7 @@ public async Task> GetTrackInfoAsync(Job job, string baseName, int? case TInfo tinfo: if (currentTid >= 0 && tinfo.Tid != currentTid) - FinalizeTrack(job, baseName, tracks, discTracks, currentTid, ref seconds, ref aspect, ref fps, ref filename, ref chapters, ref filesize, ref streamType, ref resolution, streamAccums, ref sourceTitleId); + FinalizeTrack(job, baseName, tracks, discTracks, currentTid, ref seconds, ref aspect, ref fps, ref filename, ref chapters, ref filesize, ref streamType, ref resolution, streamAccums); currentTid = tinfo.Tid; switch ((TrackId)tinfo.Id) { @@ -232,9 +231,6 @@ public async Task> GetTrackInfoAsync(Job job, string baseName, int? case TrackId.Filesize: long.TryParse(tinfo.Value, out filesize); break; - case TrackId.SourceTitleId: - int.TryParse(tinfo.Value.Trim(), out sourceTitleId); - break; } break; @@ -251,7 +247,7 @@ public async Task> GetTrackInfoAsync(Job job, string baseName, int? } if (currentTid >= 0) - FinalizeTrack(job, baseName, tracks, discTracks, currentTid, ref seconds, ref aspect, ref fps, ref filename, ref chapters, ref filesize, ref streamType, ref resolution, streamAccums, ref sourceTitleId); + FinalizeTrack(job, baseName, tracks, discTracks, currentTid, ref seconds, ref aspect, ref fps, ref filename, ref chapters, ref filesize, ref streamType, ref resolution, streamAccums); _logger.LogInformation("GetTrackInfo: {Lines} lines, {Tracks} tracks, lastTid={Tid}", lineCount, tracks.Count, currentTid); @@ -315,14 +311,13 @@ private static StreamAccum GetOrCreateAccum(Dictionary accums, private static void FinalizeTrack(Job job, string baseName, List tracks, List discTracks, int currentTid, ref int seconds, ref string aspect, ref double fps, ref string filename, ref int chapters, ref long filesize, ref int streamType, ref string resolution, - Dictionary streamAccums, ref int sourceTitleId) + Dictionary streamAccums) { - tracks.Add(CreateTrackObj(job, currentTid, baseName, seconds, aspect, fps, filename, chapters, filesize, sourceTitleId)); + tracks.Add(CreateTrackObj(job, currentTid, baseName, seconds, aspect, fps, filename, chapters, filesize)); var discTrack = new DiscTrack { TrackNumber = currentTid.ToString(), - SourceTitleId = sourceTitleId > 0 ? sourceTitleId : null, FileName = string.IsNullOrEmpty(filename) ? null : filename, Length = seconds > 0 ? seconds : null, Chapters = chapters > 0 ? chapters : null, @@ -414,21 +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) { - // Look up the SourceTitleId (actual 1-based disc title number) from the track, - // so we pass the correct title to MakeMKV's mkv command. MakeMKV interprets 0 - // as "all titles", so the 0-based TINFO index from the info scan won't work. - var sourceTitleId = job.Tracks - ?.FirstOrDefault(t => t.TrackNumber == trackNumber) - ?.SourceTitleId; - - // Use SourceTitleId if available, otherwise fall back to trackNumber - // (which is the 0-based TINFO index — only correct when info was scanned - // with --minlength=0 so all titles are present). - var mkvTitleNum = sourceTitleId?.ToString() ?? trackNumber; // Estimate expected file size from the track for progress monitoring. - // job.Tracks may not be populated at this point (tracks are returned as a separate list from - // GetTrackInfoWithCacheAsync), so we check safely with ?.FirstOrDefault(). - var expectedSize = job.Tracks?.FirstOrDefault(t => t.TrackNumber == trackNumber)?.FileSize ?? 0; + var expectedSize = job.Tracks + ?.FirstOrDefault(t => t.TrackNumber == trackNumber) + ?.FileSize ?? 0; var monitorCts = CancellationTokenSource.CreateLinkedTokenSource(ct); var monitorTask = expectedSize > 0 && progress is not null @@ -437,13 +421,14 @@ public async Task RipTrackAsync(Job job, string trackNumber, string outputPath, try { - // Use the actual 1-based title number (SourceTitleId) instead of the - // 0-based TINFO index, because MakeMKV's mkv command interprets 0 as - // "all titles" — which would rip every eligible title instead of just - // the one we selected. - var args = $"--robot --messages=-stdout --progress=-stdout mkv --minlength={minLength} dev:{job.DevPath} {mkvTitleNum} \"{outputPath}\""; + // 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} {mkvTitleNum} \"{outputPath}\""; + args = $"--robot --messages=-stdout --progress=-stdout mkv {mkvArgs} --minlength={minLength} dev:{job.DevPath} {trackNumber} \"{outputPath}\""; await foreach (var line in _runner.RunStreamingAsync("makemkvcon", args, ct: ct)) ParseAndReportProgress(line, progress); @@ -575,13 +560,12 @@ private void ParseAndReportProgress(string line, IProgress? progress) } } - private static Track CreateTrackObj(Job job, int tid, string baseName, int seconds, string aspect, double fps, string filename, int chapters, long filesize, int sourceTitleId) + private static Track CreateTrackObj(Job job, int tid, string baseName, int seconds, string aspect, double fps, string filename, int chapters, long filesize) { return new Track { JobId = job.Id, TrackNumber = tid.ToString(), - SourceTitleId = sourceTitleId > 0 ? sourceTitleId : null, Length = seconds, AspectRatio = string.IsNullOrEmpty(aspect) ? null : aspect, Fps = fps > 0 ? fps : null, From a659399cd5fb8431b62cde32b9de882b0f509efb Mon Sep 17 00:00:00 2001 From: ARM Dotnet Port Date: Fri, 17 Jul 2026 19:41:47 -0500 Subject: [PATCH 8/9] =?UTF-8?q?Fix:=20MaxConcurrentTranscodes=20not=20resp?= =?UTF-8?q?ected=20=E2=80=94=20limiter=20was=20frozen=20at=20startup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TranscodeSlotLimiter read MaxConcurrentTranscodes from IOptions once at DI construction time and never updated. Since the Web UI saves settings into the DB (not the YAML file), any change via the UI was silently ignored. The limiter has been rewritten as a dynamic counter+queue gate that accepts the effective maxConcurrent value from each caller. FfmpegService and HandBrakeService now resolve job.Config?.MaxConcurrentTranscodes ?? settings.Value.MaxConcurrentTranscodes and pass it to AcquireAsync, so UI-driven changes take effect immediately. --- src/ArmRipper.Core/Rip/FfmpegService.cs | 3 +- src/ArmRipper.Core/Rip/HandBrakeService.cs | 13 ++- .../Rip/ITranscodeSlotLimiter.cs | 7 +- .../Rip/TranscodeSlotLimiter.cs | 108 +++++++++++++++--- 4 files changed, 105 insertions(+), 26 deletions(-) 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/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; } } From c67a0c8225f074988b64d77f2de2c9cbb1f5b04f Mon Sep 17 00:00:00 2001 From: ARM Dotnet Port Date: Fri, 17 Jul 2026 23:09:51 -0500 Subject: [PATCH 9/9] docs: restructure IMPROVEMENTS.md into index + individual files under docs/improvements/ - Split monolithic IMPROVEMENTS.md into a table-of-contents index - Each improvement topic now lives in its own file under docs/improvements/ - Added disc-type-audiobook-mp3.md documenting the MP3 audiobook detection gap (job 607) --- docs/IMPROVEMENTS.md | 72 ++++++++----------- docs/improvements/configuration-4k-uhd.md | 7 ++ .../improvements/configuration-appsettings.md | 3 + docs/improvements/configuration-seed-data.md | 3 + docs/improvements/container-deployment.md | 13 ++++ docs/improvements/data-persistence.md | 5 ++ docs/improvements/dependency-injection.md | 3 + docs/improvements/disc-databases.md | 5 ++ docs/improvements/disc-type-audiobook-mp3.md | 34 +++++++++ docs/improvements/gitignore.md | 3 + docs/improvements/mcp.md | 16 +++++ docs/improvements/musicbrainz.md | 3 + docs/improvements/notifications-apprise.md | 6 ++ docs/improvements/notifications-pushover.md | 6 ++ docs/improvements/pages-batch-actions.md | 3 + docs/improvements/pages-dashboard.md | 3 + docs/improvements/pages-disc-detection.md | 3 + docs/improvements/pages-identification.md | 3 + docs/improvements/security.md | 3 + docs/improvements/signalr.md | 3 + docs/improvements/startup-recovery.md | 3 + docs/improvements/testing.md | 7 ++ docs/improvements/ui-restart-last-stage.md | 3 + 23 files changed, 166 insertions(+), 44 deletions(-) create mode 100644 docs/improvements/configuration-4k-uhd.md create mode 100644 docs/improvements/configuration-appsettings.md create mode 100644 docs/improvements/configuration-seed-data.md create mode 100644 docs/improvements/container-deployment.md create mode 100644 docs/improvements/data-persistence.md create mode 100644 docs/improvements/dependency-injection.md create mode 100644 docs/improvements/disc-databases.md create mode 100644 docs/improvements/disc-type-audiobook-mp3.md create mode 100644 docs/improvements/gitignore.md create mode 100644 docs/improvements/mcp.md create mode 100644 docs/improvements/musicbrainz.md create mode 100644 docs/improvements/notifications-apprise.md create mode 100644 docs/improvements/notifications-pushover.md create mode 100644 docs/improvements/pages-batch-actions.md create mode 100644 docs/improvements/pages-dashboard.md create mode 100644 docs/improvements/pages-disc-detection.md create mode 100644 docs/improvements/pages-identification.md create mode 100644 docs/improvements/security.md create mode 100644 docs/improvements/signalr.md create mode 100644 docs/improvements/startup-recovery.md create mode 100644 docs/improvements/testing.md create mode 100644 docs/improvements/ui-restart-last-stage.md diff --git a/docs/IMPROVEMENTS.md b/docs/IMPROVEMENTS.md index 88bdd86..13e4d82 100644 --- a/docs/IMPROVEMENTS.md +++ b/docs/IMPROVEMENTS.md @@ -2,76 +2,60 @@ Track usability, DX, and architecture improvements. Focus: user-friendliness, easy setup, easy diagnosis. +Each entry below links to a dedicated page in [`docs/improvements/`](./improvements/) with full details. + +--- + ## Gitignore -- DB file `data/arm.db` is not gitignored — should be added so test/seed data isn't committed. +- [gitignore.md](./improvements/gitignore.md) — DB file `data/arm.db` not gitignored ## Data / Persistence -- **Migrate from `DateTime` to `DateTimeOffset`** — All model timestamps (`Job.StartTime`, `Job.StopTime`, `Notification.Timestamp`, `DiscMetadata.CreatedAt`/`LastUsedAt`, etc.) use `DateTime`. EF Core reads these back as `DateTimeKind.Unspecified` from SQLite, losing the UTC context. `.ToLocalTime()` in views works but is a band-aid. Switching to `DateTimeOffset` stores the offset with the value and makes timezone intent explicit. Requires touching models, EF mappings, all view comparisons, and serialization. +- [data-persistence.md](./improvements/data-persistence.md) — Migrate from `DateTime` to `DateTimeOffset` ## Configuration & Setup -- **Add 4K UHD disc type with separate settings** — Currently `ArmSettings` and `ConfigSnapshot` only have `HbArgsDvd` / `HbPresetDvd` and `HbArgsBd` / `HbPresetBd`. 4K UHD discs need different handling (ffmpeg passthrough to preserve HDR, no HandBrake re-encode). Add `HbArgsUhd` / `HbPresetUhd` and `FfmpegPostFileArgsUhd` properties, a `DiscType.Uhd` enum value, and wire them through `HandBrakeService.GetHbSettings()` and the `Conductor` pipeline. For 4K UHD, the typical workflow is `USE_FFMPEG: true` with `-c:v copy -c:a ac3 -b:a 640k` to preserve HDR metadata losslessly. Users currently have to swap `arm.yaml` manually when switching between 1080p and 4K discs — automatic disc-type detection would eliminate this. -- No `appsettings.Production.json` or env-aware config profiles. Would help users separate sensitive keys (API keys) from path config. -- Seed data scripts exist in `scripts/` but require manual run. Consider auto-seeding on first launch for demo/testing. +- [configuration-4k-uhd.md](./improvements/configuration-4k-uhd.md) — Add 4K UHD disc type with separate settings +- [configuration-appsettings.md](./improvements/configuration-appsettings.md) — Environment-aware config profiles +- [configuration-seed-data.md](./improvements/configuration-seed-data.md) — Auto-seed data on first launch + +## Disc Type Detection +- [disc-type-audiobook-mp3.md](./improvements/disc-type-audiobook-mp3.md) — MP3 audiobook / audio CD-ROM classification ## UI / User Experience -- **Restart from last successful stage** — add a "retry from failure" action that resumes the pipeline at the last failed stage instead of restarting from scratch. Requires each stage to checkpoint its completion state in the DB (e.g. a `Stages` table or bitfield on `Job`). +- [ui-restart-last-stage.md](./improvements/ui-restart-last-stage.md) — Restart from last successful stage ## SignalR -- `SignalRNotificationBroadcaster` is wired via `INotificationBroadcaster` interface. Works but the broadcaster is a singleton while the hub context is scoped per connection. Should verify no lifetime issues. +- [signalr.md](./improvements/signalr.md) — Broadcaster lifetime concerns ## Pages / Views -- **Redesign Identification section** — improve the layout of the Identification section on the Job detail page. -- **DVD/Blu-ray detection workflow** — the Settings page has a "Detect Disc" / "Scan Drives" button but the actual udev-based monitoring workflow from the original ARM isn't replicated. Should add a "Start Monitoring" action that runs the Conductor/IdentifyService loop. -- **Home dashboard** — core metrics displayed. Could add charts (job success rate over time, rips per day) or sparkline trends. -- Batch actions on Active Rips page (abandon all, retry all). +- [pages-identification.md](./improvements/pages-identification.md) — Redesign Identification section +- [pages-disc-detection.md](./improvements/pages-disc-detection.md) — DVD/Blu-ray detection workflow +- [pages-dashboard.md](./improvements/pages-dashboard.md) — Home dashboard enhancements +- [pages-batch-actions.md](./improvements/pages-batch-actions.md) — Batch actions on Active Rips page ## MusicBrainz -- **Investigate moving off XML where possible** — MusicBrainz XML parsing is fragile (manual XElement traversal, namespace handling). If MusicBrainz offers a JSON endpoint, prefer it. +- [musicbrainz.md](./improvements/musicbrainz.md) — Investigate moving off XML ## Dependency Injection -- WebUi now has full DI wiring. However many services are registered as `Scoped` when they're effectively stateless. `CliProcessRunner` is singleton. Review lifetime choices — some could be singletons or transient. +- [dependency-injection.md](./improvements/dependency-injection.md) — Review lifetime choices ## Startup & Recovery -- **Resume in-progress rips on restart** — currently, if the app is restarted while a job is ripping (VideoRipping, TranscodeActive, etc.), the background task is lost and the job stays stuck. On startup, scan for jobs in non-terminal states and resume them: re-attach the MakeMKV/HandBrake process if still running, or restart the rip/transcode stage from where it left off. Requires stage-level checkpointing (which stage completed, which files were produced) so the system can pick up without re-doing completed work. +- [startup-recovery.md](./improvements/startup-recovery.md) — Resume in-progress rips on restart ## Testing -- **Audio CD test:** Deferred — low priority. Needs abcde conf and audio disc in drive. -- **Data disc test:** Deferred — needs data disc for testing. -- **Error recovery tests:** Deferred — needs dirty/scratched discs for edge case testing. -- No CRC64 test with real DVD data (uses synthetic directory). -- No SignalR hub tests. +- [testing.md](./improvements/testing.md) — Deferred / missing tests ## Security -- `LogsController.Reader` uses `Path.GetFileName` for sanitization but could use `Path.GetFullPath` + prefix validation as defense-in-depth. +- [security.md](./improvements/security.md) — LogsController sanitization ## MCP (Model Context Protocol) -- ✅ **MCP server implemented** using the C# SDK (`ModelContextProtocol.AspNetCore` v1.4.1) with HTTP (Streamable HTTP) transport at `/mcp`. -- Exposed tools: - - `get_jobs` — list jobs with optional status filter, `offset`, and `limit` pagination. - - `get_logs` — read job log files with `offset` (line number) and `pageSize` for efficient browsing of long logs. - - `get_config` — returns current ARM Sharp configuration (API key presence is shown as booleans, values are never exposed). -- 🔲 Future tools: `update_config`, `eject_drive`, `trigger_identify`, log streaming via SSE. +- [mcp.md](./improvements/mcp.md) — Exposed tools & future tools ## Container / Deployment -- Docker image is ~2GB with full .NET SDK. Switch to self-contained publish with runtime-only image to reduce size. -- GitHub Actions CI has QEMU set up but only builds `linux/amd64`. Add `linux/arm64` multi-arch build once ARM64 runners or cross-compilation are available. -- **HandBrake nvdec support** — current `arm-dependencies:1.7.3` base image compiles HandBrake without `--enable-nvdec`. The devcontainer has a custom rebuild with nvdec working, but the production Dockerfile still uses the base image's build (no hw-decoding). Need to either fork and rebuild `arm-dependencies`, or add a multi-stage HandBrake build step to the production Dockerfile. -- Docker buildx warning — migrate from legacy builder to BuildKit. +- [container-deployment.md](./improvements/container-deployment.md) — Image size, multi-arch, nvdec, buildkit ## Disc Databases (Track Identification) -- **thediscdb.com integration** — Encrypted BDs often return 0 tracks from `makemkvcon info --robot`. thediscdb.com stores disc IDs mapped to known track layouts. Adding a lookup step would let us skip the expensive `makemkvcon info` scan for known discs and identify the correct main feature track without guessing by filesize. API is simple REST — define a `DiscDatabaseService` client, cache results locally, and plug into `IdentifyService`. +- [disc-databases.md](./improvements/disc-databases.md) — thediscdb.com integration ## Notifications (Low Priority) -Pushbullet, IFTTT, JSON webhook, and Bash script notifications are already implemented in `NotificationService.SendRemoteNotificationsAsync()`. Two additional channels remain: - -### Pushover -- **API:** `POST https://api.pushover.net/1/messages.json` with `token` (app key), `user` (user key), `message`, `title`, `sound`, etc. -- **Config keys:** `PoUserKey` / `PO_USER_KEY` already exist in `ArmSettings` and `ConfigSnapshot`, mapped from YAML. Missing: a `PoAppToken` key for the application token. -- **Implementation:** ~20 lines in `NotificationService` — `SendPushoverAsync(client, appToken, userKey, title, body, ct)`. -- **Settings UI:** Apprise tab currently read-only; would need editable form fields. - -### Apprise -- **CLI:** `apprise` is a command-line tool supporting 80+ notification services (Slack, Discord, Telegram, email, etc.). Original Python ARM invokes it via subprocess. -- **Config key:** `Apprise` / `APPRISE` already exist in `ArmSettings` and `ConfigSnapshot`. -- **Implementation:** ~30 lines in `NotificationService` — call `apprise -b "body" -t "title"` via `CliProcessRunner`. -- **Settings UI:** Same as Pushover — needs editable form on Apprise tab. +- [notifications-pushover.md](./improvements/notifications-pushover.md) — Pushover integration +- [notifications-apprise.md](./improvements/notifications-apprise.md) — Apprise integration diff --git a/docs/improvements/configuration-4k-uhd.md b/docs/improvements/configuration-4k-uhd.md new file mode 100644 index 0000000..5859fe1 --- /dev/null +++ b/docs/improvements/configuration-4k-uhd.md @@ -0,0 +1,7 @@ +# Configuration & Setup — Add 4K UHD Disc Type with Separate Settings + +Currently `ArmSettings` and `ConfigSnapshot` only have `HbArgsDvd` / `HbPresetDvd` and `HbArgsBd` / `HbPresetBd`. 4K UHD discs need different handling (ffmpeg passthrough to preserve HDR, no HandBrake re-encode). + +Add `HbArgsUhd` / `HbPresetUhd` and `FfmpegPostFileArgsUhd` properties, a `DiscType.Uhd` enum value, and wire them through `HandBrakeService.GetHbSettings()` and the `Conductor` pipeline. For 4K UHD, the typical workflow is `USE_FFMPEG: true` with `-c:v copy -c:a ac3 -b:a 640k` to preserve HDR metadata losslessly. + +Users currently have to swap `arm.yaml` manually when switching between 1080p and 4K discs — automatic disc-type detection would eliminate this. diff --git a/docs/improvements/configuration-appsettings.md b/docs/improvements/configuration-appsettings.md new file mode 100644 index 0000000..4e81e24 --- /dev/null +++ b/docs/improvements/configuration-appsettings.md @@ -0,0 +1,3 @@ +# Configuration & Setup — Environment-Aware Config Profiles + +No `appsettings.Production.json` or env-aware config profiles exists. Would help users separate sensitive keys (API keys) from path config. diff --git a/docs/improvements/configuration-seed-data.md b/docs/improvements/configuration-seed-data.md new file mode 100644 index 0000000..a567ec3 --- /dev/null +++ b/docs/improvements/configuration-seed-data.md @@ -0,0 +1,3 @@ +# Configuration & Setup — Auto-Seed Data + +Seed data scripts exist in `scripts/` but require manual run. Consider auto-seeding on first launch for demo/testing. diff --git a/docs/improvements/container-deployment.md b/docs/improvements/container-deployment.md new file mode 100644 index 0000000..9f321ce --- /dev/null +++ b/docs/improvements/container-deployment.md @@ -0,0 +1,13 @@ +# Container / Deployment + +## Docker Image Size +Docker image is ~2GB with full .NET SDK. Switch to self-contained publish with runtime-only image to reduce size. + +## Multi-Arch Builds +GitHub Actions CI has QEMU set up but only builds `linux/amd64`. Add `linux/arm64` multi-arch build once ARM64 runners or cross-compilation are available. + +## HandBrake nvdec Support +Current `arm-dependencies:1.7.3` base image compiles HandBrake without `--enable-nvdec`. The devcontainer has a custom rebuild with nvdec working, but the production Dockerfile still uses the base image's build (no hw-decoding). Need to either fork and rebuild `arm-dependencies`, or add a multi-stage HandBrake build step to the production Dockerfile. + +## BuildKit Migration +Docker buildx warning — migrate from legacy builder to BuildKit. diff --git a/docs/improvements/data-persistence.md b/docs/improvements/data-persistence.md new file mode 100644 index 0000000..d02f7c8 --- /dev/null +++ b/docs/improvements/data-persistence.md @@ -0,0 +1,5 @@ +# Data / Persistence — Migrate from `DateTime` to `DateTimeOffset` + +All model timestamps (`Job.StartTime`, `Job.StopTime`, `Notification.Timestamp`, `DiscMetadata.CreatedAt`/`LastUsedAt`, etc.) use `DateTime`. EF Core reads these back as `DateTimeKind.Unspecified` from SQLite, losing the UTC context. `.ToLocalTime()` in views works but is a band-aid. + +Switching to `DateTimeOffset` stores the offset with the value and makes timezone intent explicit. Requires touching models, EF mappings, all view comparisons, and serialization. diff --git a/docs/improvements/dependency-injection.md b/docs/improvements/dependency-injection.md new file mode 100644 index 0000000..b89c512 --- /dev/null +++ b/docs/improvements/dependency-injection.md @@ -0,0 +1,3 @@ +# Dependency Injection — Review Lifetime Choices + +WebUi now has full DI wiring. However many services are registered as `Scoped` when they're effectively stateless. `CliProcessRunner` is singleton. Review lifetime choices — some could be singletons or transient. diff --git a/docs/improvements/disc-databases.md b/docs/improvements/disc-databases.md new file mode 100644 index 0000000..7724871 --- /dev/null +++ b/docs/improvements/disc-databases.md @@ -0,0 +1,5 @@ +# Disc Databases — thediscdb.com Integration + +Encrypted BDs often return 0 tracks from `makemkvcon info --robot`. thediscdb.com stores disc IDs mapped to known track layouts. Adding a lookup step would let us skip the expensive `makemkvcon info` scan for known discs and identify the correct main feature track without guessing by filesize. + +API is simple REST — define a `DiscDatabaseService` client, cache results locally, and plug into `IdentifyService`. diff --git a/docs/improvements/disc-type-audiobook-mp3.md b/docs/improvements/disc-type-audiobook-mp3.md new file mode 100644 index 0000000..a4aa558 --- /dev/null +++ b/docs/improvements/disc-type-audiobook-mp3.md @@ -0,0 +1,34 @@ +# Disc Type Detection — MP3 Audiobook / Audio CD-ROM Support + +## Context + +Discovered while investigating job 607: an "Angels & Demons" audiobook disc mounted as an iso9660 filesystem containing `.mp3` files at the root — no `VIDEO_TS`, `BDMV`, or `CDA` directories present. + +## The Problem + +`GetDiscType()` in `IdentifyService.cs` only recognizes three disc structures: + +| Check | Pattern | Detects | +|-------|---------|---------| +| `Directory.Exists("VIDEO_TS")` | DVD | ✅ | +| `Directory.Exists("BDMV")` | Blu-ray | ✅ | +| `FindOnDisc("CDA")` | Audio CD | ✅ | +| *(everything else)* | `DiscType.Unknown` | ❌ Fails | + +An MP3 audiobook disc has none of these structures, so the Conductor hits the `default` case and the job fails with: + +> `Couldn't identify the disc type. Exiting without any action.` + +## What Should Happen + +The system already has a `DiscType.Data` case in the Conductor that calls `RipDataAsync`, and an existing `DiscType.Music` path using MusicBrainZ + `RipMusicAsync`. We should add MP3/audio file detection to `GetDiscType()`: + +- Scan the root of the mounted disc for common audio file extensions (`.mp3`, `.flac`, `.ogg`, `.wav`, `.m4a`, `.aac`) +- If audio files are found, classify as `DiscType.Music` so the existing music pipeline can handle it +- Alternatively, classify as `DiscType.Data` as a fallback for discs with files but no known video/audio directory structure + +## Open Questions + +- Should `RipMusicAsync` / the music pipeline handle MP3 files, or should this be a generic file copy (data disc) operation? +- Would MusicBrainZ identification work for an audiobook with no CDDA tracks? +- Should we add a new `DiscType.Audiobook` for dedicated handling? diff --git a/docs/improvements/gitignore.md b/docs/improvements/gitignore.md new file mode 100644 index 0000000..3b5bcb4 --- /dev/null +++ b/docs/improvements/gitignore.md @@ -0,0 +1,3 @@ +# Gitignore + +DB file `data/arm.db` is not gitignored — should be added so test/seed data isn't committed. diff --git a/docs/improvements/mcp.md b/docs/improvements/mcp.md new file mode 100644 index 0000000..8bc433c --- /dev/null +++ b/docs/improvements/mcp.md @@ -0,0 +1,16 @@ +# MCP (Model Context Protocol) + +✅ **MCP server implemented** using the C# SDK (`ModelContextProtocol.AspNetCore` v1.4.1) with HTTP (Streamable HTTP) transport at `/mcp`. + +## Exposed Tools + +- `get_jobs` — list jobs with optional status filter, `offset`, and `limit` pagination. +- `get_logs` — read job log files with `offset` (line number) and `pageSize` for efficient browsing of long logs. +- `get_config` — returns current ARM Sharp configuration (API key presence is shown as booleans, values are never exposed). + +## Future Tools (🔲) + +- `update_config` +- `eject_drive` +- `trigger_identify` +- Log streaming via SSE diff --git a/docs/improvements/musicbrainz.md b/docs/improvements/musicbrainz.md new file mode 100644 index 0000000..2f419d2 --- /dev/null +++ b/docs/improvements/musicbrainz.md @@ -0,0 +1,3 @@ +# MusicBrainz — Investigate Moving Off XML + +MusicBrainz XML parsing is fragile (manual XElement traversal, namespace handling). If MusicBrainz offers a JSON endpoint, prefer it. diff --git a/docs/improvements/notifications-apprise.md b/docs/improvements/notifications-apprise.md new file mode 100644 index 0000000..e13c4e2 --- /dev/null +++ b/docs/improvements/notifications-apprise.md @@ -0,0 +1,6 @@ +# Notifications — Apprise + +- **CLI:** `apprise` is a command-line tool supporting 80+ notification services (Slack, Discord, Telegram, email, etc.). Original Python ARM invokes it via subprocess. +- **Config key:** `Apprise` / `APPRISE` already exist in `ArmSettings` and `ConfigSnapshot`. +- **Implementation:** ~30 lines in `NotificationService` — call `apprise -b "body" -t "title"` via `CliProcessRunner`. +- **Settings UI:** Same as Pushover — needs editable form on Apprise tab. diff --git a/docs/improvements/notifications-pushover.md b/docs/improvements/notifications-pushover.md new file mode 100644 index 0000000..572bc61 --- /dev/null +++ b/docs/improvements/notifications-pushover.md @@ -0,0 +1,6 @@ +# Notifications — Pushover + +- **API:** `POST https://api.pushover.net/1/messages.json` with `token` (app key), `user` (user key), `message`, `title`, `sound`, etc. +- **Config keys:** `PoUserKey` / `PO_USER_KEY` already exist in `ArmSettings` and `ConfigSnapshot`, mapped from YAML. Missing: a `PoAppToken` key for the application token. +- **Implementation:** ~20 lines in `NotificationService` — `SendPushoverAsync(client, appToken, userKey, title, body, ct)`. +- **Settings UI:** Apprise tab currently read-only; would need editable form fields. diff --git a/docs/improvements/pages-batch-actions.md b/docs/improvements/pages-batch-actions.md new file mode 100644 index 0000000..73679ad --- /dev/null +++ b/docs/improvements/pages-batch-actions.md @@ -0,0 +1,3 @@ +# Pages / Views — Batch Actions on Active Rips Page + +Add batch actions (abandon all, retry all) on the Active Rips page. diff --git a/docs/improvements/pages-dashboard.md b/docs/improvements/pages-dashboard.md new file mode 100644 index 0000000..c483e5e --- /dev/null +++ b/docs/improvements/pages-dashboard.md @@ -0,0 +1,3 @@ +# Pages / Views — Home Dashboard Enhancements + +Core metrics displayed. Could add charts (job success rate over time, rips per day) or sparkline trends. diff --git a/docs/improvements/pages-disc-detection.md b/docs/improvements/pages-disc-detection.md new file mode 100644 index 0000000..388888d --- /dev/null +++ b/docs/improvements/pages-disc-detection.md @@ -0,0 +1,3 @@ +# Pages / Views — DVD/Blu-ray Detection Workflow + +The Settings page has a "Detect Disc" / "Scan Drives" button but the actual udev-based monitoring workflow from the original ARM isn't replicated. Should add a "Start Monitoring" action that runs the Conductor/IdentifyService loop. diff --git a/docs/improvements/pages-identification.md b/docs/improvements/pages-identification.md new file mode 100644 index 0000000..71c4e5d --- /dev/null +++ b/docs/improvements/pages-identification.md @@ -0,0 +1,3 @@ +# Pages / Views — Redesign Identification Section + +Improve the layout of the Identification section on the Job detail page. diff --git a/docs/improvements/security.md b/docs/improvements/security.md new file mode 100644 index 0000000..eb433f3 --- /dev/null +++ b/docs/improvements/security.md @@ -0,0 +1,3 @@ +# Security — LogsController Sanitization + +`LogsController.Reader` uses `Path.GetFileName` for sanitization but could use `Path.GetFullPath` + prefix validation as defense-in-depth. diff --git a/docs/improvements/signalr.md b/docs/improvements/signalr.md new file mode 100644 index 0000000..0424812 --- /dev/null +++ b/docs/improvements/signalr.md @@ -0,0 +1,3 @@ +# SignalR — Broadcaster Lifetime Concerns + +`SignalRNotificationBroadcaster` is wired via `INotificationBroadcaster` interface. Works but the broadcaster is a singleton while the hub context is scoped per connection. Should verify no lifetime issues. diff --git a/docs/improvements/startup-recovery.md b/docs/improvements/startup-recovery.md new file mode 100644 index 0000000..3f21b8f --- /dev/null +++ b/docs/improvements/startup-recovery.md @@ -0,0 +1,3 @@ +# Startup & Recovery — Resume In-Progress Rips on Restart + +Currently, if the app is restarted while a job is ripping (VideoRipping, TranscodeActive, etc.), the background task is lost and the job stays stuck. On startup, scan for jobs in non-terminal states and resume them: re-attach the MakeMKV/HandBrake process if still running, or restart the rip/transcode stage from where it left off. Requires stage-level checkpointing (which stage completed, which files were produced) so the system can pick up without re-doing completed work. diff --git a/docs/improvements/testing.md b/docs/improvements/testing.md new file mode 100644 index 0000000..c02a43a --- /dev/null +++ b/docs/improvements/testing.md @@ -0,0 +1,7 @@ +# Testing — Deferred / Missing Tests + +- **Audio CD test:** Deferred — low priority. Needs abcde conf and audio disc in drive. +- **Data disc test:** Deferred — needs data disc for testing. +- **Error recovery tests:** Deferred — needs dirty/scratched discs for edge case testing. +- No CRC64 test with real DVD data (uses synthetic directory). +- No SignalR hub tests. diff --git a/docs/improvements/ui-restart-last-stage.md b/docs/improvements/ui-restart-last-stage.md new file mode 100644 index 0000000..0342a94 --- /dev/null +++ b/docs/improvements/ui-restart-last-stage.md @@ -0,0 +1,3 @@ +# UI / User Experience — Restart from Last Successful Stage + +Add a "retry from failure" action that resumes the pipeline at the last failed stage instead of restarting from scratch. Requires each stage to checkpoint its completion state in the DB (e.g. a `Stages` table or bitfield on `Job`).