Files
yattee/Yattee/Services/API/InvidiousAPI.swift
Arkadiusz Fal de4ee9a728 Give remote playlist rows a name of their own
Playlist fetches threw away the only handle a row could be removed by:
Invidious hands out an index/indexId pair per entry and both were dropped
on conversion, so nothing downstream could say which row it meant.

Both authenticated fetches now also return a snapshot — the playlist plus
its addressable entries. Invidious entries carry the server's indexId and
survive refetches; Piped has no such token, so its entries are addressed
by position and are only valid inside the snapshot that minted them. The
entry id carries the playlist id in both cases, so position 3 of one
playlist can never be mistaken for position 3 of another.

A row needs both an indexId and an index to be a target: rows missing
either are still displayed but never appear in entries, so no removal can
be aimed at a row the server refuses to name. Page-overlap dedupe stays
keyed on index and never on videoId — the same video may sit in a
playlist twice, and each copy is its own removable row.
2026-09-02 19:44:12 +02:00

1807 lines
68 KiB
Swift

//
// InvidiousAPI.swift
// Yattee
//
// Invidious API implementation for YouTube content.
// API Documentation: https://docs.invidious.io/api/
//
@preconcurrency import Foundation
/// Invidious API client for fetching YouTube content.
actor InvidiousAPI: InstanceAPI {
private let httpClient: HTTPClient
init(httpClient: HTTPClient) {
self.httpClient = httpClient
}
// MARK: - InstanceAPI
func trending(instance: Instance) async throws -> [Video] {
let endpoint = GenericEndpoint.get("/api/v1/trending")
let response: [InvidiousVideo] = try await httpClient.fetch(endpoint, baseURL: instance.url)
return response.map { $0.toVideo(baseURL: instance.url) }
}
func popular(instance: Instance) async throws -> [Video] {
let endpoint = GenericEndpoint.get("/api/v1/popular")
let response: [InvidiousVideo] = try await httpClient.fetch(endpoint, baseURL: instance.url)
return response.map { $0.toVideo(baseURL: instance.url) }
}
func search(query: String, instance: Instance, page: Int, filters: SearchFilters = .defaults) async throws -> SearchResult {
var queryParams: [String: String] = [
"q": query,
"page": String(page),
"sort": filters.sort.rawValue,
"type": filters.type.rawValue
]
if filters.date != .any {
queryParams["date"] = filters.date.rawValue
}
if filters.duration != .any {
queryParams["duration"] = filters.duration.rawValue
}
let endpoint = GenericEndpoint.get("/api/v1/search", query: queryParams)
let response: [InvidiousSearchItem] = try await httpClient.fetch(endpoint, baseURL: instance.url)
// Helper to detect playlist IDs that may be returned as "video" type
// YouTube playlist IDs start with: PL (user playlist), RD (mix), OL (offline mix), UU (uploads)
func isPlaylistID(_ id: String) -> Bool {
id.hasPrefix("PL") || id.hasPrefix("RD") || id.hasPrefix("OL") || id.hasPrefix("UU")
}
var videos: [Video] = []
var channels: [Channel] = []
var playlists: [Playlist] = []
var orderedItems: [OrderedSearchItem] = []
for item in response {
switch item {
case .video(let video):
if isPlaylistID(video.videoId) {
// Convert misidentified playlist
let playlist = Playlist(
id: .global(video.videoId),
title: video.title,
author: Author(id: video.authorId, name: video.author),
videoCount: 0,
thumbnailURL: video.videoThumbnails?.first?.thumbnailURL(baseURL: instance.url)
)
playlists.append(playlist)
orderedItems.append(.playlist(playlist))
} else {
let v = video.toVideo(baseURL: instance.url)
videos.append(v)
orderedItems.append(.video(v))
}
case .channel(let channel):
let c = channel.toChannel(baseURL: instance.url)
channels.append(c)
orderedItems.append(.channel(c))
case .playlist(let playlist):
let p = playlist.toPlaylist(baseURL: instance.url)
playlists.append(p)
orderedItems.append(.playlist(p))
case .unknown:
break
}
}
// Determine if there are more pages based on whether we got results
let hasResults = !videos.isEmpty || !channels.isEmpty || !playlists.isEmpty
return SearchResult(
videos: videos,
channels: channels,
playlists: playlists,
orderedItems: orderedItems,
nextPage: hasResults ? page + 1 : nil
)
}
func searchSuggestions(query: String, instance: Instance) async throws -> [String] {
let endpoint = GenericEndpoint.get("/api/v1/search/suggestions", query: [
"q": query
])
let response: InvidiousSuggestions = try await httpClient.fetch(endpoint, baseURL: instance.url)
return response.suggestions
}
func video(id: String, instance: Instance) async throws -> Video {
let endpoint = GenericEndpoint.get("/api/v1/videos/\(id)")
let response: InvidiousVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url)
return response.toVideo(baseURL: instance.url)
}
func channel(id: String, instance: Instance) async throws -> Channel {
let endpoint = GenericEndpoint.get("/api/v1/channels/\(id)")
let response: InvidiousChannel = try await httpClient.fetch(endpoint, baseURL: instance.url)
return response.toChannel(baseURL: instance.url)
}
func channelVideos(id: String, instance: Instance, continuation: String?) async throws -> ChannelVideosPage {
var query: [String: String] = [:]
if let continuation {
query["continuation"] = continuation
}
let endpoint = GenericEndpoint.get("/api/v1/channels/\(id)/videos", query: query)
let response: InvidiousChannelVideosWithContinuation = try await httpClient.fetch(endpoint, baseURL: instance.url)
return ChannelVideosPage(
videos: response.videos.map { $0.toVideo(baseURL: instance.url) },
continuation: response.continuation
)
}
func channelPlaylists(id: String, instance: Instance, continuation: String?) async throws -> ChannelPlaylistsPage {
var query: [String: String] = [:]
if let continuation {
query["continuation"] = continuation
}
let endpoint = GenericEndpoint.get("/api/v1/channels/\(id)/playlists", query: query)
let response: InvidiousChannelPlaylists = try await httpClient.fetch(endpoint, baseURL: instance.url)
return ChannelPlaylistsPage(
playlists: response.playlists.map { $0.toPlaylist(baseURL: instance.url) },
continuation: response.continuation
)
}
func channelShorts(id: String, instance: Instance, continuation: String?) async throws -> ChannelVideosPage {
var query: [String: String] = [:]
if let continuation {
query["continuation"] = continuation
}
let endpoint = GenericEndpoint.get("/api/v1/channels/\(id)/shorts", query: query)
let response: InvidiousChannelVideosWithContinuation = try await httpClient.fetch(endpoint, baseURL: instance.url)
return ChannelVideosPage(
videos: response.videos.map { $0.toVideo(baseURL: instance.url) },
continuation: response.continuation
)
}
func channelStreams(id: String, instance: Instance, continuation: String?) async throws -> ChannelVideosPage {
var query: [String: String] = [:]
if let continuation {
query["continuation"] = continuation
}
let endpoint = GenericEndpoint.get("/api/v1/channels/\(id)/streams", query: query)
let response: InvidiousChannelVideosWithContinuation = try await httpClient.fetch(endpoint, baseURL: instance.url)
return ChannelVideosPage(
videos: response.videos.map { $0.toVideo(baseURL: instance.url) },
continuation: response.continuation
)
}
func playlist(id: String, instance: Instance) async throws -> Playlist {
let firstEndpoint = GenericEndpoint.get("/api/v1/playlists/\(id)")
let firstResponse: InvidiousPlaylist = try await httpClient.fetch(firstEndpoint, baseURL: instance.url)
var allVideos = firstResponse.videos ?? []
let maxPages = 50
if firstResponse.videoCount > 0 {
var page = 2
while page <= maxPages {
let endpoint = GenericEndpoint.get("/api/v1/playlists/\(id)", query: ["page": String(page)])
let response: InvidiousPlaylist = try await httpClient.fetch(endpoint, baseURL: instance.url)
let pageVideos = response.videos ?? []
if pageVideos.isEmpty { break }
allVideos.append(contentsOf: pageVideos)
page += 1
}
}
// Invidious pagination uses overlapping pages — deduplicate by playlist index
var seenIndices = Set<Int>()
allVideos = allVideos.filter { item in
if case .video(let video) = item, let index = video.index {
return seenIndices.insert(index).inserted
}
return true
}
return InvidiousPlaylist(
playlistId: firstResponse.playlistId,
title: firstResponse.title,
description: firstResponse.description,
author: firstResponse.author,
authorId: firstResponse.authorId,
videoCount: firstResponse.videoCount,
videos: allVideos
).toPlaylist(baseURL: instance.url)
}
/// Fetches a user's playlist using authenticated endpoint.
/// Required for private playlists (IVPL* IDs).
/// - Parameters:
/// - id: The playlist ID
/// - instance: The Invidious instance
/// - sid: The session ID from login
/// - Returns: The playlist with videos
func userPlaylist(id: String, instance: Instance, sid: String) async throws -> Playlist {
try await userPlaylistSnapshot(id: id, instance: instance, sid: sid).playlist
}
/// Fetches a user's playlist along with the per-entry identities needed to
/// mutate it.
///
/// Same request as ``userPlaylist(id:instance:sid:)`` — that one is a thin
/// wrapper over this — but it keeps the `index`/`indexId` tokens Invidious
/// hands out for every row instead of dropping them on conversion.
/// - Parameters:
/// - id: The playlist ID
/// - instance: The Invidious instance
/// - sid: The session ID from login
/// - Returns: The playlist plus its addressable entries
func userPlaylistSnapshot(id: String, instance: Instance, sid: String) async throws -> RemotePlaylistSnapshot {
let headers = ["Cookie": "SID=\(sid)"]
let firstEndpoint = GenericEndpoint(
path: "/api/v1/auth/playlists/\(id)",
queryItems: nil,
headers: headers
)
let firstResponse: InvidiousPlaylist = try await httpClient.fetch(firstEndpoint, baseURL: instance.url)
var additionalPages: [InvidiousPlaylist] = []
let maxPages = 50
if firstResponse.videoCount > 0 {
var page = 2
while page <= maxPages {
let endpoint = GenericEndpoint(
path: "/api/v1/auth/playlists/\(id)",
queryItems: [URLQueryItem(name: "page", value: String(page))],
headers: headers
)
let response: InvidiousPlaylist = try await httpClient.fetch(endpoint, baseURL: instance.url)
if (response.videos ?? []).isEmpty { break }
additionalPages.append(response)
page += 1
}
}
return Self.buildAuthPlaylistSnapshot(
firstPage: firstResponse,
additionalPages: additionalPages,
playlistID: id,
baseURL: instance.url
)
}
/// Builds a snapshot from already-fetched pages of
/// `/api/v1/auth/playlists/{id}`.
///
/// Pure: the network path and ``authPlaylistSnapshot(fromPageJSON:playlistID:baseURL:)``
/// both funnel through here, so the merge, dedupe and ordering rules have
/// exactly one implementation.
private static func buildAuthPlaylistSnapshot(
firstPage: InvidiousPlaylist,
additionalPages: [InvidiousPlaylist],
playlistID: String,
baseURL: URL
) -> RemotePlaylistSnapshot {
var allVideos = firstPage.videos ?? []
for page in additionalPages {
allVideos.append(contentsOf: page.videos ?? [])
}
// Invidious pagination uses overlapping pages — deduplicate by playlist
// index. The key is deliberately never `videoId`: a playlist may hold
// the same video twice at different indexes, and each copy is its own
// removable row with its own indexId.
var seenIndices = Set<Int>()
allVideos = allVideos.filter { item in
if case .video(let video) = item, let index = video.index {
return seenIndices.insert(index).inserted
}
return true
}
// Order by the server's index so entry positions ascend even if a page
// arrives out of order. Rows the server left unindexed keep their
// arrival order and sort last; the sort is stable either way.
allVideos = allVideos.enumerated().sorted { lhs, rhs in
switch (lhs.element.playlistIndex, rhs.element.playlistIndex) {
case let (left?, right?):
return left == right ? lhs.offset < rhs.offset : left < right
case (_?, nil):
return true
case (nil, _?):
return false
case (nil, nil):
return lhs.offset < rhs.offset
}
}.map(\.element)
let playlist = InvidiousPlaylist(
playlistId: firstPage.playlistId,
title: firstPage.title,
description: firstPage.description,
author: firstPage.author,
authorId: firstPage.authorId,
videoCount: firstPage.videoCount,
videos: allVideos
).toPlaylist(baseURL: baseURL)
// A row needs both tokens to be a mutation target: `indexId` names it
// and `index` places it. Rows missing either are still displayed —
// they stay in `playlist.videos` — they just never appear in `entries`,
// so no removal can be aimed at a row the server refuses to name.
let entries: [RemotePlaylistEntry] = allVideos.compactMap { item in
guard case .video(let video) = item,
let indexID = video.indexId,
let index = video.index
else { return nil }
return RemotePlaylistEntry(
id: .invidious(playlistID: playlistID, indexID: indexID),
video: video.toVideo(baseURL: baseURL),
position: index
)
}
return RemotePlaylistSnapshot(playlist: playlist, entries: entries)
}
/// Runs the snapshot assembly over raw page payloads, decoding them the way
/// the network path does. Lets the merge/dedupe/ordering rules be exercised
/// without a server.
static func authPlaylistSnapshot(
fromPageJSON pages: [Data],
playlistID: String,
baseURL: URL
) throws -> RemotePlaylistSnapshot {
let decoder = HTTPClient.makeAPIDecoder()
let decoded = try pages.map { try decoder.decode(InvidiousPlaylist.self, from: $0) }
guard let firstPage = decoded.first else {
throw APIError.decodingError("No playlist pages to decode")
}
return buildAuthPlaylistSnapshot(
firstPage: firstPage,
additionalPages: Array(decoded.dropFirst()),
playlistID: playlistID,
baseURL: baseURL
)
}
func comments(videoID: String, instance: Instance, continuation: String?) async throws -> CommentsPage {
var query: [String: String] = [:]
if let continuation {
query["continuation"] = continuation
}
let endpoint = GenericEndpoint.get("/api/v1/comments/\(videoID)", query: query)
do {
let response: InvidiousComments = try await httpClient.fetch(endpoint, baseURL: instance.url)
return CommentsPage(
comments: response.comments.map { $0.toComment(baseURL: instance.url) },
continuation: response.continuation
)
} catch APIError.notFound {
throw APIError.commentsDisabled
}
}
func streams(videoID: String, instance: Instance) async throws -> [Stream] {
let endpoint = GenericEndpoint.get("/api/v1/videos/\(videoID)")
let response: InvidiousVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url)
return response.toStreams(instanceBaseURL: instance.url)
}
func captions(videoID: String, instance: Instance) async throws -> [Caption] {
let endpoint = GenericEndpoint.get("/api/v1/videos/\(videoID)")
let response: InvidiousVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url)
return response.toCaptions(baseURL: instance.url)
}
func channelSearch(id: String, query: String, instance: Instance, page: Int) async throws -> ChannelSearchPage {
let endpoint = GenericEndpoint.get("/api/v1/channels/\(id)/search", query: [
"q": query,
"page": String(page)
])
let response: [InvidiousSearchItem] = try await httpClient.fetch(endpoint, baseURL: instance.url)
var items: [ChannelSearchItem] = []
for item in response {
switch item {
case .video(let video):
items.append(.video(video.toVideo(baseURL: instance.url)))
case .playlist(let playlist):
items.append(.playlist(playlist.toPlaylist(baseURL: instance.url)))
case .channel, .unknown:
// Channel search only returns videos and playlists
break
}
}
// Has more pages if we got results
let hasResults = !items.isEmpty
return ChannelSearchPage(items: items, nextPage: hasResults ? page + 1 : nil)
}
/// Fetches video details, streams, and captions in a single API call.
/// This is more efficient than calling video(), streams(), and captions() separately
/// since they all fetch from the same endpoint.
func videoWithStreamsAndCaptions(id: String, instance: Instance) async throws -> (video: Video, streams: [Stream], captions: [Caption]) {
let result = try await videoWithStreamsAndCaptionsAndStoryboards(id: id, instance: instance)
return (video: result.video, streams: result.streams, captions: result.captions)
}
func videoWithStreamsAndCaptionsAndStoryboards(id: String, instance: Instance) async throws -> (video: Video, streams: [Stream], captions: [Caption], storyboards: [Storyboard]) {
let endpoint = GenericEndpoint.get("/api/v1/videos/\(id)")
let response: InvidiousVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url)
return (
video: response.toVideo(baseURL: instance.url),
streams: response.toStreams(instanceBaseURL: instance.url),
captions: response.toCaptions(baseURL: instance.url),
storyboards: response.toStoryboards(instanceBaseURL: instance.url)
)
}
// MARK: - Authentication
/// Logs in to an Invidious instance and returns the session ID (SID).
/// - Parameters:
/// - email: The user's email/username
/// - password: The user's password
/// - instance: The Invidious instance to log in to
/// - Returns: The session ID (SID) cookie value
func login(email: String, password: String, instance: Instance, extraHeaders: [String: String]? = nil) async throws -> String {
// Build form-urlencoded body using URLComponents for standard encoding
var components = URLComponents()
components.queryItems = [
URLQueryItem(name: "email", value: email),
URLQueryItem(name: "password", value: password),
URLQueryItem(name: "action", value: "signin")
]
// URLQueryItem leaves '+' unencoded, but in form-urlencoded '+' means space
let bodyString = components.percentEncodedQuery?
.replacingOccurrences(of: "+", with: "%2B") ?? ""
guard let bodyData = bodyString.data(using: .utf8) else {
throw APIError.invalidRequest
}
// Build the request manually to handle cookies
var request = URLRequest(url: instance.url.appendingPathComponent("login"))
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.httpBody = bodyData
// Apply any extra headers (e.g. an HTTP Basic Auth Authorization header
// for instances behind a reverse proxy). The login endpoint uses its own
// URLSession below to capture Set-Cookie, so it cannot inherit headers
// from the injected httpClient.
if let extraHeaders {
for (key, value) in extraHeaders {
request.setValue(value, forHTTPHeaderField: key)
}
}
// Use a session that doesn't follow redirects so we can capture the Set-Cookie header
let sessionConfig = URLSessionConfiguration.ephemeral
let session = URLSession(configuration: sessionConfig, delegate: RedirectBlocker(), delegateQueue: nil)
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.unknown("Invalid response type")
}
// Check for successful login (302 redirect or 200 OK)
// Invidious returns 302 on success, redirecting to home
guard httpResponse.statusCode == 200 || httpResponse.statusCode == 302 else {
// Check for error message in response body
if let responseText = String(data: data, encoding: .utf8),
responseText.contains("Wrong username") || responseText.contains("Invalid") || responseText.contains("incorrect") {
throw APIError.unauthorized
}
let message = String(data: data, encoding: .utf8)
throw APIError.httpError(statusCode: httpResponse.statusCode, message: message)
}
// Extract SID from Set-Cookie header
// Format: "SID=<value>; domain=...; expires=...; ..."
// HTTP/2 uses lowercase headers, so we need to check case-insensitively
var cookieValue: String?
// Try direct access first (works for HTTP/1.1)
if let value = httpResponse.allHeaderFields["Set-Cookie"] as? String {
cookieValue = value
} else if let value = httpResponse.value(forHTTPHeaderField: "Set-Cookie") {
cookieValue = value
} else {
// Iterate through all headers to find set-cookie (case-insensitive)
for (key, value) in httpResponse.allHeaderFields {
if let keyStr = key as? String,
keyStr.lowercased() == "set-cookie",
let valueStr = value as? String {
cookieValue = valueStr
break
}
}
}
guard let cookies = cookieValue else {
throw APIError.unauthorized
}
return try extractSID(from: cookies)
}
/// Extracts SID from Set-Cookie header value.
private func extractSID(from cookieHeader: String) throws -> String {
// Look for SID= in the cookie string
let pattern = "SID=([^;]+)"
guard let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(in: cookieHeader, range: NSRange(cookieHeader.startIndex..., in: cookieHeader)),
let sidRange = Range(match.range(at: 1), in: cookieHeader) else {
throw APIError.unauthorized
}
return String(cookieHeader[sidRange])
}
/// Fetches the subscription feed for a logged-in user.
/// - Parameters:
/// - instance: The Invidious instance
/// - sid: The session ID from login
/// - page: Page number for pagination (1-based)
/// - maxResults: Maximum number of videos to return per page
/// - Returns: Array of videos from subscribed channels
func feed(instance: Instance, sid: String, page: Int = 1, maxResults: Int = 50) async throws -> InvidiousFeedResponse {
let endpoint = GenericEndpoint.get("/api/v1/auth/feed", query: [
"max_results": String(maxResults),
"page": String(page)
])
// Fetch raw data first for debugging
let rawData = try await httpClient.fetchData(
endpoint,
baseURL: instance.url,
customHeaders: ["Cookie": "SID=\(sid)"]
)
// Decode the response
let response: InvidiousAuthFeedResponse
do {
response = try JSONDecoder().decode(InvidiousAuthFeedResponse.self, from: rawData)
} catch {
let rawString = String(data: rawData, encoding: .utf8) ?? "Unable to decode"
LoggingService.shared.error(
"Failed to decode Invidious feed. Raw response (first 1000 chars): \(String(rawString.prefix(1000)))",
category: .api
)
throw error
}
// Combine notifications and videos arrays - Invidious returns feed items in notifications
let allVideos = (response.notifications ?? []) + response.videos
let videos = allVideos.map { $0.toVideo(baseURL: instance.url) }
LoggingService.shared.debug(
"Invidious feed: \(response.notifications?.count ?? 0) notifications + \(response.videos.count) videos = \(videos.count) total",
category: .api
)
// Invidious feed doesn't provide explicit "hasMore", assume there's more until we get empty page
return InvidiousFeedResponse(videos: videos, hasMore: !videos.isEmpty)
}
/// Fetches the user's subscriptions.
/// - Parameters:
/// - instance: The Invidious instance
/// - sid: The session ID from login
/// - Returns: Array of subscribed channels
func subscriptions(instance: Instance, sid: String) async throws -> [InvidiousSubscription] {
let endpoint = GenericEndpoint.get("/api/v1/auth/subscriptions")
let response: [InvidiousSubscription] = try await httpClient.fetch(
endpoint,
baseURL: instance.url,
customHeaders: ["Cookie": "SID=\(sid)"]
)
return response
}
/// Fetches the user's playlists.
/// - Parameters:
/// - instance: The Invidious instance
/// - sid: The session ID from login
/// - Returns: Array of user's playlists
func userPlaylists(instance: Instance, sid: String) async throws -> [Playlist] {
let endpoint = GenericEndpoint.get("/api/v1/auth/playlists")
let response: [InvidiousAuthPlaylist] = try await httpClient.fetch(
endpoint,
baseURL: instance.url,
customHeaders: ["Cookie": "SID=\(sid)"]
)
return response.map { $0.toPlaylist(baseURL: instance.url) }
}
// MARK: - Subscription Management
/// Subscribes to a channel on the Invidious instance.
/// - Parameters:
/// - channelID: The YouTube channel ID (UCID) to subscribe to
/// - instance: The Invidious instance
/// - sid: The session ID from login
/// - Throws: APIError if the subscription fails
func subscribe(to channelID: String, instance: Instance, sid: String) async throws {
let endpoint = GenericEndpoint.post("/api/v1/auth/subscriptions/\(channelID)")
try await httpClient.sendRequest(
endpoint,
baseURL: instance.url,
customHeaders: ["Cookie": "SID=\(sid)"]
)
}
/// Unsubscribes from a channel on the Invidious instance.
/// - Parameters:
/// - channelID: The YouTube channel ID (UCID) to unsubscribe from
/// - instance: The Invidious instance
/// - sid: The session ID from login
/// - Throws: APIError if the unsubscription fails
func unsubscribe(from channelID: String, instance: Instance, sid: String) async throws {
let endpoint = GenericEndpoint.delete("/api/v1/auth/subscriptions/\(channelID)")
try await httpClient.sendRequest(
endpoint,
baseURL: instance.url,
customHeaders: ["Cookie": "SID=\(sid)"]
)
}
}
// MARK: - Video Proxy
extension InvidiousAPI {
/// Rewrites a stream URL to route through the given instance.
/// Replaces the scheme, host, and port with the instance's, keeping the original path and query.
static func proxiedURL(instance: Instance, originalURL: URL) -> URL {
var components = URLComponents(url: originalURL, resolvingAgainstBaseURL: true) ?? URLComponents()
let instanceComponents = URLComponents(url: instance.url, resolvingAgainstBaseURL: true)
components.scheme = instanceComponents?.scheme ?? "https"
components.host = instanceComponents?.host
components.port = instanceComponents?.port
return components.url ?? originalURL
}
/// Checks if a URL points to a YouTube CDN (googlevideo.com or youtube.com).
/// Only these URLs should be proxied — URLs already on the instance should not be.
static func isYouTubeCDNURL(_ url: URL) -> Bool {
guard let host = url.host?.lowercased() else { return false }
return host.hasSuffix("googlevideo.com") || host.hasSuffix("youtube.com")
}
/// Performs a HEAD request to detect if a URL returns HTTP 403 (Forbidden).
/// Used for auto-detecting when ISPs block direct YouTube CDN access.
static func isForbidden(_ url: URL) async -> Bool {
var request = URLRequest(url: url)
request.httpMethod = "HEAD"
request.timeoutInterval = 5
do {
let (_, response) = try await URLSession.shared.data(for: request)
if let httpResponse = response as? HTTPURLResponse {
return httpResponse.statusCode == 403
}
} catch {
// Network errors are not 403 — don't proxy on timeout or other failures
}
return false
}
/// Applies proxy URL rewriting to an array of streams if needed.
/// - Parameters:
/// - streams: The original streams from the API
/// - instance: The instance to proxy through
/// - Returns: Streams with YouTube CDN URLs rewritten to go through the instance
static func proxyStreamsIfNeeded(_ streams: [Stream], instance: Instance) async -> [Stream] {
guard instance.supportsVideoProxying else { return streams }
// Yattee Server does proxying server-side via the ?proxy=true query
// on the videos endpoint (the converter mints signed /proxy/relay
// URLs). Don't second-guess that here by host-rewriting CDN URLs.
if instance.type == .yatteeServer { return streams }
let firstCDNURL = streams.first(where: { isYouTubeCDNURL($0.url) })?.url
let shouldProxy: Bool
if instance.proxiesVideos {
shouldProxy = true
LoggingService.shared.info("Proxying streams through \(instance.displayName) (user-enabled)", category: .player)
} else if let cdnURL = firstCDNURL {
// Auto-detect via cached HEAD probe. Cache cuts videos 2..N down
// to a synchronous lookup; on the first miss we pay the HEAD once.
shouldProxy = await ProxyDetectionCache.shared.decision(
for: instance,
sampleURL: cdnURL,
probe: { await isForbidden($0) }
)
if shouldProxy {
LoggingService.shared.info("Proxying streams through \(instance.displayName) (auto-detected 403)", category: .player)
}
} else {
shouldProxy = false
}
// Even when we don't end up proxying, remember a sample CDN URL so
// future videos can prewarm the probe before their API call returns.
if !shouldProxy, let cdnURL = firstCDNURL {
await ProxyDetectionCache.shared.record(decision: false, sampleURL: cdnURL, for: instance)
}
guard shouldProxy else { return streams }
return streams.map { stream in
if isYouTubeCDNURL(stream.url) {
return stream.withURL(proxiedURL(instance: instance, originalURL: stream.url))
}
return stream
}
}
/// Kick off proxy auto-detection in the background using a previously-seen
/// CDN URL for this instance. Cheap when there's nothing to do (no sample
/// URL yet, or the decision is already cached). Call this when the player
/// starts loading so the verdict is ready by the time streams come back.
static func prewarmProxyDetection(for instance: Instance) async {
guard instance.supportsVideoProxying,
instance.type != .yatteeServer,
!instance.proxiesVideos else { return }
await ProxyDetectionCache.shared.prewarm(
instance: instance,
probe: { await isForbidden($0) }
)
}
}
// MARK: - Redirect Blocker
/// URLSession delegate that prevents automatic redirect following.
/// Used for login requests where we need to capture the Set-Cookie header from the 302 response.
private final class RedirectBlocker: NSObject, URLSessionTaskDelegate, Sendable {
func urlSession(
_ session: URLSession,
task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse,
newRequest request: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void
) {
// Return nil to stop the redirect and capture the original response
completionHandler(nil)
}
}
// MARK: - Channel Tab Response Models
/// Response page for channel playlists.
struct ChannelPlaylistsPage: Sendable {
let playlists: [Playlist]
let continuation: String?
}
/// Response page for channel videos (shorts, streams).
struct ChannelVideosPage: Sendable {
let videos: [Video]
let continuation: String?
}
// MARK: - Auth Response Models
/// Response for Invidious feed endpoint.
struct InvidiousFeedResponse: Sendable {
let videos: [Video]
let hasMore: Bool
}
/// Subscription from Invidious auth API.
struct InvidiousSubscription: Decodable, Sendable, Identifiable {
let author: String
let authorId: String
let authorThumbnails: [InvidiousSubscriptionThumbnail]?
var id: String { authorId }
/// Thumbnail URL for the subscription avatar.
/// This is set externally after fetching channel details since the subscriptions API
/// doesn't return thumbnails.
var thumbnailURL: URL?
enum CodingKeys: String, CodingKey {
case author, authorId, authorThumbnails
}
}
struct InvidiousSubscriptionThumbnail: Decodable, Sendable {
let url: String
let width: Int?
let height: Int?
func thumbnailURL(baseURL: URL) -> URL? {
// Handle protocol-relative URLs (starting with //)
if url.hasPrefix("//") {
return URL(string: "https:" + url)
}
// Handle relative paths by resolving against baseURL
if url.hasPrefix("/") {
return URL(string: url, relativeTo: baseURL)?.absoluteURL
}
return URL(string: url)
}
}
// MARK: - InvidiousSubscription to Channel Conversion
extension InvidiousSubscription {
/// Converts this Invidious subscription to a Channel model for local storage.
func toChannel(baseURL: URL) -> Channel {
let thumbURL = thumbnailURL ?? authorThumbnails?.first?.thumbnailURL(baseURL: baseURL)
return Channel(
id: .global(authorId),
name: author,
thumbnailURL: thumbURL
)
}
}
/// Playlist from Invidious authenticated API (/api/v1/auth/playlists).
private struct InvidiousAuthPlaylist: Decodable, Sendable {
let type: String? // "invidiousPlaylist"
let title: String
let playlistId: String
let author: String?
let description: String?
let videoCount: Int
let updated: Int64?
let isListed: Bool?
let videos: [InvidiousAuthPlaylistVideo]?
nonisolated func toPlaylist(baseURL: URL) -> Playlist {
Playlist(
id: .global(playlistId),
title: title,
description: description,
author: author.map { Author(id: "", name: $0) },
videoCount: videoCount,
thumbnailURL: Thumbnail.reliableURL(for: videos?.first?.videoThumbnails?.first?.thumbnailURL(baseURL: baseURL)),
videos: videos?.map { $0.toVideo(baseURL: baseURL) } ?? []
)
}
}
/// Video within an authenticated playlist response.
private struct InvidiousAuthPlaylistVideo: Decodable, Sendable {
let title: String
let videoId: String
let author: String
let authorId: String
let authorUrl: String?
let videoThumbnails: [InvidiousThumbnail]?
let index: Int?
let indexId: String?
let lengthSeconds: Int
nonisolated func toVideo(baseURL: URL) -> Video {
Video(
id: .global(videoId),
title: title,
description: nil,
author: Author(id: authorId, name: author),
duration: TimeInterval(lengthSeconds),
publishedAt: nil,
publishedText: nil,
viewCount: nil,
likeCount: nil,
thumbnails: videoThumbnails?.map { $0.toThumbnail(baseURL: baseURL) } ?? [],
isLive: false,
isUpcoming: false,
scheduledStartTime: nil
)
}
}
// MARK: - Invidious Response Models
/// Response from the authenticated feed endpoint.
/// The API returns an object with notifications and videos arrays.
private struct InvidiousAuthFeedResponse: Decodable, Sendable {
let notifications: [InvidiousVideo]?
let videos: [InvidiousVideo]
}
private struct InvidiousVideo: Decodable, Sendable {
let videoId: String
let index: Int?
/// Per-entry token from the authenticated playlist endpoints; the handle
/// Invidious accepts when removing this exact row.
let indexId: String?
let title: String
let description: String?
let author: String
let authorId: String
let authorUrl: String?
let lengthSeconds: Int
let published: Int64?
let publishedText: String?
let viewCount: Int?
let likeCount: Int?
let videoThumbnails: [InvidiousThumbnail]?
let liveNow: Bool?
let isUpcoming: Bool?
let premiereTimestamp: Int64?
nonisolated func toVideo(baseURL: URL) -> Video {
Video(
id: .global(videoId),
title: title,
description: description,
author: Author(id: authorId, name: author),
duration: TimeInterval(lengthSeconds),
publishedAt: published.map { Date(timeIntervalSince1970: TimeInterval($0)) },
publishedText: publishedText,
viewCount: viewCount,
likeCount: likeCount,
thumbnails: videoThumbnails?.map { $0.toThumbnail(baseURL: baseURL) } ?? [],
isLive: liveNow ?? false,
isUpcoming: isUpcoming ?? false,
scheduledStartTime: premiereTimestamp.map { Date(timeIntervalSince1970: TimeInterval($0)) }
)
}
}
private struct InvidiousVideoDetails: Decodable, Sendable {
let videoId: String
let title: String
let description: String?
let descriptionHtml: String?
let author: String
let authorId: String
let authorThumbnails: [InvidiousThumbnail]?
let subCountText: String?
let lengthSeconds: Int
let published: Int64?
let publishedText: String?
let viewCount: Int?
let likeCount: Int?
let videoThumbnails: [InvidiousThumbnail]?
let liveNow: Bool?
let isUpcoming: Bool?
let premiereTimestamp: Int64?
let hlsUrl: String?
let dashUrl: String?
let formatStreams: [InvidiousFormatStream]?
let adaptiveFormats: [InvidiousAdaptiveFormat]?
let captions: [InvidiousCaption]?
let storyboards: [InvidiousStoryboard]?
let recommendedVideos: [InvidiousRecommendedVideo]?
/// Parses subscriber count from text like "1.78M" or "500K"
nonisolated var subscriberCount: Int? {
guard let text = subCountText else { return nil }
let cleaned = text.trimmingCharacters(in: .whitespaces).uppercased()
let multiplier: Double
var numericPart = cleaned
if cleaned.hasSuffix("B") {
multiplier = 1_000_000_000
numericPart = String(cleaned.dropLast())
} else if cleaned.hasSuffix("M") {
multiplier = 1_000_000
numericPart = String(cleaned.dropLast())
} else if cleaned.hasSuffix("K") {
multiplier = 1_000
numericPart = String(cleaned.dropLast())
} else {
multiplier = 1
}
guard let value = Double(numericPart) else { return nil }
return Int(value * multiplier)
}
nonisolated func toVideo(baseURL: URL) -> Video {
// Convert recommended videos, limiting to 12
let related: [Video]? = recommendedVideos?.prefix(12).map { $0.toVideo(baseURL: baseURL) }
return Video(
id: .global(videoId),
title: title,
description: description,
author: Author(
id: authorId,
name: author,
thumbnailURL: authorThumbnails?.authorThumbnailURL(baseURL: baseURL),
subscriberCount: subscriberCount
),
duration: TimeInterval(lengthSeconds),
publishedAt: published.map { Date(timeIntervalSince1970: TimeInterval($0)) },
publishedText: publishedText,
viewCount: viewCount,
likeCount: likeCount,
thumbnails: videoThumbnails?.map { $0.toThumbnail(baseURL: baseURL) } ?? [],
isLive: liveNow ?? false,
isUpcoming: isUpcoming ?? false,
scheduledStartTime: premiereTimestamp.map { Date(timeIntervalSince1970: TimeInterval($0)) },
relatedVideos: related
)
}
nonisolated func toStreams(instanceBaseURL: URL) -> [Stream] {
var streams: [Stream] = []
// Add HLS stream (adaptive - works for both live and on-demand content)
if let hlsUrl, let url = URL(string: hlsUrl) {
streams.append(Stream(
url: url,
resolution: nil,
format: "hls",
isLive: liveNow ?? false,
mimeType: "application/x-mpegURL"
))
}
// Add DASH stream (adaptive - supports VP9, AV1, higher qualities)
// MPV can play DASH manifests directly
if let dashUrl {
// Resolve relative DASH URLs against instance base URL
let resolvedDashURL: URL?
if dashUrl.hasPrefix("/") {
resolvedDashURL = URL(string: dashUrl, relativeTo: instanceBaseURL)?.absoluteURL
} else {
resolvedDashURL = URL(string: dashUrl)
}
if let url = resolvedDashURL {
streams.append(Stream(
url: url,
resolution: nil,
format: "dash",
isLive: liveNow ?? false,
mimeType: "application/dash+xml"
))
}
}
// Add format streams (combined audio+video)
if let formatStreams {
streams.append(contentsOf: formatStreams.compactMap { $0.toStream(isLive: liveNow ?? false) })
}
// Add adaptive formats (separate audio/video)
if let adaptiveFormats {
streams.append(contentsOf: adaptiveFormats.compactMap { $0.toStream(isLive: liveNow ?? false) })
}
return streams
}
nonisolated func toCaptions(baseURL: URL) -> [Caption] {
guard let captions else { return [] }
return captions.compactMap { $0.toCaption(baseURL: baseURL) }
}
nonisolated func toStoryboards(instanceBaseURL: URL) -> [Storyboard] {
storyboards?.compactMap { $0.toStoryboard(instanceBaseURL: instanceBaseURL) } ?? []
}
}
private struct InvidiousCaption: Decodable, Sendable {
let label: String
let languageCode: String
let url: String
nonisolated func toCaption(baseURL: URL) -> Caption? {
// Prepend /companion to route through companion service
let companionURL = "/companion" + url
guard let fullURL = URL(string: companionURL, relativeTo: baseURL) else { return nil }
return Caption(
label: label,
languageCode: languageCode,
url: fullURL
)
}
}
private struct InvidiousStoryboard: Decodable, Sendable {
let url: String?
let templateUrl: String?
let width: Int
let height: Int
let count: Int
let interval: Int
let storyboardWidth: Int
let storyboardHeight: Int
let storyboardCount: Int
nonisolated func toStoryboard(instanceBaseURL: URL) -> Storyboard? {
// templateUrl is the direct YouTube URL (may be blocked)
// url is the proxied URL through the instance (preferred)
guard templateUrl != nil || url != nil else { return nil }
return Storyboard(
proxyUrl: url,
templateUrl: templateUrl ?? "",
instanceBaseURL: instanceBaseURL,
width: width,
height: height,
count: count,
interval: interval,
storyboardWidth: storyboardWidth,
storyboardHeight: storyboardHeight,
storyboardCount: storyboardCount
)
}
}
/// Recommended video from Invidious video details response.
private struct InvidiousRecommendedVideo: Decodable, Sendable {
let videoId: String
let title: String
let author: String
let authorId: String
let authorUrl: String?
let videoThumbnails: [InvidiousThumbnail]?
let lengthSeconds: Int
let viewCountText: String?
let viewCount: Int?
nonisolated func toVideo(baseURL: URL) -> Video {
// Parse view count from text if numeric viewCount not available
let views: Int? = viewCount ?? parseViewCount(from: viewCountText)
return Video(
id: .global(videoId),
title: title,
description: nil,
author: Author(id: authorId, name: author),
duration: TimeInterval(lengthSeconds),
publishedAt: nil,
publishedText: nil,
viewCount: views,
likeCount: nil,
thumbnails: videoThumbnails?.map { $0.toThumbnail(baseURL: baseURL) } ?? [],
isLive: false,
isUpcoming: false,
scheduledStartTime: nil
)
}
/// Parses view count from text like "1.2M views" or "500K views".
private nonisolated func parseViewCount(from text: String?) -> Int? {
guard let text else { return nil }
let cleaned = text
.replacingOccurrences(of: " views", with: "")
.replacingOccurrences(of: ",", with: "")
.trimmingCharacters(in: .whitespaces)
.uppercased()
let multiplier: Double
var numericPart = cleaned
if cleaned.hasSuffix("B") {
multiplier = 1_000_000_000
numericPart = String(cleaned.dropLast())
} else if cleaned.hasSuffix("M") {
multiplier = 1_000_000
numericPart = String(cleaned.dropLast())
} else if cleaned.hasSuffix("K") {
multiplier = 1_000
numericPart = String(cleaned.dropLast())
} else {
multiplier = 1
}
guard let value = Double(numericPart) else { return nil }
return Int(value * multiplier)
}
}
private struct InvidiousFormatStream: Decodable, Sendable {
let url: String?
let itag: String?
let type: String?
let quality: String?
let container: String?
let encoding: String?
let resolution: String?
let size: String?
let fps: Int?
nonisolated func toStream(isLive: Bool = false) -> Stream? {
guard let urlString = url, let streamUrl = URL(string: urlString) else { return nil }
// Parse audio codec from mimeType if present
// Format: "video/mp4; codecs=\"avc1.42001E, mp4a.40.2\""
let audioCodec = parseAudioCodec(from: type)
// Extract video codec from encoding field, or fall back to parsing from type
let videoCodec = encoding ?? parseVideoCodec(from: type)
return Stream(
url: streamUrl,
resolution: resolution.flatMap { StreamResolution(heightLabel: $0) },
format: container ?? "unknown",
videoCodec: videoCodec,
audioCodec: audioCodec,
isLive: isLive,
mimeType: type,
fps: fps
)
}
/// Parse video codec from mimeType codecs string.
/// Format: "video/mp4; codecs=\"avc1.42001E, mp4a.40.2\""
private nonisolated func parseVideoCodec(from mimeType: String?) -> String? {
guard let mimeType else { return nil }
guard let codecsRange = mimeType.range(of: "codecs=\"") else { return nil }
let codecsStart = codecsRange.upperBound
guard let codecsEnd = mimeType[codecsStart...].firstIndex(of: "\"") else { return nil }
let codecsString = mimeType[codecsStart..<codecsEnd]
let codecs = codecsString.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
// Find video codec (avc1, vp9, av01, etc.)
for codec in codecs {
let lowercased = codec.lowercased()
if lowercased.starts(with: "avc") {
return "avc1"
} else if lowercased.starts(with: "vp9") || lowercased.starts(with: "vp09") {
return "vp9"
} else if lowercased.starts(with: "av01") || lowercased.starts(with: "av1") {
return "av1"
} else if lowercased.starts(with: "hev") || lowercased.starts(with: "hvc") {
return "hevc"
}
}
return nil
}
/// Parse audio codec from mimeType codecs string.
/// Format streams always contain both video and audio.
private nonisolated func parseAudioCodec(from mimeType: String?) -> String? {
guard let mimeType else { return nil }
// Look for codecs in the mimeType string
// Example: "video/mp4; codecs=\"avc1.42001E, mp4a.40.2\""
guard let codecsRange = mimeType.range(of: "codecs=\"") else {
// No codecs specified, but formatStreams always have audio
// Default to aac for mp4 container
if mimeType.contains("video/mp4") {
return "aac"
}
return nil
}
let codecsStart = codecsRange.upperBound
guard let codecsEnd = mimeType[codecsStart...].firstIndex(of: "\"") else { return nil }
let codecsString = mimeType[codecsStart..<codecsEnd]
let codecs = codecsString.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
// Find audio codec (mp4a, opus, vorbis, etc.)
for codec in codecs {
let lowercased = codec.lowercased()
if lowercased.starts(with: "mp4a") {
return "aac"
} else if lowercased.contains("opus") {
return "opus"
} else if lowercased.contains("vorbis") {
return "vorbis"
}
}
// formatStreams are muxed, assume audio exists
return "aac"
}
}
private struct InvidiousAdaptiveFormat: Decodable, Sendable {
let url: String?
let itag: String?
let type: String?
let container: String?
let encoding: String?
let resolution: String?
let bitrate: String?
let clen: String?
let audioTrack: InvidiousAudioTrack?
let audioQuality: String?
let fps: Int?
var isAudioOnly: Bool {
type?.starts(with: "audio/") ?? false
}
nonisolated func toStream(isLive: Bool = false) -> Stream? {
guard let urlString = url, let streamUrl = URL(string: urlString) else { return nil }
// Try to get audio language/track from audioTrack object first,
// then fall back to parsing from URL xtags parameter
let (language, trackName, isOriginal) = parseAudioInfo()
// Extract video codec from encoding field, or fall back to parsing from type
let videoCodec: String? = if isAudioOnly {
nil
} else {
encoding ?? parseVideoCodec(from: type)
}
return Stream(
url: streamUrl,
resolution: isAudioOnly ? nil : resolution.flatMap { StreamResolution(heightLabel: $0) },
format: container ?? "unknown",
videoCodec: videoCodec,
audioCodec: isAudioOnly ? encoding : nil,
bitrate: bitrate.flatMap { Int($0) },
fileSize: clen.flatMap { Int64($0) },
isAudioOnly: isAudioOnly,
isLive: isLive,
mimeType: type,
audioLanguage: language,
audioTrackName: trackName,
isOriginalAudio: isOriginal,
fps: isAudioOnly ? nil : fps
)
}
/// Parse video codec from mimeType codecs string.
private nonisolated func parseVideoCodec(from mimeType: String?) -> String? {
guard let mimeType else { return nil }
guard let codecsRange = mimeType.range(of: "codecs=\"") else { return nil }
let codecsStart = codecsRange.upperBound
guard let codecsEnd = mimeType[codecsStart...].firstIndex(of: "\"") else { return nil }
let codecsString = mimeType[codecsStart..<codecsEnd]
let codecs = codecsString.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
// Find video codec (avc1, vp9, av01, etc.)
for codec in codecs {
let lowercased = codec.lowercased()
if lowercased.starts(with: "avc") {
return "avc1"
} else if lowercased.starts(with: "vp9") || lowercased.starts(with: "vp09") {
return "vp9"
} else if lowercased.starts(with: "av01") || lowercased.starts(with: "av1") {
return "av1"
} else if lowercased.starts(with: "hev") || lowercased.starts(with: "hvc") {
return "hevc"
}
}
return nil
}
/// Parse audio language, track name, and whether it's original from audioTrack object or URL xtags.
/// URL xtags format: xtags=acont%3Doriginal%3Alang%3Den-US or xtags=acont%3Ddubbed-auto%3Alang%3Dde-DE
private nonisolated func parseAudioInfo() -> (language: String?, trackName: String?, isOriginal: Bool) {
// When audioTrack object is available, use it for language/name but determine
// isOriginal from displayName and URL xtags (audioTrack alone is not reliable)
if let audioTrack, audioTrack.id != nil || audioTrack.displayName != nil {
let displayNameIndicatesOriginal = audioTrack.displayName?
.localizedCaseInsensitiveContains("original") ?? false
let xtagsIndicateOriginal = checkXtagsForOriginal()
let isOriginal = displayNameIndicatesOriginal || xtagsIndicateOriginal
return (audioTrack.id, audioTrack.displayName, isOriginal)
}
// Fall back to parsing from URL xtags parameter for audio streams
guard isAudioOnly, let urlString = url else {
return (nil, nil, false)
}
guard let xtags = parseXtags(from: urlString) else {
return (nil, nil, false)
}
guard let langCode = xtags["lang"] else {
return (nil, nil, false)
}
let contentType = xtags["acont"]
let isOriginal = contentType == "original"
let trackName = generateTrackName(langCode: langCode, contentType: contentType)
return (langCode, trackName, isOriginal)
}
/// Check if the stream URL's xtags indicate this is the original audio track.
private nonisolated func checkXtagsForOriginal() -> Bool {
guard isAudioOnly, let urlString = url else { return false }
guard let xtags = parseXtags(from: urlString) else { return false }
return xtags["acont"] == "original"
}
/// Parse xtags key=value pairs from a URL string.
/// Example xtags: "acont=original:drc=1:lang=en-US"
private nonisolated func parseXtags(from urlString: String) -> [String: String]? {
guard let xtagsRange = urlString.range(of: "xtags=") else { return nil }
let xtagsStart = xtagsRange.upperBound
let xtagsEnd = urlString[xtagsStart...].firstIndex(of: "&") ?? urlString.endIndex
let xtagsEncoded = String(urlString[xtagsStart..<xtagsEnd])
guard let xtags = xtagsEncoded.removingPercentEncoding else { return nil }
return xtags.split(separator: ":").reduce(into: [String: String]()) { result, pair in
let parts = pair.split(separator: "=", maxSplits: 1)
if parts.count == 2 {
result[String(parts[0])] = String(parts[1])
}
}
}
/// Generate a human-readable track name from language code and content type.
private nonisolated func generateTrackName(langCode: String, contentType: String?) -> String {
let locale = Locale(identifier: "en")
let languageName: String
// Try to get language name from the code (handles both "en" and "en-US" formats)
if let name = locale.localizedString(forIdentifier: langCode) {
languageName = name
} else {
// Fall back to just the language part for codes like "en-US"
let baseCode = String(langCode.split(separator: "-").first ?? Substring(langCode))
languageName = locale.localizedString(forLanguageCode: baseCode) ?? langCode
}
// Add suffix based on content type
switch contentType {
case "original":
return "\(languageName) (Original)"
case "dubbed-auto":
return "\(languageName) (Auto-dubbed)"
case "dubbed":
return "\(languageName) (Dubbed)"
default:
return languageName
}
}
}
private struct InvidiousAudioTrack: Decodable, Sendable {
let id: String?
let displayName: String?
let isDefault: Bool?
}
private struct InvidiousThumbnail: Decodable, Sendable {
let quality: String?
let url: String
let width: Int?
let height: Int?
/// Resolves the thumbnail URL, handling absolute, protocol-relative, and relative paths.
/// - Parameter baseURL: The instance base URL for resolving relative paths
/// - Returns: The resolved absolute URL, or nil if the URL is invalid
nonisolated func thumbnailURL(baseURL: URL) -> URL? {
// Handle protocol-relative URLs (starting with //)
if url.hasPrefix("//") {
return URL(string: "https:" + url)
}
// Handle relative paths (starting with /)
if url.hasPrefix("/") {
return URL(string: url, relativeTo: baseURL)?.absoluteURL
}
// Absolute URL
return URL(string: url)
}
nonisolated func toThumbnail(baseURL: URL) -> Thumbnail {
Thumbnail(
url: thumbnailURL(baseURL: baseURL) ?? URL(string: "about:blank")!,
quality: quality.map { qualityFromString($0) } ?? inferQualityFromSize(),
width: width,
height: height
)
}
private nonisolated func qualityFromString(_ quality: String) -> Thumbnail.Quality {
switch quality {
case "maxres", "maxresdefault": return .maxres
case "sddefault", "sd": return .standard
case "high": return .high
case "medium": return .medium
default: return .default
}
}
private nonisolated func inferQualityFromSize() -> Thumbnail.Quality {
guard let width else { return .default }
switch width {
case 0..<200: return .default
case 200..<400: return .medium
case 400..<800: return .high
case 800..<1200: return .standard
default: return .maxres
}
}
}
private extension Array where Element == InvidiousThumbnail {
/// Selects an appropriate author thumbnail (at least 100px for good quality on Retina displays).
/// - Parameter baseURL: The instance base URL for resolving relative paths
/// - Returns: The resolved thumbnail URL
func authorThumbnailURL(baseURL: URL) -> URL? {
// Prefer 100px or larger for good quality on Retina displays, fall back to largest available
let preferred = first { ($0.width ?? 0) >= 100 }
return (preferred ?? last)?.thumbnailURL(baseURL: baseURL)
}
}
private struct InvidiousChannel: Decodable, Sendable {
let authorId: String
let author: String
let description: String?
let subCount: Int?
let totalViews: Int64?
let authorThumbnails: [InvidiousThumbnail]?
let authorBanners: [InvidiousThumbnail]?
let authorVerified: Bool?
nonisolated func toChannel(baseURL: URL) -> Channel {
Channel(
id: .global(authorId),
name: author,
description: description,
subscriberCount: subCount,
thumbnailURL: authorThumbnails?.authorThumbnailURL(baseURL: baseURL),
bannerURL: authorBanners?.last?.thumbnailURL(baseURL: baseURL),
isVerified: authorVerified ?? false
)
}
}
private struct InvidiousChannelVideos: Decodable, Sendable {
let videos: [InvidiousVideo]
}
private struct InvidiousChannelVideosWithContinuation: Decodable, Sendable {
let videos: [InvidiousVideo]
let continuation: String?
}
private struct InvidiousChannelPlaylists: Decodable, Sendable {
let playlists: [InvidiousChannelPlaylistItem]
let continuation: String?
}
private struct InvidiousChannelPlaylistItem: Decodable, Sendable {
let playlistId: String
let title: String
let author: String?
let authorId: String?
let videoCount: Int
let playlistThumbnail: String?
nonisolated func toPlaylist(baseURL: URL) -> Playlist {
// Handle protocol-relative URLs, relative paths, and absolute URLs
let thumbnailURL: URL? = playlistThumbnail.flatMap { urlString -> URL? in
if urlString.hasPrefix("//") {
return URL(string: "https:" + urlString)
} else if urlString.hasPrefix("/") {
return URL(string: urlString, relativeTo: baseURL)?.absoluteURL
}
return URL(string: urlString)
}
return Playlist(
id: .global(playlistId),
title: title,
author: authorId.map { Author(id: $0, name: author ?? "") },
videoCount: videoCount,
thumbnailURL: thumbnailURL,
videos: []
)
}
}
/// Item within a playlist - can be a video or a parse error from Invidious.
/// Invidious may return `"type": "parse-error"` for videos it failed to parse from YouTube.
private enum InvidiousPlaylistItem: Decodable, Sendable {
case video(InvidiousVideo)
case parseError
case unknown
private enum CodingKeys: String, CodingKey {
case type
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decodeIfPresent(String.self, forKey: .type)
switch type {
case "video", nil:
// Videos may or may not have a type field
do {
self = .video(try InvidiousVideo(from: decoder))
} catch {
self = .unknown
}
case "parse-error":
self = .parseError
default:
self = .unknown
}
}
/// The playlist index of the wrapped video, when the item is a video the
/// server indexed.
var playlistIndex: Int? {
if case .video(let video) = self {
return video.index
}
return nil
}
}
private struct InvidiousPlaylist: Decodable, Sendable {
let playlistId: String
let title: String
let description: String?
let author: String?
let authorId: String?
let videoCount: Int
let videos: [InvidiousPlaylistItem]?
nonisolated func toPlaylist(baseURL: URL) -> Playlist {
// Extract only valid videos, skipping parse errors and unknown items
let validVideos: [Video] = videos?.compactMap { item in
if case .video(let video) = item {
return video.toVideo(baseURL: baseURL)
}
return nil
} ?? []
return Playlist(
id: .global(playlistId),
title: title,
description: description,
author: authorId.map { Author(id: $0, name: author ?? "") },
videoCount: videoCount,
thumbnailURL: Thumbnail.reliableURL(for: validVideos.first?.thumbnails.first?.url),
videos: validVideos
)
}
}
private struct InvidiousComments: Decodable, Sendable {
let comments: [InvidiousComment]
let continuation: String?
}
private struct InvidiousComment: Decodable, Sendable {
let commentId: String
let author: String
let authorId: String
let authorThumbnails: [InvidiousThumbnail]?
let authorIsChannelOwner: Bool?
let content: String
let published: Int64?
let publishedText: String?
let likeCount: Int?
let isEdited: Bool?
let isPinned: Bool?
let creatorHeart: InvidiousCreatorHeart?
let replies: InvidiousCommentReplies?
nonisolated func toComment(baseURL: URL) -> Comment {
Comment(
id: commentId,
author: Author(
id: authorId,
name: author,
thumbnailURL: authorThumbnails?.first?.thumbnailURL(baseURL: baseURL)
),
content: content,
publishedAt: published.map { Date(timeIntervalSince1970: TimeInterval($0)) },
publishedText: publishedText,
likeCount: likeCount,
isPinned: isPinned ?? false,
isCreatorComment: authorIsChannelOwner ?? false,
hasCreatorHeart: creatorHeart != nil,
replyCount: replies?.replyCount ?? 0,
repliesContinuation: replies?.continuation
)
}
}
private struct InvidiousCreatorHeart: Decodable, Sendable {
let creatorThumbnail: String?
let creatorName: String?
}
private struct InvidiousCommentReplies: Decodable, Sendable {
let replyCount: Int
let continuation: String?
}
private enum InvidiousSearchItem: Decodable, Sendable {
case video(InvidiousVideo)
case channel(InvidiousSearchChannel)
case playlist(InvidiousSearchPlaylist)
case unknown
private enum CodingKeys: String, CodingKey {
case type
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(String.self, forKey: .type)
switch type {
case "video":
self = .video(try InvidiousVideo(from: decoder))
case "channel":
self = .channel(try InvidiousSearchChannel(from: decoder))
case "playlist":
self = .playlist(try InvidiousSearchPlaylist(from: decoder))
default:
self = .unknown
}
}
}
private struct InvidiousSearchChannel: Decodable, Sendable {
let authorId: String
let author: String
let description: String?
let subCount: Int?
let videoCount: Int?
let authorThumbnails: [InvidiousThumbnail]?
let authorVerified: Bool?
nonisolated func toChannel(baseURL: URL) -> Channel {
Channel(
id: .global(authorId),
name: author,
description: description,
subscriberCount: subCount,
videoCount: videoCount,
thumbnailURL: authorThumbnails?.authorThumbnailURL(baseURL: baseURL),
isVerified: authorVerified ?? false
)
}
}
private struct InvidiousSearchPlaylist: Decodable, Sendable {
let playlistId: String
let title: String
let author: String?
let authorId: String?
let videoCount: Int
let playlistThumbnail: String?
let videos: [InvidiousVideo]?
nonisolated func toPlaylist(baseURL: URL) -> Playlist {
// Use playlistThumbnail from search results, fall back to first video thumbnail
// Handle protocol-relative URLs, relative paths, and absolute URLs
let thumbnailURL: URL? = playlistThumbnail.flatMap { urlString -> URL? in
if urlString.hasPrefix("//") {
return URL(string: "https:" + urlString)
} else if urlString.hasPrefix("/") {
return URL(string: urlString, relativeTo: baseURL)?.absoluteURL
}
return URL(string: urlString)
} ?? videos?.first?.videoThumbnails?.first?.thumbnailURL(baseURL: baseURL)
return Playlist(
id: .global(playlistId),
title: title,
author: authorId.map { Author(id: $0, name: author ?? "") },
videoCount: videoCount,
thumbnailURL: Thumbnail.reliableURL(for: thumbnailURL),
videos: videos?.map { $0.toVideo(baseURL: baseURL) } ?? []
)
}
}
private struct InvidiousSuggestions: Decodable, Sendable {
let query: String
let suggestions: [String]
}