From d4f5dfd49fe5f675fecc09b715dc4aa938eb61e2 Mon Sep 17 00:00:00 2001 From: KhooLy <73142442+KhooLy@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:59:01 +0300 Subject: [PATCH] Add Apple addon catalog bootstrap --- .../FluxaAppleAddonCatalogResolver.swift | 154 ++++++++++++++++++ .../AppleCore/FluxaAppleCatalogLoader.swift | 56 ++++--- appleApp/iOS/FluxaAppleCatalogBootstrap.swift | 12 +- appleApp/iOS/FluxaAppleCatalogStartup.swift | 47 ++++++ appleApp/iOS/FluxaIosApp.swift | 9 + 5 files changed, 253 insertions(+), 25 deletions(-) create mode 100644 appleApp/AppleCore/FluxaAppleAddonCatalogResolver.swift create mode 100644 appleApp/iOS/FluxaAppleCatalogStartup.swift diff --git a/appleApp/AppleCore/FluxaAppleAddonCatalogResolver.swift b/appleApp/AppleCore/FluxaAppleAddonCatalogResolver.swift new file mode 100644 index 0000000..dc0aeef --- /dev/null +++ b/appleApp/AppleCore/FluxaAppleAddonCatalogResolver.swift @@ -0,0 +1,154 @@ +import Foundation + +struct FluxaAppleAddonCatalogResolver { + private let session: URLSession + private let decoder = JSONDecoder() + + init(session: URLSession = .shared) { + self.session = session + } + + func resolveRequests(localAddonUrls: [String]) async throws -> [FluxaAppleCatalogRequest] { + var requests = [FluxaAppleCatalogRequest]() + var lastError: Error? + for rawUrl in localAddonUrls { + do { + let transportUrl = normalizeManifestUrl(rawUrl) + guard let manifestUrl = URL(string: transportUrl) else { + continue + } + let (data, response) = try await session.data(from: manifestUrl) + guard let httpResponse = response as? HTTPURLResponse, + (200..<300).contains(httpResponse.statusCode) else { + throw URLError(.badServerResponse) + } + let manifest = try decoder.decode(FluxaAppleAddonManifest.self, from: data) + guard manifest.supportsCatalog else { + continue + } + requests.append(contentsOf: manifest.catalogs.orEmpty().compactMap { + makeRequest(catalog: $0, manifest: manifest, transportUrl: transportUrl) + }) + } catch { + lastError = error + } + } + if requests.isEmpty, let lastError { + throw lastError + } + return requests + } + + private func makeRequest( + catalog: FluxaAppleAddonCatalog, + manifest: FluxaAppleAddonManifest, + transportUrl: String + ) -> FluxaAppleCatalogRequest? { + guard let catalogId = catalog.id?.trimmingCharacters(in: .whitespacesAndNewlines), + !catalogId.isEmpty, + let contentType = catalog.type?.trimmingCharacters(in: .whitespacesAndNewlines), + !contentType.isEmpty, + catalog.supportsInitialLoad, + let url = URL(string: resourceUrl( + transportUrl: transportUrl, + resource: "catalog", + contentType: contentType, + id: catalogId + )) else { + return nil + } + let title = catalog.name?.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedTitle = title.flatMap { $0.isEmpty ? nil : $0 } ?? catalogId + return FluxaAppleCatalogRequest( + id: "\(manifest.id):\(contentType):\(catalogId)", + title: resolvedTitle, + url: url, + contentType: contentType, + addonTransportUrl: transportUrl, + catalogType: contentType + ) + } + + private func normalizeManifestUrl(_ rawUrl: String) -> String { + let trimmed = rawUrl.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return "" + } + let lower = trimmed.lowercased() + let withScheme: String + if lower.hasPrefix("stremio://") { + withScheme = "https://" + String(trimmed.dropFirst("stremio://".count)) + } else if lower.hasPrefix("http://") || lower.hasPrefix("https://") { + withScheme = trimmed + } else { + withScheme = "https://\(trimmed)" + } + if withScheme.lowercased().hasSuffix("manifest.json") { + return withScheme + } + return withScheme.hasSuffix("/") ? "\(withScheme)manifest.json" : "\(withScheme)/manifest.json" + } + + private func resourceUrl( + transportUrl: String, + resource: String, + contentType: String, + id: String + ) -> String { + let lower = transportUrl.lowercased() + let base: String + if let range = lower.range(of: "manifest.json", options: .backwards) { + base = String(transportUrl[.. String { + value.addingPercentEncoding(withAllowedCharacters: .alphanumerics.union(CharacterSet(charactersIn: "-_.*"))) ?? value + } +} + +private struct FluxaAppleAddonManifest: Decodable { + let id: String + let resources: [FluxaAppleAddonResource]? + let catalogs: [FluxaAppleAddonCatalog]? + + var supportsCatalog: Bool { + resources?.contains(where: { $0.name == "catalog" }) ?? false + } +} + +private struct FluxaAppleAddonResource: Decodable { + let name: String + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let name = try? container.decode(String.self) { + self.name = name + } else { + self.name = try container.decode(FluxaAppleAddonResourceObject.self).name + } + } +} + +private struct FluxaAppleAddonResourceObject: Decodable { + let name: String +} + +private struct FluxaAppleAddonCatalog: Decodable { + let type: String? + let id: String? + let name: String? + let extra: [FluxaAppleAddonCatalogExtra]? + + var supportsInitialLoad: Bool { + !(extra?.contains(where: { $0.isRequired == true }) ?? false) + } +} + +private struct FluxaAppleAddonCatalogExtra: Decodable { + let isRequired: Bool? +} diff --git a/appleApp/AppleCore/FluxaAppleCatalogLoader.swift b/appleApp/AppleCore/FluxaAppleCatalogLoader.swift index 2e5edb2..b8d703d 100644 --- a/appleApp/AppleCore/FluxaAppleCatalogLoader.swift +++ b/appleApp/AppleCore/FluxaAppleCatalogLoader.swift @@ -20,32 +20,40 @@ final class FluxaAppleCatalogLoader { func loadSnapshot(requests: [FluxaAppleCatalogRequest]) async throws -> String { var rows = [FluxaAppleCatalogRow]() + var lastError: Error? for request in requests { - let (data, response) = try await session.data(from: request.url) - guard let httpResponse = response as? HTTPURLResponse, - (200..<300).contains(httpResponse.statusCode) else { - throw URLError(.badServerResponse) - } - let catalog = try decoder.decode(FluxaAppleStremioCatalogResponse.self, from: data) - rows.append( - FluxaAppleCatalogRow( - id: request.id, - title: request.title, - canLoadMore: false, - items: catalog.metas.map { - FluxaAppleCatalogItem( - id: $0.id, - type: $0.type ?? request.contentType, - title: $0.name, - subtitle: $0.releaseInfo ?? "", - artworkUrl: $0.poster, - logoUrl: $0.logo, - addonTransportUrl: request.addonTransportUrl, - catalogType: request.catalogType - ) - } + do { + let (data, response) = try await session.data(from: request.url) + guard let httpResponse = response as? HTTPURLResponse, + (200..<300).contains(httpResponse.statusCode) else { + throw URLError(.badServerResponse) + } + let catalog = try decoder.decode(FluxaAppleStremioCatalogResponse.self, from: data) + rows.append( + FluxaAppleCatalogRow( + id: request.id, + title: request.title, + canLoadMore: false, + items: catalog.metas.map { + FluxaAppleCatalogItem( + id: $0.id, + type: $0.type ?? request.contentType, + title: $0.name, + subtitle: $0.releaseInfo ?? "", + artworkUrl: $0.poster, + logoUrl: $0.logo, + addonTransportUrl: request.addonTransportUrl, + catalogType: request.catalogType + ) + } + ) ) - ) + } catch { + lastError = error + } + } + if rows.isEmpty, let lastError { + throw lastError } return String(decoding: try encoder.encode(FluxaAppleCatalogSnapshot(rows: rows)), as: UTF8.self) } diff --git a/appleApp/iOS/FluxaAppleCatalogBootstrap.swift b/appleApp/iOS/FluxaAppleCatalogBootstrap.swift index d5e2edb..a4a9763 100644 --- a/appleApp/iOS/FluxaAppleCatalogBootstrap.swift +++ b/appleApp/iOS/FluxaAppleCatalogBootstrap.swift @@ -4,13 +4,23 @@ import Foundation @MainActor final class FluxaAppleCatalogBootstrap { private let loader: FluxaAppleCatalogLoader + private let resolver: FluxaAppleAddonCatalogResolver - init(loader: FluxaAppleCatalogLoader = FluxaAppleCatalogLoader()) { + init( + loader: FluxaAppleCatalogLoader = FluxaAppleCatalogLoader(), + resolver: FluxaAppleAddonCatalogResolver = FluxaAppleAddonCatalogResolver() + ) { self.loader = loader + self.resolver = resolver } func refresh(requests: [FluxaAppleCatalogRequest]) async throws { let snapshot = try await loader.loadSnapshot(requests: requests) FluxaApple.shared.updateCatalogHomeJson(homeJson: snapshot) } + + func refresh(localAddonUrls: [String]) async throws { + let requests = try await resolver.resolveRequests(localAddonUrls: localAddonUrls) + try await refresh(requests: requests) + } } diff --git a/appleApp/iOS/FluxaAppleCatalogStartup.swift b/appleApp/iOS/FluxaAppleCatalogStartup.swift new file mode 100644 index 0000000..7548b98 --- /dev/null +++ b/appleApp/iOS/FluxaAppleCatalogStartup.swift @@ -0,0 +1,47 @@ +import Foundation +import FluxaShared + +final class FluxaAppleAddonConfigurationStore { + static let localAddonUrlsKey = "fluxa.apple.localAddonUrls" + + private let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + func localAddonUrls() -> [String] { + let stored = defaults.stringArray(forKey: Self.localAddonUrlsKey) + let values = stored ?? ["https://v3-cinemeta.strem.io/manifest.json"] + return values.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + } + + func save(localAddonUrls: [String]) { + let normalized = localAddonUrls.map { + $0.trimmingCharacters(in: .whitespacesAndNewlines) + }.filter { !$0.isEmpty } + defaults.set(normalized, forKey: Self.localAddonUrlsKey) + } +} + +@MainActor +final class FluxaAppleCatalogStartup { + private let configurationStore: FluxaAppleAddonConfigurationStore + private let bootstrap: FluxaAppleCatalogBootstrap + + init( + configurationStore: FluxaAppleAddonConfigurationStore = FluxaAppleAddonConfigurationStore(), + bootstrap: FluxaAppleCatalogBootstrap = FluxaAppleCatalogBootstrap() + ) { + self.configurationStore = configurationStore + self.bootstrap = bootstrap + } + + func refresh() async { + do { + try await bootstrap.refresh(localAddonUrls: configurationStore.localAddonUrls()) + } catch { + FluxaApple.shared.updateCatalogHomeJson(homeJson: "{\"rows\":[],\"isLoading\":false}") + } + } +} diff --git a/appleApp/iOS/FluxaIosApp.swift b/appleApp/iOS/FluxaIosApp.swift index e5c61e0..6d111d6 100644 --- a/appleApp/iOS/FluxaIosApp.swift +++ b/appleApp/iOS/FluxaIosApp.swift @@ -4,11 +4,20 @@ import SwiftUI struct FluxaIosApp: App { private let coreVersion = FluxaRustCoreRuntime.version() private let headlessRuntime = requireFluxaAppleHeadlessRuntime() + private let catalogStartup = FluxaAppleCatalogStartup() + @State private var hasStartedCatalog = false var body: some Scene { WindowGroup { FluxaRootView() .ignoresSafeArea() + .task { + guard !hasStartedCatalog else { + return + } + hasStartedCatalog = true + await catalogStartup.refresh() + } } } }