import Foundation /// 核心配置管理器 - 对应 Android 版 ApiConfig.java /// 负责加载和解析远程 JSON 配置,管理视频源列表 @MainActor class ApiConfig: ObservableObject { static let shared = ApiConfig() private static let maxConfigResolveDepth = 6 private static let maxRedirectCandidates = 20 struct MultiRepoOption: Identifiable, Equatable { let name: String let url: String var id: String { url.lowercased() } } @Published var sourceBeanList: [SourceBean] = [] @Published var homeSourceBean: SourceBean? @Published var parseBeanList: [ParseBean] = [] @Published var liveChannelGroupList: [LiveChannelGroup] = [] @Published var dohList: [(name: String, url: String)] = [] @Published var isLoaded: Bool = false @Published var configUrl: String = "" @Published var liveConfigUrl: String = "" @Published var wallpaper: String = "" private let network = NetworkManager.shared private init() {} /// 加载远程配置 func loadConfig(from apiUrl: String) async throws { try await loadConfigs(vodApiUrl: apiUrl, liveApiUrl: apiUrl) } /// 分别加载点播配置和直播配置 func loadConfigs(vodApiUrl: String, liveApiUrl: String) async throws { let trimmedVod = vodApiUrl.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedLive = liveApiUrl.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedVod.isEmpty else { throw ConfigError.parseError("点播接口地址不能为空") } let resolvedLive = trimmedLive.isEmpty ? trimmedVod : trimmedLive self.configUrl = trimmedVod self.liveConfigUrl = resolvedLive if trimmedVod == resolvedLive { let configResult = try await fetchConfig(from: trimmedVod) parseConfig( configResult.config, apiUrl: configResult.loadedFrom, includeSources: true, includeLive: true ) } else { let vodConfig = try await fetchConfig(from: trimmedVod) parseConfig( vodConfig.config, apiUrl: vodConfig.loadedFrom, includeSources: true, includeLive: false ) let liveConfig = try await fetchConfig(from: resolvedLive) parseConfig( liveConfig.config, apiUrl: liveConfig.loadedFrom, includeSources: false, includeLive: true ) } self.isLoaded = true } private func fetchConfig(from apiUrl: String) async throws -> (config: AppConfigData, loadedFrom: String) { try await fetchConfig( from: apiUrl, visitedUrls: Set(), depth: 0 ) } private func fetchConfig( from apiUrl: String, visitedUrls: Set, depth: Int ) async throws -> (config: AppConfigData, loadedFrom: String) { guard depth <= Self.maxConfigResolveDepth else { throw ConfigError.parseError("配置跳转层级过深(超过 \(Self.maxConfigResolveDepth) 层)") } let normalizedUrl = Self.normalizeConfigUrl(apiUrl) guard !normalizedUrl.isEmpty else { throw ConfigError.parseError("配置地址为空") } let visitKey = normalizedUrl.lowercased() guard !visitedUrls.contains(visitKey) else { throw ConfigError.parseError("检测到循环引用的配置地址: \(normalizedUrl)") } var nextVisited = visitedUrls nextVisited.insert(visitKey) let jsonStr = try await network.getString(from: normalizedUrl) // 清理非标准 JSON(Android 端 Gson 默认支持注释,Swift 需要手动处理) let cleanedJson = Self.stripJsonComments(jsonStr) guard let data = cleanedJson.data(using: .utf8) else { throw ConfigError.parseError("无法解析配置数据") } let decoder = JSONDecoder() let decodedConfig = try? decoder.decode(AppConfigData.self, from: data) if let config = decodedConfig, config.hasUsableContent { return (config, normalizedUrl) } if let multiRepo = try? decoder.decode(MultiRepoConfigData.self, from: data) { let candidateUrls = Self.uniqueUrlsInOrder( multiRepo.candidateUrls.map(Self.normalizeConfigUrl) ) var lastError: Error? for candidateUrl in candidateUrls { do { return try await fetchConfig( from: candidateUrl, visitedUrls: nextVisited, depth: depth + 1 ) } catch { lastError = error } } if let lastError { throw ConfigError.parseError("多仓库配置中没有可用地址,最后错误: \(lastError.localizedDescription)") } throw ConfigError.parseError("多仓库配置中没有可用地址") } let redirectCandidates = Self.extractConfigRedirectCandidates(from: cleanedJson) .filter { $0.lowercased() != visitKey && !nextVisited.contains($0.lowercased()) } if !redirectCandidates.isEmpty { var lastError: Error? for candidate in redirectCandidates { do { return try await fetchConfig( from: candidate, visitedUrls: nextVisited, depth: depth + 1 ) } catch { lastError = error } } if let lastError { throw ConfigError.parseError("页面跳转配置解析失败,最后错误: \(lastError.localizedDescription)") } } if decodedConfig != nil { throw ConfigError.parseError("配置缺少可用站点(sites / lives / parses)") } throw ConfigError.parseError("配置格式不受支持") } private static func uniqueUrlsInOrder(_ urls: [String]) -> [String] { var seen: Set = [] var result: [String] = [] for url in urls { let trimmed = url.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { continue } let key = trimmed.lowercased() guard !seen.contains(key) else { continue } seen.insert(key) result.append(trimmed) } return result } /// 从网页/文本中提取可能的配置地址: /// - 明文 URL 文本 /// - data-clipboard-text(常见导航页“点击复制”) /// - JSON 片段中的 url 字段 private static func extractConfigRedirectCandidates(from content: String) -> [String] { let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) var rawCandidates: [String] = [] if (trimmed.hasPrefix("http://") || trimmed.hasPrefix("https://")) && !trimmed.contains("\n") { rawCandidates.append(trimmed) } rawCandidates.append(contentsOf: matchCaptureGroup( pattern: #"data-clipboard-text\s*=\s*["']([^"']+)["']"#, in: content )) rawCandidates.append(contentsOf: matchCaptureGroup( pattern: #""url"\s*:\s*"([^"]+)""#, in: content )) rawCandidates.append(contentsOf: matchCaptureGroup( pattern: #"(https?://[^\s"'<>\\]+)"#, in: content )) let normalized = rawCandidates .map(sanitizeExtractedUrl) .map(normalizeConfigUrl) .filter { !$0.isEmpty } .filter(isLikelyConfigPointerUrl) .filter { !isLikelyBinaryAssetUrl($0) } return Array(uniqueUrlsInOrder(normalized).prefix(maxRedirectCandidates)) } private static func matchCaptureGroup(pattern: String, in content: String) -> [String] { guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { return [] } let range = NSRange(content.startIndex..= 2, let subRange = Range(match.range(at: 1), in: content) else { return nil } return String(content[subRange]) } } private static func sanitizeExtractedUrl(_ value: String) -> String { var result = value.trimmingCharacters(in: .whitespacesAndNewlines) result = result.replacingOccurrences(of: "&", with: "&") result = result.replacingOccurrences(of: "\\/", with: "/") while let last = result.last, [".", ",", ";", ")", "]", "}", "\"", "'"].contains(last) { result.removeLast() } while let first = result.first, ["\"", "'", "(", "[", "{"].contains(first) { result.removeFirst() } return result.trimmingCharacters(in: .whitespacesAndNewlines) } private static func isLikelyBinaryAssetUrl(_ urlString: String) -> Bool { guard let components = URLComponents(string: urlString) else { return false } let path = components.path.lowercased() return path.hasSuffix(".png") || path.hasSuffix(".jpg") || path.hasSuffix(".jpeg") || path.hasSuffix(".webp") || path.hasSuffix(".gif") || path.hasSuffix(".svg") || path.hasSuffix(".ico") || path.hasSuffix(".css") || path.hasSuffix(".woff") || path.hasSuffix(".woff2") || path.hasSuffix(".ttf") } private static func isLikelyConfigPointerUrl(_ urlString: String) -> Bool { guard let components = URLComponents(string: urlString) else { return false } let host = (components.host ?? "").lowercased() let path = components.path.lowercased() let query = (components.percentEncodedQuery ?? "").lowercased() if host.contains("raw.githubusercontent.com") || host.contains("githubusercontent.com") { return true } if path.contains(".json") || path.hasSuffix("/tv") || path.hasSuffix("/tv/") || path.hasSuffix("/m") || path.hasSuffix("/m/") || path.contains("tvbox") || path.contains("box") || query.contains("json") || query.contains("config") || query.contains("url=") { return true } return false } /// 统一规范配置 URL: /// 1) 修正 https:/xxx 之类的单斜杠写法; /// 2) 将 github.com/.../blob/... 转为 raw.githubusercontent.com/...; /// 3) 兼容 gh-proxy + github/blob 的嵌套代理地址。 static func normalizeConfigUrl(_ rawUrl: String) -> String { let trimmed = rawUrl.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return trimmed } let fixedScheme = fixMalformedSchemeIfNeeded(trimmed) if let normalizedProxy = normalizeGhProxyWrappedUrl(fixedScheme) { return normalizedProxy } if let githubRaw = convertGitHubBlobUrlToRaw(fixedScheme) { return githubRaw } return fixedScheme } private static func fixMalformedSchemeIfNeeded(_ urlString: String) -> String { let fixedHttps = urlString.replacingOccurrences( of: #"^https:/(?!/)"#, with: "https://", options: .regularExpression ) return fixedHttps.replacingOccurrences( of: #"^http:/(?!/)"#, with: "http://", options: .regularExpression ) } private static func normalizeGhProxyWrappedUrl(_ urlString: String) -> String? { guard let components = URLComponents(string: urlString), let host = components.host?.lowercased(), host.contains("gh-proxy") else { return nil } let path = components.percentEncodedPath.trimmingCharacters(in: CharacterSet(charactersIn: "/")) guard !path.isEmpty else { return nil } let decodedPath = path.removingPercentEncoding ?? path let fixedEmbedded = fixMalformedSchemeIfNeeded(decodedPath) guard fixedEmbedded.hasPrefix("http://") || fixedEmbedded.hasPrefix("https://") else { return nil } let normalizedEmbedded = convertGitHubBlobUrlToRaw(fixedEmbedded) ?? fixedEmbedded let scheme = components.scheme ?? "https" let portSuffix = components.port.map { ":\($0)" } ?? "" var rebuilt = "\(scheme)://\(host)\(portSuffix)/\(normalizedEmbedded)" if let query = components.percentEncodedQuery, !query.isEmpty { rebuilt += "?\(query)" } if let fragment = components.percentEncodedFragment, !fragment.isEmpty { rebuilt += "#\(fragment)" } return rebuilt } private static func convertGitHubBlobUrlToRaw(_ urlString: String) -> String? { guard let components = URLComponents(string: urlString), let host = components.host?.lowercased(), host == "github.com" || host == "www.github.com" else { return nil } let parts = components.path.split(separator: "/", omittingEmptySubsequences: true) guard parts.count >= 5, parts[2] == "blob" else { return nil } let owner = String(parts[0]) let repo = String(parts[1]) let branch = String(parts[3]) let filePath = parts.dropFirst(4).joined(separator: "/") guard !filePath.isEmpty else { return nil } var rawUrl = "https://raw.githubusercontent.com/\(owner)/\(repo)/\(branch)/\(filePath)" if let query = components.percentEncodedQuery, !query.isEmpty { rawUrl += "?\(query)" } if let fragment = components.percentEncodedFragment, !fragment.isEmpty { rawUrl += "#\(fragment)" } return rawUrl } /// 去除 JSON 中的 // 行注释,兼容 TVBox 配置文件格式 /// Android 端 Gson 原生支持注释,Swift 的 JSONDecoder 不支持 static func stripJsonComments(_ json: String) -> String { let lines = json.components(separatedBy: "\n") var result: [String] = [] for line in lines { let trimmed = line.trimmingCharacters(in: .whitespaces) // 跳过纯注释行(以 // 开头) if trimmed.hasPrefix("//") { continue } // 处理行尾注释:只在引号外的 // 才是注释 let cleaned = removeInlineComment(from: line) result.append(cleaned) } var joined = result.joined(separator: "\n") // 修复尾部逗号问题:,] 或 ,} (注释行被删除后可能产生) // 使用正则替换 , 后面跟着空白和 ] 或 } 的情况 joined = joined.replacingOccurrences( of: ",\\s*([\\]\\}])", with: "$1", options: .regularExpression ) return joined } /// 移除行内注释(只处理不在字符串内的 //) private static func removeInlineComment(from line: String) -> String { var inString = false var escape = false let chars = Array(line) for i in 0.. [MultiRepoOption]? { let normalizedUrl = Self.normalizeConfigUrl(apiUrl) let jsonStr = try await network.getString(from: normalizedUrl) let cleanedJson = Self.stripJsonComments(jsonStr) guard let data = cleanedJson.data(using: .utf8) else { throw ConfigError.parseError("无法解析配置数据") } let decoder = JSONDecoder() if let config = try? decoder.decode(AppConfigData.self, from: data), config.hasUsableContent { return nil } guard let multiRepo = try? decoder.decode(MultiRepoConfigData.self, from: data) else { return nil } let normalizedCandidates = Self.uniqueUrlsInOrder( multiRepo.candidateUrls.map(Self.normalizeConfigUrl) ) var options: [MultiRepoOption] = [] for candidate in normalizedCandidates { let matchedEntry = multiRepo.urls?.first(where: { Self.normalizeConfigUrl($0.url ?? "") == candidate }) let displayName = matchedEntry?.name?.trimmingCharacters(in: .whitespacesAndNewlines) let fallbackName = URL(string: candidate)?.host ?? candidate let resolvedName: String if let displayName, !displayName.isEmpty { resolvedName = displayName } else { resolvedName = fallbackName } options.append( MultiRepoOption( name: resolvedName, url: candidate ) ) } return options } /// 解析配置数据 private func parseConfig( _ config: AppConfigData, apiUrl: String, includeSources: Bool, includeLive: Bool ) { if includeSources { // 解析站点列表 var sources: [SourceBean] = [] if let sites = config.sites { for site in sites { let bean = SourceBean( key: site.key ?? UUID().uuidString, name: site.name ?? "未命名", api: site.api ?? "", searchable: site.searchable?.value ?? 1, filterable: site.filterable?.value ?? 1, playerType: site.playerType?.value ?? 0, type: site.type?.value ?? 1, ext: site.ext?.stringValue ) sources.append(bean) } } self.sourceBeanList = sources // 设置默认主页源:优先选择 Swift 支持的源 if let saved = UserDefaults.standard.string(forKey: HawkConfig.HOME_API), let found = sources.first(where: { $0.key == saved }) { self.homeSourceBean = found } else { // 优先选择支持的源(type 0/1/4),跳过 type=3 (JAR) self.homeSourceBean = sources.first(where: { $0.isSupportedInSwift }) ?? sources.first } // 解析解析器列表 if let parses = config.parses { self.parseBeanList = parses.map { p in ParseBean(name: p.name ?? "", url: p.url ?? "", type: p.type?.value ?? 0) } } // 解析 DoH 列表 if let dohs = config.doh { self.dohList = dohs.compactMap { d in guard let name = d.name, let url = d.url else { return nil } return (name: name, url: url) } } // 壁纸 self.wallpaper = config.wallpaper ?? "" } if includeLive { if let lives = config.lives { parseLives(lives, apiUrl: apiUrl) } else { liveChannelGroupList = [] } } } /// 解析直播列表 private func parseLives(_ lives: [AppConfigData.LiveConfig], apiUrl: String) { Task { var mergedGroups: [String: LiveChannelGroup] = [:] 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)") } } // 如果有内嵌频道 if let channels = live.channels { let inlineGroups = parseInlineLiveChannels(channels) mergeLiveGroups(inlineGroups, into: &mergedGroups) } } self.liveChannelGroupList = sortedGroups(from: mergedGroups) } } /// 解析 m3u / txt 格式的直播内容 private func parseLiveContent(_ content: String) -> [LiveChannelGroup] { var groups: [String: LiveChannelGroup] = [:] var currentGroupName = "默认" let lines = content.components(separatedBy: .newlines) let firstNonEmptyLine = lines.first { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } let isM3U = firstNonEmptyLine?.uppercased().hasPrefix("#EXTM3U") == true // 检测是否为 M3U 格式 if isM3U { var currentName = "" var currentGroup = "默认" for line in lines { let trimmed = line.trimmingCharacters(in: .whitespaces) if trimmed.hasPrefix("#EXTINF:") { // 解析频道名和分组 if let nameRange = trimmed.range(of: ",", options: .backwards) { currentName = String(trimmed[nameRange.upperBound...]).trimmingCharacters(in: .whitespaces) } currentGroup = "默认" if let groupMatch = trimmed.range(of: "group-title=\"") { let afterGroup = trimmed[groupMatch.upperBound...] if let endQuote = afterGroup.firstIndex(of: "\"") { currentGroup = String(afterGroup[..= 2 { let name = parts[0].trimmingCharacters(in: .whitespaces) let url = parts[1...].joined(separator: ",").trimmingCharacters(in: .whitespaces) if !name.isEmpty && Self.isLiveStreamUrl(url) { appendChannel( named: name, urls: [url], logo: "", to: currentGroupName, groups: &groups ) } } } } return sortedGroups(from: groups) } private func parseInlineLiveChannels(_ channels: [AppConfigData.LiveConfig.LiveChannelConfig]) -> [LiveChannelGroup] { var groups: [String: LiveChannelGroup] = [:] for channel in channels { appendChannel( named: channel.name ?? "", urls: channel.urls ?? [], logo: channel.logo ?? "", to: channel.group ?? "其他", groups: &groups ) } return sortedGroups(from: groups) } private func mergeLiveGroups(_ incomingGroups: [LiveChannelGroup], into groups: inout [String: LiveChannelGroup]) { for group in incomingGroups { for channel in group.channels { appendChannel( named: channel.channelName, urls: channel.channelUrls, logo: channel.logo, to: group.groupName, groups: &groups ) } } } private func appendChannel( named channelName: String, urls: [String], logo: String, to groupName: String, groups: inout [String: LiveChannelGroup] ) { let normalizedName = Self.normalizeChannelName(channelName) guard !normalizedName.isEmpty else { return } let validUrls = Self.uniqueLiveUrls(urls) guard !validUrls.isEmpty else { return } let normalizedGroupName = Self.normalizeGroupName(groupName) if groups[normalizedGroupName] == nil { groups[normalizedGroupName] = LiveChannelGroup( groupName: normalizedGroupName, groupIndex: groups.count ) } guard var group = groups[normalizedGroupName] else { return } if let existingIndex = group.channels.firstIndex(where: { Self.normalizeChannelName($0.channelName) == normalizedName }) { var existing = group.channels[existingIndex] var existingUrls = Set(existing.channelUrls.map(Self.normalizeLiveUrl)) for url in validUrls { let normalizedUrl = Self.normalizeLiveUrl(url) if !existingUrls.contains(normalizedUrl) { existing.channelUrls.append(url) existingUrls.insert(normalizedUrl) } } let trimmedLogo = logo.trimmingCharacters(in: .whitespacesAndNewlines) if existing.logo.isEmpty && !trimmedLogo.isEmpty { existing.logo = trimmedLogo } group.channels[existingIndex] = existing } else { var item = LiveChannelItem(channelName: normalizedName, channelIndex: group.channels.count) item.channelUrls = validUrls item.logo = logo.trimmingCharacters(in: .whitespacesAndNewlines) group.channels.append(item) } groups[normalizedGroupName] = group } private func sortedGroups(from groups: [String: LiveChannelGroup]) -> [LiveChannelGroup] { groups.values .sorted { $0.groupIndex < $1.groupIndex } .map { group in var reindexedGroup = group reindexedGroup.channels = group.channels.enumerated().map { index, channel in var reindexedChannel = channel reindexedChannel.channelIndex = index return reindexedChannel } return reindexedGroup } } private func resolveLiveUrl(_ urlString: String, baseConfigUrl: String) -> String { let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return trimmed } if let url = URL(string: trimmed), url.scheme != nil { return Self.normalizeConfigUrl(trimmed) } guard let baseUrl = URL(string: baseConfigUrl), let resolved = URL(string: trimmed, relativeTo: baseUrl)?.absoluteURL else { return trimmed } return Self.normalizeConfigUrl(resolved.absoluteString) } private static func uniqueLiveUrls(_ urls: [String]) -> [String] { var result: [String] = [] var seen: Set = [] for url in urls { let normalized = normalizeLiveUrl(url) guard !normalized.isEmpty, isLiveStreamUrl(normalized), !seen.contains(normalized) else { continue } seen.insert(normalized) result.append(normalized) } return result } private static func normalizeGroupName(_ groupName: String) -> String { let trimmed = groupName.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? "默认" : trimmed } private static func normalizeChannelName(_ channelName: String) -> String { channelName.trimmingCharacters(in: .whitespacesAndNewlines) } private static func normalizeLiveUrl(_ url: String) -> String { url.trimmingCharacters(in: .whitespacesAndNewlines) } private static func isLiveStreamUrl(_ url: String) -> Bool { let lowercased = url.lowercased() return lowercased.hasPrefix("http://") || lowercased.hasPrefix("https://") || lowercased.hasPrefix("rtmp://") || lowercased.hasPrefix("rtsp://") } /// 获取指定 key 的源 func getSource(key: String) -> SourceBean? { sourceBeanList.first(where: { $0.key == key }) } /// 获取可搜索的源列表 func getSearchableSources() -> [SourceBean] { sourceBeanList.filter { $0.isSearchable } } /// 设置主页源 func setHomeSource(_ source: SourceBean) { self.homeSourceBean = source UserDefaults.standard.set(source.key, forKey: HawkConfig.HOME_API) } } enum ConfigError: LocalizedError { case parseError(String) case networkError(String) var errorDescription: String? { switch self { case .parseError(let msg): return "配置解析错误: \(msg)" case .networkError(let msg): return "网络错误: \(msg)" } } }