diff --git a/tvbox/Persistence/CacheStore.swift b/tvbox/Persistence/CacheStore.swift index a6122ba..aabee0b 100644 --- a/tvbox/Persistence/CacheStore.swift +++ b/tvbox/Persistence/CacheStore.swift @@ -3,6 +3,13 @@ import SwiftData /// SwiftData 持久化模型 - 对应 Android 版 Room 数据库 +/// 收藏/历史的业务唯一键(source + vodId)。 +private func makeVodBusinessKey(vodId: String, sourceKey: String) -> String { + let normalizedVodId = vodId.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedSourceKey = sourceKey.trimmingCharacters(in: .whitespacesAndNewlines) + return "\(normalizedSourceKey)::\(normalizedVodId)" +} + /// 单部剧的续播状态 struct VodPlaybackState: Codable { /// 当前播放线路标识。 @@ -16,6 +23,8 @@ struct VodPlaybackState: Codable { /// 视频收藏 @Model final class VodCollect { + /// 业务唯一键(sourceKey + vodId)。 + var bizKey: String = "" /// 视频 ID(与 sourceKey 组成唯一语义键)。 var vodId: String = "" /// 片名。 @@ -28,6 +37,7 @@ final class VodCollect { var updateTime: Date = Date() init(vodId: String, vodName: String, vodPic: String, sourceKey: String) { + self.bizKey = makeVodBusinessKey(vodId: vodId, sourceKey: sourceKey) self.vodId = vodId self.vodName = vodName self.vodPic = vodPic @@ -39,6 +49,8 @@ final class VodCollect { /// 播放历史记录 @Model final class VodRecord { + /// 业务唯一键(sourceKey + vodId)。 + var bizKey: String = "" /// 视频 ID。 var vodId: String = "" /// 片名。 @@ -55,6 +67,7 @@ final class VodRecord { var updateTime: Date = Date() init(vodId: String, vodName: String, vodPic: String, sourceKey: String, playNote: String = "") { + self.bizKey = makeVodBusinessKey(vodId: vodId, sourceKey: sourceKey) self.vodId = vodId self.vodName = vodName self.vodPic = vodPic @@ -89,50 +102,64 @@ actor CacheStore { @MainActor func addCollect(_ video: Movie.Video, context: ModelContext) { - // 先检查是否已存在 let vodId = video.id let sourceKey = video.sourceKey - let predicate = #Predicate { item in - item.vodId == vodId && item.sourceKey == sourceKey - } - let descriptor = FetchDescriptor(predicate: predicate) + let bizKey = makeVodBusinessKey(vodId: vodId, sourceKey: sourceKey) - if let existing = try? context.fetch(descriptor), !existing.isEmpty { - return // 已收藏 + do { + let matched = try fetchCollects(vodId: vodId, sourceKey: sourceKey, context: context) + if let first = matched.first { + first.bizKey = bizKey + first.vodName = video.name + first.vodPic = video.pic + first.updateTime = Date() + for duplicate in matched.dropFirst() { + context.delete(duplicate) + } + } else { + let collect = VodCollect( + vodId: vodId, + vodName: video.name, + vodPic: video.pic, + sourceKey: sourceKey + ) + context.insert(collect) + } + try context.save() + } catch { + print("写入收藏失败: \(error)") } - - let collect = VodCollect( - vodId: video.id, - vodName: video.name, - vodPic: video.pic, - sourceKey: video.sourceKey - ) - context.insert(collect) - try? context.save() } @MainActor func removeCollect(vodId: String, sourceKey: String, context: ModelContext) { - // 收藏以 (vodId, sourceKey) 为业务唯一键,删除时也按该组合匹配。 - let predicate = #Predicate { item in - item.vodId == vodId && item.sourceKey == sourceKey - } - let descriptor = FetchDescriptor(predicate: predicate) - if let items = try? context.fetch(descriptor) { + do { + let items = try fetchCollects(vodId: vodId, sourceKey: sourceKey, context: context) + guard !items.isEmpty else { return } for item in items { context.delete(item) } - try? context.save() + try context.save() + } catch { + print("删除收藏失败: \(error)") } } @MainActor func isCollected(vodId: String, sourceKey: String, context: ModelContext) -> Bool { + let bizKey = makeVodBusinessKey(vodId: vodId, sourceKey: sourceKey) let predicate = #Predicate { item in - item.vodId == vodId && item.sourceKey == sourceKey + item.bizKey == bizKey || (item.bizKey == "" && item.vodId == vodId && item.sourceKey == sourceKey) } let descriptor = FetchDescriptor(predicate: predicate) - return (try? context.fetchCount(descriptor)) ?? 0 > 0 + + do { + let count = try context.fetchCount(descriptor) + return count > 0 + } catch { + print("查询收藏状态失败: \(error)") + return false + } } @MainActor @@ -144,39 +171,55 @@ actor CacheStore { ) { let vodId = video.id let sourceKey = video.sourceKey - let record = fetchRecord(vodId: vodId, sourceKey: sourceKey, context: context) let encodedState = Self.encodePlaybackState(playbackState) + let bizKey = makeVodBusinessKey(vodId: vodId, sourceKey: sourceKey) - // 更新或插入 - if let record { - record.playNote = playNote - if let encodedState { - record.dataJson = encodedState + do { + let matched = try fetchRecords(vodId: vodId, sourceKey: sourceKey, context: context) + + // 更新或插入 + if let record = matched.first { + record.bizKey = bizKey + record.playNote = playNote + if let encodedState { + record.dataJson = encodedState + } + record.updateTime = Date() + for duplicate in matched.dropFirst() { + context.delete(duplicate) + } + } else { + let record = VodRecord( + vodId: vodId, + vodName: video.name, + vodPic: video.pic, + sourceKey: sourceKey, + playNote: playNote + ) + if let encodedState { + record.dataJson = encodedState + } + context.insert(record) } - record.updateTime = Date() - } else { - let record = VodRecord( - vodId: video.id, - vodName: video.name, - vodPic: video.pic, - sourceKey: video.sourceKey, - playNote: playNote - ) - if let encodedState { - record.dataJson = encodedState - } - context.insert(record) + + try context.save() + } catch { + print("写入播放记录失败: \(error)") } - try? context.save() } /// 读取续播状态(若无记录或 JSON 无法解码则返回 `nil`)。 @MainActor func getPlaybackState(vodId: String, sourceKey: String, context: ModelContext) -> VodPlaybackState? { - guard let record = fetchRecord(vodId: vodId, sourceKey: sourceKey, context: context) else { + do { + guard let record = try fetchRecords(vodId: vodId, sourceKey: sourceKey, context: context).first else { + return nil + } + return Self.decodePlaybackState(record.dataJson) + } catch { + print("读取续播状态失败: \(error)") return nil } - return Self.decodePlaybackState(record.dataJson) } @MainActor @@ -190,14 +233,47 @@ actor CacheStore { } @MainActor - private func fetchRecord(vodId: String, sourceKey: String, context: ModelContext) -> VodRecord? { - // 历史记录同样以 (vodId, sourceKey) 作为业务键。 + private func fetchRecords(vodId: String, sourceKey: String, context: ModelContext) throws -> [VodRecord] { + let bizKey = makeVodBusinessKey(vodId: vodId, sourceKey: sourceKey) let predicate = #Predicate { item in - item.vodId == vodId && item.sourceKey == sourceKey + item.bizKey == bizKey || (item.bizKey == "" && item.vodId == vodId && item.sourceKey == sourceKey) } let descriptor = FetchDescriptor(predicate: predicate) - guard let records = try? context.fetch(descriptor) else { return nil } - return records.first + let records = try context.fetch(descriptor) + + // 兼容旧数据:命中 legacy 记录时补写业务键。 + var needsSave = false + for record in records where record.bizKey.isEmpty { + record.bizKey = bizKey + needsSave = true + } + if needsSave { + try context.save() + } + + return records.sorted(by: { $0.updateTime > $1.updateTime }) + } + + @MainActor + private func fetchCollects(vodId: String, sourceKey: String, context: ModelContext) throws -> [VodCollect] { + let bizKey = makeVodBusinessKey(vodId: vodId, sourceKey: sourceKey) + let predicate = #Predicate { item in + item.bizKey == bizKey || (item.bizKey == "" && item.vodId == vodId && item.sourceKey == sourceKey) + } + let descriptor = FetchDescriptor(predicate: predicate) + let collects = try context.fetch(descriptor) + + // 兼容旧数据:命中 legacy 记录时补写业务键。 + var needsSave = false + for collect in collects where collect.bizKey.isEmpty { + collect.bizKey = bizKey + needsSave = true + } + if needsSave { + try context.save() + } + + return collects.sorted(by: { $0.updateTime > $1.updateTime }) } private nonisolated static func encodePlaybackState(_ state: VodPlaybackState?) -> String? { diff --git a/tvbox/Services/ApiConfig.swift b/tvbox/Services/ApiConfig.swift index 696dc3e..57a0834 100644 --- a/tvbox/Services/ApiConfig.swift +++ b/tvbox/Services/ApiConfig.swift @@ -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? + 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) // 清理非标准 JSON(Android 端 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 = [] 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 格式的直播内容 diff --git a/tvbox/Services/SourceService.swift b/tvbox/Services/SourceService.swift index 67f516b..f6e415a 100644 --- a/tvbox/Services/SourceService.swift +++ b/tvbox/Services/SourceService.swift @@ -34,19 +34,24 @@ class SourceService { jsonStr = try await network.getString(from: api) } else if sourceBean.type == 4 { // Type 4: 远程接口,需要 extend 和 filter 参数 - var url = api.contains("?") ? "\(api)&filter=true" : "\(api)?filter=true" + var queryItems: [URLQueryItem] = [ + URLQueryItem(name: "filter", value: "true") + ] // 加载 extend if let ext = sourceBean.ext, !ext.isEmpty { let extend = await resolveExtend(ext) if !extend.isEmpty { - let encoded = extend.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? extend - url += "&extend=\(encoded)" + queryItems.append(URLQueryItem(name: "extend", value: extend)) } } + let url = try buildURL(base: api, queryItems: queryItems) jsonStr = try await network.getString(from: url) } else { // JSON 接口 (type=1) - let url = api.contains("?") ? "\(api)&ac=class" : "\(api)?ac=class" + let url = try buildURL( + base: api, + queryItems: [URLQueryItem(name: "ac", value: "class")] + ) jsonStr = try await network.getString(from: url) } @@ -62,12 +67,24 @@ class SourceService { if sourceBean.type == 4 { // type=4 用 ac=detail 格式,与 getList 保持一致 let ext = Data("{}".utf8).base64EncodedString() - listUrl = api.contains("?") - ? "\(api)&ac=detail&filter=true&pg=1&ext=\(ext)" - : "\(api)?ac=detail&filter=true&pg=1&ext=\(ext)" + listUrl = try buildURL( + base: api, + queryItems: [ + URLQueryItem(name: "ac", value: "detail"), + URLQueryItem(name: "filter", value: "true"), + URLQueryItem(name: "pg", value: "1"), + URLQueryItem(name: "ext", value: ext) + ] + ) } else { // type=1 用 ac=videolist 格式 - listUrl = api.contains("?") ? "\(api)&ac=videolist&pg=1" : "\(api)?ac=videolist&pg=1" + listUrl = try buildURL( + base: api, + queryItems: [ + URLQueryItem(name: "ac", value: "videolist"), + URLQueryItem(name: "pg", value: "1") + ] + ) } if let listStr = try? await network.getString(from: listUrl) { let fallback = (try? parseVideoList(listStr, sourceKey: sourceBean.key, type: sourceBean.type)) ?? [] @@ -152,48 +169,61 @@ class SourceService { guard sourceBean.isSupportedInSwift else { throw SourceError.unsupportedType(sourceBean.typeDescription) } guard sourceBean.isHttpApi else { throw SourceError.invalidApiUrl(api) } - var url: String + let url: String if sourceBean.type == 0 { // XML 接口 - url = "\(api)?ac=videolist&t=\(sortData.id)&pg=\(page)" + url = try buildURL( + base: api, + queryItems: [ + URLQueryItem(name: "ac", value: "videolist"), + URLQueryItem(name: "t", value: sortData.id), + URLQueryItem(name: "pg", value: String(page)) + ] + ) } else if sourceBean.type == 4 { // Type 4: 远程接口 - url = api.contains("?") - ? "\(api)&ac=detail&filter=true&t=\(sortData.id)&pg=\(page)" - : "\(api)?ac=detail&filter=true&t=\(sortData.id)&pg=\(page)" + var queryItems: [URLQueryItem] = [ + URLQueryItem(name: "ac", value: "detail"), + URLQueryItem(name: "filter", value: "true"), + URLQueryItem(name: "t", value: sortData.id), + URLQueryItem(name: "pg", value: String(page)) + ] // 附加筛选参数(base64 编码) if let filters = filters, !filters.isEmpty { if let filterData = try? JSONSerialization.data(withJSONObject: filters), let filterStr = String(data: filterData, encoding: .utf8) { let ext = Data(filterStr.utf8).base64EncodedString() - url += "&ext=\(ext)" + queryItems.append(URLQueryItem(name: "ext", value: ext)) } } else { let ext = Data("{}".utf8).base64EncodedString() - url += "&ext=\(ext)" + queryItems.append(URLQueryItem(name: "ext", value: ext)) } // 加载 extend if let ext = sourceBean.ext, !ext.isEmpty { let extend = await resolveExtend(ext) if !extend.isEmpty { - let encoded = extend.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? extend - url += "&extend=\(encoded)" + queryItems.append(URLQueryItem(name: "extend", value: extend)) } } + url = try buildURL(base: api, queryItems: queryItems) } else { // JSON 接口 (type=1) - url = api.contains("?") - ? "\(api)&ac=videolist&t=\(sortData.id)&pg=\(page)" - : "\(api)?ac=videolist&t=\(sortData.id)&pg=\(page)" + var queryItems: [URLQueryItem] = [ + URLQueryItem(name: "ac", value: "videolist"), + URLQueryItem(name: "t", value: sortData.id), + URLQueryItem(name: "pg", value: String(page)) + ] // 附加筛选参数 if let filters = filters { for (key, value) in filters { - url += "&\(key)=\(value)" + queryItems.append(URLQueryItem(name: key, value: value)) } } + url = try buildURL(base: api, queryItems: queryItems) } let jsonStr = try await network.getString(from: url) @@ -255,28 +285,39 @@ class SourceService { guard sourceBean.isSupportedInSwift else { throw SourceError.unsupportedType(sourceBean.typeDescription) } guard sourceBean.isHttpApi else { throw SourceError.invalidApiUrl(api) } - var url: String + let url: String if sourceBean.type == 0 { - url = "\(api)?ac=videolist&ids=\(vodId)" + url = try buildURL( + base: api, + queryItems: [ + URLQueryItem(name: "ac", value: "videolist"), + URLQueryItem(name: "ids", value: vodId) + ] + ) } else if sourceBean.type == 4 { // Type 4: 远程接口 - url = api.contains("?") - ? "\(api)&ac=detail&ids=\(vodId)" - : "\(api)?ac=detail&ids=\(vodId)" + var queryItems: [URLQueryItem] = [ + URLQueryItem(name: "ac", value: "detail"), + URLQueryItem(name: "ids", value: vodId) + ] // 加载 extend if let ext = sourceBean.ext, !ext.isEmpty { let extend = await resolveExtend(ext) if !extend.isEmpty { - let encoded = extend.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? extend - url += "&extend=\(encoded)" + queryItems.append(URLQueryItem(name: "extend", value: extend)) } } + url = try buildURL(base: api, queryItems: queryItems) } else { // JSON 接口 (type=1) - url = api.contains("?") - ? "\(api)&ac=detail&ids=\(vodId)" - : "\(api)?ac=detail&ids=\(vodId)" + url = try buildURL( + base: api, + queryItems: [ + URLQueryItem(name: "ac", value: "detail"), + URLQueryItem(name: "ids", value: vodId) + ] + ) } let jsonStr = try await network.getString(from: url) @@ -284,25 +325,27 @@ class SourceService { } private func parseDetail(_ jsonStr: String, sourceKey: String, type: Int) throws -> VodInfo? { + if type == 0 { + return parseXMLDetail(jsonStr, sourceKey: sourceKey) + } + guard let data = jsonStr.data(using: .utf8) else { throw SourceError.parseError("无法解析数据") } - if type == 1 || type == 4 { - if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let list = json["list"] as? [[String: Any]], - let first = list.first { + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let list = json["list"] as? [[String: Any]], + let first = list.first { + + let decoder = JSONDecoder() + if let itemData = try? JSONSerialization.data(withJSONObject: first), + var video = try? decoder.decode(Movie.Video.self, from: itemData) { + video.sourceKey = sourceKey - let decoder = JSONDecoder() - if let itemData = try? JSONSerialization.data(withJSONObject: first), - var video = try? decoder.decode(Movie.Video.self, from: itemData) { - video.sourceKey = sourceKey - - let playFrom = first["vod_play_from"] as? String ?? "" - let playUrl = first["vod_play_url"] as? String ?? "" - - return VodInfo.from(video: video, playFrom: playFrom, playUrl: playUrl) - } + let playFrom = first["vod_play_from"] as? String ?? "" + let playUrl = first["vod_play_url"] as? String ?? "" + + return VodInfo.from(video: video, playFrom: playFrom, playUrl: playUrl) } } @@ -318,31 +361,35 @@ class SourceService { guard sourceBean.isSupportedInSwift else { throw SourceError.unsupportedType(sourceBean.typeDescription) } guard sourceBean.isHttpApi else { throw SourceError.invalidApiUrl(api) } - let encodedKeyword = keyword.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? keyword - - var url: String + let url: String if sourceBean.type == 0 { - url = "\(api)?wd=\(encodedKeyword)" + url = try buildURL( + base: api, + queryItems: [URLQueryItem(name: "wd", value: keyword)] + ) } else if sourceBean.type == 4 { // Type 4: 远程接口 let quickValue = sourceBean.isQuickSearchEnabled ? "true" : "false" - url = api.contains("?") - ? "\(api)&wd=\(encodedKeyword)&ac=detail&quick=\(quickValue)" - : "\(api)?wd=\(encodedKeyword)&ac=detail&quick=\(quickValue)" + var queryItems: [URLQueryItem] = [ + URLQueryItem(name: "wd", value: keyword), + URLQueryItem(name: "ac", value: "detail"), + URLQueryItem(name: "quick", value: quickValue) + ] // 加载 extend if let ext = sourceBean.ext, !ext.isEmpty { let extend = await resolveExtend(ext) if !extend.isEmpty { - let encoded = extend.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? extend - url += "&extend=\(encoded)" + queryItems.append(URLQueryItem(name: "extend", value: extend)) } } + url = try buildURL(base: api, queryItems: queryItems) } else { // JSON 接口 (type=1) - url = api.contains("?") - ? "\(api)&wd=\(encodedKeyword)" - : "\(api)?wd=\(encodedKeyword)" + url = try buildURL( + base: api, + queryItems: [URLQueryItem(name: "wd", value: keyword)] + ) } let jsonStr = try await network.getString(from: url) @@ -437,6 +484,128 @@ class SourceService { return extend } } + + private func parseXMLDetail(_ xml: String, sourceKey: String) -> VodInfo? { + guard let videoBlock = firstMatch( + pattern: #""#, + in: xml + ) else { + return nil + } + + let vodId = extractXMLTag("id", in: videoBlock) + guard !vodId.isEmpty else { return nil } + + var video = Movie.Video(id: vodId) + video.name = extractXMLTag("name", in: videoBlock) + video.pic = extractXMLTag("pic", in: videoBlock) + video.note = extractXMLTag("note", in: videoBlock) + video.year = extractXMLTag("year", in: videoBlock) + video.area = extractXMLTag("area", in: videoBlock) + video.type = extractXMLTag("type", in: videoBlock) + video.director = extractXMLTag("director", in: videoBlock) + video.actor = extractXMLTag("actor", in: videoBlock) + video.des = extractXMLTag("des", in: videoBlock) + video.sourceKey = sourceKey + + let ddNodes = extractXMLDDNodes(from: videoBlock) + let playFrom: String + let playUrl: String + + if ddNodes.isEmpty { + playFrom = "默认" + playUrl = "" + } else { + playFrom = ddNodes.map { $0.flag }.joined(separator: "$$$") + playUrl = ddNodes.map { $0.url }.joined(separator: "$$$") + } + + return VodInfo.from(video: video, playFrom: playFrom, playUrl: playUrl) + } + + private func extractXMLDDNodes(from block: String) -> [(flag: String, url: String)] { + guard let regex = try? NSRegularExpression( + pattern: #"]*)>([\s\S]*?)"#, + options: [.caseInsensitive] + ) else { + return [] + } + + let nsRange = NSRange(block.startIndex..= 3 else { continue } + guard let attrRange = Range(match.range(at: 1), in: block), + let valueRange = Range(match.range(at: 2), in: block) else { + continue + } + + let attrs = String(block[attrRange]) + let rawUrl = decodeXMLText(String(block[valueRange])) + guard !rawUrl.isEmpty else { continue } + + let flag = firstMatch( + pattern: #"flag\s*=\s*["']([^"']+)["']"#, + in: attrs, + captureGroup: 1 + ) ?? "线路\(index + 1)" + result.append((flag: decodeXMLText(flag), url: rawUrl)) + } + + return result + } + + private func extractXMLTag(_ tag: String, in content: String) -> String { + let escapedTag = NSRegularExpression.escapedPattern(for: tag) + let pattern = "<\(escapedTag)>\\s*([\\s\\S]*?)\\s*" + let value = firstMatch(pattern: pattern, in: content, captureGroup: 1) ?? "" + return decodeXMLText(value) + } + + private func firstMatch(pattern: String, in content: String, captureGroup: Int = 0) -> String? { + guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { + return nil + } + let range = NSRange(content.startIndex.. captureGroup, + let subRange = Range(match.range(at: captureGroup), in: content) else { + return nil + } + return String(content[subRange]) + } + + private func decodeXMLText(_ raw: String) -> String { + var value = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if value.hasPrefix(""), value.count >= 12 { + value.removeFirst(9) + value.removeLast(3) + } + value = value.replacingOccurrences(of: "&", with: "&") + value = value.replacingOccurrences(of: "<", with: "<") + value = value.replacingOccurrences(of: ">", with: ">") + value = value.replacingOccurrences(of: """, with: "\"") + value = value.replacingOccurrences(of: "'", with: "'") + return value.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func buildURL(base: String, queryItems: [URLQueryItem]) throws -> String { + let trimmedBase = base.trimmingCharacters(in: .whitespacesAndNewlines) + guard var components = URLComponents(string: trimmedBase) else { + throw SourceError.invalidApiUrl(base) + } + + var mergedQueryItems = components.queryItems ?? [] + mergedQueryItems.append(contentsOf: queryItems) + components.queryItems = mergedQueryItems + + guard let url = components.url else { + throw SourceError.invalidApiUrl(base) + } + return url.absoluteString + } } enum SourceError: LocalizedError { diff --git a/tvbox/ViewModels/LiveViewModel.swift b/tvbox/ViewModels/LiveViewModel.swift index dce7e5e..5f8044b 100644 --- a/tvbox/ViewModels/LiveViewModel.swift +++ b/tvbox/ViewModels/LiveViewModel.swift @@ -1,5 +1,6 @@ import Foundation import SwiftUI +import Combine /// 直播 ViewModel @MainActor @@ -19,12 +20,16 @@ class LiveViewModel: ObservableObject { /// 是否显示频道列表(预留给 TV 遥控交互)。 @Published var showChannelList = false + /// 订阅配置更新,支持直播频道列表实时刷新。 + private var cancellables: Set = [] + + init() { + bindLiveChannelGroups() + } + /// 加载直播频道 func loadChannels() { - self.channelGroups = ApiConfig.shared.liveChannelGroupList - if let firstGroup = channelGroups.first, let firstChannel = firstGroup.channels.first { - currentChannel = firstChannel - } + applyChannelGroups(ApiConfig.shared.liveChannelGroupList) } /// 选择频道分组 @@ -90,6 +95,60 @@ class LiveViewModel: ObservableObject { // 当前版本先清空,避免展示过期节目单。 epgList = [] } + + private func bindLiveChannelGroups() { + ApiConfig.shared.$liveChannelGroupList + .sink { [weak self] groups in + self?.applyChannelGroups(groups) + } + .store(in: &cancellables) + } + + private func applyChannelGroups(_ groups: [LiveChannelGroup]) { + let previousChannelId = currentChannel?.id + channelGroups = groups + + guard !groups.isEmpty else { + selectedGroupIndex = 0 + selectedChannelIndex = 0 + currentChannel = nil + return + } + + if let previousChannelId, + let located = locateChannel(withId: previousChannelId, in: groups) { + selectedGroupIndex = located.groupIndex + selectedChannelIndex = located.channelIndex + currentChannel = groups[located.groupIndex].channels[located.channelIndex] + return + } + + let clampedGroupIndex = min(max(0, selectedGroupIndex), groups.count - 1) + selectedGroupIndex = clampedGroupIndex + + let channels = groups[clampedGroupIndex].channels + guard !channels.isEmpty else { + selectedChannelIndex = 0 + currentChannel = nil + return + } + + let clampedChannelIndex = min(max(0, selectedChannelIndex), channels.count - 1) + selectedChannelIndex = clampedChannelIndex + currentChannel = channels[clampedChannelIndex] + } + + private func locateChannel( + withId channelId: String, + in groups: [LiveChannelGroup] + ) -> (groupIndex: Int, channelIndex: Int)? { + for (groupIndex, group) in groups.enumerated() { + if let channelIndex = group.channels.firstIndex(where: { $0.id == channelId }) { + return (groupIndex, channelIndex) + } + } + return nil + } } // 安全数组下标访问 diff --git a/tvbox/Views/ContentView.swift b/tvbox/Views/ContentView.swift index 1e1f9e4..432ea67 100644 --- a/tvbox/Views/ContentView.swift +++ b/tvbox/Views/ContentView.swift @@ -289,6 +289,7 @@ struct ContentView: View { .clipShape(Capsule()) .shadow(color: .red.opacity(0.4), radius: 12, x: 0, y: 6) } + .buttonStyle(.plain) .disabled( settingsVM.isLoadingConfig || settingsVM.vodApiUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty diff --git a/tvbox/Views/Favorites/FavoritesView.swift b/tvbox/Views/Favorites/FavoritesView.swift index a641e95..a5ac637 100644 --- a/tvbox/Views/Favorites/FavoritesView.swift +++ b/tvbox/Views/Favorites/FavoritesView.swift @@ -38,7 +38,11 @@ struct FavoritesView: View { .contextMenu { Button(role: .destructive) { modelContext.delete(item) - try? modelContext.save() + do { + try modelContext.save() + } catch { + print("删除收藏失败: \(error)") + } } label: { Label("取消收藏", systemImage: "heart.slash") } diff --git a/tvbox/Views/History/HistoryView.swift b/tvbox/Views/History/HistoryView.swift index 5bc19ae..d0508d1 100644 --- a/tvbox/Views/History/HistoryView.swift +++ b/tvbox/Views/History/HistoryView.swift @@ -38,7 +38,11 @@ struct HistoryView: View { .contextMenu { Button(role: .destructive) { modelContext.delete(item) - try? modelContext.save() + do { + try modelContext.save() + } catch { + print("删除历史记录失败: \(error)") + } } label: { Label("删除记录", systemImage: "trash") }