mirror of
https://github.com/FluxaMedia/fluxa.git
synced 2026-07-30 05:29:12 +00:00
Add Apple addon catalog bootstrap
This commit is contained in:
parent
efb6b0c233
commit
d4f5dfd49f
5 changed files with 253 additions and 25 deletions
154
appleApp/AppleCore/FluxaAppleAddonCatalogResolver.swift
Normal file
154
appleApp/AppleCore/FluxaAppleAddonCatalogResolver.swift
Normal 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?
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
47
appleApp/iOS/FluxaAppleCatalogStartup.swift
Normal file
47
appleApp/iOS/FluxaAppleCatalogStartup.swift
Normal 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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue