// Flight pack: the phone-side equivalent of the macOS OfflineCacheController. // One sweep keeps the next N episodes of EVERY in-progress show downloaded, // under a total storage budget — run it before a flight (or let it run on every // app foreground via Settings → Auto-pack). // // Eviction is conservative: only episodes BEHIND the show's resume point // (already watched) are deleted to make room; unwatched downloads are never // evicted. When the budget fills, remaining shows are skipped and reported. import Foundation @MainActor enum FlightPack { /// Fallback size estimate when an older bridge doesn't send episode bytes. private static let assumedEpisodeBytes: Int64 = 1_500_000_000 @discardableResult static func run(client: BridgeClient, downloads: DownloadManager, settings: BridgeSettings) async -> String { let perShow = settings.packEpisodesPerShow let budget = Int64(settings.packBudgetGB) * 1_000_000_000 let items: [ContinueItem] let shows: [BridgeShow] do { items = try await client.fetchContinue() shows = try await client.fetchShows() } catch { return "Pack failed: \(error.localizedDescription)" } var evicted = 0 var queued = 0 var skipped = 0 var used = downloads.totalBytes for item in items { // already sorted most-recently-watched first guard let resume = item.resume, let show = shows.first(where: { $0.id == item.showId }), let resumeIdx = show.episodes.firstIndex(where: { $0.id == resume.episodeId }) else { continue } // Evict watched: downloaded episodes of this show before the resume point. let resumeEp = show.episodes[resumeIdx] for entry in downloads.entries where entry.show == show.name && (entry.season, entry.episode) < (resumeEp.season, resumeEp.episode) { used -= entry.bytes evicted += 1 downloads.delete(episodeId: entry.episodeId) } // Queue the resume episode and what follows, up to the per-show count. for ep in show.episodes[resumeIdx...].prefix(max(1, perShow)) { guard !downloads.isDownloaded(ep.id), !downloads.isActive(ep.id) else { continue } let estimate = ep.bytes ?? assumedEpisodeBytes guard used + estimate <= budget else { skipped += 1; continue } downloads.download(DownloadRequest( episodeId: ep.id, ext: ep.ext, show: show.name, label: ep.label, season: ep.season, episode: ep.episode, url: client.streamURL(episodeId: ep.id) )) used += estimate queued += 1 } } var parts = ["Queued \(queued)"] if evicted > 0 { parts.append("evicted \(evicted) watched") } if skipped > 0 { parts.append("\(skipped) over budget") } return parts.joined(separator: ", ") } } // MARK: - Offline library cache /// Persists the last successful catalog so the Library tab can browse offline /// (downloads still play; anything else needs the bridge back). enum LibraryCache { private struct Payload: Codable { let shows: [BridgeShow] let movies: [BridgeMovie] } private static var url: URL { FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] .appendingPathComponent("library-cache.json") } static func save(shows: [BridgeShow], movies: [BridgeMovie]) { if let data = try? JSONEncoder().encode(Payload(shows: shows, movies: movies)) { try? data.write(to: url) } } static func load() -> (shows: [BridgeShow], movies: [BridgeMovie])? { guard let data = try? Data(contentsOf: url), let payload = try? JSONDecoder().decode(Payload.self, from: data) else { return nil } return (payload.shows, payload.movies) } }