fix: populate queue titles independently of playback extraction
The sidebar scheduler waited for playback and used full stream extraction for titles. Use independent utility-priority title requests, native YouTube oEmbed, and metadata-only extraction for generic sites. Preserve foreground title precedence and stale row protection. Verified: make check-youtube check-queue check-resolved check-adaptive check-stream all.
This commit is contained in:
@@ -33,7 +33,7 @@ struct AutomaticQualityPlaybackChecks {
|
|||||||
let defaults = UserDefaults(suiteName: suite)!
|
let defaults = UserDefaults(suiteName: suite)!
|
||||||
defer { defaults.removePersistentDomain(forName: suite) }
|
defer { defaults.removePersistentDomain(forName: suite) }
|
||||||
let clock = FixedQualityClock()
|
let clock = FixedQualityClock()
|
||||||
let model = PlayerModel(defaults: defaults, qualityClock: { clock.now })
|
let model = PlayerModel(defaults: defaults, youtubeTitleResolver: { _ in nil }, qualityClock: { clock.now })
|
||||||
defer { model.shutdown() }
|
defer { model.shutdown() }
|
||||||
model.setVolume(0)
|
model.setVolume(0)
|
||||||
// Control only the external extractor; media decoding and all model transitions stay real.
|
// Control only the external extractor; media decoding and all model transitions stay real.
|
||||||
|
|||||||
@@ -117,28 +117,31 @@ struct ResolvedPlaybackCheck {
|
|||||||
let suite = "terebi.queue-metadata-check." + UUID().uuidString
|
let suite = "terebi.queue-metadata-check." + UUID().uuidString
|
||||||
let defaults = UserDefaults(suiteName: suite)!
|
let defaults = UserDefaults(suiteName: suite)!
|
||||||
defer { defaults.removePersistentDomain(forName: suite) }
|
defer { defaults.removePersistentDomain(forName: suite) }
|
||||||
let urls = ["AAAAAAAAAAA", "BBBBBBBBBBB", "CCCCCCCCCCC", "DDDDDDDDDDD"].map {
|
let urls = ["AAAAAAAAAAA", "BBBBBBBBBBB", "CCCCCCCCCCC"].map {
|
||||||
URL(string: "https://www.youtube.com/watch?v=\($0)")!
|
URL(string: "https://www.youtube.com/watch?v=\($0)")!
|
||||||
}
|
} + [URL(string: "https://video.example/watch/fixture")!]
|
||||||
let episode = URL(string: "https://barelystarted.miruro.tv/watch/fixture-ID/episode?ep=1")!
|
let episode = URL(string: "https://barelystarted.miruro.tv/watch/fixture-ID/episode?ep=1")!
|
||||||
var foreground: CheckedContinuation<YouTubeVideo, Never>?
|
var foreground: CheckedContinuation<YouTubeVideo, Never>?
|
||||||
var background: CheckedContinuation<YouTubeVideo, Never>?
|
var background: CheckedContinuation<String?, Never>?
|
||||||
var holdFirst = true
|
var holdFirst = true
|
||||||
var episodeRequests = 0
|
var episodeRequests = 0
|
||||||
func video(_ url: URL, title: String) -> YouTubeVideo {
|
var titleRequests: [URL] = []
|
||||||
|
func video(_ url: URL, title: String, height: Int = 1080) -> YouTubeVideo {
|
||||||
YouTubeVideo(title: title, video: .init(url: source.url, headers: source.headers), audio: nil,
|
YouTubeVideo(title: title, video: .init(url: source.url, headers: source.headers), audio: nil,
|
||||||
duration: 0, isLive: false, webpageURL: url, height: nil, availableHeights: [])
|
duration: 0, isLive: false, webpageURL: url, height: height, availableHeights: [1080, 720])
|
||||||
}
|
}
|
||||||
let model = PlayerModel(defaults: defaults, youtubeResolver: { url, _ in
|
let model = PlayerModel(defaults: defaults, youtubeResolver: { url, height in
|
||||||
if url == urls[0], holdFirst {
|
if holdFirst || height != nil {
|
||||||
holdFirst = false
|
holdFirst = false
|
||||||
return await withCheckedContinuation { foreground = $0 }
|
return await withCheckedContinuation { foreground = $0 }
|
||||||
}
|
}
|
||||||
if url == urls[1], Task.currentPriority <= .utility {
|
|
||||||
return await withCheckedContinuation { background = $0 }
|
|
||||||
}
|
|
||||||
if url == urls[2] { throw CocoaError(.fileReadCorruptFile) }
|
|
||||||
return video(url, title: url == urls[0] ? "Playing title" : "Fresh playback title")
|
return video(url, title: url == urls[0] ? "Playing title" : "Fresh playback title")
|
||||||
|
}, youtubeTitleResolver: { url in
|
||||||
|
require(Task.currentPriority <= .utility, "title-only lookups use lower priority")
|
||||||
|
titleRequests.append(url)
|
||||||
|
if url == urls[1] { return await withCheckedContinuation { background = $0 } }
|
||||||
|
if url == urls[2] { throw CocoaError(.fileReadCorruptFile) }
|
||||||
|
return url == urls[0] ? "Early current title" : "Generic page title"
|
||||||
}, miruroResolver: { _, _ in
|
}, miruroResolver: { _, _ in
|
||||||
fatalError("FAIL: sidebar enrichment must not fetch Miruro playback sources")
|
fatalError("FAIL: sidebar enrichment must not fetch Miruro playback sources")
|
||||||
}, miruroMetadataResolver: { _, _ in
|
}, miruroMetadataResolver: { _, _ in
|
||||||
@@ -154,68 +157,79 @@ struct ResolvedPlaybackCheck {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
model.openStreamQueue(queue([urls[0], urls[1], episode, urls[2], urls[3]]))
|
model.openStreamQueue(queue([urls[0], urls[1], episode, urls[2], urls[3]]))
|
||||||
try await until("foreground extraction starts first", model: model) { foreground != nil }
|
try await until("early current title and next title request while full extraction is held", model: model) {
|
||||||
require(background == nil && episodeRequests == 0, "sidebar lookups yield to selected extraction")
|
foreground != nil && background != nil && model.queue.currentItem?.displayName == "Early current title"
|
||||||
foreground!.resume(returning: video(urls[0], title: "Playing title"))
|
|
||||||
foreground = nil
|
|
||||||
try await until("native playback and background extraction start", model: model) {
|
|
||||||
!model.isBusy && model.state.status == PLAYER_PLAYING && background != nil
|
|
||||||
}
|
}
|
||||||
require(episodeRequests == 0, "sidebar lookups are serial")
|
require(model.resolvingStream && model.fileName == nil && episodeRequests == 0 &&
|
||||||
require(model.setPlayback(playing: false), "pause for background title resolution")
|
titleRequests == [urls[0], urls[1]], "serial sidebar work is independent of foreground extraction")
|
||||||
try await until("metadata smoke pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
|
||||||
let selected = model.queue.currentID
|
let selected = model.queue.currentID
|
||||||
let position = model.state.position
|
|
||||||
let pendingID = model.queue.items[1].id
|
let pendingID = model.queue.items[1].id
|
||||||
model.moveQueueItems(fromOffsets: IndexSet(integer: 1), toOffset: 5)
|
model.moveQueueItems(fromOffsets: IndexSet(integer: 1), toOffset: 5)
|
||||||
background!.resume(returning: video(urls[1], title: "Queued title"))
|
background!.resume(returning: "Queued title")
|
||||||
background = nil
|
background = nil
|
||||||
let deadline = ContinuousClock.now.advanced(by: .seconds(2))
|
try await until("mixed queue titles continue before foreground extraction finishes", model: model) {
|
||||||
while model.queue.items.last?.displayName != "Queued title", ContinuousClock.now < deadline {
|
model.queue.items.last?.displayName == "Queued title" &&
|
||||||
try await Task.sleep(for: .milliseconds(10))
|
model.queue.items.first { $0.url == episode }?.displayName == "Queued episode" &&
|
||||||
}
|
model.queue.items.first { $0.url == urls[3] }?.displayName == "Generic page title"
|
||||||
require(model.queue.items.last?.id == pendingID && model.queue.items.last?.displayName == "Queued title",
|
|
||||||
"unplayed queue entry resolves by identity after reorder")
|
|
||||||
try await until("mixed queue metadata continues after failure", model: model) {
|
|
||||||
model.queue.items.first { $0.url == episode }?.displayName == "Queued episode" &&
|
|
||||||
model.queue.items.first { $0.url == urls[3] }?.displayName == "Fresh playback title"
|
|
||||||
}
|
}
|
||||||
|
require(model.queue.items.last?.id == pendingID && model.queue.currentID == selected &&
|
||||||
|
model.resolvingStream && model.fileName == nil, "reordered metadata updates only its original row")
|
||||||
require(model.queue.items.first { $0.url == urls[2] }?.displayName == urls[2].absoluteString &&
|
require(model.queue.items.first { $0.url == urls[2] }?.displayName == urls[2].absoluteString &&
|
||||||
model.commandError == nil, "failed enrichment preserves fallback and does not block later titles")
|
model.queue.items.first { $0.url == urls[2] }?.metadataAttempted == true &&
|
||||||
require(model.queue.currentID == selected &&
|
model.commandError == nil, "failed title preserves fallback and does not block later titles")
|
||||||
model.state.position == position && model.state.status == PLAYER_PAUSED &&
|
foreground!.resume(returning: video(urls[0], title: "Playing title"))
|
||||||
model.fileName == "Playing title", "background metadata preserves selection, position, paused state and current title")
|
foreground = nil
|
||||||
|
try await until("foreground title takes precedence and native playback starts", model: model) {
|
||||||
|
model.state.status == PLAYER_PLAYING && model.queue.currentItem?.displayName == "Playing title"
|
||||||
|
}
|
||||||
|
require(model.setPlayback(playing: false), "pause for metadata preservation")
|
||||||
|
try await until("metadata smoke pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
||||||
|
let position = model.state.position
|
||||||
|
for _ in 0..<10 { await Task.yield() }
|
||||||
|
require(model.queue.currentID == selected && model.state.position == position &&
|
||||||
|
model.fileName == "Playing title", "title-only metadata preserves native selection, position and current title")
|
||||||
|
|
||||||
model.openStreamQueue(queue([urls[0], urls[1]]))
|
model.openStreamQueue(queue([urls[0], urls[1]]))
|
||||||
try await until("next background lookup starts", model: model) { background != nil }
|
try await until("next title-only lookup starts", model: model) { background != nil }
|
||||||
let cancelled = background!
|
let selectedLookup = background!
|
||||||
background = nil
|
background = nil
|
||||||
model.selectQueueItem(model.queue.items[1].id)
|
model.selectQueueItem(model.queue.items[1].id)
|
||||||
try await until("selection preempts pending metadata with fresh playback extraction", model: model) {
|
try await until("selection resolves fresh playback independently", model: model) {
|
||||||
!model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == "Fresh playback title"
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == "Fresh playback title"
|
||||||
}
|
}
|
||||||
cancelled.resume(returning: video(urls[1], title: "Stale title"))
|
selectedLookup.resume(returning: "Stale title")
|
||||||
for _ in 0..<10 { await Task.yield() }
|
for _ in 0..<10 { await Task.yield() }
|
||||||
require(model.queue.currentItem?.displayName == "Fresh playback title" &&
|
require(model.queue.currentItem?.displayName == "Fresh playback title" &&
|
||||||
model.fileName == "Fresh playback title", "cancelled metadata cannot overwrite selected title")
|
model.fileName == "Fresh playback title", "late title-only result cannot overwrite foreground metadata")
|
||||||
|
|
||||||
model.openStreamQueue(queue([urls[0], urls[1]]))
|
model.openStreamQueue(queue([urls[0], urls[1]]))
|
||||||
try await until("removable background lookup starts", model: model) { background != nil }
|
try await until("removable title-only lookup starts", model: model) { background != nil }
|
||||||
let removed = background!
|
let removed = background!
|
||||||
background = nil
|
background = nil
|
||||||
model.removeQueueItem(model.queue.items[1].id)
|
model.removeQueueItem(model.queue.items[1].id)
|
||||||
model.openStreamQueue(queue([urls[0], urls[1]]))
|
model.openStreamQueue(queue([urls[0], urls[1]]))
|
||||||
try await until("replacement background lookup starts", model: model) { background != nil }
|
try await until("replacement title-only lookup starts", model: model) { background != nil }
|
||||||
removed.resume(returning: video(urls[1], title: "Removed title"))
|
removed.resume(returning: "Removed title")
|
||||||
for _ in 0..<10 { await Task.yield() }
|
for _ in 0..<10 { await Task.yield() }
|
||||||
require(model.queue.items[1].displayName == urls[1].absoluteString,
|
require(model.queue.items[1].displayName == urls[1].absoluteString,
|
||||||
"removed/replaced item cannot rename a new row for the same URL")
|
"removed/replaced item cannot rename a new row for the same URL")
|
||||||
background!.resume(returning: video(urls[1], title: "Replacement title"))
|
require(model.setPlayback(playing: false), "pause before quality extraction")
|
||||||
|
try await until("quality smoke pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
||||||
|
model.setYouTubeQuality(720)
|
||||||
|
try await until("quality extraction is held", model: model) { foreground != nil }
|
||||||
|
background!.resume(returning: "Replacement title")
|
||||||
background = nil
|
background = nil
|
||||||
try await until("replacement result updates its own row", model: model) {
|
try await until("title arrives while quality extraction is held", model: model) {
|
||||||
model.queue.items[1].displayName == "Replacement title"
|
model.queue.items[1].displayName == "Replacement title"
|
||||||
}
|
}
|
||||||
print("PASS: background mixed-stream titles, utility priority, serial lookup, reorder, failure, playback preemption and stale removal/replacement")
|
require(model.resolvingStream && model.fileName == "Playing title" &&
|
||||||
|
model.queue.currentItem?.displayName == "Playing title", "title work does not replace current quality metadata")
|
||||||
|
foreground!.resume(returning: video(urls[0], title: "Quality title", height: 720))
|
||||||
|
foreground = nil
|
||||||
|
try await until("quality extraction resumes paused native playback", model: model) {
|
||||||
|
!model.isBusy && model.state.status == PLAYER_PAUSED && model.fileName == "Quality title"
|
||||||
|
}
|
||||||
|
print("PASS: early current/queued titles during held playback and quality extraction; serial utility lookup, generic/Miruro titles, reorder, failure and stale replacement/foreground precedence")
|
||||||
}
|
}
|
||||||
|
|
||||||
static func streamQueueLifecycle(source: ResolvedSource) async throws {
|
static func streamQueueLifecycle(source: ResolvedSource) async throws {
|
||||||
@@ -242,14 +256,12 @@ struct ResolvedPlaybackCheck {
|
|||||||
video: .init(url: source.url, headers: source.headers), audio: nil,
|
video: .init(url: source.url, headers: source.headers), audio: nil,
|
||||||
duration: 0, isLive: false, webpageURL: youtubeURL, height: nil, availableHeights: [])
|
duration: 0, isLive: false, webpageURL: youtubeURL, height: nil, availableHeights: [])
|
||||||
let model = PlayerModel(defaults: defaults, streamQueue: queue, youtubeResolver: { _, _ in
|
let model = PlayerModel(defaults: defaults, streamQueue: queue, youtubeResolver: { _, _ in
|
||||||
if Task.currentPriority > .utility {
|
youtubeRequests += 1
|
||||||
youtubeRequests += 1
|
if youtubeRequests == 1 {
|
||||||
if youtubeRequests == 1 {
|
return await withCheckedContinuation { stale = $0 }
|
||||||
return await withCheckedContinuation { stale = $0 }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return video
|
return video
|
||||||
}, miruroResolver: { _, _ in
|
}, youtubeTitleResolver: { _ in nil }, miruroResolver: { _, _ in
|
||||||
episodeRequests += 1
|
episodeRequests += 1
|
||||||
return source
|
return source
|
||||||
}, miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
|
}, miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
|
||||||
@@ -349,7 +361,7 @@ struct ResolvedPlaybackCheck {
|
|||||||
return YouTubeVideo(title: url.absoluteString,
|
return YouTubeVideo(title: url.absoluteString,
|
||||||
video: .init(url: source.url, headers: source.headers), audio: nil,
|
video: .init(url: source.url, headers: source.headers), audio: nil,
|
||||||
duration: 0, isLive: false, webpageURL: url, height: nil, availableHeights: [])
|
duration: 0, isLive: false, webpageURL: url, height: nil, availableHeights: [])
|
||||||
}, miruroResolver: { _, _ in episodeRequests += 1; return source },
|
}, youtubeTitleResolver: { _ in nil }, miruroResolver: { _, _ in episodeRequests += 1; return source },
|
||||||
miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
|
miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
|
||||||
defer { model.shutdown() }
|
defer { model.shutdown() }
|
||||||
func deliver(_ items: [(URL, URL)]) throws {
|
func deliver(_ items: [(URL, URL)]) throws {
|
||||||
@@ -468,7 +480,7 @@ struct ResolvedPlaybackCheck {
|
|||||||
let empty = PlayerModel(defaults: defaults, youtubeResolver: { url, _ in
|
let empty = PlayerModel(defaults: defaults, youtubeResolver: { url, _ in
|
||||||
YouTubeVideo(title: "Cold enqueue", video: .init(url: source.url, headers: source.headers), audio: nil,
|
YouTubeVideo(title: "Cold enqueue", video: .init(url: source.url, headers: source.headers), audio: nil,
|
||||||
duration: 0, isLive: false, webpageURL: url, height: nil, availableHeights: [])
|
duration: 0, isLive: false, webpageURL: url, height: nil, availableHeights: [])
|
||||||
})
|
}, youtubeTitleResolver: { _ in nil })
|
||||||
defer { empty.shutdown() }
|
defer { empty.shutdown() }
|
||||||
let coldReport = directory.appendingPathComponent("cold.jsonl")
|
let coldReport = directory.appendingPathComponent("cold.jsonl")
|
||||||
empty.enqueueStreamQueue(StreamQueue(entries: [.init(url: fourth, identity: fourth.absoluteString,
|
empty.enqueueStreamQueue(StreamQueue(entries: [.init(url: fourth, identity: fourth.absoluteString,
|
||||||
@@ -498,7 +510,7 @@ struct ResolvedPlaybackCheck {
|
|||||||
duration: 0, isLive: false, webpageURL: url, height: nil, availableHeights: [])
|
duration: 0, isLive: false, webpageURL: url, height: nil, availableHeights: [])
|
||||||
if url == cancelledURL { return await withCheckedContinuation { pending = $0 } }
|
if url == cancelledURL { return await withCheckedContinuation { pending = $0 } }
|
||||||
return video
|
return video
|
||||||
}, miruroResolver: { _, _ in source },
|
}, youtubeTitleResolver: { _ in nil }, miruroResolver: { _, _ in source },
|
||||||
miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
|
miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
|
||||||
defer { model.shutdown() }
|
defer { model.shutdown() }
|
||||||
try await model.addStreamToQueue(first.absoluteString)
|
try await model.addStreamToQueue(first.absoluteString)
|
||||||
|
|||||||
@@ -309,13 +309,109 @@ struct YouTubeResolverChecks {
|
|||||||
throw Failure(description: "FAIL: extractor did not reach \(name) within the isolated check deadline")
|
throw Failure(description: "FAIL: extractor did not reach \(name) within the isolated check deadline")
|
||||||
}
|
}
|
||||||
|
|
||||||
static func cancelled(_ task: Task<YouTubeVideo, Error>, _ message: String) async throws {
|
static func cancelled<T: Sendable>(_ task: Task<T, Error>, _ message: String) async throws {
|
||||||
do { _ = try await task.value }
|
do { _ = try await task.value }
|
||||||
catch is CancellationError { return }
|
catch is CancellationError { return }
|
||||||
catch { throw Failure(description: "FAIL: \(message) returned an extraction error instead of cancellation") }
|
catch { throw Failure(description: "FAIL: \(message) returned an extraction error instead of cancellation") }
|
||||||
throw Failure(description: "FAIL: \(message) returned media instead of cancellation")
|
throw Failure(description: "FAIL: \(message) returned media instead of cancellation")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final class TitleProtocol: URLProtocol, @unchecked Sendable {
|
||||||
|
static let stalled = AsyncStream<Void>.makeStream()
|
||||||
|
override class func canInit(with request: URLRequest) -> Bool { true }
|
||||||
|
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
||||||
|
override func startLoading() {
|
||||||
|
let parts = URLComponents(url: request.url!, resolvingAgainstBaseURL: false)!
|
||||||
|
precondition(parts.scheme == "https" && parts.host == "www.youtube.com" && parts.path == "/oembed")
|
||||||
|
precondition(request.value(forHTTPHeaderField: "Cookie") == nil)
|
||||||
|
let page = parts.queryItems!.first { $0.name == "url" }!.value!
|
||||||
|
precondition(parts.queryItems!.first { $0.name == "format" }!.value == "json")
|
||||||
|
let id = URLComponents(string: page)!.queryItems!.first!.value!
|
||||||
|
if id == "CancelledAA" {
|
||||||
|
Self.stalled.continuation.yield()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let status = id == "Unavailable" ? 404 : 200
|
||||||
|
let mime = id == "WrongMimeAA" ? "text/html" : "application/json"
|
||||||
|
let oversized = ["OversizedAA", "ChunkedAAAA"].contains(id)
|
||||||
|
let body = oversized ? Data(repeating: 32, count: 65537) :
|
||||||
|
Data((id == "MalformedAA" ? "not JSON" : #"{"title":" Early YouTube title "}"#).utf8)
|
||||||
|
var headers = ["Content-Type": mime]
|
||||||
|
if id != "ChunkedAAAA" { headers["Content-Length"] = "\(body.count)" }
|
||||||
|
client!.urlProtocol(self, didReceive: HTTPURLResponse(url: request.url!, statusCode: status,
|
||||||
|
httpVersion: nil, headerFields: headers)!,
|
||||||
|
cacheStoragePolicy: .notAllowed)
|
||||||
|
client!.urlProtocol(self, didLoad: body)
|
||||||
|
client!.urlProtocolDidFinishLoading(self)
|
||||||
|
}
|
||||||
|
override func stopLoading() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func titles(root: URL) async throws {
|
||||||
|
let configuration = URLSessionConfiguration.ephemeral
|
||||||
|
configuration.protocolClasses = [TitleProtocol.self]
|
||||||
|
let youtube = try await YouTubeResolver.resolveTitle(URL(string: "https://youtu.be/jNQXAC9IVRw")!,
|
||||||
|
executableURL: URL(fileURLWithPath: "/missing-helper"), configuration: configuration)
|
||||||
|
try require(youtube == "Early YouTube title", "native YouTube oEmbed needs no extractor, runtime or playable formats")
|
||||||
|
for id in ["Unavailable", "WrongMimeAA", "OversizedAA", "ChunkedAAAA", "MalformedAA"] {
|
||||||
|
do {
|
||||||
|
_ = try await YouTubeResolver.resolveTitle(URL(string: "https://youtu.be/\(id)")!, configuration: configuration)
|
||||||
|
throw Failure(description: "FAIL: invalid oEmbed response was accepted: \(id)")
|
||||||
|
} catch let failure as Failure { throw failure }
|
||||||
|
catch {}
|
||||||
|
}
|
||||||
|
let native = Task {
|
||||||
|
try await YouTubeResolver.resolveTitle(URL(string: "https://youtu.be/CancelledAA")!, configuration: configuration)
|
||||||
|
}
|
||||||
|
var started = TitleProtocol.stalled.stream.makeAsyncIterator()
|
||||||
|
_ = await started.next()
|
||||||
|
native.cancel()
|
||||||
|
do {
|
||||||
|
_ = try await native.value
|
||||||
|
throw Failure(description: "FAIL: cancelled oEmbed request returned a title")
|
||||||
|
} catch let failure as Failure { throw failure }
|
||||||
|
catch {
|
||||||
|
try require(error is CancellationError || (error as? URLError)?.code == .cancelled,
|
||||||
|
"native oEmbed cancellation ends its request without a title")
|
||||||
|
}
|
||||||
|
try require(try YouTubeResolver.decodeTitle(Data(#"{"title":" Metadata only ","formats":"ignored"}"#.utf8)) == "Metadata only",
|
||||||
|
"title decoding does not require or interpret playback formats")
|
||||||
|
try require(try YouTubeResolver.decodeTitle(Data(#"{"title":" "}"#.utf8)) == nil, "empty title retains fallback")
|
||||||
|
for json in [#"{"_type":"playlist","title":"Collection"}"#, #"{"title":"bad\u0000title"}"#, #"{"title":42}"#] {
|
||||||
|
try reject("unsafe, collection or malformed title") { _ = try YouTubeResolver.decodeTitle(Data(json.utf8)) }
|
||||||
|
}
|
||||||
|
let generic = URL(string: "https://video.example/watch?id=123")!
|
||||||
|
let extractor = try Extractor(root: root, name: "title only", json: #"{"title":"Generic title"}"#, body: #"""
|
||||||
|
printf '%s\n' "$@" > "$base/arguments"
|
||||||
|
/bin/cat "$base/response.json"
|
||||||
|
"""#)
|
||||||
|
let title = try await YouTubeResolver.resolveTitle(generic, executableURL: extractor.executable)
|
||||||
|
let arguments = try String(contentsOf: extractor.marker("arguments"), encoding: .utf8)
|
||||||
|
.split(separator: "\n").map(String.init)
|
||||||
|
try require(title == "Generic title" && arguments.suffix(2) == ["--", generic.absoluteString] &&
|
||||||
|
arguments.contains("--ignore-config") && arguments.contains("--no-plugin-dirs") &&
|
||||||
|
arguments.contains("--no-remote-components") && arguments.contains("--flat-playlist") &&
|
||||||
|
arguments.contains("--skip-download") && arguments.contains("--ignore-no-formats-error") &&
|
||||||
|
arguments.contains("--no-js-runtimes") && !arguments.contains("--js-runtimes") &&
|
||||||
|
!arguments.contains("--format"), "generic title extraction is isolated from full playback format/runtime policy")
|
||||||
|
do {
|
||||||
|
_ = try await YouTubeResolver.resolveTitle(URL(string: "https://user:password@video.example/")!,
|
||||||
|
executableURL: extractor.executable)
|
||||||
|
throw Failure(description: "FAIL: title lookup accepted embedded credentials")
|
||||||
|
} catch let failure as Failure { throw failure }
|
||||||
|
catch {}
|
||||||
|
let waiting = try Extractor(root: root, name: "cancel title", body: #"""
|
||||||
|
trap '' TERM INT
|
||||||
|
: > "$base/ready"
|
||||||
|
while [ ! -f "$base/release" ]; do /bin/sleep 0.01; done
|
||||||
|
/bin/cat "$base/response.json"
|
||||||
|
"""#)
|
||||||
|
let task = Task { try await YouTubeResolver.resolveTitle(generic, executableURL: waiting.executable) }
|
||||||
|
try await awaitMarker(waiting, "ready")
|
||||||
|
task.cancel()
|
||||||
|
try await cancelled(task, "title-only subprocess cancellation")
|
||||||
|
}
|
||||||
|
|
||||||
static func subprocesses(root: URL, runtime: URL) async throws {
|
static func subprocesses(root: URL, runtime: URL) async throws {
|
||||||
let successful = try Extractor(root: root, name: "successful split")
|
let successful = try Extractor(root: root, name: "successful split")
|
||||||
let result = try await YouTubeResolver.resolve(page, executableURL: successful.executable, runtimeURL: runtime)
|
let result = try await YouTubeResolver.resolve(page, executableURL: successful.executable, runtimeURL: runtime)
|
||||||
@@ -458,6 +554,12 @@ struct YouTubeResolverChecks {
|
|||||||
try require(unknown.video.url == "https://media.example/unknown.mp4" &&
|
try require(unknown.video.url == "https://media.example/unknown.mp4" &&
|
||||||
unknown.audio == nil && unknown.height == nil && unknown.webpageURL == genericPage,
|
unknown.audio == nil && unknown.height == nil && unknown.webpageURL == genericPage,
|
||||||
"generic HTML5 video without codec or height hints remains playable and retains page identity")
|
"generic HTML5 video without codec or height hints remains playable and retains page identity")
|
||||||
|
let metadataOnly: [String: Any] = ["id": "123", "title": "Title without playable formats",
|
||||||
|
"webpage_url": genericPage.absoluteString, "extractor": "generic", "extractor_key": "Generic",
|
||||||
|
"formats": []]
|
||||||
|
try JSONSerialization.data(withJSONObject: metadataOnly).write(to: extractor.marker("response.json"))
|
||||||
|
let title = try await YouTubeResolver.resolveTitle(genericPage, executableURL: extractor.executable)
|
||||||
|
try require(title == "Title without playable formats", "bundled yt-dlp returns title metadata without playable formats or runtime")
|
||||||
}
|
}
|
||||||
|
|
||||||
static func bundledHelpers(root: URL) throws {
|
static func bundledHelpers(root: URL) throws {
|
||||||
@@ -493,9 +595,9 @@ struct YouTubeResolverChecks {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func main() async throws {
|
static func main() async throws {
|
||||||
// A broken pipe/cancellation implementation must fail, not leave this check hanging.
|
// Includes five cold starts of the bundled extractor against offline metadata.
|
||||||
let watchdog = Task {
|
let watchdog = Task {
|
||||||
try await Task.sleep(for: .seconds(30))
|
try await Task.sleep(for: .seconds(45))
|
||||||
fatalError("FAIL: resolver checks exceeded their bounded subprocess deadline")
|
fatalError("FAIL: resolver checks exceeded their bounded subprocess deadline")
|
||||||
}
|
}
|
||||||
defer { watchdog.cancel() }
|
defer { watchdog.cancel() }
|
||||||
@@ -511,6 +613,11 @@ struct YouTubeResolverChecks {
|
|||||||
try qualities()
|
try qualities()
|
||||||
try bundledHelpers(root: root)
|
try bundledHelpers(root: root)
|
||||||
try await subprocesses(root: root, runtime: runtime)
|
try await subprocesses(root: root, runtime: runtime)
|
||||||
|
print("PASS: extractor subprocess isolation and cancellation")
|
||||||
|
fflush(stdout)
|
||||||
|
try await titles(root: root)
|
||||||
|
print("PASS: independent native YouTube and generic title resolution")
|
||||||
|
fflush(stdout)
|
||||||
try await actualQualitySelection(root: root, runtime: runtime)
|
try await actualQualitySelection(root: root, runtime: runtime)
|
||||||
print("YouTube resolver checks passed")
|
print("YouTube resolver checks passed")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ To run without installing, use `make run`. The built `.build/terebi.app` is self
|
|||||||
|
|
||||||
- **Local files:** use **⌘O**, drag files onto the player, or choose Finder's **Open With**. Multiple files form a queue in natural filename order, with automatic advance. Use **View → Show Queue** to play, reorder or remove items; **+ → file icon** or dropping files onto the sidebar appends them. The queue lasts only for the session; opening other media replaces it.
|
- **Local files:** use **⌘O**, drag files onto the player, or choose Finder's **Open With**. Multiple files form a queue in natural filename order, with automatic advance. Use **View → Show Queue** to play, reorder or remove items; **+ → file icon** or dropping files onto the sidebar appends them. The queue lasts only for the session; opening other media replaces it.
|
||||||
- **Streams:** use **Open Stream…** (**⇧⌘O**) or drop an HTTP(S) video link. To append without replacing playback, press the sidebar's **+**, then the **link icon**: the URL field opens upwards; **Return** fetches and adds the video. The link icon toggles the field; **Esc** dismisses it and cancels a pending fetch. Fetch errors leave the queue unchanged. Links are passed to the bundled yt-dlp without a site allowlist; extraction must return a single streamable video, not a playlist or channel. Miruro keeps its dedicated resolver: links must use `https://barelystarted.miruro.tv/watch/<opaque-id>/<slug>?ep=<episode>` with an explicit positive episode number.
|
- **Streams:** use **Open Stream…** (**⇧⌘O**) or drop an HTTP(S) video link. To append without replacing playback, press the sidebar's **+**, then the **link icon**: the URL field opens upwards; **Return** fetches and adds the video. The link icon toggles the field; **Esc** dismisses it and cancels a pending fetch. Fetch errors leave the queue unchanged. Links are passed to the bundled yt-dlp without a site allowlist; extraction must return a single streamable video, not a playlist or channel. Miruro keeps its dedicated resolver: links must use `https://barelystarted.miruro.tv/watch/<opaque-id>/<slug>?ep=<episode>` with an explicit positive episode number.
|
||||||
- **Stream queues:** integrations can supply an ordered yt-dlp/Miruro queue with `--new-instance --queue-file /absolute/queue.json`, or append without interrupting the running player using `--enqueue --queue-file /absolute/queue.json` (also for a single item). Enqueue starts playback if the player is empty or not running. The same sidebar supports play, reorder, remove, previous/next and automatic advance. Queued titles load serially in the background at lower priority, yielding during playback loading, buffering and quality changes. Failed lookups retain the supplied label. Playback sources still resolve fresh when selected or revisited; local-file batches still use natural filename order. yt-dlp items use the original page URL for resume and version-1 progress reports; recognized YouTube video URLs still canonicalize.
|
- **Stream queues:** integrations can supply an ordered yt-dlp/Miruro queue with `--new-instance --queue-file /absolute/queue.json`, or append without interrupting the running player using `--enqueue --queue-file /absolute/queue.json` (also for a single item). Enqueue starts playback if the player is empty or not running. The same sidebar supports play, reorder, remove, previous/next and automatic advance. Unresolved titles, including the selected row, load serially at lower priority without waiting for playback loading, buffering or quality extraction. YouTube uses a title-only oEmbed request; other sites use yt-dlp metadata and Miruro uses optional episode metadata. Arrival still depends on the site/network; failed lookups retain the supplied label, and full playback metadata takes precedence. Playback sources still resolve fresh when selected or revisited; local-file batches still use natural filename order. yt-dlp items use the original page URL for resume and version-1 progress reports; recognized YouTube video URLs still canonicalize.
|
||||||
- **Seeking:** click or drag the progress track or thumb to seek. The thumb centers on the cursor, clamped to the track endpoints.
|
- **Seeking:** click or drag the progress track or thumb to seek. The thumb centers on the cursor, clamped to the track endpoints.
|
||||||
- **Miruro metadata:** titles and opening/ending intervals load independently of playback when available. Small rounded gaps mark interval boundaries in the slider. Inside a segment, **Skip Opening** or **Skip Ending** appears at the top-right below the titlebar for up to ten seconds or until the segment ends; clicking seeks to its end. A light background fill grows as the offer expires, with a short entrance/exit transition; Reduce Motion uses stepped countdown updates without the transition. Ignored offers stay dismissed until the media is closed or replaced. Missing/invalid metadata leaves playback usable, and timings may differ between cuts. Nothing skips automatically.
|
- **Miruro metadata:** titles and opening/ending intervals load independently of playback when available. Small rounded gaps mark interval boundaries in the slider. Inside a segment, **Skip Opening** or **Skip Ending** appears at the top-right below the titlebar for up to ten seconds or until the segment ends; clicking seeks to its end. A light background fill grows as the offer expires, with a short entrance/exit transition; Reduce Motion uses stepped countdown updates without the transition. Ignored offers stay dismissed until the media is closed or replaced. Missing/invalid metadata leaves playback usable, and timings may differ between cuts. Nothing skips automatically.
|
||||||
- **Resume:** videos resume their saved position; finished videos restart from the beginning. Live and audio-only media do not save progress.
|
- **Resume:** videos resume their saved position; finished videos restart from the beginning. Live and audio-only media do not save progress.
|
||||||
|
|||||||
+10
-12
@@ -187,6 +187,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
private var reportingErrorShown = false
|
private var reportingErrorShown = false
|
||||||
private var reportingErrorBuffer = [CChar]()
|
private var reportingErrorBuffer = [CChar]()
|
||||||
private let youtubeResolver: @MainActor (URL, Int?) async throws -> YouTubeVideo
|
private let youtubeResolver: @MainActor (URL, Int?) async throws -> YouTubeVideo
|
||||||
|
private let youtubeTitleResolver: @MainActor (URL) async throws -> String?
|
||||||
private let miruroResolver: @MainActor (String, Int) async throws -> ResolvedSource
|
private let miruroResolver: @MainActor (String, Int) async throws -> ResolvedSource
|
||||||
private let miruroMetadataResolver: @MainActor (String, Int) async throws -> MiruroMetadata
|
private let miruroMetadataResolver: @MainActor (String, Int) async throws -> MiruroMetadata
|
||||||
private var reportingErrorMessage: String {
|
private var reportingErrorMessage: String {
|
||||||
@@ -196,6 +197,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
init(defaults: UserDefaults = .standard, configuration: PlayerConfiguration = PlayerConfiguration(),
|
init(defaults: UserDefaults = .standard, configuration: PlayerConfiguration = PlayerConfiguration(),
|
||||||
progressFile: String? = nil, streamQueue: StreamQueue? = nil,
|
progressFile: String? = nil, streamQueue: StreamQueue? = nil,
|
||||||
youtubeResolver: @escaping @MainActor (URL, Int?) async throws -> YouTubeVideo = { try await YouTubeResolver.resolve($0, height: $1) },
|
youtubeResolver: @escaping @MainActor (URL, Int?) async throws -> YouTubeVideo = { try await YouTubeResolver.resolve($0, height: $1) },
|
||||||
|
youtubeTitleResolver: @escaping @MainActor (URL) async throws -> String? = { try await YouTubeResolver.resolveTitle($0) },
|
||||||
miruroResolver: @escaping @MainActor (String, Int) async throws -> ResolvedSource = { try await MiruroResolver.resolve(mediaID: $0, episode: $1) },
|
miruroResolver: @escaping @MainActor (String, Int) async throws -> ResolvedSource = { try await MiruroResolver.resolve(mediaID: $0, episode: $1) },
|
||||||
miruroMetadataResolver: @escaping @MainActor (String, Int) async throws -> MiruroMetadata = { try await MiruroMetadataResolver.resolve(mediaID: $0, episode: $1) },
|
miruroMetadataResolver: @escaping @MainActor (String, Int) async throws -> MiruroMetadata = { try await MiruroMetadataResolver.resolve(mediaID: $0, episode: $1) },
|
||||||
qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime },
|
qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime },
|
||||||
@@ -203,6 +205,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
self.qualityClock = qualityClock
|
self.qualityClock = qualityClock
|
||||||
self.skipClock = skipClock
|
self.skipClock = skipClock
|
||||||
self.youtubeResolver = youtubeResolver
|
self.youtubeResolver = youtubeResolver
|
||||||
|
self.youtubeTitleResolver = youtubeTitleResolver
|
||||||
self.miruroResolver = miruroResolver
|
self.miruroResolver = miruroResolver
|
||||||
self.miruroMetadataResolver = miruroMetadataResolver
|
self.miruroMetadataResolver = miruroMetadataResolver
|
||||||
keyBindings = configuration.keys
|
keyBindings = configuration.keys
|
||||||
@@ -656,7 +659,6 @@ final class PlayerModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func cancelStreamResolution() {
|
private func cancelStreamResolution() {
|
||||||
cancelQueueMetadata()
|
|
||||||
guard transition.isResolving else { return }
|
guard transition.isResolving else { return }
|
||||||
objectWillChange.send()
|
objectWillChange.send()
|
||||||
transition.cancelResolution()
|
transition.cancelResolution()
|
||||||
@@ -681,18 +683,14 @@ final class PlayerModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func resolveQueueMetadataIfNeeded() {
|
private func resolveQueueMetadataIfNeeded() {
|
||||||
// Playback and quality changes own the network before sidebar enrichment.
|
// One utility-priority title lookup runs alongside (never behind) stream extraction.
|
||||||
guard !isBusy, !resolvingStream, !state.buffering else {
|
|
||||||
cancelQueueMetadata()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if let request = queueMetadataTask,
|
if let request = queueMetadataTask,
|
||||||
!queue.items.contains(where: { $0.id == request.id }) {
|
!queue.items.contains(where: { $0.id == request.id }) {
|
||||||
cancelQueueMetadata()
|
cancelQueueMetadata()
|
||||||
}
|
}
|
||||||
guard queueMetadataPending, queueMetadataTask == nil else { return }
|
guard queueMetadataPending, queueMetadataTask == nil else { return }
|
||||||
guard let item = queue.items.first(where: {
|
guard let item = queue.items.first(where: {
|
||||||
!$0.url.isFileURL && $0.id != queue.currentID && !$0.metadataAttempted
|
!$0.url.isFileURL && !$0.metadataAttempted
|
||||||
}) else {
|
}) else {
|
||||||
queueMetadataPending = false
|
queueMetadataPending = false
|
||||||
return
|
return
|
||||||
@@ -701,16 +699,14 @@ final class PlayerModel: ObservableObject {
|
|||||||
queue.updateLabel(nil, for: item.id)
|
queue.updateLabel(nil, for: item.id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let youtube = youtubeResolver
|
let youtube = youtubeTitleResolver
|
||||||
let miruro = miruroMetadataResolver
|
let miruro = miruroMetadataResolver
|
||||||
let task = Task(priority: .utility) { [weak self] in
|
let task = Task(priority: .utility) { [weak self] in
|
||||||
let title: String?
|
let title: String?
|
||||||
do {
|
do {
|
||||||
switch stream {
|
switch stream {
|
||||||
case .youtube(let url):
|
case .youtube(let url):
|
||||||
let video = try await youtube(url, nil)
|
title = try await youtube(url)
|
||||||
guard video.webpageURL == url else { throw CocoaError(.fileReadCorruptFile) }
|
|
||||||
title = video.title
|
|
||||||
case .miruro(let id, let episode):
|
case .miruro(let id, let episode):
|
||||||
title = try await miruro(id, episode).title
|
title = try await miruro(id, episode).title
|
||||||
}
|
}
|
||||||
@@ -719,7 +715,8 @@ final class PlayerModel: ObservableObject {
|
|||||||
title = nil
|
title = nil
|
||||||
}
|
}
|
||||||
guard !Task.isCancelled, let self else { return }
|
guard !Task.isCancelled, let self else { return }
|
||||||
if self.queue.currentID != item.id {
|
// Full playback/episode metadata may have populated this row while we waited.
|
||||||
|
if self.queue.items.first(where: { $0.id == item.id })?.metadataAttempted == false {
|
||||||
self.queue.updateLabel(title, for: item.id)
|
self.queue.updateLabel(title, for: item.id)
|
||||||
}
|
}
|
||||||
self.queueMetadataTask = nil
|
self.queueMetadataTask = nil
|
||||||
@@ -1559,6 +1556,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
if castDevice != nil { stopCasting(resume: false) }
|
if castDevice != nil { stopCasting(resume: false) }
|
||||||
castDiscovery.stop()
|
castDiscovery.stop()
|
||||||
cancelStreamResolution()
|
cancelStreamResolution()
|
||||||
|
cancelQueueMetadata()
|
||||||
replaceMetadata(for: nil)
|
replaceMetadata(for: nil)
|
||||||
selectSubtitle(nil)
|
selectSubtitle(nil)
|
||||||
systemPlayback?.shutdown()
|
systemPlayback?.shutdown()
|
||||||
|
|||||||
@@ -139,22 +139,97 @@ enum YouTubeResolver {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --skip-download/-j still perform extraction; --flat-playlist only skips playlist
|
||||||
|
// entries. YouTube's oEmbed endpoint avoids format and JavaScript challenge work.
|
||||||
|
static func resolveTitle(_ url: URL, executableURL: URL? = nil,
|
||||||
|
configuration: URLSessionConfiguration? = nil) async throws -> String? {
|
||||||
|
try Task.checkCancellation()
|
||||||
|
let webpageURL = try validateURL(url.absoluteString)
|
||||||
|
if webpageURL.host == "www.youtube.com", webpageURL.path == "/watch",
|
||||||
|
URLComponents(url: webpageURL, resolvingAgainstBaseURL: false)?.queryItems?.first?.name == "v" {
|
||||||
|
var endpoint = URLComponents(string: "https://www.youtube.com/oembed")!
|
||||||
|
endpoint.queryItems = [URLQueryItem(name: "url", value: webpageURL.absoluteString),
|
||||||
|
URLQueryItem(name: "format", value: "json")]
|
||||||
|
let configuration = configuration ?? .ephemeral
|
||||||
|
configuration.timeoutIntervalForRequest = 15
|
||||||
|
configuration.timeoutIntervalForResource = 20
|
||||||
|
configuration.httpShouldSetCookies = false
|
||||||
|
configuration.httpCookieStorage = nil
|
||||||
|
configuration.urlCache = nil
|
||||||
|
let session = URLSession(configuration: configuration, delegate: YouTubeTitleRedirectPolicy(), delegateQueue: nil)
|
||||||
|
defer { session.invalidateAndCancel() }
|
||||||
|
let (bytes, response) = try await session.bytes(from: endpoint.url!)
|
||||||
|
guard let http = response as? HTTPURLResponse, http.statusCode == 200,
|
||||||
|
http.mimeType == "application/json", response.expectedContentLength <= 65536 else {
|
||||||
|
throw YouTubeResolutionError(message: "The optional video title was unavailable.")
|
||||||
|
}
|
||||||
|
var data = Data()
|
||||||
|
for try await byte in bytes {
|
||||||
|
try Task.checkCancellation()
|
||||||
|
guard data.count < 65536 else {
|
||||||
|
throw YouTubeResolutionError(message: "The optional video title was too large.")
|
||||||
|
}
|
||||||
|
data.append(byte)
|
||||||
|
}
|
||||||
|
try Task.checkCancellation()
|
||||||
|
return try decodeTitle(data)
|
||||||
|
}
|
||||||
|
let (executable, directories) = try extractorExecutable(executableURL)
|
||||||
|
var environment = ProcessInfo.processInfo.environment
|
||||||
|
environment["PATH"] = directories.joined(separator: ":")
|
||||||
|
// Generic sites may need site-specific extraction, but no playback format,
|
||||||
|
// runtime, stream validation or native media load is required for their title.
|
||||||
|
let arguments = [
|
||||||
|
"--ignore-config", "--no-plugin-dirs", "--no-cache-dir", "--no-remote-components",
|
||||||
|
"--no-playlist", "--flat-playlist", "--simulate", "--skip-download", "--dump-single-json",
|
||||||
|
"--no-progress", "--color", "never", "--no-check-formats", "--ignore-no-formats-error",
|
||||||
|
"--no-js-runtimes", "--socket-timeout", "15", "--extractor-retries", "0", "--retries", "0",
|
||||||
|
"--", webpageURL.absoluteString
|
||||||
|
]
|
||||||
|
let output = try await YouTubeExtraction(executable: executable, arguments: arguments, environment: environment).run()
|
||||||
|
try Task.checkCancellation()
|
||||||
|
return try decodeTitle(output.stdout)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decodeTitle(_ data: Data) throws -> String? {
|
||||||
|
struct Metadata: Decodable { let title: String?; let _type: String? }
|
||||||
|
guard data.count <= outputLimit else {
|
||||||
|
throw YouTubeResolutionError(message: "The optional video title was too large.")
|
||||||
|
}
|
||||||
|
let metadata = try JSONDecoder().decode(Metadata.self, from: data)
|
||||||
|
guard metadata._type == nil || ["video", "url", "url_transparent"].contains(metadata._type!) else {
|
||||||
|
throw YouTubeResolutionError(message: "The optional video title did not identify a single video.")
|
||||||
|
}
|
||||||
|
guard let title = metadata.title?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!title.isEmpty else { return nil }
|
||||||
|
guard title.utf8.count <= 16384,
|
||||||
|
!title.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) else {
|
||||||
|
throw YouTubeResolutionError(message: "The optional video title was invalid.")
|
||||||
|
}
|
||||||
|
return title
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func extractorExecutable(_ override: URL?) throws -> (URL, [String]) {
|
||||||
|
let bundle = Bundle.main.bundleURL
|
||||||
|
let directories = bundle.pathExtension == "app"
|
||||||
|
? [bundle.appendingPathComponent("Contents/MacOS").path]
|
||||||
|
: searchDirectories()
|
||||||
|
let executable = try override.map { try executableFile($0, name: "yt-dlp") }
|
||||||
|
?? bundledExecutable("yt-dlp")
|
||||||
|
?? findExecutable("yt-dlp", directories: directories)
|
||||||
|
guard let executable else {
|
||||||
|
throw YouTubeResolutionError(message: "Stream playback requires yt-dlp. Rebuild or reinstall terebi with its bundled helpers.")
|
||||||
|
}
|
||||||
|
return (executable, directories)
|
||||||
|
}
|
||||||
|
|
||||||
static func resolve(_ url: URL, height: Int? = nil, executableURL: URL? = nil, runtimeURL: URL? = nil) async throws -> YouTubeVideo {
|
static func resolve(_ url: URL, height: Int? = nil, executableURL: URL? = nil, runtimeURL: URL? = nil) async throws -> YouTubeVideo {
|
||||||
try Task.checkCancellation()
|
try Task.checkCancellation()
|
||||||
let webpageURL = try validateURL(url.absoluteString)
|
let webpageURL = try validateURL(url.absoluteString)
|
||||||
guard height == nil || height! > 0 else {
|
guard height == nil || height! > 0 else {
|
||||||
throw YouTubeResolutionError(message: "Choose a positive video resolution.")
|
throw YouTubeResolutionError(message: "Choose a positive video resolution.")
|
||||||
}
|
}
|
||||||
let bundle = Bundle.main.bundleURL
|
let (executable, directories) = try extractorExecutable(executableURL)
|
||||||
let directories = bundle.pathExtension == "app"
|
|
||||||
? [bundle.appendingPathComponent("Contents/MacOS").path]
|
|
||||||
: searchDirectories()
|
|
||||||
let executable = try executableURL.map { try executableFile($0, name: "yt-dlp") }
|
|
||||||
?? bundledExecutable("yt-dlp")
|
|
||||||
?? findExecutable("yt-dlp", directories: directories)
|
|
||||||
guard let executable else {
|
|
||||||
throw YouTubeResolutionError(message: "Stream playback requires yt-dlp. Rebuild or reinstall terebi with its bundled helpers.")
|
|
||||||
}
|
|
||||||
let runtime: URL?
|
let runtime: URL?
|
||||||
if let runtimeURL {
|
if let runtimeURL {
|
||||||
runtime = try executableFile(runtimeURL, name: "JavaScript runtime")
|
runtime = try executableFile(runtimeURL, name: "JavaScript runtime")
|
||||||
@@ -284,6 +359,14 @@ enum YouTubeResolver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private final class YouTubeTitleRedirectPolicy: NSObject, URLSessionTaskDelegate {
|
||||||
|
func urlSession(_ session: URLSession, task: URLSessionTask,
|
||||||
|
willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest,
|
||||||
|
completionHandler: @escaping @Sendable (URLRequest?) -> Void) {
|
||||||
|
completionHandler(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private struct YouTubeMetadata: Decodable {
|
private struct YouTubeMetadata: Decodable {
|
||||||
let _type: String?
|
let _type: String?
|
||||||
let title: String?
|
let title: String?
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
- [x] in compact mode, "side"bar should be expandable downwards (but hidden by default).
|
- [x] in compact mode, "side"bar should be expandable downwards (but hidden by default).
|
||||||
- let's also add a keybinding that toggles sidebar visibility in both normal and compact mode.
|
- let's also add a keybinding that toggles sidebar visibility in both normal and compact mode.
|
||||||
- also add some horizontal margin to the list entries
|
- also add some horizontal margin to the list entries
|
||||||
- [ ] sidebar: fetch data quicker, so titles are populated immediately instead of only after everything from the current video has been fetched.
|
- [x] sidebar: fetch data quicker, so titles are populated independently of current-video stream extraction (including the current row); arrival still depends on the site/network.
|
||||||
@@ -47,7 +47,7 @@ The manifest is bounded to 8 MiB, version 1, and 1–4096 entries in playback or
|
|||||||
|
|
||||||
Use canonical YouTube video pages or supported Miruro episode pages, never expiring source addresses. Stable identities must be unique even across alternate YouTube forms or different Miruro slugs. Reports must have distinct absolute nonexistent destinations, each under a canonical, nonsymlink, user-owned `0700` directory. Tubular retains each item's existing identity registration/session and stores the manifest in the first session. The CLI validates the complete document before launching; the GUI validates it again before opening any media. Invalid queues fail rather than falling back to a single stream. Queue launch requires either `--new-instance` (replace in a separate player) or `--enqueue` (append to the running player), accepts `--no-config`, and cannot combine with `--url`, `--source-file` or `--progress-file`. `--enqueue` and `--new-instance` are mutually exclusive.
|
Use canonical YouTube video pages or supported Miruro episode pages, never expiring source addresses. Stable identities must be unique even across alternate YouTube forms or different Miruro slugs. Reports must have distinct absolute nonexistent destinations, each under a canonical, nonsymlink, user-owned `0700` directory. Tubular retains each item's existing identity registration/session and stores the manifest in the first session. The CLI validates the complete document before launching; the GUI validates it again before opening any media. Invalid queues fail rather than falling back to a single stream. Queue launch requires either `--new-instance` (replace in a separate player) or `--enqueue` (append to the running player), accepts `--no-config`, and cannot combine with `--url`, `--source-file` or `--progress-file`. `--enqueue` and `--new-instance` are mutually exclusive.
|
||||||
|
|
||||||
The supplied order is preserved (local-file batches still use natural filename order). Existing sidebar reorder/remove/play and previous/next navigation apply. Natural end advances without clearing the queue. Unselected labels are enriched serially at utility priority after playback loading settles; buffering, selection and quality changes cancel background work. yt-dlp extraction supplies page titles; Miruro uses optional watch-route metadata without fetching playback sources. Failures keep the supplied label and do not block later entries or surface playback errors. Results follow item IDs across reordering and cannot rename removed/replaced rows. Only titles are retained: playback resolves fresh on selection/revisits, and canceled/stale results cannot replace a newer selection.
|
The supplied order is preserved (local-file batches still use natural filename order). Existing sidebar reorder/remove/play and previous/next navigation apply. Natural end advances without clearing the queue. Unresolved labels, including the current row, start serial utility-priority lookups without waiting for active playback or quality extraction, loading or buffering. YouTube uses native oEmbed without format/challenge extraction; generic HTTP(S) sites use isolated yt-dlp metadata extraction without requiring playable formats or a JavaScript runtime (site-specific extraction may still take time). Miruro uses optional watch-route metadata without fetching playback sources. Network arrival is not instantaneous. Failures keep the supplied label and do not block later entries or surface playback errors. Results follow item IDs across reordering and cannot rename removed/replaced rows or overwrite richer foreground metadata. Only titles are retained: playback still resolves fresh on selection/revisits.
|
||||||
|
|
||||||
Enqueue uses native macOS URL-open delivery to the explicit application, not launch arguments: repeated requests reach the existing process. Both one-item and multi-item requests use the same manifest. The current selection, playback/pause state, timeline, pending resolution and metadata stay intact; an empty player selects and starts the first entry. Original single-file, single-stream and resolved-source playback remain navigable when items are appended. Enqueue never silently deduplicates: any identity still in the queue, or any report-path collision, rejects the complete batch with an error in the player. Removed videos can be re-added with fresh report destinations, including the original standalone video. Old report paths remain reserved for the process lifetime; at most 4096 queue-report destinations may be registered, counting retired sessions. Existing files are never adopted as reporting sessions. Keep manifests and private sessions available until delivery; CLI success acknowledges macOS delivery, not GUI acceptance or playback.
|
Enqueue uses native macOS URL-open delivery to the explicit application, not launch arguments: repeated requests reach the existing process. Both one-item and multi-item requests use the same manifest. The current selection, playback/pause state, timeline, pending resolution and metadata stay intact; an empty player selects and starts the first entry. Original single-file, single-stream and resolved-source playback remain navigable when items are appended. Enqueue never silently deduplicates: any identity still in the queue, or any report-path collision, rejects the complete batch with an error in the player. Removed videos can be re-added with fresh report destinations, including the original standalone video. Old report paths remain reserved for the process lifetime; at most 4096 queue-report destinations may be registered, counting retired sessions. Existing files are never adopted as reporting sessions. Keep manifests and private sessions available until delivery; CLI success acknowledges macOS delivery, not GUI acceptance or playback.
|
||||||
|
|
||||||
@@ -55,7 +55,9 @@ Each report is immutably bound to its stable identity in the native decoder. Reg
|
|||||||
|
|
||||||
Runnable headless queue checks: `make check-cli check-queue check-reporting check-resolved`. The existing `check-resolved` local HTTP/HLS/VTT harness drives a real native `PlayerModel` through mixed YouTube/Miruro queues with injected fixture resolution, stale-result cancellation, manual navigation, natural-end advancement, fresh revisits/resume and isolated v1/v2 reports. It also delivers repeated enqueue URLs while paused and playing, checks original standalone selection/timeline preservation, lazy append ordering, atomic invalid/conflicting requests, independent single/queue report revisits, removal/re-addition with fresh reports and unchanged retired files, and empty-player startup. These checks require no browser or live streaming service. Actual LaunchServices/UI delivery remains a separate user-owned verification step; no GUI smoke result is claimed for enqueue.
|
Runnable headless queue checks: `make check-cli check-queue check-reporting check-resolved`. The existing `check-resolved` local HTTP/HLS/VTT harness drives a real native `PlayerModel` through mixed YouTube/Miruro queues with injected fixture resolution, stale-result cancellation, manual navigation, natural-end advancement, fresh revisits/resume and isolated v1/v2 reports. It also delivers repeated enqueue URLs while paused and playing, checks original standalone selection/timeline preservation, lazy append ordering, atomic invalid/conflicting requests, independent single/queue report revisits, removal/re-addition with fresh reports and unchanged retired files, and empty-player startup. These checks require no browser or live streaming service. Actual LaunchServices/UI delivery remains a separate user-owned verification step; no GUI smoke result is claimed for enqueue.
|
||||||
|
|
||||||
Background-title verification: `make check-resolved check-queue check-youtube all` passed. The native local-HLS smoke exercises unplayed YouTube/Miruro titles, serial utility-priority enrichment, unchanged paused selection/position/title, reordering during lookup, failure fallbacks, fresh playback preemption, and stale removal/replacement results. No browser or live service was used; sidebar appearance remains a user-owned visual check.
|
Background-title regression: the native local-HLS check first failed because the current row stayed unresolved while foreground stream extraction was held. The updated check holds both playback and quality extraction and requires current/queued titles before releasing them; it also covers serial utility-priority lookup, generic/Miruro titles, reorder, failure fallbacks, unchanged paused selection/position/current title, foreground precedence and stale removal/replacement. Resolver checks exercise bounded native YouTube oEmbed with an intercepted URLSession and real bundled yt-dlp against local metadata fixtures without playable formats. No browser or live service is needed; sidebar appearance remains a user-owned visual check.
|
||||||
|
|
||||||
|
Verified with `make check-youtube check-queue check-resolved check-adaptive check-stream all`: all passed. This includes native oEmbed cancellation and response-size bounds (declared and streamed), metadata-only bundled extraction, real local HLS/VTT playback, and app/CLI builds. No live-service latency or visual UI result is claimed.
|
||||||
|
|
||||||
Removal/re-addition verification: the new native registration regression failed before the fix and passed afterward. `make check-reporting check-queue .build/resolved-playback-check all` passed. A focused `.build/resolved-playback-check /absolute/source.json --enqueue-only` smoke using local video-only H.264 HLS passed paused/playing append, natural-end advancement, atomic conflicts/rollback, removal and re-addition of the current standalone video and an inactive queued video, fresh-session playback, byte-for-byte retired-report preservation, and empty-player startup. The source document requires `headers` and `subtitles` (empty values are valid). No full A/V-suite or packaged-GUI/LaunchServices pass is claimed; the app was built, not installed. Actual UI confirmation remains user-owned; temporary fixture/server files were removed.
|
Removal/re-addition verification: the new native registration regression failed before the fix and passed afterward. `make check-reporting check-queue .build/resolved-playback-check all` passed. A focused `.build/resolved-playback-check /absolute/source.json --enqueue-only` smoke using local video-only H.264 HLS passed paused/playing append, natural-end advancement, atomic conflicts/rollback, removal and re-addition of the current standalone video and an inactive queued video, fresh-session playback, byte-for-byte retired-report preservation, and empty-player startup. The source document requires `headers` and `subtitles` (empty values are valid). No full A/V-suite or packaged-GUI/LaunchServices pass is claimed; the app was built, not installed. Actual UI confirmation remains user-owned; temporary fixture/server files were removed.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user