tv-anarchy/Sources/TVAnarchyCore/Torrents/DownloadsController.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

299 lines
14 KiB
Swift

import Foundation
import Observation
/// Drives the Downloads tab: the live transmission transfer dashboard (polled).
/// Search lives in its own Search tab now. @Observable/@MainActor like the others.
@Observable
@MainActor
public final class DownloadsController {
public private(set) var transfers: [TorrentRow] = []
public private(set) var transfersError: String?
public private(set) var lastAction: String?
/// Set true while the Downloads tab is on screen drives the poll cadence so
/// we don't hammer black (each poll is a cold SSH + torrent-get over a loaded
/// disk) while the user is elsewhere. Polling never fully stops, so the tab
/// opens on last-known data instead of a blank cold fetch.
public var detailVisible = false { didSet { if detailVisible != oldValue { wake() } } }
/// Fired when transfers transition to complete (or on startup backfill), with
/// the black-side content folders the owner scans them directly into the library.
public var onDownloadsCompleted: (([String]) -> Void)?
private let service: TorrentService
private var pollTask: Task<Void, Never>?
private var polling = false
private var completedIds: Set<Int> = []
/// When each downloading torrent's rate first hit 0 to time the stall/dead
/// thresholds in `TransferHealth` across polls.
private var stuckSince: [Int: Date] = [:]
/// Ids already alerted as needing attention, so we notify once per entry.
private var notifiedAttention: Set<Int> = []
public init(service: TorrentService = TorrentService()) { self.service = service }
/// Exposed for DownloadsView re-search flows (title extraction + search).
public func researchQuery(for row: TorrentRow) -> String { service.researchQuery(for: row) }
public func searchForResearch(_ q: String, limit: Int = 8) async throws -> [TorrentResult] {
try await service.searchForResearch(q, limit: limit)
}
public func warmupSearch() async { await service.warmupSearch() }
public func start() {
pollTask?.cancel()
pollTask = Task { [weak self] in
while !Task.isCancelled {
await self?.refreshTransfers()
let secs = self?.pollInterval ?? 60
try? await Task.sleep(for: .seconds(secs))
}
}
}
public func stop() { pollTask?.cancel() }
/// Restart the loop so a visibility change takes effect immediately (an
/// in-flight `Task.sleep` would otherwise hold the old, slower cadence).
private func wake() { if pollTask != nil { start() } }
/// Poll fast only when it matters the tab is open AND something is actually
/// downloading. Everything idle/seeding + tab hidden backs right off.
private var pollInterval: Double {
let downloading = transfers.contains { !$0.isComplete }
switch (detailVisible, downloading) {
case (true, true): return 4
case (true, false): return 12
case (false, true): return 20
case (false, false): return 60
}
}
// MARK: dashboard facets (each transfer falls in exactly one)
/// Still fetching (not yet 100%).
public var downloading: [TorrentRow] { transfers.filter { !$0.isComplete } }
/// Complete and still seeding.
public var seeding: [TorrentRow] { transfers.filter { $0.isComplete && $0.status == 6 } }
/// Complete, not seeding (done), newest first.
public var completed: [TorrentRow] { transfers.filter { $0.isComplete && $0.status != 6 } }
public var totalDownRate: Int { transfers.reduce(0) { $0 + $1.rateDownload } }
public var totalUpRate: Int { transfers.reduce(0) { $0 + $1.rateUpload } }
public static func rate(_ bytesPerSec: Int) -> String {
guard bytesPerSec > 0 else { return "" }
let f = ByteCountFormatter(); f.countStyle = .file
return f.string(fromByteCount: Int64(bytesPerSec)) + "/s"
}
/// The single most-recently-completed transfer, surfaced at the top of the UI.
public var mostRecentlyCompleted: TorrentRow? {
transfers.first { $0.completedAt != nil }
}
/// Most-recently-completed first (doneDate desc); still-downloading
/// (doneDate 0) sink to the bottom, newest-added first among them.
static func byRecency(_ a: TorrentRow, _ b: TorrentRow) -> Bool {
if a.doneDate != b.doneDate { return a.doneDate > b.doneDate }
return a.addedDate > b.addedDate
}
// MARK: health-aware ordering
/// Health of a transfer, timing the stall/dead thresholds from `stuckSince`.
public func health(_ row: TorrentRow) -> TransferHealth {
let stuck = stuckSince[row.id].map { Date().timeIntervalSince($0) }
return row.health(secondsStuck: stuck)
}
/// Active downloads, **attention first** (errored/dead/stalled), then by soonest
/// ETA so what needs you, and what's about to be watchable, float to the top.
public var downloadingSorted: [TorrentRow] {
let ranked = downloading.map { ($0, health($0)) }
return ranked.sorted { Self.downloadOrder($0.0, $0.1, $1.0, $1.1) }.map(\.0)
}
/// How many active downloads need attention (stalled/dead/errored).
public var attentionCount: Int { downloading.lazy.filter { self.health($0).needsAttention }.count }
/// Pure ordering (unit-tested): by health priority, then soonest ETA.
static func downloadOrder(_ a: TorrentRow, _ ha: TransferHealth,
_ b: TorrentRow, _ hb: TransferHealth) -> Bool {
if ha.sortRank != hb.sortRank { return ha.sortRank < hb.sortRank }
let ea = a.eta > 0 ? a.eta : Int.max
let eb = b.eta > 0 ? b.eta : Int.max
return ea < eb
}
/// Update the rate-zero timers each poll: start the clock when a downloading
/// torrent's rate hits 0, clear it when bytes flow again or it's gone.
private func updateStuckTracking() {
let now = Date()
let present = Set(transfers.map(\.id))
for t in transfers where t.status == 4 {
if t.rateDownload == 0 { if stuckSince[t.id] == nil { stuckSince[t.id] = now } }
else { stuckSince[t.id] = nil }
}
stuckSince = stuckSince.filter { present.contains($0.key) }
}
public func refreshTransfers() async {
guard !polling else { return }
polling = true
defer { polling = false }
do {
// Most-recently-completed first (doneDate desc); still-downloading
// (doneDate 0) sink to the bottom, newest-added first among them.
transfers = try await service.list().sorted(by: Self.byRecency)
updateStuckTracking()
transfersError = nil
Log.info("refreshTransfers → \(transfers.count) transfers")
// A transfer flipping to complete = new media on black rescan library.
// Notifications only fire for *new* completions; indexing also backfills
// any complete folder we've never kicked through --add (e.g. finished
// while the app was closed).
let nowComplete = Set(transfers.filter { $0.isComplete }.map(\.id))
var foldersToIndex: [String] = []
if !completedIds.isEmpty {
let newly = nowComplete.subtracting(completedIds)
if !newly.isEmpty {
let done = transfers.filter { newly.contains($0.id) }
Log.info("download complete (\(newly.count) item\(newly.count == 1 ? "" : "s")) → incremental index")
for t in done { NotificationsService.shared.post(title: "Ready to watch", body: t.name) }
foldersToIndex = done.compactMap(\.contentFolder)
}
} else {
let backfill = IndexedFoldersStore.unmarkedFolders(in: transfers)
if !backfill.isEmpty {
Log.info("index backfill (\(backfill.count) folder\(backfill.count == 1 ? "" : "s")) from startup baseline")
foldersToIndex = backfill
}
}
if !foldersToIndex.isEmpty {
IndexedFoldersStore.mark(foldersToIndex)
onDownloadsCompleted?(foldersToIndex)
}
completedIds = nowComplete
// Stall/dead surfacing once per entry into an attention state.
let attentionNow = Set(downloading.filter { health($0).needsAttention }.map(\.id))
for id in attentionNow.subtracting(notifiedAttention) {
if let t = transfers.first(where: { $0.id == id }) {
NotificationsService.shared.post(title: "Download stuck", body: t.name)
}
}
notifiedAttention = attentionNow
} catch {
transfersError = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
Log.error("refreshTransfers: \(transfersError ?? "?")")
}
}
/// Categories offered at add-time route a torrent straight into media/<cat>.
public static let addCategories = ["tv", "anime", "movies", "cartoons", "unsorted", "porn"]
/// Warn when completed transfers in `category` are filed under the wrong path
/// (downloadDir doesn't live under `media/<category>/`).
public func misplaced(in category: String) -> String? {
let root = MediaPaths.remoteRoot + "/" + category
let bad = transfers.filter { row in
guard let dir = row.downloadDir else { return false }
return !dir.hasPrefix(root) && SearchMatcher.isMisplaced(row)
}
guard let first = bad.first else { return nil }
let extra = bad.count > 1 ? " (and \(bad.count - 1) more)" : ""
return "\(first.name)” completed under \(first.downloadDir ?? "?"), not \(root). Move files on black before re-adding\(extra)."
}
public func add(_ result: TorrentResult, category: String? = nil) async {
guard let magnet = result.magnet else { return }
do {
try await service.add(magnet, category: category)
lastAction = category.map { "Added to \($0): \(result.filename)" } ?? "Added: \(result.filename)"
Log.info("add ok [\(category ?? "default")]: \(result.filename)")
await refreshTransfers()
} catch {
lastAction = "Add failed: \((error as? LocalizedError)?.errorDescription ?? error.localizedDescription)"
Log.error("add failed: \(result.filename): \(lastAction ?? "")")
}
}
public func remove(_ row: TorrentRow, deleteData: Bool) async {
do {
try await service.remove(row.id, deleteData: deleteData)
lastAction = "Removed: \(row.name)"
Log.info("remove ok (deleteData=\(deleteData)): \(row.name)")
await refreshTransfers()
} catch {
lastAction = "Remove failed: \((error as? LocalizedError)?.errorDescription ?? error.localizedDescription)"
Log.error("remove failed: \(row.name): \(lastAction ?? "")")
}
}
// MARK: per-torrent debug detail + advance actions
/// The transfer whose debug sheet is open, its loaded detail, and load state.
public private(set) var detailFor: Int?
public private(set) var detail: TorrentDetail?
public private(set) var detailLoading = false
public private(set) var detailError: String?
/// Open the debug sheet for a transfer: fetch its full snapshot on demand.
public func loadDetail(for id: Int) async {
detailFor = id
detailLoading = true
detailError = nil
do {
detail = try await service.detail(id)
} catch {
detail = nil
detailError = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
Log.error("detail \(id) failed: \(detailError ?? "")")
}
detailLoading = false
}
public func closeDetail() { detailFor = nil; detail = nil; detailError = nil }
/// Run a recovery action, then refresh both the detail and the dashboard so the
/// effect (peers returning, recheck progress, paused state) shows immediately.
public func act(_ action: TorrentService.Action, on id: Int) async {
do {
try await service.act(action, on: id)
lastAction = "\(action.rawValue.capitalized): \(detail?.name ?? "id \(id)")"
Log.info("tx-\(action.rawValue) ok: id \(id)")
if detailFor == id { await loadDetail(for: id) }
await refreshTransfers()
} catch {
detailError = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
lastAction = "\(action.rawValue.capitalized) failed"
Log.error("tx-\(action.rawValue) failed (id \(id)): \(detailError ?? "")")
}
}
/// Research/swap: remove the old torrent (keeping its data on disk) then add the
/// chosen better-seeded magnet (transmission will hash-check existing files into
/// the new torrent). Category inferred from downloadDir if possible.
public func replace(_ old: TorrentRow, with better: TorrentResult) async {
guard let magnet = better.magnet else { return }
do {
// Keep data so bytes are reused by the replacement torrent.
try await service.remove(old.id, deleteData: false)
// Re-add; try to preserve prior category from dir prefix.
var cat: String? = nil
if let dir = old.downloadDir {
for c in Self.addCategories {
if dir.contains("/\(c)/") || dir.hasSuffix("/\(c)") { cat = c; break }
}
}
try await service.add(magnet, category: cat)
lastAction = "Swapped to better release: \(better.filename)"
Log.info("replace ok: removed \(old.id) kept data; added \(better.filename) cat=\(cat ?? "default")")
if detailFor == old.id { closeDetail() }
await refreshTransfers()
} catch {
lastAction = "Swap failed: \((error as? LocalizedError)?.errorDescription ?? error.localizedDescription)"
Log.error("replace failed for id \(old.id): \(lastAction ?? "")")
if detailFor == old.id { detailError = lastAction }
}
}
}