feat: choose YouTube quality with a 1080p default
This commit is contained in:
@@ -167,6 +167,40 @@ struct YouTubeResolverChecks {
|
||||
try require(try decode(emptyHeaders).video.headers.isEmpty, "absent headers serialize to an empty block")
|
||||
}
|
||||
|
||||
static func qualities() throws {
|
||||
func video(_ height: Int, audio: Bool = false, range: String = "SDR", transport: String = "https") -> [String: Any] {
|
||||
["height": height, "vcodec": "h264", "acodec": audio ? "aac" : "none",
|
||||
"dynamic_range": range, "protocol": transport, "url": "https://media.example/\(height)p"]
|
||||
}
|
||||
let audio: [String: Any] = ["vcodec": "none", "acodec": "aac", "url": "https://media.example/audio"]
|
||||
var object: [String: Any] = [
|
||||
"requested_formats": [video(1080), audio],
|
||||
"formats": [video(2160), video(1440), video(1080), video(1080, audio: true), video(720),
|
||||
video(480), video(4320, range: "HDR10"), video(360, transport: "http_dash_segments"),
|
||||
video(0), audio]
|
||||
]
|
||||
let normal = try decode(object)
|
||||
try require(normal.height == 1080 && normal.availableHeights == [2160, 1440, 1080, 720, 480],
|
||||
"quality choices are unique playable SDR resolutions, including manual choices above the default cap")
|
||||
object["requested_formats"] = [video(720), audio]
|
||||
try require(try decode(object).height == 720, "a lower available resolution is accepted by the default policy")
|
||||
object["requested_formats"] = [video(2160), audio]
|
||||
try reject("4K stream selected by the default policy") { _ = try decode(object) }
|
||||
let data = try JSONSerialization.data(withJSONObject: object)
|
||||
let high = try YouTubeResolver.decode(data, webpageURL: page, height: 2160)
|
||||
try require(high.height == 2160 && high.video.url == "https://media.example/2160p",
|
||||
"explicit selection permits supported 4K video")
|
||||
try reject("requested resolution silently replaced by another") {
|
||||
_ = try YouTubeResolver.decode(data, webpageURL: page, height: 1440)
|
||||
}
|
||||
object["requested_formats"] = [video(720, audio: true)]
|
||||
object["formats"] = [video(2160), video(720, audio: true)]
|
||||
try require(try decode(object).availableHeights == [720],
|
||||
"video-only resolutions without a playable separate audio stream are not offered")
|
||||
object["requested_formats"] = [video(-720, audio: true)]
|
||||
try reject("invalid selected video dimensions") { _ = try decode(object) }
|
||||
}
|
||||
|
||||
static func malformedMetadata() throws {
|
||||
try reject("non-JSON extractor output") { _ = try decode("not JSON") }
|
||||
for json in ["null", "[]", "{}", #"{"requested_formats":[]}"#] {
|
||||
@@ -366,6 +400,49 @@ struct YouTubeResolverChecks {
|
||||
"cancelling an old request cannot terminate or replace a newer resolution")
|
||||
}
|
||||
|
||||
static func actualQualitySelection(root: URL, runtime: URL) async throws {
|
||||
let directories = (ProcessInfo.processInfo.environment["PATH"] ?? "").split(separator: ":").map(String.init)
|
||||
+ ["/opt/homebrew/bin", "/usr/local/bin"]
|
||||
guard let executable = directories.map({ URL(fileURLWithPath: $0).appendingPathComponent("yt-dlp") })
|
||||
.first(where: { FileManager.default.isExecutableFile(atPath: $0.path) }) else {
|
||||
print("Skipping actual yt-dlp quality selection: yt-dlp is not installed")
|
||||
return
|
||||
}
|
||||
func video(_ height: Int, combined: Bool = false) -> [String: Any] {
|
||||
["format_id": "\(height)", "height": height, "width": height * 16 / 9, "ext": "mp4",
|
||||
"vcodec": "h264", "acodec": combined ? "aac" : "none", "dynamic_range": "SDR",
|
||||
"protocol": "https", "url": "https://media.example/\(height).mp4", "preference": height == 720 ? 100 : 0]
|
||||
}
|
||||
var object: [String: Any] = [
|
||||
"id": "jNQXAC9IVRw", "title": "Quality ordering", "duration": 12,
|
||||
"webpage_url": page.absoluteString, "extractor": "youtube", "extractor_key": "Youtube",
|
||||
"formats": [video(2160), video(1080, combined: true), video(720),
|
||||
["format_id": "audio", "ext": "m4a", "vcodec": "none", "acodec": "aac",
|
||||
"protocol": "https", "url": "https://media.example/audio.m4a"]]
|
||||
]
|
||||
let extractor = try Extractor(root: root, name: "actual quality selection")
|
||||
let quotedExecutable = "'" + executable.path.replacingOccurrences(of: "'", with: "'\\''") + "'"
|
||||
let script = """
|
||||
#!/bin/bash
|
||||
set -eu
|
||||
base=${0%/*}
|
||||
args=("$@")
|
||||
exec \(quotedExecutable) --load-info-json "$base/response.json" "${args[@]:0:${#args[@]}-2}"
|
||||
"""
|
||||
try Data(script.utf8).write(to: extractor.executable)
|
||||
try JSONSerialization.data(withJSONObject: object).write(to: extractor.marker("response.json"))
|
||||
let normal = try await YouTubeResolver.resolve(page, executableURL: extractor.executable, runtimeURL: runtime)
|
||||
try require(normal.height == 1080 && normal.audio == nil, "1080p combined video wins over preferred 720p and capped-out 4K")
|
||||
let high = try await YouTubeResolver.resolve(page, height: 2160, executableURL: extractor.executable, runtimeURL: runtime)
|
||||
try require(high.height == 2160 && high.audio != nil, "explicit 4K selects the higher video with separate audio")
|
||||
var formats = object["formats"] as! [[String: Any]]
|
||||
formats.removeAll { ($0["height"] as? Int) == 1080 }
|
||||
object["formats"] = formats
|
||||
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")
|
||||
}
|
||||
|
||||
static func main() async throws {
|
||||
// A broken pipe/cancellation implementation must fail, not leave this check hanging.
|
||||
let watchdog = Task {
|
||||
@@ -382,7 +459,9 @@ struct YouTubeResolverChecks {
|
||||
try videoURLs()
|
||||
try metadata()
|
||||
try malformedMetadata()
|
||||
try qualities()
|
||||
try await subprocesses(root: root, runtime: runtime)
|
||||
try await actualQualitySelection(root: root, runtime: runtime)
|
||||
print("YouTube resolver checks passed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,9 @@ brew install yt-dlp deno
|
||||
|
||||
Use **⇧⌘O → Open YouTube URL…**, the playback-actions menu, or drop a YouTube video link onto the window. Watch, short-link, Shorts, live, and embed URLs identify one video; playlists and channels are not opened. Resolution is cancellable by closing or replacing the media. Finder launches also search the usual Homebrew executable locations.
|
||||
|
||||
yt-dlp selects the best available SDR video and audio, including separate streams, with a combined-stream fallback. FFmpeg plays the selected HTTP(S) streams or native HLS manifests directly: no embedded browser, temporary full-video download, or muxing step. Playback, coordinated seeking, frame stepping, volume, and same-window compact mode use the existing native player. **Buffering…** freezes the playback clock without disabling pause, close, or replacement; live/non-seekable inputs disable seeking and frame stepping, and live sources show **LIVE**.
|
||||
yt-dlp defaults to the highest available SDR resolution at or below **1080p**, falling down to a lower resolution when 1080p is unavailable, with the best available audio. FFmpeg plays the selected HTTP(S) streams or native HLS manifests directly, including separate video/audio inputs or a combined stream: no embedded browser, temporary full-video download, or muxing step. Playback, coordinated seeking, frame stepping, volume, and same-window compact mode use the existing native player. **Buffering…** freezes the playback clock without disabling pause, close, or replacement; live/non-seekable inputs disable seeking and frame stepping, and live sources show **LIVE**.
|
||||
|
||||
While playing YouTube, choose **gear → Quality** in either normal or compact mode. The dropdown lists the video's supported SDR resolutions, including 1440p/4K when available, and shows the current resolution. **Default (up to 1080p)** restores the cap. Switching resolves fresh stream URLs and briefly pauses while reopening, preserving the position for seekable recorded videos, play/pause state, and the same window, size, and mode. An extraction failure leaves the previous stream selected and restores its playback state. Live/non-seekable streams reopen without position restoration. New video opens use the default again; manual quality choices are not persisted.
|
||||
|
||||
The app ignores yt-dlp configuration and plugins, does not read browser cookies, and does not update or fetch extractor components automatically. Private, age-restricted, region-blocked, or sign-in-required videos can fail. If YouTube extraction changes, run `brew upgrade yt-dlp deno`; local playback needs neither dependency.
|
||||
|
||||
@@ -114,11 +116,11 @@ Only implemented actions have controls; there are no placeholder playlist, subti
|
||||
- `Sources/Core/player.cpp`: demux/decode worker for local files and decode/timeline worker for network sources, bounded buffers, authored timestamps, audio-master synchronization, paused previews, exact frame stepping, independent decoder/resampler drain, seeking, errors, and shutdown. A seek stops output and flushes both selected inputs' old timeline state before resuming.
|
||||
- `Sources/Core/network_input.hpp`: network-only demux readers with bounded encoded packet queues, asynchronous coordinated seeks, fifteen-second I/O deadlines, and interruption on seek/replacement/close/destruction. Remote input protocols exclude local-file access through manifests.
|
||||
- `Sources/Core/output.hpp` and `Sources/macOS/Output.mm`: native output contract, allocation-free/nonblocking Core Audio callback and bounded audio ring, latency-aware presentation clock, retained frame mailbox, and Metal YUV conversion. The view can outlive the player safely.
|
||||
- `Sources/App/TerebiApp.swift`: MainActor state polling, file/YouTube panels and Finder/drop handling, cancellable URL resolution, autoplay, video host, AppKit glass and native slider cells, floating controls, visibility, video-fitted sizing, and native window integration. Both accepted open paths enqueue play on the new source's command timeline; the C interface still opens paused for callers that need explicit control.
|
||||
- `Sources/App/TerebiApp.swift`: MainActor state polling, file/YouTube panels and Finder/drop handling, cancellable URL resolution, YouTube quality switching with in-memory position/playback-state restoration, autoplay, video host, AppKit glass and native slider cells, floating controls, visibility, video-fitted sizing, and native window integration. New media opens autoplay; quality switches preserve play/pause state. The C interface opens paused for callers that need explicit control.
|
||||
- `Sources/App/PlaybackProgress.swift`: local per-video position persistence and canonical file identities, using injectable UserDefaults. The player model checkpoints authoritative positions and defers resumed autoplay until the saved seek is queued against the ready source.
|
||||
- `Sources/App/SystemPlaybackControls.swift`: native remote-command registration, main-thread dispatch, Now Playing publication, seekability gating, and target cleanup.
|
||||
- `Sources/App/CompactWindow.swift`: same-window compact geometry, saved normal window state, asynchronous fullscreen transitions and failure recovery, all-Spaces/floating behavior, magnetic edge snapping, and its persisted preference.
|
||||
- `Sources/App/YouTubeResolver.swift`: YouTube URL and metadata validation, external yt-dlp/runtime discovery, SDR stream selection and headers, bounded nonblocking process output, sanitized extraction errors, and request-local cancellation.
|
||||
- `Sources/App/YouTubeResolver.swift`: YouTube URL and metadata validation, external yt-dlp/runtime discovery, capped-default or explicit-resolution SDR selection, playable resolution discovery and headers, bounded nonblocking process output, sanitized extraction errors, and request-local cancellation.
|
||||
- `Sources/CLI/PlayerCommand.swift` and `Sources/CLI/TerebiCLI.swift`: command-line validation/help and native application discovery/launching. Launch the app without media first to establish its singleton window, then deliver the selected URL; the app owns Apple events to prevent SwiftUI's default external-document routing from replacing that window.
|
||||
- `Sources/Launcher/FFmpegRuntime.swift` and `Sources/App/Bootstrap.swift`: shared native dependency preflight and the app's FFmpeg-independent entrypoint. The bootstrap loads the player library and calls its exported `terebi_app_main` entry point without replacing the executable, consuming initial launch events, or losing macOS app identity. `Contents/Resources/ffmpeg-libraries.txt` records the library's actual linked FFmpeg ABI paths.
|
||||
- `Makefile`: compiler/linker commands, dependency preflight, generated runtime manifest, developer/release bundles, installation, and checks. No generated Xcode project or additional build dependency.
|
||||
@@ -135,7 +137,7 @@ Generates short fixtures and exercises the real playback interface and native ou
|
||||
|
||||
`make check-window` runs the compact-mode check, also included in `make check`. It exercises magnetic thresholds/corners, negative-origin screens, portrait/extreme aspect bounds, isolated preference persistence, actual hidden-window entry/resizing/snapping/restoration, view-host identity, keyboard eligibility, and a replay of AppKit's fullscreen-failure lifecycle. It requires a graphical macOS session but does not display windows or play audio. Visible fullscreen transitions, cross-Space/Stage Manager behavior, and hover/drag usability still require a check in the app.
|
||||
|
||||
`make check-youtube` checks URL/metadata/header trust boundaries and real subprocess output, bounded pipe draining, cancellation, and request isolation using isolated extractor fixtures. It needs neither installed yt-dlp/Deno nor external network access.
|
||||
`make check-youtube` checks URL/metadata/header trust boundaries, resolution choices and the default cap, and real subprocess output, bounded pipe draining, cancellation, and request isolation using isolated extractor fixtures. If yt-dlp is installed, it also runs the actual selector against offline metadata to verify 1080p priority over extractor preferences, combined-stream selection, lower-resolution fallback, and explicit 4K. It requires neither Deno nor external network access; the actual-selector portion is skipped when yt-dlp is absent.
|
||||
|
||||
`make check-network` uses a range-capable loopback HTTP server and actual native playback. It checks combined/separate streams, identical stream indices across inputs, selected headers, coordinated seeks, exact VFR/B-frame stepping, asymmetric EOF and audio-tail seeks, recorded native HLS seeks, live/non-range seek rejection, buffering before the entire file arrives, permanently stalled primary/secondary reads, cancellation/replacement/destruction, HTTP failures, and recovery. Both new checks are included in `make check`.
|
||||
|
||||
@@ -157,7 +159,7 @@ Visual correctness, perceived A/V synchronization, and control usability still n
|
||||
|
||||
## Current limits
|
||||
|
||||
Local regular files and YouTube video URLs, automatically selected best audio/video tracks, software decoding, and SDR output. PQ/HLG HDR is rejected rather than rendered incorrectly. No subtitles, selectable tracks, VideoToolbox decoding, or rotation/deinterlacing. Mid-track audio format changes report an error. The URL UI accepts YouTube videos rather than arbitrary web pages or streaming services.
|
||||
Local regular files and YouTube video URLs, selectable YouTube resolutions, automatically selected audio/video tracks within the chosen quality, software decoding, and SDR output. PQ/HLG HDR is rejected rather than rendered incorrectly. No subtitles, explicit codec/audio-track selection, VideoToolbox decoding, or rotation/deinterlacing. Mid-track audio format changes report an error. The URL UI accepts YouTube videos rather than arbitrary web pages or streaming services.
|
||||
|
||||
Forward frame stepping retains the live video decoder cursor instead of seeking and re-decoding the same keyframe group for each repeat. Backward stepping still scans from an earlier keyframe; long-GOP or unindexed files can therefore step backward slowly, without blanking the image or allocating a command per key repeat. Audio is skipped while stepping and rebuilt once on resume from the verified preroll anchor; resuming after a long forward run can also require a longer preroll. Intermediate frames needed by inter-frame dependencies are decoded and discarded; frames are not cached across the entire file. Every accepted step counts. Stepping requires usable decoded presentation timestamps and reports an error rather than inventing frame times when they are absent.
|
||||
|
||||
|
||||
+70
-10
@@ -12,6 +12,9 @@ final class PlayerModel: ObservableObject {
|
||||
@Published private(set) var pendingRevision: UInt64?
|
||||
@Published private(set) var resolvingYouTube = false
|
||||
@Published private(set) var isLiveStream = false
|
||||
@Published private(set) var youtubeHeights: [Int] = []
|
||||
@Published private(set) var youtubeHeight: Int?
|
||||
@Published private(set) var youtubeQuality: Int?
|
||||
@Published var seekPosition = 0.0
|
||||
@Published var isSeeking = false
|
||||
@Published private(set) var volume = 1.0
|
||||
@@ -47,6 +50,7 @@ final class PlayerModel: ObservableObject {
|
||||
private var playbackProgress: PlaybackProgress
|
||||
private var progressURL: URL?
|
||||
private var resumePosition: Double?
|
||||
private var resumePlaying = true
|
||||
private var lastSavedPosition: Double?
|
||||
private var systemPlayback: SystemPlaybackControls?
|
||||
|
||||
@@ -76,6 +80,9 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
var canSeek: Bool { canTogglePlayback && state.can_seek && state.duration.isFinite && state.duration > 0 }
|
||||
var canStepFrame: Bool { canTogglePlayback && state.has_video && state.can_seek && !isSeeking }
|
||||
var canChangeYouTubeQuality: Bool {
|
||||
canTogglePlayback && !youtubeHeights.isEmpty && !isSeeking && !seekPending && !state.seek_pending
|
||||
}
|
||||
var canOpen: Bool { handle != nil }
|
||||
var canClose: Bool { handle != nil && (isBusy || state.status != PLAYER_EMPTY || fileName != nil) }
|
||||
var canSetVolume: Bool { handle != nil && !isBusy && state.has_audio && state.status != PLAYER_ERROR }
|
||||
@@ -122,8 +129,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
func openYouTube(_ text: String) {
|
||||
guard let handle else { return }
|
||||
cancelYouTubeResolution()
|
||||
guard canOpen else { return }
|
||||
let url: URL
|
||||
do {
|
||||
url = try YouTubeResolver.validateURL(text)
|
||||
@@ -132,6 +138,25 @@ final class PlayerModel: ObservableObject {
|
||||
revealControls()
|
||||
return
|
||||
}
|
||||
resolveYouTube(url, height: nil)
|
||||
}
|
||||
|
||||
func setYouTubeQuality(_ height: Int?) {
|
||||
guard canChangeYouTubeQuality, let url = progressURL, let handle,
|
||||
height != youtubeQuality, height == nil || youtubeHeights.contains(height!) else { return }
|
||||
let target = height ?? youtubeHeights.filter { $0 <= 1080 }.max()
|
||||
if target == youtubeHeight {
|
||||
youtubeQuality = height
|
||||
return
|
||||
}
|
||||
let current = player_get_state(handle)
|
||||
let position = !isLiveStream && current.can_seek ? current.position : nil
|
||||
resolveYouTube(url, height: height, restoring: (position, current.status == PLAYER_PLAYING))
|
||||
}
|
||||
|
||||
private func resolveYouTube(_ url: URL, height: Int?, restoring: (position: Double?, playing: Bool)? = nil) {
|
||||
guard let handle else { return }
|
||||
cancelYouTubeResolution()
|
||||
if !player_pause(handle) {
|
||||
commandError = "Could not pause the current media to open YouTube. Please try again."
|
||||
revealControls()
|
||||
@@ -145,18 +170,21 @@ final class PlayerModel: ObservableObject {
|
||||
revealControls()
|
||||
youtubeTask = Task { [weak self] in
|
||||
do {
|
||||
let video = try await YouTubeResolver.resolve(url)
|
||||
let video = try await YouTubeResolver.resolve(url, height: height)
|
||||
guard !Task.isCancelled, let self, self.youtubeRequest == request, self.handle != nil else { return }
|
||||
self.youtubeTask = nil
|
||||
self.youtubeRequest = nil
|
||||
self.resolvingYouTube = false
|
||||
self.openResolvedYouTube(video)
|
||||
self.openResolvedYouTube(video, height: height, restoring: restoring)
|
||||
} catch {
|
||||
guard !Task.isCancelled, let self, self.youtubeRequest == request else { return }
|
||||
self.youtubeTask = nil
|
||||
self.youtubeRequest = nil
|
||||
self.resolvingYouTube = false
|
||||
self.commandError = error.localizedDescription
|
||||
if restoring?.playing == true, let handle = self.handle, !player_play(handle) {
|
||||
self.commandError = error.localizedDescription + "\nCould not resume the previous playback."
|
||||
}
|
||||
self.revealControls()
|
||||
}
|
||||
}
|
||||
@@ -169,7 +197,7 @@ final class PlayerModel: ObservableObject {
|
||||
resolvingYouTube = false
|
||||
}
|
||||
|
||||
private func openResolvedYouTube(_ video: YouTubeVideo) {
|
||||
private func openResolvedYouTube(_ video: YouTubeVideo, height: Int?, restoring: (position: Double?, playing: Bool)?) {
|
||||
guard let handle else { return }
|
||||
saveProgress()
|
||||
let revision = player_get_state(handle).revision
|
||||
@@ -191,23 +219,39 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
guard accepted else {
|
||||
commandError = "Could not queue the YouTube video to open. Please try again."
|
||||
if restoring?.playing == true, !player_play(handle) {
|
||||
commandError = "Could not queue the YouTube video or resume the previous playback. Please try again."
|
||||
}
|
||||
revealControls()
|
||||
return
|
||||
}
|
||||
didAcceptOpen(revision: revision, title: video.title, icon: nil, url: video.webpageURL, live: video.isLive)
|
||||
didAcceptOpen(revision: revision, title: video.title, icon: nil, url: video.webpageURL,
|
||||
live: video.isLive, restoring: restoring)
|
||||
youtubeHeights = video.availableHeights
|
||||
youtubeHeight = video.height
|
||||
youtubeQuality = height
|
||||
}
|
||||
|
||||
private func didAcceptOpen(revision: UInt64, title: String, icon: NSImage?, url: URL, live: Bool = false) {
|
||||
private func didAcceptOpen(revision: UInt64, title: String, icon: NSImage?, url: URL, live: Bool = false,
|
||||
restoring: (position: Double?, playing: Bool)? = nil) {
|
||||
guard let handle else { return }
|
||||
pendingRevision = revision
|
||||
fileName = title
|
||||
fileIcon = icon
|
||||
isLiveStream = live
|
||||
youtubeHeights = []
|
||||
youtubeHeight = nil
|
||||
youtubeQuality = nil
|
||||
progressURL = url
|
||||
if let restoring {
|
||||
resumePosition = restoring.position
|
||||
} else {
|
||||
resumePosition = live ? nil : playbackProgress.position(for: url)
|
||||
}
|
||||
resumePlaying = restoring?.playing ?? true
|
||||
lastSavedPosition = nil
|
||||
needsVideoFit = true
|
||||
commandError = resumePosition != nil || player_play(handle) ? nil :
|
||||
needsVideoFit = restoring == nil
|
||||
commandError = resumePosition != nil || !resumePlaying || player_play(handle) ? nil :
|
||||
"Could not queue playback. Please try again."
|
||||
isSeeking = false
|
||||
seekPending = false
|
||||
@@ -274,6 +318,9 @@ final class PlayerModel: ObservableObject {
|
||||
fileName = nil
|
||||
fileIcon = nil
|
||||
isLiveStream = false
|
||||
youtubeHeights = []
|
||||
youtubeHeight = nil
|
||||
youtubeQuality = nil
|
||||
progressURL = nil
|
||||
resumePosition = nil
|
||||
lastSavedPosition = nil
|
||||
@@ -503,7 +550,7 @@ final class PlayerModel: ObservableObject {
|
||||
playbackProgress.save(nil, for: url)
|
||||
}
|
||||
}
|
||||
commandError = player_play(handle) ? nil : "Could not queue playback. Please try again."
|
||||
commandError = !resumePlaying || player_play(handle) ? nil : "Could not queue playback. Please try again."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -733,6 +780,19 @@ private struct PlayerContent: View {
|
||||
Button("Open File…", action: model.openPanel).disabled(!model.canOpen)
|
||||
Button("Open YouTube URL…", action: model.openYouTubePanel).disabled(!model.canOpen)
|
||||
Button("Close File", action: model.closeFile).disabled(!model.canClose)
|
||||
if !model.youtubeHeights.isEmpty {
|
||||
Divider()
|
||||
Picker("Quality", selection: Binding(
|
||||
get: { model.youtubeQuality ?? 0 },
|
||||
set: { model.setYouTubeQuality($0 == 0 ? nil : $0) })) {
|
||||
Text("Default (up to 1080p)").tag(0)
|
||||
ForEach(model.youtubeHeights, id: \.self) { height in
|
||||
Text("\(height)p").tag(height)
|
||||
}
|
||||
}
|
||||
.disabled(!model.canChangeYouTubeQuality)
|
||||
Text(model.youtubeHeight.map { "Current: \($0)p" } ?? "Current resolution unknown")
|
||||
}
|
||||
Divider()
|
||||
Button(model.isCompact ? "Exit Compact Mode" : "Enter Compact Mode", action: model.toggleCompact)
|
||||
.disabled(!model.canToggleCompact)
|
||||
|
||||
@@ -14,6 +14,8 @@ struct YouTubeVideo: Sendable {
|
||||
let duration: Double
|
||||
let isLive: Bool
|
||||
let webpageURL: URL
|
||||
let height: Int?
|
||||
let availableHeights: [Int]
|
||||
}
|
||||
|
||||
private struct YouTubeResolutionError: LocalizedError {
|
||||
@@ -58,7 +60,7 @@ enum YouTubeResolver {
|
||||
return URL(string: "https://www.youtube.com/watch?v=\(id)")!
|
||||
}
|
||||
|
||||
static func decode(_ data: Data, webpageURL: URL) throws -> YouTubeVideo {
|
||||
static func decode(_ data: Data, webpageURL: URL, height: Int? = nil) throws -> YouTubeVideo {
|
||||
guard data.count <= outputLimit else {
|
||||
throw YouTubeResolutionError(message: "yt-dlp returned too much metadata for one video.")
|
||||
}
|
||||
@@ -102,6 +104,27 @@ enum YouTubeResolver {
|
||||
guard videoFormat.dynamic_range == nil || videoFormat.dynamic_range?.uppercased() == "SDR" else {
|
||||
throw YouTubeResolutionError(message: "This video has no supported SDR stream. HDR playback is not supported.")
|
||||
}
|
||||
guard videoFormat.height == nil || videoFormat.height! > 0 else {
|
||||
throw YouTubeResolutionError(message: "yt-dlp returned an invalid video resolution.")
|
||||
}
|
||||
if let height {
|
||||
guard height > 0, videoFormat.height == height else {
|
||||
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.")
|
||||
}
|
||||
let formats = metadata.formats ?? [videoFormat] + (audioFormat.map { [$0] } ?? [])
|
||||
let hasSeparateAudio = formats.contains {
|
||||
$0.hasAudio && !$0.hasVideo && (try? stream($0, fallbackHeaders: metadata.http_headers)) != nil
|
||||
}
|
||||
let availableHeights = Set(formats.compactMap { format -> Int? in
|
||||
guard format.hasVideo, format.hasAudio || hasSeparateAudio,
|
||||
format.dynamic_range == nil || format.dynamic_range?.uppercased() == "SDR",
|
||||
let height = format.height, height > 0,
|
||||
(try? stream(format, fallbackHeaders: metadata.http_headers)) != nil else { return nil }
|
||||
return height
|
||||
}).sorted(by: >)
|
||||
let video = try stream(videoFormat, fallbackHeaders: metadata.http_headers)
|
||||
let audio = try audioFormat.map { try stream($0, fallbackHeaders: metadata.http_headers) }
|
||||
let title = metadata.title?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -109,13 +132,17 @@ enum YouTubeResolver {
|
||||
title: title?.isEmpty == false ? title! : "YouTube video",
|
||||
video: video, audio: audio, duration: duration,
|
||||
isLive: metadata.is_live == true || metadata.live_status == "is_live",
|
||||
webpageURL: try validateURL(webpageURL.absoluteString)
|
||||
webpageURL: try validateURL(webpageURL.absoluteString),
|
||||
height: videoFormat.height, availableHeights: availableHeights
|
||||
)
|
||||
}
|
||||
|
||||
static func resolve(_ url: URL, executableURL: URL? = nil, runtimeURL: URL? = nil) async throws -> YouTubeVideo {
|
||||
static func resolve(_ url: URL, height: Int? = nil, executableURL: URL? = nil, runtimeURL: URL? = nil) async throws -> YouTubeVideo {
|
||||
try Task.checkCancellation()
|
||||
let webpageURL = try validateURL(url.absoluteString)
|
||||
guard height == nil || height! > 0 else {
|
||||
throw YouTubeResolutionError(message: "Choose a positive video resolution.")
|
||||
}
|
||||
let directories = searchDirectories()
|
||||
let executable = try executableURL.map { try executableFile($0, name: "yt-dlp") }
|
||||
?? findExecutable("yt-dlp", directories: directories)
|
||||
@@ -135,13 +162,15 @@ enum YouTubeResolver {
|
||||
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 arguments = [
|
||||
"--ignore-config", "--no-plugin-dirs", "--no-cache-dir", "--no-remote-components",
|
||||
"--no-playlist", "--simulate", "--skip-download", "--dump-single-json",
|
||||
"--no-progress", "--color", "never", "--no-check-formats",
|
||||
"--no-js-runtimes", "--js-runtimes", "\(runtimeName):\(runtime.path)",
|
||||
"--socket-timeout", "20", "--extractor-retries", "0", "--retries", "0",
|
||||
"--format", "bestvideo[dynamic_range=?SDR]\(transport)+bestaudio\(transport)/best[dynamic_range=?SDR]\(transport)",
|
||||
"--format-sort", "res", "--format-sort-force",
|
||||
"--format", "bestvideo*[dynamic_range=?SDR]\(resolution)\(transport)+bestaudio\(transport)/best[dynamic_range=?SDR]\(resolution)\(transport)",
|
||||
"--", webpageURL.absoluteString
|
||||
]
|
||||
let output = try await YouTubeExtraction(executable: executable, arguments: arguments, environment: environment).run()
|
||||
@@ -149,7 +178,7 @@ enum YouTubeResolver {
|
||||
if let error = dependencyError(String(decoding: output.stderr, as: UTF8.self).lowercased()) {
|
||||
throw error
|
||||
}
|
||||
let video = try decode(output.stdout, webpageURL: webpageURL)
|
||||
let video = try decode(output.stdout, webpageURL: webpageURL, height: height)
|
||||
try Task.checkCancellation()
|
||||
return video
|
||||
}
|
||||
@@ -250,6 +279,8 @@ private struct YouTubeMetadata: Decodable {
|
||||
let vcodec: String?
|
||||
let acodec: String?
|
||||
let dynamic_range: String?
|
||||
let height: Int?
|
||||
let formats: [YouTubeMetadata]?
|
||||
let http_headers: [String: String]?
|
||||
let requested_formats: [YouTubeMetadata]?
|
||||
|
||||
|
||||
Reference in New Issue
Block a user