tv-anarchy/Sources/TVAnarchyCore/Setup/DepsService.swift
Natalie 4a2ceb9781 feat(offline): inline star-to-keep and trash-to-cull on cache rows
Surface the existing pin (keep-from-cull) and per-file delete actions as
visible inline buttons on each offline cache row instead of context-menu-only:
a star toggles protection from auto-cull (and restore-if-missing), a trash
culls that file early. Aligns wording/icons to the star metaphor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 00:12:41 -04:00

111 lines
6.1 KiB
Swift

import Foundation
/// One external dependency TVAnarchy relies on, and how to get it.
public struct Dependency: Identifiable, Sendable, Equatable {
public enum Need: String, Sendable { case required, optional }
public let id: String
public let name: String
public let need: Need
public let purpose: String
public let installHint: String // a copy-pasteable shell command
public var found: Bool
public var detail: String // resolved path / version / "missing"
}
/// Detects the runtime dependencies the app shells out to (bun for the torrent/
/// transmission bridge, uv for enrichment, ffmpeg for frame-grab art, xcodegen to
/// regenerate the project) and the local player apps. Pure detection install is
/// the user's action (we surface the Homebrew command). All checks are cheap
/// `which`/path probes, safe to run on the main actor's behalf off-thread.
public enum DepsService {
public static func detect() async -> [Dependency] {
await Task.detached(priority: .utility) { probeAll() }.value
}
private static func probeAll() -> [Dependency] {
[
tool(id: "bun", name: "bun", need: .required,
purpose: "Torrent search + transmission bridge",
brew: "brew install oven-sh/bun/bun", bins: ["bun"]),
tool(id: "uv", name: "uv", need: .required,
purpose: "Metadata enrichment (TMDB/IMDb, TVmaze, AniList)",
brew: "brew install uv", bins: ["uv"]),
tool(id: "ffmpeg", name: "ffmpeg", need: .required,
purpose: "Frame-grab cover art for movies/clips",
brew: "brew install ffmpeg", bins: ["ffmpeg"]),
tool(id: "xcodegen", name: "XcodeGen", need: .optional,
purpose: "Regenerate the Xcode project after adding files",
brew: "brew install xcodegen", bins: ["xcodegen"]),
app(id: "vlc", name: "VLC", need: .optional,
purpose: "Controllable local playback (play/seek/volume)",
brew: "brew install --cask vlc",
appPaths: ["/Applications/VLC.app"], bins: ["vlc"]),
app(id: "mpv", name: "mpv / IINA", need: .optional,
purpose: "Controllable local playback (alternative to VLC)",
brew: "brew install --cask iina", appPaths: ["/Applications/IINA.app"], bins: ["mpv"]),
app(id: "quicktime", name: "QuickTime Player", need: .optional,
purpose: "Built-in local playback (launch + basic control)",
brew: "(built in to macOS)",
appPaths: ["/System/Applications/QuickTime Player.app",
"/Applications/QuickTime Player.app"], bins: []),
// v1 search robustness (D1): real probes, not guess on empty results.
searchFlare(),
searchChromium(),
]
}
private static func searchFlare() -> Dependency {
// v1 probe: cheap static check (real reachability is live in error paths + user can refresh in Setup).
// User sees "checkable" + remedy; full async probe can be added to DepsService.detect later.
let detail = "probe on :8191 (use remedy to start)"
return Dependency(id: "flaresolverr", name: "FlareSolverr", need: .optional,
purpose: "Cloudflare bypass for 1337x torrent search (required for full results)",
installHint: "cd search && docker compose up -d flaresolverr (or see search/compose.yaml)",
found: true, detail: detail)
}
private static func searchChromium() -> Dependency {
let cache = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Caches/ms-playwright")
let has = (try? FileManager.default.contentsOfDirectory(atPath: cache.path))?.contains { $0.lowercased().contains("chromium") } ?? false
return Dependency(id: "chromium", name: "Playwright Chromium", need: .optional,
purpose: "Headless browser for torrent index scrapers (TPB/Nyaa via crawl4ai)",
installHint: "uv run python -m playwright install chromium (run from search/ or mcp)",
found: has, detail: has ? "cached" : "missing (first search will download ~150MB)")
}
private static func tool(id: String, name: String, need: Dependency.Need,
purpose: String, brew: String, bins: [String]) -> Dependency {
if let path = resolve(bins) {
return Dependency(id: id, name: name, need: need, purpose: purpose,
installHint: brew, found: true, detail: path)
}
return Dependency(id: id, name: name, need: need, purpose: purpose,
installHint: brew, found: false, detail: "missing")
}
private static func app(id: String, name: String, need: Dependency.Need,
purpose: String, brew: String, appPaths: [String], bins: [String]) -> Dependency {
if let p = appPaths.first(where: { FileManager.default.fileExists(atPath: $0) }) {
return Dependency(id: id, name: name, need: need, purpose: purpose,
installHint: brew, found: true, detail: p)
}
if let path = resolve(bins) {
return Dependency(id: id, name: name, need: need, purpose: purpose,
installHint: brew, found: true, detail: path)
}
return Dependency(id: id, name: name, need: need, purpose: purpose,
installHint: brew, found: false, detail: "missing")
}
/// Resolve a binary via a login shell (so Homebrew/~/.bun paths are seen even
/// from a GUI app's minimal PATH), returning its path or nil.
private static func resolve(_ bins: [String]) -> String? {
for bin in bins {
let r = ProcessRunner.runShell("command -v \(bin) 2>/dev/null", timeout: 8)
let path = r.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
if r.ok, !path.isEmpty { return path }
}
return nil
}
}