tv-anarchy/Sources/TVAnarchyCore/Search/SearchController.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

108 lines
4.9 KiB
Swift

import Foundation
import Observation
/// The unified finder behind the Search tab. One query answers "do I have it,
/// am I getting it, can I get it?" by matching three sources:
/// the scanned library (owned),
/// live transmission transfers (in flight / done),
/// torrent search results (available to download).
/// Library + transfer facets are instant (local); torrents are the async leg.
@Observable
@MainActor
public final class SearchController {
public var query: String = ""
public private(set) var libraryMatches: [CachedShow] = []
public private(set) var transferMatches: [TorrentRow] = []
public private(set) var torrentResults: [TorrentResult] = []
/// v1 completion: grouped by canonical title for collection cards (each value sorted by seeders desc).
public private(set) var torrentCollections: [String: [TorrentResult]] = [:]
public private(set) var searching = false
public private(set) var torrentError: String?
public private(set) var lastAction: String?
public private(set) var didSearch = false
private let library: LibraryController
private let downloads: DownloadsController
private let torrents: TorrentService
public init(library: LibraryController, downloads: DownloadsController,
torrents: TorrentService = TorrentService()) {
self.library = library; self.downloads = downloads; self.torrents = torrents
}
public func search() async {
let q = query.trimmingCharacters(in: .whitespaces)
guard !q.isEmpty, !searching else { return }
let lower = q.lowercased()
didSearch = true
// Instant local facets match show name, episode labels, and paths.
libraryMatches = library.shows
.filter { show in
if show.name.lowercased().contains(lower) { return true }
return show.episodes.contains { ep in
ep.label.lowercased().contains(lower) || ep.path.lowercased().contains(lower)
}
}
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
transferMatches = downloads.transfers.filter { row in
row.name.lowercased().contains(lower)
|| row.downloadDir?.lowercased().contains(lower) == true
}
// Async torrent leg.
searching = true
torrentError = nil
defer { searching = false }
do {
let raw = try await torrents.search(q, limit: 25)
torrentResults = raw.filter { r in
!SearchMatcher.overlapsTransfer(r, transfers: downloads.transfers)
&& !SearchMatcher.overlapsLibrary(r, shows: library.shows, query: q)
}
// v1 collections: group by parsed title (simple key) for cards + best-seeded pick.
var groups: [String: [TorrentResult]] = [:]
for r in torrentResults {
let p = FilenameParser.parse(path: r.filename)
let key = p.title.isEmpty ? r.filename : p.title.lowercased()
groups[key, default: []].append(r)
}
torrentCollections = groups.mapValues { $0.sorted { $0.seeders > $1.seeders } }
if torrentResults.isEmpty {
torrentError = raw.isEmpty
? "Search returned no results (see Setup → Dependencies for FlareSolverr / Chromium status)."
: "No new torrents — matches already in your library or downloads."
}
} catch {
torrentResults = []
torrentError = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
}
}
/// Add a torrent result (optionally routed to a category) via the downloads
/// controller, and refresh the in-flight facet so it shows immediately.
public func add(_ result: TorrentResult, category: String?) async {
await downloads.add(result, category: category)
lastAction = downloads.lastAction
let lower = query.trimmingCharacters(in: .whitespaces).lowercased()
if !lower.isEmpty { transferMatches = downloads.transfers.filter { $0.name.lowercased().contains(lower) } }
}
/// True when the library already has something matching used to warn before
/// re-downloading.
public var ownsMatch: Bool { !libraryMatches.isEmpty }
/// Human-readable reason to confirm before adding, if any.
public func addWarning(for result: TorrentResult, category: String?) -> String? {
if SearchMatcher.overlapsLibrary(result, shows: library.shows, query: query) {
return "This looks like something you already have in your library."
}
if SearchMatcher.overlapsTransfer(result, transfers: downloads.transfers) {
return "A matching torrent is already downloading or complete."
}
if let cat = category, let misplaced = downloads.misplaced(in: cat) {
return misplaced
}
return nil
}
}