Files
yattee/Yattee/Services/API/YatteeServerAPI.swift
2026-02-08 18:33:56 +01:00

1613 lines
57 KiB
Swift

//
// YatteeServerAPI.swift
// Yattee
//
// Yattee Server API implementation for YouTube content.
// The Yattee server provides an Invidious-compatible API, so this shares
// response models with InvidiousAPI but handles unsupported endpoints.
//
@preconcurrency import Foundation
/// Yattee Server API client for fetching YouTube content.
/// Uses Invidious-compatible JSON format for responses.
actor YatteeServerAPI: InstanceAPI {
private let httpClient: HTTPClient
/// Optional Basic Auth header value for authenticated requests.
/// Set this when the server requires authentication.
private var authHeader: String?
init(httpClient: HTTPClient) {
self.httpClient = httpClient
}
/// Sets the Basic Auth header to use for all subsequent requests.
/// - Parameter authHeader: The Authorization header value (e.g., "Basic dXNlcjpwYXNz")
func setAuthHeader(_ authHeader: String?) {
self.authHeader = authHeader
}
/// Builds custom headers including auth if available.
private func buildHeaders(additionalHeaders: [String: String]? = nil) -> [String: String]? {
var headers = additionalHeaders ?? [:]
if let authHeader {
headers["Authorization"] = authHeader
}
return headers.isEmpty ? nil : headers
}
// MARK: - InstanceAPI
func trending(instance: Instance) async throws -> [Video] {
// Yattee server proxies trending from Invidious if configured
let endpoint = GenericEndpoint.get("/api/v1/trending")
let response: [YatteeVideo] = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
return response.map { $0.toVideo() }
}
func popular(instance: Instance) async throws -> [Video] {
// Yattee server proxies popular from Invidious if configured
let endpoint = GenericEndpoint.get("/api/v1/popular")
let response: [YatteeVideo] = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
return response.map { $0.toVideo() }
}
func search(query: String, instance: Instance, page: Int, filters: SearchFilters = .defaults) async throws -> SearchResult {
var queryParams: [String: String] = [
"q": query,
"page": String(page),
"type": filters.type.rawValue
]
if filters.sort != .relevance {
queryParams["sort"] = filters.sort.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: [YatteeSearchItem] = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
// 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
)
playlists.append(playlist)
orderedItems.append(.playlist(playlist))
} else {
let v = video.toVideo()
videos.append(v)
orderedItems.append(.video(v))
}
case .channel(let channel):
let c = channel.toChannel()
channels.append(c)
orderedItems.append(.channel(c))
case .playlist(let playlist):
let p = playlist.toPlaylist()
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
])
return try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
}
func video(id: String, instance: Instance) async throws -> Video {
let endpoint = GenericEndpoint.get("/api/v1/videos/\(id)")
let response: YatteeVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
return response.toVideo()
}
func channel(id: String, instance: Instance) async throws -> Channel {
let endpoint = GenericEndpoint.get("/api/v1/channels/\(id)")
let response: YatteeChannel = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
return response.toChannel()
}
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: YatteeChannelVideosWithContinuation = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
return ChannelVideosPage(
videos: response.videos.map { $0.toVideo() },
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: YatteeChannelPlaylists = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
return ChannelPlaylistsPage(
playlists: response.playlists.map { $0.toPlaylist() },
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: YatteeChannelVideosWithContinuation = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
return ChannelVideosPage(
videos: response.videos.map { $0.toVideo() },
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: YatteeChannelVideosWithContinuation = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
return ChannelVideosPage(
videos: response.videos.map { $0.toVideo() },
continuation: response.continuation
)
}
func playlist(id: String, instance: Instance) async throws -> Playlist {
let endpoint = GenericEndpoint.get("/api/v1/playlists/\(id)")
let response: YatteePlaylist = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
return response.toPlaylist()
}
func comments(videoID: String, instance: Instance, continuation: String?) async throws -> CommentsPage {
// Yattee server proxies comments through Invidious if configured
var query: [String: String] = [:]
if let continuation {
query["continuation"] = continuation
}
let endpoint = GenericEndpoint.get("/api/v1/comments/\(videoID)", query: query)
do {
let response: YatteeComments = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
return CommentsPage(
comments: response.comments.map { $0.toComment() },
continuation: response.continuation
)
} catch APIError.notFound {
throw APIError.commentsDisabled
} catch APIError.httpError(statusCode: 503, _) {
// Invidious not configured on server
throw APIError.commentsDisabled
}
}
func streams(videoID: String, instance: Instance) async throws -> [Stream] {
let endpoint = GenericEndpoint.get("/api/v1/videos/\(videoID)")
let response: YatteeVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
return response.toStreams()
}
func captions(videoID: String, instance: Instance) async throws -> [Caption] {
let endpoint = GenericEndpoint.get("/api/v1/videos/\(videoID)")
let response: YatteeVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
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)
])
// Yattee Server returns {"videos": [...]} wrapper, not a plain array
let response: YatteeChannelSearchResponse = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
var items: [ChannelSearchItem] = []
for video in response.videos {
items.append(.video(video.toVideo()))
}
// 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.
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: YatteeVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
return (
video: response.toVideo(),
streams: response.toStreams(),
captions: response.toCaptions(baseURL: instance.url),
storyboards: response.toStoryboards(instanceBaseURL: instance.url)
)
}
// MARK: - Proxy Streams for Downloads
/// Fetches streams with URLs that proxy through the Yattee Server for faster LAN downloads.
/// The proxy URLs point to the server's /proxy/fast/{video_id}?itag=X endpoint instead of
/// directly to YouTube CDN, allowing the server to download at full speed and serve locally.
func proxyStreams(videoID: String, instance: Instance) async throws -> [Stream] {
let endpoint = GenericEndpoint.get("/api/v1/videos/\(videoID)", query: ["proxy": "true"])
let response: YatteeVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
return response.toStreams()
}
/// Fetches video details, proxy streams, captions, and storyboards in a single API call.
/// Use this for downloads to get streams that route through the server.
func videoWithProxyStreamsAndCaptionsAndStoryboards(id: String, instance: Instance) async throws -> (video: Video, streams: [Stream], captions: [Caption], storyboards: [Storyboard]) {
let endpoint = GenericEndpoint.get("/api/v1/videos/\(id)", query: ["proxy": "true"])
let response: YatteeVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
return (
video: response.toVideo(),
streams: response.toStreams(),
captions: response.toCaptions(baseURL: instance.url),
storyboards: response.toStoryboards(instanceBaseURL: instance.url)
)
}
// MARK: - External URL Extraction
/// Extracts video information from any URL that yt-dlp supports.
///
/// This enables playback from sites like Vimeo, Twitter, TikTok, and hundreds
/// of other sites supported by yt-dlp.
///
/// - Parameters:
/// - url: The URL to extract (e.g., https://vimeo.com/12345)
/// - instance: The Yattee Server instance to use for extraction
/// - Returns: Tuple of video, streams, and captions
/// - Throws: `ExtractionError` if extraction fails
func extractURL(_ url: URL, instance: Instance) async throws -> (video: Video, streams: [Stream], captions: [Caption]) {
let endpoint = GenericEndpoint(
path: "/api/v1/extract",
queryItems: [URLQueryItem(name: "url", value: url.absoluteString)],
timeout: 180 // 3 minutes for slow site extraction
)
let response: YatteeExternalVideo = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
return (
video: response.toVideo(originalURL: url),
streams: response.toStreams(),
captions: response.toCaptions(baseURL: instance.url)
)
}
/// Extracts channel/user videos from any URL that yt-dlp supports.
///
/// This works with Vimeo, Dailymotion, SoundCloud, and many other sites.
/// Note that some sites (like Twitter/X) may not support channel extraction.
///
/// - Parameters:
/// - url: The channel/user URL to extract (e.g., https://vimeo.com/username)
/// - page: Page number (1-based)
/// - instance: The Yattee Server instance to use for extraction
/// - Returns: Tuple of channel, videos list, and optional continuation token for next page
/// - Throws: `HTTPError` if extraction fails (e.g., site doesn't support channel extraction)
func extractChannel(url: URL, page: Int = 1, instance: Instance) async throws -> (channel: Channel, videos: [Video], continuation: String?) {
let endpoint = GenericEndpoint(
path: "/api/v1/extract/channel",
queryItems: [
URLQueryItem(name: "url", value: url.absoluteString),
URLQueryItem(name: "page", value: String(page))
],
timeout: 180 // 3 minutes for slow site extraction
)
let response: YatteeExternalChannel = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
return (
channel: response.toChannel(originalURL: url),
videos: response.toVideos(channelURL: url),
continuation: response.continuation
)
}
// MARK: - Stateless Feed Endpoints
/// Fetches feed using stateless POST endpoint with channel list.
func postFeed(channels: [StatelessChannelRequest], limit: Int, offset: Int, instance: Instance) async throws -> StatelessFeedResponse {
let body = StatelessFeedRequest(channels: channels, limit: limit, offset: offset)
let endpoint = GenericEndpoint.post("/api/v1/feed", body: body)
return try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
}
/// Checks feed status for given channels (lightweight polling).
func postFeedStatus(channels: [StatelessChannelStatusRequest], instance: Instance) async throws -> StatelessFeedStatusResponse {
let body = StatelessFeedStatusRequest(channels: channels)
let endpoint = GenericEndpoint.post("/api/v1/feed/status", body: body)
return try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
}
// MARK: - Channel Metadata
/// Fetches cached channel metadata (subscriber counts, verified status) for multiple channels.
/// Returns only cached data - no API calls to YouTube.
func channelsMetadata(channelIDs: [String], instance: Instance) async throws -> ChannelsMetadataResponse {
let body = ChannelMetadataRequest(channelIds: channelIDs)
let endpoint = GenericEndpoint.post("/api/v1/channels/metadata", body: body)
return try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
}
// MARK: - Server Info
/// Fetches server info including version, dependencies, and enabled sites.
func fetchServerInfo(for instance: Instance) async throws -> InstanceDetectorModels.YatteeServerFullInfo {
let endpoint = GenericEndpoint.get("/info")
return try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders())
}
}
// MARK: - Server Feed Models
/// A video from the server feed.
struct ServerFeedVideo: Decodable, Sendable {
let type: String
let videoId: String
let title: String
let author: String
let authorId: String
let lengthSeconds: Int
let published: Int64?
let publishedText: String?
let viewCount: Int?
let videoThumbnails: [YatteeThumbnail]?
let extractor: String
let videoUrl: String?
func toVideo() -> Video? {
// Determine content source based on extractor
let videoID: VideoID
if extractor == "youtube" {
videoID = .global(videoId)
} else if let urlString = videoUrl, let url = URL(string: urlString) {
videoID = .extracted(videoId, extractor: extractor, originalURL: url)
} else {
// Fallback to global with extractor as provider
videoID = .global(videoId, provider: extractor)
}
return Video(
id: videoID,
title: title,
description: nil,
author: Author(id: authorId, name: author),
duration: TimeInterval(lengthSeconds),
publishedAt: published.map { Date(timeIntervalSince1970: TimeInterval($0)) },
publishedText: publishedText,
viewCount: viewCount,
likeCount: nil,
thumbnails: videoThumbnails?.map { $0.toThumbnail() } ?? [],
isLive: false,
isUpcoming: false,
scheduledStartTime: nil
)
}
}
// MARK: - Stateless Feed Models
/// Channel info for stateless feed request.
struct StatelessChannelRequest: Encodable, Sendable {
let channelId: String
let site: String
let channelName: String?
let channelUrl: String?
let avatarUrl: String?
enum CodingKeys: String, CodingKey {
case channelId = "channel_id"
case site
case channelName = "channel_name"
case channelUrl = "channel_url"
case avatarUrl = "avatar_url"
}
init(channelId: String, site: String, channelName: String?, channelUrl: String? = nil, avatarUrl: String?) {
self.channelId = channelId
self.site = site
self.channelName = channelName
self.channelUrl = channelUrl
self.avatarUrl = avatarUrl
}
}
/// Minimal channel info for status check.
struct StatelessChannelStatusRequest: Encodable, Sendable {
let channelId: String
let site: String
enum CodingKeys: String, CodingKey {
case channelId = "channel_id"
case site
}
}
/// Request body for stateless feed endpoint.
struct StatelessFeedRequest: Encodable, Sendable {
let channels: [StatelessChannelRequest]
let limit: Int
let offset: Int
}
/// Request body for stateless feed status endpoint.
struct StatelessFeedStatusRequest: Encodable, Sendable {
let channels: [StatelessChannelStatusRequest]
}
/// Response from stateless feed endpoint.
struct StatelessFeedResponse: Decodable, Sendable {
let status: String
let videos: [ServerFeedVideo]
let total: Int
let hasMore: Bool
let readyCount: Int?
let pendingCount: Int?
let errorCount: Int?
let etaSeconds: Int?
var isReady: Bool { status == "ready" }
func toVideos() -> [Video] {
videos.compactMap { $0.toVideo() }
}
}
/// Response from stateless feed status endpoint.
struct StatelessFeedStatusResponse: Decodable, Sendable {
let status: String
let readyCount: Int
let pendingCount: Int
let errorCount: Int
var isReady: Bool { status == "ready" }
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
status = try container.decode(String.self, forKey: .status)
readyCount = try container.decode(Int.self, forKey: .readyCount)
pendingCount = try container.decode(Int.self, forKey: .pendingCount)
// Default to 0 for backwards compatibility with older server versions
errorCount = try container.decodeIfPresent(Int.self, forKey: .errorCount) ?? 0
}
private enum CodingKeys: String, CodingKey {
case status
case readyCount
case pendingCount
case errorCount
}
}
// MARK: - Channel Metadata Models
/// Request body for channels metadata endpoint.
struct ChannelMetadataRequest: Encodable, Sendable {
let channelIds: [String]
enum CodingKeys: String, CodingKey {
case channelIds = "channel_ids"
}
}
/// Response from channels metadata endpoint.
struct ChannelsMetadataResponse: Decodable, Sendable {
let channels: [ChannelMetadataItem]
}
/// Cached metadata for a single channel.
/// Note: Uses automatic snake_case to camelCase conversion via HTTPClient's keyDecodingStrategy.
struct ChannelMetadataItem: Decodable, Sendable {
let channelId: String
let subscriberCount: Int?
/// SQLite stores booleans as integers (0/1)
let isVerified: Int?
var isVerifiedBool: Bool {
isVerified == 1
}
}
// MARK: - yt-dlp Server Response Models (Invidious-compatible)
private struct YatteeVideo: Decodable, Sendable {
let videoId: 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: [YatteeThumbnail]?
let liveNow: Bool?
let isUpcoming: Bool?
let premiereTimestamp: Int64?
nonisolated func toVideo() -> 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() } ?? [],
isLive: liveNow ?? false,
isUpcoming: isUpcoming ?? false,
scheduledStartTime: premiereTimestamp.map { Date(timeIntervalSince1970: TimeInterval($0)) }
)
}
}
private struct YatteeVideoDetails: Decodable, Sendable {
let videoId: String
let title: String
let description: String?
let descriptionHtml: String?
let author: String
let authorId: String
let authorThumbnails: [YatteeThumbnail]?
let subCountText: String?
let lengthSeconds: Int
let published: Int64?
let publishedText: String?
let viewCount: Int?
let likeCount: Int?
let videoThumbnails: [YatteeThumbnail]?
let liveNow: Bool?
let isUpcoming: Bool?
let premiereTimestamp: Int64?
let hlsUrl: String?
let dashUrl: String?
let formatStreams: [YatteeFormatStream]?
let adaptiveFormats: [YatteeAdaptiveFormat]?
let captions: [YatteeCaption]?
let storyboards: [YatteeStoryboard]?
let recommendedVideos: [YatteeRecommendedVideo]?
/// 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() -> Video {
// Convert recommended videos, limiting to 12
let related: [Video]? = recommendedVideos?.prefix(12).map { $0.toVideo() }
return Video(
id: .global(videoId),
title: title,
description: description,
author: Author(
id: authorId,
name: author,
thumbnailURL: authorThumbnails?.authorThumbnailURL,
subscriberCount: subscriberCount
),
duration: TimeInterval(lengthSeconds),
publishedAt: published.map { Date(timeIntervalSince1970: TimeInterval($0)) },
publishedText: publishedText,
viewCount: viewCount,
likeCount: likeCount,
thumbnails: videoThumbnails?.map { $0.toThumbnail() } ?? [],
isLive: liveNow ?? false,
isUpcoming: isUpcoming ?? false,
scheduledStartTime: premiereTimestamp.map { Date(timeIntervalSince1970: TimeInterval($0)) },
relatedVideos: related
)
}
nonisolated func toStreams() -> [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)
if let dashUrl, let url = URL(string: dashUrl) {
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 YatteeStoryboard: 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 Yattee Server video details response.
private struct YatteeRecommendedVideo: Decodable, Sendable {
let videoId: String
let title: String
let author: String
let authorId: String
let authorUrl: String?
let videoThumbnails: [YatteeThumbnail]?
let lengthSeconds: Int
let viewCountText: String?
let viewCount: Int?
nonisolated func toVideo() -> 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() } ?? [],
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)
}
}
// MARK: - External Channel Model (for non-YouTube channel extraction)
private struct YatteeExternalChannel: Decodable, Sendable {
let author: String
let authorId: String
let authorUrl: String
let extractor: String
let videos: [YatteeExternalVideoListItem]
let continuation: String?
nonisolated func toChannel(originalURL: URL) -> Channel {
Channel(
id: ChannelID.extracted(authorId, extractor: extractor, originalURL: originalURL),
name: author,
description: nil,
subscriberCount: nil,
videoCount: videos.count,
thumbnailURL: nil,
bannerURL: nil,
isVerified: false
)
}
nonisolated func toVideos(channelURL: URL) -> [Video] {
videos.compactMap { $0.toVideo(channelURL: channelURL, extractor: extractor) }
}
}
private struct YatteeExternalVideoListItem: Decodable, Sendable {
let videoId: 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 viewCountText: String?
let videoThumbnails: [YatteeThumbnail]?
let extractor: String?
let videoUrl: String? // Original video URL for extraction
nonisolated func toVideo(channelURL: URL, extractor: String) -> Video {
let extractorName = self.extractor ?? extractor
// Use the actual video URL from the server, fall back to channel URL
let videoURL = videoUrl.flatMap { URL(string: $0) } ?? channelURL
return Video(
id: .extracted(videoId, extractor: extractorName, originalURL: videoURL),
title: title,
description: description,
author: Author(
id: authorId.isEmpty ? extractorName : authorId,
name: author.isEmpty ? extractorName.capitalized : author,
url: authorUrl.flatMap { URL(string: $0) } ?? channelURL,
hasRealChannelInfo: true // Channel videos always have valid channel URL
),
duration: TimeInterval(lengthSeconds),
publishedAt: published.map { Date(timeIntervalSince1970: TimeInterval($0)) },
publishedText: publishedText,
viewCount: viewCount,
likeCount: nil,
thumbnails: videoThumbnails?.map { $0.toThumbnail() } ?? [],
isLive: false,
isUpcoming: false,
scheduledStartTime: nil
)
}
}
private struct YatteeCaption: Decodable, Sendable {
let label: String
let languageCode: String
let url: String
let autoGenerated: Bool?
nonisolated func toCaption(baseURL: URL) -> Caption? {
// Caption URLs from server are full URLs like:
// http://server/api/v1/captions/{id}/content?lang=en
var captionURL: URL?
if url.hasPrefix("/") {
captionURL = URL(string: url, relativeTo: baseURL)?.absoluteURL
} else {
captionURL = URL(string: url)
}
// Ensure caption URL uses same scheme as instance (fixes http/https mismatch from reverse proxy)
if var urlComponents = captionURL.flatMap({ URLComponents(url: $0, resolvingAgainstBaseURL: true) }),
let instanceScheme = baseURL.scheme,
urlComponents.host == baseURL.host {
urlComponents.scheme = instanceScheme
captionURL = urlComponents.url
}
guard let captionURL else { return nil }
return Caption(
label: label,
languageCode: languageCode,
url: captionURL
)
}
}
private struct YatteeFormatStream: Decodable, Sendable {
let url: String?
let itag: String?
let type: String?
let quality: String?
let container: String?
let encoding: String?
let resolution: String?
let width: Int?
let height: Int?
let size: String?
let fps: Int?
let httpHeaders: [String: String]?
nonisolated func toStream(isLive: Bool = false) -> Stream? {
guard let urlString = url, let streamUrl = URL(string: urlString) else { return nil }
let audioCodec = parseAudioCodec(from: type)
let videoCodec = encoding ?? parseVideoCodec(from: type)
// Prefer actual width/height from API, fallback to parsing from resolution label
let streamResolution: StreamResolution?
if let w = width, let h = height, w > 0, h > 0 {
streamResolution = StreamResolution(width: w, height: h)
} else {
streamResolution = resolution.flatMap { StreamResolution(heightLabel: $0) }
}
return Stream(
url: streamUrl,
resolution: streamResolution,
format: container ?? "unknown",
videoCodec: videoCodec,
audioCodec: audioCodec,
isLive: isLive,
mimeType: type,
httpHeaders: httpHeaders,
fps: fps
)
}
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) }
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
}
private nonisolated func parseAudioCodec(from mimeType: String?) -> String? {
guard let mimeType else { return nil }
guard let codecsRange = mimeType.range(of: "codecs=\"") else {
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) }
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"
}
}
return "aac"
}
}
private struct YatteeAdaptiveFormat: Decodable, Sendable {
let url: String?
let itag: String?
let type: String?
let container: String?
let encoding: String?
let resolution: String?
let width: Int?
let height: Int?
let bitrate: String?
let clen: String?
let fps: Int?
let audioTrack: YatteeAudioTrack?
let audioQuality: String?
let httpHeaders: [String: String]?
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 }
let (language, trackName, isOriginal) = parseAudioInfo()
let videoCodec: String? = if isAudioOnly {
nil
} else {
encoding ?? parseVideoCodec(from: type)
}
// Prefer actual width/height from API, fallback to parsing from resolution label
let streamResolution: StreamResolution?
if !isAudioOnly, let w = width, let h = height, w > 0, h > 0 {
streamResolution = StreamResolution(width: w, height: h)
} else if !isAudioOnly {
streamResolution = resolution.flatMap { StreamResolution(heightLabel: $0) }
} else {
streamResolution = nil
}
return Stream(
url: streamUrl,
resolution: streamResolution,
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,
httpHeaders: httpHeaders,
fps: isAudioOnly ? nil : fps
)
}
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) }
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
}
private nonisolated func parseAudioInfo() -> (language: String?, trackName: String?, isOriginal: Bool) {
if let audioTrack, audioTrack.id != nil || audioTrack.displayName != nil {
return (audioTrack.id, audioTrack.displayName, audioTrack.isDefault ?? false)
}
guard isAudioOnly, let urlString = url else {
return (nil, nil, false)
}
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])
guard let xtags = xtagsEncoded.removingPercentEncoding else {
return (nil, nil, false)
}
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)
}
let contentType = pairs["acont"]
let isOriginal = contentType == "original"
let trackName = generateTrackName(langCode: langCode, contentType: contentType)
return (langCode, trackName, isOriginal)
}
private nonisolated func generateTrackName(langCode: String, contentType: String?) -> String {
let locale = Locale(identifier: "en")
let languageName: String
if let name = locale.localizedString(forIdentifier: langCode) {
languageName = name
} else {
let baseCode = String(langCode.split(separator: "-").first ?? Substring(langCode))
languageName = locale.localizedString(forLanguageCode: baseCode) ?? langCode
}
switch contentType {
case "original":
return "\(languageName) (Original)"
case "dubbed-auto":
return "\(languageName) (Auto-dubbed)"
case "dubbed":
return "\(languageName) (Dubbed)"
default:
return languageName
}
}
}
private struct YatteeAudioTrack: Decodable, Sendable {
let id: String?
let displayName: String?
let isDefault: Bool?
}
struct YatteeThumbnail: Decodable, Sendable {
let quality: String?
let url: String
let width: Int?
let height: Int?
var thumbnailURL: URL? {
if url.hasPrefix("//") {
return URL(string: "https:" + url)
}
return URL(string: url)
}
nonisolated func toThumbnail() -> Thumbnail {
Thumbnail(
url: thumbnailURL ?? 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 == YatteeThumbnail {
var authorThumbnailURL: URL? {
let preferred = first { ($0.width ?? 0) >= 100 }
return (preferred ?? last)?.thumbnailURL
}
}
private struct YatteeChannel: Decodable, Sendable {
let authorId: String
let author: String
let description: String?
let subCount: Int?
let totalViews: Int64?
let authorThumbnails: [YatteeThumbnail]?
let authorBanners: [YatteeThumbnail]?
let authorVerified: Bool?
nonisolated func toChannel() -> Channel {
Channel(
id: .global(authorId),
name: author,
description: description,
subscriberCount: subCount,
thumbnailURL: authorThumbnails?.authorThumbnailURL,
bannerURL: authorBanners?.last?.thumbnailURL,
isVerified: authorVerified ?? false
)
}
}
private struct YatteeChannelVideos: Decodable, Sendable {
let videos: [YatteeVideo]
}
private struct YatteeChannelSearchResponse: Decodable, Sendable {
let videos: [YatteeVideo]
}
private struct YatteeChannelVideosWithContinuation: Decodable, Sendable {
let videos: [YatteeVideo]
let continuation: String?
}
private struct YatteeChannelPlaylists: Decodable, Sendable {
let playlists: [YatteeChannelPlaylistItem]
let continuation: String?
}
private struct YatteeChannelPlaylistItem: Decodable, Sendable {
let playlistId: String
let title: String
let author: String?
let authorId: String?
let videoCount: Int
let playlistThumbnail: String?
nonisolated func toPlaylist() -> Playlist {
let thumbnailURL: URL? = playlistThumbnail.flatMap { urlString -> URL? in
if urlString.hasPrefix("//") {
return URL(string: "https:" + urlString)
}
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.
/// The server may return `"type": "parse-error"` for videos it failed to parse.
private enum YatteePlaylistItem: Decodable, Sendable {
case video(YatteeVideo)
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 YatteeVideo(from: decoder))
} catch {
self = .unknown
}
case "parse-error":
self = .parseError
default:
self = .unknown
}
}
}
private struct YatteePlaylist: Decodable, Sendable {
let playlistId: String
let title: String
let description: String?
let author: String?
let authorId: String?
let videoCount: Int
let videos: [YatteePlaylistItem]?
nonisolated func toPlaylist() -> 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()
}
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 enum YatteeSearchItem: Decodable, Sendable {
case video(YatteeVideo)
case channel(YatteeSearchChannel)
case playlist(YatteeSearchPlaylist)
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 YatteeVideo(from: decoder))
case "channel":
self = .channel(try YatteeSearchChannel(from: decoder))
case "playlist":
self = .playlist(try YatteeSearchPlaylist(from: decoder))
default:
self = .unknown
}
}
}
private struct YatteeSearchChannel: Decodable, Sendable {
let authorId: String
let author: String
let description: String?
let subCount: Int?
let videoCount: Int?
let authorThumbnails: [YatteeThumbnail]?
let authorVerified: Bool?
nonisolated func toChannel() -> Channel {
Channel(
id: .global(authorId),
name: author,
description: description,
subscriberCount: subCount,
videoCount: videoCount,
thumbnailURL: authorThumbnails?.authorThumbnailURL,
isVerified: authorVerified ?? false
)
}
}
private struct YatteeSearchPlaylist: Decodable, Sendable {
let playlistId: String
let title: String
let author: String?
let authorId: String?
let videoCount: Int
let playlistThumbnail: String?
let videos: [YatteeVideo]?
nonisolated func toPlaylist() -> Playlist {
let thumbnailURL: URL? = playlistThumbnail.flatMap { urlString -> URL? in
if urlString.hasPrefix("//") {
return URL(string: "https:" + urlString)
}
return URL(string: urlString)
} ?? videos?.first?.videoThumbnails?.first?.thumbnailURL
return Playlist(
id: .global(playlistId),
title: title,
author: authorId.map { Author(id: $0, name: author ?? "") },
videoCount: videoCount,
thumbnailURL: thumbnailURL,
videos: videos?.map { $0.toVideo() } ?? []
)
}
}
// MARK: - Comments Models (Invidious-compatible)
private struct YatteeComments: Decodable, Sendable {
let comments: [YatteeComment]
let continuation: String?
}
private struct YatteeComment: Decodable, Sendable {
let commentId: String
let author: String
let authorId: String
let authorThumbnails: [YatteeThumbnail]?
let authorIsChannelOwner: Bool?
let content: String
let published: Int64?
let publishedText: String?
let likeCount: Int?
let isEdited: Bool?
let isPinned: Bool?
let creatorHeart: YatteeCreatorHeart?
let replies: YatteeCommentReplies?
nonisolated func toComment() -> Comment {
Comment(
id: commentId,
author: Author(
id: authorId,
name: author,
thumbnailURL: authorThumbnails?.first?.thumbnailURL
),
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 YatteeCreatorHeart: Decodable, Sendable {
let creatorThumbnail: String?
let creatorName: String?
}
private struct YatteeCommentReplies: Decodable, Sendable {
let replyCount: Int
let continuation: String?
}
// MARK: - External Video Model (for non-YouTube sites)
private struct YatteeExternalVideo: Decodable, Sendable {
let videoId: String
let title: String
let description: String?
let descriptionHtml: String?
let author: String
let authorId: String
let authorUrl: String?
let authorThumbnails: [YatteeThumbnail]?
let subCountText: String?
let lengthSeconds: Int
let published: Int64?
let publishedText: String?
let viewCount: Int?
let likeCount: Int?
let videoThumbnails: [YatteeThumbnail]?
let liveNow: Bool?
let isUpcoming: Bool?
let premiereTimestamp: Int64?
let hlsUrl: String?
let dashUrl: String?
let formatStreams: [YatteeFormatStream]?
let adaptiveFormats: [YatteeAdaptiveFormat]?
let captions: [YatteeCaption]?
// External-specific fields
let extractor: String?
let originalUrl: String?
/// 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(originalURL: URL) -> Video {
// Use the extractor and original URL to create an external video ID
let extractorName = extractor ?? "unknown"
let parsedAuthorURL = authorUrl.flatMap { URL(string: $0) }
return Video(
id: .extracted(videoId, extractor: extractorName, originalURL: originalURL),
title: title,
description: description,
author: Author(
id: authorId.isEmpty ? extractorName : authorId,
name: author.isEmpty ? extractorName.capitalized : author,
thumbnailURL: authorThumbnails?.authorThumbnailURL,
subscriberCount: subscriberCount,
url: parsedAuthorURL,
hasRealChannelInfo: !authorId.isEmpty || authorUrl != nil
),
duration: TimeInterval(lengthSeconds),
publishedAt: published.map { Date(timeIntervalSince1970: TimeInterval($0)) },
publishedText: publishedText,
viewCount: viewCount,
likeCount: likeCount,
thumbnails: videoThumbnails?.map { $0.toThumbnail() } ?? [],
isLive: liveNow ?? false,
isUpcoming: isUpcoming ?? false,
scheduledStartTime: premiereTimestamp.map { Date(timeIntervalSince1970: TimeInterval($0)) }
)
}
nonisolated func toStreams() -> [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)
if let dashUrl, let url = URL(string: dashUrl) {
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) }
}
}