feat: play resolved Miruro HLS with external VTT and stable episode progress
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
import Foundation
|
||||
|
||||
/// Immutable, reusable subtitles; times are acknowledged player positions, not wall-clock time.
|
||||
struct WebVTTDocument: Sendable {
|
||||
struct Cue: Sendable, Equatable {
|
||||
let start: Double
|
||||
let end: Double
|
||||
let text: String
|
||||
}
|
||||
|
||||
let cues: [Cue]
|
||||
private let maximumEnds: [Double]
|
||||
static let byteLimit = 4 * 1024 * 1024
|
||||
|
||||
private enum SubtitleError: LocalizedError {
|
||||
case request, transport, content
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .request: "Could not load subtitles. The subtitle URL or headers are invalid."
|
||||
case .transport: "Could not load subtitles. Check the connection or choose another subtitle track."
|
||||
case .content: "The subtitle resource is not valid WebVTT."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func load(url: String, headers: String) async throws -> WebVTTDocument {
|
||||
let request = try request(url: url, headers: headers)
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.timeoutIntervalForRequest = 15
|
||||
configuration.timeoutIntervalForResource = 20
|
||||
configuration.httpCookieStorage = nil
|
||||
configuration.urlCredentialStorage = nil
|
||||
configuration.urlCache = nil
|
||||
let session = URLSession(configuration: configuration, delegate: SubtitleRedirects(request: request), delegateQueue: nil)
|
||||
defer { session.invalidateAndCancel() }
|
||||
let data: Data
|
||||
do {
|
||||
let (bytes, response) = try await session.bytes(for: request)
|
||||
guard let response = response as? HTTPURLResponse,
|
||||
(200...299).contains(response.statusCode), response.expectedContentLength <= Int64(byteLimit) else {
|
||||
throw SubtitleError.transport
|
||||
}
|
||||
var buffer = Data()
|
||||
if response.expectedContentLength > 0 { buffer.reserveCapacity(Int(response.expectedContentLength)) }
|
||||
for try await byte in bytes {
|
||||
guard buffer.count < byteLimit else { throw SubtitleError.transport }
|
||||
buffer.append(byte)
|
||||
}
|
||||
data = buffer
|
||||
} catch {
|
||||
if Task.isCancelled { throw CancellationError() }
|
||||
throw SubtitleError.transport
|
||||
}
|
||||
// Providers sometimes disguise VTT resources as images: trust the explicit format and content,
|
||||
// never the path extension or Content-Type.
|
||||
guard let text = String(data: data, encoding: .utf8) else { throw SubtitleError.content }
|
||||
return try parse(text)
|
||||
}
|
||||
|
||||
static func parse(_ text: String) throws -> WebVTTDocument {
|
||||
guard text.utf8.count <= byteLimit, !text.contains("\0") else { throw SubtitleError.content }
|
||||
var normalized = text
|
||||
if normalized.first == "\u{FEFF}" { normalized.removeFirst() }
|
||||
normalized = normalized.replacingOccurrences(of: "\r\n", with: "\n").replacingOccurrences(of: "\r", with: "\n")
|
||||
let lines = normalized.components(separatedBy: "\n")
|
||||
guard let header = lines.first,
|
||||
header == "WEBVTT" || header.hasPrefix("WEBVTT ") || header.hasPrefix("WEBVTT\t"),
|
||||
!header.contains("-->") else { throw SubtitleError.content }
|
||||
var index = 1
|
||||
// Header metadata ends at the first blank line. A cue without this separator is malformed.
|
||||
while index < lines.count && !lines[index].isEmpty {
|
||||
guard !lines[index].contains("-->") else { throw SubtitleError.content }
|
||||
index += 1
|
||||
}
|
||||
var cues: [Cue] = []
|
||||
var maximumEnds: [Double] = []
|
||||
while index < lines.count {
|
||||
while index < lines.count && lines[index].isEmpty { index += 1 }
|
||||
let beginning = index
|
||||
while index < lines.count && !lines[index].isEmpty { index += 1 }
|
||||
guard beginning < index else { break }
|
||||
let block = lines[beginning..<index]
|
||||
let first = lines[beginning]
|
||||
if first == "NOTE" || first.hasPrefix("NOTE ") || first.hasPrefix("NOTE\t") { continue }
|
||||
if first == "STYLE" || first == "REGION" {
|
||||
guard cues.isEmpty, !block.contains(where: { $0.contains("-->") }) else { throw SubtitleError.content }
|
||||
continue // ponytail: plain-text cues; add layout/styling when positional subtitles required.
|
||||
}
|
||||
var timingIndex = beginning
|
||||
if !first.contains("-->") { timingIndex += 1 } // Optional cue identifier.
|
||||
guard timingIndex < index else { throw SubtitleError.content }
|
||||
let timing = lines[timingIndex].split(whereSeparator: { $0 == " " || $0 == "\t" })
|
||||
guard timing.count >= 3, timing[1] == "-->",
|
||||
let start = timestamp(String(timing[0])), let end = timestamp(String(timing[2])),
|
||||
end >= start, start >= (cues.last?.start ?? 0) else { throw SubtitleError.content }
|
||||
// Equal timestamps produce an inactive cue, not a failure of the entire track.
|
||||
try validateSettings(timing.dropFirst(3))
|
||||
let payload = lines[(timingIndex + 1)..<index].joined(separator: "\n")
|
||||
guard !payload.contains("-->") else { throw SubtitleError.content }
|
||||
let cue = Cue(start: start, end: end, text: try plainText(payload))
|
||||
cues.append(cue)
|
||||
maximumEnds.append(max(end, maximumEnds.last ?? 0))
|
||||
}
|
||||
return WebVTTDocument(cues: cues, maximumEnds: maximumEnds)
|
||||
}
|
||||
|
||||
func text(at acknowledgedSeconds: Double) -> String {
|
||||
guard acknowledgedSeconds.isFinite, acknowledgedSeconds >= 0 else { return "" }
|
||||
// Binary-search starts, then inspect only the potentially overlapping interval. No cursor:
|
||||
// backward seeks and switching to an already-loaded track need no reset.
|
||||
var low = 0
|
||||
var high = cues.count
|
||||
while low < high {
|
||||
let middle = low + (high - low) / 2
|
||||
if cues[middle].start <= acknowledgedSeconds { low = middle + 1 } else { high = middle }
|
||||
}
|
||||
var active: [String] = []
|
||||
var index = low
|
||||
while index > 0 {
|
||||
index -= 1
|
||||
if maximumEnds[index] <= acknowledgedSeconds { break }
|
||||
if cues[index].end > acknowledgedSeconds && !cues[index].text.isEmpty { active.append(cues[index].text) }
|
||||
}
|
||||
return active.reversed().joined(separator: "\n")
|
||||
}
|
||||
|
||||
// Shared with the focused check: these are the exact request and redirect policies used above.
|
||||
static func request(url text: String, headers: String) throws -> URLRequest {
|
||||
guard text.utf8.count <= 32768,
|
||||
!text.unicodeScalars.contains(where: { CharacterSet.whitespacesAndNewlines.contains($0) || CharacterSet.controlCharacters.contains($0) }),
|
||||
let components = URLComponents(string: text),
|
||||
["http", "https"].contains(components.scheme?.lowercased() ?? ""),
|
||||
let host = components.host, !host.isEmpty,
|
||||
components.user == nil, components.password == nil, components.fragment == nil,
|
||||
components.port == nil || (1...65535).contains(components.port!),
|
||||
let url = components.url, headers.utf8.count <= 65536,
|
||||
headers.isEmpty || headers.hasSuffix("\r\n") else { throw SubtitleError.request }
|
||||
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15)
|
||||
request.httpShouldHandleCookies = false
|
||||
var names = Set<String>()
|
||||
let lines = headers.isEmpty ? [] : headers.components(separatedBy: "\r\n").dropLast().map { $0 }
|
||||
guard lines.count <= 64 else { throw SubtitleError.request }
|
||||
for line in lines {
|
||||
guard let colon = line.firstIndex(of: ":") else { throw SubtitleError.request }
|
||||
let name = String(line[..<colon])
|
||||
var value = String(line[line.index(after: colon)...])
|
||||
// ResolvedSource serializes exactly one separator space; the resource value is verbatim.
|
||||
if value.first == " " { value.removeFirst() }
|
||||
guard !name.isEmpty, name.utf8.count <= 256,
|
||||
name.utf8.allSatisfy({ (65...90).contains($0) || (97...122).contains($0) || (48...57).contains($0) || "!#$%&'*+-.^_`|~".utf8.contains($0) }),
|
||||
names.insert(name.lowercased()).inserted, value.utf8.count <= 16384,
|
||||
!value.unicodeScalars.contains(where: { ($0.value < 32 && $0.value != 9) || $0.value == 127 }) else { throw SubtitleError.request }
|
||||
request.setValue(value, forHTTPHeaderField: name)
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
static func permitsRedirect(from source: URL, to destination: URL) -> Bool {
|
||||
guard let a = URLComponents(url: source, resolvingAgainstBaseURL: false),
|
||||
let b = URLComponents(url: destination, resolvingAgainstBaseURL: false),
|
||||
let scheme = a.scheme?.lowercased(), ["http", "https"].contains(scheme),
|
||||
b.scheme?.lowercased() == scheme, a.host?.lowercased() == b.host?.lowercased(),
|
||||
(a.port ?? (scheme == "https" ? 443 : 80)) == (b.port ?? (scheme == "https" ? 443 : 80)),
|
||||
b.user == nil, b.password == nil, b.fragment == nil else { return false }
|
||||
// Reject rather than guess which resource-specific headers contain credentials.
|
||||
return true
|
||||
}
|
||||
|
||||
private static func timestamp(_ text: String) -> Double? {
|
||||
let fields = text.split(separator: ":", omittingEmptySubsequences: false)
|
||||
guard fields.count == 2 || fields.count == 3 else { return nil }
|
||||
let seconds = fields.last!.split(separator: ".", omittingEmptySubsequences: false)
|
||||
guard seconds.count == 2, seconds[0].utf8.count == 2, seconds[1].utf8.count == 3,
|
||||
fields.dropLast().allSatisfy({ $0.utf8.count >= 2 }),
|
||||
(fields.dropLast().map(String.init) + seconds.map(String.init)).allSatisfy({ $0.utf8.allSatisfy { (48...57).contains($0) } }),
|
||||
let second = Double(seconds[0]), second < 60,
|
||||
let milliseconds = Double(seconds[1]), let minute = Double(fields[fields.count - 2]),
|
||||
minute < 60, fields[fields.count - 2].utf8.count == 2 else { return nil }
|
||||
let hours = fields.count == 3 ? Double(fields[0]) : 0
|
||||
guard let hours else { return nil }
|
||||
let result = hours * 3600 + minute * 60 + second + milliseconds / 1000
|
||||
return result.isFinite ? result : nil
|
||||
}
|
||||
|
||||
private static func validateSettings(_ settings: ArraySlice<Substring>) throws {
|
||||
func percent(_ text: Substring) -> Bool {
|
||||
guard text.last == "%" else { return false }
|
||||
let number = text.dropLast()
|
||||
guard !number.isEmpty, number.utf8.allSatisfy({ (48...57).contains($0) || $0 == 46 }),
|
||||
let value = Double(number) else { return false }
|
||||
return (0...100).contains(value)
|
||||
}
|
||||
var names = Set<Substring>()
|
||||
for setting in settings {
|
||||
let pair = setting.split(separator: ":", omittingEmptySubsequences: false)
|
||||
guard pair.count == 2, !pair[1].isEmpty, names.insert(pair[0]).inserted else { throw SubtitleError.content }
|
||||
let values = pair[1].split(separator: ",", omittingEmptySubsequences: false)
|
||||
let valid: Bool
|
||||
switch pair[0] {
|
||||
case "vertical": valid = values.count == 1 && ["rl", "lr"].contains(pair[1])
|
||||
case "align": valid = values.count == 1 && ["start", "center", "end", "left", "right"].contains(pair[1])
|
||||
case "size": valid = values.count == 1 && percent(pair[1])
|
||||
case "region": valid = values.count == 1
|
||||
case "position":
|
||||
valid = (values.count == 1 || values.count == 2) && percent(values[0]) && (values.count == 1 || ["line-left", "center", "line-right", "auto"].contains(values[1]))
|
||||
case "line":
|
||||
let number = values[0].first == "-" ? values[0].dropFirst() : values[0][...]
|
||||
let integer = !number.isEmpty && number.utf8.allSatisfy { (48...57).contains($0) }
|
||||
valid = (values.count == 1 || values.count == 2) && (values[0] == "auto" || percent(values[0]) || integer) && (values.count == 1 || ["start", "center", "end"].contains(values[1]))
|
||||
default: valid = false
|
||||
}
|
||||
guard valid else { throw SubtitleError.content }
|
||||
}
|
||||
}
|
||||
|
||||
private static func plainText(_ payload: String) throws -> String {
|
||||
var result = ""
|
||||
var index = payload.startIndex
|
||||
let entities: [String: String] = ["amp": "&", "lt": "<", "gt": ">", "nbsp": "\u{A0}", "lrm": "\u{200E}", "rlm": "\u{200F}", "quot": "\"", "apos": "'"]
|
||||
while index < payload.endIndex {
|
||||
if payload[index] == "<" {
|
||||
guard let end = payload[index...].firstIndex(of: ">") else { throw SubtitleError.content }
|
||||
var tag = String(payload[payload.index(after: index)..<end])
|
||||
if tag.first == "/" { tag.removeFirst() }
|
||||
let name = tag.prefix { $0 != " " && $0 != "\t" && $0 != "." }
|
||||
guard ["b", "i", "u", "c", "v", "lang", "ruby", "rt"].contains(String(name)) || timestamp(tag) != nil else { throw SubtitleError.content }
|
||||
index = payload.index(after: end)
|
||||
} else if payload[index] == "&", let end = payload[index...].firstIndex(of: ";"), payload.distance(from: index, to: end) <= 16 {
|
||||
let entity = String(payload[payload.index(after: index)..<end])
|
||||
var decoded = entities[entity]
|
||||
if entity.hasPrefix("#") {
|
||||
let hexadecimal = entity.hasPrefix("#x") || entity.hasPrefix("#X")
|
||||
let digits = entity.dropFirst(hexadecimal ? 2 : 1)
|
||||
if let number = UInt32(digits, radix: hexadecimal ? 16 : 10), number != 0, let scalar = UnicodeScalar(number) { decoded = String(scalar) }
|
||||
}
|
||||
if let decoded {
|
||||
result += decoded
|
||||
index = payload.index(after: end)
|
||||
} else {
|
||||
result.append(payload[index])
|
||||
index = payload.index(after: index)
|
||||
}
|
||||
} else {
|
||||
result.append(payload[index])
|
||||
index = payload.index(after: index)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
private final class SubtitleRedirects: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
|
||||
private let request: URLRequest
|
||||
private let lock = NSLock()
|
||||
private var count = 0
|
||||
|
||||
init(request: URLRequest) { self.request = request }
|
||||
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse,
|
||||
newRequest: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {
|
||||
lock.lock()
|
||||
count += 1
|
||||
let withinLimit = count <= 5
|
||||
lock.unlock()
|
||||
guard withinLimit, let source = request.url, let destination = newRequest.url,
|
||||
WebVTTDocument.permitsRedirect(from: source, to: destination) else {
|
||||
completionHandler(nil)
|
||||
return
|
||||
}
|
||||
var redirected = newRequest
|
||||
redirected.httpShouldHandleCookies = false
|
||||
for (name, value) in request.allHTTPHeaderFields ?? [:] { redirected.setValue(value, forHTTPHeaderField: name) }
|
||||
completionHandler(redirected)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
|
||||
struct ResolvedSubtitle: Decodable, Sendable {
|
||||
let url: String
|
||||
let format: String
|
||||
let language: String
|
||||
let label: String
|
||||
let isDefault: Bool
|
||||
private let headerFields: [String: String]
|
||||
var headers: String {
|
||||
headerFields.sorted { $0.key < $1.key }.map { "\($0.key): \($0.value)\r\n" }.joined()
|
||||
}
|
||||
fileprivate var validatedHeaders: [String: String] { headerFields }
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case url, format, language, label
|
||||
case isDefault = "default"
|
||||
case headerFields = "headers"
|
||||
}
|
||||
}
|
||||
|
||||
struct ResolvedSource: Sendable {
|
||||
let url: String
|
||||
let headers: String
|
||||
let identity: String
|
||||
let title: String
|
||||
let subtitles: [ResolvedSubtitle]
|
||||
|
||||
private struct Document: Decodable {
|
||||
struct Identity: Decodable {
|
||||
let provider: String
|
||||
let media_id: String
|
||||
let episode: Int
|
||||
}
|
||||
let version: Int
|
||||
let url: String
|
||||
let headers: [String: String]
|
||||
let identity: Identity
|
||||
let title: String?
|
||||
let subtitles: [ResolvedSubtitle]
|
||||
}
|
||||
|
||||
private struct InvalidSource: LocalizedError {
|
||||
var errorDescription: String? { "Invalid or unreadable resolved source file." }
|
||||
}
|
||||
|
||||
static func load(from file: URL) throws -> ResolvedSource {
|
||||
do {
|
||||
guard file.isFileURL else { throw InvalidSource() }
|
||||
let descriptor: Int32 = file.withUnsafeFileSystemRepresentation { path in
|
||||
guard let path else { return -1 }
|
||||
return Darwin.open(path, O_RDONLY | O_NONBLOCK | O_CLOEXEC)
|
||||
}
|
||||
guard descriptor >= 0 else { throw InvalidSource() }
|
||||
var info = stat()
|
||||
guard fstat(descriptor, &info) == 0, (info.st_mode & S_IFMT) == S_IFREG else {
|
||||
Darwin.close(descriptor)
|
||||
throw InvalidSource()
|
||||
}
|
||||
let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: true)
|
||||
defer { try? handle.close() }
|
||||
let data = try handle.read(upToCount: 1_048_577) ?? Data()
|
||||
guard data.count <= 1_048_576 else { throw InvalidSource() }
|
||||
let document = try JSONDecoder().decode(Document.self, from: data)
|
||||
let id = document.identity
|
||||
guard document.version == 1, id.provider == "miruro",
|
||||
!id.media_id.isEmpty, id.media_id.utf8.count <= 512,
|
||||
id.media_id.utf8.allSatisfy({ isIDByte($0) }),
|
||||
id.episode > 0, id.episode <= Int(Int32.max),
|
||||
validURL(document.url), validHeaders(document.headers),
|
||||
validText(document.title ?? "", limit: 4096),
|
||||
document.subtitles.count <= 32,
|
||||
document.subtitles.filter({ $0.isDefault }).count <= 1 else { throw InvalidSource() }
|
||||
for subtitle in document.subtitles {
|
||||
guard validURL(subtitle.url), subtitle.format == "vtt",
|
||||
validText(subtitle.language, limit: 128), !subtitle.language.isEmpty,
|
||||
validText(subtitle.label, limit: 4096), !subtitle.label.isEmpty,
|
||||
validHeaders(subtitle.validatedHeaders) else { throw InvalidSource() }
|
||||
}
|
||||
let headers = document.headers.sorted { $0.key < $1.key }
|
||||
.map { "\($0.key): \($0.value)\r\n" }.joined()
|
||||
return ResolvedSource(url: document.url, headers: headers,
|
||||
identity: "miruro:\(id.media_id):\(id.episode)", title: document.title ?? "Miruro episode \(id.episode)",
|
||||
subtitles: document.subtitles)
|
||||
} catch { throw InvalidSource() }
|
||||
}
|
||||
|
||||
private static func isIDByte(_ byte: UInt8) -> Bool {
|
||||
(65...90).contains(byte) || (97...122).contains(byte) ||
|
||||
(48...57).contains(byte) || byte == 45 || byte == 95
|
||||
}
|
||||
|
||||
private static func validText(_ text: String, limit: Int) -> Bool {
|
||||
text.utf8.count <= limit && !text.unicodeScalars.contains { $0.value < 32 || $0.value == 127 }
|
||||
}
|
||||
|
||||
private static func validURL(_ text: String) -> Bool {
|
||||
guard !text.isEmpty, validText(text, limit: 32768),
|
||||
!text.contains(where: { $0.isWhitespace }),
|
||||
let components = URLComponents(string: text),
|
||||
components.scheme == "https" || components.scheme == "http",
|
||||
let host = components.host, !host.isEmpty,
|
||||
components.user == nil, components.password == nil,
|
||||
components.fragment == nil, components.url != nil else { return false }
|
||||
return components.port == nil || (1...65535).contains(components.port!)
|
||||
}
|
||||
|
||||
private static func validHeaders(_ headers: [String: String]) -> Bool {
|
||||
guard headers.count <= 64 else { return false }
|
||||
var names = Set<String>()
|
||||
var size = 0
|
||||
for (name, value) in headers {
|
||||
guard !name.isEmpty, name.utf8.count <= 256,
|
||||
name.utf8.allSatisfy({ isIDByte($0) || "!#$%&'*+.^`|~".utf8.contains($0) }),
|
||||
names.insert(name.lowercased()).inserted,
|
||||
value.utf8.count <= 16384,
|
||||
!value.unicodeScalars.contains(where: { $0.value < 32 || $0.value == 127 }) else { return false }
|
||||
size += name.utf8.count + value.utf8.count + 4
|
||||
}
|
||||
return size <= 65536
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,12 @@ final class PlayerModel: ObservableObject {
|
||||
@Published private(set) var youtubeQuality: Int?
|
||||
@Published private(set) var automaticYouTubeQuality = false
|
||||
@Published private(set) var showsBufferInfo = false
|
||||
@Published private(set) var subtitleTracks: [ResolvedSubtitle] = []
|
||||
@Published private(set) var selectedSubtitle: Int?
|
||||
@Published private(set) var subtitleText = ""
|
||||
@Published private(set) var subtitleError: String?
|
||||
private var subtitleDocument: WebVTTDocument?
|
||||
private var subtitleTask: Task<Void, Never>?
|
||||
@Published var seekPosition = 0.0
|
||||
@Published var isSeeking = false
|
||||
@Published private(set) var volume = 1.0
|
||||
@@ -218,6 +224,45 @@ final class PlayerModel: ObservableObject {
|
||||
resolveYouTube(url, height: nil)
|
||||
}
|
||||
|
||||
func openResolvedSource(_ file: URL) {
|
||||
do {
|
||||
let source = try ResolvedSource.load(from: file)
|
||||
cancelYouTubeResolution()
|
||||
openMedia(.resolved(source))
|
||||
} catch {
|
||||
commandError = error.localizedDescription
|
||||
revealControls()
|
||||
}
|
||||
}
|
||||
|
||||
func selectSubtitle(_ index: Int?) {
|
||||
subtitleTask?.cancel()
|
||||
subtitleTask = nil
|
||||
subtitleDocument = nil
|
||||
subtitleText = ""
|
||||
subtitleError = nil
|
||||
selectedSubtitle = index
|
||||
guard let index, subtitleTracks.indices.contains(index) else { return }
|
||||
let track = subtitleTracks[index]
|
||||
subtitleTask = Task { [weak self] in
|
||||
do {
|
||||
let document = try await WebVTTDocument.load(url: track.url, headers: track.headers)
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
self.subtitleDocument = document
|
||||
self.updateSubtitle()
|
||||
} catch {
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
self.subtitleError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateSubtitle() {
|
||||
subtitleText = state.seek_pending || transition.seekPending || isBusy ||
|
||||
state.status == PLAYER_EMPTY || state.status == PLAYER_ERROR ? "" :
|
||||
subtitleDocument?.text(at: state.position) ?? ""
|
||||
}
|
||||
|
||||
func setShowsBufferInfo(_ enabled: Bool) {
|
||||
showsBufferInfo = enabled
|
||||
revealControls()
|
||||
@@ -338,11 +383,24 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
case .resolved(let source):
|
||||
accepted = source.identity.withCString { identity in
|
||||
source.url.withCString { url in
|
||||
source.headers.withCString { headers in
|
||||
var input = PlayerNetworkSource(video_url: url, audio_url: nil,
|
||||
video_headers: headers, audio_headers: nil, duration: 0, is_live: false,
|
||||
media_url: identity, initial_position: savedPosition ?? 0)
|
||||
return player_open_network(handle, &input)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
guard accepted else {
|
||||
switch source {
|
||||
case .file:
|
||||
commandError = "Could not queue the file to open. Please try again."
|
||||
case .resolved:
|
||||
commandError = "Could not queue the resolved stream to open."
|
||||
case .youtube:
|
||||
failYouTubeChange(change)
|
||||
}
|
||||
@@ -363,6 +421,19 @@ final class PlayerModel: ObservableObject {
|
||||
youtubeHeights = video.availableHeights
|
||||
youtubeHeight = video.height
|
||||
youtubeQuality = height
|
||||
case .resolved(let source):
|
||||
fileName = source.title
|
||||
fileIcon = nil
|
||||
youtubeHeights = []
|
||||
youtubeHeight = nil
|
||||
youtubeQuality = nil
|
||||
}
|
||||
selectSubtitle(nil)
|
||||
if case .resolved(let resolved) = source {
|
||||
subtitleTracks = resolved.subtitles
|
||||
selectSubtitle(subtitleTracks.firstIndex(where: \.isDefault))
|
||||
} else {
|
||||
subtitleTracks = []
|
||||
}
|
||||
needsVideoFit = !change.isQuality
|
||||
commandError = transition.needsLocalRestoration || !change.playing || player_play(handle) ? nil :
|
||||
@@ -429,6 +500,8 @@ final class PlayerModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
transition.closeMedia()
|
||||
selectSubtitle(nil)
|
||||
subtitleTracks = []
|
||||
compactIntent = nil
|
||||
compactWindow.restore(resumeFullScreen: false, animated: false)
|
||||
fileName = nil
|
||||
@@ -744,6 +817,7 @@ final class PlayerModel: ObservableObject {
|
||||
guard observation.ready else { return }
|
||||
defer { systemPlayback?.update() }
|
||||
state = next
|
||||
updateSubtitle()
|
||||
if let playing = observation.playback { setPlayback(playing: playing) }
|
||||
if !isBusy && (next.status == PLAYER_EMPTY || next.status == PLAYER_ERROR || !next.has_video) {
|
||||
compactWindow.restore(resumeFullScreen: false, animated: false)
|
||||
@@ -792,6 +866,7 @@ final class PlayerModel: ObservableObject {
|
||||
func shutdown() {
|
||||
saveProgress()
|
||||
cancelYouTubeResolution()
|
||||
selectSubtitle(nil)
|
||||
systemPlayback?.shutdown()
|
||||
systemPlayback = nil
|
||||
compactWindow.restore(resumeFullScreen: false, animated: false)
|
||||
@@ -834,11 +909,13 @@ final class PlayerModel: ObservableObject {
|
||||
private enum MediaSource {
|
||||
case file(URL)
|
||||
case youtube(YouTubeVideo, height: Int?)
|
||||
case resolved(ResolvedSource)
|
||||
|
||||
var url: URL {
|
||||
switch self {
|
||||
case .file(let url): return url
|
||||
case .youtube(let video, _): return video.webpageURL
|
||||
case .resolved(let source): return URL(string: source.identity)!
|
||||
}
|
||||
}
|
||||
var live: Bool {
|
||||
@@ -1555,6 +1632,40 @@ private struct PlayerContent: View {
|
||||
if model.isCompact {
|
||||
CompactDragSurface(model: model).accessibilityHidden(true)
|
||||
}
|
||||
if !model.subtitleText.isEmpty {
|
||||
Text(model.subtitleText)
|
||||
.font(.system(size: max(16, min(28, geometry.size.width / 32)), weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(8)
|
||||
.background(.black.opacity(0.75), in: RoundedRectangle(cornerRadius: 4))
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, model.controlsVisible ? 110 : 24)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
if !model.subtitleTracks.isEmpty && (model.controlsVisible || model.subtitleError != nil) {
|
||||
VStack(alignment: .trailing) {
|
||||
Menu {
|
||||
Button("Off") { model.selectSubtitle(nil) }
|
||||
ForEach(model.subtitleTracks.indices, id: \.self) { index in
|
||||
Button {
|
||||
model.selectSubtitle(index)
|
||||
} label: {
|
||||
Label(model.subtitleTracks[index].label + " (" + model.subtitleTracks[index].language + ")",
|
||||
systemImage: model.selectedSubtitle == index ? "checkmark" : "text.bubble")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "captions.bubble").accessibilityLabel("Subtitles")
|
||||
}
|
||||
if let error = model.subtitleError {
|
||||
Text(error).font(.caption).foregroundStyle(.yellow)
|
||||
}
|
||||
}
|
||||
.padding(.top, 44).padding(.trailing, 16)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
if let error = model.errorText {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "exclamationmark.triangle").font(.system(size: 32)).foregroundStyle(.yellow)
|
||||
@@ -1918,6 +2029,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
let url = URL(string: arguments[index + 1]) {
|
||||
PlayerModel.shared.open(url)
|
||||
}
|
||||
if let index = arguments.firstIndex(of: "--source-file"), index + 1 < arguments.count {
|
||||
PlayerModel.shared.openResolvedSource(URL(fileURLWithPath: arguments[index + 1]))
|
||||
}
|
||||
}
|
||||
|
||||
// Let SwiftUI create the initial window for a Finder/document launch.
|
||||
|
||||
@@ -4,22 +4,25 @@ enum PlayerCommand {
|
||||
case help
|
||||
case printDefaultConfig
|
||||
case launch(URL?, newInstance: Bool, noConfig: Bool, progressFile: URL?)
|
||||
case launchSource(sourceFile: URL, newInstance: Bool, noConfig: Bool, progressFile: URL?)
|
||||
|
||||
static let usage = """
|
||||
Usage: terebi [--new-instance] [--no-config] [FILE | YOUTUBE_URL]
|
||||
terebi [--new-instance] [--no-config] --url YOUTUBE_URL
|
||||
terebi --new-instance --url YOUTUBE_URL --progress-file PATH
|
||||
terebi --new-instance --source-file PATH [--progress-file PATH]
|
||||
terebi [--new-instance] [--no-config] -- FILE
|
||||
terebi --help
|
||||
terebi --default-config
|
||||
|
||||
Open one local media file or YouTube video and start playback, resuming saved
|
||||
video progress when available. With no arguments, open or activate the player.
|
||||
Open one local file, YouTube video or resolved source and start playback,
|
||||
resuming saved video progress. With no arguments, open or activate the player.
|
||||
Quote paths and URLs containing spaces or shell metacharacters.
|
||||
|
||||
--new-instance Launch a separate player, leaving existing playback untouched.
|
||||
--no-config Use built-in settings instead of config.toml on a fresh launch.
|
||||
--url URL Open a YouTube video URL.
|
||||
--source-file PATH Open a validated resolved HTTP(S) source in a new player.
|
||||
--progress-file PATH Write JSONL playback facts to a new private file; requires
|
||||
--new-instance. The GUI owns reporting after this CLI exits.
|
||||
-- Treat the following argument as a filename, even with a leading '-'.
|
||||
@@ -77,6 +80,19 @@ enum PlayerCommand {
|
||||
guard progressFile == nil else { throw ArgumentError("--progress-file cannot be combined with --default-config.") }
|
||||
return .printDefaultConfig
|
||||
}
|
||||
if inputs.first == "--source-file" {
|
||||
guard newInstance else { throw ArgumentError("--source-file requires --new-instance.") }
|
||||
guard inputs.count == 2, !inputs[1].isEmpty, !inputs[1].contains("\0"),
|
||||
!inputs[1].hasPrefix("--") else {
|
||||
throw ArgumentError("--source-file requires exactly one filesystem path.")
|
||||
}
|
||||
let base = directory.hasDirectoryPath ? directory :
|
||||
URL(fileURLWithPath: directory.path, isDirectory: true)
|
||||
let sourceFile = URL(fileURLWithPath: inputs[1], relativeTo: base).absoluteURL.standardizedFileURL
|
||||
_ = try ResolvedSource.load(from: sourceFile)
|
||||
return .launchSource(sourceFile: sourceFile, newInstance: newInstance,
|
||||
noConfig: noConfig, progressFile: progressFile)
|
||||
}
|
||||
if inputs.first == "--url" {
|
||||
guard inputs.count == 2 else { throw ArgumentError("--url requires exactly one YouTube URL.") }
|
||||
return try launch(YouTubeResolver.validateURL(inputs[1]))
|
||||
|
||||
@@ -18,6 +18,13 @@ enum TerebiCLI {
|
||||
case .printDefaultConfig:
|
||||
print(PlayerConfiguration.defaultTOML, terminator: "")
|
||||
case .launch(let url, let newInstance, let noConfig, let progressFile):
|
||||
await launch(url: url, sourceFile: nil, newInstance: newInstance, noConfig: noConfig, progressFile: progressFile)
|
||||
case .launchSource(let sourceFile, let newInstance, let noConfig, let progressFile):
|
||||
await launch(url: nil, sourceFile: sourceFile, newInstance: newInstance, noConfig: noConfig, progressFile: progressFile)
|
||||
}
|
||||
}
|
||||
|
||||
private static func launch(url: URL?, sourceFile: URL?, newInstance: Bool, noConfig: Bool, progressFile: URL?) async {
|
||||
if let progressFile {
|
||||
var existing = stat()
|
||||
guard lstat(progressFile.path, &existing) != 0 else {
|
||||
@@ -62,6 +69,9 @@ enum TerebiCLI {
|
||||
if let progressFile {
|
||||
configuration.arguments += ["--progress-file", progressFile.path]
|
||||
}
|
||||
if let sourceFile {
|
||||
configuration.arguments += ["--source-file", sourceFile.path]
|
||||
}
|
||||
if newInstance, let url {
|
||||
configuration.arguments += ["--open-media", url.absoluteString]
|
||||
}
|
||||
@@ -75,7 +85,6 @@ enum TerebiCLI {
|
||||
fail("Could not launch the player: \(error.localizedDescription)", code: 69)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func fail(_ message: String, code: Int32) -> Never {
|
||||
FileHandle.standardError.write(Data("terebi: \(message)\n".utf8))
|
||||
|
||||
@@ -17,6 +17,7 @@ extern "C" {
|
||||
#include <mutex>
|
||||
#include <new>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
namespace playback {
|
||||
@@ -49,6 +50,9 @@ class NetworkInput {
|
||||
public:
|
||||
static constexpr double aheadSeconds = 60, behindSeconds = 30;
|
||||
AVFormatContext *format = nullptr;
|
||||
// Resolved sources bind credentials to their original origin for all HLS I/O.
|
||||
std::string headerOrigin;
|
||||
std::string requestHeaders;
|
||||
|
||||
NetworkInput(const std::atomic<bool> &stopping, const std::atomic<uint64_t> &epoch,
|
||||
uint64_t activeEpoch, std::condition_variable &playerWake, StreamCacheBudget &budget)
|
||||
|
||||
+76
-8
@@ -18,6 +18,7 @@ extern "C" {
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <condition_variable>
|
||||
#include <cstdarg>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <deque>
|
||||
@@ -167,6 +168,53 @@ bool validHeaders(const char *text) {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string networkOrigin(const char *text) {
|
||||
if (!validNetworkURL(text)) return {};
|
||||
std::string_view url(text);
|
||||
const auto start = url.starts_with("https://") ? 8u : 7u;
|
||||
std::string authority(url.substr(start, url.find_first_of("/?#", start) - start));
|
||||
for (char &value : authority)
|
||||
if (value >= 'A' && value <= 'Z') value += 'a' - 'A';
|
||||
const auto colon = authority.front() == '[' ? authority.find(':', authority.find(']')) : authority.find(':');
|
||||
if (colon == std::string::npos) authority += start == 8 ? ":443" : ":80";
|
||||
else {
|
||||
// Canonicalize explicit default ports and leading zeroes.
|
||||
authority.replace(colon + 1, std::string::npos, std::to_string(std::stoul(authority.substr(colon + 1))));
|
||||
}
|
||||
return std::string(url.substr(0, start)) + authority;
|
||||
}
|
||||
|
||||
int resolvedIOOpen(AVFormatContext *format, AVIOContext **pb, const char *url,
|
||||
int flags, AVDictionary **options) noexcept {
|
||||
try {
|
||||
const auto &input = *static_cast<playback::NetworkInput *>(format->opaque);
|
||||
// crypto forwards options to its underlying HTTP protocol; do not unwrap the
|
||||
// actual URL, since FFmpeg must still decrypt AES-128 segments itself.
|
||||
const char *resource = std::strncmp(url, "crypto+", 7) == 0 ||
|
||||
std::strncmp(url, "crypto:", 7) == 0 ? url + 7 : url;
|
||||
if (networkOrigin(resource) != input.headerOrigin) return AVERROR(EACCES);
|
||||
int result = av_dict_set(options, "protocol_whitelist", "http,https,tcp,tls,crypto", 0);
|
||||
if (result >= 0) result = av_dict_set(options, "headers", input.requestHeaders.c_str(), 0);
|
||||
// FFmpeg 9's HTTP redirect loop retains custom headers. Reject every redirect
|
||||
// before any second request, including same-origin redirects, rather than leak.
|
||||
if (result >= 0) result = av_dict_set(options, "max_redirects", "0", 0);
|
||||
if (result < 0) return result;
|
||||
return avio_open2(pb, url, flags, &format->interrupt_callback, options);
|
||||
} catch (const std::bad_alloc &) {
|
||||
return AVERROR(ENOMEM);
|
||||
} catch (...) {
|
||||
return AVERROR(EINVAL);
|
||||
}
|
||||
}
|
||||
|
||||
void redactedFFmpegLog(void *, int level, const char *, va_list) {
|
||||
if (level > AV_LOG_WARNING || level > av_log_get_level()) return;
|
||||
// FFmpeg diagnostics can interpolate signed URLs, cookies, keys and headers,
|
||||
// including at error level. Player::check still exposes operation + AVERROR.
|
||||
std::fputs(level <= AV_LOG_ERROR ? "FFmpeg error (private details redacted)\n" :
|
||||
"FFmpeg warning (private details redacted)\n", stderr);
|
||||
}
|
||||
}
|
||||
|
||||
struct Player {
|
||||
@@ -521,12 +569,19 @@ struct Player {
|
||||
steadyAnchor = Clock::now();
|
||||
}
|
||||
|
||||
playback::NetworkInput *openNetworkInput(int slot, const std::string &url, const std::string &headers, bool live) {
|
||||
playback::NetworkInput *openNetworkInput(int slot, const std::string &url, const std::string &headers,
|
||||
bool live, bool resolved) {
|
||||
inputs[slot] = std::make_unique<playback::NetworkInput>(stopping, requestedEpoch, activeEpoch, wake, cacheBudget);
|
||||
auto &input = *inputs[slot];
|
||||
input.format = avformat_alloc_context();
|
||||
if (!input.format) throw std::bad_alloc();
|
||||
input.format->interrupt_callback = {playback::NetworkInput::interrupt, &input};
|
||||
if (resolved) {
|
||||
input.headerOrigin = networkOrigin(url.c_str());
|
||||
input.requestHeaders = headers;
|
||||
input.format->opaque = &input;
|
||||
input.format->io_open = resolvedIOOpen;
|
||||
}
|
||||
AVDictionary *options = nullptr;
|
||||
int result = av_dict_set(&options, "protocol_whitelist", "http,https,tcp,tls,crypto", 0);
|
||||
// Leave room for socket-timeout recovery within the reader's total 15-second deadline.
|
||||
@@ -542,6 +597,12 @@ struct Player {
|
||||
if (result >= 0 && !live) result = av_dict_set(&options, "reconnect_delay_total_max", "5", 0);
|
||||
// Leave short-seek read-ahead disabled: cache misses must escape stalled responses.
|
||||
if (result >= 0 && !headers.empty()) result = av_dict_set(&options, "headers", headers.c_str(), 0);
|
||||
if (result >= 0 && resolved) result = av_dict_set(&options, "http_persistent", "0", 0);
|
||||
// HLS persistent requests bypass io_open (and its origin/header policy).
|
||||
if (result >= 0 && resolved) result = av_dict_set(&options, "http_multiple", "0", 0);
|
||||
// Resolved HLS may name genuine TS/fMP4 resources as images. Probe their
|
||||
// content instead of trusting suffixes; io_open still enforces every URL.
|
||||
if (result >= 0 && resolved) result = av_dict_set(&options, "extension_picky", "0", 0);
|
||||
if (result < 0) {
|
||||
av_dict_free(&options);
|
||||
check(result, "Configuring network input");
|
||||
@@ -569,7 +630,8 @@ struct Player {
|
||||
status = PLAYER_OPENING;
|
||||
publish();
|
||||
if (network) {
|
||||
auto *primary = openNetworkInput(0, command.path, command.videoHeaders, command.live);
|
||||
const bool resolved = command.mediaURL.starts_with("miruro:");
|
||||
auto *primary = openNetworkInput(0, command.path, command.videoHeaders, command.live, resolved);
|
||||
format = primary->format;
|
||||
openTrack(1, format, primary, AVMEDIA_TYPE_VIDEO, -1);
|
||||
if (command.audioURL.empty()) {
|
||||
@@ -577,7 +639,7 @@ struct Player {
|
||||
} else {
|
||||
if (!tracks[1].codec)
|
||||
throw std::runtime_error("The selected video stream contains no playable video");
|
||||
auto *audio = openNetworkInput(1, command.audioURL, command.audioHeaders, command.live);
|
||||
auto *audio = openNetworkInput(1, command.audioURL, command.audioHeaders, command.live, resolved);
|
||||
openTrack(0, audio->format, audio, AVMEDIA_TYPE_AUDIO, -1);
|
||||
if (!tracks[0].codec)
|
||||
throw std::runtime_error("The selected audio stream contains no playable audio");
|
||||
@@ -629,10 +691,12 @@ struct Player {
|
||||
}
|
||||
if (context->duration != AV_NOPTS_VALUE && context->duration > 0)
|
||||
measuredDuration = std::max(measuredDuration, static_cast<double>(context->duration) / AV_TIME_BASE);
|
||||
// Manifest demuxers own their I/O; a nil top-level AVIOContext does not forbid time seeks.
|
||||
// HLS time seeks reopen segments; byte-seek support on the initial
|
||||
// manifest response says nothing about its demuxer's seek capability.
|
||||
seekable = seekable && !(context->ctx_flags & AVFMTCTX_UNSEEKABLE) &&
|
||||
(context->pb ? (context->pb->seekable & AVIO_SEEKABLE_NORMAL) != 0 :
|
||||
(context->iformat->flags & AVFMT_NOFILE) != 0);
|
||||
(context->iformat == av_find_input_format("hls") ||
|
||||
(context->iformat->flags & AVFMT_NOFILE) != 0 ||
|
||||
(context->pb && (context->pb->seekable & AVIO_SEEKABLE_NORMAL) != 0));
|
||||
}
|
||||
// One origin for both inputs: independently zeroing them would erase real A/V offsets.
|
||||
origin = std::isfinite(first) ? first : 0;
|
||||
@@ -1376,7 +1440,11 @@ bool submit(Player *player, Function function) noexcept {
|
||||
|
||||
extern "C" {
|
||||
Player *player_create(void) {
|
||||
try { return new Player; } catch (...) { return nullptr; }
|
||||
try {
|
||||
static std::once_flag logging;
|
||||
std::call_once(logging, [] { av_log_set_callback(redactedFFmpegLog); });
|
||||
return new Player;
|
||||
} catch (...) { return nullptr; }
|
||||
}
|
||||
bool player_set_progress_file(Player *player, const char *path, char *error) {
|
||||
if (error) error[0] = '\0';
|
||||
@@ -1416,7 +1484,7 @@ bool player_open(Player *player, const char *path) {
|
||||
bool player_open_network(Player *player, const PlayerNetworkSource *source) {
|
||||
if (!source || !std::isfinite(source->duration) || source->duration < 0 ||
|
||||
!std::isfinite(source->initial_position) || source->initial_position < 0 ||
|
||||
(source->media_url && *source->media_url && !playback::ProgressReporter::validMediaURL(source->media_url)) ||
|
||||
(source->media_url && *source->media_url && !playback::ProgressReporter::validMediaIdentity(source->media_url)) ||
|
||||
!validNetworkURL(source->video_url) ||
|
||||
(source->audio_url && *source->audio_url && !validNetworkURL(source->audio_url)) ||
|
||||
!validHeaders(source->video_headers) || !validHeaders(source->audio_headers)) return false;
|
||||
|
||||
@@ -53,7 +53,8 @@ typedef struct PlayerNetworkSource {
|
||||
const char *audio_headers;
|
||||
double duration;
|
||||
bool is_live;
|
||||
// Canonical YouTube webpage identity, never an extracted stream URL.
|
||||
// Canonical YouTube webpage or stable miruro:<base64url media_id>:<positive episode>
|
||||
// identity, never an extracted stream/CDN URL.
|
||||
// NULL/empty means no reporting for this source.
|
||||
const char *media_url;
|
||||
// Initial restored position, applied before publishing ready state. Zero
|
||||
|
||||
@@ -69,11 +69,31 @@ bool ProgressReporter::validMediaURL(std::string_view url) noexcept {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ProgressReporter::validMediaIdentity(std::string_view identity) noexcept {
|
||||
if (validMediaURL(identity)) return true;
|
||||
constexpr std::string_view prefix = "miruro:";
|
||||
if (identity.substr(0, prefix.size()) != prefix) return false;
|
||||
const auto separator = identity.find(':', prefix.size());
|
||||
if (separator == std::string_view::npos) return false;
|
||||
const auto id = identity.substr(prefix.size(), separator - prefix.size());
|
||||
if (id.empty() || id.size() > 512) return false;
|
||||
for (char c : id) {
|
||||
if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
|
||||
(c >= '0' && c <= '9') || c == '-' || c == '_')) return false;
|
||||
}
|
||||
const auto episode = identity.substr(separator + 1);
|
||||
if (episode.empty() || episode.front() < '1' || episode.front() > '9') return false;
|
||||
for (char c : episode) if (c < '0' || c > '9') return false;
|
||||
int value = 0;
|
||||
const auto parsed = std::from_chars(episode.data(), episode.data() + episode.size(), value);
|
||||
return parsed.ec == std::errc{} && parsed.ptr == episode.data() + episode.size() && value > 0;
|
||||
}
|
||||
|
||||
void ProgressReporter::begin(const std::string &url, bool isLive) noexcept {
|
||||
if (finished || disabled.load(std::memory_order_acquire)) return;
|
||||
if (url.empty()) { closeMedia(); return; }
|
||||
if (!validMediaURL(url)) {
|
||||
disable("Progress media URL is not a canonical YouTube watch URL");
|
||||
if (!validMediaIdentity(url)) {
|
||||
disable("Progress media identity is invalid");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -144,11 +164,21 @@ bool ProgressReporter::emit(const char *event) noexcept {
|
||||
try {
|
||||
std::string record;
|
||||
record.reserve(256);
|
||||
record += "{\"version\":1,\"event\":\"";
|
||||
const bool miruro = mediaURL.compare(0, 7, "miruro:") == 0;
|
||||
record += miruro ? "{\"version\":2,\"event\":\"" : "{\"version\":1,\"event\":\"";
|
||||
record += event;
|
||||
record += "\",\"media_url\":\"";
|
||||
record += mediaURL;
|
||||
record += "\",\"position_seconds\":";
|
||||
if (miruro) {
|
||||
const auto separator = mediaURL.find(':', 7);
|
||||
record += "\",\"media_identity\":{\"provider\":\"miruro\",\"media_id\":\"";
|
||||
record.append(mediaURL, 7, separator - 7);
|
||||
record += "\",\"episode\":";
|
||||
record.append(mediaURL, separator + 1, std::string::npos);
|
||||
record += "},\"position_seconds\":";
|
||||
} else {
|
||||
record += "\",\"media_url\":\"";
|
||||
record += mediaURL;
|
||||
record += "\",\"position_seconds\":";
|
||||
}
|
||||
appendNumber(record, snapshot.position);
|
||||
record += ",\"duration_seconds\":";
|
||||
if (snapshot.duration > 0) appendNumber(record, snapshot.duration);
|
||||
|
||||
@@ -20,6 +20,7 @@ public:
|
||||
ProgressReporter &operator=(const ProgressReporter &) = delete;
|
||||
|
||||
static bool validMediaURL(std::string_view url) noexcept;
|
||||
static bool validMediaIdentity(std::string_view identity) noexcept;
|
||||
void begin(const std::string &mediaURL, bool live = false) noexcept;
|
||||
void observe(const PlayerState &state) noexcept;
|
||||
void closeMedia() noexcept;
|
||||
|
||||
Reference in New Issue
Block a user