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>
108 lines
4.9 KiB
Swift
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
|
|
}
|
|
}
|