From b132bab39311d2ce6499eb4e30a596467e3fd3e8 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Thu, 22 Oct 2026 17:05:29 +0200 Subject: [PATCH] feat: resolve HTTP video URLs with bundled yt-dlp --- Checks/player-command.swift | 13 ++++++-- Checks/progress-reporting.mm | 29 ++++++++++++----- Checks/stream-resolver.swift | 9 ++++-- Checks/youtube-resolver.swift | 50 +++++++++++++++++++----------- Makefile | 4 +-- README.md | 12 +++---- Sources/App/StreamQueue.swift | 2 +- Sources/App/StreamResolver.swift | 5 +-- Sources/App/TerebiApp.swift | 8 ++--- Sources/App/YouTubeResolver.swift | 39 ++++++++++++----------- Sources/CLI/PlayerCommand.swift | 4 +-- Sources/Core/network_policy.hpp | 7 +++-- Sources/Core/player.h | 4 +-- Sources/Core/progress_reporter.cpp | 14 ++++----- 14 files changed, 121 insertions(+), 79 deletions(-) diff --git a/Checks/player-command.swift b/Checks/player-command.swift index c469db5..227309c 100644 --- a/Checks/player-command.swift +++ b/Checks/player-command.swift @@ -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"], diff --git a/Checks/progress-reporting.mm b/Checks/progress-reporting.mm index 3d1e601..e23078f 100644 --- a/Checks/progress-reporting.mm +++ b/Checks/progress-reporting.mm @@ -55,7 +55,7 @@ static NSArray *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"); diff --git a/Checks/stream-resolver.swift b/Checks/stream-resolver.swift index 27c66c8..aa6177b 100644 --- a/Checks/stream-resolver.swift +++ b/Checks/stream-resolver.swift @@ -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) } } diff --git a/Checks/youtube-resolver.swift b/Checks/youtube-resolver.swift index 0671712..22180df 100644 --- a/Checks/youtube-resolver.swift +++ b/Checks/youtube-resolver.swift @@ -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 { diff --git a/Makefile b/Makefile index 51adac1..b7fe0ea 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 8f13958..fb9d07f 100644 --- a/README.md +++ b/README.md @@ -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//?ep=` 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//?ep=` 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. diff --git a/Sources/App/StreamQueue.swift b/Sources/App/StreamQueue.swift index 44e01cc..0bac3d3 100644 --- a/Sources/App/StreamQueue.swift +++ b/Sources/App/StreamQueue.swift @@ -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)" diff --git a/Sources/App/StreamResolver.swift b/Sources/App/StreamResolver.swift index 913f57e..26644c2 100644 --- a/Sources/App/StreamResolver.swift +++ b/Sources/App/StreamResolver.swift @@ -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), diff --git a/Sources/App/TerebiApp.swift b/Sources/App/TerebiApp.swift index 96f5de8..e310808 100644 --- a/Sources/App/TerebiApp.swift +++ b/Sources/App/TerebiApp.swift @@ -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 diff --git a/Sources/App/YouTubeResolver.swift b/Sources/App/YouTubeResolver.swift index 2fefbf0..b583bf0 100644 --- a/Sources/App/YouTubeResolver.swift +++ b/Sources/App/YouTubeResolver.swift @@ -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 { diff --git a/Sources/CLI/PlayerCommand.swift b/Sources/CLI/PlayerCommand.swift index 3f487d1..507b3da 100644 --- a/Sources/CLI/PlayerCommand.swift +++ b/Sources/CLI/PlayerCommand.swift @@ -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; diff --git a/Sources/Core/network_policy.hpp b/Sources/Core/network_policy.hpp index 8b80143..a94a44f 100644 --- a/Sources/Core/network_policy.hpp +++ b/Sources/Core/network_policy.hpp @@ -6,9 +6,7 @@ #include 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); diff --git a/Sources/Core/player.h b/Sources/Core/player.h index 77344c4..6a29247 100644 --- a/Sources/Core/player.h +++ b/Sources/Core/player.h @@ -58,8 +58,8 @@ typedef struct PlayerNetworkSource { const char *audio_headers; double duration; bool is_live; - // Canonical YouTube webpage or stable miruro:: - // identity, never an extracted stream/CDN URL. + // Original HTTP(S) video URL (canonical for YouTube), or stable + // miruro:: 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 diff --git a/Sources/Core/progress_reporter.cpp b/Sources/Core/progress_reporter.cpp index 4cd0a82..a3bcce5 100644 --- a/Sources/Core/progress_reporter.cpp +++ b/Sources/Core/progress_reporter.cpp @@ -1,4 +1,5 @@ #include "progress_reporter.hpp" +#include "network_policy.hpp" #include #include #include @@ -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);