Files
yattee/Yattee/Services/API/InvidiousAPI.swift
Arkadiusz Fal b4855f5be9 Read a playlist back before writing over it
Ten review findings in the remote-playlist work, all of them the same
shape: something acted on what it remembered instead of what the server
says now.

- A confirmed-gone playlist is forgotten whole — snapshot, list entry,
  and both ordering fences — so a refresh that started before the delete
  can no longer republish it, and a list refetch that fails offline no
  longer leaves it on screen.
- `.accountsDidChange` discards account-scoped content when the account
  it belonged to is no longer the effective one, as the status
  notification already did.
- A tab that stops being offered no longer stays selected: the browse
  view moves to the first available one, which reloads by itself.
- A Handoff account id is accepted only when that account is on the
  instance the playlist lives on.
- Composite writes moved up into `AccountService`, over a transport whose
  every method is exactly one request: create-with-privacy, rename, and
  set-description. The credential is rechecked between each pair, so a
  sign-out between the two halves of a create or an edit can no longer
  send the second half under the previous user's secret.
- Editing a playlist no longer forces it private. Invidious serialises
  visibility as `isListed` — `privacy.public?`, so unlisted and private
  are indistinguishable — which the snapshot now carries; the picker
  starts there, and sends nothing at all unless it was moved. Invidious
  keeps the stored visibility when the PATCH omits it (measured), so an
  untouched picker leaves it exactly as it was.
- Tapping Edit on a server-side playlist awaits an authenticated refetch
  first and populates the form from it; a failed refetch shows an error
  and no sheet.
- Playlist rows are keyed by row rather than by video id, which two rows
  of the same video share.
- The add-to-playlist sheet offers only accounts whose instance is
  enabled and that hold a credential, alongside the write capability.
2026-09-02 23:59:31 +02:00

1988 lines
76 KiB
Swift

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