This commit is contained in:
JinJiangHuang
2026-03-03 12:28:02 +08:00
parent aebf17dfb5
commit 76ed32ccf3
7 changed files with 568 additions and 149 deletions
+128 -52
View File
@@ -3,6 +3,13 @@ import SwiftData
/// SwiftData - Android Room /// 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 { struct VodPlaybackState: Codable {
/// 线 /// 线
@@ -16,6 +23,8 @@ struct VodPlaybackState: Codable {
/// ///
@Model @Model
final class VodCollect { final class VodCollect {
/// sourceKey + vodId
var bizKey: String = ""
/// ID sourceKey /// ID sourceKey
var vodId: String = "" var vodId: String = ""
/// ///
@@ -28,6 +37,7 @@ final class VodCollect {
var updateTime: Date = Date() var updateTime: Date = Date()
init(vodId: String, vodName: String, vodPic: String, sourceKey: String) { init(vodId: String, vodName: String, vodPic: String, sourceKey: String) {
self.bizKey = makeVodBusinessKey(vodId: vodId, sourceKey: sourceKey)
self.vodId = vodId self.vodId = vodId
self.vodName = vodName self.vodName = vodName
self.vodPic = vodPic self.vodPic = vodPic
@@ -39,6 +49,8 @@ final class VodCollect {
/// ///
@Model @Model
final class VodRecord { final class VodRecord {
/// sourceKey + vodId
var bizKey: String = ""
/// ID /// ID
var vodId: String = "" var vodId: String = ""
/// ///
@@ -55,6 +67,7 @@ final class VodRecord {
var updateTime: Date = Date() var updateTime: Date = Date()
init(vodId: String, vodName: String, vodPic: String, sourceKey: String, playNote: String = "") { init(vodId: String, vodName: String, vodPic: String, sourceKey: String, playNote: String = "") {
self.bizKey = makeVodBusinessKey(vodId: vodId, sourceKey: sourceKey)
self.vodId = vodId self.vodId = vodId
self.vodName = vodName self.vodName = vodName
self.vodPic = vodPic self.vodPic = vodPic
@@ -89,50 +102,64 @@ actor CacheStore {
@MainActor @MainActor
func addCollect(_ video: Movie.Video, context: ModelContext) { func addCollect(_ video: Movie.Video, context: ModelContext) {
//
let vodId = video.id let vodId = video.id
let sourceKey = video.sourceKey let sourceKey = video.sourceKey
let predicate = #Predicate<VodCollect> { item in let bizKey = makeVodBusinessKey(vodId: vodId, sourceKey: sourceKey)
item.vodId == vodId && item.sourceKey == sourceKey
}
let descriptor = FetchDescriptor<VodCollect>(predicate: predicate)
if let existing = try? context.fetch(descriptor), !existing.isEmpty { do {
return // 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 @MainActor
func removeCollect(vodId: String, sourceKey: String, context: ModelContext) { func removeCollect(vodId: String, sourceKey: String, context: ModelContext) {
// (vodId, sourceKey) do {
let predicate = #Predicate<VodCollect> { item in let items = try fetchCollects(vodId: vodId, sourceKey: sourceKey, context: context)
item.vodId == vodId && item.sourceKey == sourceKey guard !items.isEmpty else { return }
}
let descriptor = FetchDescriptor<VodCollect>(predicate: predicate)
if let items = try? context.fetch(descriptor) {
for item in items { for item in items {
context.delete(item) context.delete(item)
} }
try? context.save() try context.save()
} catch {
print("删除收藏失败: \(error)")
} }
} }
@MainActor @MainActor
func isCollected(vodId: String, sourceKey: String, context: ModelContext) -> Bool { func isCollected(vodId: String, sourceKey: String, context: ModelContext) -> Bool {
let bizKey = makeVodBusinessKey(vodId: vodId, sourceKey: sourceKey)
let predicate = #Predicate<VodCollect> { item in let predicate = #Predicate<VodCollect> { item in
item.vodId == vodId && item.sourceKey == sourceKey item.bizKey == bizKey || (item.bizKey == "" && item.vodId == vodId && item.sourceKey == sourceKey)
} }
let descriptor = FetchDescriptor<VodCollect>(predicate: predicate) let descriptor = FetchDescriptor<VodCollect>(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 @MainActor
@@ -144,39 +171,55 @@ actor CacheStore {
) { ) {
let vodId = video.id let vodId = video.id
let sourceKey = video.sourceKey let sourceKey = video.sourceKey
let record = fetchRecord(vodId: vodId, sourceKey: sourceKey, context: context)
let encodedState = Self.encodePlaybackState(playbackState) let encodedState = Self.encodePlaybackState(playbackState)
let bizKey = makeVodBusinessKey(vodId: vodId, sourceKey: sourceKey)
// do {
if let record { let matched = try fetchRecords(vodId: vodId, sourceKey: sourceKey, context: context)
record.playNote = playNote
if let encodedState { //
record.dataJson = encodedState 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 { try context.save()
let record = VodRecord( } catch {
vodId: video.id, print("写入播放记录失败: \(error)")
vodName: video.name,
vodPic: video.pic,
sourceKey: video.sourceKey,
playNote: playNote
)
if let encodedState {
record.dataJson = encodedState
}
context.insert(record)
} }
try? context.save()
} }
/// JSON `nil` /// JSON `nil`
@MainActor @MainActor
func getPlaybackState(vodId: String, sourceKey: String, context: ModelContext) -> VodPlaybackState? { 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 nil
} }
return Self.decodePlaybackState(record.dataJson)
} }
@MainActor @MainActor
@@ -190,14 +233,47 @@ actor CacheStore {
} }
@MainActor @MainActor
private func fetchRecord(vodId: String, sourceKey: String, context: ModelContext) -> VodRecord? { private func fetchRecords(vodId: String, sourceKey: String, context: ModelContext) throws -> [VodRecord] {
// (vodId, sourceKey) let bizKey = makeVodBusinessKey(vodId: vodId, sourceKey: sourceKey)
let predicate = #Predicate<VodRecord> { item in let predicate = #Predicate<VodRecord> { item in
item.vodId == vodId && item.sourceKey == sourceKey item.bizKey == bizKey || (item.bizKey == "" && item.vodId == vodId && item.sourceKey == sourceKey)
} }
let descriptor = FetchDescriptor<VodRecord>(predicate: predicate) let descriptor = FetchDescriptor<VodRecord>(predicate: predicate)
guard let records = try? context.fetch(descriptor) else { return nil } let records = try context.fetch(descriptor)
return records.first
// 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<VodCollect> { item in
item.bizKey == bizKey || (item.bizKey == "" && item.vodId == vodId && item.sourceKey == sourceKey)
}
let descriptor = FetchDescriptor<VodCollect>(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? { private nonisolated static func encodePlaybackState(_ state: VodPlaybackState?) -> String? {
+140 -34
View File
@@ -7,6 +7,8 @@ class ApiConfig: ObservableObject {
static let shared = ApiConfig() static let shared = ApiConfig()
private static let maxConfigResolveDepth = 6 private static let maxConfigResolveDepth = 6
private static let maxRedirectCandidates = 20 private static let maxRedirectCandidates = 20
private static let rawConfigCacheTTL: TimeInterval = 20
private static let maxRawConfigCacheEntries = 24
struct MultiRepoOption: Identifiable, Equatable { struct MultiRepoOption: Identifiable, Equatable {
let name: String let name: String
@@ -26,6 +28,13 @@ class ApiConfig: ObservableObject {
@Published var wallpaper: String = "" @Published var wallpaper: String = ""
private let network = NetworkManager.shared 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() {} private init() {}
@@ -41,6 +50,10 @@ class ApiConfig: ObservableObject {
guard !trimmedVod.isEmpty else { guard !trimmedVod.isEmpty else {
throw ConfigError.parseError("点播接口地址不能为空") throw ConfigError.parseError("点播接口地址不能为空")
} }
let loadToken = UUID()
activeLoadToken = loadToken
liveParseTask?.cancel()
liveParseTask = nil
let resolvedLive = trimmedLive.isEmpty ? trimmedVod : trimmedLive let resolvedLive = trimmedLive.isEmpty ? trimmedVod : trimmedLive
self.configUrl = trimmedVod self.configUrl = trimmedVod
@@ -48,32 +61,55 @@ class ApiConfig: ObservableObject {
if trimmedVod == resolvedLive { if trimmedVod == resolvedLive {
let configResult = try await fetchConfig(from: trimmedVod) let configResult = try await fetchConfig(from: trimmedVod)
parseConfig( guard activeLoadToken == loadToken else { return }
await parseConfig(
configResult.config, configResult.config,
apiUrl: configResult.loadedFrom, apiUrl: configResult.loadedFrom,
includeSources: true, includeSources: true,
includeLive: true includeLive: false,
loadToken: loadToken
)
scheduleLiveParsing(
config: configResult.config,
apiUrl: configResult.loadedFrom,
loadToken: loadToken
) )
} else { } else {
let vodConfig = try await fetchConfig(from: trimmedVod) async let vodConfigTask = fetchConfig(from: trimmedVod)
parseConfig( async let liveConfigTask = fetchConfig(from: resolvedLive)
let (vodConfig, liveConfig) = try await (vodConfigTask, liveConfigTask)
guard activeLoadToken == loadToken else { return }
await parseConfig(
vodConfig.config, vodConfig.config,
apiUrl: vodConfig.loadedFrom, apiUrl: vodConfig.loadedFrom,
includeSources: true, includeSources: true,
includeLive: false includeLive: false,
loadToken: loadToken
) )
scheduleLiveParsing(
let liveConfig = try await fetchConfig(from: resolvedLive) config: liveConfig.config,
parseConfig(
liveConfig.config,
apiUrl: liveConfig.loadedFrom, apiUrl: liveConfig.loadedFrom,
includeSources: false, loadToken: loadToken
includeLive: true
) )
} }
guard activeLoadToken == loadToken else { return }
self.isLoaded = true 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) { private func fetchConfig(from apiUrl: String) async throws -> (config: AppConfigData, loadedFrom: String) {
try await fetchConfig( try await fetchConfig(
@@ -105,7 +141,7 @@ class ApiConfig: ObservableObject {
var nextVisited = visitedUrls var nextVisited = visitedUrls
nextVisited.insert(visitKey) nextVisited.insert(visitKey)
let jsonStr = try await network.getString(from: normalizedUrl) let jsonStr = try await fetchConfigText(from: normalizedUrl)
// JSONAndroid Gson Swift // JSONAndroid Gson Swift
let cleanedJson = Self.stripJsonComments(jsonStr) let cleanedJson = Self.stripJsonComments(jsonStr)
@@ -173,6 +209,34 @@ class ApiConfig: ObservableObject {
throw ConfigError.parseError("配置格式不受支持") 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] { private static func uniqueUrlsInOrder(_ urls: [String]) -> [String] {
var seen: Set<String> = [] var seen: Set<String> = []
var result: [String] = [] var result: [String] = []
@@ -457,7 +521,7 @@ class ApiConfig: ObservableObject {
/// nil /// nil
func fetchMultiRepoOptions(from apiUrl: String) async throws -> [MultiRepoOption]? { func fetchMultiRepoOptions(from apiUrl: String) async throws -> [MultiRepoOption]? {
let normalizedUrl = Self.normalizeConfigUrl(apiUrl) 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) let cleanedJson = Self.stripJsonComments(jsonStr)
guard let data = cleanedJson.data(using: .utf8) else { guard let data = cleanedJson.data(using: .utf8) else {
@@ -506,8 +570,11 @@ class ApiConfig: ObservableObject {
_ config: AppConfigData, _ config: AppConfigData,
apiUrl: String, apiUrl: String,
includeSources: Bool, includeSources: Bool,
includeLive: Bool includeLive: Bool,
) { loadToken: UUID
) async {
guard activeLoadToken == loadToken else { return }
if includeSources { if includeSources {
// //
var sources: [SourceBean] = [] var sources: [SourceBean] = []
@@ -543,6 +610,8 @@ class ApiConfig: ObservableObject {
self.parseBeanList = parses.map { p in self.parseBeanList = parses.map { p in
ParseBean(name: p.name ?? "", url: p.url ?? "", type: p.type?.value ?? 0) ParseBean(name: p.name ?? "", url: p.url ?? "", type: p.type?.value ?? 0)
} }
} else {
self.parseBeanList = []
} }
// DoH // DoH
@@ -551,6 +620,8 @@ class ApiConfig: ObservableObject {
guard let name = d.name, let url = d.url else { return nil } guard let name = d.name, let url = d.url else { return nil }
return (name: name, url: url) return (name: name, url: url)
} }
} else {
self.dohList = []
} }
// //
@@ -559,7 +630,9 @@ class ApiConfig: ObservableObject {
if includeLive { if includeLive {
if let lives = config.lives { 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 { } else {
liveChannelGroupList = [] liveChannelGroupList = []
} }
@@ -567,32 +640,65 @@ class ApiConfig: ObservableObject {
} }
/// ///
private func parseLives(_ lives: [AppConfigData.LiveConfig], apiUrl: String) { private func parseLives(
Task { _ lives: [AppConfigData.LiveConfig],
var mergedGroups: [String: LiveChannelGroup] = [:] 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
// url if let liveUrl = live.url, !liveUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
if let liveUrl = live.url, !liveUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { let resolvedUrl = resolveLiveUrl(liveUrl, baseConfigUrl: apiUrl)
let resolvedUrl = resolveLiveUrl(liveUrl, baseConfigUrl: apiUrl) remoteLiveTargets.append((order: index, url: resolvedUrl))
do { }
let content = try await network.getString(from: resolvedUrl)
let groups = parseLiveContent(content) //
mergeLiveGroups(groups, into: &mergedGroups) if let channels = live.channels {
} catch { let inlineGroups = parseInlineLiveChannels(channels)
print("加载直播源失败: \(resolvedUrl), error: \(error)") 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)] = []
if let channels = live.channels { for await (order, content) in group {
let inlineGroups = parseInlineLiveChannels(channels) if let content {
mergeLiveGroups(inlineGroups, into: &mergedGroups) 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 /// m3u / txt
+226 -57
View File
@@ -34,19 +34,24 @@ class SourceService {
jsonStr = try await network.getString(from: api) jsonStr = try await network.getString(from: api)
} else if sourceBean.type == 4 { } else if sourceBean.type == 4 {
// Type 4: extend filter // Type 4: extend filter
var url = api.contains("?") ? "\(api)&filter=true" : "\(api)?filter=true" var queryItems: [URLQueryItem] = [
URLQueryItem(name: "filter", value: "true")
]
// extend // extend
if let ext = sourceBean.ext, !ext.isEmpty { if let ext = sourceBean.ext, !ext.isEmpty {
let extend = await resolveExtend(ext) let extend = await resolveExtend(ext)
if !extend.isEmpty { if !extend.isEmpty {
let encoded = extend.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? extend queryItems.append(URLQueryItem(name: "extend", value: extend))
url += "&extend=\(encoded)"
} }
} }
let url = try buildURL(base: api, queryItems: queryItems)
jsonStr = try await network.getString(from: url) jsonStr = try await network.getString(from: url)
} else { } else {
// JSON (type=1) // 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) jsonStr = try await network.getString(from: url)
} }
@@ -62,12 +67,24 @@ class SourceService {
if sourceBean.type == 4 { if sourceBean.type == 4 {
// type=4 ac=detail getList // type=4 ac=detail getList
let ext = Data("{}".utf8).base64EncodedString() let ext = Data("{}".utf8).base64EncodedString()
listUrl = api.contains("?") listUrl = try buildURL(
? "\(api)&ac=detail&filter=true&pg=1&ext=\(ext)" base: api,
: "\(api)?ac=detail&filter=true&pg=1&ext=\(ext)" queryItems: [
URLQueryItem(name: "ac", value: "detail"),
URLQueryItem(name: "filter", value: "true"),
URLQueryItem(name: "pg", value: "1"),
URLQueryItem(name: "ext", value: ext)
]
)
} else { } else {
// type=1 ac=videolist // 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) { if let listStr = try? await network.getString(from: listUrl) {
let fallback = (try? parseVideoList(listStr, sourceKey: sourceBean.key, type: sourceBean.type)) ?? [] 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.isSupportedInSwift else { throw SourceError.unsupportedType(sourceBean.typeDescription) }
guard sourceBean.isHttpApi else { throw SourceError.invalidApiUrl(api) } guard sourceBean.isHttpApi else { throw SourceError.invalidApiUrl(api) }
var url: String let url: String
if sourceBean.type == 0 { if sourceBean.type == 0 {
// XML // 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 { } else if sourceBean.type == 4 {
// Type 4: // Type 4:
url = api.contains("?") var queryItems: [URLQueryItem] = [
? "\(api)&ac=detail&filter=true&t=\(sortData.id)&pg=\(page)" URLQueryItem(name: "ac", value: "detail"),
: "\(api)?ac=detail&filter=true&t=\(sortData.id)&pg=\(page)" URLQueryItem(name: "filter", value: "true"),
URLQueryItem(name: "t", value: sortData.id),
URLQueryItem(name: "pg", value: String(page))
]
// base64 // base64
if let filters = filters, !filters.isEmpty { if let filters = filters, !filters.isEmpty {
if let filterData = try? JSONSerialization.data(withJSONObject: filters), if let filterData = try? JSONSerialization.data(withJSONObject: filters),
let filterStr = String(data: filterData, encoding: .utf8) { let filterStr = String(data: filterData, encoding: .utf8) {
let ext = Data(filterStr.utf8).base64EncodedString() let ext = Data(filterStr.utf8).base64EncodedString()
url += "&ext=\(ext)" queryItems.append(URLQueryItem(name: "ext", value: ext))
} }
} else { } else {
let ext = Data("{}".utf8).base64EncodedString() let ext = Data("{}".utf8).base64EncodedString()
url += "&ext=\(ext)" queryItems.append(URLQueryItem(name: "ext", value: ext))
} }
// extend // extend
if let ext = sourceBean.ext, !ext.isEmpty { if let ext = sourceBean.ext, !ext.isEmpty {
let extend = await resolveExtend(ext) let extend = await resolveExtend(ext)
if !extend.isEmpty { if !extend.isEmpty {
let encoded = extend.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? extend queryItems.append(URLQueryItem(name: "extend", value: extend))
url += "&extend=\(encoded)"
} }
} }
url = try buildURL(base: api, queryItems: queryItems)
} else { } else {
// JSON (type=1) // JSON (type=1)
url = api.contains("?") var queryItems: [URLQueryItem] = [
? "\(api)&ac=videolist&t=\(sortData.id)&pg=\(page)" URLQueryItem(name: "ac", value: "videolist"),
: "\(api)?ac=videolist&t=\(sortData.id)&pg=\(page)" URLQueryItem(name: "t", value: sortData.id),
URLQueryItem(name: "pg", value: String(page))
]
// //
if let filters = filters { if let filters = filters {
for (key, value) in 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) 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.isSupportedInSwift else { throw SourceError.unsupportedType(sourceBean.typeDescription) }
guard sourceBean.isHttpApi else { throw SourceError.invalidApiUrl(api) } guard sourceBean.isHttpApi else { throw SourceError.invalidApiUrl(api) }
var url: String let url: String
if sourceBean.type == 0 { 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 { } else if sourceBean.type == 4 {
// Type 4: // Type 4:
url = api.contains("?") var queryItems: [URLQueryItem] = [
? "\(api)&ac=detail&ids=\(vodId)" URLQueryItem(name: "ac", value: "detail"),
: "\(api)?ac=detail&ids=\(vodId)" URLQueryItem(name: "ids", value: vodId)
]
// extend // extend
if let ext = sourceBean.ext, !ext.isEmpty { if let ext = sourceBean.ext, !ext.isEmpty {
let extend = await resolveExtend(ext) let extend = await resolveExtend(ext)
if !extend.isEmpty { if !extend.isEmpty {
let encoded = extend.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? extend queryItems.append(URLQueryItem(name: "extend", value: extend))
url += "&extend=\(encoded)"
} }
} }
url = try buildURL(base: api, queryItems: queryItems)
} else { } else {
// JSON (type=1) // JSON (type=1)
url = api.contains("?") url = try buildURL(
? "\(api)&ac=detail&ids=\(vodId)" base: api,
: "\(api)?ac=detail&ids=\(vodId)" queryItems: [
URLQueryItem(name: "ac", value: "detail"),
URLQueryItem(name: "ids", value: vodId)
]
)
} }
let jsonStr = try await network.getString(from: url) 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? { 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 { guard let data = jsonStr.data(using: .utf8) else {
throw SourceError.parseError("无法解析数据") throw SourceError.parseError("无法解析数据")
} }
if type == 1 || type == 4 { if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let list = json["list"] as? [[String: Any]],
let list = json["list"] as? [[String: Any]], let first = list.first {
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() let playFrom = first["vod_play_from"] as? String ?? ""
if let itemData = try? JSONSerialization.data(withJSONObject: first), let playUrl = first["vod_play_url"] as? String ?? ""
var video = try? decoder.decode(Movie.Video.self, from: itemData) {
video.sourceKey = sourceKey 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.isSupportedInSwift else { throw SourceError.unsupportedType(sourceBean.typeDescription) }
guard sourceBean.isHttpApi else { throw SourceError.invalidApiUrl(api) } guard sourceBean.isHttpApi else { throw SourceError.invalidApiUrl(api) }
let encodedKeyword = keyword.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? keyword let url: String
var url: String
if sourceBean.type == 0 { if sourceBean.type == 0 {
url = "\(api)?wd=\(encodedKeyword)" url = try buildURL(
base: api,
queryItems: [URLQueryItem(name: "wd", value: keyword)]
)
} else if sourceBean.type == 4 { } else if sourceBean.type == 4 {
// Type 4: // Type 4:
let quickValue = sourceBean.isQuickSearchEnabled ? "true" : "false" let quickValue = sourceBean.isQuickSearchEnabled ? "true" : "false"
url = api.contains("?") var queryItems: [URLQueryItem] = [
? "\(api)&wd=\(encodedKeyword)&ac=detail&quick=\(quickValue)" URLQueryItem(name: "wd", value: keyword),
: "\(api)?wd=\(encodedKeyword)&ac=detail&quick=\(quickValue)" URLQueryItem(name: "ac", value: "detail"),
URLQueryItem(name: "quick", value: quickValue)
]
// extend // extend
if let ext = sourceBean.ext, !ext.isEmpty { if let ext = sourceBean.ext, !ext.isEmpty {
let extend = await resolveExtend(ext) let extend = await resolveExtend(ext)
if !extend.isEmpty { if !extend.isEmpty {
let encoded = extend.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? extend queryItems.append(URLQueryItem(name: "extend", value: extend))
url += "&extend=\(encoded)"
} }
} }
url = try buildURL(base: api, queryItems: queryItems)
} else { } else {
// JSON (type=1) // JSON (type=1)
url = api.contains("?") url = try buildURL(
? "\(api)&wd=\(encodedKeyword)" base: api,
: "\(api)?wd=\(encodedKeyword)" queryItems: [URLQueryItem(name: "wd", value: keyword)]
)
} }
let jsonStr = try await network.getString(from: url) let jsonStr = try await network.getString(from: url)
@@ -437,6 +484,128 @@ class SourceService {
return extend return extend
} }
} }
private func parseXMLDetail(_ xml: String, sourceKey: String) -> VodInfo? {
guard let videoBlock = firstMatch(
pattern: #"<video[\s\S]*?</video>"#,
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: #"<dd([^>]*)>([\s\S]*?)</dd>"#,
options: [.caseInsensitive]
) else {
return []
}
let nsRange = NSRange(block.startIndex..<block.endIndex, in: block)
let matches = regex.matches(in: block, range: nsRange)
var result: [(flag: String, url: String)] = []
for (index, match) in matches.enumerated() {
guard match.numberOfRanges >= 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*</\(escapedTag)>"
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..<content.endIndex, in: content)
guard let match = regex.firstMatch(in: content, options: [], range: range),
match.numberOfRanges > 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("<![CDATA["), value.hasSuffix("]]>"), value.count >= 12 {
value.removeFirst(9)
value.removeLast(3)
}
value = value.replacingOccurrences(of: "&amp;", with: "&")
value = value.replacingOccurrences(of: "&lt;", with: "<")
value = value.replacingOccurrences(of: "&gt;", with: ">")
value = value.replacingOccurrences(of: "&quot;", with: "\"")
value = value.replacingOccurrences(of: "&#39;", 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 { enum SourceError: LocalizedError {
+63 -4
View File
@@ -1,5 +1,6 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
import Combine
/// ViewModel /// ViewModel
@MainActor @MainActor
@@ -19,12 +20,16 @@ class LiveViewModel: ObservableObject {
/// TV /// TV
@Published var showChannelList = false @Published var showChannelList = false
///
private var cancellables: Set<AnyCancellable> = []
init() {
bindLiveChannelGroups()
}
/// ///
func loadChannels() { func loadChannels() {
self.channelGroups = ApiConfig.shared.liveChannelGroupList applyChannelGroups(ApiConfig.shared.liveChannelGroupList)
if let firstGroup = channelGroups.first, let firstChannel = firstGroup.channels.first {
currentChannel = firstChannel
}
} }
/// ///
@@ -90,6 +95,60 @@ class LiveViewModel: ObservableObject {
// //
epgList = [] 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
}
} }
// 访 // 访
+1
View File
@@ -289,6 +289,7 @@ struct ContentView: View {
.clipShape(Capsule()) .clipShape(Capsule())
.shadow(color: .red.opacity(0.4), radius: 12, x: 0, y: 6) .shadow(color: .red.opacity(0.4), radius: 12, x: 0, y: 6)
} }
.buttonStyle(.plain)
.disabled( .disabled(
settingsVM.isLoadingConfig settingsVM.isLoadingConfig
|| settingsVM.vodApiUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || settingsVM.vodApiUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+5 -1
View File
@@ -38,7 +38,11 @@ struct FavoritesView: View {
.contextMenu { .contextMenu {
Button(role: .destructive) { Button(role: .destructive) {
modelContext.delete(item) modelContext.delete(item)
try? modelContext.save() do {
try modelContext.save()
} catch {
print("删除收藏失败: \(error)")
}
} label: { } label: {
Label("取消收藏", systemImage: "heart.slash") Label("取消收藏", systemImage: "heart.slash")
} }
+5 -1
View File
@@ -38,7 +38,11 @@ struct HistoryView: View {
.contextMenu { .contextMenu {
Button(role: .destructive) { Button(role: .destructive) {
modelContext.delete(item) modelContext.delete(item)
try? modelContext.save() do {
try modelContext.save()
} catch {
print("删除历史记录失败: \(error)")
}
} label: { } label: {
Label("删除记录", systemImage: "trash") Label("删除记录", systemImage: "trash")
} }