Skip to content

Commit c40ad39

Browse files
trevhollidayclaude
andcommitted
Add worker management, abort rip, rip queue CLI, SQLite logs, font scaling, and UX polish
- Worker: auto-kick on rip confirm; kickWorker checks running state before starting - Abort rip: writes cancelled status to queue; worker watcher terminates makemkvcon via SIGTERM - rip-queue CLI: list / clear / reset-stuck subcommands; cancelled status added to RipQueueJob - Worker management: frostscribe worker reinstall + health commands; Settings health panel with live status, queue counts, last log line, and Reinstall & Restart button (min 600ms loading) - status reset: frostscribe status reset clears stale ripping state after crashes - SQLite log store: GRDB-backed logs.db with 7-day retention; LogsView trash button - Font scaling: 1.25x across all rip flow views; semantic fonts converted to explicit sizes - Rip log overlay: moved from carousel to left panel below progress bar - AutoScribe: uses HeuristicTitleSuggester instead of largest-title heuristic - export-training-data CLI: exports TitleSelectionStore to CSV for Create ML - make install: single target builds CLI + worker + UI, installs all, reinstalls worker Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 7a0e885 commit c40ad39

36 files changed

+1886
-233
lines changed

FrostscribeUI/FrostscribeUI.xcodeproj/project.pbxproj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
E1B2C3D4E5F6A7B8C9D0E1F8 /* QueueRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1B2C3D4E5F6A7B8C9D0E1F4 /* QueueRowView.swift */; };
2020
E1B2C3D4E5F6A7B8C9D0E1F9 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1B2C3D4E5F6A7B8C9D0E1F5 /* SettingsView.swift */; };
2121
LV01C3D4E5F6A7B8C9D0E1F2 /* LogsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = LV01C3D4E5F6A7B8C9D0E1F1 /* LogsView.swift */; };
22+
SV01C3D4E5F6A7B8C9D0E1F2 /* StatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = SV01C3D4E5F6A7B8C9D0E1F1 /* StatsView.swift */; };
23+
SM01C3D4E5F6A7B8C9D0E1F2 /* StatsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = SM01C3D4E5F6A7B8C9D0E1F1 /* StatsViewModel.swift */; };
2224
F1B2C3D4E5F6A7B8C9D0E1F3 /* FrostscribeCore in Frameworks */ = {isa = PBXBuildFile; productRef = F1B2C3D4E5F6A7B8C9D0E1F5 /* FrostscribeCore */; };
2325
AW01C3D4E5F6A7B8C9D0E1F2 /* AppSupportWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = AW01C3D4E5F6A7B8C9D0E1F1 /* AppSupportWatcher.swift */; };
2426
F3B2C3D4E5F6A7B8C9D0E1F3 /* VigilWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3B2C3D4E5F6A7B8C9D0E1F1 /* VigilWatcher.swift */; };
@@ -72,6 +74,8 @@
7274
D1B2C3D4E5F6A7B8C9D0E1F4 /* QueueRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueueRowView.swift; sourceTree = "<group>"; };
7375
D1B2C3D4E5F6A7B8C9D0E1F5 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
7476
LV01C3D4E5F6A7B8C9D0E1F1 /* LogsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogsView.swift; sourceTree = "<group>"; };
77+
SV01C3D4E5F6A7B8C9D0E1F1 /* StatsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsView.swift; sourceTree = "<group>"; };
78+
SM01C3D4E5F6A7B8C9D0E1F1 /* StatsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsViewModel.swift; sourceTree = "<group>"; };
7579
FB01C3D4E5F6A7B8C9D0E1F1 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
7680
D1B2C3D4E5F6A7B8C9D0E1F6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
7781
D1B2C3D4E5F6A7B8C9D0E1F7 /* FrostscribeUI.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FrostscribeUI.entitlements; sourceTree = "<group>"; };
@@ -175,6 +179,7 @@
175179
C1B2C3D4E5F6A7B8C9D0E1F9 /* QueueViewModel.swift */,
176180
FA01C3D4E5F6A7B8C9D0E1F1 /* RipFlowViewModel.swift */,
177181
FC01C3D4E5F6A7B8C9D0E1F1 /* NavigationCoordinator.swift */,
182+
SM01C3D4E5F6A7B8C9D0E1F1 /* StatsViewModel.swift */,
178183
);
179184
path = ViewModels;
180185
sourceTree = "<group>";
@@ -187,6 +192,7 @@
187192
D1B2C3D4E5F6A7B8C9D0E1F4 /* QueueRowView.swift */,
188193
D1B2C3D4E5F6A7B8C9D0E1F5 /* SettingsView.swift */,
189194
LV01C3D4E5F6A7B8C9D0E1F1 /* LogsView.swift */,
195+
SV01C3D4E5F6A7B8C9D0E1F1 /* StatsView.swift */,
190196
FA00C3D4E5F6A7B8C9D0E1F1 /* Rip */,
191197
);
192198
path = Views;
@@ -305,6 +311,8 @@
305311
E1B2C3D4E5F6A7B8C9D0E1F8 /* QueueRowView.swift in Sources */,
306312
E1B2C3D4E5F6A7B8C9D0E1F9 /* SettingsView.swift in Sources */,
307313
LV01C3D4E5F6A7B8C9D0E1F2 /* LogsView.swift in Sources */,
314+
SV01C3D4E5F6A7B8C9D0E1F2 /* StatsView.swift in Sources */,
315+
SM01C3D4E5F6A7B8C9D0E1F2 /* StatsViewModel.swift in Sources */,
308316
AW01C3D4E5F6A7B8C9D0E1F2 /* AppSupportWatcher.swift in Sources */,
309317
F3B2C3D4E5F6A7B8C9D0E1F3 /* VigilWatcher.swift in Sources */,
310318
F3B2C3D4E5F6A7B8C9D0E1F4 /* VigilViewModel.swift in Sources */,

FrostscribeUI/FrostscribeUI/ViewModels/RipFlowViewModel.swift

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ final class RipFlowViewModel {
2727
private(set) var tmdbResults: [TMDBClient.SearchResult] = []
2828
private(set) var isSearching = false
2929
private(set) var scanMessage: String = ""
30+
private(set) var ripMessage: String = ""
31+
private(set) var suggestedTitleNumber: Int? = nil
3032

3133
// Persisted across phases for the left panel / carousel
3234
private(set) var posterURL: URL?
@@ -75,6 +77,13 @@ final class RipFlowViewModel {
7577
}
7678
}
7779

80+
var canAbort: Bool {
81+
switch phase {
82+
case .idle, .done, .error: return false
83+
default: return true
84+
}
85+
}
86+
7887
var isRipping: Bool {
7988
if case .ripping = phase { return true }
8089
return false
@@ -89,6 +98,7 @@ final class RipFlowViewModel {
8998
private var storedConfig: Config?
9099
private var ripTask: Task<Void, Never>?
91100
private var searchTask: Task<Void, Never>?
101+
private var currentRipJobId: String?
92102

93103
// MARK: - Initialize (resume in-progress rip on launch)
94104

@@ -117,6 +127,7 @@ final class RipFlowViewModel {
117127
let store = RipHistoryStore(appSupportURL: ConfigManager.appSupportURL)
118128
ripEstimate = RipEstimator(store: store).estimate(discType: discType, sizeBytes: queueJob.titleSizeBytes)
119129

130+
currentRipJobId = queueJob.id
120131
phase = .ripping(title: queueJob.jobLabel, progress: pct)
121132
ripTask = Task { await pollRip(jobId: queueJob.id, title: queueJob.jobLabel) }
122133

@@ -256,6 +267,7 @@ final class RipFlowViewModel {
256267
if isTV {
257268
phase = .tvEpisode(scanResult, title: title, year: year)
258269
} else {
270+
suggestedTitleNumber = HeuristicTitleSuggester().suggest(from: scanResult.titles)?.number
259271
phase = .titleSelection(scanResult, title: title, year: year,
260272
isTV: false, season: 1, episode: 1)
261273
}
@@ -266,6 +278,7 @@ final class RipFlowViewModel {
266278
func setEpisode(season: Int, episode: Int, scanResult: DiscScanResult,
267279
title: String, year: String) {
268280
phaseStack.append(phase)
281+
suggestedTitleNumber = HeuristicTitleSuggester().suggest(from: scanResult.titles)?.number
269282
phase = .titleSelection(scanResult, title: title, year: year,
270283
isTV: true, season: season, episode: episode)
271284
}
@@ -276,6 +289,17 @@ final class RipFlowViewModel {
276289
mediaTitle: String, year: String,
277290
isTV: Bool, season: Int, episode: Int) {
278291
phaseStack.append(phase)
292+
// Record this selection for ML training data
293+
let mediaType: RipJob.MediaType = isTV ? .tvshow : .movie
294+
Task.detached {
295+
TitleSelectionStore(appSupportURL: ConfigManager.appSupportURL).record(
296+
selected: discTitle,
297+
allTitles: scanResult.titles,
298+
discType: scanResult.discType,
299+
mediaType: mediaType,
300+
discName: scanResult.discName
301+
)
302+
}
279303
advanceToAudioOrConfirmation(chosenTitle: discTitle, scanResult: scanResult,
280304
title: mediaTitle, year: year,
281305
isTV: isTV, season: season, episode: episode)
@@ -385,12 +409,39 @@ final class RipFlowViewModel {
385409
return
386410
}
387411

412+
kickWorker()
413+
414+
currentRipJobId = job.id
388415
phase = .ripping(title: ripInput.jobLabel, progress: 0)
389416
let jobId = job.id
390417
let title = ripInput.jobLabel
391418
ripTask = Task { await pollRip(jobId: jobId, title: title) }
392419
}
393420

421+
private func kickWorker() {
422+
// `launchctl list` (no args) outputs PID\tExit\tLabel per line; "-" PID means not running.
423+
let check = Process()
424+
check.executableURL = URL(fileURLWithPath: "/bin/launchctl")
425+
check.arguments = ["list"]
426+
let pipe = Pipe()
427+
check.standardOutput = pipe
428+
check.standardError = FileHandle.nullDevice
429+
try? check.run()
430+
check.waitUntilExit()
431+
432+
let output = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
433+
let line = output.split(separator: "\n").first { $0.contains("com.frostscribe.worker") }
434+
let pid = line?.split(separator: "\t").first.map(String.init)?.trimmingCharacters(in: .whitespaces) ?? "-"
435+
guard pid == "-" else { return }
436+
437+
let start = Process()
438+
start.executableURL = URL(fileURLWithPath: "/bin/launchctl")
439+
start.arguments = ["start", "com.frostscribe.worker"]
440+
start.standardOutput = FileHandle.nullDevice
441+
start.standardError = FileHandle.nullDevice
442+
try? start.run()
443+
}
444+
394445
private func pollRip(jobId: String, title: String) async {
395446
let ripQueue = RipQueueManager(appSupportURL: ConfigManager.appSupportURL)
396447
let statusMgr = StatusManager(appSupportURL: ConfigManager.appSupportURL)
@@ -402,6 +453,9 @@ final class RipFlowViewModel {
402453
let currentJob = file.currentJob {
403454
let pct = Int(currentJob.progress.replacingOccurrences(of: "%", with: "")) ?? 0
404455
phase = .ripping(title: title, progress: pct)
456+
if let msg = currentJob.currentItem, !msg.isEmpty {
457+
ripMessage = msg
458+
}
405459
}
406460

407461
// Check rip queue for terminal state
@@ -431,6 +485,12 @@ final class RipFlowViewModel {
431485
// MARK: - Reset
432486

433487
func reset() {
488+
// Signal the worker to terminate makemkvcon before resetting UI state.
489+
if let jobId = currentRipJobId {
490+
let ripQueue = RipQueueManager(appSupportURL: ConfigManager.appSupportURL)
491+
try? ripQueue.markCancelled(id: jobId)
492+
}
493+
currentRipJobId = nil
434494
ripTask?.cancel()
435495
phaseStack = []
436496
searchTask?.cancel()
@@ -440,6 +500,8 @@ final class RipFlowViewModel {
440500
tmdbResults = []
441501
isSearching = false
442502
scanMessage = ""
503+
ripMessage = ""
504+
suggestedTitleNumber = nil
443505
posterURL = nil
444506
backdropURLs = []
445507
mediaDetails = nil
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import Foundation
2+
import FrostscribeCore
3+
4+
@MainActor
5+
@Observable
6+
final class StatsViewModel {
7+
8+
struct DiscBreakdown {
9+
let bluray: Int
10+
let dvd: Int
11+
let uhd: Int
12+
}
13+
14+
private(set) var totalRips: Int = 0
15+
private(set) var dataRippedGB: Double = 0
16+
private(set) var avgRipMinutes: Double = 0
17+
private(set) var fastestLabel: String = ""
18+
private(set) var fastestMinutes: Double = 0
19+
private(set) var breakdown: DiscBreakdown = DiscBreakdown(bluray: 0, dvd: 0, uhd: 0)
20+
private(set) var successRate: Double = 0
21+
private(set) var totalAttempts: Int = 0
22+
23+
// Encode stats
24+
private(set) var totalEncodes: Int = 0
25+
private(set) var encodeErrors: Int = 0
26+
private(set) var encodeSuccessRate: Double = 0
27+
28+
// ML / selection stats
29+
private(set) var selectionEvents: Int = 0
30+
private(set) var titlesRecorded: Int = 0
31+
private(set) var suggestionAccuracy: Double = 0
32+
private(set) var suggestionMatchCount: Int = 0
33+
34+
func load() {
35+
let store = RipHistoryStore(appSupportURL: ConfigManager.appSupportURL)
36+
let all = store.load()
37+
guard !all.isEmpty else { return }
38+
39+
let successful = all.filter(\.success)
40+
totalAttempts = all.count
41+
totalRips = successful.count
42+
successRate = Double(totalRips) / Double(totalAttempts) * 100
43+
44+
let totalBytes = successful.reduce(0) { $0 + $1.titleSizeBytes }
45+
dataRippedGB = Double(totalBytes) / 1_073_741_824
46+
47+
if !successful.isEmpty {
48+
let totalSec = successful.reduce(0.0) { $0 + $1.ripDurationSeconds }
49+
avgRipMinutes = totalSec / Double(successful.count) / 60.0
50+
}
51+
52+
if let fastest = successful.min(by: { $0.ripDurationSeconds < $1.ripDurationSeconds }) {
53+
fastestLabel = fastest.jobLabel
54+
fastestMinutes = fastest.ripDurationSeconds / 60.0
55+
}
56+
57+
breakdown = DiscBreakdown(
58+
bluray: successful.filter { $0.discType == .bluray }.count,
59+
dvd: successful.filter { $0.discType == .dvd }.count,
60+
uhd: successful.filter { $0.discType == .uhd }.count
61+
)
62+
63+
// Encode stats from queue
64+
let allEncodes = (try? QueueManager(appSupportURL: ConfigManager.appSupportURL).read()) ?? []
65+
let terminal = allEncodes.filter { $0.status == .done || $0.status == .error }
66+
totalEncodes = terminal.count
67+
encodeErrors = terminal.filter { $0.status == .error }.count
68+
encodeSuccessRate = totalEncodes > 0
69+
? Double(totalEncodes - encodeErrors) / Double(totalEncodes) * 100 : 0
70+
71+
loadSelectionStats()
72+
}
73+
74+
private func loadSelectionStats() {
75+
let selStore = TitleSelectionStore(appSupportURL: ConfigManager.appSupportURL)
76+
let records = selStore.load()
77+
guard !records.isEmpty else { return }
78+
79+
titlesRecorded = records.count
80+
81+
// Group by selectionId
82+
let groups = Dictionary(grouping: records, by: \.selectionId)
83+
selectionEvents = groups.count
84+
85+
// Accuracy: for each group, does the heuristic top-scorer match the user's pick?
86+
var matches = 0
87+
for (_, rows) in groups {
88+
guard let selected = rows.first(where: \.isSelected) else { continue }
89+
let best = rows.max { heuristicScore($0) < heuristicScore($1) }
90+
if best?.titleNumber == selected.titleNumber { matches += 1 }
91+
}
92+
suggestionMatchCount = matches
93+
suggestionAccuracy = selectionEvents > 0
94+
? Double(matches) / Double(selectionEvents) * 100 : 0
95+
}
96+
97+
/// Mirrors HeuristicTitleSuggester using stored scalar fields.
98+
private func heuristicScore(_ r: TitleSelectionRecord) -> Double {
99+
var s = 0.0
100+
let group = r.selectionId // unused but kept for clarity
101+
102+
// Use rank-based proxies since we don't have the full sibling set here
103+
if r.orderWeight == 0 { s += 40 }
104+
s += max(0, 10 - Double(r.orderWeight)) * 0.5
105+
if r.durationRank == 1 { s += 30 }
106+
else if r.durationRank == 2 { s += 18 }
107+
if r.sizeRank == 1 { s += 15 }
108+
else if r.sizeRank == 2 { s += 9 }
109+
if r.hasLosslessAudio { s += 5 }
110+
if r.videoHeight >= 2160 { s += 4 }
111+
else if r.videoHeight >= 1080 { s += 2 }
112+
if r.angle > 1 { s -= 8 }
113+
if r.durationMinutes < 30 { s -= 20 }
114+
return s
115+
}
116+
}

0 commit comments

Comments
 (0)