Skip to content

Commit e0218b6

Browse files
committed
Skip DVD encoding, tune hardware quality, fix rip freeze UI, add VLC preview
- Add skipEncodingDVD config flag: raw MKV moved directly to library, bypassing HandBrake for DVDs - Fix rip progress UI freezing at last reported % after MakeMKV completes — now shows 100% while worker finalizes the file move - Add Play in VLC button to title selection rows for previewing disc titles before ripping - Fix resolveWorkerBin() to prefer installed Homebrew paths over sibling binary, preventing stale plist after reinstall - Tune hardware (VideoToolbox) quality values to produce reasonable file sizes; rf18 → 80 - DVD encoding args: --height 720 --comb-detect --decomb --color-matrix 601
1 parent 777fb99 commit e0218b6

File tree

9 files changed

+126
-34
lines changed

9 files changed

+126
-34
lines changed

FrostscribeUI/FrostscribeUI/ViewModels/RipFlowViewModel.swift

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -453,9 +453,9 @@ final class RipFlowCoordinator {
453453
let statusMgr = StatusManager(appSupportURL: ConfigManager.appSupportURL)
454454

455455
while !Task.isCancelled {
456-
if let file = try? statusMgr.read(),
457-
file.status == .ripping,
458-
let currentJob = file.currentJob {
456+
let statusFile = try? statusMgr.read()
457+
458+
if let file = statusFile, file.status == .ripping, let currentJob = file.currentJob {
459459
let pct = Int(currentJob.progress.replacing("%", with: "")) ?? 0
460460
phase = .ripping(title: title, progress: pct)
461461
if let msg = currentJob.currentItem, !msg.isEmpty {
@@ -473,7 +473,11 @@ final class RipFlowCoordinator {
473473
phase = .error(job.errorMessage ?? "Rip failed")
474474
return
475475
default:
476-
break
476+
// Status went idle but queue not yet updated — rip finished, worker is
477+
// moving the file. Show 100% so the UI doesn't appear frozen.
478+
if statusFile?.status != .ripping {
479+
phase = .ripping(title: title, progress: 100)
480+
}
477481
}
478482
} else {
479483
phase = .idle

FrostscribeUI/FrostscribeUI/Views/Rip/TitleSelectionView.swift

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,17 @@ struct TitleSelectionView: View {
1717

1818
private var filteredCount: Int { scanResult.titles.count - displayedTitles.count }
1919

20+
/// Resolves the block device path (e.g. /dev/disk10) for the mounted disc volume.
21+
private var discDevicePath: String? {
22+
guard let name = scanResult.discName else { return nil }
23+
let volPath = "/Volumes/\(name)"
24+
var fs = statfs()
25+
guard statfs(volPath, &fs) == 0 else { return nil }
26+
return withUnsafeBytes(of: fs.f_mntfromname) { bytes in
27+
String(cString: bytes.baseAddress!.assumingMemoryBound(to: CChar.self))
28+
}
29+
}
30+
2031
var body: some View {
2132
VStack(alignment: .leading, spacing: 0) {
2233
HStack {
@@ -46,7 +57,9 @@ struct TitleSelectionView: View {
4657
TitleRow(
4758
title: title,
4859
isMain: title.isMainTitleCandidate,
49-
isSuggested: title.number == vm.suggestedTitleNumber
60+
isSuggested: title.number == vm.suggestedTitleNumber,
61+
discDevicePath: discDevicePath,
62+
discType: scanResult.discType
5063
)
5164
.contentShape(Rectangle())
5265
.onTapGesture {
@@ -66,6 +79,8 @@ private struct TitleRow: View {
6679
let title: DiscTitle
6780
let isMain: Bool
6881
let isSuggested: Bool
82+
let discDevicePath: String?
83+
let discType: DiscType
6984

7085
@State private var showDetail = false
7186

@@ -115,6 +130,19 @@ private struct TitleRow: View {
115130

116131
Spacer(minLength: 0)
117132

133+
// Play in VLC button
134+
if discDevicePath != nil {
135+
Button {
136+
playInVLC()
137+
} label: {
138+
Image(systemName: "play.circle")
139+
.font(.system(size: 18))
140+
.foregroundStyle(FrostTheme.glacier.opacity(0.7))
141+
}
142+
.buttonStyle(.plain)
143+
.help("Play title \(title.number) in VLC")
144+
}
145+
118146
// Info button
119147
Button {
120148
showDetail = true
@@ -132,6 +160,20 @@ private struct TitleRow: View {
132160
.opacity(isMain ? 1.0 : 0.75)
133161
}
134162

163+
private func playInVLC() {
164+
guard let device = discDevicePath else { return }
165+
let scheme = discType == .dvd ? "dvd" : "bluray"
166+
// VLC uses 1-indexed titles; MakeMKV uses 0-indexed.
167+
let titleNum = title.number + 1
168+
guard let url = URL(string: "\(scheme)://\(device)#\(titleNum)") else { return }
169+
if let vlcURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "org.videolan.vlc") {
170+
NSWorkspace.shared.open([url], withApplicationAt: vlcURL,
171+
configuration: NSWorkspace.OpenConfiguration())
172+
} else {
173+
NSWorkspace.shared.open(url)
174+
}
175+
}
176+
135177
// MARK: - Sub-views
136178

137179
@ViewBuilder

FrostscribeUI/FrostscribeUI/Views/SettingsView.swift

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,14 @@ struct SettingsView: View {
3838
}
3939

4040
Section("Encoder & Quality") {
41+
VStack(alignment: .leading, spacing: 4) {
42+
Toggle("Skip encoding for DVDs", isOn: $config.skipEncodingDVD)
43+
.tint(FrostTheme.frostCyan)
44+
.onChange(of: config.skipEncodingDVD) { save() }
45+
Text("Raw MKV is moved directly to the library. Recommended when storage is not a concern.")
46+
.font(.caption)
47+
.foregroundStyle(.secondary)
48+
}
4149
encoderQualityRow("DVD", encoder: $config.encoderTypeDVD, quality: $config.qualityDVD)
4250
encoderQualityRow("Blu-ray", encoder: $config.encoderTypeBluray, quality: $config.qualityBluray)
4351
encoderQualityRow("UHD", encoder: $config.encoderTypeUHD, quality: $config.qualityUHD)
@@ -317,7 +325,10 @@ private struct WorkerHealthSection: View {
317325
Task.detached {
318326
let start = Date()
319327
let p = Process()
320-
p.executableURL = URL(fileURLWithPath: "/usr/local/bin/frostscribe")
328+
let frostscribeBin = FileManager.default.fileExists(atPath: "/opt/homebrew/bin/frostscribe")
329+
? "/opt/homebrew/bin/frostscribe"
330+
: "/usr/local/bin/frostscribe"
331+
p.executableURL = URL(fileURLWithPath: frostscribeBin)
321332
p.arguments = ["worker", "reinstall"]
322333
p.standardOutput = FileHandle.nullDevice
323334
p.standardError = FileHandle.nullDevice

Sources/FrostscribeCLI/Commands/WorkerCommand.swift

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -275,17 +275,17 @@ private func launchctl(_ args: String...) -> Int32 {
275275
}
276276

277277
private func resolveWorkerBin() -> String {
278-
let sibling = URL(fileURLWithPath: CommandLine.arguments[0])
279-
.deletingLastPathComponent()
280-
.appending(path: "frostscribe-worker")
281-
.path
282-
283-
if FileManager.default.fileExists(atPath: sibling) { return sibling }
284-
278+
// Prefer installed Homebrew/system paths over the sibling so that
279+
// reinstall always writes the stable installed binary to the plist,
280+
// not a temp build-dir path.
285281
for path in ["/opt/homebrew/bin/frostscribe-worker", "/usr/local/bin/frostscribe-worker"] {
286282
if FileManager.default.fileExists(atPath: path) { return path }
287283
}
288284

285+
let sibling = URL(fileURLWithPath: CommandLine.arguments[0])
286+
.deletingLastPathComponent()
287+
.appending(path: "frostscribe-worker")
288+
.path
289289
return sibling
290290
}
291291

Sources/FrostscribeCore/Config/Config.swift

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ public struct Config: Sendable {
2020
public var qualityBluray: EncodeQuality
2121
public var qualityUHD: EncodeQuality
2222
public var filterMovieTitles: Bool
23+
/// When true, DVD rips skip HandBrake and the raw MKV is moved directly to the library.
24+
public var skipEncodingDVD: Bool
2325

2426
public init(
2527
mediaServer: MediaServer = .jellyfin,
@@ -39,7 +41,8 @@ public struct Config: Sendable {
3941
qualityDVD: EncodeQuality = .rf20,
4042
qualityBluray: EncodeQuality = .rf18,
4143
qualityUHD: EncodeQuality = .rf18,
42-
filterMovieTitles: Bool = true
44+
filterMovieTitles: Bool = true,
45+
skipEncodingDVD: Bool = false
4346
) {
4447
self.mediaServer = mediaServer
4548
self.moviesDir = moviesDir
@@ -59,6 +62,7 @@ public struct Config: Sendable {
5962
self.qualityBluray = qualityBluray
6063
self.qualityUHD = qualityUHD
6164
self.filterMovieTitles = filterMovieTitles
65+
self.skipEncodingDVD = skipEncodingDVD
6266
}
6367
}
6468

@@ -79,7 +83,7 @@ extension Config: Codable {
7983
selectAudioTracks,
8084
encoderTypeDVD, encoderTypeBluray, encoderTypeUHD,
8185
qualityDVD, qualityBluray, qualityUHD,
82-
filterMovieTitles
86+
filterMovieTitles, skipEncodingDVD
8387
}
8488

8589
public init(from decoder: Decoder) throws {
@@ -102,5 +106,6 @@ extension Config: Codable {
102106
qualityBluray = (try? c.decode(EncodeQuality.self, forKey: .qualityBluray)) ?? .rf18
103107
qualityUHD = (try? c.decode(EncodeQuality.self, forKey: .qualityUHD)) ?? .rf18
104108
filterMovieTitles = (try? c.decode(Bool.self, forKey: .filterMovieTitles)) ?? true
109+
skipEncodingDVD = (try? c.decode(Bool.self, forKey: .skipEncodingDVD)) ?? false
105110
}
106111
}

Sources/FrostscribeCore/Encoding/EncoderPreset.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ public enum EncoderPreset {
3737
args += ["--encoder-preset", "quality"]
3838
}
3939
if isDVD {
40-
args += ["--comb-detect", "--decomb", "--color-matrix", "601"]
40+
// Upscale DVDs to 720p — Infuse labels correctly, much faster than 1080p.
41+
args += ["--height", "720", "--comb-detect", "--decomb", "--color-matrix", "601"]
4142
}
4243
return args + audio
4344
}

Sources/FrostscribeCore/Models/EncodeQuality.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,10 @@ public enum EncodeQuality: Int, Codable, Sendable, CaseIterable {
1919
public var hardwareQuality: Int {
2020
switch self {
2121
case .rf18: return 80
22-
case .rf20: return 72
23-
case .rf22: return 65
24-
case .rf24: return 58
25-
case .rf26: return 50
22+
case .rf20: return 82
23+
case .rf22: return 75
24+
case .rf24: return 67
25+
case .rf26: return 60
2626
}
2727
}
2828

Sources/FrostscribeWorker/EncodeWorker.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,18 @@ actor EncodeWorker {
8989

9090
let config = (try? ConfigManager().load()) ?? Config()
9191
let discType = DiscType(rawValue: job.discType) ?? .bluray
92+
let skipEncode = config.skipEncodingDVD && (discType == .dvd || discType == .unknown)
93+
94+
if skipEncode {
95+
try? FileManager.default.removeItem(at: output)
96+
try FileManager.default.moveItem(at: input, to: output)
97+
try? FileManager.default.removeItem(at: input.deletingLastPathComponent())
98+
log("Encode skipped, raw MKV moved to library: \(job.label)")
99+
try queueManager.updateStatus(id: job.id, status: .done, completedAt: .now)
100+
hookRunner.fire(event: "encode_complete", title: "Rip Complete", body: job.label)
101+
return
102+
}
103+
92104
let quality = EncoderPreset.quality(for: discType, config: config)
93105
let encoderType = config.encoderType(for: discType)
94106
// Throttle progress writes: only update queue.json when progress changes by ≥0.5%

Sources/FrostscribeWorker/RipWorker.swift

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -117,21 +117,38 @@ actor RipWorker {
117117
}
118118
watchTask.cancel()
119119

120-
// Hand off to encode queue
121-
try encodeQueueManager.add(
122-
input: mkvURL,
123-
output: URL(fileURLWithPath: job.outputPath),
124-
preset: job.preset,
125-
discType: job.discType,
126-
title: job.encodeTitle,
127-
episode: job.episode,
128-
audioTracks: job.audioTracks
129-
)
120+
let config = (try? ConfigManager().load()) ?? Config()
121+
let skipEncode = config.skipEncodingDVD && (discType == .dvd || discType == .unknown)
130122

131-
log("Rip complete, queued encode: \(job.jobLabel)")
132-
try ripQueueManager.updateStatus(id: job.id, status: .done, completedAt: .now)
133-
hookRunner.fire(event: "rip_complete", title: "Rip Complete",
134-
body: "\(job.jobLabel) added to encode queue")
123+
if skipEncode {
124+
// Move raw MKV directly to the library, skipping HandBrake.
125+
let outputURL = URL(fileURLWithPath: job.outputPath)
126+
try FileManager.default.createDirectory(
127+
at: outputURL.deletingLastPathComponent(),
128+
withIntermediateDirectories: true
129+
)
130+
try? FileManager.default.removeItem(at: outputURL)
131+
try FileManager.default.moveItem(at: mkvURL, to: outputURL)
132+
try? FileManager.default.removeItem(at: mkvURL.deletingLastPathComponent())
133+
log("Rip complete, raw MKV moved to library (encoding skipped): \(job.jobLabel)")
134+
try ripQueueManager.updateStatus(id: job.id, status: .done, completedAt: .now)
135+
hookRunner.fire(event: "rip_complete", title: "Rip Complete", body: job.jobLabel)
136+
} else {
137+
// Hand off to encode queue
138+
try encodeQueueManager.add(
139+
input: mkvURL,
140+
output: URL(fileURLWithPath: job.outputPath),
141+
preset: job.preset,
142+
discType: job.discType,
143+
title: job.encodeTitle,
144+
episode: job.episode,
145+
audioTracks: job.audioTracks
146+
)
147+
log("Rip complete, queued encode: \(job.jobLabel)")
148+
try ripQueueManager.updateStatus(id: job.id, status: .done, completedAt: .now)
149+
hookRunner.fire(event: "rip_complete", title: "Rip Complete",
150+
body: "\(job.jobLabel) added to encode queue")
151+
}
135152
} catch {
136153
let nsErr = error as NSError
137154
if nsErr.domain == NSCocoaErrorDomain && nsErr.code == 513 {

0 commit comments

Comments
 (0)