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 } }