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>
97 lines
4 KiB
Swift
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)
|
|
}
|
|
}
|