mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
1613 lines
57 KiB
Swift
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) }
|
|
}
|
|
}
|