mirror of
https://github.com/yattee/yattee.git
synced 2026-02-19 17:29:45 +00:00
521 lines
18 KiB
Swift
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
|
|
)
|
|
}
|
|
}
|