Files
tvbox-Swift/tvbox/Services/ApiConfig.swift
T
2026-03-01 14:04:44 +08:00

428 lines
16 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()
@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)
// 清理非标准 JSONAndroid 端 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..<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
}
/// 解析配置数据
private func parseConfig(_ config: AppConfigData, apiUrl: String) {
// 解析站点列表
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 let lives = config.lives {
parseLives(lives, apiUrl: apiUrl)
}
self.isLoaded = true
}
/// 解析直播列表
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[..<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 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<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)"
}
}
}