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
/// /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 {
/// 线
@@ -16,6 +23,8 @@ struct VodPlaybackState: Codable {
///
@Model
final class VodCollect {
/// sourceKey + vodId
var bizKey: String = ""
/// ID sourceKey
var vodId: String = ""
///
@@ -28,6 +37,7 @@ final class VodCollect {
var updateTime: Date = Date()
init(vodId: String, vodName: String, vodPic: String, sourceKey: String) {
self.bizKey = makeVodBusinessKey(vodId: vodId, sourceKey: sourceKey)
self.vodId = vodId
self.vodName = vodName
self.vodPic = vodPic
@@ -39,6 +49,8 @@ final class VodCollect {
///
@Model
final class VodRecord {
/// sourceKey + vodId
var bizKey: String = ""
/// ID
var vodId: String = ""
///
@@ -55,6 +67,7 @@ final class VodRecord {
var updateTime: Date = Date()
init(vodId: String, vodName: String, vodPic: String, sourceKey: String, playNote: String = "") {
self.bizKey = makeVodBusinessKey(vodId: vodId, sourceKey: sourceKey)
self.vodId = vodId
self.vodName = vodName
self.vodPic = vodPic
@@ -89,50 +102,64 @@ actor CacheStore {
@MainActor
func addCollect(_ video: Movie.Video, context: ModelContext) {
//
let vodId = video.id
let sourceKey = video.sourceKey
let predicate = #Predicate<VodCollect> { item in
item.vodId == vodId && item.sourceKey == sourceKey
}
let descriptor = FetchDescriptor<VodCollect>(predicate: predicate)
let bizKey = makeVodBusinessKey(vodId: vodId, sourceKey: sourceKey)
if let existing = try? context.fetch(descriptor), !existing.isEmpty {
return //
do {
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
func removeCollect(vodId: String, sourceKey: String, context: ModelContext) {
// (vodId, sourceKey)
let predicate = #Predicate<VodCollect> { item in
item.vodId == vodId && item.sourceKey == sourceKey
}
let descriptor = FetchDescriptor<VodCollect>(predicate: predicate)
if let items = try? context.fetch(descriptor) {
do {
let items = try fetchCollects(vodId: vodId, sourceKey: sourceKey, context: context)
guard !items.isEmpty else { return }
for item in items {
context.delete(item)
}
try? context.save()
try context.save()
} catch {
print("删除收藏失败: \(error)")
}
}
@MainActor
func isCollected(vodId: String, sourceKey: String, context: ModelContext) -> Bool {
let bizKey = makeVodBusinessKey(vodId: vodId, sourceKey: sourceKey)
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)
return (try? context.fetchCount(descriptor)) ?? 0 > 0
do {
let count = try context.fetchCount(descriptor)
return count > 0
} catch {
print("查询收藏状态失败: \(error)")
return false
}
}
@MainActor
@@ -144,39 +171,55 @@ actor CacheStore {
) {
let vodId = video.id
let sourceKey = video.sourceKey
let record = fetchRecord(vodId: vodId, sourceKey: sourceKey, context: context)
let encodedState = Self.encodePlaybackState(playbackState)
let bizKey = makeVodBusinessKey(vodId: vodId, sourceKey: sourceKey)
//
if let record {
record.playNote = playNote
if let encodedState {
record.dataJson = encodedState
do {
let matched = try fetchRecords(vodId: vodId, sourceKey: sourceKey, context: context)
//
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 {
let record = VodRecord(
vodId: video.id,
vodName: video.name,
vodPic: video.pic,
sourceKey: video.sourceKey,
playNote: playNote
)
if let encodedState {
record.dataJson = encodedState
}
context.insert(record)
try context.save()
} catch {
print("写入播放记录失败: \(error)")
}
try? context.save()
}
/// JSON `nil`
@MainActor
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 Self.decodePlaybackState(record.dataJson)
}
@MainActor
@@ -190,14 +233,47 @@ actor CacheStore {
}
@MainActor
private func fetchRecord(vodId: String, sourceKey: String, context: ModelContext) -> VodRecord? {
// (vodId, sourceKey)
private func fetchRecords(vodId: String, sourceKey: String, context: ModelContext) throws -> [VodRecord] {
let bizKey = makeVodBusinessKey(vodId: vodId, sourceKey: sourceKey)
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)
guard let records = try? context.fetch(descriptor) else { return nil }
return records.first
let records = try context.fetch(descriptor)
// 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? {
+140 -34
View File
@@ -7,6 +7,8 @@ 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
@@ -26,6 +28,13 @@ class ApiConfig: ObservableObject {
@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() {}
@@ -41,6 +50,10 @@ class ApiConfig: ObservableObject {
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
@@ -48,33 +61,56 @@ class ApiConfig: ObservableObject {
if trimmedVod == resolvedLive {
let configResult = try await fetchConfig(from: trimmedVod)
parseConfig(
guard activeLoadToken == loadToken else { return }
await parseConfig(
configResult.config,
apiUrl: configResult.loadedFrom,
includeSources: true,
includeLive: true
includeLive: false,
loadToken: loadToken
)
scheduleLiveParsing(
config: configResult.config,
apiUrl: configResult.loadedFrom,
loadToken: loadToken
)
} else {
let vodConfig = try await fetchConfig(from: trimmedVod)
parseConfig(
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
includeLive: false,
loadToken: loadToken
)
let liveConfig = try await fetchConfig(from: resolvedLive)
parseConfig(
liveConfig.config,
scheduleLiveParsing(
config: liveConfig.config,
apiUrl: liveConfig.loadedFrom,
includeSources: false,
includeLive: true
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,
@@ -105,7 +141,7 @@ class ApiConfig: ObservableObject {
var nextVisited = visitedUrls
nextVisited.insert(visitKey)
let jsonStr = try await network.getString(from: normalizedUrl)
let jsonStr = try await fetchConfigText(from: normalizedUrl)
// JSONAndroid Gson Swift
let cleanedJson = Self.stripJsonComments(jsonStr)
@@ -173,6 +209,34 @@ class ApiConfig: ObservableObject {
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] = []
@@ -457,7 +521,7 @@ class ApiConfig: ObservableObject {
/// nil
func fetchMultiRepoOptions(from apiUrl: String) async throws -> [MultiRepoOption]? {
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)
guard let data = cleanedJson.data(using: .utf8) else {
@@ -506,8 +570,11 @@ class ApiConfig: ObservableObject {
_ config: AppConfigData,
apiUrl: String,
includeSources: Bool,
includeLive: Bool
) {
includeLive: Bool,
loadToken: UUID
) async {
guard activeLoadToken == loadToken else { return }
if includeSources {
//
var sources: [SourceBean] = []
@@ -543,6 +610,8 @@ class ApiConfig: ObservableObject {
self.parseBeanList = parses.map { p in
ParseBean(name: p.name ?? "", url: p.url ?? "", type: p.type?.value ?? 0)
}
} else {
self.parseBeanList = []
}
// DoH
@@ -551,6 +620,8 @@ class ApiConfig: ObservableObject {
guard let name = d.name, let url = d.url else { return nil }
return (name: name, url: url)
}
} else {
self.dohList = []
}
//
@@ -559,7 +630,9 @@ class ApiConfig: ObservableObject {
if includeLive {
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 {
liveChannelGroupList = []
}
@@ -567,32 +640,65 @@ class ApiConfig: ObservableObject {
}
///
private func parseLives(_ lives: [AppConfigData.LiveConfig], apiUrl: String) {
Task {
var mergedGroups: [String: LiveChannelGroup] = [:]
private func parseLives(
_ lives: [AppConfigData.LiveConfig],
apiUrl: String,
loadToken: UUID
) async -> [LiveChannelGroup] {
var mergedGroups: [String: LiveChannelGroup] = [:]
var remoteLiveTargets: [(order: Int, url: String)] = []
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)")
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)
}
}
}
//
if let channels = live.channels {
let inlineGroups = parseInlineLiveChannels(channels)
mergeLiveGroups(inlineGroups, into: &mergedGroups)
var results: [(Int, String)] = []
for await (order, content) in group {
if let content {
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
+224 -55
View File
@@ -34,19 +34,24 @@ class SourceService {
jsonStr = try await network.getString(from: api)
} else if sourceBean.type == 4 {
// Type 4: extend filter
var url = api.contains("?") ? "\(api)&filter=true" : "\(api)?filter=true"
var queryItems: [URLQueryItem] = [
URLQueryItem(name: "filter", value: "true")
]
// extend
if let ext = sourceBean.ext, !ext.isEmpty {
let extend = await resolveExtend(ext)
if !extend.isEmpty {
let encoded = extend.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? extend
url += "&extend=\(encoded)"
queryItems.append(URLQueryItem(name: "extend", value: extend))
}
}
let url = try buildURL(base: api, queryItems: queryItems)
jsonStr = try await network.getString(from: url)
} else {
// 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)
}
@@ -62,12 +67,24 @@ class SourceService {
if sourceBean.type == 4 {
// type=4 ac=detail getList
let ext = Data("{}".utf8).base64EncodedString()
listUrl = api.contains("?")
? "\(api)&ac=detail&filter=true&pg=1&ext=\(ext)"
: "\(api)?ac=detail&filter=true&pg=1&ext=\(ext)"
listUrl = try buildURL(
base: api,
queryItems: [
URLQueryItem(name: "ac", value: "detail"),
URLQueryItem(name: "filter", value: "true"),
URLQueryItem(name: "pg", value: "1"),
URLQueryItem(name: "ext", value: ext)
]
)
} else {
// 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) {
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.isHttpApi else { throw SourceError.invalidApiUrl(api) }
var url: String
let url: String
if sourceBean.type == 0 {
// 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 {
// Type 4:
url = api.contains("?")
? "\(api)&ac=detail&filter=true&t=\(sortData.id)&pg=\(page)"
: "\(api)?ac=detail&filter=true&t=\(sortData.id)&pg=\(page)"
var queryItems: [URLQueryItem] = [
URLQueryItem(name: "ac", value: "detail"),
URLQueryItem(name: "filter", value: "true"),
URLQueryItem(name: "t", value: sortData.id),
URLQueryItem(name: "pg", value: String(page))
]
// base64
if let filters = filters, !filters.isEmpty {
if let filterData = try? JSONSerialization.data(withJSONObject: filters),
let filterStr = String(data: filterData, encoding: .utf8) {
let ext = Data(filterStr.utf8).base64EncodedString()
url += "&ext=\(ext)"
queryItems.append(URLQueryItem(name: "ext", value: ext))
}
} else {
let ext = Data("{}".utf8).base64EncodedString()
url += "&ext=\(ext)"
queryItems.append(URLQueryItem(name: "ext", value: ext))
}
// extend
if let ext = sourceBean.ext, !ext.isEmpty {
let extend = await resolveExtend(ext)
if !extend.isEmpty {
let encoded = extend.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? extend
url += "&extend=\(encoded)"
queryItems.append(URLQueryItem(name: "extend", value: extend))
}
}
url = try buildURL(base: api, queryItems: queryItems)
} else {
// JSON (type=1)
url = api.contains("?")
? "\(api)&ac=videolist&t=\(sortData.id)&pg=\(page)"
: "\(api)?ac=videolist&t=\(sortData.id)&pg=\(page)"
var queryItems: [URLQueryItem] = [
URLQueryItem(name: "ac", value: "videolist"),
URLQueryItem(name: "t", value: sortData.id),
URLQueryItem(name: "pg", value: String(page))
]
//
if let filters = 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)
@@ -255,28 +285,39 @@ class SourceService {
guard sourceBean.isSupportedInSwift else { throw SourceError.unsupportedType(sourceBean.typeDescription) }
guard sourceBean.isHttpApi else { throw SourceError.invalidApiUrl(api) }
var url: String
let url: String
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 {
// Type 4:
url = api.contains("?")
? "\(api)&ac=detail&ids=\(vodId)"
: "\(api)?ac=detail&ids=\(vodId)"
var queryItems: [URLQueryItem] = [
URLQueryItem(name: "ac", value: "detail"),
URLQueryItem(name: "ids", value: vodId)
]
// extend
if let ext = sourceBean.ext, !ext.isEmpty {
let extend = await resolveExtend(ext)
if !extend.isEmpty {
let encoded = extend.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? extend
url += "&extend=\(encoded)"
queryItems.append(URLQueryItem(name: "extend", value: extend))
}
}
url = try buildURL(base: api, queryItems: queryItems)
} else {
// JSON (type=1)
url = api.contains("?")
? "\(api)&ac=detail&ids=\(vodId)"
: "\(api)?ac=detail&ids=\(vodId)"
url = try buildURL(
base: api,
queryItems: [
URLQueryItem(name: "ac", value: "detail"),
URLQueryItem(name: "ids", value: vodId)
]
)
}
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? {
if type == 0 {
return parseXMLDetail(jsonStr, sourceKey: sourceKey)
}
guard let data = jsonStr.data(using: .utf8) else {
throw SourceError.parseError("无法解析数据")
}
if type == 1 || type == 4 {
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let list = json["list"] as? [[String: Any]],
let first = list.first {
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let list = json["list"] as? [[String: Any]],
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()
if let itemData = try? JSONSerialization.data(withJSONObject: first),
var video = try? decoder.decode(Movie.Video.self, from: itemData) {
video.sourceKey = sourceKey
let playFrom = first["vod_play_from"] as? String ?? ""
let playUrl = first["vod_play_url"] as? String ?? ""
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)
}
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.isHttpApi else { throw SourceError.invalidApiUrl(api) }
let encodedKeyword = keyword.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? keyword
var url: String
let url: String
if sourceBean.type == 0 {
url = "\(api)?wd=\(encodedKeyword)"
url = try buildURL(
base: api,
queryItems: [URLQueryItem(name: "wd", value: keyword)]
)
} else if sourceBean.type == 4 {
// Type 4:
let quickValue = sourceBean.isQuickSearchEnabled ? "true" : "false"
url = api.contains("?")
? "\(api)&wd=\(encodedKeyword)&ac=detail&quick=\(quickValue)"
: "\(api)?wd=\(encodedKeyword)&ac=detail&quick=\(quickValue)"
var queryItems: [URLQueryItem] = [
URLQueryItem(name: "wd", value: keyword),
URLQueryItem(name: "ac", value: "detail"),
URLQueryItem(name: "quick", value: quickValue)
]
// extend
if let ext = sourceBean.ext, !ext.isEmpty {
let extend = await resolveExtend(ext)
if !extend.isEmpty {
let encoded = extend.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? extend
url += "&extend=\(encoded)"
queryItems.append(URLQueryItem(name: "extend", value: extend))
}
}
url = try buildURL(base: api, queryItems: queryItems)
} else {
// JSON (type=1)
url = api.contains("?")
? "\(api)&wd=\(encodedKeyword)"
: "\(api)?wd=\(encodedKeyword)"
url = try buildURL(
base: api,
queryItems: [URLQueryItem(name: "wd", value: keyword)]
)
}
let jsonStr = try await network.getString(from: url)
@@ -437,6 +484,128 @@ class SourceService {
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 {
+63 -4
View File
@@ -1,5 +1,6 @@
import Foundation
import SwiftUI
import Combine
/// ViewModel
@MainActor
@@ -19,12 +20,16 @@ class LiveViewModel: ObservableObject {
/// TV
@Published var showChannelList = false
///
private var cancellables: Set<AnyCancellable> = []
init() {
bindLiveChannelGroups()
}
///
func loadChannels() {
self.channelGroups = ApiConfig.shared.liveChannelGroupList
if let firstGroup = channelGroups.first, let firstChannel = firstGroup.channels.first {
currentChannel = firstChannel
}
applyChannelGroups(ApiConfig.shared.liveChannelGroupList)
}
///
@@ -90,6 +95,60 @@ class LiveViewModel: ObservableObject {
//
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())
.shadow(color: .red.opacity(0.4), radius: 12, x: 0, y: 6)
}
.buttonStyle(.plain)
.disabled(
settingsVM.isLoadingConfig
|| settingsVM.vodApiUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+5 -1
View File
@@ -38,7 +38,11 @@ struct FavoritesView: View {
.contextMenu {
Button(role: .destructive) {
modelContext.delete(item)
try? modelContext.save()
do {
try modelContext.save()
} catch {
print("删除收藏失败: \(error)")
}
} label: {
Label("取消收藏", systemImage: "heart.slash")
}
+5 -1
View File
@@ -38,7 +38,11 @@ struct HistoryView: View {
.contextMenu {
Button(role: .destructive) {
modelContext.delete(item)
try? modelContext.save()
do {
try modelContext.save()
} catch {
print("删除历史记录失败: \(error)")
}
} label: {
Label("删除记录", systemImage: "trash")
}