fluxa/appleApp/iOS/FluxaAppleDetailStartup.swift
KhooLy 0227d8697a Implement iOS offline downloads matching Android's actual feature set
Android's download engine turned out to be simpler than "full parity"
implied: enqueue, progress, cancel - no pause/resume, no WiFi-only
gating, no auto-delete (none of those exist on Android either, despite
a dead "paused" status branch). Mirror that scope on iOS:

- FluxaAppleDownloadManager: background URLSession-based download
  engine, UserDefaults+JSON persisted (same pattern as other Apple*
  managers in this codebase), pushes state into Kotlin via a new
  updateDownloads bridge call.
- AppleLibraryDataSource now reuses the existing commonMain
  OfflineDownloadItem model and OfflineDownloadGrouping/formatting
  logic directly (same code Android uses) instead of reimplementing
  grouping/status-label/size-label logic in Swift.
- FluxaAppleDetailStartup.downloadEpisode/downloadSeason resolve a
  playable stream through the existing addon resource loader, then
  hand off to the download manager.

Known gap: no application(_:handleEventsForBackgroundURLSession:)
AppDelegate hook, so downloads won't reliably resume/report after the
app is fully killed by the OS - only while backgrounded, not
terminated. Also unverified like the rest of the iOS work this
session: no Xcode/Swift toolchain in this environment to compile
against.
2026-08-12 01:44:21 +03:00

485 lines
17 KiB
Swift

import FluxaCore
import FluxaShared
import Foundation
@MainActor
final class FluxaAppleDetailStartup {
private let coordinator: FluxaAppleHeadlessCoordinator
private let configurationStore: FluxaAppleAddonConfigurationStore
private let addonResourceLoader: FluxaAppleAddonResourceLoader
private let encoder = JSONEncoder()
private var cache: [String: FluxaAppleDetailCacheEntry] = [:]
init(
coordinator: FluxaAppleHeadlessCoordinator,
configurationStore: FluxaAppleAddonConfigurationStore = FluxaAppleAddonConfigurationStore(),
addonResourceLoader: FluxaAppleAddonResourceLoader = FluxaAppleAddonResourceLoader()
) {
self.coordinator = coordinator
self.configurationStore = configurationStore
self.addonResourceLoader = addonResourceLoader
}
func load(request: FluxaShared.AppleDetailRequestSnapshot) async {
do {
let action = FluxaAppleDetailAction(
type: "detailLoadRequested",
contentType: request.type,
id: request.id,
language: "en",
sourceAddonTransportUrl: request.addonTransportUrl,
sourceAddonCatalogType: request.catalogType,
profile: FluxaAppleDetailProfile(id: "apple-default")
)
let actionJson = String(decoding: try encoder.encode(action), as: UTF8.self)
let result = try await coordinator.dispatch(actionJson: actionJson)
await updateSharedDetail(result: result, request: request)
} catch {
updateEmptyDetail(request: request)
}
}
func toggleWatchlist(request: FluxaShared.AppleDetailRequestSnapshot) async {
do {
let action = FluxaAppleToggleWatchlistAction(
type: "toggleWatchlistRequested",
item: FluxaAppleToggleWatchlistItem(
id: request.id,
type: request.type,
name: request.title ?? request.id
)
)
let actionJson = String(decoding: try encoder.encode(action), as: UTF8.self)
let result = try await coordinator.dispatch(actionJson: actionJson)
await updateSharedDetail(result: result, request: request)
} catch {
return
}
}
func selectSeason(request: FluxaShared.AppleDetailSeasonRequestSnapshot) {
guard let entry = cache[request.id] else { return }
entry.selectedSeason = request.season
entry.selectedEpisodeId = nil
pushSnapshot(entry: entry, streams: [], isLoadingStreams: false, loadingAddonNames: [], selectedAddon: nil)
}
func loadSources(request: FluxaShared.AppleDetailStreamsRequestSnapshot) async {
guard let entry = cache[request.id] else { return }
entry.selectedEpisodeId = request.episodeId
pushSnapshot(
entry: entry,
streams: [],
isLoadingStreams: true,
loadingAddonNames: entry.addons.map(addonDisplayName),
selectedAddon: nil
)
let targetId = request.episodeId ?? request.id
let streams = await loadDirectStreams(addons: entry.addons, contentType: request.type, id: targetId)
let subtitleUrls = await loadSubtitleUrls(addons: entry.addons, contentType: request.type, id: targetId)
pushSnapshot(
entry: entry,
streams: streams.map { toSharedStream($0, subtitleUrls: subtitleUrls) },
isLoadingStreams: false,
loadingAddonNames: [],
selectedAddon: nil,
errorKey: streams.isEmpty ? "auto.no_results_found" : nil
)
}
func downloadEpisode(request: FluxaShared.AppleDetailStreamsRequestSnapshot) async {
guard let entry = cache[request.id] else { return }
let episode = request.episodeId.flatMap { episodeId in entry.videos.first { $0.id == episodeId } }
await enqueueDownload(entry: entry, episode: episode)
}
func downloadSeason(request: FluxaShared.AppleDetailSeasonRequestSnapshot) async {
guard let entry = cache[request.id] else { return }
let episodes = entry.videos.filter { $0.season == request.season }
for episode in episodes {
await enqueueDownload(entry: entry, episode: episode)
}
}
private func enqueueDownload(entry: FluxaAppleDetailCacheEntry, episode: FluxaAppleVideo?) async {
let targetId = episode?.id ?? entry.id
guard let stream = await loadDirectStreams(addons: entry.addons, contentType: entry.type, id: targetId).first else {
return
}
let headers = decodeHeaders(stream.requestHeadersJson)
FluxaAppleDownloadManager.shared.enqueue(
metaId: entry.id,
metaType: entry.type,
title: entry.title,
episodeTitle: episode?.name,
videoId: episode?.id,
posterUrl: entry.posterUrl,
backgroundUrl: entry.backgroundUrl,
streamUrl: stream.playableUrl,
requestHeaders: headers
)
}
private func updateSharedDetail(
result: FluxaAppleHeadlessResult,
request: FluxaShared.AppleDetailRequestSnapshot
) async {
guard case .object(let detail)? = result.state["detail"],
case .object(let meta)? = detail["meta"] else {
updateEmptyDetail(request: request)
return
}
let id = text(meta["id"]) ?? request.id
let type = text(meta["type"]) ?? request.type
let addons = addonUrls(for: request)
let videos = parseVideos(meta["videos"])
let seasonsCount = int32(meta["seasonsCount"])
let seasons = availableSeasons(from: videos, seasonsCount: seasonsCount)
let initialSeason = seasons.first.flatMap { Int32($0) } ?? 1
let entry = FluxaAppleDetailCacheEntry(
id: id,
type: type,
title: text(meta["name"]) ?? request.id,
description: text(meta["description"]) ?? "",
posterUrl: text(meta["poster"]),
backgroundUrl: text(meta["background"]),
logoUrl: text(meta["logo"]),
releaseLabel: text(meta["releaseInfo"]) ?? "",
ratingLabel: text(meta["imdbRating"]) ?? "",
isInWatchlist: bool(detail["isInWatchlist"]),
videos: videos,
availableSeasons: seasons,
addons: addons,
selectedSeason: initialSeason,
selectedEpisodeId: nil
)
cache[id] = entry
if type == "series" && !videos.isEmpty {
pushSnapshot(entry: entry, streams: [], isLoadingStreams: false, loadingAddonNames: [], selectedAddon: nil)
} else {
let streams = await loadDirectStreams(addons: addons, contentType: type, id: id)
let subtitleUrls = await loadSubtitleUrls(addons: addons, contentType: type, id: id)
pushSnapshot(
entry: entry,
streams: streams.map { toSharedStream($0, subtitleUrls: subtitleUrls) },
isLoadingStreams: false,
loadingAddonNames: [],
selectedAddon: nil
)
}
}
private func pushSnapshot(
entry: FluxaAppleDetailCacheEntry,
streams: [FluxaShared.AppleDetailStreamSnapshot],
isLoadingStreams: Bool,
loadingAddonNames: [String],
selectedAddon: String?,
errorKey: String? = nil
) {
let episodes = entry.videos
.filter { $0.season == entry.selectedSeason }
.map(toEpisodeSnapshot)
FluxaApple.shared.updateDetail(snapshot: FluxaShared.AppleDetailSnapshot(
id: entry.id,
type: entry.type,
title: entry.title,
description: entry.description,
posterUrl: entry.posterUrl,
backgroundUrl: entry.backgroundUrl,
logoUrl: entry.logoUrl,
releaseLabel: entry.releaseLabel,
ratingLabel: entry.ratingLabel,
isInWatchlist: entry.isInWatchlist,
isLoading: false,
errorKey: errorKey,
streams: streams,
hasStreamProviders: !entry.addons.isEmpty,
availableSeasons: entry.availableSeasons,
selectedSeason: entry.selectedSeason,
seasonEpisodes: episodes,
selectedEpisodeId: entry.selectedEpisodeId,
isLoadingStreams: isLoadingStreams,
availableAddons: entry.addons.map(addonDisplayName),
loadingAddonNames: loadingAddonNames,
selectedAddon: selectedAddon
))
}
private func addonUrls(for request: FluxaShared.AppleDetailRequestSnapshot) -> [String] {
([request.addonTransportUrl].compactMap { $0 } + configurationStore.enabledAddonUrls())
.reduce(into: [String]()) { result, addon in
if !result.contains(addon) {
result.append(addon)
}
}
}
private func loadDirectStreams(
addons: [String],
contentType: String,
id: String
) async -> [FluxaCore.AppleDetailStreamSnapshot] {
var results = [FluxaCore.AppleDetailStreamSnapshot]()
for addon in addons {
if let streams = try? await addonResourceLoader.loadDirectStreams(
transportUrl: addon,
contentType: contentType,
id: id
) {
results.append(contentsOf: streams)
}
}
return results
}
private func loadSubtitleUrls(
addons: [String],
contentType: String,
id: String
) async -> [String] {
var results = [String]()
for addon in addons {
if let subtitles = try? await addonResourceLoader.loadSubtitleUrls(
transportUrl: addon,
contentType: contentType,
id: id
) {
results.append(contentsOf: subtitles)
}
}
var seen = Set<String>()
return results.filter { seen.insert($0).inserted }
}
private func toSharedStream(
_ stream: FluxaCore.AppleDetailStreamSnapshot,
subtitleUrls: [String]
) -> FluxaShared.AppleDetailStreamSnapshot {
FluxaShared.AppleDetailStreamSnapshot(
addonName: stream.addonName,
title: stream.title,
playableUrl: stream.playableUrl,
requestHeadersJson: stream.requestHeadersJson,
subtitleUrls: subtitleUrls
)
}
private func toEpisodeSnapshot(_ video: FluxaAppleVideo) -> FluxaShared.AppleDetailEpisodeSnapshot {
FluxaShared.AppleDetailEpisodeSnapshot(
id: video.id,
season: video.season,
number: video.number,
title: video.name ?? video.id,
description: video.overview,
thumbnailUrl: video.thumbnail,
releaseLabel: video.released,
runtimeLabel: video.episodeRuntime.map { "\($0)m" },
isUpcoming: isUpcoming(video.released),
isWatched: false
)
}
private func parseVideos(_ value: FluxaAppleJsonValue?) -> [FluxaAppleVideo] {
guard case .array(let items)? = value else { return [] }
return items.compactMap { item -> FluxaAppleVideo? in
guard case .object(let fields) = item, let id = text(fields["id"]) else { return nil }
return FluxaAppleVideo(
id: id,
name: text(fields["name"]),
season: int32(fields["season"]) ?? 0,
number: int32(fields["number"]) ?? 0,
released: text(fields["released"]),
thumbnail: text(fields["thumbnail"]),
overview: text(fields["overview"]),
episodeRuntime: int(fields["episodeRuntime"])
)
}
}
private func availableSeasons(from videos: [FluxaAppleVideo], seasonsCount: Int32?) -> [String] {
var seasons = Set<Int32>()
if let seasonsCount, seasonsCount > 0 {
for season in 1...seasonsCount {
seasons.insert(season)
}
}
for video in videos where video.season > 0 {
seasons.insert(video.season)
}
var sorted = seasons.sorted().map(String.init)
if videos.contains(where: { $0.season == 0 }) {
sorted.append("0")
}
return sorted.isEmpty ? ["1"] : sorted
}
private func addonDisplayName(_ transportUrl: String) -> String {
URL(string: transportUrl)?.host ?? transportUrl
}
private func decodeHeaders(_ json: String) -> [String: String] {
guard let data = json.data(using: .utf8),
let decoded = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return [:]
}
return decoded.compactMapValues { $0 as? String }
}
private func isUpcoming(_ released: String?) -> Bool {
guard let released, !released.isEmpty else { return false }
if let date = ISO8601DateFormatter().date(from: released) {
return date > Date()
}
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
formatter.timeZone = TimeZone(identifier: "UTC")
if let date = formatter.date(from: String(released.prefix(10))) {
return date > Date()
}
return false
}
private func updateEmptyDetail(request: FluxaShared.AppleDetailRequestSnapshot) {
cache[request.id] = nil
FluxaApple.shared.updateDetail(snapshot: FluxaShared.AppleDetailSnapshot(
id: request.id,
type: request.type,
title: request.title ?? request.id,
description: "",
posterUrl: nil,
backgroundUrl: nil,
logoUrl: nil,
releaseLabel: "",
ratingLabel: "",
isInWatchlist: false,
isLoading: false,
errorKey: "auto.no_results_found",
streams: [],
hasStreamProviders: false,
availableSeasons: [],
selectedSeason: 1,
seasonEpisodes: [],
selectedEpisodeId: nil,
isLoadingStreams: false,
availableAddons: [],
loadingAddonNames: [],
selectedAddon: nil
))
}
private func text(_ value: FluxaAppleJsonValue?) -> String? {
switch value {
case .string(let text): return text
case .number(let number): return String(number)
default: return nil
}
}
private func bool(_ value: FluxaAppleJsonValue?) -> Bool {
if case .boolean(let value)? = value { return value }
return false
}
private func int32(_ value: FluxaAppleJsonValue?) -> Int32? {
if case .number(let number)? = value { return Int32(number) }
if case .string(let text)? = value { return Int32(text) }
return nil
}
private func int(_ value: FluxaAppleJsonValue?) -> Int? {
if case .number(let number)? = value { return Int(number) }
if case .string(let text)? = value { return Int(text) }
return nil
}
}
private final class FluxaAppleDetailCacheEntry {
let id: String
let type: String
let title: String
let description: String
let posterUrl: String?
let backgroundUrl: String?
let logoUrl: String?
let releaseLabel: String
let ratingLabel: String
let isInWatchlist: Bool
let videos: [FluxaAppleVideo]
let availableSeasons: [String]
let addons: [String]
var selectedSeason: Int32
var selectedEpisodeId: String?
init(
id: String,
type: String,
title: String,
description: String,
posterUrl: String?,
backgroundUrl: String?,
logoUrl: String?,
releaseLabel: String,
ratingLabel: String,
isInWatchlist: Bool,
videos: [FluxaAppleVideo],
availableSeasons: [String],
addons: [String],
selectedSeason: Int32,
selectedEpisodeId: String?
) {
self.id = id
self.type = type
self.title = title
self.description = description
self.posterUrl = posterUrl
self.backgroundUrl = backgroundUrl
self.logoUrl = logoUrl
self.releaseLabel = releaseLabel
self.ratingLabel = ratingLabel
self.isInWatchlist = isInWatchlist
self.videos = videos
self.availableSeasons = availableSeasons
self.addons = addons
self.selectedSeason = selectedSeason
self.selectedEpisodeId = selectedEpisodeId
}
}
private struct FluxaAppleVideo {
let id: String
let name: String?
let season: Int32
let number: Int32
let released: String?
let thumbnail: String?
let overview: String?
let episodeRuntime: Int?
}
private struct FluxaAppleDetailAction: Encodable {
let type: String
let contentType: String
let id: String
let language: String
let sourceAddonTransportUrl: String?
let sourceAddonCatalogType: String?
let profile: FluxaAppleDetailProfile
}
private struct FluxaAppleDetailProfile: Encodable {
let id: String
}
private struct FluxaAppleToggleWatchlistAction: Encodable {
let type: String
let item: FluxaAppleToggleWatchlistItem
}
private struct FluxaAppleToggleWatchlistItem: Encodable {
let id: String
let type: String
let name: String
}