// // 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) } /// `@handle` channel IDs (youtube.com/@name links) resolved to UC… IDs, per instance. private var resolvedChannelHandles: [String: String] = [:] /// Invidious' channel endpoints only take UC… IDs, so a `@handle` is resolved first. private func channelID(for id: String, instance: Instance) async throws -> String { guard id.hasPrefix("@") else { return id } let key = "\(instance.url.absoluteString)|\(id.lowercased())" if let cached = resolvedChannelHandles[key] { return cached } let endpoint = GenericEndpoint.get("/api/v1/resolveurl", query: ["url": "https://www.youtube.com/\(id)"]) let response: InvidiousResolvedURL = try await httpClient.fetch(endpoint, baseURL: instance.url) guard let ucid = response.ucid, !ucid.isEmpty else { return id } resolvedChannelHandles[key] = ucid return ucid } func channel(id: String, instance: Instance) async throws -> Channel { let id = try await channelID(for: id, instance: instance) 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 id = try await channelID(for: id, instance: instance) 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 id = try await channelID(for: id, instance: instance) 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 id = try await channelID(for: id, instance: instance) 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 id = try await channelID(for: id, instance: instance) 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, isListed: firstResponse.isListed, videos: allVideos ).toPlaylist(baseURL: instance.url) } /// Fetches a user's playlist along with the per-entry identities needed to /// mutate it. /// /// Uses the authenticated endpoint (required for private IVPL* playlists) /// and keeps the `index`/`indexId` tokens Invidious /// hands out for every row instead of dropping them on conversion. /// - Parameters: /// - id: The playlist ID /// - instance: The Invidious instance /// - sid: The session ID from login /// - Returns: The playlist plus its addressable entries func userPlaylistSnapshot(id: String, instance: Instance, sid: String) async throws -> RemotePlaylistSnapshot { let headers = ["Cookie": "SID=\(sid)"] let firstEndpoint = GenericEndpoint( path: "/api/v1/auth/playlists/\(id)", queryItems: nil, headers: headers ) let firstResponse: InvidiousPlaylist = try await httpClient.fetch(firstEndpoint, baseURL: instance.url) var additionalPages: [InvidiousPlaylist] = [] let maxPages = 50 if firstResponse.videoCount > 0 { var page = 2 while page <= maxPages { let endpoint = GenericEndpoint( path: "/api/v1/auth/playlists/\(id)", queryItems: [URLQueryItem(name: "page", value: String(page))], headers: headers ) let response: InvidiousPlaylist = try await httpClient.fetch(endpoint, baseURL: instance.url) if (response.videos ?? []).isEmpty { break } additionalPages.append(response) page += 1 } } return Self.buildAuthPlaylistSnapshot( firstPage: firstResponse, additionalPages: additionalPages, playlistID: id, baseURL: instance.url ) } /// Builds a snapshot from already-fetched pages of /// `/api/v1/auth/playlists/{id}`. /// /// Pure: the network path and ``authPlaylistSnapshot(fromPageJSON:playlistID:baseURL:)`` /// both funnel through here, so the merge, dedupe and ordering rules have /// exactly one implementation. private static func buildAuthPlaylistSnapshot( firstPage: InvidiousPlaylist, additionalPages: [InvidiousPlaylist], playlistID: String, baseURL: URL ) -> RemotePlaylistSnapshot { var allVideos = firstPage.videos ?? [] for page in additionalPages { allVideos.append(contentsOf: page.videos ?? []) } // Invidious pagination uses overlapping pages — deduplicate by playlist // index. The key is deliberately never `videoId`: a playlist may hold // the same video twice at different indexes, and each copy is its own // removable row with its own indexId. var seenIndices = Set() allVideos = allVideos.filter { item in if case .video(let video) = item, let index = video.index { return seenIndices.insert(index).inserted } return true } // Order by the server's index so entry positions ascend even if a page // arrives out of order. Rows the server left unindexed keep their // arrival order and sort last; the sort is stable either way. allVideos = allVideos.enumerated().sorted { lhs, rhs in switch (lhs.element.playlistIndex, rhs.element.playlistIndex) { case let (left?, right?): return left == right ? lhs.offset < rhs.offset : left < right case (_?, nil): return true case (nil, _?): return false case (nil, nil): return lhs.offset < rhs.offset } }.map(\.element) let playlist = InvidiousPlaylist( playlistId: firstPage.playlistId, title: firstPage.title, description: firstPage.description, author: firstPage.author, authorId: firstPage.authorId, videoCount: firstPage.videoCount, isListed: firstPage.isListed, videos: allVideos ).toPlaylist(baseURL: baseURL) // A row needs both tokens to be a mutation target: `indexId` names it // and `index` places it. Rows missing either are still displayed — // they stay in `playlist.videos` — they just never appear in `entries`, // so no removal can be aimed at a row the server refuses to name. // // `displayRow` is counted over exactly what `toPlaylist` keeps, so an // entry says which visible row it belongs to without anyone having to // match video ids — which could not tell two rows of the same video // apart anyway. var displayRow = 0 var entries: [RemotePlaylistEntry] = [] for item in allVideos { guard case .video(let video) = item else { continue } let row = displayRow displayRow += 1 guard let indexID = video.indexId, let index = video.index else { continue } entries.append( RemotePlaylistEntry( id: .invidious(playlistID: playlistID, indexID: indexID), video: video.toVideo(baseURL: baseURL), position: index, displayRow: row ) ) } return RemotePlaylistSnapshot(playlist: playlist, entries: entries, isListed: firstPage.isListed) } /// Runs the snapshot assembly over raw page payloads, decoding them the way /// the network path does. Lets the merge/dedupe/ordering rules be exercised /// without a server. static func authPlaylistSnapshot( fromPageJSON pages: [Data], playlistID: String, baseURL: URL ) throws -> RemotePlaylistSnapshot { let decoder = HTTPClient.makeAPIDecoder() let decoded = try pages.map { try decoder.decode(InvidiousPlaylist.self, from: $0) } guard let firstPage = decoded.first else { throw APIError.decodingError("No playlist pages to decode") } return buildAuthPlaylistSnapshot( firstPage: firstPage, additionalPages: Array(decoded.dropFirst()), playlistID: playlistID, baseURL: baseURL ) } func comments(videoID: String, instance: Instance, continuation: String?) async throws -> CommentsPage { var query: [String: String] = [:] if let continuation { query["continuation"] = continuation } let endpoint = GenericEndpoint.get("/api/v1/comments/\(videoID)", query: query) do { let response: InvidiousComments = try await httpClient.fetch(endpoint, baseURL: instance.url) return CommentsPage( comments: response.comments.map { $0.toComment(baseURL: instance.url) }, continuation: response.continuation ) } catch APIError.notFound { throw APIError.commentsDisabled } } func streams(videoID: String, instance: Instance) async throws -> [Stream] { let endpoint = GenericEndpoint.get("/api/v1/videos/\(videoID)") let response: InvidiousVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url) return response.toStreams(instanceBaseURL: instance.url) } func captions(videoID: String, instance: Instance) async throws -> [Caption] { let endpoint = GenericEndpoint.get("/api/v1/videos/\(videoID)") let response: InvidiousVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url) return response.toCaptions(baseURL: instance.url) } func channelSearch(id: String, query: String, instance: Instance, page: Int) async throws -> ChannelSearchPage { let id = try await channelID(for: id, instance: instance) 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) } func videoWithStreamsAndCaptionsAndStoryboards(id: String, instance: Instance) async throws -> (video: Video, streams: [Stream], captions: [Caption], storyboards: [Storyboard]) { let endpoint = GenericEndpoint.get("/api/v1/videos/\(id)") let response: InvidiousVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url) return ( video: response.toVideo(baseURL: instance.url), streams: response.toStreams(instanceBaseURL: instance.url), captions: response.toCaptions(baseURL: instance.url), storyboards: response.toStoryboards(instanceBaseURL: instance.url) ) } // MARK: - Authentication /// Logs in to an Invidious instance and returns the session ID (SID). /// - Parameters: /// - email: The user's email/username /// - password: The user's password /// - instance: The Invidious instance to log in to /// - Returns: The session ID (SID) cookie value func login(email: String, password: String, instance: Instance, extraHeaders: [String: String]? = nil) async throws -> String { // Build form-urlencoded body using URLComponents for standard encoding var components = URLComponents() components.queryItems = [ URLQueryItem(name: "email", value: email), URLQueryItem(name: "password", value: password), URLQueryItem(name: "action", value: "signin") ] // URLQueryItem leaves '+' unencoded, but in form-urlencoded '+' means space let bodyString = components.percentEncodedQuery? .replacingOccurrences(of: "+", with: "%2B") ?? "" guard let bodyData = bodyString.data(using: .utf8) else { throw APIError.invalidRequest } // Build the request manually to handle cookies var request = URLRequest(url: instance.url.appendingPathComponent("login")) request.httpMethod = "POST" request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") request.httpBody = bodyData // Apply any extra headers (e.g. an HTTP Basic Auth Authorization header // for instances behind a reverse proxy). The login endpoint uses its own // URLSession below to capture Set-Cookie, so it cannot inherit headers // from the injected httpClient. if let extraHeaders { for (key, value) in extraHeaders { request.setValue(value, forHTTPHeaderField: key) } } // Use a session that doesn't follow redirects so we can capture the Set-Cookie header let sessionConfig = URLSessionConfiguration.ephemeral let session = URLSession(configuration: sessionConfig, delegate: RedirectBlocker(), delegateQueue: nil) let (data, response) = try await session.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw APIError.unknown("Invalid response type") } // Check for successful login (302 redirect or 200 OK) // Invidious returns 302 on success, redirecting to home guard httpResponse.statusCode == 200 || httpResponse.statusCode == 302 else { // Check for error message in response body if let responseText = String(data: data, encoding: .utf8), responseText.contains("Wrong username") || responseText.contains("Invalid") || responseText.contains("incorrect") { throw APIError.unauthorized } let message = String(data: data, encoding: .utf8) throw APIError.httpError(statusCode: httpResponse.statusCode, message: message) } // Extract SID from Set-Cookie header // Format: "SID=; 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: - Playlist Management /// Creates a playlist owned by the logged-in user. /// /// Invidious answers 201 with `{"title", "playlistId"}` — not the whole /// playlist — so the returned value carries the new id and the title that /// was asked for, and nothing else. Callers that need the rest refetch. /// - Parameters: /// - title: The playlist title /// - privacy: Who may see it. Required by the server, which rejects the /// request outright when it is missing. /// - instance: The Invidious instance /// - sid: The session ID from login /// - Returns: The created playlist, with no videos func createPlaylist( title: String, privacy: PlaylistPrivacy, instance: Instance, sid: String ) async throws -> Playlist { struct CreateRequest: Encodable, Sendable { let title: String let privacy: String } struct CreateResponse: Decodable, Sendable { let playlistId: String let title: String? } let body = try JSONEncoder().encode(CreateRequest(title: title, privacy: privacy.rawValue)) let endpoint = GenericEndpoint( path: "/api/v1/auth/playlists", method: .post, headers: authHeaders(sid: sid), body: body ) let response: CreateResponse = try await httpClient.fetch(endpoint, baseURL: instance.url) return Playlist(id: .global(response.playlistId), title: response.title ?? title) } /// Edits a playlist's title, description or privacy. /// /// The title is sent even when unchanged: Invidious reads it out of the body /// without a default and answers 403 `Missing hash key: "title"` when it is /// absent (measured). The other two fields fall back to what is stored, so /// omitting them is how the server is told to keep them — which is the only /// way to leave a playlist's visibility alone, since the API never says /// which of unlisted and private it currently is. Answers 204 with no body. /// - Parameters: /// - id: The playlist ID /// - title: The title to store /// - description: The description to store, or `nil` to leave it alone /// - privacy: The visibility to store, or `nil` to leave it alone /// - instance: The Invidious instance /// - sid: The session ID from login func updatePlaylist( id: String, title: String, description: String?, privacy: PlaylistPrivacy?, instance: Instance, sid: String ) async throws { struct UpdateRequest: Encodable, Sendable { let title: String /// Omitted from the JSON when nil, which keeps the stored visibility. let privacy: String? /// Omitted from the JSON when nil, which is how the server is told /// to keep the description it already has. let description: String? } let body = try JSONEncoder().encode( UpdateRequest(title: title, privacy: privacy?.rawValue, description: description) ) let endpoint = GenericEndpoint( path: "/api/v1/auth/playlists/\(id)", method: .patch, headers: authHeaders(sid: sid), body: body ) try await httpClient.sendRequest(endpoint, baseURL: instance.url) } /// Deletes one of the user's playlists. Answers 204 with no body. /// - Parameters: /// - id: The playlist ID /// - instance: The Invidious instance /// - sid: The session ID from login func deletePlaylist(id: String, instance: Instance, sid: String) async throws { let endpoint = GenericEndpoint( path: "/api/v1/auth/playlists/\(id)", method: .delete, headers: authHeaders(sid: sid) ) try await httpClient.sendRequest(endpoint, baseURL: instance.url) } /// Appends a video to one of the user's playlists. /// /// The 201 response carries the row the server minted, including the /// `indexId` it can later be removed by. It is deliberately not decoded: /// the whole playlist is refetched after every mutation, and a row parsed /// from a response is one more thing that can disagree with the server. /// - Parameters: /// - playlistID: The playlist to add to /// - videoID: The video to add /// - instance: The Invidious instance /// - sid: The session ID from login func addVideoToPlaylist( playlistID: String, videoID: String, instance: Instance, sid: String ) async throws { struct AddRequest: Encodable, Sendable { let videoId: String } let body = try JSONEncoder().encode(AddRequest(videoId: videoID)) let endpoint = GenericEndpoint( path: "/api/v1/auth/playlists/\(playlistID)/videos", method: .post, headers: authHeaders(sid: sid), body: body ) try await httpClient.sendRequest(endpoint, baseURL: instance.url) } /// Removes one row from one of the user's playlists. /// /// Addressed by `indexId`, the per-entry token the fetch handed out — never /// by position. The server names the same row by it across refetches, so a /// removal cannot land on whatever moved into that position meanwhile. /// Answers 204 with no body. /// - Parameters: /// - playlistID: The playlist to remove from /// - indexID: The entry's `indexId` from ``userPlaylistSnapshot(id:instance:sid:)`` /// - instance: The Invidious instance /// - sid: The session ID from login func removeVideoFromPlaylist( playlistID: String, indexID: String, instance: Instance, sid: String ) async throws { let endpoint = GenericEndpoint( path: "/api/v1/auth/playlists/\(playlistID)/videos/\(indexID)", method: .delete, headers: authHeaders(sid: sid) ) try await httpClient.sendRequest(endpoint, baseURL: instance.url) } // MARK: - Watch History /// One page of the user's server-side watch history, newest first. /// /// The server answers a bare array of video IDs — no titles, no durations, /// nothing else — so a caller that wants to show them has to hydrate each /// one itself. Past the end the array is empty, which is the only way to /// tell there is no further page. /// - Parameters: /// - instance: The Invidious instance /// - sid: The session ID from login /// - page: 1-based page number /// - maxResults: How many IDs the page may hold /// - Returns: Video IDs, newest first func watchHistory( instance: Instance, sid: String, page: Int, maxResults: Int ) async throws -> [String] { let endpoint = GenericEndpoint( path: "/api/v1/auth/history", queryItems: [ URLQueryItem(name: "page", value: String(page)), URLQueryItem(name: "max_results", value: String(maxResults)) ], headers: authHeaders(sid: sid) ) let response: [String] = try await httpClient.fetch(endpoint, baseURL: instance.url) return response } /// Records a video as watched in the user's server-side history. /// /// Answers 204 with no body, and repeating it is not an error: the server /// moves an ID it already holds back to the front, which is what a re-watch /// should do to the ordering. /// - Parameters: /// - videoID: The YouTube video ID /// - instance: The Invidious instance /// - sid: The session ID from login func markWatched(videoID: String, instance: Instance, sid: String) async throws { let endpoint = GenericEndpoint( path: "/api/v1/auth/history/\(videoID)", method: .post, headers: authHeaders(sid: sid) ) try await httpClient.sendRequest(endpoint, baseURL: instance.url) } /// Removes one video from the user's server-side history. /// /// Idempotent — an ID the history never held answers 204 just the same. /// - Parameters: /// - videoID: The YouTube video ID /// - instance: The Invidious instance /// - sid: The session ID from login func removeWatched(videoID: String, instance: Instance, sid: String) async throws { let endpoint = GenericEndpoint( path: "/api/v1/auth/history/\(videoID)", method: .delete, headers: authHeaders(sid: sid) ) try await httpClient.sendRequest(endpoint, baseURL: instance.url) } /// The headers every authenticated write carries. `Content-Type` is set for /// all of them, including the bodiless ones, so a proxy in front of the /// instance cannot guess something else. private nonisolated func authHeaders(sid: String) -> [String: String] { ["Cookie": "SID=\(sid)", "Content-Type": "application/json"] } } // MARK: - Video Proxy extension InvidiousAPI { /// Rewrites a stream URL to route through the given instance. /// Replaces the scheme, host, and port with the instance's, keeping the original path and query. static func proxiedURL(instance: Instance, originalURL: URL) -> URL { var components = URLComponents(url: originalURL, resolvingAgainstBaseURL: true) ?? URLComponents() let instanceComponents = URLComponents(url: instance.url, resolvingAgainstBaseURL: true) components.scheme = instanceComponents?.scheme ?? "https" components.host = instanceComponents?.host components.port = instanceComponents?.port return components.url ?? originalURL } /// Checks if a URL points to a YouTube CDN (googlevideo.com or youtube.com). /// Only these URLs should be proxied — URLs already on the instance should not be. static func isYouTubeCDNURL(_ url: URL) -> Bool { guard let host = url.host?.lowercased() else { return false } return host.hasSuffix("googlevideo.com") || host.hasSuffix("youtube.com") } /// Performs a HEAD request to detect if a URL returns HTTP 403 (Forbidden). /// Used for auto-detecting when ISPs block direct YouTube CDN access. static func isForbidden(_ url: URL) async -> Bool { var request = URLRequest(url: url) request.httpMethod = "HEAD" request.timeoutInterval = 5 do { let (_, response) = try await URLSession.shared.data(for: request) if let httpResponse = response as? HTTPURLResponse { return httpResponse.statusCode == 403 } } catch { // Network errors are not 403 — don't proxy on timeout or other failures } return false } /// Applies proxy URL rewriting to an array of streams if needed. /// - Parameters: /// - streams: The original streams from the API /// - instance: The instance to proxy through /// - Returns: Streams with YouTube CDN URLs rewritten to go through the instance static func proxyStreamsIfNeeded(_ streams: [Stream], instance: Instance) async -> [Stream] { guard instance.supportsVideoProxying else { return streams } // Yattee Server does proxying server-side via the ?proxy=true query // on the videos endpoint (the converter mints signed /proxy/relay // URLs). Don't second-guess that here by host-rewriting CDN URLs. if instance.type == .yatteeServer { return streams } let firstCDNURL = streams.first(where: { isYouTubeCDNURL($0.url) })?.url let shouldProxy: Bool if instance.proxiesVideos { shouldProxy = true LoggingService.shared.info("Proxying streams through \(instance.displayName) (user-enabled)", category: .player) } else if let cdnURL = firstCDNURL { // Auto-detect via cached HEAD probe. Cache cuts videos 2..N down // to a synchronous lookup; on the first miss we pay the HEAD once. shouldProxy = await ProxyDetectionCache.shared.decision( for: instance, sampleURL: cdnURL, probe: { await isForbidden($0) } ) if shouldProxy { LoggingService.shared.info("Proxying streams through \(instance.displayName) (auto-detected 403)", category: .player) } } else { shouldProxy = false } // Even when we don't end up proxying, remember a sample CDN URL so // future videos can prewarm the probe before their API call returns. if !shouldProxy, let cdnURL = firstCDNURL { await ProxyDetectionCache.shared.record(decision: false, sampleURL: cdnURL, for: instance) } guard shouldProxy else { return streams } return streams.map { stream in if isYouTubeCDNURL(stream.url) { return stream.withURL(proxiedURL(instance: instance, originalURL: stream.url)) } return stream } } /// Kick off proxy auto-detection in the background using a previously-seen /// CDN URL for this instance. Cheap when there's nothing to do (no sample /// URL yet, or the decision is already cached). Call this when the player /// starts loading so the verdict is ready by the time streams come back. static func prewarmProxyDetection(for instance: Instance) async { guard instance.supportsVideoProxying, instance.type != .yatteeServer, !instance.proxiesVideos else { return } await ProxyDetectionCache.shared.prewarm( instance: instance, probe: { await isForbidden($0) } ) } } // MARK: - Redirect Blocker /// URLSession delegate that prevents automatic redirect following. /// Used for login requests where we need to capture the Set-Cookie header from the 302 response. private final class RedirectBlocker: NSObject, URLSessionTaskDelegate, Sendable { func urlSession( _ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void ) { // Return nil to stop the redirect and capture the original response completionHandler(nil) } } // MARK: - Channel Tab Response Models /// Response page for channel playlists. struct ChannelPlaylistsPage: Sendable { let playlists: [Playlist] let continuation: String? } /// Response page for channel videos (shorts, streams). struct ChannelVideosPage: Sendable { let videos: [Video] let continuation: String? } // MARK: - Auth Response Models /// Response for Invidious feed endpoint. struct InvidiousFeedResponse: Sendable { let videos: [Video] let hasMore: Bool } /// Subscription from Invidious auth API. struct InvidiousSubscription: Decodable, Sendable, Identifiable { let author: String let authorId: String let authorThumbnails: [InvidiousSubscriptionThumbnail]? var id: String { authorId } /// Thumbnail URL for the subscription avatar. /// This is set externally after fetching channel details since the subscriptions API /// doesn't return thumbnails. var thumbnailURL: URL? enum CodingKeys: String, CodingKey { case author, authorId, authorThumbnails } } struct InvidiousSubscriptionThumbnail: Decodable, Sendable { let url: String let width: Int? let height: Int? func thumbnailURL(baseURL: URL) -> URL? { // Handle protocol-relative URLs (starting with //) if url.hasPrefix("//") { return URL(string: "https:" + url) } // Handle relative paths by resolving against baseURL if url.hasPrefix("/") { return URL(string: url, relativeTo: baseURL)?.absoluteURL } return URL(string: url) } } // MARK: - InvidiousSubscription to Channel Conversion extension InvidiousSubscription { /// Converts this Invidious subscription to a Channel model for local storage. func toChannel(baseURL: URL) -> Channel { let thumbURL = thumbnailURL ?? authorThumbnails?.first?.thumbnailURL(baseURL: baseURL) return Channel( id: .global(authorId), name: author, thumbnailURL: thumbURL ) } } /// Playlist from Invidious authenticated API (/api/v1/auth/playlists). private struct InvidiousAuthPlaylist: Decodable, Sendable { let type: String? // "invidiousPlaylist" let title: String let playlistId: String let author: String? let description: String? let videoCount: Int let updated: Int64? let isListed: Bool? let videos: [InvidiousAuthPlaylistVideo]? nonisolated func toPlaylist(baseURL: URL) -> Playlist { Playlist( id: .global(playlistId), title: title, description: description, author: author.map { Author(id: "", name: $0) }, videoCount: videoCount, thumbnailURL: Thumbnail.reliableURL(for: videos?.first?.videoThumbnails?.first?.thumbnailURL(baseURL: baseURL)), videos: videos?.map { $0.toVideo(baseURL: baseURL) } ?? [] ) } } /// Video within an authenticated playlist response. private struct InvidiousAuthPlaylistVideo: Decodable, Sendable { let title: String let videoId: String let author: String let authorId: String let authorUrl: String? let videoThumbnails: [InvidiousThumbnail]? let index: Int? let indexId: String? let lengthSeconds: Int nonisolated func toVideo(baseURL: URL) -> Video { Video( id: .global(videoId), title: title, description: nil, author: Author(id: authorId, name: author), duration: TimeInterval(lengthSeconds), publishedAt: nil, publishedText: nil, viewCount: nil, likeCount: nil, thumbnails: videoThumbnails?.map { $0.toThumbnail(baseURL: baseURL) } ?? [], isLive: false, isUpcoming: false, scheduledStartTime: nil ) } } // MARK: - Invidious Sentinels /// Invidious answers with the moment it replied where it has no upload date: /// the row comes back as `"published": , "publishedText": "0 seconds /// ago"`, which renders as "in 0 seconds" beside a view count of zero. A /// publication that has not happened yet is one the instance does not know. enum InvidiousSentinel { /// The date and the text Invidious actually has, both dropped where the /// reading is that stamp. /// /// A premiere is left alone: it is scheduled, so its `published` is meant /// to be a date that has not arrived yet. static func published(_ raw: Int64?, text: String?, isUpcoming: Bool) -> (date: Date?, text: String?) { guard let raw else { return (nil, text) } let date = Date(timeIntervalSince1970: TimeInterval(raw)) if !isUpcoming, RelativeDateFormatter.isStampedNow(date) { return (nil, nil) } return (date, text) } } // MARK: - Invidious Response Models /// Response from the authenticated feed endpoint. /// The API returns an object with notifications and videos arrays. private struct InvidiousAuthFeedResponse: Decodable, Sendable { let notifications: [InvidiousVideo]? let videos: [InvidiousVideo] } private struct InvidiousVideo: Decodable, Sendable { let videoId: String let index: Int? /// Per-entry token from the authenticated playlist endpoints; the handle /// Invidious accepts when removing this exact row. let indexId: String? let title: String let description: String? let author: String let authorId: String let authorUrl: String? let lengthSeconds: Int let published: Int64? let publishedText: String? let viewCount: Int? let likeCount: Int? let videoThumbnails: [InvidiousThumbnail]? let liveNow: Bool? let isUpcoming: Bool? let premiereTimestamp: Int64? nonisolated func toVideo(baseURL: URL) -> Video { let publishedReading = InvidiousSentinel.published( published, text: publishedText, isUpcoming: isUpcoming ?? false ) return Video( id: .global(videoId), title: title, description: description, author: Author(id: authorId, name: author), duration: TimeInterval(lengthSeconds), publishedAt: publishedReading.date, publishedText: publishedReading.text, 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) } let publishedReading = InvidiousSentinel.published( published, text: publishedText, isUpcoming: isUpcoming ?? false ) 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: publishedReading.date, publishedText: publishedReading.text, 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, // An empty authorId (unlinked recommendation author) can't open a // channel, so it hides Go to Channel instead of failing to load. author: Author(id: authorId, name: author, hasRealChannelInfo: !authorId.isEmpty), 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) { // When audioTrack object is available, use it for language/name but determine // isOriginal from displayName and URL xtags (audioTrack alone is not reliable) if let audioTrack, audioTrack.id != nil || audioTrack.displayName != nil { let displayNameIndicatesOriginal = audioTrack.displayName? .localizedCaseInsensitiveContains("original") ?? false let xtagsIndicateOriginal = checkXtagsForOriginal() let isOriginal = displayNameIndicatesOriginal || xtagsIndicateOriginal return (audioTrack.id, audioTrack.displayName, isOriginal) } // Fall back to parsing from URL xtags parameter for audio streams guard isAudioOnly, let urlString = url else { return (nil, nil, false) } guard let xtags = parseXtags(from: urlString) else { return (nil, nil, false) } guard let langCode = xtags["lang"] else { return (nil, nil, false) } let contentType = xtags["acont"] let isOriginal = contentType == "original" let trackName = generateTrackName(langCode: langCode, contentType: contentType) return (langCode, trackName, isOriginal) } /// Check if the stream URL's xtags indicate this is the original audio track. private nonisolated func checkXtagsForOriginal() -> Bool { guard isAudioOnly, let urlString = url else { return false } guard let xtags = parseXtags(from: urlString) else { return false } return xtags["acont"] == "original" } /// Parse xtags key=value pairs from a URL string. /// Example xtags: "acont=original:drc=1:lang=en-US" private nonisolated func parseXtags(from urlString: String) -> [String: String]? { guard let xtagsRange = urlString.range(of: "xtags=") else { return nil } let xtagsStart = xtagsRange.upperBound let xtagsEnd = urlString[xtagsStart...].firstIndex(of: "&") ?? urlString.endIndex let xtagsEncoded = String(urlString[xtagsStart.. 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 InvidiousChannelVideosWithContinuation: Decodable, Sendable { let videos: [InvidiousVideo] let continuation: String? } private struct InvidiousChannelPlaylists: Decodable, Sendable { let playlists: [InvidiousChannelPlaylistItem] let continuation: String? } private struct InvidiousChannelPlaylistItem: Decodable, Sendable { let playlistId: String let title: String let author: String? let authorId: String? let videoCount: Int let playlistThumbnail: String? nonisolated func toPlaylist(baseURL: URL) -> Playlist { // Handle protocol-relative URLs, relative paths, and absolute URLs let thumbnailURL: URL? = playlistThumbnail.flatMap { urlString -> URL? in if urlString.hasPrefix("//") { return URL(string: "https:" + urlString) } else if urlString.hasPrefix("/") { return URL(string: urlString, relativeTo: baseURL)?.absoluteURL } return URL(string: urlString) } return Playlist( id: .global(playlistId), title: title, author: authorId.map { Author(id: $0, name: author ?? "") }, videoCount: videoCount, thumbnailURL: thumbnailURL, videos: [] ) } } /// Item within a playlist - can be a video or a parse error from Invidious. /// Invidious may return `"type": "parse-error"` for videos it failed to parse from YouTube. private enum InvidiousPlaylistItem: Decodable, Sendable { case video(InvidiousVideo) case parseError case unknown private enum CodingKeys: String, CodingKey { case type } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decodeIfPresent(String.self, forKey: .type) switch type { case "video", nil: // Videos may or may not have a type field do { self = .video(try InvidiousVideo(from: decoder)) } catch { self = .unknown } case "parse-error": self = .parseError default: self = .unknown } } /// The playlist index of the wrapped video, when the item is a video the /// server indexed. var playlistIndex: Int? { if case .video(let video) = self { return video.index } return nil } } private struct InvidiousPlaylist: Decodable, Sendable { let playlistId: String let title: String let description: String? let author: String? let authorId: String? let videoCount: Int /// The whole of what Invidious says about visibility: `privacy.public?`, /// serialised. Unlisted and private are both `false` and indistinguishable. let isListed: Bool? let videos: [InvidiousPlaylistItem]? nonisolated func toPlaylist(baseURL: URL) -> Playlist { // Extract only valid videos, skipping parse errors and unknown items let validVideos: [Video] = videos?.compactMap { item in if case .video(let video) = item { return video.toVideo(baseURL: baseURL) } return nil } ?? [] return Playlist( id: .global(playlistId), title: title, description: description, author: authorId.map { Author(id: $0, name: author ?? "") }, videoCount: videoCount, thumbnailURL: Thumbnail.reliableURL(for: validVideos.first?.thumbnails.first?.url), videos: validVideos ) } } private struct InvidiousComments: Decodable, Sendable { let comments: [InvidiousComment] let continuation: String? } private struct InvidiousComment: Decodable, Sendable { let commentId: String let author: String let authorId: String let authorThumbnails: [InvidiousThumbnail]? let authorIsChannelOwner: Bool? let content: String let published: Int64? let publishedText: String? let likeCount: Int? let isEdited: Bool? let isPinned: Bool? let creatorHeart: InvidiousCreatorHeart? let replies: InvidiousCommentReplies? nonisolated func toComment(baseURL: URL) -> Comment { Comment( id: commentId, author: Author( id: authorId, name: author, thumbnailURL: authorThumbnails?.first?.thumbnailURL(baseURL: baseURL) ), content: content, publishedAt: published.map { Date(timeIntervalSince1970: TimeInterval($0)) }, publishedText: publishedText, likeCount: likeCount, isPinned: isPinned ?? false, isCreatorComment: authorIsChannelOwner ?? false, hasCreatorHeart: creatorHeart != nil, replyCount: replies?.replyCount ?? 0, repliesContinuation: replies?.continuation ) } } private struct InvidiousCreatorHeart: Decodable, Sendable { let creatorThumbnail: String? let creatorName: String? } private struct InvidiousCommentReplies: Decodable, Sendable { let replyCount: Int let continuation: String? } private enum InvidiousSearchItem: Decodable, Sendable { case video(InvidiousVideo) case channel(InvidiousSearchChannel) case playlist(InvidiousSearchPlaylist) case unknown private enum CodingKeys: String, CodingKey { case type } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(String.self, forKey: .type) switch type { case "video": self = .video(try InvidiousVideo(from: decoder)) case "channel": self = .channel(try InvidiousSearchChannel(from: decoder)) case "playlist": self = .playlist(try InvidiousSearchPlaylist(from: decoder)) default: self = .unknown } } } private struct InvidiousSearchChannel: Decodable, Sendable { let authorId: String let author: String let description: String? let subCount: Int? let videoCount: Int? let authorThumbnails: [InvidiousThumbnail]? let authorVerified: Bool? nonisolated func toChannel(baseURL: URL) -> Channel { Channel( id: .global(authorId), name: author, description: description, subscriberCount: subCount, videoCount: videoCount, thumbnailURL: authorThumbnails?.authorThumbnailURL(baseURL: baseURL), isVerified: authorVerified ?? false ) } } private struct InvidiousSearchPlaylist: Decodable, Sendable { let playlistId: String let title: String let author: String? let authorId: String? let videoCount: Int let playlistThumbnail: String? let videos: [InvidiousVideo]? nonisolated func toPlaylist(baseURL: URL) -> Playlist { // Use playlistThumbnail from search results, fall back to first video thumbnail // Handle protocol-relative URLs, relative paths, and absolute URLs let thumbnailURL: URL? = playlistThumbnail.flatMap { urlString -> URL? in if urlString.hasPrefix("//") { return URL(string: "https:" + urlString) } else if urlString.hasPrefix("/") { return URL(string: urlString, relativeTo: baseURL)?.absoluteURL } return URL(string: urlString) } ?? videos?.first?.videoThumbnails?.first?.thumbnailURL(baseURL: baseURL) return Playlist( id: .global(playlistId), title: title, author: authorId.map { Author(id: $0, name: author ?? "") }, videoCount: videoCount, thumbnailURL: Thumbnail.reliableURL(for: thumbnailURL), videos: videos?.map { $0.toVideo(baseURL: baseURL) } ?? [] ) } } private struct InvidiousSuggestions: Decodable, Sendable { let query: String let suggestions: [String] } /// Response of `/api/v1/resolveurl`. private struct InvidiousResolvedURL: Decodable, Sendable { let ucid: String? }