feat: resolve HTTP video URLs with bundled yt-dlp

This commit is contained in:
2026-10-22 17:05:29 +02:00
parent 6a4afc9f0e
commit b132bab393
14 changed files with 121 additions and 79 deletions
+11 -2
View File
@@ -36,6 +36,10 @@ struct PlayerCommandChecks {
let miruro = URL(string: "https://barelystarted.miruro.tv/watch/OERe_a0YS4h-DlsBDOeXwX7HOO6316tw/the-apothecary-diaries-season-3?ep=1")! let miruro = URL(string: "https://barelystarted.miruro.tv/watch/OERe_a0YS4h-DlsBDOeXwX7HOO6316tw/the-apothecary-diaries-season-3?ep=1")!
require(try opened([miruro.absoluteString]) == miruro, "Miruro positional URLs retain episode page identity") require(try opened([miruro.absoluteString]) == miruro, "Miruro positional URLs retain episode page identity")
require(try opened(["--url", miruro.absoluteString]) == miruro, "explicit Miruro URLs forward without network resolution") require(try opened(["--url", miruro.absoluteString]) == miruro, "explicit Miruro URLs forward without network resolution")
let generic = URL(string: "https://vimeo.com/76979871?quality=original")!
require(try opened([generic.absoluteString]) == generic &&
opened(["--url", generic.absoluteString]) == generic,
"positional and explicit third-party URLs reach extraction unchanged")
require(try opened(["--new-instance"], newInstance: true) == nil, "an independent launch can omit media") require(try opened(["--new-instance"], newInstance: true) == nil, "an independent launch can omit media")
require(try opened(["--new-instance", movie.lastPathComponent], newInstance: true) == movie, require(try opened(["--new-instance", movie.lastPathComponent], newInstance: true) == movie,
"independent local launches preserve relative paths and spaces") "independent local launches preserve relative paths and spaces")
@@ -171,6 +175,12 @@ struct PlayerCommandChecks {
require(parsedQueue.entries.map(\.url) == [miruro, canonical], "stream queue order is supplied, never filename-sorted") require(parsedQueue.entries.map(\.url) == [miruro, canonical], "stream queue order is supplied, never filename-sorted")
require(parsedQueue.entries[0].identity.hasPrefix("miruro:") && require(parsedQueue.entries[0].identity.hasPrefix("miruro:") &&
parsedQueue.entries[1].identity == canonical.absoluteString, "stable identities exclude slugs and transport URLs") parsedQueue.entries[1].identity == canonical.absoluteString, "stable identities exclude slugs and transport URLs")
try saveQueue(["version": 1, "items": [["url": generic.absoluteString, "progress_file": youtubeReport]]])
let genericQueue = try StreamQueue.load(from: queueFile)
require(genericQueue.entries[0].url == generic &&
genericQueue.entries[0].identity == generic.absoluteString &&
!genericQueue.entries[0].label.contains("YouTube"),
"third-party queues retain page identity without a YouTube label")
let duplicateYouTube = ["url": "https://youtu.be/jNQXAC9IVRw", "progress_file": miruroReport] let duplicateYouTube = ["url": "https://youtu.be/jNQXAC9IVRw", "progress_file": miruroReport]
let duplicateMiruro = ["url": miruro.absoluteString.replacingOccurrences(of: "the-apothecary-diaries-season-3", with: "different-slug"), let duplicateMiruro = ["url": miruro.absoluteString.replacingOccurrences(of: "the-apothecary-diaries-season-3", with: "different-slug"),
"progress_file": youtubeReport] "progress_file": youtubeReport]
@@ -183,7 +193,6 @@ struct PlayerCommandChecks {
["version": 1, "items": [entries[0], ["url": canonical.absoluteString, "progress_file": miruroReport]]], ["version": 1, "items": [entries[0], ["url": canonical.absoluteString, "progress_file": miruroReport]]],
["version": 1, "items": [entries[0], ["url": canonical.absoluteString, ["version": 1, "items": [entries[0], ["url": canonical.absoluteString,
"progress_file": privateDirectory.resolvingSymlinksInPath().appendingPathComponent("miruro.jsonl").path]]], "progress_file": privateDirectory.resolvingSymlinksInPath().appendingPathComponent("miruro.jsonl").path]]],
["version": 1, "items": [["url": "https://cdn.example/expired.m3u8", "progress_file": youtubeReport]]],
["version": 1, "items": [["url": canonical.absoluteString, "progress_file": "relative.jsonl"]]], ["version": 1, "items": [["url": canonical.absoluteString, "progress_file": "relative.jsonl"]]],
["version": 1, "items": [["url": canonical.absoluteString, "progress_file": movie.path]]] ["version": 1, "items": [["url": canonical.absoluteString, "progress_file": movie.path]]]
] ]
@@ -221,7 +230,7 @@ struct PlayerCommandChecks {
for arguments in [["--url"], ["--url", canonical.absoluteString, movie.path], ["--"], for arguments in [["--url"], ["--url", canonical.absoluteString, movie.path], ["--"],
[movie.path, movie.path], ["--unknown"], ["missing.mp4"], [directory.path], [movie.path, movie.path], ["--unknown"], ["missing.mp4"], [directory.path],
["file://remote-host/video.mp4"], ["https://example.com/video.mp4"], ["file://remote-host/video.mp4"], ["ftp://example.com/video.mp4"],
["--new-instance", "--new-instance"], ["--new-instance", "--url"], ["--new-instance", "--new-instance"], ["--new-instance", "--url"],
["--new-instance", "--"], ["--new-instance", movie.path, movie.path], ["--new-instance", "--"], ["--new-instance", movie.path, movie.path],
["--new-instance", "--unknown"], ["--no-config", "--no-config"], ["--new-instance", "--unknown"], ["--no-config", "--no-config"],
+21 -8
View File
@@ -55,7 +55,7 @@ static NSArray<NSDictionary *> *records(const std::string &path) {
"schema version is numeric one"); "schema version is numeric one");
require([@[@"progress", @"ended", @"closed"] containsObject:row[@"event"]], "known event type"); require([@[@"progress", @"ended", @"closed"] containsObject:row[@"event"]], "known event type");
require([row[@"media_url"] isKindOfClass:[NSString class]] && require([row[@"media_url"] isKindOfClass:[NSString class]] &&
ProgressReporter::validMediaURL([row[@"media_url"] UTF8String]), "canonical consumer identity"); ProgressReporter::validMediaURL([row[@"media_url"] UTF8String]), "HTTP(S) consumer identity");
require([row[@"position_seconds"] isKindOfClass:[NSNumber class]] && require([row[@"position_seconds"] isKindOfClass:[NSNumber class]] &&
std::isfinite([row[@"position_seconds"] doubleValue]) && std::isfinite([row[@"position_seconds"] doubleValue]) &&
[row[@"position_seconds"] doubleValue] >= 0, "position is finite and nonnegative"); [row[@"position_seconds"] doubleValue] >= 0, "position is finite and nonnegative");
@@ -240,13 +240,26 @@ int main(int argc, char **argv) {
for (NSUInteger i = 0; i < rows.count; ++i) for (NSUInteger i = 0; i < rows.count; ++i)
expect(rows[i], i % 2 ? @"closed" : @"progress", firstURL, 97, false, i < 8 ? 0 : 100); expect(rows[i], i % 2 ? @"closed" : @"progress", firstURL, 97, false, i < 8 ? 0 : 100);
require(ProgressReporter::validMediaURL(firstURL) && const std::string genericURL = "https://video.example/watch?label=\"quoted\"&id=123#part";
ProgressReporter::validMediaURL("https://www.youtube.com/watch?v=aA0_-123456"), "eleven ASCII ID characters are accepted"); require(ProgressReporter::validMediaURL(genericURL) &&
for (const char *invalid : {"", "http://www.youtube.com/watch?v=AAAAAAAAAAA", ProgressReporter::validMediaURL("http://127.0.0.1:8080/video.mp4"),
"https://youtube.com/watch?v=AAAAAAAAAAA", "https://www.youtube.com/watch?v=AAAAAAAAAA", "HTTP(S) page identities are accepted beyond YouTube");
"https://www.youtube.com/watch?v=AAAAAAAAAAAA", "https://www.youtube.com/watch?v=AAAAAAAAAAA&x=1", for (const char *invalid : {"", "file:///tmp/video.mp4", "ftp://example.com/video",
"https://www.youtube.com/watch?v=AAAAAAAAAA%", "https://www.youtube.com/watch?v=AAAAAAAAAA\n"}) "https://", "https://user:secret@example.com/video", "https://example.com:0/video",
require(!ProgressReporter::validMediaURL(invalid), "noncanonical URLs are rejected"); "https://example.com:65536/video", "https://example.com/video\n"})
require(!ProgressReporter::validMediaURL(invalid), "unsafe page identities are rejected");
const auto genericPath = root + "/generic.jsonl";
{
ProgressReporter reporter(genericPath, [] { return 0.; });
reporter.begin(genericURL);
reporter.observe(sample(PLAYER_PLAYING, 25));
reporter.finish();
require(reporter.error().empty(), "generic page progress drains successfully");
}
rows = records(genericPath);
require(rows.count == 2, "generic page has initial and terminal snapshots");
expect(rows[0], @"progress", genericURL, 25, true);
expect(rows[1], @"closed", genericURL, 25, true);
require(ProgressReporter::validMediaIdentity("miruro:opaque_A-1:12") && require(ProgressReporter::validMediaIdentity("miruro:opaque_A-1:12") &&
ProgressReporter::validMediaIdentity("miruro:a:2147483647"), "strict Miruro identities accepted"); ProgressReporter::validMediaIdentity("miruro:a:2147483647"), "strict Miruro identities accepted");
+6 -3
View File
@@ -52,9 +52,12 @@ struct StreamResolverChecks {
throw Failure(message: "YouTube URL classification") throw Failure(message: "YouTube URL classification")
} }
try require(youtube.absoluteString == "https://www.youtube.com/watch?v=jNQXAC9IVRw", "existing YouTube normalization") try require(youtube.absoluteString == "https://www.youtube.com/watch?v=jNQXAC9IVRw", "existing YouTube normalization")
let other = "https://vimeo.com/76979871?quality=original"
guard case .youtube(let generic) = try StreamURL.parse(other) else {
throw Failure(message: "other HTTP(S) sites route through yt-dlp")
}
try require(generic.absoluteString == other, "other sites retain their URL and query")
for invalid in [ for invalid in [
"https://example.com/video", "https://www.miruro.to/watch?id=21&ep=1",
page.replacingOccurrences(of: "barelystarted.miruro.tv", with: "barelystarted.miruro.tv.evil.example"),
page.replacingOccurrences(of: "barelystarted.miruro.tv", with: "barelystarted.miruro.tv@evil.example"), page.replacingOccurrences(of: "barelystarted.miruro.tv", with: "barelystarted.miruro.tv@evil.example"),
page.replacingOccurrences(of: "https://", with: "http://"), page.replacingOccurrences(of: "https://", with: "http://"),
page.replacingOccurrences(of: "https://", with: "https://user:secret@"), page.replacingOccurrences(of: "https://", with: "https://user:secret@"),
@@ -70,7 +73,7 @@ struct StreamResolverChecks {
page + "&ep=2", page + "&provider=bee", page + "#secret", page + "&ep=2", page + "&provider=bee", page + "#secret",
page.replacingOccurrences(of: ".tv/", with: ".tv:444/"), page.replacingOccurrences(of: ".tv/", with: ".tv:444/"),
page.replacingOccurrences(of: "?ep", with: "/?ep"), page.replacingOccurrences(of: "?ep", with: "/?ep"),
"https://youtu.be/invalid", "file:///secret" "file:///secret"
] { ] {
try reject("unsupported or unsafe URL") { _ = try StreamURL.parse(invalid) } try reject("unsupported or unsafe URL") { _ = try StreamURL.parse(invalid) }
} }
+32 -18
View File
@@ -70,20 +70,20 @@ struct YouTubeResolverChecks {
let url = try YouTubeResolver.validateURL(input) let url = try YouTubeResolver.validateURL(input)
try require(url == page, "supported video URLs identify the same video") try require(url == page, "supported video URLs identify the same video")
} }
for input in ["https://vimeo.com/76979871", "https://example.com/watch?v=jNQXAC9IVRw",
"https://youtube.com.evil.example/watch?v=jNQXAC9IVRw",
"https://evil.youtube.com/watch?v=jNQXAC9IVRw",
"http://127.0.0.1:8080/video.mp4?token=kept#fragment",
"https://www.youtube.com/playlist?list=PLexample"] {
try require(try YouTubeResolver.validateURL(input).absoluteString == input,
"HTTP(S) URLs outside canonical YouTube video forms reach yt-dlp unchanged")
}
let hostile = [ let hostile = [
"", "not a URL", "javascript:alert(1)", "file:///watch?v=jNQXAC9IVRw", "", "not a URL", "javascript:alert(1)", "file:///watch?v=jNQXAC9IVRw",
"https://example.com/watch?v=jNQXAC9IVRw", "ftp://example.com/video", "https://", "https://example.com:0/video",
"https://youtube.com.evil.example/watch?v=jNQXAC9IVRw", "https://example.com:65536/video", "https://example.com/video\ninjected",
"https://evil.youtube.com/watch?v=jNQXAC9IVRw",
"https://youtube.com@evil.example/watch?v=jNQXAC9IVRw", "https://youtube.com@evil.example/watch?v=jNQXAC9IVRw",
"https://user:password@www.youtube.com/watch?v=jNQXAC9IVRw", "https://user:password@www.youtube.com/watch?v=jNQXAC9IVRw"
"https://www.youtube.com/playlist?list=PLexample",
"https://www.youtube.com/watch?list=PLexample",
"https://www.youtube.com/watch?v=short",
"https://youtu.be/jNQXAC9IVRwextra", "https://youtu.be/jNQXAC9IVR!",
"https://www.youtube.com/shorts/", "https://youtu.be/jNQXAC9IVRw/extra",
"https://www.youtube.com/watch?v=jNQXAC9IVR%0A",
"https://www.youtube.com/watch?v=jNQXAC9IVR%C3%A9"
] ]
for input in hostile { for input in hostile {
try reject("hostile or malformed video URL") { _ = try YouTubeResolver.validateURL(input) } try reject("hostile or malformed video URL") { _ = try YouTubeResolver.validateURL(input) }
@@ -202,6 +202,9 @@ struct YouTubeResolverChecks {
} }
static func malformedMetadata() throws { static func malformedMetadata() throws {
var collection = try fixture(combinedJSON)
collection["_type"] = "playlist"
try reject("collection result instead of a single video") { _ = try decode(collection) }
try reject("non-JSON extractor output") { _ = try decode("not JSON") } try reject("non-JSON extractor output") { _ = try decode("not JSON") }
for json in ["null", "[]", "{}", #"{"requested_formats":[]}"#] { for json in ["null", "[]", "{}", #"{"requested_formats":[]}"#] {
try reject("missing playable stream metadata") { _ = try decode(json) } try reject("missing playable stream metadata") { _ = try decode(json) }
@@ -210,10 +213,11 @@ struct YouTubeResolverChecks {
_ = try decode(#"{"url":"https://media.example/video","vcodec":"h264","acodec":"aac","duration":1e999}"#) _ = try decode(#"{"url":"https://media.example/video","vcodec":"h264","acodec":"aac","duration":1e999}"#)
} }
let combined = try fixture(combinedJSON) let combined = try fixture(combinedJSON)
var missingURL = combined
missingURL.removeValue(forKey: "url")
try reject("missing combined stream URL") { _ = try decode(missingURL) }
for key in ["url", "vcodec", "acodec"] { for key in ["url", "vcodec", "acodec"] {
var object = combined var object = combined
object.removeValue(forKey: key)
try reject("missing required combined \(key)") { _ = try decode(object) }
object[key] = 17 object[key] = 17
try reject("wrong type for combined \(key)") { _ = try decode(object) } try reject("wrong type for combined \(key)") { _ = try decode(object) }
} }
@@ -236,11 +240,9 @@ struct YouTubeResolverChecks {
var hdr = combined var hdr = combined
hdr["dynamic_range"] = "HDR10" hdr["dynamic_range"] = "HDR10"
try reject("HDR input unsupported by the SDR renderer") { _ = try decode(hdr) } try reject("HDR input unsupported by the SDR renderer") { _ = try decode(hdr) }
for value in ["none", NSNull()] as [Any] { var audioOnly = combined
var object = combined audioOnly["vcodec"] = "none"
object["vcodec"] = value try reject("explicitly audio-only result") { _ = try decode(audioOnly) }
try reject("combined input without video codec") { _ = try decode(object) }
}
for transport in ["http_dash_segments", "mhtml", "rtmp"] { for transport in ["http_dash_segments", "mhtml", "rtmp"] {
var object = combined var object = combined
object["protocol"] = transport object["protocol"] = transport
@@ -444,6 +446,18 @@ struct YouTubeResolverChecks {
try JSONSerialization.data(withJSONObject: object).write(to: extractor.marker("response.json")) try JSONSerialization.data(withJSONObject: object).write(to: extractor.marker("response.json"))
let lower = try await YouTubeResolver.resolve(page, executableURL: extractor.executable, runtimeURL: runtime) let lower = try await YouTubeResolver.resolve(page, executableURL: extractor.executable, runtimeURL: runtime)
try require(lower.height == 720, "default falls downward when 1080p is absent, never upward to available 4K") try require(lower.height == 720, "default falls downward when 1080p is absent, never upward to available 4K")
let genericPage = URL(string: "https://video.example/watch?id=123")!
let generic: [String: Any] = [
"id": "123", "title": "Generic HTML5 video", "webpage_url": genericPage.absoluteString,
"extractor": "generic", "extractor_key": "Generic",
"formats": [["format_id": "0", "url": "https://media.example/unknown.mp4",
"ext": "mp4", "protocol": "https"]]
]
try JSONSerialization.data(withJSONObject: generic).write(to: extractor.marker("response.json"))
let unknown = try await YouTubeResolver.resolve(genericPage, executableURL: extractor.executable, runtimeURL: runtime)
try require(unknown.video.url == "https://media.example/unknown.mp4" &&
unknown.audio == nil && unknown.height == nil && unknown.webpageURL == genericPage,
"generic HTML5 video without codec or height hints remains playable and retains page identity")
} }
static func bundledHelpers(root: URL) throws { static func bundledHelpers(root: URL) throws {
+2 -2
View File
@@ -74,7 +74,7 @@ $(APP)/Contents/Resources/TOMLDecoder-LICENSE.txt: $(CONFIG_LIBRARY)
$(BUILD_DIR)/player.o: Sources/Core/player.cpp Sources/Core/player.h Sources/Core/player_macos.h Sources/Core/output.hpp Sources/Core/video_decoder.hpp Sources/Core/network_input.hpp Sources/Core/network_policy.hpp Sources/Core/progress_reporter.hpp Makefile $(NATIVE_STAMP) | $(BUILD_DIR) check-dependencies $(BUILD_DIR)/player.o: Sources/Core/player.cpp Sources/Core/player.h Sources/Core/player_macos.h Sources/Core/output.hpp Sources/Core/video_decoder.hpp Sources/Core/network_input.hpp Sources/Core/network_policy.hpp Sources/Core/progress_reporter.hpp Makefile $(NATIVE_STAMP) | $(BUILD_DIR) check-dependencies
$(CXX) $(CXXFLAGS) -c $< -o $@ $(CXX) $(CXXFLAGS) -c $< -o $@
$(BUILD_DIR)/progress_reporter.o: Sources/Core/progress_reporter.cpp Sources/Core/progress_reporter.hpp Sources/Core/player.h Makefile $(NATIVE_STAMP) | $(BUILD_DIR) $(BUILD_DIR)/progress_reporter.o: Sources/Core/progress_reporter.cpp Sources/Core/progress_reporter.hpp Sources/Core/network_policy.hpp Sources/Core/player.h Makefile $(NATIVE_STAMP) | $(BUILD_DIR)
$(CXX) $(CXXFLAGS) -c $< -o $@ $(CXX) $(CXXFLAGS) -c $< -o $@
$(BUILD_DIR)/cast_media.o: Sources/Core/cast_media.cpp Sources/Core/cast_media.h Sources/Core/video_decoder.hpp Sources/Core/network_policy.hpp Sources/Core/network_input.hpp Makefile $(NATIVE_STAMP) | $(BUILD_DIR) check-dependencies $(BUILD_DIR)/cast_media.o: Sources/Core/cast_media.cpp Sources/Core/cast_media.h Sources/Core/video_decoder.hpp Sources/Core/network_policy.hpp Sources/Core/network_input.hpp Makefile $(NATIVE_STAMP) | $(BUILD_DIR) check-dependencies
@@ -273,7 +273,7 @@ $(BUILD_DIR)/playback-progress-check: Checks/playback-progress.swift Sources/App
check-progress: $(BUILD_DIR)/playback-progress-check check-progress: $(BUILD_DIR)/playback-progress-check
$(BUILD_DIR)/playback-progress-check $(BUILD_DIR)/playback-progress-check
$(BUILD_DIR)/progress-reporting-check: Checks/progress-reporting.mm Sources/Core/progress_reporter.cpp Sources/Core/progress_reporter.hpp Sources/Core/player.h Makefile | $(BUILD_DIR) $(BUILD_DIR)/progress-reporting-check: Checks/progress-reporting.mm Sources/Core/progress_reporter.cpp Sources/Core/progress_reporter.hpp Sources/Core/network_policy.hpp Sources/Core/player.h Makefile | $(BUILD_DIR)
$(CXX) $(CXXFLAGS) -fobjc-arc Checks/progress-reporting.mm Sources/Core/progress_reporter.cpp -framework Foundation -o $@ $(CXX) $(CXXFLAGS) -fobjc-arc Checks/progress-reporting.mm Sources/Core/progress_reporter.cpp -framework Foundation -o $@
check-reporting: $(BUILD_DIR)/progress-reporting-check check-reporting: $(BUILD_DIR)/progress-reporting-check
+6 -6
View File
@@ -1,6 +1,6 @@
# Terebi # Terebi
A native macOS player for local media, YouTube videos and Miruro episodes, with ordered playback queues, saved playback positions, compact mode and Chromecast support. A native macOS player for local media, yt-dlp-supported videos (including YouTube) and Miruro episodes, with ordered playback queues, saved playback positions, compact mode and Chromecast support.
## Build and install ## Build and install
@@ -22,13 +22,13 @@ To run without installing, use `make run`. The built `.build/terebi.app` is self
## Usage ## Usage
- **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; **+** 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; **+** 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 a supported link. YouTube supports individual videos, not playlists or channels. Miruro links must use `https://barelystarted.miruro.tv/watch/<opaque-id>/<slug>?ep=<episode>` with an explicit positive episode number; arbitrary streaming sites are not supported. - **Streams:** use **Open Stream…** (**⇧⌘O**) or drop an HTTP(S) video link. 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 YouTube/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. Only the selected item resolves, including fresh resolution on revisits; local-file batches still use natural filename order. - **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. Only the selected item resolves, including fresh resolution on revisits; 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.
- **Compact mode:** press **P** for a floating player; double-click it to restore. Drag noninteractive areas to move the window. Use the titlebar pin to keep the normal window on top. - **Compact mode:** press **P** for a floating player; double-click it to restore. Drag noninteractive areas to move the window. Use the titlebar pin to keep the normal window on top.
- **Quality:** YouTube defaults to SDR up to 1080p. Use **gear → Quality** for other available resolutions or automatic quality. - **Quality:** yt-dlp defaults to SDR up to 1080p when resolution is reported. Missing resolution/codec hints are left to native probing. Use **gear → Quality** for other reported resolutions or automatic quality.
### Keyboard shortcuts ### Keyboard shortcuts
@@ -52,7 +52,7 @@ Open media, select the **Cast** icon or **Playback → Cast to Device**, and cho
Playback controls and queue navigation work on the receiver. **Play on This Mac** returns to local playback at the receiver's position. Keep the Mac connected: it relays the media and prevents idle system sleep during playback, while allowing display sleep when casting. Closing the lid or explicitly sleeping the Mac can interrupt playback. Playback controls and queue navigation work on the receiver. **Play on This Mac** returns to local playback at the receiver's position. Keep the Mac connected: it relays the media and prevents idle system sleep during playback, while allowing display sleep when casting. Closing the lid or explicitly sleeping the Mac can interrupt playback.
Cast output is limited to 1080p/30 fps; frame stepping and YouTube quality changes are local-only. Use Cast only on a trusted network. AirPlay is not supported. Cast output is limited to 1080p/30 fps; frame stepping and stream quality changes are local-only. Use Cast only on a trusted network. AirPlay is not supported.
## Command line ## Command line
@@ -82,7 +82,7 @@ Copy the settings you want to change into the config file; omitted settings keep
- SDR output only; HDR is not supported. - SDR output only; HDR is not supported.
- No embedded or YouTube captions, manual audio-track selection, rotation or deinterlacing. External WebVTT subtitles are supported for resolved streams. - No embedded or YouTube captions, manual audio-track selection, rotation or deinterlacing. External WebVTT subtitles are supported for resolved streams.
- Private, age-restricted, region-blocked or sign-in-required YouTube videos may fail; browser cookies are not used. Rebuild and reinstall for bundled YouTube extractor updates. - yt-dlp support does not guarantee playback: extraction must yield HTTP(S)/HLS media without download-only fragment assembly. Audio-only results with explicit codec metadata are unsupported. Private, age-restricted, region-blocked or sign-in-required videos may fail; browser cookies are not used. Rebuild and reinstall for bundled extractor updates.
- Seeking can be slow on long-GOP or unindexed media. Live streams cannot be sought, and expired stream URLs are not automatically refreshed. - Seeking can be slow on long-GOP or unindexed media. Live streams cannot be sought, and expired stream URLs are not automatically refreshed.
- Audio-device changes during playback are not handled. - Audio-device changes during playback are not handled.
+1 -1
View File
@@ -64,7 +64,7 @@ struct StreamQueue: Sendable {
case .youtube(let canonical): case .youtube(let canonical):
url = canonical url = canonical
identity = canonical.absoluteString identity = canonical.absoluteString
label = "YouTube · " + (URLComponents(url: canonical, resolvingAgainstBaseURL: false)?.queryItems?.first?.value ?? "Video") label = canonical.absoluteString
case .miruro(let mediaID, let episode): case .miruro(let mediaID, let episode):
url = URL(string: item.url)! url = URL(string: item.url)!
identity = "miruro:\(mediaID):\(episode)" identity = "miruro:\(mediaID):\(episode)"
+3 -2
View File
@@ -11,8 +11,9 @@ enum StreamURL: Sendable {
static func parse(_ text: String) throws -> StreamURL { static func parse(_ text: String) throws -> StreamURL {
let text = text.trimmingCharacters(in: .whitespacesAndNewlines) let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
if let url = try? YouTubeResolver.validateURL(text) { return .youtube(url) } let url = try YouTubeResolver.validateURL(text)
let invalid = StreamResolutionError(message: "Enter a YouTube video URL or a Miruro episode URL from barelystarted.miruro.tv.") if url.host?.lowercased() != "barelystarted.miruro.tv" { return .youtube(url) }
let invalid = StreamResolutionError(message: "Enter a Miruro episode URL from barelystarted.miruro.tv with an explicit positive episode number.")
guard !text.unicodeScalars.contains(where: { guard !text.unicodeScalars.contains(where: {
CharacterSet.whitespacesAndNewlines.contains($0) || CharacterSet.controlCharacters.contains($0) CharacterSet.whitespacesAndNewlines.contains($0) || CharacterSet.controlCharacters.contains($0)
}), let components = URLComponents(string: text), }), let components = URLComponents(string: text),
+4 -4
View File
@@ -572,7 +572,7 @@ final class PlayerModel: ObservableObject {
cancelStreamResolution() cancelStreamResolution()
if !change.automatic { automaticQuality = AutomaticQuality() } if !change.automatic { automaticQuality = AutomaticQuality() }
if !change.automatic && !player_pause(handle) { if !change.automatic && !player_pause(handle) {
commandError = "Could not pause the current media to open YouTube. Please try again." commandError = "Could not pause the current media to open the stream. Please try again."
revealControls() revealControls()
return return
} }
@@ -620,10 +620,10 @@ final class PlayerModel: ObservableObject {
automaticQuality.extractionFailed(at: qualityNow) automaticQuality.extractionFailed(at: qualityNow)
return return
} }
commandError = error?.localizedDescription ?? "Could not queue the YouTube video to open. Please try again." commandError = error?.localizedDescription ?? "Could not queue the video to open. Please try again."
if change.isQuality && change.playing, let handle, !player_play(handle) { if change.isQuality && change.playing, let handle, !player_play(handle) {
commandError = error.map { $0.localizedDescription + "\nCould not resume the previous playback." } ?? commandError = error.map { $0.localizedDescription + "\nCould not resume the previous playback." } ??
"Could not queue the YouTube video or resume the previous playback. Please try again." "Could not queue the video or resume the previous playback. Please try again."
} }
revealControls() revealControls()
} }
@@ -3450,7 +3450,7 @@ private struct PlayerContent: View {
model.openFiles(urls) model.openFiles(urls)
} else { } else {
guard urls.count == 1, let url = urls.first, guard urls.count == 1, let url = urls.first,
(try? YouTubeResolver.validateURL(url.absoluteString)) != nil else { return false } (try? StreamURL.parse(url.absoluteString)) != nil else { return false }
model.open(url) model.open(url)
} }
return true return true
+21 -18
View File
@@ -30,12 +30,13 @@ enum YouTubeResolver {
let text = text.trimmingCharacters(in: .whitespacesAndNewlines) let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty, guard !text.isEmpty,
!text.unicodeScalars.contains(where: { CharacterSet.whitespacesAndNewlines.contains($0) || CharacterSet.controlCharacters.contains($0) }), !text.unicodeScalars.contains(where: { CharacterSet.whitespacesAndNewlines.contains($0) || CharacterSet.controlCharacters.contains($0) }),
let components = URLComponents(string: text), var components = URLComponents(string: text),
let scheme = components.scheme?.lowercased(), ["http", "https"].contains(scheme), let scheme = components.scheme?.lowercased(), ["http", "https"].contains(scheme),
components.user == nil, components.password == nil, components.user == nil, components.password == nil,
components.port == nil || components.port == (scheme == "https" ? 443 : 80), components.port == nil || (1...65535).contains(components.port!),
let host = components.host?.lowercased() else { let host = components.host?.lowercased(), !host.isEmpty,
throw YouTubeResolutionError(message: "Enter a valid HTTP or HTTPS YouTube video URL.") let url = components.url, url.absoluteString.utf8.count <= 65536 else {
throw YouTubeResolutionError(message: "Enter a valid HTTP or HTTPS video URL without embedded credentials.")
} }
let id: String? let id: String?
let path = components.path.split(separator: "/", omittingEmptySubsequences: false) let path = components.path.split(separator: "/", omittingEmptySubsequences: false)
@@ -51,11 +52,12 @@ enum YouTubeResolver {
id = nil id = nil
} }
} else { } else {
throw YouTubeResolutionError(message: "Only YouTube video URLs are supported.") id = nil
} }
guard let id, id.utf8.count == 11, guard let id, id.utf8.count == 11,
id.utf8.allSatisfy({ (65...90).contains($0) || (97...122).contains($0) || (48...57).contains($0) || $0 == 45 || $0 == 95 }) else { id.utf8.allSatisfy({ (65...90).contains($0) || (97...122).contains($0) || (48...57).contains($0) || $0 == 45 || $0 == 95 }) else {
throw YouTubeResolutionError(message: "The YouTube URL must identify one video, not a playlist or channel.") components.scheme = scheme
return components.url!
} }
return URL(string: "https://www.youtube.com/watch?v=\(id)")! return URL(string: "https://www.youtube.com/watch?v=\(id)")!
} }
@@ -112,7 +114,7 @@ enum YouTubeResolver {
throw YouTubeResolutionError(message: "The requested \(height)p resolution is no longer available. Choose another quality.") throw YouTubeResolutionError(message: "The requested \(height)p resolution is no longer available. Choose another quality.")
} }
} else if let selectedHeight = videoFormat.height, selectedHeight > 1080 { } else if let selectedHeight = videoFormat.height, selectedHeight > 1080 {
throw YouTubeResolutionError(message: "YouTube did not provide a supported stream at or below 1080p.") throw YouTubeResolutionError(message: "yt-dlp did not provide a supported stream at or below 1080p.")
} }
let formats = metadata.formats ?? [videoFormat] + (audioFormat.map { [$0] } ?? []) let formats = metadata.formats ?? [videoFormat] + (audioFormat.map { [$0] } ?? [])
let hasSeparateAudio = formats.contains { let hasSeparateAudio = formats.contains {
@@ -129,7 +131,7 @@ enum YouTubeResolver {
let audio = try audioFormat.map { try stream($0, fallbackHeaders: metadata.http_headers) } let audio = try audioFormat.map { try stream($0, fallbackHeaders: metadata.http_headers) }
let title = metadata.title?.trimmingCharacters(in: .whitespacesAndNewlines) let title = metadata.title?.trimmingCharacters(in: .whitespacesAndNewlines)
return YouTubeVideo( return YouTubeVideo(
title: title?.isEmpty == false ? title! : "YouTube video", title: title?.isEmpty == false ? title! : "Video",
video: video, audio: audio, duration: duration, video: video, audio: audio, duration: duration,
isLive: metadata.is_live == true || metadata.live_status == "is_live", isLive: metadata.is_live == true || metadata.live_status == "is_live",
webpageURL: try validateURL(webpageURL.absoluteString), webpageURL: try validateURL(webpageURL.absoluteString),
@@ -151,7 +153,7 @@ enum YouTubeResolver {
?? bundledExecutable("yt-dlp") ?? bundledExecutable("yt-dlp")
?? findExecutable("yt-dlp", directories: directories) ?? findExecutable("yt-dlp", directories: directories)
guard let executable else { guard let executable else {
throw YouTubeResolutionError(message: "YouTube playback requires yt-dlp. Rebuild or reinstall terebi with its bundled helpers.") 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 {
@@ -162,12 +164,12 @@ enum YouTubeResolver {
} }
guard let runtime, guard let runtime,
let runtimeName = ["deno": "deno", "node": "node", "qjs": "quickjs", "quickjs": "quickjs", "bun": "bun"][runtime.lastPathComponent] else { let runtimeName = ["deno": "deno", "node": "node", "qjs": "quickjs", "quickjs": "quickjs", "bun": "bun"][runtime.lastPathComponent] else {
throw YouTubeResolutionError(message: "YouTube playback requires a supported JavaScript runtime. Rebuild or reinstall terebi with its bundled Deno helper.") throw YouTubeResolutionError(message: "Stream playback requires a supported JavaScript runtime. Rebuild or reinstall terebi with its bundled Deno helper.")
} }
var environment = ProcessInfo.processInfo.environment var environment = ProcessInfo.processInfo.environment
environment["PATH"] = directories.joined(separator: ":") environment["PATH"] = directories.joined(separator: ":")
let transport = "[protocol~='^(https?|m3u8(_native)?)$']" let transport = "[protocol~='^(https?|m3u8(_native)?)$']"
let resolution = height.map { "[height=\($0)]" } ?? "[height<=1080]" let resolution = height.map { "[height=\($0)]" } ?? "[height<=?1080]"
let arguments = [ let arguments = [
"--ignore-config", "--no-plugin-dirs", "--no-cache-dir", "--no-remote-components", "--ignore-config", "--no-plugin-dirs", "--no-cache-dir", "--no-remote-components",
"--no-playlist", "--simulate", "--skip-download", "--dump-single-json", "--no-playlist", "--simulate", "--skip-download", "--dump-single-json",
@@ -257,7 +259,7 @@ enum YouTubeResolver {
return YouTubeResolutionError(message: "The bundled YouTube helpers could not solve YouTube's JavaScript challenge. Upgrade terebi or rebuild its bundled yt-dlp, EJS components, and Deno.") return YouTubeResolutionError(message: "The bundled YouTube helpers could not solve YouTube's JavaScript challenge. Upgrade terebi or rebuild its bundled yt-dlp, EJS components, and Deno.")
} }
if text.contains("no such option") || text.contains("unrecognized arguments") { if text.contains("no such option") || text.contains("unrecognized arguments") {
return YouTubeResolutionError(message: "The bundled yt-dlp is too old for YouTube streaming. Upgrade terebi or rebuild its bundled helpers.") return YouTubeResolutionError(message: "The bundled yt-dlp is too old for streaming. Upgrade terebi or rebuild its bundled helpers.")
} }
return nil return nil
} }
@@ -267,16 +269,16 @@ enum YouTubeResolver {
if let error = dependencyError(text) { return error } if let error = dependencyError(text) { return error }
// Never include extractor output: it can contain signed URLs or credentials. // Never include extractor output: it can contain signed URLs or credentials.
if text.contains("sign in") || text.contains("login") || text.contains("private video") || text.contains("age-restricted") || text.contains("cookies") { if text.contains("sign in") || text.contains("login") || text.contains("private video") || text.contains("age-restricted") || text.contains("cookies") {
return YouTubeResolutionError(message: "YouTube requires sign-in or additional access for this video. terebi does not read browser cookies; try a publicly accessible video.") return YouTubeResolutionError(message: "This site requires sign-in or additional access for this video. terebi does not read browser cookies; try a publicly accessible video.")
} }
if text.contains("no video formats") || text.contains("no formats") || text.contains("requested format") { if text.contains("no video formats") || text.contains("no formats") || text.contains("requested format") {
return YouTubeResolutionError(message: "YouTube did not provide a supported SDR video and audio stream. Upgrade terebi or rebuild its bundled helpers and try again.") return YouTubeResolutionError(message: "yt-dlp did not provide a supported SDR video and audio stream. Upgrade terebi or rebuild its bundled helpers and try again.")
} }
if text.contains("not available") || text.contains("unavailable") || text.contains("removed") || text.contains("copyright") || text.contains("blocked") { if text.contains("not available") || text.contains("unavailable") || text.contains("removed") || text.contains("copyright") || text.contains("blocked") {
return YouTubeResolutionError(message: "This YouTube video is unavailable or restricted in your region.") return YouTubeResolutionError(message: "This video is unavailable or restricted in your region.")
} }
if text.contains("timed out") || text.contains("connection") || text.contains("http error") || text.contains("certificate") || text.contains("urlopen") { if text.contains("timed out") || text.contains("connection") || text.contains("http error") || text.contains("certificate") || text.contains("urlopen") {
return YouTubeResolutionError(message: "Could not reach YouTube. Check your network connection and try again; upgrade terebi if the problem persists.") return YouTubeResolutionError(message: "Could not reach the video site. Check your network connection and try again; upgrade terebi if the problem persists.")
} }
return YouTubeResolutionError(message: "The bundled yt-dlp could not resolve this video (exit status \(status)). Upgrade terebi or rebuild its bundled helpers, then try again.") return YouTubeResolutionError(message: "The bundled yt-dlp could not resolve this video (exit status \(status)). Upgrade terebi or rebuild its bundled helpers, then try again.")
} }
@@ -298,8 +300,9 @@ private struct YouTubeMetadata: Decodable {
let http_headers: [String: String]? let http_headers: [String: String]?
let requested_formats: [YouTubeMetadata]? let requested_formats: [YouTubeMetadata]?
var hasVideo: Bool { vcodec.map { !$0.isEmpty && $0 != "none" } ?? false } // Generic extractors may omit codec hints; FFmpeg probes the actual streams.
var hasAudio: Bool { acodec.map { !$0.isEmpty && $0 != "none" } ?? false } var hasVideo: Bool { vcodec.map { !$0.isEmpty && $0 != "none" } ?? true }
var hasAudio: Bool { acodec.map { !$0.isEmpty && $0 != "none" } ?? true }
} }
private struct YouTubeExtractionOutput: Sendable { private struct YouTubeExtractionOutput: Sendable {
+2 -2
View File
@@ -19,13 +19,13 @@ enum PlayerCommand {
terebi --help terebi --help
terebi --default-config terebi --default-config
Open one local file, YouTube video, Miruro episode or resolved source and start playback, Open one local file, yt-dlp-supported video, Miruro episode or resolved source and start playback,
resuming saved video progress. With no arguments, open or activate the player. resuming saved video progress. With no arguments, open or activate the player.
Quote paths and URLs containing spaces or shell metacharacters. Quote paths and URLs containing spaces or shell metacharacters.
--new-instance Launch a separate player, leaving existing playback untouched. --new-instance Launch a separate player, leaving existing playback untouched.
--no-config Use built-in settings instead of config.toml on a fresh launch. --no-config Use built-in settings instead of config.toml on a fresh launch.
--url URL Open a YouTube video or Miruro episode URL. --url URL Resolve an HTTP(S) video URL with yt-dlp, or open a Miruro episode.
--source-file PATH Open a validated resolved HTTP(S) source in a new player. --source-file PATH Open a validated resolved HTTP(S) source in a new player.
--queue-file PATH Open an ordered version-1 stream queue with per-item reports. --queue-file PATH Open an ordered version-1 stream queue with per-item reports.
--enqueue Append a queue to the running player without interrupting playback; --enqueue Append a queue to the running player without interrupting playback;
+4 -3
View File
@@ -6,9 +6,7 @@
#include <arpa/inet.h> #include <arpa/inet.h>
namespace playback { namespace playback {
inline bool validNetworkURL(const char *text) { inline bool validNetworkURL(std::string_view url) {
if (!text) return false;
const std::string_view url(text);
if (url.size() > 65536) return false; if (url.size() > 65536) return false;
const std::size_t start = url.starts_with("https://") ? 8 : url.starts_with("http://") ? 7 : 0; const std::size_t start = url.starts_with("https://") ? 8 : url.starts_with("http://") ? 7 : 0;
if (!start) return false; if (!start) return false;
@@ -52,6 +50,9 @@ inline bool validNetworkURL(const char *text) {
} }
return port.empty() || number > 0; return port.empty() || number > 0;
} }
inline bool validNetworkURL(const char *text) {
return text && validNetworkURL(std::string_view(text));
}
inline bool validHeaders(const char *text) { inline bool validHeaders(const char *text) {
if (!text) return true; if (!text) return true;
const std::string_view headers(text); const std::string_view headers(text);
+2 -2
View File
@@ -58,8 +58,8 @@ typedef struct PlayerNetworkSource {
const char *audio_headers; const char *audio_headers;
double duration; double duration;
bool is_live; bool is_live;
// Canonical YouTube webpage or stable miruro:<base64url media_id>:<positive episode> // Original HTTP(S) video URL (canonical for YouTube), or stable
// identity, never an extracted stream/CDN URL. // miruro:<base64url media_id>:<positive episode> identity, never an extracted CDN URL.
// NULL/empty means no reporting for this source. // NULL/empty means no reporting for this source.
const char *media_url; const char *media_url;
// Initial restored position, applied before publishing ready state. Zero // Initial restored position, applied before publishing ready state. Zero
+6 -8
View File
@@ -1,4 +1,5 @@
#include "progress_reporter.hpp" #include "progress_reporter.hpp"
#include "network_policy.hpp"
#include <cerrno> #include <cerrno>
#include <charconv> #include <charconv>
#include <chrono> #include <chrono>
@@ -87,13 +88,7 @@ void ProgressReporter::resume() {
} }
bool ProgressReporter::validMediaURL(std::string_view url) noexcept { bool ProgressReporter::validMediaURL(std::string_view url) noexcept {
constexpr std::string_view prefix = "https://www.youtube.com/watch?v="; return validNetworkURL(url);
if (url.size() != prefix.size() + 11 || url.substr(0, prefix.size()) != prefix) return false;
for (char character : url.substr(prefix.size())) {
if (!((character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') ||
(character >= '0' && character <= '9') || character == '_' || character == '-')) return false;
}
return true;
} }
bool ProgressReporter::validMediaIdentity(std::string_view identity) noexcept { bool ProgressReporter::validMediaIdentity(std::string_view identity) noexcept {
@@ -203,7 +198,10 @@ bool ProgressReporter::emit(const char *event) noexcept {
record += "},\"position_seconds\":"; record += "},\"position_seconds\":";
} else { } else {
record += "\",\"media_url\":\""; record += "\",\"media_url\":\"";
record += mediaURL; for (char character : mediaURL) {
if (character == '"' || character == '\\') record += '\\';
record += character;
}
record += "\",\"position_seconds\":"; record += "\",\"position_seconds\":";
} }
appendNumber(record, snapshot.position); appendNumber(record, snapshot.position);