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:
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
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user