mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
Ten review findings in the remote-playlist work, all of them the same shape: something acted on what it remembered instead of what the server says now. - A confirmed-gone playlist is forgotten whole — snapshot, list entry, and both ordering fences — so a refresh that started before the delete can no longer republish it, and a list refetch that fails offline no longer leaves it on screen. - `.accountsDidChange` discards account-scoped content when the account it belonged to is no longer the effective one, as the status notification already did. - A tab that stops being offered no longer stays selected: the browse view moves to the first available one, which reloads by itself. - A Handoff account id is accepted only when that account is on the instance the playlist lives on. - Composite writes moved up into `AccountService`, over a transport whose every method is exactly one request: create-with-privacy, rename, and set-description. The credential is rechecked between each pair, so a sign-out between the two halves of a create or an edit can no longer send the second half under the previous user's secret. - Editing a playlist no longer forces it private. Invidious serialises visibility as `isListed` — `privacy.public?`, so unlisted and private are indistinguishable — which the snapshot now carries; the picker starts there, and sends nothing at all unless it was moved. Invidious keeps the stored visibility when the PATCH omits it (measured), so an untouched picker leaves it exactly as it was. - Tapping Edit on a server-side playlist awaits an authenticated refetch first and populates the form from it; a failed refetch shows an error and no sheet. - Playlist rows are keyed by row rather than by video id, which two rows of the same video share. - The add-to-playlist sheet offers only accounts whose instance is enabled and that hold a credential, alongside the write capability.
710 lines
30 KiB
Swift
710 lines
30 KiB
Swift
//
|
|
// ContentService.swift
|
|
// Yattee
|
|
//
|
|
// Unified interface for fetching content from any backend (Invidious, Piped, PeerTube).
|
|
//
|
|
|
|
import Foundation
|
|
|
|
/// Protocol defining the common content fetching interface.
|
|
protocol ContentServiceProtocol: Sendable {
|
|
func trending(for instance: Instance) async throws -> [Video]
|
|
func popular(for instance: Instance) async throws -> [Video]
|
|
func feed(for instance: Instance, credential: String) async throws -> [Video]
|
|
func subscriptions(for instance: Instance, credential: String) async throws -> [Channel]
|
|
func search(query: String, instance: Instance, page: Int, filters: SearchFilters) async throws -> SearchResult
|
|
func searchSuggestions(query: String, instance: Instance) async throws -> [String]
|
|
func video(id: String, instance: Instance) async throws -> Video
|
|
func channel(id: String, instance: Instance) async throws -> Channel
|
|
func channelVideos(id: String, instance: Instance, continuation: String?) async throws -> ChannelVideosPage
|
|
func channelPlaylists(id: String, instance: Instance, continuation: String?) async throws -> ChannelPlaylistsPage
|
|
func channelShorts(id: String, instance: Instance, continuation: String?) async throws -> ChannelVideosPage
|
|
func channelStreams(id: String, instance: Instance, continuation: String?) async throws -> ChannelVideosPage
|
|
func playlist(id: String, instance: Instance) async throws -> Playlist
|
|
func comments(videoID: String, instance: Instance, continuation: String?) async throws -> CommentsPage
|
|
func streams(videoID: String, instance: Instance) async throws -> [Stream]
|
|
func captions(videoID: String, instance: Instance) async throws -> [Caption]
|
|
func channelSearch(id: String, query: String, instance: Instance, page: Int) async throws -> ChannelSearchPage
|
|
}
|
|
|
|
/// Unified content service that routes requests to the appropriate API based on instance type.
|
|
actor ContentService: ContentServiceProtocol {
|
|
private let httpClientFactory: HTTPClientFactory
|
|
|
|
// Default HTTPClient for instances with standard SSL (allowInvalidCertificates = false)
|
|
private let defaultHTTPClient: HTTPClient
|
|
|
|
// Cached API instances for default SSL mode
|
|
private let defaultInvidiousAPI: InvidiousAPI
|
|
private let defaultPipedAPI: PipedAPI
|
|
private let defaultPeerTubeAPI: PeerTubeAPI
|
|
private let defaultYatteeServerAPI: YatteeServerAPI
|
|
|
|
/// Credentials manager for fetching basic auth headers on demand.
|
|
private let basicAuthCredentialsManager: BasicAuthCredentialsManager?
|
|
|
|
init(httpClient: HTTPClient, basicAuthCredentialsManager: BasicAuthCredentialsManager? = nil) {
|
|
// Legacy init - create factory internally
|
|
self.httpClientFactory = HTTPClientFactory()
|
|
self.defaultHTTPClient = httpClient
|
|
self.defaultInvidiousAPI = InvidiousAPI(httpClient: httpClient)
|
|
self.defaultPipedAPI = PipedAPI(httpClient: httpClient)
|
|
self.defaultPeerTubeAPI = PeerTubeAPI(httpClient: httpClient)
|
|
self.defaultYatteeServerAPI = YatteeServerAPI(httpClient: httpClient)
|
|
self.basicAuthCredentialsManager = basicAuthCredentialsManager
|
|
}
|
|
|
|
init(httpClientFactory: HTTPClientFactory, basicAuthCredentialsManager: BasicAuthCredentialsManager? = nil) {
|
|
self.httpClientFactory = httpClientFactory
|
|
// Create default client for instances that don't need insecure SSL
|
|
self.defaultHTTPClient = httpClientFactory.createClient(allowInvalidCertificates: false)
|
|
self.defaultInvidiousAPI = InvidiousAPI(httpClient: defaultHTTPClient)
|
|
self.defaultPipedAPI = PipedAPI(httpClient: defaultHTTPClient)
|
|
self.defaultPeerTubeAPI = PeerTubeAPI(httpClient: defaultHTTPClient)
|
|
self.defaultYatteeServerAPI = YatteeServerAPI(httpClient: defaultHTTPClient)
|
|
self.basicAuthCredentialsManager = basicAuthCredentialsManager
|
|
}
|
|
|
|
// MARK: - Routing
|
|
|
|
/// Builds a per-instance HTTPClient with the basic-auth `Authorization` header baked in,
|
|
/// or returns nil if no basic-auth credentials are configured for the instance.
|
|
/// Used to inject reverse-proxy basic auth uniformly across all backends.
|
|
private func httpClientWithBasicAuth(for instance: Instance) async -> HTTPClient? {
|
|
guard let authHeader = await basicAuthCredentialsManager?.basicAuthHeader(for: instance) else {
|
|
return nil
|
|
}
|
|
let client = httpClientFactory.createClient(for: instance)
|
|
await client.setDefaultHeaders(["Authorization": authHeader])
|
|
return client
|
|
}
|
|
|
|
/// Returns an API client configured for the instance's SSL and auth requirements.
|
|
private func api(for instance: Instance) async -> any InstanceAPI {
|
|
switch instance.type {
|
|
case .invidious:
|
|
return await invidiousAPI(for: instance)
|
|
case .piped:
|
|
return await pipedAPI(for: instance)
|
|
case .peertube:
|
|
return await peerTubeAPI(for: instance)
|
|
case .yatteeServer:
|
|
return await yatteeServerAPI(for: instance)
|
|
}
|
|
}
|
|
|
|
/// Returns a YatteeServerAPI configured for the instance's SSL and auth requirements.
|
|
private func yatteeServerAPI(for instance: Instance) async -> YatteeServerAPI {
|
|
if let authClient = await httpClientWithBasicAuth(for: instance) {
|
|
return YatteeServerAPI(httpClient: authClient)
|
|
}
|
|
if !instance.allowInvalidCertificates {
|
|
return defaultYatteeServerAPI
|
|
}
|
|
let insecureClient = httpClientFactory.createClient(for: instance)
|
|
return YatteeServerAPI(httpClient: insecureClient)
|
|
}
|
|
|
|
/// Returns an InvidiousAPI configured for the instance's SSL and auth requirements.
|
|
private func invidiousAPI(for instance: Instance) async -> InvidiousAPI {
|
|
if let authClient = await httpClientWithBasicAuth(for: instance) {
|
|
return InvidiousAPI(httpClient: authClient)
|
|
}
|
|
if !instance.allowInvalidCertificates {
|
|
return defaultInvidiousAPI
|
|
}
|
|
let insecureClient = httpClientFactory.createClient(for: instance)
|
|
return InvidiousAPI(httpClient: insecureClient)
|
|
}
|
|
|
|
/// Returns a PipedAPI configured for the instance's SSL and auth requirements.
|
|
private func pipedAPI(for instance: Instance) async -> PipedAPI {
|
|
if let authClient = await httpClientWithBasicAuth(for: instance) {
|
|
return PipedAPI(httpClient: authClient)
|
|
}
|
|
if !instance.allowInvalidCertificates {
|
|
return defaultPipedAPI
|
|
}
|
|
let insecureClient = httpClientFactory.createClient(for: instance)
|
|
return PipedAPI(httpClient: insecureClient)
|
|
}
|
|
|
|
/// Returns a PeerTubeAPI configured for the instance's SSL and auth requirements.
|
|
private func peerTubeAPI(for instance: Instance) async -> PeerTubeAPI {
|
|
if let authClient = await httpClientWithBasicAuth(for: instance) {
|
|
return PeerTubeAPI(httpClient: authClient)
|
|
}
|
|
if !instance.allowInvalidCertificates {
|
|
return defaultPeerTubeAPI
|
|
}
|
|
let insecureClient = httpClientFactory.createClient(for: instance)
|
|
return PeerTubeAPI(httpClient: insecureClient)
|
|
}
|
|
|
|
// MARK: - ContentServiceProtocol
|
|
|
|
func trending(for instance: Instance) async throws -> [Video] {
|
|
try await api(for: instance).trending(instance: instance)
|
|
}
|
|
|
|
func popular(for instance: Instance) async throws -> [Video] {
|
|
try await api(for: instance).popular(instance: instance)
|
|
}
|
|
|
|
func feed(for instance: Instance, credential: String) async throws -> [Video] {
|
|
switch instance.type {
|
|
case .invidious:
|
|
let response = try await invidiousAPI(for: instance).feed(
|
|
instance: instance,
|
|
sid: credential,
|
|
page: 1,
|
|
maxResults: 50 // Fetch 50, but HomeView will show max 15 per section
|
|
)
|
|
return response.videos
|
|
case .piped:
|
|
return try await pipedAPI(for: instance).feed(
|
|
instance: instance,
|
|
authToken: credential
|
|
)
|
|
default:
|
|
throw APIError.notSupported
|
|
}
|
|
}
|
|
|
|
/// Fetches one page of the credential owner's feed.
|
|
///
|
|
/// Separate from the unpaged `feed(for:credential:)` because only Invidious
|
|
/// has pages at all: Piped answers with the whole feed however it is asked,
|
|
/// which is why it always reports there is no more to come.
|
|
func feed(for instance: Instance, credential: String, page: Int) async throws -> FeedPage {
|
|
switch instance.type {
|
|
case .invidious:
|
|
let response = try await invidiousAPI(for: instance).feed(
|
|
instance: instance,
|
|
sid: credential,
|
|
page: page
|
|
)
|
|
return FeedPage(videos: response.videos, hasMore: response.hasMore)
|
|
case .piped:
|
|
let videos = try await pipedAPI(for: instance).feed(
|
|
instance: instance,
|
|
authToken: credential
|
|
)
|
|
return FeedPage(videos: videos, hasMore: false)
|
|
default:
|
|
throw APIError.notSupported
|
|
}
|
|
}
|
|
|
|
func subscriptions(for instance: Instance, credential: String) async throws -> [Channel] {
|
|
switch instance.type {
|
|
case .invidious:
|
|
let subs = try await invidiousAPI(for: instance).subscriptions(
|
|
instance: instance,
|
|
sid: credential
|
|
)
|
|
return subs.map { $0.toChannel(baseURL: instance.url) }
|
|
case .piped:
|
|
let subs = try await pipedAPI(for: instance).subscriptions(
|
|
instance: instance,
|
|
authToken: credential
|
|
)
|
|
return subs.map { $0.toChannel() }
|
|
default:
|
|
throw APIError.notSupported
|
|
}
|
|
}
|
|
|
|
func search(query: String, instance: Instance, page: Int = 1, filters: SearchFilters = .defaults) async throws -> SearchResult {
|
|
try await api(for: instance).search(query: query, instance: instance, page: page, filters: filters)
|
|
}
|
|
|
|
func searchSuggestions(query: String, instance: Instance) async throws -> [String] {
|
|
try await api(for: instance).searchSuggestions(query: query, instance: instance)
|
|
}
|
|
|
|
func video(id: String, instance: Instance) async throws -> Video {
|
|
try await api(for: instance).video(id: id, instance: instance)
|
|
}
|
|
|
|
func channel(id: String, instance: Instance) async throws -> Channel {
|
|
try await api(for: instance).channel(id: id, instance: instance)
|
|
}
|
|
|
|
func channelVideos(id: String, instance: Instance, continuation: String? = nil) async throws -> ChannelVideosPage {
|
|
try await api(for: instance).channelVideos(id: id, instance: instance, continuation: continuation)
|
|
}
|
|
|
|
func channelPlaylists(id: String, instance: Instance, continuation: String? = nil) async throws -> ChannelPlaylistsPage {
|
|
try await api(for: instance).channelPlaylists(id: id, instance: instance, continuation: continuation)
|
|
}
|
|
|
|
func channelShorts(id: String, instance: Instance, continuation: String? = nil) async throws -> ChannelVideosPage {
|
|
try await api(for: instance).channelShorts(id: id, instance: instance, continuation: continuation)
|
|
}
|
|
|
|
func channelStreams(id: String, instance: Instance, continuation: String? = nil) async throws -> ChannelVideosPage {
|
|
try await api(for: instance).channelStreams(id: id, instance: instance, continuation: continuation)
|
|
}
|
|
|
|
func playlist(id: String, instance: Instance) async throws -> Playlist {
|
|
try await api(for: instance).playlist(id: id, instance: instance)
|
|
}
|
|
|
|
func comments(videoID: String, instance: Instance, continuation: String? = nil) async throws -> CommentsPage {
|
|
try await api(for: instance).comments(videoID: videoID, instance: instance, continuation: continuation)
|
|
}
|
|
|
|
func streams(videoID: String, instance: Instance) async throws -> [Stream] {
|
|
try await api(for: instance).streams(videoID: videoID, instance: instance)
|
|
}
|
|
|
|
func captions(videoID: String, instance: Instance) async throws -> [Caption] {
|
|
try await api(for: instance).captions(videoID: videoID, instance: instance)
|
|
}
|
|
|
|
func channelSearch(id: String, query: String, instance: Instance, page: Int = 1) async throws -> ChannelSearchPage {
|
|
try await api(for: instance).channelSearch(id: id, query: query, instance: instance, page: page)
|
|
}
|
|
|
|
// MARK: - Authenticated Playlists
|
|
|
|
/// Fetches the playlists the credential's owner has on the instance.
|
|
func userPlaylists(instance: Instance, credential: String) async throws -> [Playlist] {
|
|
switch instance.type {
|
|
case .invidious:
|
|
return try await invidiousAPI(for: instance).userPlaylists(instance: instance, sid: credential)
|
|
case .piped:
|
|
return try await pipedAPI(for: instance).userPlaylists(instance: instance, authToken: credential)
|
|
default:
|
|
throw APIError.notSupported
|
|
}
|
|
}
|
|
|
|
/// Fetches one of the credential owner's playlists along with the per-entry
|
|
/// identities its rows can be removed by.
|
|
func userPlaylistSnapshot(
|
|
id: String,
|
|
instance: Instance,
|
|
credential: String
|
|
) async throws -> RemotePlaylistSnapshot {
|
|
switch instance.type {
|
|
case .invidious:
|
|
return try await invidiousAPI(for: instance).userPlaylistSnapshot(
|
|
id: id,
|
|
instance: instance,
|
|
sid: credential
|
|
)
|
|
case .piped:
|
|
return try await pipedAPI(for: instance).userPlaylistSnapshot(
|
|
id: id,
|
|
instance: instance,
|
|
authToken: credential
|
|
)
|
|
default:
|
|
throw APIError.notSupported
|
|
}
|
|
}
|
|
|
|
/// Creates a playlist for the credential's owner. **One request.**
|
|
///
|
|
/// Neither backend takes a description when creating, so this does not take
|
|
/// one either: setting it is a separate write, sequenced by the caller with
|
|
/// a credential recheck in between.
|
|
///
|
|
/// The returned playlist is the little the server says about what it just
|
|
/// made — an id and a title. Nothing else is invented here: callers refetch
|
|
/// the list, which is the only description that is actually the server's.
|
|
/// - Parameters:
|
|
/// - privacy: Ignored by backends without a visibility concept (Piped),
|
|
/// and defaulted to private on those that have one when not given —
|
|
/// Invidious rejects a creation that carries no visibility at all.
|
|
func createPlaylist(
|
|
title: String,
|
|
privacy: PlaylistPrivacy?,
|
|
instance: Instance,
|
|
credential: String
|
|
) async throws -> Playlist {
|
|
switch instance.type {
|
|
case .invidious:
|
|
return try await invidiousAPI(for: instance).createPlaylist(
|
|
title: title,
|
|
privacy: privacy ?? .private,
|
|
instance: instance,
|
|
sid: credential
|
|
)
|
|
|
|
case .piped:
|
|
let playlistID = try await pipedAPI(for: instance).createPlaylist(
|
|
name: title,
|
|
instance: instance,
|
|
authToken: credential
|
|
)
|
|
return Playlist(id: .global(playlistID), title: title)
|
|
|
|
default:
|
|
throw APIError.notSupported
|
|
}
|
|
}
|
|
|
|
/// Renames one of the credential owner's playlists. **One request.**
|
|
///
|
|
/// - Parameter privacy: the visibility to store, or `nil` to leave whatever
|
|
/// the playlist has. Ignored on backends without a visibility concept.
|
|
func renamePlaylist(
|
|
id: String,
|
|
title: String,
|
|
privacy: PlaylistPrivacy?,
|
|
instance: Instance,
|
|
credential: String
|
|
) async throws {
|
|
switch instance.type {
|
|
case .invidious:
|
|
try await invidiousAPI(for: instance).updatePlaylist(
|
|
id: id,
|
|
title: title,
|
|
description: nil,
|
|
privacy: privacy,
|
|
instance: instance,
|
|
sid: credential
|
|
)
|
|
|
|
case .piped:
|
|
try await pipedAPI(for: instance).renamePlaylist(
|
|
id: id,
|
|
newName: title,
|
|
instance: instance,
|
|
authToken: credential
|
|
)
|
|
|
|
default:
|
|
throw APIError.notSupported
|
|
}
|
|
}
|
|
|
|
/// Stores a playlist's description. **One request.**
|
|
///
|
|
/// - Parameter title: the playlist's title, resent unchanged. Invidious
|
|
/// edits every attribute through one endpoint and refuses a body without
|
|
/// a title; Piped has a description endpoint of its own and ignores this.
|
|
func setPlaylistDescription(
|
|
id: String,
|
|
description: String,
|
|
title: String,
|
|
instance: Instance,
|
|
credential: String
|
|
) async throws {
|
|
switch instance.type {
|
|
case .invidious:
|
|
try await invidiousAPI(for: instance).updatePlaylist(
|
|
id: id,
|
|
title: title,
|
|
description: description,
|
|
privacy: nil,
|
|
instance: instance,
|
|
sid: credential
|
|
)
|
|
|
|
case .piped:
|
|
try await pipedAPI(for: instance).updatePlaylistDescription(
|
|
id: id,
|
|
description: description,
|
|
instance: instance,
|
|
authToken: credential
|
|
)
|
|
|
|
default:
|
|
throw APIError.notSupported
|
|
}
|
|
}
|
|
|
|
/// Deletes one of the credential owner's playlists.
|
|
func deletePlaylist(id: String, instance: Instance, credential: String) async throws {
|
|
switch instance.type {
|
|
case .invidious:
|
|
try await invidiousAPI(for: instance).deletePlaylist(id: id, instance: instance, sid: credential)
|
|
case .piped:
|
|
try await pipedAPI(for: instance).deletePlaylist(id: id, instance: instance, authToken: credential)
|
|
default:
|
|
throw APIError.notSupported
|
|
}
|
|
}
|
|
|
|
/// Appends a video to one of the credential owner's playlists.
|
|
func addVideoToPlaylist(
|
|
playlistID: String,
|
|
videoID: String,
|
|
instance: Instance,
|
|
credential: String
|
|
) async throws {
|
|
switch instance.type {
|
|
case .invidious:
|
|
try await invidiousAPI(for: instance).addVideoToPlaylist(
|
|
playlistID: playlistID,
|
|
videoID: videoID,
|
|
instance: instance,
|
|
sid: credential
|
|
)
|
|
case .piped:
|
|
try await pipedAPI(for: instance).addToPlaylist(
|
|
id: playlistID,
|
|
videoID: videoID,
|
|
instance: instance,
|
|
authToken: credential
|
|
)
|
|
default:
|
|
throw APIError.notSupported
|
|
}
|
|
}
|
|
|
|
/// Removes one row from one of the credential owner's playlists.
|
|
///
|
|
/// The entry id says which backend minted it, and it has to be the one this
|
|
/// instance runs: an Invidious `indexId` means nothing to Piped, and a
|
|
/// Piped position sent to Invidious would be a *different row* rather than
|
|
/// an error, so a mismatch is refused rather than translated.
|
|
func removeEntry(
|
|
_ entryID: RemotePlaylistEntryID,
|
|
instance: Instance,
|
|
credential: String
|
|
) async throws {
|
|
switch (entryID, instance.type) {
|
|
case (.invidious(let playlistID, let indexID), .invidious):
|
|
try await invidiousAPI(for: instance).removeVideoFromPlaylist(
|
|
playlistID: playlistID,
|
|
indexID: indexID,
|
|
instance: instance,
|
|
sid: credential
|
|
)
|
|
|
|
case (.piped(let playlistID, let position), .piped):
|
|
try await pipedAPI(for: instance).removeFromPlaylist(
|
|
id: playlistID,
|
|
index: position,
|
|
instance: instance,
|
|
authToken: credential
|
|
)
|
|
|
|
case (_, .invidious), (_, .piped):
|
|
throw APIError.invalidRequest
|
|
|
|
default:
|
|
throw APIError.notSupported
|
|
}
|
|
}
|
|
|
|
// MARK: - Authentication
|
|
|
|
/// Signs in to an instance and returns the credential its backend issues —
|
|
/// a session id for Invidious, an auth token for Piped.
|
|
///
|
|
/// Routed through here so a login carries the same per-instance
|
|
/// configuration every other request does: the reverse-proxy basic auth, the
|
|
/// certificate policy, and the User-Agent some instances insist on. A login
|
|
/// that skipped any of them would fail before reaching the login endpoint.
|
|
func login(username: String, password: String, instance: Instance) async throws -> String {
|
|
switch instance.type {
|
|
case .invidious:
|
|
// Invidious' login POST runs on its own ephemeral URLSession so it
|
|
// can read the Set-Cookie the SID arrives in, and therefore inherits
|
|
// nothing from the client's default headers. An instance behind a
|
|
// basic-auth proxy needs that header handed over explicitly, or the
|
|
// proxy answers 401 before the upstream ever sees the credentials.
|
|
var extraHeaders: [String: String]?
|
|
if let basicAuthHeader = await basicAuthCredentialsManager?.basicAuthHeader(for: instance) {
|
|
extraHeaders = ["Authorization": basicAuthHeader]
|
|
}
|
|
return try await invidiousAPI(for: instance).login(
|
|
email: username,
|
|
password: password,
|
|
instance: instance,
|
|
extraHeaders: extraHeaders
|
|
)
|
|
case .piped:
|
|
// Piped's login goes through the HTTPClient, so the per-instance
|
|
// client already carries the proxy header in its default headers.
|
|
return try await pipedAPI(for: instance).login(
|
|
username: username,
|
|
password: password,
|
|
instance: instance
|
|
)
|
|
default:
|
|
throw APIError.notSupported
|
|
}
|
|
}
|
|
|
|
/// Fetches streams with proxy URLs for downloads (Yattee Server: hits /proxy/fast/
|
|
/// so the server caches the file on disk; other backends apply Invidious URL rewriting).
|
|
func proxyStreams(videoID: String, instance: Instance) async throws -> [Stream] {
|
|
if instance.type == .yatteeServer {
|
|
return try await yatteeServerAPI(for: instance)
|
|
.proxyStreams(videoID: videoID, instance: instance, mode: .download)
|
|
}
|
|
let fetchedStreams = try await streams(videoID: videoID, instance: instance)
|
|
return await InvidiousAPI.proxyStreamsIfNeeded(fetchedStreams, instance: instance)
|
|
}
|
|
|
|
/// Fetches video details + streams for downloads. For Yattee Server this routes
|
|
/// through `/proxy/fast/`, which caches the file on disk on the server.
|
|
func videoWithProxyStreamsAndCaptionsAndStoryboards(id: String, instance: Instance) async throws -> (video: Video, streams: [Stream], captions: [Caption], storyboards: [Storyboard]) {
|
|
if instance.type == .yatteeServer {
|
|
return try await yatteeServerAPI(for: instance)
|
|
.videoWithProxyStreamsAndCaptionsAndStoryboards(id: id, instance: instance, mode: .download)
|
|
}
|
|
var result = try await videoWithStreamsAndCaptionsAndStoryboards(id: id, instance: instance)
|
|
result.streams = await InvidiousAPI.proxyStreamsIfNeeded(result.streams, instance: instance)
|
|
return result
|
|
}
|
|
|
|
/// Fetches video details, streams, and captions in a single API call (Invidious and Yattee Server).
|
|
/// For other backends, falls back to separate calls.
|
|
func videoWithStreamsAndCaptions(id: String, instance: Instance) async throws -> (video: Video, streams: [Stream], captions: [Caption]) {
|
|
let result = try await videoWithStreamsAndCaptionsAndStoryboards(id: id, instance: instance)
|
|
return (result.video, result.streams, result.captions)
|
|
}
|
|
|
|
/// Fetches video details, streams, captions, and storyboards in a single API call.
|
|
/// Storyboards are only available for Invidious and Yattee Server instances.
|
|
func videoWithStreamsAndCaptionsAndStoryboards(id: String, instance: Instance) async throws -> (video: Video, streams: [Stream], captions: [Caption], storyboards: [Storyboard]) {
|
|
switch instance.type {
|
|
case .invidious:
|
|
return try await invidiousAPI(for: instance).videoWithStreamsAndCaptionsAndStoryboards(id: id, instance: instance)
|
|
case .yatteeServer:
|
|
// When the user has opted into proxying for this Yattee Server source,
|
|
// fetch playback streams through the server's /proxy/relay byte-relay
|
|
// (Range-friendly, fast TTFB). When off, leave routing to the server's
|
|
// per-site proxy_streaming flag — the server can still proxy by default,
|
|
// but the client doesn't force it on top.
|
|
if instance.proxiesVideos {
|
|
return try await yatteeServerAPI(for: instance)
|
|
.videoWithProxyStreamsAndCaptionsAndStoryboards(id: id, instance: instance, mode: .relay)
|
|
}
|
|
return try await yatteeServerAPI(for: instance).videoWithStreamsAndCaptionsAndStoryboards(id: id, instance: instance)
|
|
case .piped:
|
|
// Piped fallback - make separate calls (no storyboard support)
|
|
let pipedAPI = await pipedAPI(for: instance)
|
|
async let videoTask = pipedAPI.video(id: id, instance: instance)
|
|
async let streamsTask = pipedAPI.streams(videoID: id, instance: instance)
|
|
async let captionsTask = pipedAPI.captions(videoID: id, instance: instance)
|
|
|
|
let video = try await videoTask
|
|
let streams = try await streamsTask
|
|
let captions = try await captionsTask
|
|
return (video, streams, captions, [])
|
|
|
|
case .peertube:
|
|
// PeerTube fallback - make separate calls (no storyboard support)
|
|
let peerTubeAPI = await peerTubeAPI(for: instance)
|
|
async let videoTask = peerTubeAPI.video(id: id, instance: instance)
|
|
async let streamsTask = peerTubeAPI.streams(videoID: id, instance: instance)
|
|
async let captionsTask = peerTubeAPI.captions(videoID: id, instance: instance)
|
|
|
|
let video = try await videoTask
|
|
let streams = try await streamsTask
|
|
let captions = try await captionsTask
|
|
return (video, streams, captions, [])
|
|
}
|
|
}
|
|
|
|
// MARK: - External URL Extraction
|
|
|
|
/// Extracts video information from any URL that yt-dlp supports.
|
|
/// Requires a Yattee Server instance.
|
|
///
|
|
/// - Parameters:
|
|
/// - url: The URL to extract (e.g., https://vimeo.com/12345)
|
|
/// - instance: A Yattee Server instance
|
|
/// - Returns: Tuple of video, streams, and captions
|
|
func extractURL(_ url: URL, instance: Instance) async throws -> (video: Video, streams: [Stream], captions: [Caption]) {
|
|
guard instance.type == .yatteeServer else {
|
|
throw APIError.notSupported
|
|
}
|
|
return try await yatteeServerAPI(for: instance).extractURL(url, instance: instance)
|
|
}
|
|
|
|
/// Extracts channel/user videos from any URL that yt-dlp supports.
|
|
/// Requires a Yattee Server instance.
|
|
///
|
|
/// This works with Vimeo, Dailymotion, SoundCloud, and many other sites.
|
|
/// Note that some sites (like Twitter/X) may not support channel extraction.
|
|
///
|
|
/// - Parameters:
|
|
/// - url: The channel/user URL to extract (e.g., https://vimeo.com/username)
|
|
/// - page: Page number (1-based)
|
|
/// - instance: A Yattee Server instance
|
|
/// - Returns: Tuple of channel, videos list, and optional continuation token for next page
|
|
func extractChannel(url: URL, page: Int = 1, instance: Instance) async throws -> (channel: Channel, videos: [Video], continuation: String?) {
|
|
guard instance.type == .yatteeServer else {
|
|
throw APIError.notSupported
|
|
}
|
|
return try await yatteeServerAPI(for: instance).extractChannel(url: url, page: page, instance: instance)
|
|
}
|
|
|
|
// MARK: - Yattee Server Info
|
|
|
|
/// Fetches server info including version, dependencies, and enabled sites.
|
|
/// Requires a Yattee Server instance.
|
|
func yatteeServerInfo(for instance: Instance) async throws -> InstanceDetectorModels.YatteeServerFullInfo {
|
|
guard instance.type == .yatteeServer else {
|
|
throw APIError.notSupported
|
|
}
|
|
return try await yatteeServerAPI(for: instance).fetchServerInfo(for: instance)
|
|
}
|
|
}
|
|
|
|
/// The real transport behind ``AccountService``. Declared here rather than on
|
|
/// the protocol's own file so the conformance is checked next to the methods
|
|
/// that satisfy it; the protocol exists so tests can script a server.
|
|
extension ContentService: RemotePlaylistTransport {}
|
|
|
|
// MARK: - Feed Page
|
|
|
|
/// One page of an authenticated feed, with whatever the backend says about
|
|
/// there being another.
|
|
struct FeedPage: Sendable {
|
|
let videos: [Video]
|
|
let hasMore: Bool
|
|
}
|
|
|
|
// MARK: - Search Result
|
|
|
|
enum OrderedSearchItem: Sendable {
|
|
case video(Video)
|
|
case channel(Channel)
|
|
case playlist(Playlist)
|
|
}
|
|
|
|
struct SearchResult: Sendable {
|
|
let videos: [Video]
|
|
let channels: [Channel]
|
|
let playlists: [Playlist]
|
|
let orderedItems: [OrderedSearchItem] // Preserves original API order
|
|
let nextPage: Int?
|
|
|
|
static let empty = SearchResult(videos: [], channels: [], playlists: [], orderedItems: [], nextPage: nil)
|
|
}
|
|
|
|
// MARK: - Channel Search Result
|
|
|
|
/// Item in channel search results, preserving API order for mixed video/playlist display.
|
|
enum ChannelSearchItem: Sendable, Identifiable {
|
|
case video(Video)
|
|
case playlist(Playlist)
|
|
|
|
var id: String {
|
|
switch self {
|
|
case .video(let video): return "video-\(video.id.videoID)"
|
|
case .playlist(let playlist): return "playlist-\(playlist.id.playlistID)"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Page of channel search results.
|
|
struct ChannelSearchPage: Sendable {
|
|
let items: [ChannelSearchItem]
|
|
let nextPage: Int?
|
|
|
|
static let empty = ChannelSearchPage(items: [], nextPage: nil)
|
|
}
|