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:
12
KSPlayer-main/Sources/KSPlayer/Subtitle/AudioRecognize.swift
Normal file
12
KSPlayer-main/Sources/KSPlayer/Subtitle/AudioRecognize.swift
Normal 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)
|
||||
}
|
||||
422
KSPlayer-main/Sources/KSPlayer/Subtitle/KSParseProtocol.swift
Normal file
422
KSPlayer-main/Sources/KSPlayer/Subtitle/KSParseProtocol.swift
Normal 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
|
||||
}
|
||||
}
|
||||
386
KSPlayer-main/Sources/KSPlayer/Subtitle/KSSubtitle.swift
Normal file
386
KSPlayer-main/Sources/KSPlayer/Subtitle/KSSubtitle.swift
Normal 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)
|
||||
}
|
||||
// 要用async,不能在更新UI的时候,修改Publishe变量
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
404
KSPlayer-main/Sources/KSPlayer/Subtitle/SubtitleDataSouce.swift
Normal file
404
KSPlayer-main/Sources/KSPlayer/Subtitle/SubtitleDataSouce.swift
Normal 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
|
||||
// 因为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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user