mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +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:
@@ -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
|
||||
|
||||
@@ -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" : {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user