fluxa/appleApp/iOS/FluxaAppleHomeEffectHandler.swift
KhooLy 6b218ba97f Track enabled/disabled addon state and respect it when fetching content
FluxaAppleAddonConfigurationStore gains a disabled-addon set and
enabledAddonUrls(); Home/Search/Discover/Detail now fetch from
enabledAddonUrls() instead of the raw configured list, so disabling an
addon in the store actually stops it from being queried.
2026-07-20 15:05:25 +03:00

234 lines
9.6 KiB
Swift

import Foundation
import FluxaShared
final class FluxaAppleHomeEffectHandler: FluxaApplePlatformEffectHandler {
private let configurationStore: FluxaAppleAddonConfigurationStore
private let catalogBootstrap: FluxaAppleCatalogBootstrap
private let addonResourceLoader: FluxaAppleAddonResourceLoader
private let addonCatalogResolver: FluxaAppleAddonCatalogResolver
private let libraryStore: FluxaAppleLibraryStore
init(
configurationStore: FluxaAppleAddonConfigurationStore,
catalogBootstrap: FluxaAppleCatalogBootstrap,
addonResourceLoader: FluxaAppleAddonResourceLoader = FluxaAppleAddonResourceLoader(),
addonCatalogResolver: FluxaAppleAddonCatalogResolver = FluxaAppleAddonCatalogResolver(),
libraryStore: FluxaAppleLibraryStore = FluxaAppleLibraryStore()
) {
self.configurationStore = configurationStore
self.catalogBootstrap = catalogBootstrap
self.addonResourceLoader = addonResourceLoader
self.addonCatalogResolver = addonCatalogResolver
self.libraryStore = libraryStore
}
func execute(effect: FluxaAppleHeadlessEffect) async throws -> FluxaAppleJsonValue {
switch effect.type {
case "readHomeBootstrap":
let rows = try await catalogBootstrap.loadRows(
localAddonUrls: configurationStore.enabledAddonUrls()
)
return .object([
"categories": .array(rows.map(homeCategory)),
"continueWatching": .array([]),
"watchlist": .array([]),
"userAddons": .array(configurationStore.localAddonUrls().map { .string($0) }),
"metadataFeeds": .array([]),
"billboard": rows.first?.items.first.map(homeMeta) ?? .null
])
case "refreshContinueWatching":
return .object(["continueWatching": .array([])])
case "fetchMetaDetail":
return try await loadMeta(effect: effect)
case "runSearch":
return try await runSearch(effect: effect)
case "runDiscover":
return try await runDiscover(effect: effect)
case "readDiscoverCatalogFilters":
return try await readDiscoverCatalogFilters(effect: effect)
case "readLibraryState":
return .object([
"watchlist": .array(libraryStore.watchlist()),
"continueWatching": .array([]),
"liked": .array([]),
"watched": .object([:])
])
case "readCalendarMonth":
return .object(["items": .array([])])
case "writeLibraryCommand":
return try writeLibraryCommand(effect: effect)
case "readPlaybackProgress":
return .null
default:
throw NSError(domain: "FluxaAppleUnsupportedEffect", code: 1)
}
}
private func homeCategory(_ row: AppleCatalogRowSnapshot) -> FluxaAppleJsonValue {
.object([
"id": .string(row.id),
"name": .string(row.title),
"semanticName": .string(row.title),
"type": .string(row.items.first?.type ?? ""),
"catalogId": .string(row.id),
"items": .array(row.items.map(homeMeta))
])
}
private func homeMeta(_ item: AppleCatalogItemSnapshot) -> FluxaAppleJsonValue {
.object([
"id": .string(item.id),
"type": .string(item.type),
"name": .string(item.title),
"poster": optionalString(item.artworkUrl),
"logo": optionalString(item.logoUrl),
"releaseInfo": .string(item.subtitle),
"addonTransportUrl": optionalString(item.addonTransportUrl),
"catalogType": optionalString(item.catalogType)
])
}
private func optionalString(_ value: String?) -> FluxaAppleJsonValue {
value.map(FluxaAppleJsonValue.string) ?? .null
}
private func string(_ value: FluxaAppleJsonValue?) -> String? {
guard case .string(let text)? = value else {
return nil
}
return text
}
private func loadMeta(effect: FluxaAppleHeadlessEffect) async throws -> FluxaAppleJsonValue {
guard case .object(let payload) = effect.payload,
let contentType = string(payload["contentType"]),
let id = string(payload["id"]) else {
throw URLError(.cannotParseResponse)
}
let transportUrl = string(payload["sourceAddonTransportUrl"])
?? configurationStore.enabledAddonUrls().first
guard let transportUrl else {
throw URLError(.fileDoesNotExist)
}
return try await addonResourceLoader.loadMeta(
transportUrl: transportUrl,
contentType: contentType,
id: id
)
}
private func runSearch(effect: FluxaAppleHeadlessEffect) async throws -> FluxaAppleJsonValue {
guard case .object(let payload) = effect.payload,
let query = string(payload["query"]) else {
throw URLError(.cannotParseResponse)
}
let items = try await catalogBootstrap.loadSearchItems(
localAddonUrls: configurationStore.enabledAddonUrls(),
query: query
)
return .object(["results": .array(items.map(homeMeta))])
}
private func runDiscover(effect: FluxaAppleHeadlessEffect) async throws -> FluxaAppleJsonValue {
guard case .object(let payload) = effect.payload,
let contentType = string(payload["contentType"]) else {
throw URLError(.cannotParseResponse)
}
let filters: [String: FluxaAppleJsonValue] = {
guard case .object(let value)? = payload["filters"] else {
return [:]
}
return value
}()
let selectedCatalogKey = string(filters["catalogKey"])
let genre = string(filters["genre"])
let catalogs = try await addonCatalogResolver.resolveDiscoverCatalogs(
localAddonUrls: configurationStore.enabledAddonUrls(),
contentType: contentType
)
let selectedCatalogs = selectedCatalogKey.map { key in catalogs.filter { $0.key == key } } ?? []
let catalogsToLoad = selectedCatalogs.isEmpty ? catalogs : selectedCatalogs
let requests = catalogsToLoad.compactMap { catalog -> FluxaAppleCatalogRequest? in
guard let url = addonCatalogResolver.discoverUrl(
transportUrl: catalog.transportUrl,
contentType: catalog.contentType,
catalogId: catalog.catalogId,
genre: genre
) else {
return nil
}
return FluxaAppleCatalogRequest(
id: catalog.key,
title: catalog.label,
url: url,
contentType: catalog.contentType,
addonTransportUrl: catalog.transportUrl,
catalogType: catalog.contentType
)
}
let rows = try await catalogBootstrap.loadRows(requests: requests)
let items = rows.flatMap(\.items)
return .object([
"results": .array(items.map(homeMeta)),
"resultSources": .object([:])
])
}
private func readDiscoverCatalogFilters(effect: FluxaAppleHeadlessEffect) async throws -> FluxaAppleJsonValue {
guard case .object(let payload) = effect.payload,
let contentType = string(payload["contentType"]) else {
throw URLError(.cannotParseResponse)
}
let selectedCatalogKey = string(payload["selectedCatalogKey"])
let catalogs = try await addonCatalogResolver.resolveDiscoverCatalogs(
localAddonUrls: configurationStore.enabledAddonUrls(),
contentType: contentType
)
let selectedCatalog = catalogs.first { $0.key == selectedCatalogKey }
let genres = selectedCatalog.map { catalog in
catalog.requiresGenre ? catalog.genres : [""] + catalog.genres
} ?? []
return .object([
"catalogs": .array(catalogs.map(discoverCatalog)),
"genres": .array(genres.map { genre in
.object([
"id": genre.isEmpty ? .null : .string(genre),
"label": .string(genre)
])
})
])
}
private func discoverCatalog(_ catalog: FluxaAppleDiscoverCatalog) -> FluxaAppleJsonValue {
.object([
"key": .string(catalog.key),
"label": .string(catalog.label),
"transportUrl": .string(catalog.transportUrl),
"type": .string(catalog.contentType),
"id": .string(catalog.catalogId),
"genres": .array(catalog.genres.map(FluxaAppleJsonValue.string)),
"requiresGenre": .boolean(catalog.requiresGenre)
])
}
private func writeLibraryCommand(effect: FluxaAppleHeadlessEffect) throws -> FluxaAppleJsonValue {
guard case .object(let payload) = effect.payload,
case .object(let command)? = payload["command"],
case .string(let type)? = command["type"] else {
throw URLError(.cannotParseResponse)
}
switch type {
case "toggleWatchlist":
guard let item = command["item"] else {
throw URLError(.cannotParseResponse)
}
let result = libraryStore.watchlistState(item: item)
return .object([
"watchlist": .array(result.watchlist),
"isInWatchlist": .boolean(result.isInWatchlist)
])
default:
throw NSError(domain: "FluxaAppleUnsupportedLibraryCommand", code: 1)
}
}
}