// // SubtitleDataSouce.swift // KSPlayer-7de52535 // // Created by kintan on 2018/8/7. // import Foundation public class EmptySubtitleInfo: SubtitleInfo { public var isEnabled: Bool = true public let subtitleID: String = "" public var delay: TimeInterval = 0 public let name = NSLocalizedString("no show subtitle", comment: "") public func search(for _: TimeInterval) -> [SubtitlePart] { [] } } public class URLSubtitleInfo: KSSubtitle, SubtitleInfo { public var isEnabled: Bool = false { didSet { if isEnabled, parts.isEmpty { Task { try? await parse(url: downloadURL, userAgent: userAgent) } } } } public private(set) var downloadURL: URL public var delay: TimeInterval = 0 public private(set) var name: String public let subtitleID: String public var comment: String? public var userInfo: NSMutableDictionary? private let userAgent: String? public convenience init(url: URL) { self.init(subtitleID: url.absoluteString, name: url.lastPathComponent, url: url) } public init(subtitleID: String, name: String, url: URL, userAgent: String? = nil) { self.subtitleID = subtitleID self.name = name self.userAgent = userAgent downloadURL = url super.init() if !url.isFileURL, name.isEmpty { url.download(userAgent: userAgent) { [weak self] filename, tmpUrl in guard let self else { return } self.name = filename self.downloadURL = tmpUrl var fileURL = URL(fileURLWithPath: NSTemporaryDirectory()) fileURL.appendPathComponent(filename) try? FileManager.default.moveItem(at: tmpUrl, to: fileURL) self.downloadURL = fileURL } } } } public protocol SubtitleDataSouce: AnyObject { var infos: [any SubtitleInfo] { get } } public protocol FileURLSubtitleDataSouce: SubtitleDataSouce { func searchSubtitle(fileURL: URL?) async throws } public protocol CacheSubtitleDataSouce: FileURLSubtitleDataSouce { func addCache(fileURL: URL, downloadURL: URL) } public protocol SearchSubtitleDataSouce: SubtitleDataSouce { func searchSubtitle(query: String?, languages: [String]) async throws } public extension KSOptions { static var subtitleDataSouces: [SubtitleDataSouce] = [DirectorySubtitleDataSouce()] } public class PlistCacheSubtitleDataSouce: CacheSubtitleDataSouce { public static let singleton = PlistCacheSubtitleDataSouce() public var infos = [any SubtitleInfo]() private let srtCacheInfoPath: String // 因为plist不能保存URL private var srtInfoCaches: [String: [String]] private init() { let cacheFolder = (NSTemporaryDirectory() as NSString).appendingPathComponent("KSSubtitleCache") if !FileManager.default.fileExists(atPath: cacheFolder) { try? FileManager.default.createDirectory(atPath: cacheFolder, withIntermediateDirectories: true, attributes: nil) } srtCacheInfoPath = (cacheFolder as NSString).appendingPathComponent("KSSrtInfo.plist") srtInfoCaches = [String: [String]]() DispatchQueue.global().async { [weak self] in guard let self else { return } self.srtInfoCaches = (NSMutableDictionary(contentsOfFile: self.srtCacheInfoPath) as? [String: [String]]) ?? [String: [String]]() } } public func searchSubtitle(fileURL: URL?) async throws { infos = [any SubtitleInfo]() guard let fileURL else { return } infos = srtInfoCaches[fileURL.absoluteString]?.compactMap { downloadURL -> (any SubtitleInfo)? in guard let url = URL(string: downloadURL) else { return nil } let info = URLSubtitleInfo(url: url) info.comment = "local" return info } ?? [any SubtitleInfo]() } public func addCache(fileURL: URL, downloadURL: URL) { let file = fileURL.absoluteString let path = downloadURL.absoluteString var array = srtInfoCaches[file] ?? [String]() if !array.contains(where: { $0 == path }) { array.append(path) srtInfoCaches[file] = array DispatchQueue.global().async { [weak self] in guard let self else { return } (self.srtInfoCaches as NSDictionary).write(toFile: self.srtCacheInfoPath, atomically: false) } } } } public class URLSubtitleDataSouce: SubtitleDataSouce { public var infos: [any SubtitleInfo] public init(urls: [URL]) { infos = urls.map { URLSubtitleInfo(url: $0) } } } public class DirectorySubtitleDataSouce: FileURLSubtitleDataSouce { public var infos = [any SubtitleInfo]() public init() {} public func searchSubtitle(fileURL: URL?) async throws { infos = [any SubtitleInfo]() guard let fileURL else { return } if fileURL.isFileURL { let subtitleURLs: [URL] = (try? FileManager.default.contentsOfDirectory(at: fileURL.deletingLastPathComponent(), includingPropertiesForKeys: nil).filter(\.isSubtitle)) ?? [] infos = subtitleURLs.map { URLSubtitleInfo(url: $0) }.sorted { left, right in left.name < right.name } } } } public class ShooterSubtitleDataSouce: FileURLSubtitleDataSouce { public var infos = [any SubtitleInfo]() public init() {} public func searchSubtitle(fileURL: URL?) async throws { infos = [any SubtitleInfo]() guard let fileURL else { return } guard fileURL.isFileURL, let searchApi = URL(string: "https://www.shooter.cn/api/subapi.php")? .add(queryItems: ["format": "json", "pathinfo": fileURL.path, "filehash": fileURL.shooterFilehash]) else { return } var request = URLRequest(url: searchApi) request.httpMethod = "POST" let (data, _) = try await URLSession.shared.data(for: request) guard let json = try JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return } infos = json.flatMap { sub in let filesDic = sub["Files"] as? [[String: String]] // let desc = sub["Desc"] as? String ?? "" let delay = TimeInterval(sub["Delay"] as? Int ?? 0) / 1000.0 return filesDic?.compactMap { dic in if let string = dic["Link"], let url = URL(string: string) { let info = URLSubtitleInfo(subtitleID: string, name: "", url: url) info.delay = delay return info } return nil } ?? [URLSubtitleInfo]() } } } public class AssrtSubtitleDataSouce: SearchSubtitleDataSouce { private let token: String public var infos = [any SubtitleInfo]() public init(token: String) { self.token = token } public func searchSubtitle(query: String?, languages _: [String] = ["zh-cn"]) async throws { infos = [any SubtitleInfo]() guard let query else { return } guard let searchApi = URL(string: "https://api.assrt.net/v1/sub/search")?.add(queryItems: ["q": query]) else { return } var request = URLRequest(url: searchApi) request.httpMethod = "POST" request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization") let (data, _) = try await URLSession.shared.data(for: request) guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { return } guard let status = json["status"] as? Int, status == 0 else { return } guard let subDict = json["sub"] as? [String: Any], let subArray = subDict["subs"] as? [[String: Any]] else { return } var result = [URLSubtitleInfo]() for sub in subArray { if let assrtSubID = sub["id"] as? Int { try await result.append(contentsOf: loadDetails(assrtSubID: String(assrtSubID))) } } infos = result } func loadDetails(assrtSubID: String) async throws -> [URLSubtitleInfo] { var infos = [URLSubtitleInfo]() guard let detailApi = URL(string: "https://api.assrt.net/v1/sub/detail")?.add(queryItems: ["id": assrtSubID]) else { return infos } var request = URLRequest(url: detailApi) request.httpMethod = "POST" request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization") let (data, _) = try await URLSession.shared.data(for: request) guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return infos } guard let status = json["status"] as? Int, status == 0 else { return infos } guard let subDict = json["sub"] as? [String: Any], let subArray = subDict["subs"] as? [[String: Any]], let sub = subArray.first else { return infos } if let fileList = sub["filelist"] as? [[String: String]] { for dic in fileList { if let urlString = dic["url"], let filename = dic["f"], let url = URL(string: urlString) { let info = URLSubtitleInfo(subtitleID: urlString, name: filename, url: url) infos.append(info) } } } else if let urlString = sub["url"] as? String, let filename = sub["filename"] as? String, let url = URL(string: urlString) { let info = URLSubtitleInfo(subtitleID: urlString, name: filename, url: url) infos.append(info) } return infos } } public class OpenSubtitleDataSouce: SearchSubtitleDataSouce { private var token: String? = nil private let username: String? private let password: String? private let apiKey: String public var infos = [any SubtitleInfo]() public init(apiKey: String, username: String? = nil, password: String? = nil) { self.apiKey = apiKey self.username = username self.password = password } public func searchSubtitle(query: String?, languages: [String] = ["zh-cn"]) async throws { try await searchSubtitle(query: query, imdbID: 0, tmdbID: 0, languages: languages) } public func searchSubtitle(query: String?, imdbID: Int, tmdbID: Int, languages: [String] = ["zh-cn"]) async throws { infos = [any SubtitleInfo]() var queryItems = [String: String]() if let query { queryItems["query"] = query } if imdbID != 0 { queryItems["imbd_id"] = String(imdbID) } if tmdbID != 0 { queryItems["tmdb_id"] = String(tmdbID) } if queryItems.isEmpty { return } queryItems["languages"] = languages.joined(separator: ",") try await searchSubtitle(queryItems: queryItems) } // https://opensubtitles.stoplight.io/docs/opensubtitles-api/a172317bd5ccc-search-for-subtitles public func searchSubtitle(queryItems: [String: String]) async throws { infos = [any SubtitleInfo]() if queryItems.isEmpty { return } guard let searchApi = URL(string: "https://api.opensubtitles.com/api/v1/subtitles")?.add(queryItems: queryItems) else { return } var request = URLRequest(url: searchApi) request.addValue(apiKey, forHTTPHeaderField: "Api-Key") if let token { request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } let (data, _) = try await URLSession.shared.data(for: request) guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { return } guard let dataArray = json["data"] as? [[String: Any]] else { return } var result = [URLSubtitleInfo]() for sub in dataArray { if let attributes = sub["attributes"] as? [String: Any], let files = attributes["files"] as? [[String: Any]] { for file in files { if let fileID = file["file_id"] as? Int, let info = try await loadDetails(fileID: fileID) { result.append(info) } } } } infos = result } func loadDetails(fileID: Int) async throws -> URLSubtitleInfo? { guard let detailApi = URL(string: "https://api.opensubtitles.com/api/v1/download")?.add(queryItems: ["file_id": String(fileID)]) else { return nil } var request = URLRequest(url: detailApi) request.httpMethod = "POST" request.addValue(apiKey, forHTTPHeaderField: "Api-Key") if let token { request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } let (data, _) = try await URLSession.shared.data(for: request) guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } guard let link = json["link"] as? String, let fileName = json["file_name"] as? String, let url = URL(string: link) else { return nil } return URLSubtitleInfo(subtitleID: String(fileID), name: fileName, url: url) } } extension URL { public var components: URLComponents? { URLComponents(url: self, resolvingAgainstBaseURL: true) } func add(queryItems: [String: String]) -> URL? { guard var urlComponents = components else { return nil } var reserved = CharacterSet.urlQueryAllowed reserved.remove(charactersIn: ": #[]@!$&'()*+, ;=") urlComponents.percentEncodedQueryItems = queryItems.compactMap { key, value in URLQueryItem(name: key.addingPercentEncoding(withAllowedCharacters: reserved) ?? key, value: value.addingPercentEncoding(withAllowedCharacters: reserved)) } return urlComponents.url } var shooterFilehash: String { let file: FileHandle do { file = try FileHandle(forReadingFrom: self) } catch { return "" } defer { file.closeFile() } file.seekToEndOfFile() let fileSize: UInt64 = file.offsetInFile guard fileSize >= 12288 else { return "" } let offsets: [UInt64] = [ 4096, fileSize / 3 * 2, fileSize / 3, fileSize - 8192, ] let hash = offsets.map { offset -> String in file.seek(toFileOffset: offset) return file.readData(ofLength: 4096).md5() }.joined(separator: ";") return hash } }