diff --git a/Yattee/Data/DataManager+Playlists.swift b/Yattee/Data/DataManager+Playlists.swift index 7c8c65ba..c9e52298 100644 --- a/Yattee/Data/DataManager+Playlists.swift +++ b/Yattee/Data/DataManager+Playlists.swift @@ -24,6 +24,30 @@ extension DataManager { return playlist } + /// A title no local playlist has yet: the base, else "Base (2)", "Base (3)"… + func uniquePlaylistTitle(_ baseTitle: String) -> String { + let existingTitles = Set(playlists().map(\.title)) + guard existingTitles.contains(baseTitle) else { return baseTitle } + var counter = 2 + while existingTitles.contains("\(baseTitle) (\(counter))") { + counter += 1 + } + return "\(baseTitle) (\(counter))" + } + + /// Saves a remote playlist's videos as a new local playlist. + @discardableResult + func importPlaylist(_ playlist: Playlist) -> LocalPlaylist { + let localPlaylist = createPlaylist( + title: uniquePlaylistTitle(playlist.title), + description: playlist.description + ) + for video in playlist.videos { + addToPlaylist(video, playlist: localPlaylist) + } + return localPlaylist + } + /// Updates a playlist's title and description. func updatePlaylist(_ playlist: LocalPlaylist, title: String, description: String?) { playlist.title = title diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index 39ac786e..53b4ece8 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -7417,7 +7417,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Supports direct video links (MP4, M4V, MOV, WebM, MKV, AVI, FLV)" + "value" : "Supports direct video links (MP4, M4V, MOV, WebM, MKV, AVI, FLV). Add an instance to open YouTube and PeerTube links" } } } @@ -7428,7 +7428,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Supports hundreds of sites with Yattee Server" + "value" : "Videos, playlists and channels from YouTube and PeerTube, plus hundreds of other sites with Yattee Server" } } } @@ -7439,7 +7439,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Supports YouTube and other compatible video links" + "value" : "Videos, playlists and channels from YouTube and PeerTube, plus direct video links" } } } @@ -7483,7 +7483,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "URL is not a video" + "value" : "This link isn't a video" } } } @@ -7620,6 +7620,17 @@ } } }, + "openLink.unsupportedYouTubeLink" : { + "comment" : "Error for a YouTube link Yattee can't open (feeds, hashtags, posts…)", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This kind of YouTube link isn't supported" + } + } + } + }, "openLink.urlCount %lld" : { "comment" : "URL count indicator", "localizations" : { diff --git a/Yattee/Services/Navigation/LinkActionPerformer.swift b/Yattee/Services/Navigation/LinkActionPerformer.swift index c2b9a817..e4c874c6 100644 --- a/Yattee/Services/Navigation/LinkActionPerformer.swift +++ b/Yattee/Services/Navigation/LinkActionPerformer.swift @@ -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 { + 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"] } diff --git a/Yattee/Services/Navigation/LinkVideoResolver.swift b/Yattee/Services/Navigation/LinkVideoResolver.swift index 66ef98b4..062664a7 100644 --- a/Yattee/Services/Navigation/LinkVideoResolver.swift +++ b/Yattee/Services/Navigation/LinkVideoResolver.swift @@ -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") } } } diff --git a/Yattee/Services/Navigation/URLRouter.swift b/Yattee/Services/Navigation/URLRouter.swift index 1179dc06..6a4028b0 100644 --- a/Yattee/Services/Navigation/URLRouter.swift +++ b/Yattee/Services/Navigation/URLRouter.swift @@ -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) diff --git a/Yattee/ViewModels/OpenedLinksViewModel.swift b/Yattee/ViewModels/OpenedLinksViewModel.swift index 08cb3f60..afd2ec12 100644 --- a/Yattee/ViewModels/OpenedLinksViewModel.swift +++ b/Yattee/ViewModels/OpenedLinksViewModel.swift @@ -37,11 +37,25 @@ final class OpenedLinksViewModel { private static let maxConcurrentResolutions = 4 init(urls: [URL]) { + // A watch link inside a playlist (`watch?v=X&list=Y`) shows both the + // video and the playlist + let router = URLRouter() + let expanded = urls.flatMap { url -> [URL] in + guard let listID = router.youTubePlaylistContext(url), + let playlistURL = Self.youTubePlaylistURL(listID) else { return [url] } + return [url, playlistURL] + } var seen = Set() - let unique = urls.filter { seen.insert($0.absoluteString).inserted } + let unique = expanded.filter { seen.insert($0.absoluteString).inserted } links = unique.enumerated().map { OpenedLink(id: $0.offset, url: $0.element) } } + private static func youTubePlaylistURL(_ listID: String) -> URL? { + var components = URLComponents(string: "https://www.youtube.com/playlist") + components?.queryItems = [URLQueryItem(name: "list", value: listID)] + return components?.url + } + /// Resolved videos, in link order. var videos: [Video] { links.compactMap { link in @@ -111,13 +125,9 @@ final class OpenedLinksViewModel { case .video(.loaded(let video), _): return .video(video) - case .playlist(.remote(let playlistID, let instance, _, _)): - guard let instance = instance - ?? playlistID.source.flatMap({ appEnvironment.instancesManager.playlistInstance(for: $0) }) else { - return .unresolved(destination, message: String(localized: "openLink.noInstance")) - } + case .playlist(let source): do { - let playlist = try await appEnvironment.contentService.playlist(id: playlistID.playlistID, instance: instance) + let playlist = try await LinkVideoResolver(appEnvironment: appEnvironment).playlist(for: source) return .playlist(playlist, destination: destination) } catch { return .unresolved(destination, message: error.localizedDescription) diff --git a/Yattee/Views/Home/OpenLinkSheet.swift b/Yattee/Views/Home/OpenLinkSheet.swift index 1be51bb9..696f177d 100644 --- a/Yattee/Views/Home/OpenLinkSheet.swift +++ b/Yattee/Views/Home/OpenLinkSheet.swift @@ -647,7 +647,8 @@ struct OpenLinkFormView: View { if case .externalVideo = destination { return true } return false } - return true + // YouTube pages that route nowhere are unsupported, not external + return !router.isYouTubeHost(url.host) } } @@ -657,7 +658,8 @@ struct OpenLinkFormView: View { let router = URLRouter() return parsedURLs.allSatisfy { url in switch router.route(url) { - case .video, .externalVideo, .directMedia, nil: return true + case .video, .externalVideo, .directMedia: return true + case nil: return !router.isYouTubeHost(url.host) default: return false } } @@ -704,7 +706,8 @@ struct OpenLinkFormView: View { private func loadURLs() async { let urls = parsedURLs guard let appEnvironment, !urls.isEmpty else { return } - if urls.count > 1 { + // A watch link inside a playlist shows both, like several links + if urls.count > 1 || URLRouter().youTubePlaylistContext(urls[0]) != nil { extractedItems = [] dismissIfRequested() appEnvironment.navigationCoordinator.navigateToOpenedLink(.openedLinks(urls)) @@ -764,7 +767,7 @@ struct OpenLinkFormView: View { if !firstVideoPlayed { // Play first video - this expands player - playVideo(video, sourceURL: url, appEnvironment: appEnvironment) + await playVideo(video, sourceURL: url, appEnvironment: appEnvironment) firstVideoPlayed = true } else { // Add to queue @@ -805,13 +808,16 @@ struct OpenLinkFormView: View { } } - private func playVideo(_ video: Video, sourceURL: URL, appEnvironment: AppEnvironment) { + private func playVideo(_ video: Video, sourceURL: URL, appEnvironment: AppEnvironment) async { // Don't pass a specific stream - let the player's selectStreamAndBackend // choose the best video+audio combination. Using streams.first would // incorrectly select audio-only streams for sites like Bilibili. let router = URLRouter() let startTime = router.parseTimestamp(router.unwrapped(sourceURL)) - appEnvironment.playerService.openVideo(video, startTime: startTime, forceStartTime: startTime != nil) + // A watch link inside a playlist queues the playlist around it. + // Awaited, so later links queue after the playlist instead of being + // cleared by it. + await LinkVideoResolver(appEnvironment: appEnvironment).play(video, from: sourceURL, startTime: startTime) } // MARK: - Download Action diff --git a/Yattee/Views/Playlist/UnifiedPlaylistDetailView.swift b/Yattee/Views/Playlist/UnifiedPlaylistDetailView.swift index 969f1a26..50f8e92e 100644 --- a/Yattee/Views/Playlist/UnifiedPlaylistDetailView.swift +++ b/Yattee/Views/Playlist/UnifiedPlaylistDetailView.swift @@ -1378,22 +1378,6 @@ struct UnifiedPlaylistDetailView: View { } } - /// Generates a unique playlist name by appending (2), (3), etc. if needed. - private func generateUniqueName(_ baseName: String, existingTitles: Set) -> String { - if !existingTitles.contains(baseName) { - return baseName - } - - var counter = 2 - while true { - let candidateName = "\(baseName) (\(counter))" - if !existingTitles.contains(candidateName) { - return candidateName - } - counter += 1 - } - } - /// Imports the remote playlist to local storage. private func importToLocal() async { guard let appEnvironment, let remotePlaylist else { return } @@ -1402,8 +1386,7 @@ struct UnifiedPlaylistDetailView: View { importProgress = nil // Generate unique name - let existingTitles = Set(appEnvironment.dataManager.playlists().map(\.title)) - let uniqueName = generateUniqueName(remotePlaylist.title, existingTitles: existingTitles) + let uniqueName = appEnvironment.dataManager.uniquePlaylistTitle(remotePlaylist.title) // Create local playlist let newLocalPlaylist = appEnvironment.dataManager.createPlaylist( diff --git a/Yattee/YatteeApp.swift b/Yattee/YatteeApp.swift index 06380515..551995af 100644 --- a/Yattee/YatteeApp.swift +++ b/Yattee/YatteeApp.swift @@ -602,7 +602,7 @@ struct YatteeApp: App { case .open: // Play directly (existing behavior) Task { - await playVideoFromDeepLink(videoID: videoID, startTime: startTime) + await playVideoFromDeepLink(videoID: videoID, url: originalURL, startTime: startTime) } case .download: @@ -614,7 +614,7 @@ struct YatteeApp: App { #else // tvOS doesn't support downloads, fall back to open Task { - await playVideoFromDeepLink(videoID: videoID, startTime: startTime) + await playVideoFromDeepLink(videoID: videoID, url: originalURL, startTime: startTime) } #endif @@ -655,6 +655,12 @@ struct YatteeApp: App { /// Load a link's video and show its details page. On failure, `fallback` /// (a screen that loads the link itself) shows the error. private func showLinkDetails(_ url: URL, fallback: NavigationDestination) async { + // A watch link inside a playlist shows both, as Open Link's Load does + if URLRouter().youTubePlaylistContext(url) != nil { + appEnvironment.navigationCoordinator.navigateToOpenedLink(.openedLinks([url])) + return + } + let toastID = appEnvironment.toastManager.show( scopes: [.main, .player], category: .loading, @@ -678,7 +684,7 @@ struct YatteeApp: App { } /// Play a video from a deep link. - private func playVideoFromDeepLink(videoID: VideoID, startTime: TimeInterval? = nil) async { + private func playVideoFromDeepLink(videoID: VideoID, url: URL? = nil, startTime: TimeInterval? = nil) async { LoggingService.shared.info( "Deep link play: videoID=\(videoID.videoID) source=\(videoID.source) startTime=\(startTime ?? -1)", category: .general @@ -716,8 +722,9 @@ struct YatteeApp: App { instance: instance ) LoggingService.shared.info("Deep link play: fetched video, opening player", category: .general) + // A watch link inside a playlist queues the playlist around it + await LinkVideoResolver(appEnvironment: appEnvironment).play(video, from: url, startTime: startTime) appEnvironment.toastManager.dismiss(id: toastID) - appEnvironment.playerService.openVideo(video, startTime: startTime, forceStartTime: startTime != nil) } catch { LoggingService.shared.error( "Deep link play: video fetch failed (\(error.localizedDescription)), falling back to info view", diff --git a/YatteeTests/NavigationTests.swift b/YatteeTests/NavigationTests.swift index a807377e..b5ccb0be 100644 --- a/YatteeTests/NavigationTests.swift +++ b/YatteeTests/NavigationTests.swift @@ -326,16 +326,59 @@ struct URLRouterTests { } } - @Test("YouTube URL without video ID routes to external video") + @Test("YouTube watch URL without video ID routes nowhere") func urlWithoutVideoID() { let url = URL(string: "https://www.youtube.com/watch")! - let destination = router.route(url) - // YouTube URLs without video ID are now treated as potential external videos - if case .externalVideo(let extractedURL) = destination { - #expect(extractedURL == url) - } else { - Issue.record("Expected externalVideo destination") - } + #expect(router.route(url) == nil) + } + + @Test("YouTube pages without a video route nowhere instead of to yt-dlp", arguments: [ + "https://www.youtube.com/", + "https://youtube.com", + "https://www.youtube.com/feed/subscriptions", + "https://www.youtube.com/hashtag/music", + "https://www.youtube.com/results", + "https://music.youtube.com/browse/MPREb_abc", + ]) + func youTubeNonVideoPages(_ string: String) { + #expect(router.route(URL(string: string)!) == nil) + } + + @Test("Other unparsed YouTube links still go to yt-dlp") + func youTubeClipGoesToExtraction() { + let url = URL(string: "https://www.youtube.com/clip/UgkxABC")! + #expect(router.route(url) == .externalVideo(url)) + } + + @Test("YouTube search results link routes to search") + func youTubeSearchLink() { + let url = URL(string: "https://www.youtube.com/results?search_query=swift+concurrency")! + #expect(router.route(url) == .search("swift concurrency")) + } + + @Test("youtube-nocookie embed routes to video") + func youTubeNoCookieEmbed() { + let url = URL(string: "https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?start=30")! + #expect(router.route(url) == .video(.id(.global("dQw4w9WgXcQ")))) + } + + @Test("Look-alike hosts are not YouTube") + func lookAlikeHosts() { + let url = URL(string: "https://notyoutube.com/watch?v=dQw4w9WgXcQ")! + #expect(router.route(url) == .externalVideo(url)) + #expect(!router.isYouTubeHost("youtube.com.evil.example")) + #expect(router.isYouTubeHost("music.youtube.com")) + } + + @Test("Watch link playlist context", arguments: [ + ("https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PLtest123", "PLtest123"), + ("https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=RDdQw4w9WgXcQ", nil), + ("https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=WL", nil), + ("https://www.youtube.com/watch?v=dQw4w9WgXcQ", nil), + ("https://www.youtube.com/playlist?list=PLtest123", nil), + ] as [(String, String?)]) + func playlistContext(_ string: String, _ expected: String?) { + #expect(router.youTubePlaylistContext(URL(string: string)!) == expected) } @Test("Known non-PeerTube hosts route to external video")