mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
Related and queue rows in the player use the default menu context, so the pushed screen landed underneath the still-expanded player. Collapse on player state instead of the .player context. Recommendations without an authorId no longer offer Go to Channel, which opened an empty channel.
2094 lines
80 KiB
Swift
2094 lines
80 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)
|
|
}
|
|
|
|
/// `@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<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 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<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 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=<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)
|
|
}
|
|
|
|
// 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": <now>, "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..<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 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?
|
|
}
|