feat: play resolved Miruro HLS with external VTT and stable episode progress

This commit is contained in:
2026-10-12 19:17:22 +02:00
parent 1f12660936
commit 470d8feec2
23 changed files with 1528 additions and 38 deletions
+275
View File
@@ -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)
}
}
+122
View File
@@ -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
}
}
+114
View File
@@ -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.
+18 -2
View File
@@ -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]))
+10 -1
View File
@@ -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))
+4
View File
@@ -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
View File
@@ -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;
+2 -1
View File
@@ -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
+36 -6
View File
@@ -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);
+1
View File
@@ -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;