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>
This commit is contained in:
2026-01-21 22:12:08 -06:00
commit 872354b834
283 changed files with 338296 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
//
// AudioRecognize.swift
// KSPlayer
//
// Created by kintan on 2023/9/23.
//
import Foundation
public protocol AudioRecognize: SubtitleInfo {
func append(frame: AudioFrame)
}

View File

@@ -0,0 +1,422 @@
//
// KSParseProtocol.swift
// KSPlayer-7de52535
//
// Created by kintan on 2018/8/7.
//
import Foundation
import SwiftUI
#if !canImport(UIKit)
import AppKit
#else
import UIKit
#endif
public protocol KSParseProtocol {
func canParse(scanner: Scanner) -> Bool
func parsePart(scanner: Scanner) -> SubtitlePart?
}
public extension KSOptions {
static var subtitleParses: [KSParseProtocol] = [AssParse(), VTTParse(), SrtParse()]
}
public extension String {}
public extension KSParseProtocol {
func parse(scanner: Scanner) -> [SubtitlePart] {
var groups = [SubtitlePart]()
while !scanner.isAtEnd {
if let group = parsePart(scanner: scanner) {
groups.append(group)
}
}
groups = groups.mergeSortBottomUp { $0 < $1 }
return groups
}
}
public class AssParse: KSParseProtocol {
private var styleMap = [String: ASSStyle]()
private var eventKeys = ["Layer", "Start", "End", "Style", "Name", "MarginL", "MarginR", "MarginV", "Effect", "Text"]
private var playResX = Float(0.0)
private var playResY = Float(0.0)
public func canParse(scanner: Scanner) -> Bool {
guard scanner.scanString("[Script Info]") != nil else {
return false
}
while scanner.scanString("Format:") == nil {
if scanner.scanString("PlayResX:") != nil {
playResX = scanner.scanFloat() ?? 0
} else if scanner.scanString("PlayResY:") != nil {
playResY = scanner.scanFloat() ?? 0
} else {
_ = scanner.scanUpToCharacters(from: .newlines)
}
}
guard var keys = scanner.scanUpToCharacters(from: .newlines)?.components(separatedBy: ",") else {
return false
}
keys = keys.map { $0.trimmingCharacters(in: .whitespaces) }
while scanner.scanString("Style:") != nil {
_ = scanner.scanString("Format: ")
guard let values = scanner.scanUpToCharacters(from: .newlines)?.components(separatedBy: ",") else {
continue
}
var dic = [String: String]()
for i in 1 ..< keys.count {
dic[keys[i]] = values[i]
}
styleMap[values[0]] = dic.parseASSStyle()
}
_ = scanner.scanString("[Events]")
if scanner.scanString("Format: ") != nil {
guard let keys = scanner.scanUpToCharacters(from: .newlines)?.components(separatedBy: ",") else {
return false
}
eventKeys = keys.map { $0.trimmingCharacters(in: .whitespaces) }
}
return true
}
// Dialogue: 0,0:12:37.73,0:12:38.83,Aki Default,,0,0,0,,{\be8}
// ffmpeg
// 875,,Default,NTP,0000,0000,0000,!Effect,- \\N-
public func parsePart(scanner: Scanner) -> SubtitlePart? {
let isDialogue = scanner.scanString("Dialogue") != nil
var dic = [String: String]()
for i in 0 ..< eventKeys.count {
if !isDialogue, i == 1 {
continue
}
if i == eventKeys.count - 1 {
dic[eventKeys[i]] = scanner.scanUpToCharacters(from: .newlines)
} else {
dic[eventKeys[i]] = scanner.scanUpToString(",")
_ = scanner.scanString(",")
}
}
let start: TimeInterval
let end: TimeInterval
if let startString = dic["Start"], let endString = dic["End"] {
start = startString.parseDuration()
end = endString.parseDuration()
} else {
if isDialogue {
return nil
} else {
start = 0
end = 0
}
}
var attributes: [NSAttributedString.Key: Any]?
var textPosition: TextPosition
if let style = dic["Style"], let assStyle = styleMap[style] {
attributes = assStyle.attrs
textPosition = assStyle.textPosition
if let marginL = dic["MarginL"].flatMap(Double.init), marginL != 0 {
textPosition.leftMargin = CGFloat(marginL)
}
if let marginR = dic["MarginR"].flatMap(Double.init), marginR != 0 {
textPosition.rightMargin = CGFloat(marginR)
}
if let marginV = dic["MarginV"].flatMap(Double.init), marginV != 0 {
textPosition.verticalMargin = CGFloat(marginV)
}
} else {
textPosition = TextPosition()
}
guard var text = dic["Text"] else {
return nil
}
text = text.replacingOccurrences(of: "\\N", with: "\n")
text = text.replacingOccurrences(of: "\\n", with: "\n")
let part = SubtitlePart(start, end, attributedString: text.build(textPosition: &textPosition, attributed: attributes))
part.textPosition = textPosition
return part
}
}
public struct ASSStyle {
let attrs: [NSAttributedString.Key: Any]
let textPosition: TextPosition
}
// swiftlint:disable cyclomatic_complexity
extension String {
func build(textPosition: inout TextPosition, attributed: [NSAttributedString.Key: Any]? = nil) -> NSAttributedString {
let lineCodes = splitStyle()
let attributedStr = NSMutableAttributedString()
var attributed = attributed ?? [:]
for lineCode in lineCodes {
attributedStr.append(lineCode.0.parseStyle(attributes: &attributed, style: lineCode.1, textPosition: &textPosition))
}
return attributedStr
}
func splitStyle() -> [(String, String?)] {
let scanner = Scanner(string: self)
scanner.charactersToBeSkipped = nil
var result = [(String, String?)]()
var sytle: String?
while !scanner.isAtEnd {
if scanner.scanString("{") != nil {
sytle = scanner.scanUpToString("}")
_ = scanner.scanString("}")
} else if let text = scanner.scanUpToString("{") {
result.append((text, sytle))
} else if let text = scanner.scanUpToCharacters(from: .newlines) {
result.append((text, sytle))
}
}
return result
}
func parseStyle(attributes: inout [NSAttributedString.Key: Any], style: String?, textPosition: inout TextPosition) -> NSAttributedString {
guard let style else {
return NSAttributedString(string: self, attributes: attributes)
}
var fontName: String?
var fontSize: Float?
let subStyleArr = style.components(separatedBy: "\\")
var shadow = attributes[.shadow] as? NSShadow
for item in subStyleArr {
let itemStr = item.replacingOccurrences(of: " ", with: "")
let scanner = Scanner(string: itemStr)
let char = scanner.scanCharacter()
switch char {
case "a":
let char = scanner.scanCharacter()
if char == "n" {
textPosition.ass(alignment: scanner.scanUpToCharacters(from: .newlines))
}
case "b":
attributes[.expansion] = scanner.scanFloat()
case "c":
attributes[.foregroundColor] = scanner.scanUpToCharacters(from: .newlines).flatMap(UIColor.init(assColor:))
case "f":
let char = scanner.scanCharacter()
if char == "n" {
fontName = scanner.scanUpToCharacters(from: .newlines)
} else if char == "s" {
fontSize = scanner.scanFloat()
}
case "i":
attributes[.obliqueness] = scanner.scanFloat()
case "s":
if scanner.scanString("had") != nil {
if let size = scanner.scanFloat() {
shadow = shadow ?? NSShadow()
shadow?.shadowOffset = CGSize(width: CGFloat(size), height: CGFloat(size))
shadow?.shadowBlurRadius = CGFloat(size)
}
attributes[.shadow] = shadow
} else {
attributes[.strikethroughStyle] = scanner.scanInt()
}
case "u":
attributes[.underlineStyle] = scanner.scanInt()
case "1", "2", "3", "4":
let twoChar = scanner.scanCharacter()
if twoChar == "c" {
let color = scanner.scanUpToCharacters(from: .newlines).flatMap(UIColor.init(assColor:))
if char == "1" {
attributes[.foregroundColor] = color
} else if char == "2" {
//
// attributes[.backgroundColor] = color
} else if char == "3" {
attributes[.strokeColor] = color
} else if char == "4" {
shadow = shadow ?? NSShadow()
shadow?.shadowColor = color
attributes[.shadow] = shadow
}
}
default:
break
}
}
// Apply font attributes if available
if let fontName, let fontSize {
let font = UIFont(name: fontName, size: CGFloat(fontSize)) ?? UIFont.systemFont(ofSize: CGFloat(fontSize))
attributes[.font] = font
}
return NSAttributedString(string: self, attributes: attributes)
}
}
public extension [String: String] {
func parseASSStyle() -> ASSStyle {
var attributes: [NSAttributedString.Key: Any] = [:]
if let fontName = self["Fontname"], let fontSize = self["Fontsize"].flatMap(Double.init) {
var font = UIFont(name: fontName, size: fontSize) ?? UIFont.systemFont(ofSize: fontSize)
if let degrees = self["Angle"].flatMap(Double.init), degrees != 0 {
let radians = CGFloat(degrees * .pi / 180.0)
#if !canImport(UIKit)
let matrix = AffineTransform(rotationByRadians: radians)
#else
let matrix = CGAffineTransform(rotationAngle: radians)
#endif
let fontDescriptor = UIFontDescriptor(name: fontName, matrix: matrix)
font = UIFont(descriptor: fontDescriptor, size: fontSize) ?? font
}
attributes[.font] = font
}
//
if let assColor = self["PrimaryColour"] {
attributes[.foregroundColor] = UIColor(assColor: assColor)
}
//
if let assColor = self["SecondaryColour"] {
// attributes[.backgroundColor] = UIColor(assColor: assColor)
}
if self["Bold"] == "1" {
attributes[.expansion] = 1
}
if self["Italic"] == "1" {
attributes[.obliqueness] = 1
}
if self["Underline"] == "1" {
attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
}
if self["StrikeOut"] == "1" {
attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
}
// if let scaleX = self["ScaleX"].flatMap(Double.init), scaleX != 100 {
// attributes[.expansion] = scaleX / 100.0
// }
// if let scaleY = self["ScaleY"].flatMap(Double.init), scaleY != 100 {
// attributes[.baselineOffset] = scaleY - 100.0
// }
// if let spacing = self["Spacing"].flatMap(Double.init) {
// attributes[.kern] = CGFloat(spacing)
// }
if self["BorderStyle"] == "1" {
if let strokeWidth = self["Outline"].flatMap(Double.init), strokeWidth > 0 {
attributes[.strokeWidth] = -strokeWidth
if let assColor = self["OutlineColour"] {
attributes[.strokeColor] = UIColor(assColor: assColor)
}
}
if let assColor = self["BackColour"],
let shadowOffset = self["Shadow"].flatMap(Double.init),
shadowOffset > 0
{
let shadow = NSShadow()
shadow.shadowOffset = CGSize(width: CGFloat(shadowOffset), height: CGFloat(shadowOffset))
shadow.shadowBlurRadius = shadowOffset
shadow.shadowColor = UIColor(assColor: assColor)
attributes[.shadow] = shadow
}
}
var textPosition = TextPosition()
textPosition.ass(alignment: self["Alignment"])
if let marginL = self["MarginL"].flatMap(Double.init) {
textPosition.leftMargin = CGFloat(marginL)
}
if let marginR = self["MarginR"].flatMap(Double.init) {
textPosition.rightMargin = CGFloat(marginR)
}
if let marginV = self["MarginV"].flatMap(Double.init) {
textPosition.verticalMargin = CGFloat(marginV)
}
return ASSStyle(attrs: attributes, textPosition: textPosition)
}
// swiftlint:enable cyclomatic_complexity
}
public class VTTParse: KSParseProtocol {
public func canParse(scanner: Scanner) -> Bool {
let result = scanner.scanString("WEBVTT")
if result != nil {
scanner.charactersToBeSkipped = nil
return true
} else {
return false
}
}
/**
00:00.430 --> 00:03.380
by Q66
*/
public func parsePart(scanner: Scanner) -> SubtitlePart? {
var timeStrs: String?
repeat {
timeStrs = scanner.scanUpToCharacters(from: .newlines)
_ = scanner.scanCharacters(from: .newlines)
} while !(timeStrs?.contains("-->") ?? false) && !scanner.isAtEnd
guard let timeStrs else {
return nil
}
let timeArray: [String] = timeStrs.components(separatedBy: "-->")
if timeArray.count == 2 {
let startString = timeArray[0]
let endString = timeArray[1]
_ = scanner.scanCharacters(from: .newlines)
var text = ""
var newLine: String? = nil
repeat {
if let str = scanner.scanUpToCharacters(from: .newlines) {
text += str
}
newLine = scanner.scanCharacters(from: .newlines)
if newLine == "\n" || newLine == "\r\n" {
text += "\n"
}
} while newLine == "\n" || newLine == "\r\n"
var textPosition = TextPosition()
return SubtitlePart(startString.parseDuration(), endString.parseDuration(), attributedString: text.build(textPosition: &textPosition))
}
return nil
}
}
public class SrtParse: KSParseProtocol {
public func canParse(scanner: Scanner) -> Bool {
let result = scanner.string.contains(" --> ")
if result {
scanner.charactersToBeSkipped = nil
}
return result
}
/**
45
00:02:52,184 --> 00:02:53,617
{\an4}
*/
public func parsePart(scanner: Scanner) -> SubtitlePart? {
var decimal: String?
repeat {
decimal = scanner.scanUpToCharacters(from: .newlines)
_ = scanner.scanCharacters(from: .newlines)
} while decimal.flatMap(Int.init) == nil
let startString = scanner.scanUpToString("-->")
// skip spaces and newlines by default.
_ = scanner.scanString("-->")
if let startString,
let endString = scanner.scanUpToCharacters(from: .newlines)
{
_ = scanner.scanCharacters(from: .newlines)
var text = ""
var newLine: String? = nil
repeat {
if let str = scanner.scanUpToCharacters(from: .newlines) {
text += str
}
newLine = scanner.scanCharacters(from: .newlines)
if newLine == "\n" || newLine == "\r\n" {
text += "\n"
}
} while newLine == "\n" || newLine == "\r\n"
var textPosition = TextPosition()
return SubtitlePart(startString.parseDuration(), endString.parseDuration(), attributedString: text.build(textPosition: &textPosition))
}
return nil
}
}

View File

@@ -0,0 +1,386 @@
//
// KSSubtitle.swift
// Pods
//
// Created by kintan on 2017/4/2.
//
//
import CoreFoundation
import CoreGraphics
import Foundation
import SwiftUI
public class SubtitlePart: CustomStringConvertible, Identifiable {
public var start: TimeInterval
public var end: TimeInterval
public var origin: CGPoint = .zero
public let text: NSAttributedString?
public var image: UIImage?
public var textPosition: TextPosition?
public var description: String {
"Subtile Group ==========\nstart: \(start)\nend:\(end)\ntext:\(String(describing: text))"
}
public convenience init(_ start: TimeInterval, _ end: TimeInterval, _ string: String) {
var text = string
text = text.trimmingCharacters(in: .whitespaces)
text = text.replacingOccurrences(of: "\r", with: "")
self.init(start, end, attributedString: NSAttributedString(string: text))
}
public init(_ start: TimeInterval, _ end: TimeInterval, attributedString: NSAttributedString?) {
self.start = start
self.end = end
text = attributedString
}
}
public struct TextPosition {
public var verticalAlign: VerticalAlignment = .bottom
public var horizontalAlign: HorizontalAlignment = .center
public var leftMargin: CGFloat = 0
public var rightMargin: CGFloat = 0
public var verticalMargin: CGFloat = 10
public var edgeInsets: EdgeInsets {
var edgeInsets = EdgeInsets()
if verticalAlign == .bottom {
edgeInsets.bottom = verticalMargin
} else if verticalAlign == .top {
edgeInsets.top = verticalMargin
}
if horizontalAlign == .leading {
edgeInsets.leading = leftMargin
}
if horizontalAlign == .trailing {
edgeInsets.trailing = rightMargin
}
return edgeInsets
}
public mutating func ass(alignment: String?) {
switch alignment {
case "1":
verticalAlign = .bottom
horizontalAlign = .leading
case "2":
verticalAlign = .bottom
horizontalAlign = .center
case "3":
verticalAlign = .bottom
horizontalAlign = .trailing
case "4":
verticalAlign = .center
horizontalAlign = .leading
case "5":
verticalAlign = .center
horizontalAlign = .center
case "6":
verticalAlign = .center
horizontalAlign = .trailing
case "7":
verticalAlign = .top
horizontalAlign = .leading
case "8":
verticalAlign = .top
horizontalAlign = .center
case "9":
verticalAlign = .top
horizontalAlign = .trailing
default:
break
}
}
}
extension SubtitlePart: Comparable {
public static func == (left: SubtitlePart, right: SubtitlePart) -> Bool {
if left.start == right.start, left.end == right.end {
return true
} else {
return false
}
}
public static func < (left: SubtitlePart, right: SubtitlePart) -> Bool {
if left.start < right.start {
return true
} else {
return false
}
}
}
extension SubtitlePart: NumericComparable {
public typealias Compare = TimeInterval
public static func == (left: SubtitlePart, right: TimeInterval) -> Bool {
left.start <= right && left.end >= right
}
public static func < (left: SubtitlePart, right: TimeInterval) -> Bool {
left.end < right
}
}
public protocol KSSubtitleProtocol {
func search(for time: TimeInterval) -> [SubtitlePart]
}
public protocol SubtitleInfo: KSSubtitleProtocol, AnyObject, Hashable, Identifiable {
var subtitleID: String { get }
var name: String { get }
var delay: TimeInterval { get set }
// var userInfo: NSMutableDictionary? { get set }
// var subtitleDataSouce: SubtitleDataSouce? { get set }
// var comment: String? { get }
var isEnabled: Bool { get set }
}
public extension SubtitleInfo {
var id: String { subtitleID }
func hash(into hasher: inout Hasher) {
hasher.combine(subtitleID)
}
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.subtitleID == rhs.subtitleID
}
}
public class KSSubtitle {
public var parts: [SubtitlePart] = []
public init() {}
}
extension KSSubtitle: KSSubtitleProtocol {
/// Search for target group for time
public func search(for time: TimeInterval) -> [SubtitlePart] {
var result = [SubtitlePart]()
for part in parts {
if part == time {
result.append(part)
} else if part.start > time {
break
}
}
return result
}
}
public extension KSSubtitle {
func parse(url: URL, userAgent: String? = nil, encoding: String.Encoding? = nil) async throws {
let data = try await url.data(userAgent: userAgent)
try parse(data: data, encoding: encoding)
}
func parse(data: Data, encoding: String.Encoding? = nil) throws {
var string: String?
let encodes = [encoding ?? String.Encoding.utf8,
String.Encoding(rawValue: CFStringConvertEncodingToNSStringEncoding(CFStringEncoding(CFStringEncodings.big5.rawValue))),
String.Encoding(rawValue: CFStringConvertEncodingToNSStringEncoding(CFStringEncoding(CFStringEncodings.GB_18030_2000.rawValue))),
String.Encoding.unicode]
for encode in encodes {
string = String(data: data, encoding: encode)
if string != nil {
break
}
}
guard let subtitle = string else {
throw NSError(errorCode: .subtitleUnEncoding)
}
let scanner = Scanner(string: subtitle)
_ = scanner.scanCharacters(from: .controlCharacters)
let parse = KSOptions.subtitleParses.first { $0.canParse(scanner: scanner) }
if let parse {
parts = parse.parse(scanner: scanner)
if parts.count == 0 {
throw NSError(errorCode: .subtitleUnParse)
}
} else {
throw NSError(errorCode: .subtitleFormatUnSupport)
}
}
// public static func == (lhs: KSURLSubtitle, rhs: KSURLSubtitle) -> Bool {
// lhs.url == rhs.url
// }
}
public protocol NumericComparable {
associatedtype Compare
static func < (lhs: Self, rhs: Compare) -> Bool
static func == (lhs: Self, rhs: Compare) -> Bool
}
extension Collection where Element: NumericComparable {
func binarySearch(key: Element.Compare) -> Self.Index? {
var lowerBound = startIndex
var upperBound = endIndex
while lowerBound < upperBound {
let midIndex = index(lowerBound, offsetBy: distance(from: lowerBound, to: upperBound) / 2)
if self[midIndex] == key {
return midIndex
} else if self[midIndex] < key {
lowerBound = index(lowerBound, offsetBy: 1)
} else {
upperBound = midIndex
}
}
return nil
}
}
open class SubtitleModel: ObservableObject {
public enum Size {
case smaller
case standard
case large
public var rawValue: CGFloat {
switch self {
case .smaller:
#if os(tvOS) || os(xrOS)
return 48
#elseif os(macOS) || os(xrOS)
return 20
#else
if UI_USER_INTERFACE_IDIOM() == .phone {
return 12
} else {
return 20
}
#endif
case .standard:
#if os(tvOS) || os(xrOS)
return 58
#elseif os(macOS) || os(xrOS)
return 26
#else
if UI_USER_INTERFACE_IDIOM() == .phone {
return 16
} else {
return 26
}
#endif
case .large:
#if os(tvOS) || os(xrOS)
return 68
#elseif os(macOS) || os(xrOS)
return 32
#else
if UI_USER_INTERFACE_IDIOM() == .phone {
return 20
} else {
return 32
}
#endif
}
}
}
public static var textColor: Color = .white
public static var textBackgroundColor: Color = .clear
public static var textFont: UIFont {
textBold ? .boldSystemFont(ofSize: textFontSize) : .systemFont(ofSize: textFontSize)
}
public static var textFontSize = SubtitleModel.Size.standard.rawValue
public static var textBold = false
public static var textItalic = false
public static var textPosition = TextPosition()
public static var audioRecognizes = [any AudioRecognize]()
private var subtitleDataSouces: [SubtitleDataSouce] = KSOptions.subtitleDataSouces
@Published
public private(set) var subtitleInfos = [any SubtitleInfo]()
@Published
public private(set) var parts = [SubtitlePart]()
public var subtitleDelay = 0.0 // s
public var url: URL? {
didSet {
subtitleInfos.removeAll()
searchSubtitle(query: nil, languages: [])
if url != nil {
subtitleInfos.append(contentsOf: SubtitleModel.audioRecognizes)
}
for datasouce in subtitleDataSouces {
addSubtitle(dataSouce: datasouce)
}
// asyncUIPublishe
DispatchQueue.main.async { [weak self] in
self?.parts = []
self?.selectedSubtitleInfo = nil
}
}
}
@Published
public var selectedSubtitleInfo: (any SubtitleInfo)? {
didSet {
oldValue?.isEnabled = false
selectedSubtitleInfo?.isEnabled = true
if let url, let info = selectedSubtitleInfo as? URLSubtitleInfo, !info.downloadURL.isFileURL, let cache = subtitleDataSouces.first(where: { $0 is CacheSubtitleDataSouce }) as? CacheSubtitleDataSouce {
cache.addCache(fileURL: url, downloadURL: info.downloadURL)
}
}
}
public init() {}
public func addSubtitle(info: any SubtitleInfo) {
if subtitleInfos.first(where: { $0.subtitleID == info.subtitleID }) == nil {
subtitleInfos.append(info)
}
}
public func subtitle(currentTime: TimeInterval) -> Bool {
var newParts = [SubtitlePart]()
if let subtile = selectedSubtitleInfo {
let currentTime = currentTime - subtile.delay - subtitleDelay
newParts = subtile.search(for: currentTime)
if newParts.isEmpty {
newParts = parts.filter { part in
part == currentTime
}
}
}
// swiftUI
if newParts != parts {
for part in newParts {
if let text = part.text as? NSMutableAttributedString {
text.addAttributes([.font: SubtitleModel.textFont],
range: NSRange(location: 0, length: text.length))
}
}
parts = newParts
return true
} else {
return false
}
}
public func searchSubtitle(query: String?, languages: [String]) {
for dataSouce in subtitleDataSouces {
if let dataSouce = dataSouce as? SearchSubtitleDataSouce {
subtitleInfos.removeAll { info in
dataSouce.infos.contains {
$0 === info
}
}
Task { @MainActor in
try? await dataSouce.searchSubtitle(query: query, languages: languages)
subtitleInfos.append(contentsOf: dataSouce.infos)
}
}
}
}
public func addSubtitle(dataSouce: SubtitleDataSouce) {
if let dataSouce = dataSouce as? FileURLSubtitleDataSouce {
Task { @MainActor in
try? await dataSouce.searchSubtitle(fileURL: url)
subtitleInfos.append(contentsOf: dataSouce.infos)
}
} else {
subtitleInfos.append(contentsOf: dataSouce.infos)
}
}
}

View File

@@ -0,0 +1,404 @@
//
// 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
}
}