first commit

This commit is contained in:
JinJiangHuang
2026-03-01 14:04:44 +08:00
commit 61e97da938
62 changed files with 9947 additions and 0 deletions
+427
View File
@@ -0,0 +1,427 @@
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)"
}
}
}
+78
View File
@@ -0,0 +1,78 @@
import Foundation
/// - Android OkGo
class NetworkManager {
static let shared = NetworkManager()
private let session: URLSession
private let decoder = JSONDecoder()
private init() {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 15
config.timeoutIntervalForResource = 30
config.httpMaximumConnectionsPerHost = 5
self.session = URLSession(configuration: config)
}
/// GET
func getString(from urlString: String, headers: [String: String]? = nil) async throws -> String {
guard let url = URL(string: urlString.trimmingCharacters(in: .whitespacesAndNewlines)) else {
throw NetworkError.invalidURL(urlString)
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
headers?.forEach { request.setValue($1, forHTTPHeaderField: $0) }
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
guard (200...299).contains(httpResponse.statusCode) else {
throw NetworkError.httpError(httpResponse.statusCode)
}
guard let str = String(data: data, encoding: .utf8) else {
throw NetworkError.decodingError("UTF-8 解码失败")
}
return str
}
/// GET JSON
func getJSON<T: Decodable>(from urlString: String, type: T.Type, headers: [String: String]? = nil) async throws -> T {
let str = try await getString(from: urlString, headers: headers)
guard let data = str.data(using: .utf8) else {
throw NetworkError.decodingError("字符串转 Data 失败")
}
return try decoder.decode(T.self, from: data)
}
/// GET Data
func getData(from urlString: String) async throws -> Data {
guard let url = URL(string: urlString.trimmingCharacters(in: .whitespacesAndNewlines)) else {
throw NetworkError.invalidURL(urlString)
}
let (data, _) = try await session.data(from: url)
return data
}
}
enum NetworkError: LocalizedError {
case invalidURL(String)
case invalidResponse
case httpError(Int)
case decodingError(String)
var errorDescription: String? {
switch self {
case .invalidURL(let url): return "无效的URL: \(url)"
case .invalidResponse: return "无效的响应"
case .httpError(let code): return "HTTP 错误: \(code)"
case .decodingError(let msg): return "解码错误: \(msg)"
}
}
}
+417
View File
@@ -0,0 +1,417 @@
import Foundation
/// - Android SourceViewModel.java
///
class SourceService {
static let shared = SourceService()
private let network = NetworkManager.shared
private init() {}
// MARK: -
///
func getSort(sourceBean: SourceBean) async throws -> (sorts: [MovieSort.SortData], homeVideos: [Movie.Video]) {
let api = sourceBean.api
guard !api.isEmpty else {
throw SourceError.emptyApi
}
// type=3 (JAR/Spider)
guard sourceBean.isSupportedInSwift else {
throw SourceError.unsupportedType(sourceBean.typeDescription)
}
// api HTTP URL
guard sourceBean.isHttpApi else {
throw SourceError.invalidApiUrl(api)
}
let jsonStr: String
if sourceBean.type == 0 {
// XML
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"
// 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)"
}
}
jsonStr = try await network.getString(from: url)
} else {
// JSON (type=1)
let url = api.contains("?") ? "\(api)&ac=class" : "\(api)?ac=class"
jsonStr = try await network.getString(from: url)
}
var (sorts, homeVideos) = try parseSort(jsonStr, sourceBean: sourceBean)
// vod_pic ac=class
//
let picMissingCount = homeVideos.filter { $0.pic.trimmingCharacters(in: .whitespaces).isEmpty }.count
let needsFallback = homeVideos.isEmpty || picMissingCount > homeVideos.count / 2
if needsFallback && (sourceBean.type == 1 || sourceBean.type == 4) {
let listUrl: String
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)"
} else {
// type=1 ac=videolist
listUrl = api.contains("?") ? "\(api)&ac=videolist&pg=1" : "\(api)?ac=videolist&pg=1"
}
if let listStr = try? await network.getString(from: listUrl) {
let fallback = (try? parseVideoList(listStr, sourceKey: sourceBean.key, type: sourceBean.type)) ?? []
if !fallback.isEmpty {
homeVideos = fallback
}
}
}
return (sorts, homeVideos)
}
private func parseSort(_ jsonStr: String, sourceBean: SourceBean) throws -> (sorts: [MovieSort.SortData], homeVideos: [Movie.Video]) {
guard let data = jsonStr.data(using: .utf8) else {
throw SourceError.parseError("无法解析数据")
}
var sorts: [MovieSort.SortData] = []
var homeVideos: [Movie.Video] = []
if sourceBean.type == 0 {
// XML
sorts = parseXMLCategories(from: jsonStr)
} else {
// JSON (type=1, type=4)
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
//
if let classList = json["class"] as? [[String: Any]] {
for cls in classList {
let id: String
if let intId = cls["type_id"] as? Int {
id = String(intId)
} else {
id = cls["type_id"] as? String ?? ""
}
let name = cls["type_name"] as? String ?? ""
sorts.append(MovieSort.SortData(id: id, name: name))
}
}
//
if let list = json["list"] as? [[String: Any]] {
for item in list {
let decoder = JSONDecoder()
if let itemData = try? JSONSerialization.data(withJSONObject: item),
var video = try? decoder.decode(Movie.Video.self, from: itemData) {
video.sourceKey = sourceBean.key
homeVideos.append(video)
}
}
}
}
}
return (sorts, homeVideos)
}
private func parseXMLCategories(from xml: String) -> [MovieSort.SortData] {
// XML
var sorts: [MovieSort.SortData] = []
let pattern = "<ty id=\"(\\d+)\"[^>]*>([^<]+)</ty>"
if let regex = try? NSRegularExpression(pattern: pattern) {
let matches = regex.matches(in: xml, range: NSRange(xml.startIndex..., in: xml))
for match in matches {
if let idRange = Range(match.range(at: 1), in: xml),
let nameRange = Range(match.range(at: 2), in: xml) {
let id = String(xml[idRange])
let name = String(xml[nameRange])
sorts.append(MovieSort.SortData(id: id, name: name))
}
}
}
return sorts
}
// MARK: -
///
func getList(sourceBean: SourceBean, sortData: MovieSort.SortData, page: Int = 1, filters: [String: String]? = nil) async throws -> [Movie.Video] {
let api = sourceBean.api
guard !api.isEmpty else { throw SourceError.emptyApi }
guard sourceBean.isSupportedInSwift else { throw SourceError.unsupportedType(sourceBean.typeDescription) }
guard sourceBean.isHttpApi else { throw SourceError.invalidApiUrl(api) }
var url: String
if sourceBean.type == 0 {
// XML
url = "\(api)?ac=videolist&t=\(sortData.id)&pg=\(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)"
// 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)"
}
} else {
let ext = Data("{}".utf8).base64EncodedString()
url += "&ext=\(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)"
}
}
} else {
// JSON (type=1)
url = api.contains("?")
? "\(api)&ac=videolist&t=\(sortData.id)&pg=\(page)"
: "\(api)?ac=videolist&t=\(sortData.id)&pg=\(page)"
//
if let filters = filters {
for (key, value) in filters {
url += "&\(key)=\(value)"
}
}
}
let jsonStr = try await network.getString(from: url)
return try parseVideoList(jsonStr, sourceKey: sourceBean.key, type: sourceBean.type)
}
private func parseVideoList(_ jsonStr: String, sourceKey: String, type: Int) throws -> [Movie.Video] {
guard let data = jsonStr.data(using: .utf8) else {
throw SourceError.parseError("无法解析数据")
}
var videos: [Movie.Video] = []
if type == 0 {
videos = parseXMLVideoList(from: jsonStr, sourceKey: sourceKey)
} else {
// JSON (type=1, type=4)
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let list = json["list"] as? [[String: Any]] {
let decoder = JSONDecoder()
for item in list {
if let itemData = try? JSONSerialization.data(withJSONObject: item),
var video = try? decoder.decode(Movie.Video.self, from: itemData) {
video.sourceKey = sourceKey
videos.append(video)
}
}
}
}
return videos
}
private func parseXMLVideoList(from xml: String, sourceKey: String) -> [Movie.Video] {
// XML
var videos: [Movie.Video] = []
let pattern = "<video>.*?<id>(\\d+)</id>.*?<name><!\\[CDATA\\[(.+?)\\]\\]></name>.*?<pic>(.*?)</pic>.*?<note><!\\[CDATA\\[(.*?)\\]\\]></note>.*?</video>"
if let regex = try? NSRegularExpression(pattern: pattern, options: .dotMatchesLineSeparators) {
let matches = regex.matches(in: xml, range: NSRange(xml.startIndex..., in: xml))
for match in matches {
var video = Movie.Video()
if let r = Range(match.range(at: 1), in: xml) { video.id = String(xml[r]) }
if let r = Range(match.range(at: 2), in: xml) { video.name = String(xml[r]) }
if let r = Range(match.range(at: 3), in: xml) { video.pic = String(xml[r]) }
if let r = Range(match.range(at: 4), in: xml) { video.note = String(xml[r]) }
video.sourceKey = sourceKey
videos.append(video)
}
}
return videos
}
// MARK: -
///
func getDetail(sourceBean: SourceBean, vodId: String) async throws -> VodInfo? {
let api = sourceBean.api
guard !api.isEmpty else { throw SourceError.emptyApi }
guard sourceBean.isSupportedInSwift else { throw SourceError.unsupportedType(sourceBean.typeDescription) }
guard sourceBean.isHttpApi else { throw SourceError.invalidApiUrl(api) }
var url: String
if sourceBean.type == 0 {
url = "\(api)?ac=videolist&ids=\(vodId)"
} else if sourceBean.type == 4 {
// Type 4:
url = api.contains("?")
? "\(api)&ac=detail&ids=\(vodId)"
: "\(api)?ac=detail&ids=\(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)"
}
}
} else {
// JSON (type=1)
url = api.contains("?")
? "\(api)&ac=detail&ids=\(vodId)"
: "\(api)?ac=detail&ids=\(vodId)"
}
let jsonStr = try await network.getString(from: url)
return try parseDetail(jsonStr, sourceKey: sourceBean.key, type: sourceBean.type)
}
private func parseDetail(_ jsonStr: String, sourceKey: String, type: Int) throws -> VodInfo? {
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 {
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 ?? ""
return VodInfo.from(video: video, playFrom: playFrom, playUrl: playUrl)
}
}
}
return nil
}
// MARK: -
///
func search(sourceBean: SourceBean, keyword: String) async throws -> [Movie.Video] {
let api = sourceBean.api
guard !api.isEmpty else { throw SourceError.emptyApi }
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
if sourceBean.type == 0 {
url = "\(api)?wd=\(encodedKeyword)"
} else if sourceBean.type == 4 {
// Type 4:
url = api.contains("?")
? "\(api)&wd=\(encodedKeyword)&ac=detail&quick=false"
: "\(api)?wd=\(encodedKeyword)&ac=detail&quick=false"
// 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)"
}
}
} else {
// JSON (type=1)
url = api.contains("?")
? "\(api)&wd=\(encodedKeyword)"
: "\(api)?wd=\(encodedKeyword)"
}
let jsonStr = try await network.getString(from: url)
return try parseVideoList(jsonStr, sourceKey: sourceBean.key, type: sourceBean.type)
}
///
func searchAll(keyword: String) async -> [Movie.Video] {
let sources = await ApiConfig.shared.getSearchableSources()
return await withTaskGroup(of: [Movie.Video].self) { group in
for source in sources {
//
guard source.isSupportedInSwift && source.isHttpApi else { continue }
group.addTask { [self] in
do {
return try await self.search(sourceBean: source, keyword: keyword)
} catch {
return []
}
}
}
var allResults: [Movie.Video] = []
for await results in group {
allResults.append(contentsOf: results)
}
return allResults
}
}
// MARK: - Extend
/// extend Android getFixUrl
/// extend HTTP URL extend
/// extend
private func resolveExtend(_ extend: String) async -> String {
guard !extend.isEmpty else { return "" }
// HTTP URL
guard extend.hasPrefix("http://") || extend.hasPrefix("https://") else {
return extend
}
// HTTP URL extend
do {
let content = try await network.getString(from: extend)
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
// >2500退使 URL
if trimmed.count > 2500 { return extend }
return trimmed
} catch {
return extend
}
}
}
enum SourceError: LocalizedError {
case emptyApi
case parseError(String)
case unsupportedType(String)
case invalidApiUrl(String)
var errorDescription: String? {
switch self {
case .emptyApi: return "接口地址为空"
case .parseError(let msg): return "数据解析错误: \(msg)"
case .unsupportedType(let type): return "暂不支持 \(type) 类型的数据源,请切换其他源"
case .invalidApiUrl(let url): return "无效的接口地址: \(url)"
}
}
}