Files
2026-03-03 12:28:02 +08:00

948 lines
36 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
/// 核心配置管理器 - 对应 Android 版 ApiConfig.java
/// 负责加载和解析远程 JSON 配置,管理视频源列表
@MainActor
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
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 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() {}
/// 加载远程配置
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 loadToken = UUID()
activeLoadToken = loadToken
liveParseTask?.cancel()
liveParseTask = nil
let resolvedLive = trimmedLive.isEmpty ? trimmedVod : trimmedLive
self.configUrl = trimmedVod
self.liveConfigUrl = resolvedLive
if trimmedVod == resolvedLive {
let configResult = try await fetchConfig(from: trimmedVod)
guard activeLoadToken == loadToken else { return }
await parseConfig(
configResult.config,
apiUrl: configResult.loadedFrom,
includeSources: true,
includeLive: false,
loadToken: loadToken
)
scheduleLiveParsing(
config: configResult.config,
apiUrl: configResult.loadedFrom,
loadToken: loadToken
)
} else {
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,
loadToken: loadToken
)
scheduleLiveParsing(
config: liveConfig.config,
apiUrl: liveConfig.loadedFrom,
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(
from: apiUrl,
visitedUrls: Set<String>(),
depth: 0
)
}
private func fetchConfig(
from apiUrl: String,
visitedUrls: Set<String>,
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 fetchConfigText(from: normalizedUrl)
// 清理非标准 JSONAndroid 端 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 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] = []
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..<content.endIndex, in: content)
let matches = regex.matches(in: content, options: [], range: range)
return matches.compactMap { match in
guard match.numberOfRanges >= 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: "&amp;", 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..<chars.count {
let c = chars[i]
if escape {
escape = false
continue
}
if c == "\\" && inString {
escape = true
continue
}
if c == "\"" {
inString.toggle()
continue
}
if !inString && c == "/" && i + 1 < chars.count && chars[i + 1] == "/" {
// 找到行内注释,截断
return String(chars[0..<i]).trimmingCharacters(in: .whitespaces).hasSuffix(",")
? String(String(chars[0..<i]).trimmingCharacters(in: .whitespaces).dropLast())
: String(chars[0..<i])
}
}
return line
}
/// 仅探测“多仓库入口”并返回可选项。
/// 返回 nil 表示不是多仓库入口;返回数组表示是多仓库入口(数组可能为空)。
func fetchMultiRepoOptions(from apiUrl: String) async throws -> [MultiRepoOption]? {
let normalizedUrl = Self.normalizeConfigUrl(apiUrl)
let jsonStr = try await fetchConfigText(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,
loadToken: UUID
) async {
guard activeLoadToken == loadToken else { return }
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,
quickSearch: site.quickSearch?.value ?? 0,
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)
}
} else {
self.parseBeanList = []
}
// 解析 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)
}
} else {
self.dohList = []
}
// 壁纸
self.wallpaper = config.wallpaper ?? ""
}
if includeLive {
if let lives = config.lives {
let parsedGroups = await parseLives(lives, apiUrl: apiUrl, loadToken: loadToken)
guard activeLoadToken == loadToken else { return }
liveChannelGroupList = parsedGroups
} else {
liveChannelGroupList = []
}
}
}
/// 解析直播列表
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 [] }
// 如果有 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)
}
}
}
var results: [(Int, String)] = []
for await (order, content) in group {
if let content {
results.append((order, content))
}
}
return results
}
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 格式的直播内容
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[..<endQuote])
}
}
} else if Self.isLiveStreamUrl(trimmed) {
if !currentName.isEmpty {
appendChannel(
named: currentName,
urls: [trimmed],
logo: "",
to: currentGroup,
groups: &groups
)
currentName = ""
}
}
}
} else {
// TXT 格式: 分组名,#genre# 或 频道名,url
for line in lines {
let trimmed = line.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { continue }
if trimmed.hasSuffix(",#genre#") || trimmed.hasSuffix("#genre#") {
currentGroupName = trimmed
.replacingOccurrences(of: ",#genre#", with: "")
.replacingOccurrences(of: "#genre#", with: "")
.trimmingCharacters(in: .whitespaces)
continue
}
let parts = trimmed.components(separatedBy: ",")
if parts.count >= 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<String> = []
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)"
}
}
}