Files
yattee/Yattee/Services/Navigation/LinkVideoResolver.swift
Arkadiusz Fal 45e19e6536 Open more YouTube link types and playlists from links
Route YouTube search results links to search and nocookie embeds to the
video, match YouTube hosts exactly, and report YouTube pages without a
video as unsupported instead of sending them to yt-dlp. A watch link
inside a playlist shows both the video and the playlist when loaded, and
plays the video with the playlist queued around it. URL scheme play,
queue and bookmark actions accept playlist links.
2026-10-08 22:50:36 +02:00

312 lines
12 KiB
Swift

//
// LinkVideoResolver.swift
// Yattee
//
// Turns a link into a video and its streams, and queues downloads from them.
// Shared by the Open Link sheet and URL scheme actions.
//
import Foundation
@MainActor
struct LinkVideoResolver {
let appEnvironment: AppEnvironment
/// Resolves a link to a playable video with its streams, captions and
/// storyboards: YouTube/PeerTube through the matching source, direct media
/// as-is, anything else through Yattee Server's extraction.
func resolve(_ url: URL) async throws -> (Video, [Stream], [Caption], [Storyboard]) {
let router = URLRouter()
let destination = router.route(url)
switch destination {
case .video(let source, _):
guard case .id(let videoID) = source else {
throw LinkResolveError.notAVideo
}
// YouTube/PeerTube - use content-aware instance selection
guard let instance = appEnvironment.instancesManager.instance(for: videoID.source) else {
throw LinkResolveError.noInstanceAvailable
}
let (video, streams, captions, storyboards) = try await appEnvironment.contentService
.videoWithProxyStreamsAndCaptionsAndStoryboards(
id: videoID.videoID,
instance: instance
)
return (video, streams, captions, storyboards)
case .directMedia(let mediaURL):
// Direct media URL - no extraction needed, no captions/storyboards
let video = DirectMediaHelper.createVideo(from: mediaURL)
let stream = DirectMediaHelper.createStream(from: mediaURL)
return (video, [stream], [], [])
case .externalVideo, nil:
// YouTube pages that hold no video route nowhere; say so instead
// of asking for an extraction that can only fail
if destination == nil, router.isYouTubeHost(url.host) {
throw LinkResolveError.unsupportedYouTubeLink
}
// A file mpv plays as-is needs no extraction, no captions/storyboards
if let probe = await DirectMediaProbe.probe(url) {
let video = DirectMediaHelper.createVideo(from: url, probe: probe)
let stream = DirectMediaHelper.createStream(from: url, probe: probe)
return (video, [stream], [], [])
}
// External URL - use Yattee Server (doesn't support storyboards).
// Without one, a YouTube link Yattee can't parse (clips, posts) is
// simply unsupported - asking for a server would mislead.
guard let instance = appEnvironment.instancesManager.yatteeServerInstance else {
throw router.isYouTubeHost(url.host)
? LinkResolveError.unsupportedYouTubeLink
: LinkResolveError.noYatteeServer
}
let (video, streams, captions) = try await appEnvironment.contentService
.extractURL(url, instance: instance)
return (video, streams, captions, [])
default:
throw LinkResolveError.notAVideo
}
}
/// Resolves a link to the screen that shows it: a video's details page
/// (loaded here so a bad link fails before navigating), or the playlist /
/// channel screen, which loads its own content. A `?t=` timestamp is
/// carried to the details page's Play.
func resolveDestination(_ url: URL) async throws -> NavigationDestination {
let router = URLRouter()
let url = router.unwrapped(url)
let startTime = router.parseTimestamp(url)
func details(_ video: Video) -> NavigationDestination {
var context = VideoQueueContext.single(video)
context.linkStartTime = startTime
return .video(.loaded(video), queueContext: context)
}
let destination = router.route(url)
switch destination {
case .video(.id(let videoID), _):
guard let instance = appEnvironment.instancesManager.instance(for: videoID.source) else {
throw LinkResolveError.noInstanceAvailable
}
let video = try await appEnvironment.contentService.video(id: videoID.videoID, instance: instance)
return details(video)
case .video(.loaded(let video), _):
return details(video)
case .directMedia, .externalVideo, nil:
let (video, _, _, _) = try await resolve(url)
return details(video)
case .some(let destination):
return destination
}
}
/// Plays a video opened from a link. A watch link inside a playlist
/// (`watch?v=X&list=Y`) plays it with the playlist queued around it;
/// if the playlist can't be loaded, the video plays alone.
func play(_ video: Video, from url: URL?, startTime: TimeInterval?) async {
let router = URLRouter()
if let url, appEnvironment.queueManager.isQueueEnabled,
let listID = router.youTubePlaylistContext(router.unwrapped(url)) {
let source = PlaylistSource.remote(.global(listID), instance: nil)
if let playlist = try? await playlist(for: source),
let index = playlist.videos.firstIndex(where: { $0.id.videoID == video.id.videoID }) {
var videos = playlist.videos
videos[index] = video
appEnvironment.queueManager.playFromList(
videos: videos,
index: index,
queueSource: .playlist(playlistID: listID, continuation: nil),
sourceLabel: playlist.title,
startTime: startTime
)
return
}
}
appEnvironment.playerService.openVideo(video, startTime: startTime, forceStartTime: startTime != nil)
}
/// Loads a remote playlist with its videos from the instance suited to it.
func playlist(for source: PlaylistSource) async throws -> Playlist {
guard case .remote(let playlistID, let instance, _, _) = source else {
throw LinkResolveError.notAVideo
}
guard let instance = instance
?? playlistID.source.flatMap({ appEnvironment.instancesManager.playlistInstance(for: $0) }) else {
throw LinkResolveError.noInstanceAvailable
}
return try await appEnvironment.contentService.playlist(id: playlistID.playlistID, instance: instance)
}
#if !os(tvOS)
/// Enqueues a single video for download with already-fetched streams.
func enqueueDownload(
video: Video,
streams: [Stream],
captions: [Caption],
storyboards: [Storyboard],
quality: DownloadQuality,
includeSubtitles: Bool
) async throws {
// Select best video stream
let videoStream = selectBestVideoStream(from: streams, maxQuality: quality)
guard let videoStream else {
throw DownloadError.noStreamAvailable
}
// Select audio stream if needed
var audioStream: Stream?
if videoStream.isVideoOnly {
audioStream = selectBestAudioStream(
from: streams,
preferredLanguage: appEnvironment.settingsManager.preferredAudioLanguage
)
}
// Select caption if enabled
var caption: Caption?
if includeSubtitles, let preferredLang = appEnvironment.settingsManager.preferredSubtitlesLanguage {
caption = selectBestCaption(from: captions, preferredLanguage: preferredLang)
}
let audioCodec = videoStream.isMuxed ? videoStream.audioCodec : audioStream?.audioCodec
let audioBitrate = videoStream.isMuxed ? nil : audioStream?.bitrate
try await appEnvironment.downloadManager.enqueue(
video,
quality: videoStream.qualityLabel,
formatID: videoStream.format,
streamURL: videoStream.url,
audioStreamURL: videoStream.isVideoOnly ? audioStream?.url : nil,
captionURL: caption?.url,
audioLanguage: audioStream?.audioLanguage,
captionLanguage: caption?.languageCode,
httpHeaders: videoStream.httpHeaders,
storyboard: storyboards.highest(),
dislikeCount: nil,
videoCodec: videoStream.videoCodec,
audioCodec: audioCodec,
videoBitrate: videoStream.bitrate,
audioBitrate: audioBitrate
)
}
/// Resolves each link's streams and enqueues it for download.
/// - Returns: How many links were queued and how many failed.
func downloadLinks(
_ urls: [URL],
quality: DownloadQuality,
includeSubtitles: Bool
) async -> (succeeded: Int, failed: Int) {
var succeeded = 0
var failed = 0
for url in urls {
do {
let (video, streams, captions, storyboards) = try await resolve(url)
try await enqueueDownload(
video: video,
streams: streams,
captions: captions,
storyboards: storyboards,
quality: quality,
includeSubtitles: includeSubtitles
)
succeeded += 1
} catch {
LoggingService.shared.warning(
"Open link download failed for \(url.absoluteString): \(error.localizedDescription)",
category: .downloads
)
failed += 1
}
}
return (succeeded, failed)
}
// MARK: - Stream Selection Helpers
private func selectBestVideoStream(from streams: [Stream], maxQuality: DownloadQuality) -> Stream? {
let maxRes = maxQuality.maxResolution
let videoStreams = streams
.filter { !$0.isAudioOnly && $0.resolution != nil }
.filter {
let format = StreamFormat.detect(from: $0)
return format != .hls && format != .dash
}
.sorted { s1, s2 in
let res1 = s1.resolution ?? .p360
let res2 = s2.resolution ?? .p360
if res1 != res2 { return res1 > res2 }
if s1.isMuxed != s2.isMuxed { return s1.isMuxed }
return HardwareCapabilities.shared.codecPriority(for: s1.videoCodec) >
HardwareCapabilities.shared.codecPriority(for: s2.videoCodec)
}
guard let maxRes else {
return videoStreams.first
}
if let stream = videoStreams.first(where: { ($0.resolution ?? .p360) <= maxRes }) {
return stream
}
return videoStreams.last
}
private func selectBestAudioStream(from streams: [Stream], preferredLanguage: String?) -> Stream? {
let audioStreams = streams.filter { $0.isAudioOnly }
if let preferred = preferredLanguage {
if let match = audioStreams.first(where: { ($0.audioLanguage ?? "").hasPrefix(preferred) }) {
return match
}
}
if let original = audioStreams.first(where: { $0.isOriginalAudio }) {
return original
}
return audioStreams.first
}
private func selectBestCaption(from captions: [Caption], preferredLanguage: String) -> Caption? {
if let exact = captions.first(where: { $0.languageCode == preferredLanguage }) {
return exact
}
if let prefix = captions.first(where: {
$0.languageCode.hasPrefix(preferredLanguage) || $0.baseLanguageCode == preferredLanguage
}) {
return prefix
}
return nil
}
#endif
}
// MARK: - Errors
enum LinkResolveError: LocalizedError {
case noInstanceAvailable
case noYatteeServer
case notAVideo
case unsupportedYouTubeLink
var errorDescription: String? {
switch self {
case .noInstanceAvailable:
return String(localized: "openLink.noInstance")
case .noYatteeServer:
return String(localized: "openLink.noYatteeServer")
case .notAVideo:
return String(localized: "openLink.notAVideo")
case .unsupportedYouTubeLink:
return String(localized: "openLink.unsupportedYouTubeLink")
}
}
}