mirror of
https://github.com/yattee/yattee.git
synced 2026-02-19 17:29:45 +00:00
Playlists only loaded the first page of videos. Add full pagination for both Invidious and Piped playlist endpoints (public and authenticated). Deduplicate Invidious results by playlist index to handle its overlapping page windows. Also fix URL encoding in Invidious login to use strict form-encoding charset.
1573 lines
58 KiB
Swift
1573 lines
58 KiB
Swift
//
|
|
// InvidiousAPI.swift
|
|
// Yattee
|
|
//
|
|
// Invidious API implementation for YouTube content.
|
|
// API Documentation: https://docs.invidious.io/api/
|
|
//
|
|
|
|
@preconcurrency import Foundation
|
|
|
|
/// Invidious API client for fetching YouTube content.
|
|
actor InvidiousAPI: InstanceAPI {
|
|
private let httpClient: HTTPClient
|
|
|
|
init(httpClient: HTTPClient) {
|
|
self.httpClient = httpClient
|
|
}
|
|
|
|
// MARK: - InstanceAPI
|
|
|
|
func trending(instance: Instance) async throws -> [Video] {
|
|
let endpoint = GenericEndpoint.get("/api/v1/trending")
|
|
let response: [InvidiousVideo] = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
|
return response.map { $0.toVideo(baseURL: instance.url) }
|
|
}
|
|
|
|
func popular(instance: Instance) async throws -> [Video] {
|
|
let endpoint = GenericEndpoint.get("/api/v1/popular")
|
|
let response: [InvidiousVideo] = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
|
return response.map { $0.toVideo(baseURL: instance.url) }
|
|
}
|
|
|
|
func search(query: String, instance: Instance, page: Int, filters: SearchFilters = .defaults) async throws -> SearchResult {
|
|
var queryParams: [String: String] = [
|
|
"q": query,
|
|
"page": String(page),
|
|
"sort": filters.sort.rawValue,
|
|
"type": filters.type.rawValue
|
|
]
|
|
|
|
if filters.date != .any {
|
|
queryParams["date"] = filters.date.rawValue
|
|
}
|
|
if filters.duration != .any {
|
|
queryParams["duration"] = filters.duration.rawValue
|
|
}
|
|
|
|
let endpoint = GenericEndpoint.get("/api/v1/search", query: queryParams)
|
|
let response: [InvidiousSearchItem] = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
|
|
|
// Helper to detect playlist IDs that may be returned as "video" type
|
|
// YouTube playlist IDs start with: PL (user playlist), RD (mix), OL (offline mix), UU (uploads)
|
|
func isPlaylistID(_ id: String) -> Bool {
|
|
id.hasPrefix("PL") || id.hasPrefix("RD") || id.hasPrefix("OL") || id.hasPrefix("UU")
|
|
}
|
|
|
|
var videos: [Video] = []
|
|
var channels: [Channel] = []
|
|
var playlists: [Playlist] = []
|
|
var orderedItems: [OrderedSearchItem] = []
|
|
|
|
for item in response {
|
|
switch item {
|
|
case .video(let video):
|
|
if isPlaylistID(video.videoId) {
|
|
// Convert misidentified playlist
|
|
let playlist = Playlist(
|
|
id: .global(video.videoId),
|
|
title: video.title,
|
|
author: Author(id: video.authorId, name: video.author),
|
|
videoCount: 0,
|
|
thumbnailURL: video.videoThumbnails?.first?.thumbnailURL(baseURL: instance.url)
|
|
)
|
|
playlists.append(playlist)
|
|
orderedItems.append(.playlist(playlist))
|
|
} else {
|
|
let v = video.toVideo(baseURL: instance.url)
|
|
videos.append(v)
|
|
orderedItems.append(.video(v))
|
|
}
|
|
case .channel(let channel):
|
|
let c = channel.toChannel(baseURL: instance.url)
|
|
channels.append(c)
|
|
orderedItems.append(.channel(c))
|
|
case .playlist(let playlist):
|
|
let p = playlist.toPlaylist(baseURL: instance.url)
|
|
playlists.append(p)
|
|
orderedItems.append(.playlist(p))
|
|
case .unknown:
|
|
break
|
|
}
|
|
}
|
|
|
|
// Determine if there are more pages based on whether we got results
|
|
let hasResults = !videos.isEmpty || !channels.isEmpty || !playlists.isEmpty
|
|
return SearchResult(
|
|
videos: videos,
|
|
channels: channels,
|
|
playlists: playlists,
|
|
orderedItems: orderedItems,
|
|
nextPage: hasResults ? page + 1 : nil
|
|
)
|
|
}
|
|
|
|
func searchSuggestions(query: String, instance: Instance) async throws -> [String] {
|
|
let endpoint = GenericEndpoint.get("/api/v1/search/suggestions", query: [
|
|
"q": query
|
|
])
|
|
let response: InvidiousSuggestions = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
|
return response.suggestions
|
|
}
|
|
|
|
func video(id: String, instance: Instance) async throws -> Video {
|
|
let endpoint = GenericEndpoint.get("/api/v1/videos/\(id)")
|
|
let response: InvidiousVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
|
return response.toVideo(baseURL: instance.url)
|
|
}
|
|
|
|
func channel(id: String, instance: Instance) async throws -> Channel {
|
|
let endpoint = GenericEndpoint.get("/api/v1/channels/\(id)")
|
|
let response: InvidiousChannel = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
|
return response.toChannel(baseURL: instance.url)
|
|
}
|
|
|
|
func channelVideos(id: String, instance: Instance, continuation: String?) async throws -> ChannelVideosPage {
|
|
var query: [String: String] = [:]
|
|
if let continuation {
|
|
query["continuation"] = continuation
|
|
}
|
|
let endpoint = GenericEndpoint.get("/api/v1/channels/\(id)/videos", query: query)
|
|
let response: InvidiousChannelVideosWithContinuation = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
|
return ChannelVideosPage(
|
|
videos: response.videos.map { $0.toVideo(baseURL: instance.url) },
|
|
continuation: response.continuation
|
|
)
|
|
}
|
|
|
|
func channelPlaylists(id: String, instance: Instance, continuation: String?) async throws -> ChannelPlaylistsPage {
|
|
var query: [String: String] = [:]
|
|
if let continuation {
|
|
query["continuation"] = continuation
|
|
}
|
|
let endpoint = GenericEndpoint.get("/api/v1/channels/\(id)/playlists", query: query)
|
|
let response: InvidiousChannelPlaylists = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
|
return ChannelPlaylistsPage(
|
|
playlists: response.playlists.map { $0.toPlaylist(baseURL: instance.url) },
|
|
continuation: response.continuation
|
|
)
|
|
}
|
|
|
|
func channelShorts(id: String, instance: Instance, continuation: String?) async throws -> ChannelVideosPage {
|
|
var query: [String: String] = [:]
|
|
if let continuation {
|
|
query["continuation"] = continuation
|
|
}
|
|
let endpoint = GenericEndpoint.get("/api/v1/channels/\(id)/shorts", query: query)
|
|
let response: InvidiousChannelVideosWithContinuation = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
|
return ChannelVideosPage(
|
|
videos: response.videos.map { $0.toVideo(baseURL: instance.url) },
|
|
continuation: response.continuation
|
|
)
|
|
}
|
|
|
|
func channelStreams(id: String, instance: Instance, continuation: String?) async throws -> ChannelVideosPage {
|
|
var query: [String: String] = [:]
|
|
if let continuation {
|
|
query["continuation"] = continuation
|
|
}
|
|
let endpoint = GenericEndpoint.get("/api/v1/channels/\(id)/streams", query: query)
|
|
let response: InvidiousChannelVideosWithContinuation = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
|
return ChannelVideosPage(
|
|
videos: response.videos.map { $0.toVideo(baseURL: instance.url) },
|
|
continuation: response.continuation
|
|
)
|
|
}
|
|
|
|
func playlist(id: String, instance: Instance) async throws -> Playlist {
|
|
let firstEndpoint = GenericEndpoint.get("/api/v1/playlists/\(id)")
|
|
let firstResponse: InvidiousPlaylist = try await httpClient.fetch(firstEndpoint, baseURL: instance.url)
|
|
var allVideos = firstResponse.videos ?? []
|
|
let maxPages = 50
|
|
|
|
if firstResponse.videoCount > 0 {
|
|
var page = 2
|
|
while page <= maxPages {
|
|
let endpoint = GenericEndpoint.get("/api/v1/playlists/\(id)", query: ["page": String(page)])
|
|
let response: InvidiousPlaylist = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
|
let pageVideos = response.videos ?? []
|
|
if pageVideos.isEmpty { break }
|
|
allVideos.append(contentsOf: pageVideos)
|
|
page += 1
|
|
}
|
|
}
|
|
|
|
// Invidious pagination uses overlapping pages — deduplicate by playlist index
|
|
var seenIndices = Set<Int>()
|
|
allVideos = allVideos.filter { item in
|
|
if case .video(let video) = item, let index = video.index {
|
|
return seenIndices.insert(index).inserted
|
|
}
|
|
return true
|
|
}
|
|
|
|
return InvidiousPlaylist(
|
|
playlistId: firstResponse.playlistId,
|
|
title: firstResponse.title,
|
|
description: firstResponse.description,
|
|
author: firstResponse.author,
|
|
authorId: firstResponse.authorId,
|
|
videoCount: firstResponse.videoCount,
|
|
videos: allVideos
|
|
).toPlaylist(baseURL: instance.url)
|
|
}
|
|
|
|
/// Fetches a user's playlist using authenticated endpoint.
|
|
/// Required for private playlists (IVPL* IDs).
|
|
/// - Parameters:
|
|
/// - id: The playlist ID
|
|
/// - instance: The Invidious instance
|
|
/// - sid: The session ID from login
|
|
/// - Returns: The playlist with videos
|
|
func userPlaylist(id: String, instance: Instance, sid: String) async throws -> Playlist {
|
|
let headers = ["Cookie": "SID=\(sid)"]
|
|
let firstEndpoint = GenericEndpoint(
|
|
path: "/api/v1/auth/playlists/\(id)",
|
|
queryItems: nil,
|
|
headers: headers
|
|
)
|
|
let firstResponse: InvidiousPlaylist = try await httpClient.fetch(firstEndpoint, baseURL: instance.url)
|
|
var allVideos = firstResponse.videos ?? []
|
|
let maxPages = 50
|
|
|
|
if firstResponse.videoCount > 0 {
|
|
var page = 2
|
|
while page <= maxPages {
|
|
let endpoint = GenericEndpoint(
|
|
path: "/api/v1/auth/playlists/\(id)",
|
|
queryItems: [URLQueryItem(name: "page", value: String(page))],
|
|
headers: headers
|
|
)
|
|
let response: InvidiousPlaylist = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
|
let pageVideos = response.videos ?? []
|
|
if pageVideos.isEmpty { break }
|
|
allVideos.append(contentsOf: pageVideos)
|
|
page += 1
|
|
}
|
|
}
|
|
|
|
// Invidious pagination uses overlapping pages — deduplicate by playlist index
|
|
var seenIndices = Set<Int>()
|
|
allVideos = allVideos.filter { item in
|
|
if case .video(let video) = item, let index = video.index {
|
|
return seenIndices.insert(index).inserted
|
|
}
|
|
return true
|
|
}
|
|
|
|
return InvidiousPlaylist(
|
|
playlistId: firstResponse.playlistId,
|
|
title: firstResponse.title,
|
|
description: firstResponse.description,
|
|
author: firstResponse.author,
|
|
authorId: firstResponse.authorId,
|
|
videoCount: firstResponse.videoCount,
|
|
videos: allVideos
|
|
).toPlaylist(baseURL: instance.url)
|
|
}
|
|
|
|
func comments(videoID: String, instance: Instance, continuation: String?) async throws -> CommentsPage {
|
|
var query: [String: String] = [:]
|
|
if let continuation {
|
|
query["continuation"] = continuation
|
|
}
|
|
let endpoint = GenericEndpoint.get("/api/v1/comments/\(videoID)", query: query)
|
|
do {
|
|
let response: InvidiousComments = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
|
return CommentsPage(
|
|
comments: response.comments.map { $0.toComment(baseURL: instance.url) },
|
|
continuation: response.continuation
|
|
)
|
|
} catch APIError.notFound {
|
|
throw APIError.commentsDisabled
|
|
}
|
|
}
|
|
|
|
func streams(videoID: String, instance: Instance) async throws -> [Stream] {
|
|
let endpoint = GenericEndpoint.get("/api/v1/videos/\(videoID)")
|
|
let response: InvidiousVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
|
return response.toStreams(instanceBaseURL: instance.url)
|
|
}
|
|
|
|
func captions(videoID: String, instance: Instance) async throws -> [Caption] {
|
|
let endpoint = GenericEndpoint.get("/api/v1/videos/\(videoID)")
|
|
let response: InvidiousVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
|
return response.toCaptions(baseURL: instance.url)
|
|
}
|
|
|
|
func channelSearch(id: String, query: String, instance: Instance, page: Int) async throws -> ChannelSearchPage {
|
|
let endpoint = GenericEndpoint.get("/api/v1/channels/\(id)/search", query: [
|
|
"q": query,
|
|
"page": String(page)
|
|
])
|
|
let response: [InvidiousSearchItem] = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
|
|
|
var items: [ChannelSearchItem] = []
|
|
|
|
for item in response {
|
|
switch item {
|
|
case .video(let video):
|
|
items.append(.video(video.toVideo(baseURL: instance.url)))
|
|
case .playlist(let playlist):
|
|
items.append(.playlist(playlist.toPlaylist(baseURL: instance.url)))
|
|
case .channel, .unknown:
|
|
// Channel search only returns videos and playlists
|
|
break
|
|
}
|
|
}
|
|
|
|
// Has more pages if we got results
|
|
let hasResults = !items.isEmpty
|
|
return ChannelSearchPage(items: items, nextPage: hasResults ? page + 1 : nil)
|
|
}
|
|
|
|
/// Fetches video details, streams, and captions in a single API call.
|
|
/// This is more efficient than calling video(), streams(), and captions() separately
|
|
/// since they all fetch from the same endpoint.
|
|
func videoWithStreamsAndCaptions(id: String, instance: Instance) async throws -> (video: Video, streams: [Stream], captions: [Caption]) {
|
|
let result = try await videoWithStreamsAndCaptionsAndStoryboards(id: id, instance: instance)
|
|
return (video: result.video, streams: result.streams, captions: result.captions)
|
|
}
|
|
|
|
func videoWithStreamsAndCaptionsAndStoryboards(id: String, instance: Instance) async throws -> (video: Video, streams: [Stream], captions: [Caption], storyboards: [Storyboard]) {
|
|
let endpoint = GenericEndpoint.get("/api/v1/videos/\(id)")
|
|
let response: InvidiousVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
|
return (
|
|
video: response.toVideo(baseURL: instance.url),
|
|
streams: response.toStreams(instanceBaseURL: instance.url),
|
|
captions: response.toCaptions(baseURL: instance.url),
|
|
storyboards: response.toStoryboards(instanceBaseURL: instance.url)
|
|
)
|
|
}
|
|
|
|
// MARK: - Authentication
|
|
|
|
/// Logs in to an Invidious instance and returns the session ID (SID).
|
|
/// - Parameters:
|
|
/// - email: The user's email/username
|
|
/// - password: The user's password
|
|
/// - instance: The Invidious instance to log in to
|
|
/// - Returns: The session ID (SID) cookie value
|
|
func login(email: String, password: String, instance: Instance) async throws -> String {
|
|
// Build form-urlencoded body
|
|
let bodyComponents = [
|
|
"email": email,
|
|
"password": password,
|
|
"action": "signin"
|
|
]
|
|
let formAllowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-._~"))
|
|
let bodyString = bodyComponents
|
|
.map { "\($0.key)=\($0.value.addingPercentEncoding(withAllowedCharacters: formAllowed) ?? $0.value)" }
|
|
.joined(separator: "&")
|
|
|
|
guard let bodyData = bodyString.data(using: .utf8) else {
|
|
throw APIError.invalidRequest
|
|
}
|
|
|
|
// Build the request manually to handle cookies
|
|
var request = URLRequest(url: instance.url.appendingPathComponent("login"))
|
|
request.httpMethod = "POST"
|
|
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
|
request.httpBody = bodyData
|
|
|
|
// Use a session that doesn't follow redirects so we can capture the Set-Cookie header
|
|
let sessionConfig = URLSessionConfiguration.ephemeral
|
|
let session = URLSession(configuration: sessionConfig, delegate: RedirectBlocker(), delegateQueue: nil)
|
|
let (data, response) = try await session.data(for: request)
|
|
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
throw APIError.unknown("Invalid response type")
|
|
}
|
|
|
|
// Check for successful login (302 redirect or 200 OK)
|
|
// Invidious returns 302 on success, redirecting to home
|
|
guard httpResponse.statusCode == 200 || httpResponse.statusCode == 302 else {
|
|
// Check for error message in response body
|
|
if let responseText = String(data: data, encoding: .utf8),
|
|
responseText.contains("Wrong username") || responseText.contains("Invalid") || responseText.contains("incorrect") {
|
|
throw APIError.unauthorized
|
|
}
|
|
let message = String(data: data, encoding: .utf8)
|
|
throw APIError.httpError(statusCode: httpResponse.statusCode, message: message)
|
|
}
|
|
|
|
// Extract SID from Set-Cookie header
|
|
// Format: "SID=<value>; domain=...; expires=...; ..."
|
|
// HTTP/2 uses lowercase headers, so we need to check case-insensitively
|
|
var cookieValue: String?
|
|
|
|
// Try direct access first (works for HTTP/1.1)
|
|
if let value = httpResponse.allHeaderFields["Set-Cookie"] as? String {
|
|
cookieValue = value
|
|
} else if let value = httpResponse.value(forHTTPHeaderField: "Set-Cookie") {
|
|
cookieValue = value
|
|
} else {
|
|
// Iterate through all headers to find set-cookie (case-insensitive)
|
|
for (key, value) in httpResponse.allHeaderFields {
|
|
if let keyStr = key as? String,
|
|
keyStr.lowercased() == "set-cookie",
|
|
let valueStr = value as? String {
|
|
cookieValue = valueStr
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
guard let cookies = cookieValue else {
|
|
throw APIError.unauthorized
|
|
}
|
|
|
|
return try extractSID(from: cookies)
|
|
}
|
|
|
|
/// Extracts SID from Set-Cookie header value.
|
|
private func extractSID(from cookieHeader: String) throws -> String {
|
|
// Look for SID= in the cookie string
|
|
let pattern = "SID=([^;]+)"
|
|
guard let regex = try? NSRegularExpression(pattern: pattern),
|
|
let match = regex.firstMatch(in: cookieHeader, range: NSRange(cookieHeader.startIndex..., in: cookieHeader)),
|
|
let sidRange = Range(match.range(at: 1), in: cookieHeader) else {
|
|
throw APIError.unauthorized
|
|
}
|
|
return String(cookieHeader[sidRange])
|
|
}
|
|
|
|
/// Fetches the subscription feed for a logged-in user.
|
|
/// - Parameters:
|
|
/// - instance: The Invidious instance
|
|
/// - sid: The session ID from login
|
|
/// - page: Page number for pagination (1-based)
|
|
/// - maxResults: Maximum number of videos to return per page
|
|
/// - Returns: Array of videos from subscribed channels
|
|
func feed(instance: Instance, sid: String, page: Int = 1, maxResults: Int = 50) async throws -> InvidiousFeedResponse {
|
|
let endpoint = GenericEndpoint.get("/api/v1/auth/feed", query: [
|
|
"max_results": String(maxResults),
|
|
"page": String(page)
|
|
])
|
|
|
|
// Fetch raw data first for debugging
|
|
let rawData = try await httpClient.fetchData(
|
|
endpoint,
|
|
baseURL: instance.url,
|
|
customHeaders: ["Cookie": "SID=\(sid)"]
|
|
)
|
|
|
|
// Decode the response
|
|
let response: InvidiousAuthFeedResponse
|
|
do {
|
|
response = try JSONDecoder().decode(InvidiousAuthFeedResponse.self, from: rawData)
|
|
} catch {
|
|
let rawString = String(data: rawData, encoding: .utf8) ?? "Unable to decode"
|
|
LoggingService.shared.error(
|
|
"Failed to decode Invidious feed. Raw response (first 1000 chars): \(String(rawString.prefix(1000)))",
|
|
category: .api
|
|
)
|
|
throw error
|
|
}
|
|
|
|
// Combine notifications and videos arrays - Invidious returns feed items in notifications
|
|
let allVideos = (response.notifications ?? []) + response.videos
|
|
let videos = allVideos.map { $0.toVideo(baseURL: instance.url) }
|
|
|
|
LoggingService.shared.debug(
|
|
"Invidious feed: \(response.notifications?.count ?? 0) notifications + \(response.videos.count) videos = \(videos.count) total",
|
|
category: .api
|
|
)
|
|
|
|
// Invidious feed doesn't provide explicit "hasMore", assume there's more until we get empty page
|
|
return InvidiousFeedResponse(videos: videos, hasMore: !videos.isEmpty)
|
|
}
|
|
|
|
/// Fetches the user's subscriptions.
|
|
/// - Parameters:
|
|
/// - instance: The Invidious instance
|
|
/// - sid: The session ID from login
|
|
/// - Returns: Array of subscribed channels
|
|
func subscriptions(instance: Instance, sid: String) async throws -> [InvidiousSubscription] {
|
|
let endpoint = GenericEndpoint.get("/api/v1/auth/subscriptions")
|
|
let response: [InvidiousSubscription] = try await httpClient.fetch(
|
|
endpoint,
|
|
baseURL: instance.url,
|
|
customHeaders: ["Cookie": "SID=\(sid)"]
|
|
)
|
|
return response
|
|
}
|
|
|
|
/// Fetches the user's playlists.
|
|
/// - Parameters:
|
|
/// - instance: The Invidious instance
|
|
/// - sid: The session ID from login
|
|
/// - Returns: Array of user's playlists
|
|
func userPlaylists(instance: Instance, sid: String) async throws -> [Playlist] {
|
|
let endpoint = GenericEndpoint.get("/api/v1/auth/playlists")
|
|
let response: [InvidiousAuthPlaylist] = try await httpClient.fetch(
|
|
endpoint,
|
|
baseURL: instance.url,
|
|
customHeaders: ["Cookie": "SID=\(sid)"]
|
|
)
|
|
return response.map { $0.toPlaylist(baseURL: instance.url) }
|
|
}
|
|
|
|
// MARK: - Subscription Management
|
|
|
|
/// Subscribes to a channel on the Invidious instance.
|
|
/// - Parameters:
|
|
/// - channelID: The YouTube channel ID (UCID) to subscribe to
|
|
/// - instance: The Invidious instance
|
|
/// - sid: The session ID from login
|
|
/// - Throws: APIError if the subscription fails
|
|
func subscribe(to channelID: String, instance: Instance, sid: String) async throws {
|
|
let endpoint = GenericEndpoint.post("/api/v1/auth/subscriptions/\(channelID)")
|
|
try await httpClient.sendRequest(
|
|
endpoint,
|
|
baseURL: instance.url,
|
|
customHeaders: ["Cookie": "SID=\(sid)"]
|
|
)
|
|
}
|
|
|
|
/// Unsubscribes from a channel on the Invidious instance.
|
|
/// - Parameters:
|
|
/// - channelID: The YouTube channel ID (UCID) to unsubscribe from
|
|
/// - instance: The Invidious instance
|
|
/// - sid: The session ID from login
|
|
/// - Throws: APIError if the unsubscription fails
|
|
func unsubscribe(from channelID: String, instance: Instance, sid: String) async throws {
|
|
let endpoint = GenericEndpoint.delete("/api/v1/auth/subscriptions/\(channelID)")
|
|
try await httpClient.sendRequest(
|
|
endpoint,
|
|
baseURL: instance.url,
|
|
customHeaders: ["Cookie": "SID=\(sid)"]
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Redirect Blocker
|
|
|
|
/// URLSession delegate that prevents automatic redirect following.
|
|
/// Used for login requests where we need to capture the Set-Cookie header from the 302 response.
|
|
private final class RedirectBlocker: NSObject, URLSessionTaskDelegate, Sendable {
|
|
func urlSession(
|
|
_ session: URLSession,
|
|
task: URLSessionTask,
|
|
willPerformHTTPRedirection response: HTTPURLResponse,
|
|
newRequest request: URLRequest,
|
|
completionHandler: @escaping (URLRequest?) -> Void
|
|
) {
|
|
// Return nil to stop the redirect and capture the original response
|
|
completionHandler(nil)
|
|
}
|
|
}
|
|
|
|
// MARK: - Channel Tab Response Models
|
|
|
|
/// Response page for channel playlists.
|
|
struct ChannelPlaylistsPage: Sendable {
|
|
let playlists: [Playlist]
|
|
let continuation: String?
|
|
}
|
|
|
|
/// Response page for channel videos (shorts, streams).
|
|
struct ChannelVideosPage: Sendable {
|
|
let videos: [Video]
|
|
let continuation: String?
|
|
}
|
|
|
|
// MARK: - Auth Response Models
|
|
|
|
/// Response for Invidious feed endpoint.
|
|
struct InvidiousFeedResponse: Sendable {
|
|
let videos: [Video]
|
|
let hasMore: Bool
|
|
}
|
|
|
|
/// Subscription from Invidious auth API.
|
|
struct InvidiousSubscription: Decodable, Sendable, Identifiable {
|
|
let author: String
|
|
let authorId: String
|
|
let authorThumbnails: [InvidiousSubscriptionThumbnail]?
|
|
|
|
var id: String { authorId }
|
|
|
|
/// Thumbnail URL for the subscription avatar.
|
|
/// This is set externally after fetching channel details since the subscriptions API
|
|
/// doesn't return thumbnails.
|
|
var thumbnailURL: URL?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case author, authorId, authorThumbnails
|
|
}
|
|
}
|
|
|
|
struct InvidiousSubscriptionThumbnail: Decodable, Sendable {
|
|
let url: String
|
|
let width: Int?
|
|
let height: Int?
|
|
|
|
func thumbnailURL(baseURL: URL) -> URL? {
|
|
// Handle protocol-relative URLs (starting with //)
|
|
if url.hasPrefix("//") {
|
|
return URL(string: "https:" + url)
|
|
}
|
|
// Handle relative paths by resolving against baseURL
|
|
if url.hasPrefix("/") {
|
|
return URL(string: url, relativeTo: baseURL)?.absoluteURL
|
|
}
|
|
return URL(string: url)
|
|
}
|
|
}
|
|
|
|
// MARK: - InvidiousSubscription to Channel Conversion
|
|
|
|
extension InvidiousSubscription {
|
|
/// Converts this Invidious subscription to a Channel model for local storage.
|
|
func toChannel(baseURL: URL) -> Channel {
|
|
let thumbURL = thumbnailURL ?? authorThumbnails?.first?.thumbnailURL(baseURL: baseURL)
|
|
return Channel(
|
|
id: .global(authorId),
|
|
name: author,
|
|
thumbnailURL: thumbURL
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Playlist from Invidious authenticated API (/api/v1/auth/playlists).
|
|
private struct InvidiousAuthPlaylist: Decodable, Sendable {
|
|
let type: String? // "invidiousPlaylist"
|
|
let title: String
|
|
let playlistId: String
|
|
let author: String?
|
|
let description: String?
|
|
let videoCount: Int
|
|
let updated: Int64?
|
|
let isListed: Bool?
|
|
let videos: [InvidiousAuthPlaylistVideo]?
|
|
|
|
nonisolated func toPlaylist(baseURL: URL) -> Playlist {
|
|
Playlist(
|
|
id: .global(playlistId),
|
|
title: title,
|
|
description: description,
|
|
author: author.map { Author(id: "", name: $0) },
|
|
videoCount: videoCount,
|
|
thumbnailURL: videos?.first?.videoThumbnails?.first?.thumbnailURL(baseURL: baseURL),
|
|
videos: videos?.map { $0.toVideo(baseURL: baseURL) } ?? []
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Video within an authenticated playlist response.
|
|
private struct InvidiousAuthPlaylistVideo: Decodable, Sendable {
|
|
let title: String
|
|
let videoId: String
|
|
let author: String
|
|
let authorId: String
|
|
let authorUrl: String?
|
|
let videoThumbnails: [InvidiousThumbnail]?
|
|
let index: Int?
|
|
let indexId: String?
|
|
let lengthSeconds: Int
|
|
|
|
nonisolated func toVideo(baseURL: URL) -> Video {
|
|
Video(
|
|
id: .global(videoId),
|
|
title: title,
|
|
description: nil,
|
|
author: Author(id: authorId, name: author),
|
|
duration: TimeInterval(lengthSeconds),
|
|
publishedAt: nil,
|
|
publishedText: nil,
|
|
viewCount: nil,
|
|
likeCount: nil,
|
|
thumbnails: videoThumbnails?.map { $0.toThumbnail(baseURL: baseURL) } ?? [],
|
|
isLive: false,
|
|
isUpcoming: false,
|
|
scheduledStartTime: nil
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Invidious Response Models
|
|
|
|
/// Response from the authenticated feed endpoint.
|
|
/// The API returns an object with notifications and videos arrays.
|
|
private struct InvidiousAuthFeedResponse: Decodable, Sendable {
|
|
let notifications: [InvidiousVideo]?
|
|
let videos: [InvidiousVideo]
|
|
}
|
|
|
|
private struct InvidiousVideo: Decodable, Sendable {
|
|
let videoId: String
|
|
let index: Int?
|
|
let title: String
|
|
let description: String?
|
|
let author: String
|
|
let authorId: String
|
|
let authorUrl: String?
|
|
let lengthSeconds: Int
|
|
let published: Int64?
|
|
let publishedText: String?
|
|
let viewCount: Int?
|
|
let likeCount: Int?
|
|
let videoThumbnails: [InvidiousThumbnail]?
|
|
let liveNow: Bool?
|
|
let isUpcoming: Bool?
|
|
let premiereTimestamp: Int64?
|
|
|
|
nonisolated func toVideo(baseURL: URL) -> Video {
|
|
Video(
|
|
id: .global(videoId),
|
|
title: title,
|
|
description: description,
|
|
author: Author(id: authorId, name: author),
|
|
duration: TimeInterval(lengthSeconds),
|
|
publishedAt: published.map { Date(timeIntervalSince1970: TimeInterval($0)) },
|
|
publishedText: publishedText,
|
|
viewCount: viewCount,
|
|
likeCount: likeCount,
|
|
thumbnails: videoThumbnails?.map { $0.toThumbnail(baseURL: baseURL) } ?? [],
|
|
isLive: liveNow ?? false,
|
|
isUpcoming: isUpcoming ?? false,
|
|
scheduledStartTime: premiereTimestamp.map { Date(timeIntervalSince1970: TimeInterval($0)) }
|
|
)
|
|
}
|
|
}
|
|
|
|
private struct InvidiousVideoDetails: Decodable, Sendable {
|
|
let videoId: String
|
|
let title: String
|
|
let description: String?
|
|
let descriptionHtml: String?
|
|
let author: String
|
|
let authorId: String
|
|
let authorThumbnails: [InvidiousThumbnail]?
|
|
let subCountText: String?
|
|
let lengthSeconds: Int
|
|
let published: Int64?
|
|
let publishedText: String?
|
|
let viewCount: Int?
|
|
let likeCount: Int?
|
|
let videoThumbnails: [InvidiousThumbnail]?
|
|
let liveNow: Bool?
|
|
let isUpcoming: Bool?
|
|
let premiereTimestamp: Int64?
|
|
let hlsUrl: String?
|
|
let dashUrl: String?
|
|
let formatStreams: [InvidiousFormatStream]?
|
|
let adaptiveFormats: [InvidiousAdaptiveFormat]?
|
|
let captions: [InvidiousCaption]?
|
|
let storyboards: [InvidiousStoryboard]?
|
|
let recommendedVideos: [InvidiousRecommendedVideo]?
|
|
|
|
/// Parses subscriber count from text like "1.78M" or "500K"
|
|
nonisolated var subscriberCount: Int? {
|
|
guard let text = subCountText else { return nil }
|
|
let cleaned = text.trimmingCharacters(in: .whitespaces).uppercased()
|
|
|
|
let multiplier: Double
|
|
var numericPart = cleaned
|
|
|
|
if cleaned.hasSuffix("B") {
|
|
multiplier = 1_000_000_000
|
|
numericPart = String(cleaned.dropLast())
|
|
} else if cleaned.hasSuffix("M") {
|
|
multiplier = 1_000_000
|
|
numericPart = String(cleaned.dropLast())
|
|
} else if cleaned.hasSuffix("K") {
|
|
multiplier = 1_000
|
|
numericPart = String(cleaned.dropLast())
|
|
} else {
|
|
multiplier = 1
|
|
}
|
|
|
|
guard let value = Double(numericPart) else { return nil }
|
|
return Int(value * multiplier)
|
|
}
|
|
|
|
nonisolated func toVideo(baseURL: URL) -> Video {
|
|
// Convert recommended videos, limiting to 12
|
|
let related: [Video]? = recommendedVideos?.prefix(12).map { $0.toVideo(baseURL: baseURL) }
|
|
|
|
return Video(
|
|
id: .global(videoId),
|
|
title: title,
|
|
description: description,
|
|
author: Author(
|
|
id: authorId,
|
|
name: author,
|
|
thumbnailURL: authorThumbnails?.authorThumbnailURL(baseURL: baseURL),
|
|
subscriberCount: subscriberCount
|
|
),
|
|
duration: TimeInterval(lengthSeconds),
|
|
publishedAt: published.map { Date(timeIntervalSince1970: TimeInterval($0)) },
|
|
publishedText: publishedText,
|
|
viewCount: viewCount,
|
|
likeCount: likeCount,
|
|
thumbnails: videoThumbnails?.map { $0.toThumbnail(baseURL: baseURL) } ?? [],
|
|
isLive: liveNow ?? false,
|
|
isUpcoming: isUpcoming ?? false,
|
|
scheduledStartTime: premiereTimestamp.map { Date(timeIntervalSince1970: TimeInterval($0)) },
|
|
relatedVideos: related
|
|
)
|
|
}
|
|
|
|
nonisolated func toStreams(instanceBaseURL: URL) -> [Stream] {
|
|
var streams: [Stream] = []
|
|
|
|
// Add HLS stream (adaptive - works for both live and on-demand content)
|
|
if let hlsUrl, let url = URL(string: hlsUrl) {
|
|
streams.append(Stream(
|
|
url: url,
|
|
resolution: nil,
|
|
format: "hls",
|
|
isLive: liveNow ?? false,
|
|
mimeType: "application/x-mpegURL"
|
|
))
|
|
}
|
|
|
|
// Add DASH stream (adaptive - supports VP9, AV1, higher qualities)
|
|
// MPV can play DASH manifests directly
|
|
if let dashUrl {
|
|
// Resolve relative DASH URLs against instance base URL
|
|
let resolvedDashURL: URL?
|
|
if dashUrl.hasPrefix("/") {
|
|
resolvedDashURL = URL(string: dashUrl, relativeTo: instanceBaseURL)?.absoluteURL
|
|
} else {
|
|
resolvedDashURL = URL(string: dashUrl)
|
|
}
|
|
if let url = resolvedDashURL {
|
|
streams.append(Stream(
|
|
url: url,
|
|
resolution: nil,
|
|
format: "dash",
|
|
isLive: liveNow ?? false,
|
|
mimeType: "application/dash+xml"
|
|
))
|
|
}
|
|
}
|
|
|
|
// Add format streams (combined audio+video)
|
|
if let formatStreams {
|
|
streams.append(contentsOf: formatStreams.compactMap { $0.toStream(isLive: liveNow ?? false) })
|
|
}
|
|
|
|
// Add adaptive formats (separate audio/video)
|
|
if let adaptiveFormats {
|
|
streams.append(contentsOf: adaptiveFormats.compactMap { $0.toStream(isLive: liveNow ?? false) })
|
|
}
|
|
|
|
return streams
|
|
}
|
|
|
|
nonisolated func toCaptions(baseURL: URL) -> [Caption] {
|
|
guard let captions else { return [] }
|
|
return captions.compactMap { $0.toCaption(baseURL: baseURL) }
|
|
}
|
|
|
|
nonisolated func toStoryboards(instanceBaseURL: URL) -> [Storyboard] {
|
|
storyboards?.compactMap { $0.toStoryboard(instanceBaseURL: instanceBaseURL) } ?? []
|
|
}
|
|
}
|
|
|
|
private struct InvidiousCaption: Decodable, Sendable {
|
|
let label: String
|
|
let languageCode: String
|
|
let url: String
|
|
|
|
nonisolated func toCaption(baseURL: URL) -> Caption? {
|
|
// Prepend /companion to route through companion service
|
|
let companionURL = "/companion" + url
|
|
guard let fullURL = URL(string: companionURL, relativeTo: baseURL) else { return nil }
|
|
return Caption(
|
|
label: label,
|
|
languageCode: languageCode,
|
|
url: fullURL
|
|
)
|
|
}
|
|
}
|
|
|
|
private struct InvidiousStoryboard: Decodable, Sendable {
|
|
let url: String?
|
|
let templateUrl: String?
|
|
let width: Int
|
|
let height: Int
|
|
let count: Int
|
|
let interval: Int
|
|
let storyboardWidth: Int
|
|
let storyboardHeight: Int
|
|
let storyboardCount: Int
|
|
|
|
nonisolated func toStoryboard(instanceBaseURL: URL) -> Storyboard? {
|
|
// templateUrl is the direct YouTube URL (may be blocked)
|
|
// url is the proxied URL through the instance (preferred)
|
|
guard templateUrl != nil || url != nil else { return nil }
|
|
return Storyboard(
|
|
proxyUrl: url,
|
|
templateUrl: templateUrl ?? "",
|
|
instanceBaseURL: instanceBaseURL,
|
|
width: width,
|
|
height: height,
|
|
count: count,
|
|
interval: interval,
|
|
storyboardWidth: storyboardWidth,
|
|
storyboardHeight: storyboardHeight,
|
|
storyboardCount: storyboardCount
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Recommended video from Invidious video details response.
|
|
private struct InvidiousRecommendedVideo: Decodable, Sendable {
|
|
let videoId: String
|
|
let title: String
|
|
let author: String
|
|
let authorId: String
|
|
let authorUrl: String?
|
|
let videoThumbnails: [InvidiousThumbnail]?
|
|
let lengthSeconds: Int
|
|
let viewCountText: String?
|
|
let viewCount: Int?
|
|
|
|
nonisolated func toVideo(baseURL: URL) -> Video {
|
|
// Parse view count from text if numeric viewCount not available
|
|
let views: Int? = viewCount ?? parseViewCount(from: viewCountText)
|
|
|
|
return Video(
|
|
id: .global(videoId),
|
|
title: title,
|
|
description: nil,
|
|
author: Author(id: authorId, name: author),
|
|
duration: TimeInterval(lengthSeconds),
|
|
publishedAt: nil,
|
|
publishedText: nil,
|
|
viewCount: views,
|
|
likeCount: nil,
|
|
thumbnails: videoThumbnails?.map { $0.toThumbnail(baseURL: baseURL) } ?? [],
|
|
isLive: false,
|
|
isUpcoming: false,
|
|
scheduledStartTime: nil
|
|
)
|
|
}
|
|
|
|
/// Parses view count from text like "1.2M views" or "500K views".
|
|
private nonisolated func parseViewCount(from text: String?) -> Int? {
|
|
guard let text else { return nil }
|
|
let cleaned = text
|
|
.replacingOccurrences(of: " views", with: "")
|
|
.replacingOccurrences(of: ",", with: "")
|
|
.trimmingCharacters(in: .whitespaces)
|
|
.uppercased()
|
|
|
|
let multiplier: Double
|
|
var numericPart = cleaned
|
|
|
|
if cleaned.hasSuffix("B") {
|
|
multiplier = 1_000_000_000
|
|
numericPart = String(cleaned.dropLast())
|
|
} else if cleaned.hasSuffix("M") {
|
|
multiplier = 1_000_000
|
|
numericPart = String(cleaned.dropLast())
|
|
} else if cleaned.hasSuffix("K") {
|
|
multiplier = 1_000
|
|
numericPart = String(cleaned.dropLast())
|
|
} else {
|
|
multiplier = 1
|
|
}
|
|
|
|
guard let value = Double(numericPart) else { return nil }
|
|
return Int(value * multiplier)
|
|
}
|
|
}
|
|
|
|
private struct InvidiousFormatStream: Decodable, Sendable {
|
|
let url: String?
|
|
let itag: String?
|
|
let type: String?
|
|
let quality: String?
|
|
let container: String?
|
|
let encoding: String?
|
|
let resolution: String?
|
|
let size: String?
|
|
let fps: Int?
|
|
|
|
nonisolated func toStream(isLive: Bool = false) -> Stream? {
|
|
guard let urlString = url, let streamUrl = URL(string: urlString) else { return nil }
|
|
|
|
// Parse audio codec from mimeType if present
|
|
// Format: "video/mp4; codecs=\"avc1.42001E, mp4a.40.2\""
|
|
let audioCodec = parseAudioCodec(from: type)
|
|
|
|
// Extract video codec from encoding field, or fall back to parsing from type
|
|
let videoCodec = encoding ?? parseVideoCodec(from: type)
|
|
|
|
return Stream(
|
|
url: streamUrl,
|
|
resolution: resolution.flatMap { StreamResolution(heightLabel: $0) },
|
|
format: container ?? "unknown",
|
|
videoCodec: videoCodec,
|
|
audioCodec: audioCodec,
|
|
isLive: isLive,
|
|
mimeType: type,
|
|
fps: fps
|
|
)
|
|
}
|
|
|
|
/// Parse video codec from mimeType codecs string.
|
|
/// Format: "video/mp4; codecs=\"avc1.42001E, mp4a.40.2\""
|
|
private nonisolated func parseVideoCodec(from mimeType: String?) -> String? {
|
|
guard let mimeType else { return nil }
|
|
|
|
guard let codecsRange = mimeType.range(of: "codecs=\"") else { return nil }
|
|
|
|
let codecsStart = codecsRange.upperBound
|
|
guard let codecsEnd = mimeType[codecsStart...].firstIndex(of: "\"") else { return nil }
|
|
|
|
let codecsString = mimeType[codecsStart..<codecsEnd]
|
|
let codecs = codecsString.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
|
|
|
|
// Find video codec (avc1, vp9, av01, etc.)
|
|
for codec in codecs {
|
|
let lowercased = codec.lowercased()
|
|
if lowercased.starts(with: "avc") {
|
|
return "avc1"
|
|
} else if lowercased.starts(with: "vp9") || lowercased.starts(with: "vp09") {
|
|
return "vp9"
|
|
} else if lowercased.starts(with: "av01") || lowercased.starts(with: "av1") {
|
|
return "av1"
|
|
} else if lowercased.starts(with: "hev") || lowercased.starts(with: "hvc") {
|
|
return "hevc"
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
/// Parse audio codec from mimeType codecs string.
|
|
/// Format streams always contain both video and audio.
|
|
private nonisolated func parseAudioCodec(from mimeType: String?) -> String? {
|
|
guard let mimeType else { return nil }
|
|
|
|
// Look for codecs in the mimeType string
|
|
// Example: "video/mp4; codecs=\"avc1.42001E, mp4a.40.2\""
|
|
guard let codecsRange = mimeType.range(of: "codecs=\"") else {
|
|
// No codecs specified, but formatStreams always have audio
|
|
// Default to aac for mp4 container
|
|
if mimeType.contains("video/mp4") {
|
|
return "aac"
|
|
}
|
|
return nil
|
|
}
|
|
|
|
let codecsStart = codecsRange.upperBound
|
|
guard let codecsEnd = mimeType[codecsStart...].firstIndex(of: "\"") else { return nil }
|
|
|
|
let codecsString = mimeType[codecsStart..<codecsEnd]
|
|
let codecs = codecsString.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
|
|
|
|
// Find audio codec (mp4a, opus, vorbis, etc.)
|
|
for codec in codecs {
|
|
let lowercased = codec.lowercased()
|
|
if lowercased.starts(with: "mp4a") {
|
|
return "aac"
|
|
} else if lowercased.contains("opus") {
|
|
return "opus"
|
|
} else if lowercased.contains("vorbis") {
|
|
return "vorbis"
|
|
}
|
|
}
|
|
|
|
// formatStreams are muxed, assume audio exists
|
|
return "aac"
|
|
}
|
|
}
|
|
|
|
private struct InvidiousAdaptiveFormat: Decodable, Sendable {
|
|
let url: String?
|
|
let itag: String?
|
|
let type: String?
|
|
let container: String?
|
|
let encoding: String?
|
|
let resolution: String?
|
|
let bitrate: String?
|
|
let clen: String?
|
|
let audioTrack: InvidiousAudioTrack?
|
|
let audioQuality: String?
|
|
let fps: Int?
|
|
|
|
var isAudioOnly: Bool {
|
|
type?.starts(with: "audio/") ?? false
|
|
}
|
|
|
|
nonisolated func toStream(isLive: Bool = false) -> Stream? {
|
|
guard let urlString = url, let streamUrl = URL(string: urlString) else { return nil }
|
|
|
|
// Try to get audio language/track from audioTrack object first,
|
|
// then fall back to parsing from URL xtags parameter
|
|
let (language, trackName, isOriginal) = parseAudioInfo()
|
|
|
|
// Extract video codec from encoding field, or fall back to parsing from type
|
|
let videoCodec: String? = if isAudioOnly {
|
|
nil
|
|
} else {
|
|
encoding ?? parseVideoCodec(from: type)
|
|
}
|
|
|
|
return Stream(
|
|
url: streamUrl,
|
|
resolution: isAudioOnly ? nil : resolution.flatMap { StreamResolution(heightLabel: $0) },
|
|
format: container ?? "unknown",
|
|
videoCodec: videoCodec,
|
|
audioCodec: isAudioOnly ? encoding : nil,
|
|
bitrate: bitrate.flatMap { Int($0) },
|
|
fileSize: clen.flatMap { Int64($0) },
|
|
isAudioOnly: isAudioOnly,
|
|
isLive: isLive,
|
|
mimeType: type,
|
|
audioLanguage: language,
|
|
audioTrackName: trackName,
|
|
isOriginalAudio: isOriginal,
|
|
fps: isAudioOnly ? nil : fps
|
|
)
|
|
}
|
|
|
|
/// Parse video codec from mimeType codecs string.
|
|
private nonisolated func parseVideoCodec(from mimeType: String?) -> String? {
|
|
guard let mimeType else { return nil }
|
|
|
|
guard let codecsRange = mimeType.range(of: "codecs=\"") else { return nil }
|
|
|
|
let codecsStart = codecsRange.upperBound
|
|
guard let codecsEnd = mimeType[codecsStart...].firstIndex(of: "\"") else { return nil }
|
|
|
|
let codecsString = mimeType[codecsStart..<codecsEnd]
|
|
let codecs = codecsString.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
|
|
|
|
// Find video codec (avc1, vp9, av01, etc.)
|
|
for codec in codecs {
|
|
let lowercased = codec.lowercased()
|
|
if lowercased.starts(with: "avc") {
|
|
return "avc1"
|
|
} else if lowercased.starts(with: "vp9") || lowercased.starts(with: "vp09") {
|
|
return "vp9"
|
|
} else if lowercased.starts(with: "av01") || lowercased.starts(with: "av1") {
|
|
return "av1"
|
|
} else if lowercased.starts(with: "hev") || lowercased.starts(with: "hvc") {
|
|
return "hevc"
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
/// Parse audio language, track name, and whether it's original from audioTrack object or URL xtags.
|
|
/// URL xtags format: xtags=acont%3Doriginal%3Alang%3Den-US or xtags=acont%3Ddubbed-auto%3Alang%3Dde-DE
|
|
private nonisolated func parseAudioInfo() -> (language: String?, trackName: String?, isOriginal: Bool) {
|
|
// Prefer explicit audioTrack if available
|
|
if let audioTrack, audioTrack.id != nil || audioTrack.displayName != nil {
|
|
// Can't determine if original from audioTrack alone, assume not
|
|
return (audioTrack.id, audioTrack.displayName, false)
|
|
}
|
|
|
|
// Parse from URL xtags parameter for audio streams
|
|
guard isAudioOnly, let urlString = url else {
|
|
return (nil, nil, false)
|
|
}
|
|
|
|
// Find xtags parameter in URL
|
|
guard let xtagsRange = urlString.range(of: "xtags=") else {
|
|
return (nil, nil, false)
|
|
}
|
|
|
|
let xtagsStart = xtagsRange.upperBound
|
|
let xtagsEnd = urlString[xtagsStart...].firstIndex(of: "&") ?? urlString.endIndex
|
|
let xtagsEncoded = String(urlString[xtagsStart..<xtagsEnd])
|
|
|
|
// URL decode the xtags value
|
|
guard let xtags = xtagsEncoded.removingPercentEncoding else {
|
|
return (nil, nil, false)
|
|
}
|
|
|
|
// Parse key=value pairs separated by colons
|
|
// Example: "acont=original:drc=1:lang=en-US" or "acont=dubbed-auto:lang=de-DE"
|
|
let pairs = xtags.split(separator: ":").reduce(into: [String: String]()) { result, pair in
|
|
let parts = pair.split(separator: "=", maxSplits: 1)
|
|
if parts.count == 2 {
|
|
result[String(parts[0])] = String(parts[1])
|
|
}
|
|
}
|
|
|
|
guard let langCode = pairs["lang"] else {
|
|
return (nil, nil, false)
|
|
}
|
|
|
|
// Check if this is the original audio track
|
|
let contentType = pairs["acont"]
|
|
let isOriginal = contentType == "original"
|
|
|
|
// Generate display name from language code and content type
|
|
let trackName = generateTrackName(langCode: langCode, contentType: contentType)
|
|
|
|
return (langCode, trackName, isOriginal)
|
|
}
|
|
|
|
/// Generate a human-readable track name from language code and content type.
|
|
private nonisolated func generateTrackName(langCode: String, contentType: String?) -> String {
|
|
let locale = Locale(identifier: "en")
|
|
let languageName: String
|
|
|
|
// Try to get language name from the code (handles both "en" and "en-US" formats)
|
|
if let name = locale.localizedString(forIdentifier: langCode) {
|
|
languageName = name
|
|
} else {
|
|
// Fall back to just the language part for codes like "en-US"
|
|
let baseCode = String(langCode.split(separator: "-").first ?? Substring(langCode))
|
|
languageName = locale.localizedString(forLanguageCode: baseCode) ?? langCode
|
|
}
|
|
|
|
// Add suffix based on content type
|
|
switch contentType {
|
|
case "original":
|
|
return "\(languageName) (Original)"
|
|
case "dubbed-auto":
|
|
return "\(languageName) (Auto-dubbed)"
|
|
case "dubbed":
|
|
return "\(languageName) (Dubbed)"
|
|
default:
|
|
return languageName
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct InvidiousAudioTrack: Decodable, Sendable {
|
|
let id: String?
|
|
let displayName: String?
|
|
}
|
|
|
|
private struct InvidiousThumbnail: Decodable, Sendable {
|
|
let quality: String?
|
|
let url: String
|
|
let width: Int?
|
|
let height: Int?
|
|
|
|
/// Resolves the thumbnail URL, handling absolute, protocol-relative, and relative paths.
|
|
/// - Parameter baseURL: The instance base URL for resolving relative paths
|
|
/// - Returns: The resolved absolute URL, or nil if the URL is invalid
|
|
nonisolated func thumbnailURL(baseURL: URL) -> URL? {
|
|
// Handle protocol-relative URLs (starting with //)
|
|
if url.hasPrefix("//") {
|
|
return URL(string: "https:" + url)
|
|
}
|
|
// Handle relative paths (starting with /)
|
|
if url.hasPrefix("/") {
|
|
return URL(string: url, relativeTo: baseURL)?.absoluteURL
|
|
}
|
|
// Absolute URL
|
|
return URL(string: url)
|
|
}
|
|
|
|
nonisolated func toThumbnail(baseURL: URL) -> Thumbnail {
|
|
Thumbnail(
|
|
url: thumbnailURL(baseURL: baseURL) ?? URL(string: "about:blank")!,
|
|
quality: quality.map { qualityFromString($0) } ?? inferQualityFromSize(),
|
|
width: width,
|
|
height: height
|
|
)
|
|
}
|
|
|
|
private nonisolated func qualityFromString(_ quality: String) -> Thumbnail.Quality {
|
|
switch quality {
|
|
case "maxres", "maxresdefault": return .maxres
|
|
case "sddefault", "sd": return .standard
|
|
case "high": return .high
|
|
case "medium": return .medium
|
|
default: return .default
|
|
}
|
|
}
|
|
|
|
private nonisolated func inferQualityFromSize() -> Thumbnail.Quality {
|
|
guard let width else { return .default }
|
|
switch width {
|
|
case 0..<200: return .default
|
|
case 200..<400: return .medium
|
|
case 400..<800: return .high
|
|
case 800..<1200: return .standard
|
|
default: return .maxres
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension Array where Element == InvidiousThumbnail {
|
|
/// Selects an appropriate author thumbnail (at least 100px for good quality on Retina displays).
|
|
/// - Parameter baseURL: The instance base URL for resolving relative paths
|
|
/// - Returns: The resolved thumbnail URL
|
|
func authorThumbnailURL(baseURL: URL) -> URL? {
|
|
// Prefer 100px or larger for good quality on Retina displays, fall back to largest available
|
|
let preferred = first { ($0.width ?? 0) >= 100 }
|
|
return (preferred ?? last)?.thumbnailURL(baseURL: baseURL)
|
|
}
|
|
}
|
|
|
|
private struct InvidiousChannel: Decodable, Sendable {
|
|
let authorId: String
|
|
let author: String
|
|
let description: String?
|
|
let subCount: Int?
|
|
let totalViews: Int64?
|
|
let authorThumbnails: [InvidiousThumbnail]?
|
|
let authorBanners: [InvidiousThumbnail]?
|
|
let authorVerified: Bool?
|
|
|
|
nonisolated func toChannel(baseURL: URL) -> Channel {
|
|
Channel(
|
|
id: .global(authorId),
|
|
name: author,
|
|
description: description,
|
|
subscriberCount: subCount,
|
|
thumbnailURL: authorThumbnails?.authorThumbnailURL(baseURL: baseURL),
|
|
bannerURL: authorBanners?.last?.thumbnailURL(baseURL: baseURL),
|
|
isVerified: authorVerified ?? false
|
|
)
|
|
}
|
|
}
|
|
|
|
private struct InvidiousChannelVideos: Decodable, Sendable {
|
|
let videos: [InvidiousVideo]
|
|
}
|
|
|
|
private struct InvidiousChannelVideosWithContinuation: Decodable, Sendable {
|
|
let videos: [InvidiousVideo]
|
|
let continuation: String?
|
|
}
|
|
|
|
private struct InvidiousChannelPlaylists: Decodable, Sendable {
|
|
let playlists: [InvidiousChannelPlaylistItem]
|
|
let continuation: String?
|
|
}
|
|
|
|
private struct InvidiousChannelPlaylistItem: Decodable, Sendable {
|
|
let playlistId: String
|
|
let title: String
|
|
let author: String?
|
|
let authorId: String?
|
|
let videoCount: Int
|
|
let playlistThumbnail: String?
|
|
|
|
nonisolated func toPlaylist(baseURL: URL) -> Playlist {
|
|
// Handle protocol-relative URLs, relative paths, and absolute URLs
|
|
let thumbnailURL: URL? = playlistThumbnail.flatMap { urlString -> URL? in
|
|
if urlString.hasPrefix("//") {
|
|
return URL(string: "https:" + urlString)
|
|
} else if urlString.hasPrefix("/") {
|
|
return URL(string: urlString, relativeTo: baseURL)?.absoluteURL
|
|
}
|
|
return URL(string: urlString)
|
|
}
|
|
|
|
return Playlist(
|
|
id: .global(playlistId),
|
|
title: title,
|
|
author: authorId.map { Author(id: $0, name: author ?? "") },
|
|
videoCount: videoCount,
|
|
thumbnailURL: thumbnailURL,
|
|
videos: []
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Item within a playlist - can be a video or a parse error from Invidious.
|
|
/// Invidious may return `"type": "parse-error"` for videos it failed to parse from YouTube.
|
|
private enum InvidiousPlaylistItem: Decodable, Sendable {
|
|
case video(InvidiousVideo)
|
|
case parseError
|
|
case unknown
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case type
|
|
}
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
let type = try container.decodeIfPresent(String.self, forKey: .type)
|
|
|
|
switch type {
|
|
case "video", nil:
|
|
// Videos may or may not have a type field
|
|
do {
|
|
self = .video(try InvidiousVideo(from: decoder))
|
|
} catch {
|
|
self = .unknown
|
|
}
|
|
case "parse-error":
|
|
self = .parseError
|
|
default:
|
|
self = .unknown
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct InvidiousPlaylist: Decodable, Sendable {
|
|
let playlistId: String
|
|
let title: String
|
|
let description: String?
|
|
let author: String?
|
|
let authorId: String?
|
|
let videoCount: Int
|
|
let videos: [InvidiousPlaylistItem]?
|
|
|
|
nonisolated func toPlaylist(baseURL: URL) -> Playlist {
|
|
// Extract only valid videos, skipping parse errors and unknown items
|
|
let validVideos: [Video] = videos?.compactMap { item in
|
|
if case .video(let video) = item {
|
|
return video.toVideo(baseURL: baseURL)
|
|
}
|
|
return nil
|
|
} ?? []
|
|
|
|
return Playlist(
|
|
id: .global(playlistId),
|
|
title: title,
|
|
description: description,
|
|
author: authorId.map { Author(id: $0, name: author ?? "") },
|
|
videoCount: videoCount,
|
|
thumbnailURL: validVideos.first?.thumbnails.first?.url,
|
|
videos: validVideos
|
|
)
|
|
}
|
|
}
|
|
|
|
private struct InvidiousComments: Decodable, Sendable {
|
|
let comments: [InvidiousComment]
|
|
let continuation: String?
|
|
}
|
|
|
|
private struct InvidiousComment: Decodable, Sendable {
|
|
let commentId: String
|
|
let author: String
|
|
let authorId: String
|
|
let authorThumbnails: [InvidiousThumbnail]?
|
|
let authorIsChannelOwner: Bool?
|
|
let content: String
|
|
let published: Int64?
|
|
let publishedText: String?
|
|
let likeCount: Int?
|
|
let isEdited: Bool?
|
|
let isPinned: Bool?
|
|
let creatorHeart: InvidiousCreatorHeart?
|
|
let replies: InvidiousCommentReplies?
|
|
|
|
nonisolated func toComment(baseURL: URL) -> Comment {
|
|
Comment(
|
|
id: commentId,
|
|
author: Author(
|
|
id: authorId,
|
|
name: author,
|
|
thumbnailURL: authorThumbnails?.first?.thumbnailURL(baseURL: baseURL)
|
|
),
|
|
content: content,
|
|
publishedAt: published.map { Date(timeIntervalSince1970: TimeInterval($0)) },
|
|
publishedText: publishedText,
|
|
likeCount: likeCount,
|
|
isPinned: isPinned ?? false,
|
|
isCreatorComment: authorIsChannelOwner ?? false,
|
|
hasCreatorHeart: creatorHeart != nil,
|
|
replyCount: replies?.replyCount ?? 0,
|
|
repliesContinuation: replies?.continuation
|
|
)
|
|
}
|
|
}
|
|
|
|
private struct InvidiousCreatorHeart: Decodable, Sendable {
|
|
let creatorThumbnail: String?
|
|
let creatorName: String?
|
|
}
|
|
|
|
private struct InvidiousCommentReplies: Decodable, Sendable {
|
|
let replyCount: Int
|
|
let continuation: String?
|
|
}
|
|
|
|
private enum InvidiousSearchItem: Decodable, Sendable {
|
|
case video(InvidiousVideo)
|
|
case channel(InvidiousSearchChannel)
|
|
case playlist(InvidiousSearchPlaylist)
|
|
case unknown
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case type
|
|
}
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
let type = try container.decode(String.self, forKey: .type)
|
|
|
|
switch type {
|
|
case "video":
|
|
self = .video(try InvidiousVideo(from: decoder))
|
|
case "channel":
|
|
self = .channel(try InvidiousSearchChannel(from: decoder))
|
|
case "playlist":
|
|
self = .playlist(try InvidiousSearchPlaylist(from: decoder))
|
|
default:
|
|
self = .unknown
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct InvidiousSearchChannel: Decodable, Sendable {
|
|
let authorId: String
|
|
let author: String
|
|
let description: String?
|
|
let subCount: Int?
|
|
let videoCount: Int?
|
|
let authorThumbnails: [InvidiousThumbnail]?
|
|
let authorVerified: Bool?
|
|
|
|
nonisolated func toChannel(baseURL: URL) -> Channel {
|
|
Channel(
|
|
id: .global(authorId),
|
|
name: author,
|
|
description: description,
|
|
subscriberCount: subCount,
|
|
videoCount: videoCount,
|
|
thumbnailURL: authorThumbnails?.authorThumbnailURL(baseURL: baseURL),
|
|
isVerified: authorVerified ?? false
|
|
)
|
|
}
|
|
}
|
|
|
|
private struct InvidiousSearchPlaylist: Decodable, Sendable {
|
|
let playlistId: String
|
|
let title: String
|
|
let author: String?
|
|
let authorId: String?
|
|
let videoCount: Int
|
|
let playlistThumbnail: String?
|
|
let videos: [InvidiousVideo]?
|
|
|
|
nonisolated func toPlaylist(baseURL: URL) -> Playlist {
|
|
// Use playlistThumbnail from search results, fall back to first video thumbnail
|
|
// Handle protocol-relative URLs, relative paths, and absolute URLs
|
|
let thumbnailURL: URL? = playlistThumbnail.flatMap { urlString -> URL? in
|
|
if urlString.hasPrefix("//") {
|
|
return URL(string: "https:" + urlString)
|
|
} else if urlString.hasPrefix("/") {
|
|
return URL(string: urlString, relativeTo: baseURL)?.absoluteURL
|
|
}
|
|
return URL(string: urlString)
|
|
} ?? videos?.first?.videoThumbnails?.first?.thumbnailURL(baseURL: baseURL)
|
|
|
|
return Playlist(
|
|
id: .global(playlistId),
|
|
title: title,
|
|
author: authorId.map { Author(id: $0, name: author ?? "") },
|
|
videoCount: videoCount,
|
|
thumbnailURL: thumbnailURL,
|
|
videos: videos?.map { $0.toVideo(baseURL: baseURL) } ?? []
|
|
)
|
|
}
|
|
}
|
|
|
|
private struct InvidiousSuggestions: Decodable, Sendable {
|
|
let query: String
|
|
let suggestions: [String]
|
|
}
|