Files
simvision/KSPlayer-main/Sources/KSPlayer/Subtitle/SubtitleDataSouce.swift
Michael Simard 872354b834 Initial commit: SimVision tvOS streaming app
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>
2026-01-21 22:12:08 -06:00

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
// plistURL
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
}
}