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

521 lines
18 KiB
Swift

//
// 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
)
}
}