Add Apple addon catalog bootstrap

This commit is contained in:
KhooLy 2026-07-13 15:59:01 +03:00
parent efb6b0c233
commit d4f5dfd49f
5 changed files with 253 additions and 25 deletions

View file

@ -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[..<range.lowerBound])
} else {
base = transportUrl
}
let normalizedBase = base.hasSuffix("/") ? base : "\(base)/"
return "\(normalizedBase)\(resource)/\(encodePathSegment(contentType))/\(encodePathSegment(id)).json"
}
private func encodePathSegment(_ value: String) -> 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?
}

View file

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

View file

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

View file

@ -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}")
}
}
}

View file

@ -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()
}
}
}
}