// // 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() 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() 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=; 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.. 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.. 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.. (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.. 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] }