tv-anarchy/Sources/TVAnarchyiOS/FlightPack.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

97 lines
4 KiB
Swift

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