This commit is contained in:
JinJiangHuang
2026-03-03 12:28:02 +08:00
parent aebf17dfb5
commit 76ed32ccf3
7 changed files with 568 additions and 149 deletions
+140 -34
View File
@@ -7,6 +7,8 @@ class ApiConfig: ObservableObject {
static let shared = ApiConfig()
private static let maxConfigResolveDepth = 6
private static let maxRedirectCandidates = 20
private static let rawConfigCacheTTL: TimeInterval = 20
private static let maxRawConfigCacheEntries = 24
struct MultiRepoOption: Identifiable, Equatable {
let name: String
@@ -26,6 +28,13 @@ class ApiConfig: ObservableObject {
@Published var wallpaper: String = ""
private let network = NetworkManager.shared
private var activeLoadToken = UUID()
private var liveParseTask: Task<Void, Never>?
private struct RawConfigCacheEntry {
let content: String
let fetchedAt: Date
}
private var rawConfigCache: [String: RawConfigCacheEntry] = [:]
private init() {}
@@ -41,6 +50,10 @@ class ApiConfig: ObservableObject {
guard !trimmedVod.isEmpty else {
throw ConfigError.parseError("点播接口地址不能为空")
}
let loadToken = UUID()
activeLoadToken = loadToken
liveParseTask?.cancel()
liveParseTask = nil
let resolvedLive = trimmedLive.isEmpty ? trimmedVod : trimmedLive
self.configUrl = trimmedVod
@@ -48,32 +61,55 @@ class ApiConfig: ObservableObject {
if trimmedVod == resolvedLive {
let configResult = try await fetchConfig(from: trimmedVod)
parseConfig(
guard activeLoadToken == loadToken else { return }
await parseConfig(
configResult.config,
apiUrl: configResult.loadedFrom,
includeSources: true,
includeLive: true
includeLive: false,
loadToken: loadToken
)
scheduleLiveParsing(
config: configResult.config,
apiUrl: configResult.loadedFrom,
loadToken: loadToken
)
} else {
let vodConfig = try await fetchConfig(from: trimmedVod)
parseConfig(
async let vodConfigTask = fetchConfig(from: trimmedVod)
async let liveConfigTask = fetchConfig(from: resolvedLive)
let (vodConfig, liveConfig) = try await (vodConfigTask, liveConfigTask)
guard activeLoadToken == loadToken else { return }
await parseConfig(
vodConfig.config,
apiUrl: vodConfig.loadedFrom,
includeSources: true,
includeLive: false
includeLive: false,
loadToken: loadToken
)
let liveConfig = try await fetchConfig(from: resolvedLive)
parseConfig(
liveConfig.config,
scheduleLiveParsing(
config: liveConfig.config,
apiUrl: liveConfig.loadedFrom,
includeSources: false,
includeLive: true
loadToken: loadToken
)
}
guard activeLoadToken == loadToken else { return }
self.isLoaded = true
}
///
private func scheduleLiveParsing(config: AppConfigData, apiUrl: String, loadToken: UUID) {
liveParseTask?.cancel()
liveParseTask = Task { [config, apiUrl] in
await parseConfig(
config,
apiUrl: apiUrl,
includeSources: false,
includeLive: true,
loadToken: loadToken
)
}
}
private func fetchConfig(from apiUrl: String) async throws -> (config: AppConfigData, loadedFrom: String) {
try await fetchConfig(
@@ -105,7 +141,7 @@ class ApiConfig: ObservableObject {
var nextVisited = visitedUrls
nextVisited.insert(visitKey)
let jsonStr = try await network.getString(from: normalizedUrl)
let jsonStr = try await fetchConfigText(from: normalizedUrl)
// JSONAndroid Gson Swift
let cleanedJson = Self.stripJsonComments(jsonStr)
@@ -173,6 +209,34 @@ class ApiConfig: ObservableObject {
throw ConfigError.parseError("配置格式不受支持")
}
/// +
private func fetchConfigText(from normalizedUrl: String) async throws -> String {
let key = normalizedUrl.lowercased()
let now = Date()
if let entry = rawConfigCache[key] {
if now.timeIntervalSince(entry.fetchedAt) <= Self.rawConfigCacheTTL {
return entry.content
}
rawConfigCache.removeValue(forKey: key)
}
let content = try await network.getString(from: normalizedUrl)
rawConfigCache[key] = RawConfigCacheEntry(content: content, fetchedAt: now)
trimRawConfigCacheIfNeeded()
return content
}
private func trimRawConfigCacheIfNeeded() {
guard rawConfigCache.count > Self.maxRawConfigCacheEntries else { return }
let overflow = rawConfigCache.count - Self.maxRawConfigCacheEntries
let staleKeys = rawConfigCache
.sorted { $0.value.fetchedAt < $1.value.fetchedAt }
.prefix(overflow)
.map(\.key)
staleKeys.forEach { rawConfigCache.removeValue(forKey: $0) }
}
private static func uniqueUrlsInOrder(_ urls: [String]) -> [String] {
var seen: Set<String> = []
var result: [String] = []
@@ -457,7 +521,7 @@ class ApiConfig: ObservableObject {
/// nil
func fetchMultiRepoOptions(from apiUrl: String) async throws -> [MultiRepoOption]? {
let normalizedUrl = Self.normalizeConfigUrl(apiUrl)
let jsonStr = try await network.getString(from: normalizedUrl)
let jsonStr = try await fetchConfigText(from: normalizedUrl)
let cleanedJson = Self.stripJsonComments(jsonStr)
guard let data = cleanedJson.data(using: .utf8) else {
@@ -506,8 +570,11 @@ class ApiConfig: ObservableObject {
_ config: AppConfigData,
apiUrl: String,
includeSources: Bool,
includeLive: Bool
) {
includeLive: Bool,
loadToken: UUID
) async {
guard activeLoadToken == loadToken else { return }
if includeSources {
//
var sources: [SourceBean] = []
@@ -543,6 +610,8 @@ class ApiConfig: ObservableObject {
self.parseBeanList = parses.map { p in
ParseBean(name: p.name ?? "", url: p.url ?? "", type: p.type?.value ?? 0)
}
} else {
self.parseBeanList = []
}
// DoH
@@ -551,6 +620,8 @@ class ApiConfig: ObservableObject {
guard let name = d.name, let url = d.url else { return nil }
return (name: name, url: url)
}
} else {
self.dohList = []
}
//
@@ -559,7 +630,9 @@ class ApiConfig: ObservableObject {
if includeLive {
if let lives = config.lives {
parseLives(lives, apiUrl: apiUrl)
let parsedGroups = await parseLives(lives, apiUrl: apiUrl, loadToken: loadToken)
guard activeLoadToken == loadToken else { return }
liveChannelGroupList = parsedGroups
} else {
liveChannelGroupList = []
}
@@ -567,32 +640,65 @@ class ApiConfig: ObservableObject {
}
///
private func parseLives(_ lives: [AppConfigData.LiveConfig], apiUrl: String) {
Task {
var mergedGroups: [String: LiveChannelGroup] = [:]
private func parseLives(
_ lives: [AppConfigData.LiveConfig],
apiUrl: String,
loadToken: UUID
) async -> [LiveChannelGroup] {
var mergedGroups: [String: LiveChannelGroup] = [:]
var remoteLiveTargets: [(order: Int, url: String)] = []
for (index, live) in lives.enumerated() {
guard activeLoadToken == loadToken else { return [] }
for live in lives {
// url
if let liveUrl = live.url, !liveUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
let resolvedUrl = resolveLiveUrl(liveUrl, baseConfigUrl: apiUrl)
do {
let content = try await network.getString(from: resolvedUrl)
let groups = parseLiveContent(content)
mergeLiveGroups(groups, into: &mergedGroups)
} catch {
print("加载直播源失败: \(resolvedUrl), error: \(error)")
// url
if let liveUrl = live.url, !liveUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
let resolvedUrl = resolveLiveUrl(liveUrl, baseConfigUrl: apiUrl)
remoteLiveTargets.append((order: index, url: resolvedUrl))
}
//
if let channels = live.channels {
let inlineGroups = parseInlineLiveChannels(channels)
mergeLiveGroups(inlineGroups, into: &mergedGroups)
}
}
if !remoteLiveTargets.isEmpty {
let fetchedContents = await withTaskGroup(
of: (Int, String?).self,
returning: [(Int, String)].self
) { group in
for target in remoteLiveTargets {
group.addTask {
do {
let content = try await NetworkManager.shared.getString(from: target.url)
return (target.order, content)
} catch {
print("加载直播源失败: \(target.url), error: \(error)")
return (target.order, nil)
}
}
}
//
if let channels = live.channels {
let inlineGroups = parseInlineLiveChannels(channels)
mergeLiveGroups(inlineGroups, into: &mergedGroups)
var results: [(Int, String)] = []
for await (order, content) in group {
if let content {
results.append((order, content))
}
}
return results
}
self.liveChannelGroupList = sortedGroups(from: mergedGroups)
for (_, content) in fetchedContents.sorted(by: { $0.0 < $1.0 }) {
guard activeLoadToken == loadToken else { return [] }
let groups = parseLiveContent(content)
mergeLiveGroups(groups, into: &mergedGroups)
liveChannelGroupList = sortedGroups(from: mergedGroups)
}
}
return sortedGroups(from: mergedGroups)
}
/// m3u / txt