mirror of
https://github.com/yattee/yattee.git
synced 2026-11-05 11:27:20 +00:00
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.
This commit is contained in:
@@ -21,15 +21,31 @@ struct LinkActionPerformer {
|
||||
let result: LinkActionResult
|
||||
switch action {
|
||||
case .play(let url, let startTime):
|
||||
result = await play(url, startTime: startTime)
|
||||
if let source = playlistSource(url) {
|
||||
result = await playPlaylist(source)
|
||||
} else {
|
||||
result = await play(url, startTime: startTime)
|
||||
}
|
||||
case .playNext(let url):
|
||||
result = await queue(url, next: true)
|
||||
if let source = playlistSource(url) {
|
||||
result = await queuePlaylist(source, next: true)
|
||||
} else {
|
||||
result = await queue(url, next: true)
|
||||
}
|
||||
case .addToQueue(let url):
|
||||
result = await queue(url, next: false)
|
||||
if let source = playlistSource(url) {
|
||||
result = await queuePlaylist(source, next: false)
|
||||
} else {
|
||||
result = await queue(url, next: false)
|
||||
}
|
||||
case .download(let url):
|
||||
result = await download(url)
|
||||
case .bookmark(let url):
|
||||
result = await bookmark(url)
|
||||
if let source = playlistSource(url) {
|
||||
result = await savePlaylist(source)
|
||||
} else {
|
||||
result = await bookmark(url)
|
||||
}
|
||||
case .subscribe(let url):
|
||||
result = await setSubscribed(true, channelURL: url)
|
||||
case .unsubscribe(let url):
|
||||
@@ -49,7 +65,8 @@ struct LinkActionPerformer {
|
||||
case .success(let video): resolved = video.0
|
||||
case .failure(let failure): return failure.result
|
||||
}
|
||||
appEnvironment.playerService.openVideo(resolved, startTime: startTime, forceStartTime: startTime != nil)
|
||||
// A watch link inside a playlist queues the playlist around it
|
||||
await LinkVideoResolver(appEnvironment: appEnvironment).play(resolved, from: url, startTime: startTime)
|
||||
return .success(values(for: resolved))
|
||||
}
|
||||
|
||||
@@ -160,6 +177,118 @@ struct LinkActionPerformer {
|
||||
return .success(values(for: video))
|
||||
}
|
||||
|
||||
// MARK: - Playlist Actions
|
||||
|
||||
/// The playlist a link points at, when it is one.
|
||||
private func playlistSource(_ url: URL) -> PlaylistSource? {
|
||||
guard case .playlist(let source) = URLRouter().route(url) else { return nil }
|
||||
return source
|
||||
}
|
||||
|
||||
private func playPlaylist(_ source: PlaylistSource) async -> LinkActionResult {
|
||||
let playlist: Playlist
|
||||
switch await loadPlaylist(source) {
|
||||
case .success(let loaded): playlist = loaded
|
||||
case .failure(let failure): return failure.result
|
||||
}
|
||||
appEnvironment.queueManager.playFromList(
|
||||
videos: playlist.videos,
|
||||
index: 0,
|
||||
queueSource: .playlist(playlistID: playlist.id.playlistID, continuation: nil),
|
||||
sourceLabel: playlist.title
|
||||
)
|
||||
return .success(values(for: playlist))
|
||||
}
|
||||
|
||||
private func queuePlaylist(_ source: PlaylistSource, next: Bool) async -> LinkActionResult {
|
||||
let queueManager = appEnvironment.queueManager
|
||||
guard queueManager.isQueueEnabled else {
|
||||
return .failure(code: "queueDisabled", message: String(localized: "linkAction.error.queueDisabled", defaultValue: "The queue is turned off in Settings."))
|
||||
}
|
||||
let playlist: Playlist
|
||||
switch await loadPlaylist(source) {
|
||||
case .success(let loaded): playlist = loaded
|
||||
case .failure(let failure): return failure.result
|
||||
}
|
||||
let count = playlist.videos.count
|
||||
let confirmed = await confirm(
|
||||
title: next
|
||||
? String(localized: "linkAction.confirm.playlistPlayNext.title", defaultValue: "Play Playlist Next?")
|
||||
: String(localized: "linkAction.confirm.playlistQueue.title", defaultValue: "Add Playlist to Queue?"),
|
||||
subject: playlist.title,
|
||||
detail: String(localized: "linkAction.confirm.playlistVideoCount", defaultValue: "\(count) videos"),
|
||||
confirmTitle: next
|
||||
? String(localized: "linkAction.confirm.playNext.button", defaultValue: "Play Next")
|
||||
: String(localized: "linkAction.confirm.queue.button", defaultValue: "Add to Queue"),
|
||||
systemImage: next ? "text.line.first.and.arrowtriangle.forward" : "text.append"
|
||||
)
|
||||
guard confirmed else { return .cancelled }
|
||||
|
||||
let queueSource = QueueSource.playlist(playlistID: playlist.id.playlistID, continuation: nil)
|
||||
if next {
|
||||
// Each lands right after the current video, so go last to first
|
||||
for video in playlist.videos.reversed() {
|
||||
queueManager.playNext(video, queueSource: queueSource)
|
||||
}
|
||||
} else {
|
||||
queueManager.addToQueue(playlist.videos, queueSource: queueSource)
|
||||
}
|
||||
appEnvironment.toastManager.showSuccess(
|
||||
next
|
||||
? String(localized: "linkAction.done.playNext", defaultValue: "Playing next")
|
||||
: String(localized: "linkAction.done.queue", defaultValue: "Added to queue"),
|
||||
subtitle: playlist.title
|
||||
)
|
||||
return .success(values(for: playlist))
|
||||
}
|
||||
|
||||
/// Bookmarking a playlist saves it to the library as a local playlist.
|
||||
private func savePlaylist(_ source: PlaylistSource) async -> LinkActionResult {
|
||||
let playlist: Playlist
|
||||
switch await loadPlaylist(source) {
|
||||
case .success(let loaded): playlist = loaded
|
||||
case .failure(let failure): return failure.result
|
||||
}
|
||||
let confirmed = await confirm(
|
||||
title: String(localized: "linkAction.confirm.savePlaylist.title", defaultValue: "Save Playlist to Library?"),
|
||||
subject: playlist.title,
|
||||
detail: String(localized: "linkAction.confirm.playlistVideoCount", defaultValue: "\(playlist.videos.count) videos"),
|
||||
confirmTitle: String(localized: "linkAction.confirm.savePlaylist.button", defaultValue: "Save"),
|
||||
systemImage: "square.and.arrow.down"
|
||||
)
|
||||
guard confirmed else { return .cancelled }
|
||||
|
||||
appEnvironment.dataManager.importPlaylist(playlist)
|
||||
appEnvironment.toastManager.showSuccess(
|
||||
String(localized: "playlist.imported.title"),
|
||||
subtitle: playlist.title
|
||||
)
|
||||
return .success(values(for: playlist))
|
||||
}
|
||||
|
||||
/// Loads a playlist's videos, showing progress while it does.
|
||||
private func loadPlaylist(_ source: PlaylistSource) async -> Result<Playlist, LinkActionFailure> {
|
||||
let toastID = appEnvironment.toastManager.show(
|
||||
scopes: [.main, .player],
|
||||
category: .loading,
|
||||
title: String(localized: "linkAction.loadingPlaylist", defaultValue: "Loading playlist…"),
|
||||
autoDismissDelay: 30.0
|
||||
)
|
||||
defer { appEnvironment.toastManager.dismiss(id: toastID) }
|
||||
|
||||
do {
|
||||
let playlist = try await LinkVideoResolver(appEnvironment: appEnvironment).playlist(for: source)
|
||||
guard !playlist.videos.isEmpty else {
|
||||
return .failure(.loadFailed(String(localized: "linkAction.error.emptyPlaylist", defaultValue: "The playlist has no videos.")))
|
||||
}
|
||||
return .success(playlist)
|
||||
} catch LinkResolveError.noInstanceAvailable {
|
||||
return .failure(.noSource)
|
||||
} catch {
|
||||
return .failure(.loadFailed(error.localizedDescription))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Subscriptions
|
||||
|
||||
private func setSubscribed(_ subscribe: Bool, channelURL url: URL) async -> LinkActionResult {
|
||||
@@ -301,7 +430,7 @@ struct LinkActionPerformer {
|
||||
} catch let error as LinkResolveError {
|
||||
switch error {
|
||||
case .noInstanceAvailable: return .failure(.noSource)
|
||||
case .noYatteeServer, .notAVideo: return .failure(.notAVideo(error.localizedDescription))
|
||||
case .noYatteeServer, .notAVideo, .unsupportedYouTubeLink: return .failure(.notAVideo(error.localizedDescription))
|
||||
}
|
||||
} catch {
|
||||
return .failure(.loadFailed(error.localizedDescription))
|
||||
@@ -325,6 +454,10 @@ struct LinkActionPerformer {
|
||||
["videoID": video.id.videoID, "title": video.title, "channel": video.author.name]
|
||||
}
|
||||
|
||||
private func values(for playlist: Playlist) -> [String: String] {
|
||||
["playlistID": playlist.id.playlistID, "title": playlist.title, "count": String(playlist.videos.count)]
|
||||
}
|
||||
|
||||
private func playerValues() -> [String: String] {
|
||||
let state = appEnvironment.playerService.state
|
||||
guard let video = state.currentVideo else { return ["playing": "false"] }
|
||||
|
||||
@@ -42,15 +42,24 @@ struct LinkVideoResolver {
|
||||
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)
|
||||
// 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 LinkResolveError.noYatteeServer
|
||||
throw router.isYouTubeHost(url.host)
|
||||
? LinkResolveError.unsupportedYouTubeLink
|
||||
: LinkResolveError.noYatteeServer
|
||||
}
|
||||
let (video, streams, captions) = try await appEnvironment.contentService
|
||||
.extractURL(url, instance: instance)
|
||||
@@ -76,7 +85,8 @@ struct LinkVideoResolver {
|
||||
return .video(.loaded(video), queueContext: context)
|
||||
}
|
||||
|
||||
switch router.route(url) {
|
||||
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
|
||||
@@ -96,6 +106,43 @@ struct LinkVideoResolver {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(
|
||||
@@ -247,6 +294,7 @@ enum LinkResolveError: LocalizedError {
|
||||
case noInstanceAvailable
|
||||
case noYatteeServer
|
||||
case notAVideo
|
||||
case unsupportedYouTubeLink
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
@@ -256,6 +304,8 @@ enum LinkResolveError: LocalizedError {
|
||||
return String(localized: "openLink.noYatteeServer")
|
||||
case .notAVideo:
|
||||
return String(localized: "openLink.notAVideo")
|
||||
case .unsupportedYouTubeLink:
|
||||
return String(localized: "openLink.unsupportedYouTubeLink")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,18 @@ struct URLRouter: Sendable {
|
||||
return .video(.id(.global(videoID)))
|
||||
}
|
||||
|
||||
// youtube.com/results?search_query=…
|
||||
if let query = parseYouTubeSearchURL(url) {
|
||||
return .search(query)
|
||||
}
|
||||
|
||||
// YouTube pages that hold no video (home, feeds, hashtags, Music
|
||||
// browse pages) go nowhere rather than to a yt-dlp extraction that
|
||||
// can only fail
|
||||
if isYouTubeNonVideoPage(url) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try PeerTube URLs
|
||||
if let (instance, videoID) = parsePeerTubeURL(url) {
|
||||
return .video(.id(.federated(videoID, instance: instance, uuid: nil)))
|
||||
@@ -343,12 +355,61 @@ struct URLRouter: Sendable {
|
||||
|
||||
// MARK: - YouTube URL Parsing
|
||||
|
||||
/// Whether the host is YouTube's: youtube.com and its subdomains (www, m,
|
||||
/// music), youtube-nocookie.com, youtu.be. Exact, so look-alike hosts such
|
||||
/// as `notyoutube.com` don't match.
|
||||
func isYouTubeHost(_ host: String?) -> Bool {
|
||||
guard let host = host?.lowercased() else { return false }
|
||||
return host == "youtu.be"
|
||||
|| host == "youtube.com" || host.hasSuffix(".youtube.com")
|
||||
|| host == "youtube-nocookie.com" || host.hasSuffix(".youtube-nocookie.com")
|
||||
}
|
||||
|
||||
/// The playlist a YouTube watch link plays the video within
|
||||
/// (`watch?v=X&list=Y`), when it is a real playlist. Mixes (`RD…`) and
|
||||
/// private lists (Liked, Watch Later) can't be opened as playlists.
|
||||
func youTubePlaylistContext(_ url: URL) -> String? {
|
||||
guard isYouTubeHost(url.host), parseYouTubeURL(url) != nil,
|
||||
let listID = URLComponents(url: url, resolvingAgainstBaseURL: false)?
|
||||
.queryItems?.first(where: { $0.name == "list" })?.value,
|
||||
!listID.isEmpty,
|
||||
!listID.hasPrefix("RD"),
|
||||
!["LL", "WL", "LM"].contains(listID) else {
|
||||
return nil
|
||||
}
|
||||
return listID
|
||||
}
|
||||
|
||||
/// Parse `youtube.com/results?search_query=…` into the search query.
|
||||
private func parseYouTubeSearchURL(_ url: URL) -> String? {
|
||||
guard isYouTubeHost(url.host), url.path == "/results" || url.path == "/search" else { return nil }
|
||||
// Raw items: form encoding writes spaces as `+`, which must become
|
||||
// spaces before percent-decoding (an encoded `%2B` stays a plus)
|
||||
let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.percentEncodedQueryItems
|
||||
let query = (items?.first(where: { $0.name == "search_query" }) ?? items?.first(where: { $0.name == "q" }))?
|
||||
.value?
|
||||
.replacingOccurrences(of: "+", with: " ")
|
||||
.removingPercentEncoding?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let query, !query.isEmpty else { return nil }
|
||||
return query
|
||||
}
|
||||
|
||||
/// YouTube pages that are not a video, playlist, channel or search.
|
||||
private func isYouTubeNonVideoPage(_ url: URL) -> Bool {
|
||||
guard isYouTubeHost(url.host), url.host?.lowercased() != "youtu.be" else { return false }
|
||||
let path = url.pathComponents.dropFirst()
|
||||
guard let first = path.first?.lowercased() else { return true } // home page
|
||||
return ["feed", "hashtag", "results", "search", "browse", "account", "premium", "explore", "gaming", "watch"]
|
||||
.contains(first)
|
||||
}
|
||||
|
||||
/// Parse YouTube URLs and extract video ID.
|
||||
private func parseYouTubeURL(_ url: URL) -> String? {
|
||||
let host = url.host?.lowercased() ?? ""
|
||||
|
||||
// youtube.com/watch?v=VIDEO_ID
|
||||
if host.contains("youtube.com") {
|
||||
// youtube.com/watch?v=VIDEO_ID (also youtube-nocookie.com/embed/VIDEO_ID)
|
||||
if isYouTubeHost(host), host != "youtu.be" {
|
||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||
if let videoID = components?.queryItems?.first(where: { $0.name == "v" })?.value {
|
||||
return videoID
|
||||
@@ -464,7 +525,7 @@ struct URLRouter: Sendable {
|
||||
func parseYouTubeChannelURL(_ url: URL) -> String? {
|
||||
let host = url.host?.lowercased() ?? ""
|
||||
|
||||
guard host.contains("youtube.com") else { return nil }
|
||||
guard isYouTubeHost(host), host != "youtu.be" else { return nil }
|
||||
|
||||
let pathComponents = url.pathComponents
|
||||
|
||||
@@ -500,7 +561,7 @@ struct URLRouter: Sendable {
|
||||
private func parseYouTubePlaylistURL(_ url: URL) -> String? {
|
||||
let host = url.host?.lowercased() ?? ""
|
||||
|
||||
guard host.contains("youtube.com") else { return nil }
|
||||
guard isYouTubeHost(host), host != "youtu.be" else { return nil }
|
||||
|
||||
// youtube.com/playlist?list=PLAYLIST_ID
|
||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||
|
||||
Reference in New Issue
Block a user