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:
Arkadiusz Fal
2026-10-08 22:50:36 +02:00
parent 8a7218ed1b
commit 45e19e6536
10 changed files with 388 additions and 60 deletions

View File

@@ -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

View File

@@ -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" : {

View File

@@ -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"] }

View File

@@ -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")
}
}
}

View File

@@ -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)

View File

@@ -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<String>()
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)

View File

@@ -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

View File

@@ -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>) -> 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(

View File

@@ -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",

View File

@@ -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")