feat: resolve HTTP video URLs with bundled yt-dlp
This commit is contained in:
@@ -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")!
|
||||
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")
|
||||
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", movie.lastPathComponent], newInstance: true) == movie,
|
||||
"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[0].identity.hasPrefix("miruro:") &&
|
||||
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 duplicateMiruro = ["url": miruro.absoluteString.replacingOccurrences(of: "the-apothecary-diaries-season-3", with: "different-slug"),
|
||||
"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": 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": movie.path]]]
|
||||
]
|
||||
@@ -221,7 +230,7 @@ struct PlayerCommandChecks {
|
||||
|
||||
for arguments in [["--url"], ["--url", canonical.absoluteString, movie.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", movie.path, movie.path],
|
||||
["--new-instance", "--unknown"], ["--no-config", "--no-config"],
|
||||
|
||||
@@ -55,7 +55,7 @@ static NSArray<NSDictionary *> *records(const std::string &path) {
|
||||
"schema version is numeric one");
|
||||
require([@[@"progress", @"ended", @"closed"] containsObject:row[@"event"]], "known event type");
|
||||
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]] &&
|
||||
std::isfinite([row[@"position_seconds"] doubleValue]) &&
|
||||
[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)
|
||||
expect(rows[i], i % 2 ? @"closed" : @"progress", firstURL, 97, false, i < 8 ? 0 : 100);
|
||||
|
||||
require(ProgressReporter::validMediaURL(firstURL) &&
|
||||
ProgressReporter::validMediaURL("https://www.youtube.com/watch?v=aA0_-123456"), "eleven ASCII ID characters are accepted");
|
||||
for (const char *invalid : {"", "http://www.youtube.com/watch?v=AAAAAAAAAAA",
|
||||
"https://youtube.com/watch?v=AAAAAAAAAAA", "https://www.youtube.com/watch?v=AAAAAAAAAA",
|
||||
"https://www.youtube.com/watch?v=AAAAAAAAAAAA", "https://www.youtube.com/watch?v=AAAAAAAAAAA&x=1",
|
||||
"https://www.youtube.com/watch?v=AAAAAAAAAA%", "https://www.youtube.com/watch?v=AAAAAAAAAA\n"})
|
||||
require(!ProgressReporter::validMediaURL(invalid), "noncanonical URLs are rejected");
|
||||
const std::string genericURL = "https://video.example/watch?label=\"quoted\"&id=123#part";
|
||||
require(ProgressReporter::validMediaURL(genericURL) &&
|
||||
ProgressReporter::validMediaURL("http://127.0.0.1:8080/video.mp4"),
|
||||
"HTTP(S) page identities are accepted beyond YouTube");
|
||||
for (const char *invalid : {"", "file:///tmp/video.mp4", "ftp://example.com/video",
|
||||
"https://", "https://user:secret@example.com/video", "https://example.com:0/video",
|
||||
"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") &&
|
||||
ProgressReporter::validMediaIdentity("miruro:a:2147483647"), "strict Miruro identities accepted");
|
||||
|
||||
@@ -52,9 +52,12 @@ struct StreamResolverChecks {
|
||||
throw Failure(message: "YouTube URL classification")
|
||||
}
|
||||
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 [
|
||||
"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: "https://", with: "http://"),
|
||||
page.replacingOccurrences(of: "https://", with: "https://user:secret@"),
|
||||
@@ -70,7 +73,7 @@ struct StreamResolverChecks {
|
||||
page + "&ep=2", page + "&provider=bee", page + "#secret",
|
||||
page.replacingOccurrences(of: ".tv/", with: ".tv:444/"),
|
||||
page.replacingOccurrences(of: "?ep", with: "/?ep"),
|
||||
"https://youtu.be/invalid", "file:///secret"
|
||||
"file:///secret"
|
||||
] {
|
||||
try reject("unsupported or unsafe URL") { _ = try StreamURL.parse(invalid) }
|
||||
}
|
||||
|
||||
@@ -70,20 +70,20 @@ struct YouTubeResolverChecks {
|
||||
let url = try YouTubeResolver.validateURL(input)
|
||||
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 = [
|
||||
"", "not a URL", "javascript:alert(1)", "file:///watch?v=jNQXAC9IVRw",
|
||||
"https://example.com/watch?v=jNQXAC9IVRw",
|
||||
"https://youtube.com.evil.example/watch?v=jNQXAC9IVRw",
|
||||
"https://evil.youtube.com/watch?v=jNQXAC9IVRw",
|
||||
"ftp://example.com/video", "https://", "https://example.com:0/video",
|
||||
"https://example.com:65536/video", "https://example.com/video\ninjected",
|
||||
"https://youtube.com@evil.example/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"
|
||||
"https://user:password@www.youtube.com/watch?v=jNQXAC9IVRw"
|
||||
]
|
||||
for input in hostile {
|
||||
try reject("hostile or malformed video URL") { _ = try YouTubeResolver.validateURL(input) }
|
||||
@@ -202,6 +202,9 @@ struct YouTubeResolverChecks {
|
||||
}
|
||||
|
||||
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") }
|
||||
for json in ["null", "[]", "{}", #"{"requested_formats":[]}"#] {
|
||||
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}"#)
|
||||
}
|
||||
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"] {
|
||||
var object = combined
|
||||
object.removeValue(forKey: key)
|
||||
try reject("missing required combined \(key)") { _ = try decode(object) }
|
||||
object[key] = 17
|
||||
try reject("wrong type for combined \(key)") { _ = try decode(object) }
|
||||
}
|
||||
@@ -236,11 +240,9 @@ struct YouTubeResolverChecks {
|
||||
var hdr = combined
|
||||
hdr["dynamic_range"] = "HDR10"
|
||||
try reject("HDR input unsupported by the SDR renderer") { _ = try decode(hdr) }
|
||||
for value in ["none", NSNull()] as [Any] {
|
||||
var object = combined
|
||||
object["vcodec"] = value
|
||||
try reject("combined input without video codec") { _ = try decode(object) }
|
||||
}
|
||||
var audioOnly = combined
|
||||
audioOnly["vcodec"] = "none"
|
||||
try reject("explicitly audio-only result") { _ = try decode(audioOnly) }
|
||||
for transport in ["http_dash_segments", "mhtml", "rtmp"] {
|
||||
var object = combined
|
||||
object["protocol"] = transport
|
||||
@@ -444,6 +446,18 @@ struct YouTubeResolverChecks {
|
||||
try JSONSerialization.data(withJSONObject: object).write(to: extractor.marker("response.json"))
|
||||
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")
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
$(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 $@
|
||||
|
||||
$(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
|
||||
$(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 $@
|
||||
|
||||
check-reporting: $(BUILD_DIR)/progress-reporting-check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
|
||||
@@ -22,13 +22,13 @@ To run without installing, use `make run`. The built `.build/terebi.app` is self
|
||||
## 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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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 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.
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
- 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.
|
||||
- Audio-device changes during playback are not handled.
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ struct StreamQueue: Sendable {
|
||||
case .youtube(let canonical):
|
||||
url = canonical
|
||||
identity = canonical.absoluteString
|
||||
label = "YouTube · " + (URLComponents(url: canonical, resolvingAgainstBaseURL: false)?.queryItems?.first?.value ?? "Video")
|
||||
label = canonical.absoluteString
|
||||
case .miruro(let mediaID, let episode):
|
||||
url = URL(string: item.url)!
|
||||
identity = "miruro:\(mediaID):\(episode)"
|
||||
|
||||
@@ -11,8 +11,9 @@ enum StreamURL: Sendable {
|
||||
|
||||
static func parse(_ text: String) throws -> StreamURL {
|
||||
let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let url = try? YouTubeResolver.validateURL(text) { return .youtube(url) }
|
||||
let invalid = StreamResolutionError(message: "Enter a YouTube video URL or a Miruro episode URL from barelystarted.miruro.tv.")
|
||||
let url = try YouTubeResolver.validateURL(text)
|
||||
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: {
|
||||
CharacterSet.whitespacesAndNewlines.contains($0) || CharacterSet.controlCharacters.contains($0)
|
||||
}), let components = URLComponents(string: text),
|
||||
|
||||
@@ -572,7 +572,7 @@ final class PlayerModel: ObservableObject {
|
||||
cancelStreamResolution()
|
||||
if !change.automatic { automaticQuality = AutomaticQuality() }
|
||||
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()
|
||||
return
|
||||
}
|
||||
@@ -620,10 +620,10 @@ final class PlayerModel: ObservableObject {
|
||||
automaticQuality.extractionFailed(at: qualityNow)
|
||||
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) {
|
||||
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()
|
||||
}
|
||||
@@ -3450,7 +3450,7 @@ private struct PlayerContent: View {
|
||||
model.openFiles(urls)
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
return true
|
||||
|
||||
@@ -30,12 +30,13 @@ enum YouTubeResolver {
|
||||
let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty,
|
||||
!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),
|
||||
components.user == nil, components.password == nil,
|
||||
components.port == nil || components.port == (scheme == "https" ? 443 : 80),
|
||||
let host = components.host?.lowercased() else {
|
||||
throw YouTubeResolutionError(message: "Enter a valid HTTP or HTTPS YouTube video URL.")
|
||||
components.port == nil || (1...65535).contains(components.port!),
|
||||
let host = components.host?.lowercased(), !host.isEmpty,
|
||||
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 path = components.path.split(separator: "/", omittingEmptySubsequences: false)
|
||||
@@ -51,11 +52,12 @@ enum YouTubeResolver {
|
||||
id = nil
|
||||
}
|
||||
} else {
|
||||
throw YouTubeResolutionError(message: "Only YouTube video URLs are supported.")
|
||||
id = nil
|
||||
}
|
||||
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 {
|
||||
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)")!
|
||||
}
|
||||
@@ -112,7 +114,7 @@ enum YouTubeResolver {
|
||||
throw YouTubeResolutionError(message: "The requested \(height)p resolution is no longer available. Choose another quality.")
|
||||
}
|
||||
} 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 hasSeparateAudio = formats.contains {
|
||||
@@ -129,7 +131,7 @@ enum YouTubeResolver {
|
||||
let audio = try audioFormat.map { try stream($0, fallbackHeaders: metadata.http_headers) }
|
||||
let title = metadata.title?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return YouTubeVideo(
|
||||
title: title?.isEmpty == false ? title! : "YouTube video",
|
||||
title: title?.isEmpty == false ? title! : "Video",
|
||||
video: video, audio: audio, duration: duration,
|
||||
isLive: metadata.is_live == true || metadata.live_status == "is_live",
|
||||
webpageURL: try validateURL(webpageURL.absoluteString),
|
||||
@@ -151,7 +153,7 @@ enum YouTubeResolver {
|
||||
?? bundledExecutable("yt-dlp")
|
||||
?? findExecutable("yt-dlp", directories: directories)
|
||||
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?
|
||||
if let runtimeURL {
|
||||
@@ -162,12 +164,12 @@ enum YouTubeResolver {
|
||||
}
|
||||
guard let runtime,
|
||||
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
|
||||
environment["PATH"] = directories.joined(separator: ":")
|
||||
let transport = "[protocol~='^(https?|m3u8(_native)?)$']"
|
||||
let resolution = height.map { "[height=\($0)]" } ?? "[height<=1080]"
|
||||
let resolution = height.map { "[height=\($0)]" } ?? "[height<=?1080]"
|
||||
let arguments = [
|
||||
"--ignore-config", "--no-plugin-dirs", "--no-cache-dir", "--no-remote-components",
|
||||
"--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.")
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -267,16 +269,16 @@ enum YouTubeResolver {
|
||||
if let error = dependencyError(text) { return error }
|
||||
// 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") {
|
||||
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") {
|
||||
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") {
|
||||
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") {
|
||||
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.")
|
||||
}
|
||||
@@ -298,8 +300,9 @@ private struct YouTubeMetadata: Decodable {
|
||||
let http_headers: [String: String]?
|
||||
let requested_formats: [YouTubeMetadata]?
|
||||
|
||||
var hasVideo: Bool { vcodec.map { !$0.isEmpty && $0 != "none" } ?? false }
|
||||
var hasAudio: Bool { acodec.map { !$0.isEmpty && $0 != "none" } ?? false }
|
||||
// Generic extractors may omit codec hints; FFmpeg probes the actual streams.
|
||||
var hasVideo: Bool { vcodec.map { !$0.isEmpty && $0 != "none" } ?? true }
|
||||
var hasAudio: Bool { acodec.map { !$0.isEmpty && $0 != "none" } ?? true }
|
||||
}
|
||||
|
||||
private struct YouTubeExtractionOutput: Sendable {
|
||||
|
||||
@@ -19,13 +19,13 @@ enum PlayerCommand {
|
||||
terebi --help
|
||||
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.
|
||||
Quote paths and URLs containing spaces or shell metacharacters.
|
||||
|
||||
--new-instance Launch a separate player, leaving existing playback untouched.
|
||||
--no-config Use built-in settings instead of config.toml on a fresh launch.
|
||||
--url URL Open a YouTube video 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.
|
||||
--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;
|
||||
|
||||
@@ -6,9 +6,7 @@
|
||||
#include <arpa/inet.h>
|
||||
|
||||
namespace playback {
|
||||
inline bool validNetworkURL(const char *text) {
|
||||
if (!text) return false;
|
||||
const std::string_view url(text);
|
||||
inline bool validNetworkURL(std::string_view url) {
|
||||
if (url.size() > 65536) return false;
|
||||
const std::size_t start = url.starts_with("https://") ? 8 : url.starts_with("http://") ? 7 : 0;
|
||||
if (!start) return false;
|
||||
@@ -52,6 +50,9 @@ inline bool validNetworkURL(const char *text) {
|
||||
}
|
||||
return port.empty() || number > 0;
|
||||
}
|
||||
inline bool validNetworkURL(const char *text) {
|
||||
return text && validNetworkURL(std::string_view(text));
|
||||
}
|
||||
inline bool validHeaders(const char *text) {
|
||||
if (!text) return true;
|
||||
const std::string_view headers(text);
|
||||
|
||||
@@ -58,8 +58,8 @@ typedef struct PlayerNetworkSource {
|
||||
const char *audio_headers;
|
||||
double duration;
|
||||
bool is_live;
|
||||
// Canonical YouTube webpage or stable miruro:<base64url media_id>:<positive episode>
|
||||
// identity, never an extracted stream/CDN URL.
|
||||
// Original HTTP(S) video URL (canonical for YouTube), or stable
|
||||
// miruro:<base64url media_id>:<positive episode> identity, never an extracted CDN URL.
|
||||
// NULL/empty means no reporting for this source.
|
||||
const char *media_url;
|
||||
// Initial restored position, applied before publishing ready state. Zero
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "progress_reporter.hpp"
|
||||
#include "network_policy.hpp"
|
||||
#include <cerrno>
|
||||
#include <charconv>
|
||||
#include <chrono>
|
||||
@@ -87,13 +88,7 @@ void ProgressReporter::resume() {
|
||||
}
|
||||
|
||||
bool ProgressReporter::validMediaURL(std::string_view url) noexcept {
|
||||
constexpr std::string_view prefix = "https://www.youtube.com/watch?v=";
|
||||
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;
|
||||
return validNetworkURL(url);
|
||||
}
|
||||
|
||||
bool ProgressReporter::validMediaIdentity(std::string_view identity) noexcept {
|
||||
@@ -203,7 +198,10 @@ bool ProgressReporter::emit(const char *event) noexcept {
|
||||
record += "},\"position_seconds\":";
|
||||
} else {
|
||||
record += "\",\"media_url\":\"";
|
||||
record += mediaURL;
|
||||
for (char character : mediaURL) {
|
||||
if (character == '"' || character == '\\') record += '\\';
|
||||
record += character;
|
||||
}
|
||||
record += "\",\"position_seconds\":";
|
||||
}
|
||||
appendNumber(record, snapshot.position);
|
||||
|
||||
Reference in New Issue
Block a user