Features: - VOD library with movie grouping and version detection - TV show library with season/episode organization - TMDB integration for trending shows and recently aired episodes - Recent releases section with TMDB release date sorting - Watch history tracking with continue watching - Playlist caching (12-hour TTL) for offline support - M3U playlist parsing with XStream API support - Authentication with credential storage Technical: - SwiftUI for tvOS - Actor-based services for thread safety - Persistent caching for playlists, TMDB data, and watch history - KSPlayer integration for video playback Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
405 lines
15 KiB
Swift
405 lines
15 KiB
Swift
//
|
|
// 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
|
|
}
|
|
}
|