import Foundation /// 核心配置管理器 - 对应 Android 版 ApiConfig.java /// 负责加载和解析远程 JSON 配置,管理视频源列表 @MainActor class ApiConfig: ObservableObject { static let shared = ApiConfig() @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 wallpaper: String = "" private let network = NetworkManager.shared private init() {} /// 加载远程配置 func loadConfig(from apiUrl: String) async throws { self.configUrl = apiUrl let jsonStr = try await network.getString(from: apiUrl) // 清理非标准 JSON(Android 端 Gson 默认支持注释,Swift 需要手动处理) let cleanedJson = Self.stripJsonComments(jsonStr) guard let data = cleanedJson.data(using: .utf8) else { throw ConfigError.parseError("无法解析配置数据") } let config = try JSONDecoder().decode(AppConfigData.self, from: data) parseConfig(config, apiUrl: apiUrl) } /// 去除 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.. [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 trimmed } guard let baseUrl = URL(string: baseConfigUrl), let resolved = URL(string: trimmed, relativeTo: baseUrl)?.absoluteURL else { return trimmed } return 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)" } } }