mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
Yattee v2 rewrite
This commit is contained in:
395
Yattee/Services/API/ContentService.swift
Normal file
395
Yattee/Services/API/ContentService.swift
Normal file
@@ -0,0 +1,395 @@
|
||||
//
|
||||
// ContentService.swift
|
||||
// Yattee
|
||||
//
|
||||
// Unified interface for fetching content from any backend (Invidious, Piped, PeerTube).
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Protocol defining the common content fetching interface.
|
||||
protocol ContentServiceProtocol: Sendable {
|
||||
func trending(for instance: Instance) async throws -> [Video]
|
||||
func popular(for instance: Instance) async throws -> [Video]
|
||||
func feed(for instance: Instance, credential: String) async throws -> [Video]
|
||||
func subscriptions(for instance: Instance, credential: String) async throws -> [Channel]
|
||||
func search(query: String, instance: Instance, page: Int, filters: SearchFilters) async throws -> SearchResult
|
||||
func searchSuggestions(query: String, instance: Instance) async throws -> [String]
|
||||
func video(id: String, instance: Instance) async throws -> Video
|
||||
func channel(id: String, instance: Instance) async throws -> Channel
|
||||
func channelVideos(id: String, instance: Instance, continuation: String?) async throws -> ChannelVideosPage
|
||||
func channelPlaylists(id: String, instance: Instance, continuation: String?) async throws -> ChannelPlaylistsPage
|
||||
func channelShorts(id: String, instance: Instance, continuation: String?) async throws -> ChannelVideosPage
|
||||
func channelStreams(id: String, instance: Instance, continuation: String?) async throws -> ChannelVideosPage
|
||||
func playlist(id: String, instance: Instance) async throws -> Playlist
|
||||
func comments(videoID: String, instance: Instance, continuation: String?) async throws -> CommentsPage
|
||||
func streams(videoID: String, instance: Instance) async throws -> [Stream]
|
||||
func captions(videoID: String, instance: Instance) async throws -> [Caption]
|
||||
func channelSearch(id: String, query: String, instance: Instance, page: Int) async throws -> ChannelSearchPage
|
||||
}
|
||||
|
||||
/// Unified content service that routes requests to the appropriate API based on instance type.
|
||||
actor ContentService: ContentServiceProtocol {
|
||||
private let httpClientFactory: HTTPClientFactory
|
||||
|
||||
// Default HTTPClient for instances with standard SSL (allowInvalidCertificates = false)
|
||||
private let defaultHTTPClient: HTTPClient
|
||||
|
||||
// Cached API instances for default SSL mode
|
||||
private let defaultInvidiousAPI: InvidiousAPI
|
||||
private let defaultPipedAPI: PipedAPI
|
||||
private let defaultPeerTubeAPI: PeerTubeAPI
|
||||
private let defaultYatteeServerAPI: YatteeServerAPI
|
||||
|
||||
/// Credentials manager for fetching Yattee Server auth headers on demand.
|
||||
private let yatteeServerCredentialsManager: YatteeServerCredentialsManager?
|
||||
|
||||
init(httpClient: HTTPClient, yatteeServerCredentialsManager: YatteeServerCredentialsManager? = nil) {
|
||||
// Legacy init - create factory internally
|
||||
self.httpClientFactory = HTTPClientFactory()
|
||||
self.defaultHTTPClient = httpClient
|
||||
self.defaultInvidiousAPI = InvidiousAPI(httpClient: httpClient)
|
||||
self.defaultPipedAPI = PipedAPI(httpClient: httpClient)
|
||||
self.defaultPeerTubeAPI = PeerTubeAPI(httpClient: httpClient)
|
||||
self.defaultYatteeServerAPI = YatteeServerAPI(httpClient: httpClient)
|
||||
self.yatteeServerCredentialsManager = yatteeServerCredentialsManager
|
||||
}
|
||||
|
||||
init(httpClientFactory: HTTPClientFactory, yatteeServerCredentialsManager: YatteeServerCredentialsManager? = nil) {
|
||||
self.httpClientFactory = httpClientFactory
|
||||
// Create default client for instances that don't need insecure SSL
|
||||
self.defaultHTTPClient = httpClientFactory.createClient(allowInvalidCertificates: false)
|
||||
self.defaultInvidiousAPI = InvidiousAPI(httpClient: defaultHTTPClient)
|
||||
self.defaultPipedAPI = PipedAPI(httpClient: defaultHTTPClient)
|
||||
self.defaultPeerTubeAPI = PeerTubeAPI(httpClient: defaultHTTPClient)
|
||||
self.defaultYatteeServerAPI = YatteeServerAPI(httpClient: defaultHTTPClient)
|
||||
self.yatteeServerCredentialsManager = yatteeServerCredentialsManager
|
||||
}
|
||||
|
||||
// MARK: - Routing
|
||||
|
||||
/// Returns an API client configured for the instance's SSL and auth requirements.
|
||||
private func api(for instance: Instance) async -> any InstanceAPI {
|
||||
// For Yattee Server, use the dedicated method that handles auth
|
||||
if instance.type == .yatteeServer {
|
||||
return await yatteeServerAPI(for: instance)
|
||||
}
|
||||
|
||||
// For instances with standard SSL, use cached default API clients
|
||||
if !instance.allowInvalidCertificates {
|
||||
switch instance.type {
|
||||
case .invidious:
|
||||
return defaultInvidiousAPI
|
||||
case .piped:
|
||||
return defaultPipedAPI
|
||||
case .peertube:
|
||||
return defaultPeerTubeAPI
|
||||
case .yatteeServer:
|
||||
fatalError("Should be handled above")
|
||||
}
|
||||
}
|
||||
|
||||
// For instances with allowInvalidCertificates, create API with insecure HTTPClient
|
||||
let insecureClient = httpClientFactory.createClient(for: instance)
|
||||
switch instance.type {
|
||||
case .invidious:
|
||||
return InvidiousAPI(httpClient: insecureClient)
|
||||
case .piped:
|
||||
return PipedAPI(httpClient: insecureClient)
|
||||
case .peertube:
|
||||
return PeerTubeAPI(httpClient: insecureClient)
|
||||
case .yatteeServer:
|
||||
fatalError("Should be handled above")
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a YatteeServerAPI configured for the instance's SSL and auth requirements.
|
||||
private func yatteeServerAPI(for instance: Instance) async -> YatteeServerAPI {
|
||||
let api: YatteeServerAPI
|
||||
if !instance.allowInvalidCertificates {
|
||||
api = defaultYatteeServerAPI
|
||||
} else {
|
||||
let insecureClient = httpClientFactory.createClient(for: instance)
|
||||
api = YatteeServerAPI(httpClient: insecureClient)
|
||||
}
|
||||
|
||||
// Fetch auth header directly from credentials manager (avoids race condition on app startup)
|
||||
let authHeader = await yatteeServerCredentialsManager?.basicAuthHeader(for: instance)
|
||||
await api.setAuthHeader(authHeader)
|
||||
|
||||
return api
|
||||
}
|
||||
|
||||
/// Returns an InvidiousAPI configured for the instance's SSL requirements.
|
||||
private func invidiousAPI(for instance: Instance) -> InvidiousAPI {
|
||||
if !instance.allowInvalidCertificates {
|
||||
return defaultInvidiousAPI
|
||||
}
|
||||
let insecureClient = httpClientFactory.createClient(for: instance)
|
||||
return InvidiousAPI(httpClient: insecureClient)
|
||||
}
|
||||
|
||||
/// Returns a PipedAPI configured for the instance's SSL requirements.
|
||||
private func pipedAPI(for instance: Instance) -> PipedAPI {
|
||||
if !instance.allowInvalidCertificates {
|
||||
return defaultPipedAPI
|
||||
}
|
||||
let insecureClient = httpClientFactory.createClient(for: instance)
|
||||
return PipedAPI(httpClient: insecureClient)
|
||||
}
|
||||
|
||||
/// Returns a PeerTubeAPI configured for the instance's SSL requirements.
|
||||
private func peerTubeAPI(for instance: Instance) -> PeerTubeAPI {
|
||||
if !instance.allowInvalidCertificates {
|
||||
return defaultPeerTubeAPI
|
||||
}
|
||||
let insecureClient = httpClientFactory.createClient(for: instance)
|
||||
return PeerTubeAPI(httpClient: insecureClient)
|
||||
}
|
||||
|
||||
// MARK: - ContentServiceProtocol
|
||||
|
||||
func trending(for instance: Instance) async throws -> [Video] {
|
||||
try await api(for: instance).trending(instance: instance)
|
||||
}
|
||||
|
||||
func popular(for instance: Instance) async throws -> [Video] {
|
||||
try await api(for: instance).popular(instance: instance)
|
||||
}
|
||||
|
||||
func feed(for instance: Instance, credential: String) async throws -> [Video] {
|
||||
switch instance.type {
|
||||
case .invidious:
|
||||
let response = try await invidiousAPI(for: instance).feed(
|
||||
instance: instance,
|
||||
sid: credential,
|
||||
page: 1,
|
||||
maxResults: 50 // Fetch 50, but HomeView will show max 15 per section
|
||||
)
|
||||
return response.videos
|
||||
case .piped:
|
||||
return try await pipedAPI(for: instance).feed(
|
||||
instance: instance,
|
||||
authToken: credential
|
||||
)
|
||||
default:
|
||||
throw APIError.notSupported
|
||||
}
|
||||
}
|
||||
|
||||
func subscriptions(for instance: Instance, credential: String) async throws -> [Channel] {
|
||||
switch instance.type {
|
||||
case .invidious:
|
||||
let subs = try await invidiousAPI(for: instance).subscriptions(
|
||||
instance: instance,
|
||||
sid: credential
|
||||
)
|
||||
return subs.map { $0.toChannel(baseURL: instance.url) }
|
||||
case .piped:
|
||||
let subs = try await pipedAPI(for: instance).subscriptions(
|
||||
instance: instance,
|
||||
authToken: credential
|
||||
)
|
||||
return subs.map { $0.toChannel() }
|
||||
default:
|
||||
throw APIError.notSupported
|
||||
}
|
||||
}
|
||||
|
||||
func search(query: String, instance: Instance, page: Int = 1, filters: SearchFilters = .defaults) async throws -> SearchResult {
|
||||
try await api(for: instance).search(query: query, instance: instance, page: page, filters: filters)
|
||||
}
|
||||
|
||||
func searchSuggestions(query: String, instance: Instance) async throws -> [String] {
|
||||
try await api(for: instance).searchSuggestions(query: query, instance: instance)
|
||||
}
|
||||
|
||||
func video(id: String, instance: Instance) async throws -> Video {
|
||||
try await api(for: instance).video(id: id, instance: instance)
|
||||
}
|
||||
|
||||
func channel(id: String, instance: Instance) async throws -> Channel {
|
||||
try await api(for: instance).channel(id: id, instance: instance)
|
||||
}
|
||||
|
||||
func channelVideos(id: String, instance: Instance, continuation: String? = nil) async throws -> ChannelVideosPage {
|
||||
try await api(for: instance).channelVideos(id: id, instance: instance, continuation: continuation)
|
||||
}
|
||||
|
||||
func channelPlaylists(id: String, instance: Instance, continuation: String? = nil) async throws -> ChannelPlaylistsPage {
|
||||
try await api(for: instance).channelPlaylists(id: id, instance: instance, continuation: continuation)
|
||||
}
|
||||
|
||||
func channelShorts(id: String, instance: Instance, continuation: String? = nil) async throws -> ChannelVideosPage {
|
||||
try await api(for: instance).channelShorts(id: id, instance: instance, continuation: continuation)
|
||||
}
|
||||
|
||||
func channelStreams(id: String, instance: Instance, continuation: String? = nil) async throws -> ChannelVideosPage {
|
||||
try await api(for: instance).channelStreams(id: id, instance: instance, continuation: continuation)
|
||||
}
|
||||
|
||||
func playlist(id: String, instance: Instance) async throws -> Playlist {
|
||||
try await api(for: instance).playlist(id: id, instance: instance)
|
||||
}
|
||||
|
||||
func comments(videoID: String, instance: Instance, continuation: String? = nil) async throws -> CommentsPage {
|
||||
try await api(for: instance).comments(videoID: videoID, instance: instance, continuation: continuation)
|
||||
}
|
||||
|
||||
func streams(videoID: String, instance: Instance) async throws -> [Stream] {
|
||||
try await api(for: instance).streams(videoID: videoID, instance: instance)
|
||||
}
|
||||
|
||||
func captions(videoID: String, instance: Instance) async throws -> [Caption] {
|
||||
try await api(for: instance).captions(videoID: videoID, instance: instance)
|
||||
}
|
||||
|
||||
func channelSearch(id: String, query: String, instance: Instance, page: Int = 1) async throws -> ChannelSearchPage {
|
||||
try await api(for: instance).channelSearch(id: id, query: query, instance: instance, page: page)
|
||||
}
|
||||
|
||||
/// Fetches streams with proxy URLs for faster LAN downloads (Yattee Server only).
|
||||
/// For other backends, returns regular streams.
|
||||
func proxyStreams(videoID: String, instance: Instance) async throws -> [Stream] {
|
||||
if instance.type == .yatteeServer {
|
||||
return try await yatteeServerAPI(for: instance).proxyStreams(videoID: videoID, instance: instance)
|
||||
}
|
||||
return try await streams(videoID: videoID, instance: instance)
|
||||
}
|
||||
|
||||
/// Fetches video details, proxy streams, captions, and storyboards (Yattee Server only).
|
||||
/// For other backends, falls back to regular streams.
|
||||
func videoWithProxyStreamsAndCaptionsAndStoryboards(id: String, instance: Instance) async throws -> (video: Video, streams: [Stream], captions: [Caption], storyboards: [Storyboard]) {
|
||||
if instance.type == .yatteeServer {
|
||||
return try await yatteeServerAPI(for: instance).videoWithProxyStreamsAndCaptionsAndStoryboards(id: id, instance: instance)
|
||||
}
|
||||
return try await videoWithStreamsAndCaptionsAndStoryboards(id: id, instance: instance)
|
||||
}
|
||||
|
||||
/// Fetches video details, streams, and captions in a single API call (Invidious and Yattee Server).
|
||||
/// For other backends, falls back to separate calls.
|
||||
func videoWithStreamsAndCaptions(id: String, instance: Instance) async throws -> (video: Video, streams: [Stream], captions: [Caption]) {
|
||||
let result = try await videoWithStreamsAndCaptionsAndStoryboards(id: id, instance: instance)
|
||||
return (result.video, result.streams, result.captions)
|
||||
}
|
||||
|
||||
/// Fetches video details, streams, captions, and storyboards in a single API call.
|
||||
/// Storyboards are only available for Invidious and Yattee Server instances.
|
||||
func videoWithStreamsAndCaptionsAndStoryboards(id: String, instance: Instance) async throws -> (video: Video, streams: [Stream], captions: [Caption], storyboards: [Storyboard]) {
|
||||
switch instance.type {
|
||||
case .invidious:
|
||||
return try await invidiousAPI(for: instance).videoWithStreamsAndCaptionsAndStoryboards(id: id, instance: instance)
|
||||
case .yatteeServer:
|
||||
return try await yatteeServerAPI(for: instance).videoWithStreamsAndCaptionsAndStoryboards(id: id, instance: instance)
|
||||
case .piped:
|
||||
// Piped fallback - make separate calls (no storyboard support)
|
||||
let pipedAPI = pipedAPI(for: instance)
|
||||
async let videoTask = pipedAPI.video(id: id, instance: instance)
|
||||
async let streamsTask = pipedAPI.streams(videoID: id, instance: instance)
|
||||
async let captionsTask = pipedAPI.captions(videoID: id, instance: instance)
|
||||
|
||||
let video = try await videoTask
|
||||
let streams = try await streamsTask
|
||||
let captions = try await captionsTask
|
||||
return (video, streams, captions, [])
|
||||
|
||||
case .peertube:
|
||||
// PeerTube fallback - make separate calls (no storyboard support)
|
||||
let peerTubeAPI = peerTubeAPI(for: instance)
|
||||
async let videoTask = peerTubeAPI.video(id: id, instance: instance)
|
||||
async let streamsTask = peerTubeAPI.streams(videoID: id, instance: instance)
|
||||
async let captionsTask = peerTubeAPI.captions(videoID: id, instance: instance)
|
||||
|
||||
let video = try await videoTask
|
||||
let streams = try await streamsTask
|
||||
let captions = try await captionsTask
|
||||
return (video, streams, captions, [])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - External URL Extraction
|
||||
|
||||
/// Extracts video information from any URL that yt-dlp supports.
|
||||
/// Requires a Yattee Server instance.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - url: The URL to extract (e.g., https://vimeo.com/12345)
|
||||
/// - instance: A Yattee Server instance
|
||||
/// - Returns: Tuple of video, streams, and captions
|
||||
func extractURL(_ url: URL, instance: Instance) async throws -> (video: Video, streams: [Stream], captions: [Caption]) {
|
||||
guard instance.type == .yatteeServer else {
|
||||
throw APIError.notSupported
|
||||
}
|
||||
return try await yatteeServerAPI(for: instance).extractURL(url, instance: instance)
|
||||
}
|
||||
|
||||
/// Extracts channel/user videos from any URL that yt-dlp supports.
|
||||
/// Requires a Yattee Server instance.
|
||||
///
|
||||
/// 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: A Yattee Server instance
|
||||
/// - Returns: Tuple of channel, videos list, and optional continuation token for next page
|
||||
func extractChannel(url: URL, page: Int = 1, instance: Instance) async throws -> (channel: Channel, videos: [Video], continuation: String?) {
|
||||
guard instance.type == .yatteeServer else {
|
||||
throw APIError.notSupported
|
||||
}
|
||||
return try await yatteeServerAPI(for: instance).extractChannel(url: url, page: page, instance: instance)
|
||||
}
|
||||
|
||||
// MARK: - Yattee Server Info
|
||||
|
||||
/// Fetches server info including version, dependencies, and enabled sites.
|
||||
/// Requires a Yattee Server instance.
|
||||
func yatteeServerInfo(for instance: Instance) async throws -> InstanceDetectorModels.YatteeServerFullInfo {
|
||||
guard instance.type == .yatteeServer else {
|
||||
throw APIError.notSupported
|
||||
}
|
||||
return try await yatteeServerAPI(for: instance).fetchServerInfo(for: instance)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Search Result
|
||||
|
||||
enum OrderedSearchItem: Sendable {
|
||||
case video(Video)
|
||||
case channel(Channel)
|
||||
case playlist(Playlist)
|
||||
}
|
||||
|
||||
struct SearchResult: Sendable {
|
||||
let videos: [Video]
|
||||
let channels: [Channel]
|
||||
let playlists: [Playlist]
|
||||
let orderedItems: [OrderedSearchItem] // Preserves original API order
|
||||
let nextPage: Int?
|
||||
|
||||
static let empty = SearchResult(videos: [], channels: [], playlists: [], orderedItems: [], nextPage: nil)
|
||||
}
|
||||
|
||||
// MARK: - Channel Search Result
|
||||
|
||||
/// Item in channel search results, preserving API order for mixed video/playlist display.
|
||||
enum ChannelSearchItem: Sendable, Identifiable {
|
||||
case video(Video)
|
||||
case playlist(Playlist)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .video(let video): return "video-\(video.id.videoID)"
|
||||
case .playlist(let playlist): return "playlist-\(playlist.id.playlistID)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Page of channel search results.
|
||||
struct ChannelSearchPage: Sendable {
|
||||
let items: [ChannelSearchItem]
|
||||
let nextPage: Int?
|
||||
|
||||
static let empty = ChannelSearchPage(items: [], nextPage: nil)
|
||||
}
|
||||
85
Yattee/Services/API/GitHubAPI.swift
Normal file
85
Yattee/Services/API/GitHubAPI.swift
Normal file
@@ -0,0 +1,85 @@
|
||||
//
|
||||
// GitHubAPI.swift
|
||||
// Yattee
|
||||
//
|
||||
// GitHub API client for fetching repository contributors.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// GitHub API client with caching.
|
||||
actor GitHubAPI {
|
||||
private let httpClient: HTTPClient
|
||||
|
||||
/// Cache for contributors with timestamp.
|
||||
private var contributorsCache: (contributors: [GitHubContributor], timestamp: Date)?
|
||||
|
||||
/// Cache duration: 1 hour.
|
||||
private static let cacheDuration: TimeInterval = 60 * 60
|
||||
|
||||
/// GitHub API base URL.
|
||||
private static let baseURL = URL(string: "https://api.github.com")!
|
||||
|
||||
init(httpClient: HTTPClient) {
|
||||
self.httpClient = httpClient
|
||||
}
|
||||
|
||||
/// Fetches contributors for the Yattee repository.
|
||||
/// Results are cached for 1 hour.
|
||||
func contributors() async throws -> [GitHubContributor] {
|
||||
// Check cache first
|
||||
if let cached = contributorsCache,
|
||||
Date().timeIntervalSince(cached.timestamp) < Self.cacheDuration {
|
||||
return cached.contributors
|
||||
}
|
||||
|
||||
var components = URLComponents(
|
||||
url: Self.baseURL.appendingPathComponent("/repos/yattee/yattee/contributors"),
|
||||
resolvingAgainstBaseURL: false
|
||||
)!
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "per_page", value: "100")
|
||||
]
|
||||
|
||||
guard let url = components.url else {
|
||||
throw APIError.invalidRequest
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.timeoutInterval = 15
|
||||
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
|
||||
|
||||
do {
|
||||
let data = try await httpClient.performRaw(request)
|
||||
let decoder = JSONDecoder()
|
||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
let contributors = try decoder.decode([GitHubContributor].self, from: data)
|
||||
|
||||
// Cache the result
|
||||
contributorsCache = (contributors, Date())
|
||||
|
||||
Task { @MainActor in
|
||||
LoggingService.shared.debug("GitHub: fetched \(contributors.count) contributors", category: .api)
|
||||
}
|
||||
|
||||
return contributors
|
||||
} catch let error as APIError {
|
||||
if case .rateLimited = error {
|
||||
Task { @MainActor in
|
||||
LoggingService.shared.warning("GitHub API rate limited", category: .api)
|
||||
}
|
||||
}
|
||||
throw error
|
||||
} catch let error as DecodingError {
|
||||
Task { @MainActor in
|
||||
LoggingService.shared.error("GitHub decode error: \(error)", category: .api)
|
||||
}
|
||||
throw APIError.decodingError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears the cache.
|
||||
func clearCache() {
|
||||
contributorsCache = nil
|
||||
}
|
||||
}
|
||||
66
Yattee/Services/API/InstanceAPI.swift
Normal file
66
Yattee/Services/API/InstanceAPI.swift
Normal file
@@ -0,0 +1,66 @@
|
||||
//
|
||||
// InstanceAPI.swift
|
||||
// Yattee
|
||||
//
|
||||
// Protocol defining the API interface that each backend must implement.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Protocol that each backend API (Invidious, Piped, PeerTube) must implement.
|
||||
protocol InstanceAPI: Sendable {
|
||||
func trending(instance: Instance) async throws -> [Video]
|
||||
func popular(instance: Instance) async throws -> [Video]
|
||||
func search(query: String, instance: Instance, page: Int, filters: SearchFilters) async throws -> SearchResult
|
||||
func searchSuggestions(query: String, instance: Instance) async throws -> [String]
|
||||
func video(id: String, instance: Instance) async throws -> Video
|
||||
func channel(id: String, instance: Instance) async throws -> Channel
|
||||
func channelVideos(id: String, instance: Instance, continuation: String?) async throws -> ChannelVideosPage
|
||||
func channelPlaylists(id: String, instance: Instance, continuation: String?) async throws -> ChannelPlaylistsPage
|
||||
func channelShorts(id: String, instance: Instance, continuation: String?) async throws -> ChannelVideosPage
|
||||
func channelStreams(id: String, instance: Instance, continuation: String?) async throws -> ChannelVideosPage
|
||||
func playlist(id: String, instance: Instance) async throws -> Playlist
|
||||
func comments(videoID: String, instance: Instance, continuation: String?) async throws -> CommentsPage
|
||||
func streams(videoID: String, instance: Instance) async throws -> [Stream]
|
||||
func captions(videoID: String, instance: Instance) async throws -> [Caption]
|
||||
func channelSearch(id: String, query: String, instance: Instance, page: Int) async throws -> ChannelSearchPage
|
||||
}
|
||||
|
||||
// MARK: - Default Implementations
|
||||
|
||||
extension InstanceAPI {
|
||||
/// Default implementation for backends that don't distinguish popular from trending.
|
||||
func popular(instance: Instance) async throws -> [Video] {
|
||||
try await trending(instance: instance)
|
||||
}
|
||||
|
||||
/// Default implementation for backends that don't support search suggestions.
|
||||
func searchSuggestions(query: String, instance: Instance) async throws -> [String] {
|
||||
[]
|
||||
}
|
||||
|
||||
/// Default implementation for backends that don't support captions.
|
||||
func captions(videoID: String, instance: Instance) async throws -> [Caption] {
|
||||
[]
|
||||
}
|
||||
|
||||
/// Default implementation for backends that don't support channel playlists tab.
|
||||
func channelPlaylists(id: String, instance: Instance, continuation: String?) async throws -> ChannelPlaylistsPage {
|
||||
ChannelPlaylistsPage(playlists: [], continuation: nil)
|
||||
}
|
||||
|
||||
/// Default implementation for backends that don't support channel shorts tab.
|
||||
func channelShorts(id: String, instance: Instance, continuation: String?) async throws -> ChannelVideosPage {
|
||||
ChannelVideosPage(videos: [], continuation: nil)
|
||||
}
|
||||
|
||||
/// Default implementation for backends that don't support channel streams tab.
|
||||
func channelStreams(id: String, instance: Instance, continuation: String?) async throws -> ChannelVideosPage {
|
||||
ChannelVideosPage(videos: [], continuation: nil)
|
||||
}
|
||||
|
||||
/// Default implementation for backends that don't support channel search.
|
||||
func channelSearch(id: String, query: String, instance: Instance, page: Int) async throws -> ChannelSearchPage {
|
||||
throw APIError.notSupported
|
||||
}
|
||||
}
|
||||
380
Yattee/Services/API/InstanceDetector.swift
Normal file
380
Yattee/Services/API/InstanceDetector.swift
Normal file
@@ -0,0 +1,380 @@
|
||||
//
|
||||
// InstanceDetector.swift
|
||||
// Yattee
|
||||
//
|
||||
// Automatically detects backend instance type by probing API endpoints.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Errors that can occur during instance detection.
|
||||
enum DetectionError: Error, Sendable {
|
||||
case sslCertificateError
|
||||
case networkError(String)
|
||||
case unknownType
|
||||
case invalidURL
|
||||
case timeout
|
||||
|
||||
var localizedDescription: String {
|
||||
switch self {
|
||||
case .sslCertificateError:
|
||||
return String(localized: "sources.error.sslCertificate")
|
||||
case .networkError(let message):
|
||||
return message
|
||||
case .unknownType:
|
||||
return String(localized: "sources.error.couldNotDetect")
|
||||
case .invalidURL:
|
||||
return String(localized: "sources.validation.invalidURL")
|
||||
case .timeout:
|
||||
return String(localized: "sources.error.timeout")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of instance detection including type and authentication requirements.
|
||||
struct InstanceDetectionResult: Sendable {
|
||||
let type: InstanceType
|
||||
/// Whether this instance requires authentication (Basic Auth for Yattee Server).
|
||||
let requiresAuth: Bool
|
||||
|
||||
init(type: InstanceType, requiresAuth: Bool = false) {
|
||||
self.type = type
|
||||
self.requiresAuth = requiresAuth
|
||||
}
|
||||
}
|
||||
|
||||
/// Detects the type of a backend instance by probing known API endpoints.
|
||||
actor InstanceDetector {
|
||||
private let httpClient: HTTPClient
|
||||
|
||||
init(httpClient: HTTPClient) {
|
||||
self.httpClient = httpClient
|
||||
}
|
||||
|
||||
/// Detects the instance type for a given URL.
|
||||
/// - Parameter url: The base URL of the instance.
|
||||
/// - Returns: The detected instance type, or nil if detection failed.
|
||||
func detect(url: URL) async -> InstanceType? {
|
||||
let result = await detectWithAuth(url: url)
|
||||
return result?.type
|
||||
}
|
||||
|
||||
/// Detects the instance type and authentication requirements for a given URL.
|
||||
/// - Parameter url: The base URL of the instance.
|
||||
/// - Returns: The detection result including type and auth requirements, or nil if detection failed.
|
||||
func detectWithAuth(url: URL) async -> InstanceDetectionResult? {
|
||||
let result = await detectWithResult(url: url)
|
||||
switch result {
|
||||
case .success(let detectionResult):
|
||||
return detectionResult
|
||||
case .failure:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Detects the instance type with detailed error reporting.
|
||||
/// - Parameter url: The base URL of the instance.
|
||||
/// - Returns: Result containing either the detection result or a detailed error.
|
||||
func detectWithResult(url: URL) async -> Result<InstanceDetectionResult, DetectionError> {
|
||||
// Try each detection method in order of specificity
|
||||
// Check Yattee Server first as it's most specific
|
||||
do {
|
||||
if let result = try await detectYatteeServerWithError(url: url) {
|
||||
return .success(result)
|
||||
}
|
||||
} catch let error as DetectionError {
|
||||
return .failure(error)
|
||||
} catch {
|
||||
// Continue to next detection method
|
||||
}
|
||||
|
||||
if await isPeerTube(url: url) {
|
||||
return .success(InstanceDetectionResult(type: .peertube))
|
||||
}
|
||||
|
||||
if await isInvidious(url: url) {
|
||||
return .success(InstanceDetectionResult(type: .invidious))
|
||||
}
|
||||
|
||||
if await isPiped(url: url) {
|
||||
return .success(InstanceDetectionResult(type: .piped))
|
||||
}
|
||||
|
||||
return .failure(.unknownType)
|
||||
}
|
||||
|
||||
// MARK: - Detection Methods
|
||||
|
||||
/// Detects if the instance is a Yattee Server.
|
||||
/// Auth is always required for Yattee Server (after initial setup).
|
||||
/// - Parameter url: The base URL to check.
|
||||
/// - Returns: Detection result with type (always requiresAuth=true), or nil if not a Yattee Server.
|
||||
private func detectYatteeServer(url: URL) async -> InstanceDetectionResult? {
|
||||
try? await detectYatteeServerWithError(url: url)
|
||||
}
|
||||
|
||||
/// Detects if the instance is a Yattee Server with detailed error reporting.
|
||||
private func detectYatteeServerWithError(url: URL) async throws -> InstanceDetectionResult? {
|
||||
let endpoint = GenericEndpoint.get("/info")
|
||||
|
||||
do {
|
||||
// First, get raw data to debug the response
|
||||
let rawData = try await httpClient.fetchData(endpoint, baseURL: url)
|
||||
if let rawString = String(data: rawData, encoding: .utf8) {
|
||||
LoggingService.shared.debug("[InstanceDetector] Raw /info response: \(rawString)", category: .api)
|
||||
}
|
||||
|
||||
let response = try JSONDecoder().decode(InstanceDetectorModels.YatteeServerInfo.self, from: rawData)
|
||||
LoggingService.shared.debug("[InstanceDetector] Parsed YatteeServerInfo: name=\(response.name ?? "nil")", category: .api)
|
||||
|
||||
// Yattee Server returns name containing "yattee"
|
||||
if response.name?.lowercased().contains("yattee") == true {
|
||||
// Auth is always required for Yattee Server
|
||||
let result = InstanceDetectionResult(
|
||||
type: .yatteeServer,
|
||||
requiresAuth: true
|
||||
)
|
||||
LoggingService.shared.debug("[InstanceDetector] Returning result: type=yatteeServer, requiresAuth=true", category: .api)
|
||||
return result
|
||||
}
|
||||
return nil
|
||||
} catch let urlError as URLError {
|
||||
LoggingService.shared.error("[InstanceDetector] detectYatteeServer URLError", category: .api, details: urlError.localizedDescription)
|
||||
// Check for SSL certificate errors
|
||||
if urlError.code == .serverCertificateUntrusted ||
|
||||
urlError.code == .serverCertificateHasBadDate ||
|
||||
urlError.code == .serverCertificateHasUnknownRoot ||
|
||||
urlError.code == .serverCertificateNotYetValid ||
|
||||
urlError.code == .clientCertificateRejected {
|
||||
throw DetectionError.sslCertificateError
|
||||
}
|
||||
if urlError.code == .timedOut {
|
||||
throw DetectionError.timeout
|
||||
}
|
||||
throw DetectionError.networkError(urlError.localizedDescription)
|
||||
} catch {
|
||||
LoggingService.shared.error("[InstanceDetector] detectYatteeServer error", category: .api, details: error.localizedDescription)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if the instance is PeerTube by calling /api/v1/config
|
||||
private func isPeerTube(url: URL) async -> Bool {
|
||||
let endpoint = GenericEndpoint.get("/api/v1/config")
|
||||
|
||||
do {
|
||||
let response: InstanceDetectorModels.PeerTubeConfig = try await httpClient.fetch(endpoint, baseURL: url)
|
||||
// PeerTube config has specific fields
|
||||
return response.instance != nil || response.serverVersion != nil
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if the instance is Invidious by calling /api/v1/stats
|
||||
private func isInvidious(url: URL) async -> Bool {
|
||||
let endpoint = GenericEndpoint.get("/api/v1/stats")
|
||||
|
||||
do {
|
||||
let response: InstanceDetectorModels.InvidiousStats = try await httpClient.fetch(endpoint, baseURL: url)
|
||||
// Invidious stats has software.name = "invidious"
|
||||
return response.software?.name?.lowercased() == "invidious"
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if the instance is Piped by probing Piped-specific endpoints
|
||||
private func isPiped(url: URL) async -> Bool {
|
||||
// Piped has a /healthcheck endpoint that returns "OK"
|
||||
let healthEndpoint = GenericEndpoint.get("/healthcheck")
|
||||
|
||||
do {
|
||||
let data = try await httpClient.fetchData(healthEndpoint, baseURL: url)
|
||||
if let text = String(data: data, encoding: .utf8), text.contains("OK") {
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
// Continue to next check
|
||||
}
|
||||
|
||||
// Also try /config endpoint which Piped uses
|
||||
let configEndpoint = GenericEndpoint.get("/config")
|
||||
|
||||
do {
|
||||
let response: InstanceDetectorModels.PipedConfig = try await httpClient.fetch(configEndpoint, baseURL: url)
|
||||
// Piped config has specific fields
|
||||
return response.donationUrl != nil || response.statusPageUrl != nil
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Detection Response Models
|
||||
|
||||
/// Namespace for instance detection response models.
|
||||
/// Using an enum as a namespace to avoid MainActor isolation issues.
|
||||
enum InstanceDetectorModels {
|
||||
struct YatteeServerInfo: Sendable {
|
||||
let name: String?
|
||||
let version: String?
|
||||
let description: String?
|
||||
}
|
||||
|
||||
/// Full server info response from /info endpoint for display in UI.
|
||||
struct YatteeServerFullInfo: Sendable {
|
||||
let name: String?
|
||||
let version: String?
|
||||
let dependencies: Dependencies?
|
||||
let config: Config?
|
||||
let sites: [Site]?
|
||||
|
||||
struct Dependencies: Sendable {
|
||||
let ytDlp: String?
|
||||
let ffmpeg: String?
|
||||
}
|
||||
|
||||
struct Config: Sendable {
|
||||
let invidiousInstance: String?
|
||||
let allowAllSitesForExtraction: Bool?
|
||||
}
|
||||
|
||||
struct Site: Sendable {
|
||||
let name: String
|
||||
let extractorPattern: String?
|
||||
}
|
||||
}
|
||||
|
||||
struct PeerTubeConfig: Sendable {
|
||||
let instance: PeerTubeInstanceInfo?
|
||||
let serverVersion: String?
|
||||
|
||||
struct PeerTubeInstanceInfo: Sendable {
|
||||
let name: String?
|
||||
let shortDescription: String?
|
||||
}
|
||||
}
|
||||
|
||||
struct InvidiousStats: Sendable {
|
||||
let software: InvidiousSoftware?
|
||||
|
||||
struct InvidiousSoftware: Sendable {
|
||||
let name: String?
|
||||
let version: String?
|
||||
}
|
||||
}
|
||||
|
||||
struct PipedConfig: Sendable {
|
||||
let donationUrl: String?
|
||||
let statusPageUrl: String?
|
||||
let s3Enabled: Bool?
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Decodable Conformance (nonisolated)
|
||||
|
||||
extension InstanceDetectorModels.YatteeServerInfo: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name, version, description
|
||||
}
|
||||
|
||||
nonisolated init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
name = try container.decodeIfPresent(String.self, forKey: .name)
|
||||
version = try container.decodeIfPresent(String.self, forKey: .version)
|
||||
description = try container.decodeIfPresent(String.self, forKey: .description)
|
||||
}
|
||||
}
|
||||
|
||||
extension InstanceDetectorModels.YatteeServerFullInfo: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name, version, dependencies, config, sites
|
||||
}
|
||||
|
||||
nonisolated init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
name = try container.decodeIfPresent(String.self, forKey: .name)
|
||||
version = try container.decodeIfPresent(String.self, forKey: .version)
|
||||
dependencies = try container.decodeIfPresent(Dependencies.self, forKey: .dependencies)
|
||||
config = try container.decodeIfPresent(Config.self, forKey: .config)
|
||||
sites = try container.decodeIfPresent([Site].self, forKey: .sites)
|
||||
}
|
||||
}
|
||||
|
||||
extension InstanceDetectorModels.YatteeServerFullInfo.Dependencies: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ytDlp = "yt-dlp"
|
||||
case ffmpeg
|
||||
}
|
||||
|
||||
nonisolated init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
ytDlp = try container.decodeIfPresent(String.self, forKey: .ytDlp)
|
||||
ffmpeg = try container.decodeIfPresent(String.self, forKey: .ffmpeg)
|
||||
}
|
||||
}
|
||||
|
||||
// Config and Site use automatic Decodable synthesis since HTTPClient uses .convertFromSnakeCase
|
||||
extension InstanceDetectorModels.YatteeServerFullInfo.Config: Decodable {}
|
||||
extension InstanceDetectorModels.YatteeServerFullInfo.Site: Decodable {}
|
||||
|
||||
extension InstanceDetectorModels.PeerTubeConfig: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case instance, serverVersion
|
||||
}
|
||||
|
||||
nonisolated init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
instance = try container.decodeIfPresent(PeerTubeInstanceInfo.self, forKey: .instance)
|
||||
serverVersion = try container.decodeIfPresent(String.self, forKey: .serverVersion)
|
||||
}
|
||||
}
|
||||
|
||||
extension InstanceDetectorModels.PeerTubeConfig.PeerTubeInstanceInfo: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name, shortDescription
|
||||
}
|
||||
|
||||
nonisolated init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
name = try container.decodeIfPresent(String.self, forKey: .name)
|
||||
shortDescription = try container.decodeIfPresent(String.self, forKey: .shortDescription)
|
||||
}
|
||||
}
|
||||
|
||||
extension InstanceDetectorModels.InvidiousStats: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case software
|
||||
}
|
||||
|
||||
nonisolated init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
software = try container.decodeIfPresent(InvidiousSoftware.self, forKey: .software)
|
||||
}
|
||||
}
|
||||
|
||||
extension InstanceDetectorModels.InvidiousStats.InvidiousSoftware: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name, version
|
||||
}
|
||||
|
||||
nonisolated init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
name = try container.decodeIfPresent(String.self, forKey: .name)
|
||||
version = try container.decodeIfPresent(String.self, forKey: .version)
|
||||
}
|
||||
}
|
||||
|
||||
extension InstanceDetectorModels.PipedConfig: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case donationUrl, statusPageUrl, s3Enabled
|
||||
}
|
||||
|
||||
nonisolated init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
donationUrl = try container.decodeIfPresent(String.self, forKey: .donationUrl)
|
||||
statusPageUrl = try container.decodeIfPresent(String.self, forKey: .statusPageUrl)
|
||||
s3Enabled = try container.decodeIfPresent(Bool.self, forKey: .s3Enabled)
|
||||
}
|
||||
}
|
||||
1501
Yattee/Services/API/InvidiousAPI.swift
Normal file
1501
Yattee/Services/API/InvidiousAPI.swift
Normal file
File diff suppressed because it is too large
Load Diff
520
Yattee/Services/API/PeerTubeAPI.swift
Normal file
520
Yattee/Services/API/PeerTubeAPI.swift
Normal file
@@ -0,0 +1,520 @@
|
||||
//
|
||||
// PeerTubeAPI.swift
|
||||
// Yattee
|
||||
//
|
||||
// PeerTube API implementation.
|
||||
// API Documentation: https://docs.joinpeertube.org/api-rest-reference.html
|
||||
//
|
||||
|
||||
@preconcurrency import Foundation
|
||||
|
||||
/// PeerTube API client for federated video content.
|
||||
actor PeerTubeAPI: InstanceAPI {
|
||||
private let httpClient: HTTPClient
|
||||
|
||||
init(httpClient: HTTPClient) {
|
||||
self.httpClient = httpClient
|
||||
}
|
||||
|
||||
// MARK: - InstanceAPI
|
||||
|
||||
func trending(instance: Instance) async throws -> [Video] {
|
||||
let endpoint = GenericEndpoint.get("/api/v1/videos", query: [
|
||||
"sort": "-trending",
|
||||
"count": "20"
|
||||
])
|
||||
let response: PeerTubeVideoList = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||
return response.data.map { $0.toVideo(instanceURL: instance.url) }
|
||||
}
|
||||
|
||||
func popular(instance: Instance) async throws -> [Video] {
|
||||
let endpoint = GenericEndpoint.get("/api/v1/videos", query: [
|
||||
"sort": "-views",
|
||||
"count": "20"
|
||||
])
|
||||
let response: PeerTubeVideoList = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||
return response.data.map { $0.toVideo(instanceURL: instance.url) }
|
||||
}
|
||||
|
||||
func search(query: String, instance: Instance, page: Int, filters: SearchFilters) async throws -> SearchResult {
|
||||
let start = (page - 1) * 20
|
||||
let endpoint = GenericEndpoint.get("/api/v1/search/videos", query: [
|
||||
"search": query,
|
||||
"start": String(start),
|
||||
"count": "20"
|
||||
])
|
||||
let response: PeerTubeVideoList = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||
|
||||
let videos = response.data.map { $0.toVideo(instanceURL: instance.url) }
|
||||
let hasMore = start + videos.count < response.total
|
||||
|
||||
return SearchResult(
|
||||
videos: videos,
|
||||
channels: [],
|
||||
playlists: [],
|
||||
orderedItems: videos.map { .video($0) },
|
||||
nextPage: hasMore ? page + 1 : nil
|
||||
)
|
||||
}
|
||||
|
||||
func video(id: String, instance: Instance) async throws -> Video {
|
||||
let endpoint = GenericEndpoint.get("/api/v1/videos/\(id)")
|
||||
let response: PeerTubeVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||
return response.toVideo(instanceURL: instance.url)
|
||||
}
|
||||
|
||||
func channel(id: String, instance: Instance) async throws -> Channel {
|
||||
let endpoint = GenericEndpoint.get("/api/v1/video-channels/\(id)")
|
||||
let response: PeerTubeChannel = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||
return response.toChannel(instanceURL: instance.url)
|
||||
}
|
||||
|
||||
func channelVideos(id: String, instance: Instance, continuation: String?) async throws -> ChannelVideosPage {
|
||||
let pageSize = 20
|
||||
let start = continuation.flatMap { Int($0) } ?? 0
|
||||
let endpoint = GenericEndpoint.get("/api/v1/video-channels/\(id)/videos", query: [
|
||||
"start": String(start),
|
||||
"count": String(pageSize)
|
||||
])
|
||||
let response: PeerTubeVideoList = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||
let videos = response.data.map { $0.toVideo(instanceURL: instance.url) }
|
||||
// If we got a full page, there might be more
|
||||
let nextContinuation = videos.count == pageSize ? String(start + pageSize) : nil
|
||||
return ChannelVideosPage(videos: videos, continuation: nextContinuation)
|
||||
}
|
||||
|
||||
func playlist(id: String, instance: Instance) async throws -> Playlist {
|
||||
let endpoint = GenericEndpoint.get("/api/v1/video-playlists/\(id)")
|
||||
let response: PeerTubePlaylist = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||
|
||||
// Fetch playlist videos
|
||||
let videosEndpoint = GenericEndpoint.get("/api/v1/video-playlists/\(id)/videos")
|
||||
let videosResponse: PeerTubePlaylistVideos = try await httpClient.fetch(videosEndpoint, baseURL: instance.url)
|
||||
|
||||
return response.toPlaylist(instanceURL: instance.url, videos: videosResponse.data)
|
||||
}
|
||||
|
||||
func comments(videoID: String, instance: Instance, continuation: String?) async throws -> CommentsPage {
|
||||
// PeerTube uses offset-based pagination, continuation is the offset as string
|
||||
let offset = continuation.flatMap { Int($0) } ?? 0
|
||||
let count = 20
|
||||
let endpoint = GenericEndpoint.get("/api/v1/videos/\(videoID)/comment-threads", query: [
|
||||
"start": String(offset),
|
||||
"count": String(count)
|
||||
])
|
||||
let response: PeerTubeCommentList = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||
let nextOffset = offset + response.data.count
|
||||
let hasMore = nextOffset < response.total
|
||||
return CommentsPage(
|
||||
comments: response.data.map { $0.toComment(instanceURL: instance.url) },
|
||||
continuation: hasMore ? String(nextOffset) : nil
|
||||
)
|
||||
}
|
||||
|
||||
func streams(videoID: String, instance: Instance) async throws -> [Stream] {
|
||||
let endpoint = GenericEndpoint.get("/api/v1/videos/\(videoID)")
|
||||
let response: PeerTubeVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||
return response.toStreams(instanceURL: instance.url)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PeerTube Response Models
|
||||
|
||||
private struct PeerTubeVideoList: Decodable, Sendable {
|
||||
let total: Int
|
||||
let data: [PeerTubeVideo]
|
||||
}
|
||||
|
||||
private struct PeerTubeVideo: Decodable, Sendable {
|
||||
let id: Int
|
||||
let uuid: String
|
||||
let shortUUID: String?
|
||||
let name: String
|
||||
let description: String?
|
||||
let duration: Int
|
||||
let views: Int?
|
||||
let likes: Int?
|
||||
let dislikes: Int?
|
||||
let thumbnailPath: String?
|
||||
let previewPath: String?
|
||||
let publishedAt: String?
|
||||
let originallyPublishedAt: String?
|
||||
let createdAt: String?
|
||||
let channel: PeerTubeVideoChannel?
|
||||
let account: PeerTubeAccount?
|
||||
let isLive: Bool?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, uuid, name, description, duration, views, likes, dislikes, channel, account
|
||||
case shortUUID, thumbnailPath, previewPath, publishedAt, originallyPublishedAt, createdAt, isLive
|
||||
}
|
||||
|
||||
nonisolated func toVideo(instanceURL: URL) -> Video {
|
||||
let publishDate = publishedAt.flatMap { ISO8601DateFormatter().date(from: $0) }
|
||||
|
||||
var thumbnails: [Thumbnail] = []
|
||||
if let thumbnailPath {
|
||||
let thumbURL = instanceURL.appendingPathComponent(thumbnailPath)
|
||||
thumbnails.append(Thumbnail(url: thumbURL, quality: .medium))
|
||||
}
|
||||
if let previewPath {
|
||||
let previewURL = instanceURL.appendingPathComponent(previewPath)
|
||||
thumbnails.append(Thumbnail(url: previewURL, quality: .high))
|
||||
}
|
||||
|
||||
// For federated channels, use the channel's actual host instance
|
||||
let channelInstance: URL
|
||||
if let channelHost = channel?.host,
|
||||
let hostURL = URL(string: "https://\(channelHost)"),
|
||||
hostURL.host != instanceURL.host {
|
||||
channelInstance = hostURL
|
||||
} else {
|
||||
channelInstance = instanceURL
|
||||
}
|
||||
|
||||
return Video(
|
||||
id: .federated(String(id), instance: instanceURL, uuid: uuid),
|
||||
title: name,
|
||||
description: description,
|
||||
author: Author(
|
||||
id: channel?.name ?? account?.name ?? "",
|
||||
name: channel?.displayName ?? account?.displayName ?? "",
|
||||
thumbnailURL: (channel?.avatar ?? account?.avatar)
|
||||
.flatMap { instanceURL.appendingPathComponent($0.path) },
|
||||
instance: channelInstance
|
||||
),
|
||||
duration: TimeInterval(duration),
|
||||
publishedAt: publishDate,
|
||||
publishedText: nil,
|
||||
viewCount: views,
|
||||
likeCount: likes,
|
||||
thumbnails: thumbnails,
|
||||
isLive: isLive ?? false,
|
||||
isUpcoming: false,
|
||||
scheduledStartTime: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct PeerTubeVideoDetails: Decodable, Sendable {
|
||||
let id: Int
|
||||
let uuid: String
|
||||
let shortUUID: String?
|
||||
let name: String
|
||||
let description: String?
|
||||
let duration: Int
|
||||
let views: Int?
|
||||
let likes: Int?
|
||||
let dislikes: Int?
|
||||
let thumbnailPath: String?
|
||||
let previewPath: String?
|
||||
let publishedAt: String?
|
||||
let channel: PeerTubeVideoChannel?
|
||||
let account: PeerTubeAccount?
|
||||
let isLive: Bool?
|
||||
let files: [PeerTubeVideoFile]?
|
||||
let streamingPlaylists: [PeerTubeStreamingPlaylist]?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, uuid, name, description, duration, views, likes, dislikes, channel, account, files
|
||||
case shortUUID, thumbnailPath, previewPath, publishedAt, isLive, streamingPlaylists
|
||||
}
|
||||
|
||||
nonisolated func toVideo(instanceURL: URL) -> Video {
|
||||
let publishDate = publishedAt.flatMap { ISO8601DateFormatter().date(from: $0) }
|
||||
|
||||
var thumbnails: [Thumbnail] = []
|
||||
if let thumbnailPath {
|
||||
let thumbURL = instanceURL.appendingPathComponent(thumbnailPath)
|
||||
thumbnails.append(Thumbnail(url: thumbURL, quality: .medium))
|
||||
}
|
||||
if let previewPath {
|
||||
let previewURL = instanceURL.appendingPathComponent(previewPath)
|
||||
thumbnails.append(Thumbnail(url: previewURL, quality: .high))
|
||||
}
|
||||
|
||||
// For federated channels, use the channel's actual host instance
|
||||
let channelInstance: URL
|
||||
if let channelHost = channel?.host,
|
||||
let hostURL = URL(string: "https://\(channelHost)"),
|
||||
hostURL.host != instanceURL.host {
|
||||
channelInstance = hostURL
|
||||
} else {
|
||||
channelInstance = instanceURL
|
||||
}
|
||||
|
||||
return Video(
|
||||
id: .federated(String(id), instance: instanceURL, uuid: uuid),
|
||||
title: name,
|
||||
description: description,
|
||||
author: Author(
|
||||
id: channel?.name ?? account?.name ?? "",
|
||||
name: channel?.displayName ?? account?.displayName ?? "",
|
||||
thumbnailURL: (channel?.avatar ?? account?.avatar)
|
||||
.flatMap { instanceURL.appendingPathComponent($0.path) },
|
||||
instance: channelInstance
|
||||
),
|
||||
duration: TimeInterval(duration),
|
||||
publishedAt: publishDate,
|
||||
publishedText: nil,
|
||||
viewCount: views,
|
||||
likeCount: likes,
|
||||
thumbnails: thumbnails,
|
||||
isLive: isLive ?? false,
|
||||
isUpcoming: false,
|
||||
scheduledStartTime: nil
|
||||
)
|
||||
}
|
||||
|
||||
nonisolated func toStreams(instanceURL: URL) -> [Stream] {
|
||||
var streams: [Stream] = []
|
||||
|
||||
// Add HLS streams
|
||||
if let streamingPlaylists {
|
||||
for playlist in streamingPlaylists {
|
||||
if let playlistUrl = URL(string: playlist.playlistUrl) {
|
||||
streams.append(Stream(
|
||||
url: playlistUrl,
|
||||
resolution: nil,
|
||||
format: "hls",
|
||||
isLive: isLive ?? false,
|
||||
mimeType: "application/x-mpegURL"
|
||||
))
|
||||
}
|
||||
|
||||
// Add individual resolution files from HLS
|
||||
for file in playlist.files ?? [] {
|
||||
if let fileUrl = URL(string: file.fileUrl) {
|
||||
streams.append(Stream(
|
||||
url: fileUrl,
|
||||
resolution: file.resolution.flatMap {
|
||||
StreamResolution(width: 0, height: $0.id)
|
||||
},
|
||||
format: "mp4",
|
||||
audioCodec: "aac", // Mark as muxed (PeerTube MP4s contain audio)
|
||||
fileSize: file.size,
|
||||
mimeType: file.metadataUrl.flatMap { _ in "video/mp4" }
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add direct file downloads
|
||||
if let files {
|
||||
for file in files {
|
||||
if let fileUrl = URL(string: file.fileUrl) {
|
||||
streams.append(Stream(
|
||||
url: fileUrl,
|
||||
resolution: file.resolution.flatMap {
|
||||
StreamResolution(width: 0, height: $0.id)
|
||||
},
|
||||
format: "mp4",
|
||||
audioCodec: "aac", // Mark as muxed (PeerTube MP4s contain audio)
|
||||
fileSize: file.size,
|
||||
mimeType: "video/mp4"
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return streams
|
||||
}
|
||||
}
|
||||
|
||||
private struct PeerTubeVideoFile: Decodable, Sendable {
|
||||
let fileUrl: String
|
||||
let fileDownloadUrl: String?
|
||||
let resolution: PeerTubeResolution?
|
||||
let size: Int64?
|
||||
let fps: Int?
|
||||
let metadataUrl: String?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case resolution, size, fps
|
||||
case fileUrl, fileDownloadUrl, metadataUrl
|
||||
}
|
||||
}
|
||||
|
||||
private struct PeerTubeResolution: Decodable, Sendable {
|
||||
let id: Int
|
||||
let label: String
|
||||
}
|
||||
|
||||
private struct PeerTubeStreamingPlaylist: Decodable, Sendable {
|
||||
let id: Int
|
||||
let type: Int
|
||||
let playlistUrl: String
|
||||
let files: [PeerTubeVideoFile]?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, type, files
|
||||
case playlistUrl
|
||||
}
|
||||
}
|
||||
|
||||
private struct PeerTubeVideoChannel: Decodable, Sendable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let displayName: String
|
||||
let description: String?
|
||||
let url: String?
|
||||
let host: String?
|
||||
let avatar: PeerTubeAvatar?
|
||||
let banner: PeerTubeAvatar?
|
||||
let followersCount: Int?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, name, description, url, host, avatar, banner
|
||||
case displayName, followersCount
|
||||
}
|
||||
}
|
||||
|
||||
private struct PeerTubeAccount: Decodable, Sendable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let displayName: String
|
||||
let description: String?
|
||||
let url: String?
|
||||
let host: String?
|
||||
let avatar: PeerTubeAvatar?
|
||||
let followersCount: Int?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, name, description, url, host, avatar
|
||||
case displayName, followersCount
|
||||
}
|
||||
}
|
||||
|
||||
private struct PeerTubeAvatar: Decodable, Sendable {
|
||||
let path: String
|
||||
let width: Int?
|
||||
let createdAt: String?
|
||||
let updatedAt: String?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case path, width
|
||||
case createdAt, updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
private struct PeerTubeChannel: Decodable, Sendable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let displayName: String
|
||||
let description: String?
|
||||
let url: String?
|
||||
let host: String?
|
||||
let avatar: PeerTubeAvatar?
|
||||
let banner: PeerTubeAvatar?
|
||||
let followersCount: Int?
|
||||
let videosCount: Int?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, name, description, url, host, avatar, banner
|
||||
case displayName, followersCount, videosCount
|
||||
}
|
||||
|
||||
nonisolated func toChannel(instanceURL: URL) -> Channel {
|
||||
Channel(
|
||||
id: .federated(name, instance: instanceURL),
|
||||
name: displayName,
|
||||
description: description,
|
||||
subscriberCount: followersCount,
|
||||
videoCount: videosCount,
|
||||
thumbnailURL: avatar.map { instanceURL.appendingPathComponent($0.path) },
|
||||
bannerURL: banner.map { instanceURL.appendingPathComponent($0.path) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct PeerTubePlaylist: Decodable, Sendable {
|
||||
let id: Int
|
||||
let uuid: String
|
||||
let displayName: String
|
||||
let description: String?
|
||||
let thumbnailPath: String?
|
||||
let videosLength: Int
|
||||
let ownerAccount: PeerTubeAccount?
|
||||
let videoChannel: PeerTubeVideoChannel?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, uuid, description
|
||||
case displayName, thumbnailPath, videosLength, ownerAccount, videoChannel
|
||||
}
|
||||
|
||||
nonisolated func toPlaylist(instanceURL: URL, videos: [PeerTubePlaylistVideo]) -> Playlist {
|
||||
Playlist(
|
||||
id: .federated(String(id), instance: instanceURL),
|
||||
title: displayName,
|
||||
description: description,
|
||||
author: ownerAccount.map {
|
||||
Author(
|
||||
id: $0.name,
|
||||
name: $0.displayName,
|
||||
thumbnailURL: $0.avatar.map { instanceURL.appendingPathComponent($0.path) },
|
||||
instance: instanceURL
|
||||
)
|
||||
},
|
||||
videoCount: videosLength,
|
||||
thumbnailURL: thumbnailPath.map { instanceURL.appendingPathComponent($0) },
|
||||
videos: videos.map { $0.video.toVideo(instanceURL: instanceURL) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct PeerTubePlaylistVideos: Decodable, Sendable {
|
||||
let total: Int
|
||||
let data: [PeerTubePlaylistVideo]
|
||||
}
|
||||
|
||||
private struct PeerTubePlaylistVideo: Decodable, Sendable {
|
||||
let id: Int
|
||||
let video: PeerTubeVideo
|
||||
let position: Int
|
||||
let startTimestamp: Int?
|
||||
let stopTimestamp: Int?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, video, position
|
||||
case startTimestamp, stopTimestamp
|
||||
}
|
||||
}
|
||||
|
||||
private struct PeerTubeCommentList: Decodable, Sendable {
|
||||
let total: Int
|
||||
let data: [PeerTubeComment]
|
||||
}
|
||||
|
||||
private struct PeerTubeComment: Decodable, Sendable {
|
||||
let id: Int
|
||||
let threadId: Int
|
||||
let text: String
|
||||
let createdAt: String?
|
||||
let updatedAt: String?
|
||||
let account: PeerTubeAccount?
|
||||
let totalReplies: Int?
|
||||
let totalRepliesFromVideoAuthor: Int?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, text, account
|
||||
case threadId, createdAt, updatedAt, totalReplies, totalRepliesFromVideoAuthor
|
||||
}
|
||||
|
||||
nonisolated func toComment(instanceURL: URL) -> Comment {
|
||||
let publishDate = createdAt.flatMap { ISO8601DateFormatter().date(from: $0) }
|
||||
|
||||
return Comment(
|
||||
id: String(id),
|
||||
author: Author(
|
||||
id: account?.name ?? "",
|
||||
name: account?.displayName ?? "",
|
||||
thumbnailURL: account?.avatar.map { instanceURL.appendingPathComponent($0.path) },
|
||||
instance: instanceURL
|
||||
),
|
||||
content: text,
|
||||
publishedAt: publishDate,
|
||||
replyCount: totalReplies ?? 0
|
||||
)
|
||||
}
|
||||
}
|
||||
38
Yattee/Services/API/PeerTubeDirectoryAPI.swift
Normal file
38
Yattee/Services/API/PeerTubeDirectoryAPI.swift
Normal file
@@ -0,0 +1,38 @@
|
||||
//
|
||||
// PeerTubeDirectoryAPI.swift
|
||||
// Yattee
|
||||
//
|
||||
// API client for the PeerTube public instance directory.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// API client for fetching PeerTube instances from the public directory.
|
||||
actor PeerTubeDirectoryAPI {
|
||||
private let httpClient: HTTPClient
|
||||
private let baseURL = URL(string: "https://instances.joinpeertube.org")!
|
||||
|
||||
init(httpClient: HTTPClient) {
|
||||
self.httpClient = httpClient
|
||||
}
|
||||
|
||||
/// Fetches instances from the public directory.
|
||||
/// - Parameters:
|
||||
/// - start: The offset for pagination (default: 0).
|
||||
/// - count: The number of instances to fetch (default: 50).
|
||||
/// - Returns: A response containing the total count and array of instances.
|
||||
/// - Note: The API does not support server-side filtering by language/country.
|
||||
/// Filtering should be done client-side after fetching all instances.
|
||||
func fetchInstances(
|
||||
start: Int = 0,
|
||||
count: Int = 50
|
||||
) async throws -> PeerTubeDirectoryResponse {
|
||||
let query: [String: String] = [
|
||||
"start": String(start),
|
||||
"count": String(count)
|
||||
]
|
||||
|
||||
let endpoint = GenericEndpoint.get("/api/v1/instances", query: query)
|
||||
return try await httpClient.fetch(endpoint, baseURL: baseURL)
|
||||
}
|
||||
}
|
||||
975
Yattee/Services/API/PipedAPI.swift
Normal file
975
Yattee/Services/API/PipedAPI.swift
Normal file
@@ -0,0 +1,975 @@
|
||||
//
|
||||
// PipedAPI.swift
|
||||
// Yattee
|
||||
//
|
||||
// Piped API implementation for YouTube content.
|
||||
// API Documentation: https://docs.piped.video/docs/api-documentation/
|
||||
//
|
||||
|
||||
@preconcurrency import Foundation
|
||||
|
||||
/// Piped API client for fetching YouTube content.
|
||||
actor PipedAPI: InstanceAPI {
|
||||
private let httpClient: HTTPClient
|
||||
|
||||
/// Cache of tab data from channel responses, keyed by channel ID.
|
||||
/// Populated when `channel()` or `channelVideos()` fetches `/channel/{id}`.
|
||||
private var channelTabsCache: [String: [PipedChannelTab]] = [:]
|
||||
|
||||
init(httpClient: HTTPClient) {
|
||||
self.httpClient = httpClient
|
||||
}
|
||||
|
||||
// MARK: - InstanceAPI
|
||||
|
||||
func trending(instance: Instance) async throws -> [Video] {
|
||||
let endpoint = GenericEndpoint.get("/trending", query: ["region": "US"])
|
||||
let response: [PipedVideo] = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||
return response.map { $0.toVideo(instanceURL: instance.url) }
|
||||
}
|
||||
|
||||
func popular(instance: Instance) async throws -> [Video] {
|
||||
// Piped doesn't have a separate popular endpoint, use trending
|
||||
try await trending(instance: instance)
|
||||
}
|
||||
|
||||
func search(query: String, instance: Instance, page: Int, filters: SearchFilters) async throws -> SearchResult {
|
||||
let endpoint = GenericEndpoint.get("/search", query: [
|
||||
"q": query,
|
||||
"filter": "all"
|
||||
])
|
||||
let response: PipedSearchResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||
|
||||
var videos: [Video] = []
|
||||
var channels: [Channel] = []
|
||||
var playlists: [Playlist] = []
|
||||
var orderedItems: [OrderedSearchItem] = []
|
||||
|
||||
for item in response.items {
|
||||
switch item.type {
|
||||
case "stream":
|
||||
let video = item.toVideo(instanceURL: instance.url)
|
||||
videos.append(video)
|
||||
orderedItems.append(.video(video))
|
||||
case "channel":
|
||||
let channel = item.toChannel()
|
||||
channels.append(channel)
|
||||
orderedItems.append(.channel(channel))
|
||||
case "playlist":
|
||||
let playlist = item.toPlaylist()
|
||||
playlists.append(playlist)
|
||||
orderedItems.append(.playlist(playlist))
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return SearchResult(
|
||||
videos: videos,
|
||||
channels: channels,
|
||||
playlists: playlists,
|
||||
orderedItems: orderedItems,
|
||||
nextPage: response.nextpage != nil ? page + 1 : nil
|
||||
)
|
||||
}
|
||||
|
||||
func searchSuggestions(query: String, instance: Instance) async throws -> [String] {
|
||||
let endpoint = GenericEndpoint.get("/suggestions", query: [
|
||||
"query": query
|
||||
])
|
||||
let response: [String] = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||
return response
|
||||
}
|
||||
|
||||
func video(id: String, instance: Instance) async throws -> Video {
|
||||
let endpoint = GenericEndpoint.get("/streams/\(id)")
|
||||
let response: PipedStreamResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||
return response.toVideo(instanceURL: instance.url, videoId: id)
|
||||
}
|
||||
|
||||
func channel(id: String, instance: Instance) async throws -> Channel {
|
||||
let endpoint = GenericEndpoint.get("/channel/\(id)")
|
||||
let response: PipedChannelResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||
if let tabs = response.tabs {
|
||||
channelTabsCache[id] = tabs
|
||||
}
|
||||
return response.toChannel()
|
||||
}
|
||||
|
||||
func channelVideos(id: String, instance: Instance, continuation: String?) async throws -> ChannelVideosPage {
|
||||
if let continuation {
|
||||
// Fetch next page of channel videos
|
||||
let endpoint = GenericEndpoint.get("/nextpage/channel/\(id)", query: ["nextpage": continuation])
|
||||
let response: PipedNextPageResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||
return ChannelVideosPage(
|
||||
videos: response.relatedStreams.map { $0.toVideo(instanceURL: instance.url) },
|
||||
continuation: response.nextpage
|
||||
)
|
||||
} else {
|
||||
// Initial fetch - get channel data (also caches tabs)
|
||||
let endpoint = GenericEndpoint.get("/channel/\(id)")
|
||||
let response: PipedChannelResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||
if let tabs = response.tabs {
|
||||
channelTabsCache[id] = tabs
|
||||
}
|
||||
return ChannelVideosPage(
|
||||
videos: response.relatedStreams?.map { $0.toVideo(instanceURL: instance.url) } ?? [],
|
||||
continuation: response.nextpage
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func playlist(id: String, instance: Instance) async throws -> Playlist {
|
||||
let endpoint = GenericEndpoint.get("/playlists/\(id)")
|
||||
let response: PipedPlaylistResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||
return response.toPlaylist(instanceURL: instance.url)
|
||||
}
|
||||
|
||||
func comments(videoID: String, instance: Instance, continuation: String?) async throws -> CommentsPage {
|
||||
let path = continuation != nil ? "/nextpage/comments/\(videoID)" : "/comments/\(videoID)"
|
||||
var query: [String: String] = [:]
|
||||
if let continuation {
|
||||
query["nextpage"] = continuation
|
||||
}
|
||||
let endpoint = GenericEndpoint.get(path, query: query)
|
||||
let response: PipedCommentsResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||
|
||||
if response.disabled == true {
|
||||
throw APIError.commentsDisabled
|
||||
}
|
||||
|
||||
return CommentsPage(
|
||||
comments: response.comments.map { $0.toComment(instanceURL: instance.url) },
|
||||
continuation: response.nextpage
|
||||
)
|
||||
}
|
||||
|
||||
func streams(videoID: String, instance: Instance) async throws -> [Stream] {
|
||||
let endpoint = GenericEndpoint.get("/streams/\(videoID)")
|
||||
let response: PipedStreamResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||
return response.toStreams()
|
||||
}
|
||||
|
||||
// MARK: - Channel Tabs
|
||||
|
||||
func channelShorts(id: String, instance: Instance, continuation: String?) async throws -> ChannelVideosPage {
|
||||
let page = try await fetchTab(name: "shorts", channelID: id, instance: instance, continuation: continuation)
|
||||
let videos: [Video] = page.items.compactMap {
|
||||
if case .stream(let video) = $0 { return video.toVideo(instanceURL: instance.url) }
|
||||
return nil
|
||||
}
|
||||
return ChannelVideosPage(videos: videos, continuation: page.continuation)
|
||||
}
|
||||
|
||||
func channelStreams(id: String, instance: Instance, continuation: String?) async throws -> ChannelVideosPage {
|
||||
let page = try await fetchTab(name: "livestreams", channelID: id, instance: instance, continuation: continuation)
|
||||
let videos: [Video] = page.items.compactMap {
|
||||
if case .stream(let video) = $0 { return video.toVideo(instanceURL: instance.url) }
|
||||
return nil
|
||||
}
|
||||
return ChannelVideosPage(videos: videos, continuation: page.continuation)
|
||||
}
|
||||
|
||||
func channelPlaylists(id: String, instance: Instance, continuation: String?) async throws -> ChannelPlaylistsPage {
|
||||
let page = try await fetchTab(name: "playlists", channelID: id, instance: instance, continuation: continuation)
|
||||
let playlists: [Playlist] = page.items.compactMap {
|
||||
if case .playlist(let p) = $0 { return p.toPlaylist() }
|
||||
return nil
|
||||
}
|
||||
return ChannelPlaylistsPage(playlists: playlists, continuation: page.continuation)
|
||||
}
|
||||
|
||||
// MARK: - Tab Fetching
|
||||
|
||||
/// Fetches tab content for a channel, handling both initial load and pagination.
|
||||
private func fetchTab(name: String, channelID: String, instance: Instance, continuation: String?) async throws -> PipedTabPage {
|
||||
if let continuation {
|
||||
// Decode the continuation token which contains both tabData and nextpage
|
||||
let tabContinuation = try PipedTabContinuation.decode(from: continuation)
|
||||
let endpoint = GenericEndpoint.get("/channels/tabs", query: [
|
||||
"data": tabContinuation.tabData,
|
||||
"nextpage": tabContinuation.nextpage
|
||||
])
|
||||
let response: PipedTabResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||
let nextContinuation = response.nextpage.map {
|
||||
PipedTabContinuation(tabData: tabContinuation.tabData, nextpage: $0).encode()
|
||||
}
|
||||
return PipedTabPage(items: response.content, continuation: nextContinuation)
|
||||
} else {
|
||||
// Initial load - get tab data from cache (or fetch channel to populate it)
|
||||
var tabs = channelTabsCache[channelID]
|
||||
if tabs == nil {
|
||||
let endpoint = GenericEndpoint.get("/channel/\(channelID)")
|
||||
let response: PipedChannelResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||
channelTabsCache[channelID] = response.tabs
|
||||
tabs = response.tabs
|
||||
}
|
||||
|
||||
guard let tabData = tabs?.first(where: { $0.name == name })?.data else {
|
||||
// Tab not available for this channel
|
||||
return PipedTabPage(items: [], continuation: nil)
|
||||
}
|
||||
|
||||
let endpoint = GenericEndpoint.get("/channels/tabs", query: ["data": tabData])
|
||||
let response: PipedTabResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||
let nextContinuation = response.nextpage.map {
|
||||
PipedTabContinuation(tabData: tabData, nextpage: $0).encode()
|
||||
}
|
||||
return PipedTabPage(items: response.content, continuation: nextContinuation)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Authentication
|
||||
|
||||
/// Logs in to a Piped instance and returns the auth token.
|
||||
/// - Parameters:
|
||||
/// - username: The user's username
|
||||
/// - password: The user's password
|
||||
/// - instance: The Piped instance to log in to
|
||||
/// - Returns: The auth token for subsequent authenticated requests
|
||||
func login(username: String, password: String, instance: Instance) async throws -> String {
|
||||
struct LoginRequest: Encodable, Sendable {
|
||||
let username: String
|
||||
let password: String
|
||||
}
|
||||
|
||||
let body = LoginRequest(username: username, password: password)
|
||||
let endpoint = GenericEndpoint.post("/login", body: body)
|
||||
|
||||
do {
|
||||
let response: PipedLoginResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||
return response.token
|
||||
} catch let error as APIError {
|
||||
// Map HTTP 401/403 to unauthorized error
|
||||
if case .httpError(let statusCode, _) = error, statusCode == 401 || statusCode == 403 {
|
||||
throw APIError.unauthorized
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches the subscription feed for a logged-in user.
|
||||
/// - Parameters:
|
||||
/// - instance: The Piped instance
|
||||
/// - authToken: The auth token from login
|
||||
/// - Returns: Array of videos from subscribed channels
|
||||
func feed(instance: Instance, authToken: String) async throws -> [Video] {
|
||||
// Piped feed uses authToken as a query parameter
|
||||
let endpoint = GenericEndpoint.get("/feed", query: ["authToken": authToken])
|
||||
let response: [PipedVideo] = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||
return response.map { $0.toVideo(instanceURL: instance.url) }
|
||||
}
|
||||
|
||||
/// Fetches the user's subscriptions.
|
||||
/// - Parameters:
|
||||
/// - instance: The Piped instance
|
||||
/// - authToken: The auth token from login
|
||||
/// - Returns: Array of subscribed channels
|
||||
func subscriptions(instance: Instance, authToken: String) async throws -> [PipedSubscription] {
|
||||
// Subscriptions endpoint uses Authorization header
|
||||
let endpoint = GenericEndpoint(
|
||||
path: "/subscriptions",
|
||||
method: .get,
|
||||
headers: ["Authorization": authToken]
|
||||
)
|
||||
let response: [PipedSubscription] = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||
return response
|
||||
}
|
||||
|
||||
/// Subscribes to a channel.
|
||||
/// - Parameters:
|
||||
/// - channelID: The YouTube channel ID to subscribe to
|
||||
/// - instance: The Piped instance
|
||||
/// - authToken: The auth token from login
|
||||
func subscribe(channelID: String, instance: Instance, authToken: String) async throws {
|
||||
struct SubscribeRequest: Encodable, Sendable {
|
||||
let channelId: String
|
||||
}
|
||||
|
||||
let bodyData = try JSONEncoder().encode(SubscribeRequest(channelId: channelID))
|
||||
let endpoint = GenericEndpoint(
|
||||
path: "/subscribe",
|
||||
method: .post,
|
||||
headers: ["Authorization": authToken, "Content-Type": "application/json"],
|
||||
body: bodyData
|
||||
)
|
||||
|
||||
// Returns {"message": "ok"} on success
|
||||
let _: PipedMessageResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||
}
|
||||
|
||||
/// Unsubscribes from a channel.
|
||||
/// - Parameters:
|
||||
/// - channelID: The YouTube channel ID to unsubscribe from
|
||||
/// - instance: The Piped instance
|
||||
/// - authToken: The auth token from login
|
||||
func unsubscribe(channelID: String, instance: Instance, authToken: String) async throws {
|
||||
struct UnsubscribeRequest: Encodable, Sendable {
|
||||
let channelId: String
|
||||
}
|
||||
|
||||
let bodyData = try JSONEncoder().encode(UnsubscribeRequest(channelId: channelID))
|
||||
let endpoint = GenericEndpoint(
|
||||
path: "/unsubscribe",
|
||||
method: .post,
|
||||
headers: ["Authorization": authToken, "Content-Type": "application/json"],
|
||||
body: bodyData
|
||||
)
|
||||
|
||||
let _: PipedMessageResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||
}
|
||||
|
||||
/// Fetches the user's playlists.
|
||||
/// - Parameters:
|
||||
/// - instance: The Piped instance
|
||||
/// - authToken: The auth token from login
|
||||
/// - Returns: Array of user playlists (without videos)
|
||||
func userPlaylists(instance: Instance, authToken: String) async throws -> [Playlist] {
|
||||
let endpoint = GenericEndpoint(
|
||||
path: "/user/playlists",
|
||||
method: .get,
|
||||
headers: ["Authorization": authToken]
|
||||
)
|
||||
let response: [PipedUserPlaylist] = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||
return response.map { $0.toPlaylist() }
|
||||
}
|
||||
|
||||
/// Fetches a user playlist with its videos.
|
||||
/// - Parameters:
|
||||
/// - id: The playlist ID (UUID)
|
||||
/// - instance: The Piped instance
|
||||
/// - authToken: The auth token from login
|
||||
/// - Returns: Playlist with videos
|
||||
func userPlaylist(id: String, instance: Instance, authToken: String) async throws -> Playlist {
|
||||
let endpoint = GenericEndpoint(
|
||||
path: "/playlists/\(id)",
|
||||
method: .get,
|
||||
headers: ["Authorization": authToken]
|
||||
)
|
||||
let response: PipedPlaylistResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
||||
return response.toPlaylist(instanceURL: instance.url, playlistID: id)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Piped Authentication Response Models
|
||||
|
||||
/// Login response from Piped API.
|
||||
private struct PipedLoginResponse: Decodable, Sendable {
|
||||
let token: String
|
||||
}
|
||||
|
||||
/// Generic message response from Piped API (used by subscribe/unsubscribe).
|
||||
private struct PipedMessageResponse: Decodable, Sendable {
|
||||
let message: String
|
||||
}
|
||||
|
||||
/// Subscription info from Piped API.
|
||||
struct PipedSubscription: Decodable, Sendable {
|
||||
let url: String
|
||||
let name: String
|
||||
let avatar: String?
|
||||
let verified: Bool?
|
||||
|
||||
var channelId: String {
|
||||
url.replacingOccurrences(of: "/channel/", with: "")
|
||||
}
|
||||
|
||||
func toChannel() -> Channel {
|
||||
Channel(
|
||||
id: .global(channelId),
|
||||
name: name,
|
||||
thumbnailURL: avatar.flatMap { URL(string: $0) },
|
||||
isVerified: verified ?? false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - HTML Stripping
|
||||
|
||||
/// Strips HTML tags from Piped descriptions, converting them to plain text.
|
||||
private func stripHTML(_ html: String) -> String {
|
||||
var text = html
|
||||
|
||||
// Convert <br> variants to newlines
|
||||
text = text.replacingOccurrences(
|
||||
of: "<br\\s*/?>",
|
||||
with: "\n",
|
||||
options: .regularExpression
|
||||
)
|
||||
|
||||
// Extract link text from <a> tags (keep visible text, drop markup)
|
||||
text = text.replacingOccurrences(
|
||||
of: "<a[^>]*>(.*?)</a>",
|
||||
with: "$1",
|
||||
options: .regularExpression
|
||||
)
|
||||
|
||||
// Strip all remaining HTML tags
|
||||
text = text.replacingOccurrences(
|
||||
of: "<[^>]+>",
|
||||
with: "",
|
||||
options: .regularExpression
|
||||
)
|
||||
|
||||
// Decode common HTML entities
|
||||
text = text
|
||||
.replacingOccurrences(of: "&", with: "&")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
.replacingOccurrences(of: """, with: "\"")
|
||||
.replacingOccurrences(of: "'", with: "'")
|
||||
.replacingOccurrences(of: "'", with: "'")
|
||||
.replacingOccurrences(of: "'", with: "'")
|
||||
.replacingOccurrences(of: " ", with: " ")
|
||||
|
||||
// Trim excessive blank lines (3+ newlines → 2)
|
||||
text = text.replacingOccurrences(
|
||||
of: "\\n{3,}",
|
||||
with: "\n\n",
|
||||
options: .regularExpression
|
||||
)
|
||||
|
||||
return text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
// MARK: - Piped Response Models
|
||||
|
||||
private struct PipedVideo: Decodable, Sendable {
|
||||
let url: String
|
||||
let title: String
|
||||
let description: String?
|
||||
let uploaderName: String?
|
||||
let uploaderUrl: String?
|
||||
let uploaderAvatar: String?
|
||||
let duration: Int
|
||||
let uploaded: Int64?
|
||||
let uploadedDate: String?
|
||||
let views: Int64?
|
||||
let thumbnail: String?
|
||||
let uploaderVerified: Bool?
|
||||
let isShort: Bool?
|
||||
|
||||
var videoId: String {
|
||||
url.replacingOccurrences(of: "/watch?v=", with: "")
|
||||
}
|
||||
|
||||
var channelId: String? {
|
||||
uploaderUrl?.replacingOccurrences(of: "/channel/", with: "")
|
||||
}
|
||||
|
||||
nonisolated func toVideo(instanceURL: URL) -> Video {
|
||||
Video(
|
||||
id: .global(videoId),
|
||||
title: title,
|
||||
description: description.map { stripHTML($0) },
|
||||
author: Author(
|
||||
id: channelId ?? "",
|
||||
name: uploaderName ?? "",
|
||||
thumbnailURL: uploaderAvatar.flatMap { URL(string: $0) }
|
||||
),
|
||||
duration: TimeInterval(duration),
|
||||
publishedAt: uploaded.map { Date(timeIntervalSince1970: TimeInterval($0) / 1000) },
|
||||
publishedText: uploadedDate,
|
||||
viewCount: views.map { Int($0) },
|
||||
likeCount: nil,
|
||||
thumbnails: thumbnail.flatMap { URL(string: $0) }.map {
|
||||
[Thumbnail(url: $0, quality: .high)]
|
||||
} ?? [],
|
||||
isLive: duration == -1,
|
||||
isUpcoming: false,
|
||||
scheduledStartTime: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct PipedStreamResponse: Decodable, Sendable {
|
||||
let title: String
|
||||
let description: String?
|
||||
let uploader: String
|
||||
let uploaderUrl: String?
|
||||
let uploaderAvatar: String?
|
||||
let uploaderVerified: Bool?
|
||||
let uploaderSubscriberCount: Int64?
|
||||
let duration: Int
|
||||
let uploaded: Int64?
|
||||
let uploadDate: String?
|
||||
let views: Int64?
|
||||
let likes: Int64?
|
||||
let dislikes: Int64?
|
||||
let thumbnailUrl: String?
|
||||
let hls: String?
|
||||
let dash: String?
|
||||
let livestream: Bool?
|
||||
let videoStreams: [PipedVideoStream]?
|
||||
let audioStreams: [PipedAudioStream]?
|
||||
let relatedStreams: [PipedVideo]?
|
||||
|
||||
var videoId: String? {
|
||||
// Extract from thumbnail URL as fallback
|
||||
guard let thumbnailUrl else { return nil }
|
||||
// Thumbnail format: https://pipedproxy.example.com/vi/VIDEO_ID/...
|
||||
let components = thumbnailUrl.components(separatedBy: "/vi/")
|
||||
guard components.count > 1 else { return nil }
|
||||
return components[1].components(separatedBy: "/").first
|
||||
}
|
||||
|
||||
var channelId: String? {
|
||||
uploaderUrl?.replacingOccurrences(of: "/channel/", with: "")
|
||||
}
|
||||
|
||||
nonisolated func toVideo(instanceURL: URL, videoId: String? = nil) -> Video {
|
||||
// Convert related streams, limiting to 12
|
||||
let related: [Video]? = relatedStreams?.prefix(12).map { $0.toVideo(instanceURL: instanceURL) }
|
||||
|
||||
let resolvedVideoId = videoId ?? self.videoId ?? ""
|
||||
let thumbnails: [Thumbnail] = {
|
||||
if !resolvedVideoId.isEmpty,
|
||||
let url = URL(string: "https://i.ytimg.com/vi/\(resolvedVideoId)/maxresdefault.jpg") {
|
||||
return [Thumbnail(url: url, quality: .maxres)]
|
||||
}
|
||||
// Fallback to proxy URL if video ID not available
|
||||
if let proxyURL = thumbnailUrl.flatMap({ URL(string: $0) }) {
|
||||
return [Thumbnail(url: proxyURL, quality: .high)]
|
||||
}
|
||||
return []
|
||||
}()
|
||||
|
||||
return Video(
|
||||
id: .global(resolvedVideoId),
|
||||
title: title,
|
||||
description: description.map { stripHTML($0) },
|
||||
author: Author(
|
||||
id: channelId ?? "",
|
||||
name: uploader,
|
||||
thumbnailURL: uploaderAvatar.flatMap { URL(string: $0) }
|
||||
),
|
||||
duration: TimeInterval(duration),
|
||||
publishedAt: uploaded.map { Date(timeIntervalSince1970: TimeInterval($0) / 1000) },
|
||||
publishedText: uploadDate,
|
||||
viewCount: views.map { Int($0) },
|
||||
likeCount: likes.map { Int($0) },
|
||||
thumbnails: thumbnails,
|
||||
isLive: livestream ?? false,
|
||||
isUpcoming: false,
|
||||
scheduledStartTime: nil,
|
||||
relatedVideos: related
|
||||
)
|
||||
}
|
||||
|
||||
nonisolated func toStreams() -> [Stream] {
|
||||
var streams: [Stream] = []
|
||||
|
||||
// Add HLS stream (preferred - works for both live and on-demand content)
|
||||
if let hls, let url = URL(string: hls) {
|
||||
streams.append(Stream(
|
||||
url: url,
|
||||
resolution: nil,
|
||||
format: "hls",
|
||||
isLive: livestream ?? false,
|
||||
mimeType: "application/x-mpegURL"
|
||||
))
|
||||
}
|
||||
|
||||
// Add video streams
|
||||
if let videoStreams {
|
||||
streams.append(contentsOf: videoStreams.compactMap { $0.toStream() })
|
||||
}
|
||||
|
||||
// Add audio streams
|
||||
if let audioStreams {
|
||||
streams.append(contentsOf: audioStreams.compactMap { $0.toStream() })
|
||||
}
|
||||
|
||||
return streams
|
||||
}
|
||||
}
|
||||
|
||||
private struct PipedVideoStream: Decodable, Sendable {
|
||||
let url: String
|
||||
let format: String?
|
||||
let quality: String?
|
||||
let mimeType: String?
|
||||
let codec: String?
|
||||
let videoOnly: Bool?
|
||||
let bitrate: Int?
|
||||
let width: Int?
|
||||
let height: Int?
|
||||
let contentLength: Int64?
|
||||
let fps: Int?
|
||||
|
||||
nonisolated func toStream() -> Stream? {
|
||||
guard let streamUrl = URL(string: url) else { return nil }
|
||||
|
||||
let resolution: StreamResolution?
|
||||
if let width, let height {
|
||||
resolution = StreamResolution(width: width, height: height)
|
||||
} else if let quality {
|
||||
resolution = StreamResolution(heightLabel: quality)
|
||||
} else {
|
||||
resolution = nil
|
||||
}
|
||||
|
||||
return Stream(
|
||||
url: streamUrl,
|
||||
resolution: resolution,
|
||||
format: format ?? "unknown",
|
||||
videoCodec: codec,
|
||||
audioCodec: nil,
|
||||
bitrate: bitrate,
|
||||
fileSize: contentLength,
|
||||
isAudioOnly: false,
|
||||
mimeType: mimeType
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct PipedAudioStream: Decodable, Sendable {
|
||||
let url: String
|
||||
let format: String?
|
||||
let quality: String?
|
||||
let mimeType: String?
|
||||
let codec: String?
|
||||
let bitrate: Int?
|
||||
let contentLength: Int64?
|
||||
let audioTrackId: String?
|
||||
let audioTrackName: String?
|
||||
let audioTrackLocale: String?
|
||||
let audioTrackType: String?
|
||||
|
||||
nonisolated func toStream() -> Stream? {
|
||||
guard let streamUrl = URL(string: url) else { return nil }
|
||||
|
||||
return Stream(
|
||||
url: streamUrl,
|
||||
resolution: nil,
|
||||
format: format ?? "unknown",
|
||||
videoCodec: nil,
|
||||
audioCodec: codec,
|
||||
bitrate: bitrate,
|
||||
fileSize: contentLength,
|
||||
isAudioOnly: true,
|
||||
mimeType: mimeType,
|
||||
audioLanguage: audioTrackId,
|
||||
audioTrackName: audioTrackName,
|
||||
isOriginalAudio: audioTrackType == "ORIGINAL"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct PipedSearchResponse: Decodable, Sendable {
|
||||
let items: [PipedSearchItem]
|
||||
let nextpage: String?
|
||||
}
|
||||
|
||||
private struct PipedSearchItem: Decodable, Sendable {
|
||||
let type: String
|
||||
let url: String?
|
||||
let name: String?
|
||||
let title: String?
|
||||
let description: String?
|
||||
let thumbnail: String?
|
||||
let uploaderName: String?
|
||||
let uploaderUrl: String?
|
||||
let uploaderAvatar: String?
|
||||
let uploaderVerified: Bool?
|
||||
let duration: Int?
|
||||
let uploaded: Int64?
|
||||
let uploadedDate: String?
|
||||
let views: Int64?
|
||||
let videos: Int64?
|
||||
let subscribers: Int64?
|
||||
|
||||
var videoId: String? {
|
||||
url?.replacingOccurrences(of: "/watch?v=", with: "")
|
||||
}
|
||||
|
||||
var channelId: String? {
|
||||
url?.replacingOccurrences(of: "/channel/", with: "")
|
||||
}
|
||||
|
||||
var playlistId: String? {
|
||||
url?.replacingOccurrences(of: "/playlist?list=", with: "")
|
||||
}
|
||||
|
||||
nonisolated func toVideo(instanceURL: URL) -> Video {
|
||||
Video(
|
||||
id: .global(videoId ?? ""),
|
||||
title: title ?? name ?? "",
|
||||
description: description,
|
||||
author: Author(
|
||||
id: uploaderUrl?.replacingOccurrences(of: "/channel/", with: "") ?? "",
|
||||
name: uploaderName ?? "",
|
||||
thumbnailURL: uploaderAvatar.flatMap { URL(string: $0) }
|
||||
),
|
||||
duration: TimeInterval(duration ?? 0),
|
||||
publishedAt: uploaded.map { Date(timeIntervalSince1970: TimeInterval($0) / 1000) },
|
||||
publishedText: uploadedDate,
|
||||
viewCount: views.map { Int($0) },
|
||||
likeCount: nil,
|
||||
thumbnails: thumbnail.flatMap { URL(string: $0) }.map {
|
||||
[Thumbnail(url: $0, quality: .high)]
|
||||
} ?? [],
|
||||
isLive: duration == -1,
|
||||
isUpcoming: false,
|
||||
scheduledStartTime: nil
|
||||
)
|
||||
}
|
||||
|
||||
nonisolated func toChannel() -> Channel {
|
||||
Channel(
|
||||
id: .global(channelId ?? ""),
|
||||
name: name ?? "",
|
||||
description: description,
|
||||
subscriberCount: subscribers.map { Int($0) },
|
||||
videoCount: videos.map { Int($0) },
|
||||
thumbnailURL: thumbnail.flatMap { URL(string: $0) },
|
||||
isVerified: uploaderVerified ?? false
|
||||
)
|
||||
}
|
||||
|
||||
nonisolated func toPlaylist() -> Playlist {
|
||||
Playlist(
|
||||
id: .global(playlistId ?? ""),
|
||||
title: name ?? "",
|
||||
author: uploaderName.map { Author(id: "", name: $0) },
|
||||
videoCount: videos.map { Int($0) } ?? 0,
|
||||
thumbnailURL: thumbnail.flatMap { URL(string: $0) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct PipedChannelResponse: Decodable, Sendable {
|
||||
let id: String
|
||||
let name: String
|
||||
let description: String?
|
||||
let subscriberCount: Int64?
|
||||
let verified: Bool?
|
||||
let avatarUrl: String?
|
||||
let bannerUrl: String?
|
||||
let relatedStreams: [PipedVideo]?
|
||||
let nextpage: String?
|
||||
let tabs: [PipedChannelTab]?
|
||||
|
||||
nonisolated func toChannel() -> Channel {
|
||||
Channel(
|
||||
id: .global(id),
|
||||
name: name,
|
||||
description: description,
|
||||
subscriberCount: subscriberCount.map { Int($0) },
|
||||
thumbnailURL: avatarUrl.flatMap { URL(string: $0) },
|
||||
bannerURL: bannerUrl.flatMap { URL(string: $0) },
|
||||
isVerified: verified ?? false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Tab entry from the Piped channel response.
|
||||
/// Each tab has a name (e.g. "shorts", "livestreams", "playlists") and an opaque data string
|
||||
/// that must be passed to `/channels/tabs?data=...` to fetch tab content.
|
||||
private struct PipedChannelTab: Decodable, Sendable {
|
||||
let name: String
|
||||
let data: String
|
||||
}
|
||||
|
||||
/// Response from `/nextpage/channel/{id}?nextpage=...` for paginated channel videos.
|
||||
private struct PipedNextPageResponse: Decodable, Sendable {
|
||||
let relatedStreams: [PipedVideo]
|
||||
let nextpage: String?
|
||||
}
|
||||
|
||||
/// Response from `/channels/tabs?data=...` for tab content.
|
||||
private struct PipedTabResponse: Decodable, Sendable {
|
||||
let content: [PipedTabItem]
|
||||
let nextpage: String?
|
||||
}
|
||||
|
||||
/// Item in a tab response - can be a stream (video/short/livestream) or a playlist.
|
||||
private enum PipedTabItem: Decodable, Sendable {
|
||||
case stream(PipedVideo)
|
||||
case playlist(PipedTabPlaylist)
|
||||
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 "stream":
|
||||
self = .stream(try PipedVideo(from: decoder))
|
||||
case "playlist":
|
||||
self = .playlist(try PipedTabPlaylist(from: decoder))
|
||||
default:
|
||||
self = .unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Playlist item from a channel tab response.
|
||||
private struct PipedTabPlaylist: Decodable, Sendable {
|
||||
let url: String?
|
||||
let name: String?
|
||||
let thumbnail: String?
|
||||
let uploaderName: String?
|
||||
let uploaderUrl: String?
|
||||
let videos: Int64?
|
||||
|
||||
var playlistId: String? {
|
||||
url?.replacingOccurrences(of: "/playlist?list=", with: "")
|
||||
}
|
||||
|
||||
nonisolated func toPlaylist() -> Playlist {
|
||||
Playlist(
|
||||
id: .global(playlistId ?? ""),
|
||||
title: name ?? "",
|
||||
author: uploaderName.map { Author(id: uploaderUrl?.replacingOccurrences(of: "/channel/", with: "") ?? "", name: $0) },
|
||||
videoCount: videos.map { Int($0) } ?? 0,
|
||||
thumbnailURL: thumbnail.flatMap { URL(string: $0) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Encodes tab data + nextpage token into a single continuation string for round-tripping.
|
||||
private struct PipedTabContinuation {
|
||||
let tabData: String
|
||||
let nextpage: String
|
||||
|
||||
func encode() -> String {
|
||||
let payload = ["t": tabData, "n": nextpage]
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: payload),
|
||||
let string = String(data: data, encoding: .utf8) else {
|
||||
return ""
|
||||
}
|
||||
return Data(string.utf8).base64EncodedString()
|
||||
}
|
||||
|
||||
static func decode(from continuation: String) throws -> PipedTabContinuation {
|
||||
guard let data = Data(base64Encoded: continuation),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: String],
|
||||
let tabData = json["t"],
|
||||
let nextpage = json["n"] else {
|
||||
throw APIError.decodingError("Invalid tab continuation token")
|
||||
}
|
||||
return PipedTabContinuation(tabData: tabData, nextpage: nextpage)
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal result type for tab fetching.
|
||||
private struct PipedTabPage {
|
||||
let items: [PipedTabItem]
|
||||
let continuation: String?
|
||||
}
|
||||
|
||||
/// Item within a Piped playlist - gracefully handles malformed items.
|
||||
private enum PipedPlaylistItem: Decodable, Sendable {
|
||||
case video(PipedVideo)
|
||||
case unknown
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
do {
|
||||
self = .video(try PipedVideo(from: decoder))
|
||||
} catch {
|
||||
self = .unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct PipedPlaylistResponse: Decodable, Sendable {
|
||||
let name: String
|
||||
let description: String?
|
||||
let uploader: String?
|
||||
let uploaderUrl: String?
|
||||
let uploaderAvatar: String?
|
||||
let videos: Int?
|
||||
let relatedStreams: [PipedPlaylistItem]?
|
||||
let thumbnailUrl: String?
|
||||
|
||||
nonisolated func toPlaylist(instanceURL: URL, playlistID: String? = nil) -> Playlist {
|
||||
// Extract only valid videos, skipping malformed items
|
||||
let validVideos: [Video] = relatedStreams?.compactMap { item in
|
||||
if case .video(let video) = item {
|
||||
return video.toVideo(instanceURL: instanceURL)
|
||||
}
|
||||
return nil
|
||||
} ?? []
|
||||
|
||||
return Playlist(
|
||||
id: .global(playlistID ?? UUID().uuidString),
|
||||
title: name,
|
||||
description: description,
|
||||
author: uploader.map {
|
||||
Author(
|
||||
id: uploaderUrl?.replacingOccurrences(of: "/channel/", with: "") ?? "",
|
||||
name: $0,
|
||||
thumbnailURL: uploaderAvatar.flatMap { URL(string: $0) }
|
||||
)
|
||||
},
|
||||
videoCount: videos ?? validVideos.count,
|
||||
thumbnailURL: thumbnailUrl.flatMap { URL(string: $0) },
|
||||
videos: validVideos
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// User playlist from Piped `/user/playlists` endpoint.
|
||||
private struct PipedUserPlaylist: Decodable, Sendable {
|
||||
let id: String
|
||||
let name: String
|
||||
let shortDescription: String?
|
||||
let thumbnail: String?
|
||||
let videos: Int?
|
||||
|
||||
nonisolated func toPlaylist() -> Playlist {
|
||||
Playlist(
|
||||
id: .global(id),
|
||||
title: name,
|
||||
description: shortDescription,
|
||||
videoCount: videos ?? 0,
|
||||
thumbnailURL: thumbnail.flatMap { URL(string: $0) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct PipedCommentsResponse: Decodable, Sendable {
|
||||
let comments: [PipedComment]
|
||||
let nextpage: String?
|
||||
let disabled: Bool?
|
||||
let commentCount: Int?
|
||||
}
|
||||
|
||||
private struct PipedComment: Decodable, Sendable {
|
||||
let commentId: String
|
||||
let author: String
|
||||
let commentorUrl: String?
|
||||
let thumbnail: String?
|
||||
let commentText: String
|
||||
let commentedTime: String?
|
||||
let likeCount: Int?
|
||||
let pinned: Bool?
|
||||
let hearted: Bool?
|
||||
let creatorReplied: Bool?
|
||||
let replyCount: Int?
|
||||
let repliesPage: String?
|
||||
let channelOwner: Bool?
|
||||
|
||||
nonisolated func toComment(instanceURL: URL) -> Comment {
|
||||
Comment(
|
||||
id: commentId,
|
||||
author: Author(
|
||||
id: commentorUrl?.replacingOccurrences(of: "/channel/", with: "") ?? "",
|
||||
name: author,
|
||||
thumbnailURL: thumbnail.flatMap { URL(string: $0) }
|
||||
),
|
||||
content: stripHTML(commentText),
|
||||
publishedText: commentedTime,
|
||||
likeCount: likeCount,
|
||||
isPinned: pinned ?? false,
|
||||
isCreatorComment: channelOwner ?? false,
|
||||
hasCreatorHeart: hearted ?? false,
|
||||
replyCount: replyCount ?? 0,
|
||||
repliesContinuation: repliesPage
|
||||
)
|
||||
}
|
||||
}
|
||||
1612
Yattee/Services/API/YatteeServerAPI.swift
Normal file
1612
Yattee/Services/API/YatteeServerAPI.swift
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user