Skip to content

Commit 7b7fdd5

Browse files
committed
Fix duplicate encodes, worker shutdown, VLC Blu-ray preview, and rip progress stall
- WorkerReinstall: replace 0.5s sleep with pgrep wait loop (up to 5s) so the old worker fully exits before the new one starts; kill orphaned HandBrakeCLI after - EncodeWorker.stop(): call handbrakeRunner.cancel() to kill in-flight HandBrakeCLI so it doesn't outlive the worker; treat CancellationError as pending not error - HandBrakeRunner/HandBrakeRunning: add cancel() to protocol and implementation - WorkerReinstall: open full app path instead of -a flag to launch installed binary - Makefile: split --product builds (combined call silently dropped one binary); kill running FrostscribeUI before installing new app - TitleSelectionView: fix VLC Blu-ray playback using --bluray-title= arg via Process - RipFlowViewModel: detect MakeMKV "Copy complete" and clamp rip progress to 100%
1 parent e0218b6 commit 7b7fdd5

File tree

7 files changed

+77
-22
lines changed

7 files changed

+77
-22
lines changed

FrostscribeUI/FrostscribeUI/ViewModels/RipFlowViewModel.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -456,7 +456,12 @@ final class RipFlowCoordinator {
456456
let statusFile = try? statusMgr.read()
457457

458458
if let file = statusFile, file.status == .ripping, let currentJob = file.currentJob {
459-
let pct = Int(currentJob.progress.replacing("%", with: "")) ?? 0
459+
var pct = Int(currentJob.progress.replacing("%", with: "")) ?? 0
460+
// MakeMKV reports "Copy complete" at ~97% before the worker moves the
461+
// file — treat this as 100% so the UI doesn't appear frozen.
462+
if let msg = currentJob.currentItem, msg.hasPrefix("Copy complete") {
463+
pct = 100
464+
}
460465
phase = .ripping(title: title, progress: pct)
461466
if let msg = currentJob.currentItem, !msg.isEmpty {
462467
ripMessage = msg

FrostscribeUI/FrostscribeUI/Views/Rip/TitleSelectionView.swift

Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,13 @@ 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))
20+
/// Finds the mounted disc volume by looking for a BDMV (Blu-ray) or VIDEO_TS (DVD) folder.
21+
private var discVolumePath: URL? {
22+
let fm = FileManager.default
23+
let vols = fm.mountedVolumeURLs(includingResourceValuesForKeys: nil) ?? []
24+
return vols.first { vol in
25+
fm.fileExists(atPath: vol.appending(path: "BDMV").path) ||
26+
fm.fileExists(atPath: vol.appending(path: "VIDEO_TS").path)
2827
}
2928
}
3029

@@ -58,7 +57,7 @@ struct TitleSelectionView: View {
5857
title: title,
5958
isMain: title.isMainTitleCandidate,
6059
isSuggested: title.number == vm.suggestedTitleNumber,
61-
discDevicePath: discDevicePath,
60+
discVolumePath: discVolumePath,
6261
discType: scanResult.discType
6362
)
6463
.contentShape(Rectangle())
@@ -79,7 +78,7 @@ private struct TitleRow: View {
7978
let title: DiscTitle
8079
let isMain: Bool
8180
let isSuggested: Bool
82-
let discDevicePath: String?
81+
let discVolumePath: URL?
8382
let discType: DiscType
8483

8584
@State private var showDetail = false
@@ -131,7 +130,7 @@ private struct TitleRow: View {
131130
Spacer(minLength: 0)
132131

133132
// Play in VLC button
134-
if discDevicePath != nil {
133+
if discVolumePath != nil {
135134
Button {
136135
playInVLC()
137136
} label: {
@@ -161,16 +160,28 @@ private struct TitleRow: View {
161160
}
162161

163162
private func playInVLC() {
164-
guard let device = discDevicePath else { return }
165-
let scheme = discType == .dvd ? "dvd" : "bluray"
163+
guard let volPath = discVolumePath else { return }
166164
// VLC uses 1-indexed titles; MakeMKV uses 0-indexed.
167165
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())
166+
let vlcExec = "/Applications/VLC.app/Contents/MacOS/VLC"
167+
168+
if discType == .dvd {
169+
// DVD: #N title selector works in the MRL
170+
let urlString = "dvd://\(volPath.path)#\(titleNum)"
171+
guard let url = URL(string: urlString) else { return }
172+
if let vlcURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "org.videolan.vlc") {
173+
NSWorkspace.shared.open([url], withApplicationAt: vlcURL,
174+
configuration: NSWorkspace.OpenConfiguration())
175+
} else {
176+
NSWorkspace.shared.open(url)
177+
}
172178
} else {
173-
NSWorkspace.shared.open(url)
179+
// Blu-ray: title selection requires --bluray-title=N CLI argument
180+
let mrl = "bluray://\(volPath.path)/"
181+
let proc = Process()
182+
proc.executableURL = URL(fileURLWithPath: vlcExec)
183+
proc.arguments = ["--bluray-title=\(titleNum)", mrl]
184+
try? proc.run()
174185
}
175186
}
176187

Makefile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ BREW_BIN := $(shell brew --prefix)/bin
1515

1616
install:
1717
@echo "→ Building CLI and worker..."
18-
swift build -c release --product frostscribe --product frostscribe-worker
18+
swift build -c release --product frostscribe
19+
swift build -c release --product frostscribe-worker
1920
@echo "→ Installing CLI binaries to $(BREW_BIN)..."
2021
cp $(CURDIR)/.build/release/frostscribe $(BREW_BIN)/frostscribe
2122
cp $(CURDIR)/.build/release/frostscribe-worker $(BREW_BIN)/frostscribe-worker
@@ -26,6 +27,8 @@ install:
2627
-derivedDataPath /tmp/frostscribe-build \
2728
build 2>&1 | grep -E "error:|warning:|BUILD"
2829
@echo "→ Installing FrostscribeUI..."
30+
killall FrostscribeUI 2>/dev/null || true
31+
sleep 0.5
2932
rm -rf /Applications/FrostscribeUI.app
3033
cp -R /tmp/frostscribe-build/Build/Products/Release/FrostscribeUI.app /Applications/
3134
@echo "→ Reinstalling and starting worker..."

Sources/FrostscribeCLI/Commands/WorkerCommand.swift

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,31 @@ struct WorkerReinstall: ParsableCommand {
161161
func run() throws {
162162
Colors.info("Stopping worker...")
163163
_ = launchctl("bootout", "gui/\(getuid())/\(label)")
164-
Thread.sleep(forTimeInterval: 0.5)
164+
165+
// Wait for the worker process to fully exit (up to 5 s) before proceeding.
166+
// A 0.5 s sleep is not enough — the worker may still be cancelling HandBrakeCLI
167+
// and resetting queue state. Starting a new worker while the old one is alive
168+
// causes both to poll the queue simultaneously and spawn duplicate encodes.
169+
let workerName = URL(fileURLWithPath: resolveWorkerBin()).lastPathComponent
170+
for _ in 0..<50 {
171+
Thread.sleep(forTimeInterval: 0.1)
172+
let check = Process()
173+
check.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep")
174+
check.arguments = ["-x", workerName]
175+
check.standardOutput = FileHandle.nullDevice
176+
check.standardError = FileHandle.nullDevice
177+
guard (try? check.run()) != nil else { break }
178+
check.waitUntilExit()
179+
if check.terminationStatus != 0 { break } // no matching process — worker is gone
180+
}
181+
182+
// Kill any orphaned HandBrakeCLI processes left behind by the old worker.
183+
let killHB = Process()
184+
killHB.executableURL = URL(fileURLWithPath: "/usr/bin/killall")
185+
killHB.arguments = ["-9", "HandBrakeCLI"]
186+
killHB.standardOutput = FileHandle.nullDevice
187+
killHB.standardError = FileHandle.nullDevice
188+
if (try? killHB.run()) != nil { killHB.waitUntilExit() }
165189

166190
let binPath = resolveWorkerBin()
167191
let logPath = ConfigManager.appSupportURL.appending(path: "worker.log").path
@@ -192,7 +216,7 @@ struct WorkerReinstall: ParsableCommand {
192216
Colors.info("Opening FrostscribeUI...")
193217
let open = Process()
194218
open.executableURL = URL(fileURLWithPath: "/usr/bin/open")
195-
open.arguments = ["-a", "FrostscribeUI"]
219+
open.arguments = ["/Applications/FrostscribeUI.app"]
196220
try? open.run()
197221

198222
Colors.success("Reinstall complete.")

Sources/FrostscribeCore/Encoding/HandBrakeRunner.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@ public final class HandBrakeRunner: HandBrakeRunning, @unchecked Sendable {
8989
}
9090
}
9191

92+
public func cancel() {
93+
activeProcess?.terminate()
94+
}
95+
9296
private func resolvedBinPath() -> String {
9397
if binPath.isEmpty { return "HandBrakeCLI" }
9498
return binPath

Sources/FrostscribeCore/Protocols/HandBrakeRunning.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@ public protocol HandBrakeRunning: Sendable {
1010
encoderType: EncoderType,
1111
onProgress: @escaping @Sendable (Double) -> Void
1212
) async throws
13+
func cancel()
1314
}

Sources/FrostscribeWorker/EncodeWorker.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ actor EncodeWorker {
4949
func stop() {
5050
log("Frostscribe worker shutting down…")
5151
running = false
52+
// Kill any in-flight HandBrakeCLI process so it doesn't outlive the worker.
53+
handbrakeRunner.cancel()
5254
// Reset any in-flight job back to pending so the next worker start picks it up.
5355
if let jobs = try? queueManager.read() {
5456
for job in jobs where job.status == .encoding {
@@ -119,6 +121,11 @@ actor EncodeWorker {
119121
try? FileManager.default.removeItem(at: rawMKV.deletingLastPathComponent())
120122

121123
hookRunner.fire(event: "encode_complete", title: "Encode Complete", body: job.label)
124+
} catch is CancellationError {
125+
// HandBrakeCLI was killed by a signal (e.g. worker restart) — reset to pending
126+
// so the next worker start picks it up instead of leaving it in error state.
127+
log("Encode cancelled (process killed), resetting to pending: \(job.label)")
128+
try? queueManager.updateStatus(id: job.id, status: .pending)
122129
} catch {
123130
let nsErr = error as NSError
124131
if nsErr.domain == NSCocoaErrorDomain && nsErr.code == 513 {

0 commit comments

Comments
 (0)