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

152 lines
5.2 KiB
Swift

//
// OpenedLinksViewModel.swift
// Yattee
//
// Resolves several opened links into videos, playlists and channels for
// the Opened Links page.
//
import Foundation
/// One link on the Opened Links page, in the order it was entered.
struct OpenedLink: Identifiable {
enum State {
case loading
case video(Video)
case playlist(Playlist, destination: NavigationDestination)
case channel(Channel, destination: NavigationDestination)
/// A screen the link opens whose details couldn't be loaded here
/// (the screen itself shows why).
case unresolved(NavigationDestination, message: String)
case failed(String)
}
let id: Int
let url: URL
var state: State = .loading
}
@Observable
@MainActor
final class OpenedLinksViewModel {
private(set) var links: [OpenedLink]
private var hasStarted = false
/// Links resolved at once - extraction on Yattee Server is slow, but
/// one link at a time would make long lists crawl.
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 = 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
if case .video(let video) = link.state { return video }
return nil
}
}
/// Links of the videos in `videos`, for re-resolving their streams.
var videoLinks: [URL] {
links.compactMap { link in
if case .video = link.state { return link.url }
return nil
}
}
var isLoading: Bool {
links.contains { link in
if case .loading = link.state { return true }
return false
}
}
/// Starts resolving once. Owned by the model rather than the view's
/// `.task`, which is cancelled when a result's screen is pushed and would
/// fail the links still loading.
func start(appEnvironment: AppEnvironment) {
guard !hasStarted else { return }
hasStarted = true
Task { await load(appEnvironment: appEnvironment) }
}
private func load(appEnvironment: AppEnvironment) async {
let links = self.links
await withTaskGroup(of: (Int, OpenedLink.State).self) { group in
var next = 0
func enqueueNext() {
guard next < links.count else { return }
let link = links[next]
next += 1
group.addTask { @MainActor in
(link.id, await Self.resolve(link.url, appEnvironment: appEnvironment))
}
}
for _ in 0..<Self.maxConcurrentResolutions { enqueueNext() }
for await (id, state) in group {
if let index = self.links.firstIndex(where: { $0.id == id }) {
self.links[index].state = state
}
enqueueNext()
}
}
}
private static func resolve(_ url: URL, appEnvironment: AppEnvironment) async -> OpenedLink.State {
let destination: NavigationDestination
do {
destination = try await LinkVideoResolver(appEnvironment: appEnvironment).resolveDestination(url)
} catch {
return .failed(error.localizedDescription)
}
switch destination {
case .video(.loaded(let video), _):
return .video(video)
case .playlist(let source):
do {
let playlist = try await LinkVideoResolver(appEnvironment: appEnvironment).playlist(for: source)
return .playlist(playlist, destination: destination)
} catch {
return .unresolved(destination, message: error.localizedDescription)
}
case .channel(let channelID, let source):
guard let instance = appEnvironment.instancesManager.instance(for: source) else {
return .unresolved(destination, message: String(localized: "openLink.noInstance"))
}
do {
let channel = try await appEnvironment.contentService.channel(id: channelID, instance: instance)
return .channel(channel, destination: destination)
} catch {
return .unresolved(destination, message: error.localizedDescription)
}
default:
return .unresolved(destination, message: url.absoluteString)
}
}
}