Files
yattee/Yattee/Services/API/InvidiousAPI.swift
Arkadiusz Fal b6b6d280e1 Fix incomplete playlist loading by paginating through all pages
Playlists only loaded the first page of videos. Add full pagination for
both Invidious and Piped playlist endpoints (public and authenticated).
Deduplicate Invidious results by playlist index to handle its overlapping
page windows. Also fix URL encoding in Invidious login to use strict
form-encoding charset.
2026-02-12 02:02:32 +01:00

1573 lines
58 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 {
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 allVideos = firstResponse.videos ?? []
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)
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)
}
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) async throws -> String {
// Build form-urlencoded body
let bodyComponents = [
"email": email,
"password": password,
"action": "signin"
]
let formAllowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-._~"))
let bodyString = bodyComponents
.map { "\($0.key)=\($0.value.addingPercentEncoding(withAllowedCharacters: formAllowed) ?? $0.value)" }
.joined(separator: "&")
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
// 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: - 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: 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?
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) {
// Prefer explicit audioTrack if available
if let audioTrack, audioTrack.id != nil || audioTrack.displayName != nil {
// Can't determine if original from audioTrack alone, assume not
return (audioTrack.id, audioTrack.displayName, false)
}
// Parse from URL xtags parameter for audio streams
guard isAudioOnly, let urlString = url else {
return (nil, nil, false)
}
// Find xtags parameter in URL
guard let xtagsRange = urlString.range(of: "xtags=") else {
return (nil, nil, false)
}
let xtagsStart = xtagsRange.upperBound
let xtagsEnd = urlString[xtagsStart...].firstIndex(of: "&") ?? urlString.endIndex
let xtagsEncoded = String(urlString[xtagsStart..<xtagsEnd])
// URL decode the xtags value
guard let xtags = xtagsEncoded.removingPercentEncoding else {
return (nil, nil, false)
}
// Parse key=value pairs separated by colons
// Example: "acont=original:drc=1:lang=en-US" or "acont=dubbed-auto:lang=de-DE"
let pairs = 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])
}
}
guard let langCode = pairs["lang"] else {
return (nil, nil, false)
}
// Check if this is the original audio track
let contentType = pairs["acont"]
let isOriginal = contentType == "original"
// Generate display name from language code and content type
let trackName = generateTrackName(langCode: langCode, contentType: contentType)
return (langCode, trackName, isOriginal)
}
/// 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?
}
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
}
}
}
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: 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: thumbnailURL,
videos: videos?.map { $0.toVideo(baseURL: baseURL) } ?? []
)
}
}
private struct InvidiousSuggestions: Decodable, Sendable {
let query: String
let suggestions: [String]
}