Files
yattee/Yattee/Services/API/YatteeServerAPI.swift
Arkadiusz Fal 0071e1b117 Skip notifications for upcoming/premiere videos
Videos that haven't premiered yet were triggering repeated notifications
on every background refresh cycle. Filter them out by checking isUpcoming
flag and rejecting videos with future publish dates.

Also decode isUpcoming/premiereTimestamp from Yattee Server feed responses
instead of hardcoding false/nil.
2026-04-18 20:38:00 +02:00

1615 lines
58 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?
let isUpcoming: Bool?
let premiereTimestamp: Int64?
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: isUpcoming ?? false,
scheduledStartTime: premiereTimestamp.map { Date(timeIntervalSince1970: TimeInterval($0)) }
)
}
}
// 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) }
}
}