mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
Playlist fetches threw away the only handle a row could be removed by: Invidious hands out an index/indexId pair per entry and both were dropped on conversion, so nothing downstream could say which row it meant. Both authenticated fetches now also return a snapshot — the playlist plus its addressable entries. Invidious entries carry the server's indexId and survive refetches; Piped has no such token, so its entries are addressed by position and are only valid inside the snapshot that minted them. The entry id carries the playlist id in both cases, so position 3 of one playlist can never be mistaken for position 3 of another. A row needs both an indexId and an index to be a target: rows missing either are still displayed but never appear in entries, so no removal can be aimed at a row the server refuses to name. Page-overlap dedupe stays keyed on index and never on videoId — the same video may sit in a playlist twice, and each copy is its own removable row.
1124 lines
40 KiB
Swift
1124 lines
40 KiB
Swift
//
|
|
// 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.compactMap { item in
|
|
if case .video(let video) = item { return video.toVideo(instanceURL: instance.url) }
|
|
return nil
|
|
},
|
|
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?.compactMap { item in
|
|
if case .video(let video) = item { return video.toVideo(instanceURL: instance.url) }
|
|
return nil
|
|
} ?? [],
|
|
continuation: response.nextpage
|
|
)
|
|
}
|
|
}
|
|
|
|
func playlist(id: String, instance: Instance) async throws -> Playlist {
|
|
let endpoint = GenericEndpoint.get("/playlists/\(id)")
|
|
let firstResponse: PipedPlaylistResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)
|
|
var allStreams = firstResponse.relatedStreams ?? []
|
|
let maxPages = 50
|
|
|
|
var nextpage = firstResponse.nextpage
|
|
var page = 1
|
|
while let token = nextpage, page < maxPages {
|
|
let nextEndpoint = GenericEndpoint.get("/nextpage/playlists/\(id)", query: ["nextpage": token])
|
|
let nextResponse: PipedPlaylistNextPageResponse = try await httpClient.fetch(nextEndpoint, baseURL: instance.url)
|
|
let pageStreams = nextResponse.relatedStreams ?? []
|
|
if pageStreams.isEmpty { break }
|
|
allStreams.append(contentsOf: pageStreams)
|
|
nextpage = nextResponse.nextpage
|
|
page += 1
|
|
}
|
|
|
|
return PipedPlaylistResponse(
|
|
name: firstResponse.name,
|
|
description: firstResponse.description,
|
|
uploader: firstResponse.uploader,
|
|
uploaderUrl: firstResponse.uploaderUrl,
|
|
uploaderAvatar: firstResponse.uploaderAvatar,
|
|
videos: firstResponse.videos,
|
|
relatedStreams: allStreams,
|
|
thumbnailUrl: firstResponse.thumbnailUrl,
|
|
nextpage: nil
|
|
).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] {
|
|
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 {
|
|
try await userPlaylistSnapshot(id: id, instance: instance, authToken: authToken).playlist
|
|
}
|
|
|
|
/// Fetches a user playlist along with the per-entry identities needed to
|
|
/// mutate it.
|
|
///
|
|
/// Same request as ``userPlaylist(id:instance:authToken:)`` — that one is a
|
|
/// thin wrapper over this. Piped has no per-entry token, so entries are
|
|
/// addressed by their position in this fetch and are only valid until the
|
|
/// playlist next changes.
|
|
/// - Parameters:
|
|
/// - id: The playlist ID (UUID)
|
|
/// - instance: The Piped instance
|
|
/// - authToken: The auth token from login
|
|
/// - Returns: The playlist plus its addressable entries
|
|
func userPlaylistSnapshot(id: String, instance: Instance, authToken: String) async throws -> RemotePlaylistSnapshot {
|
|
let headers = ["Authorization": authToken]
|
|
let firstEndpoint = GenericEndpoint(
|
|
path: "/playlists/\(id)",
|
|
method: .get,
|
|
headers: headers
|
|
)
|
|
let firstResponse: PipedPlaylistResponse = try await httpClient.fetch(firstEndpoint, baseURL: instance.url)
|
|
var allStreams = firstResponse.relatedStreams ?? []
|
|
let maxPages = 50
|
|
|
|
var nextpage = firstResponse.nextpage
|
|
var page = 1
|
|
while let token = nextpage, page < maxPages {
|
|
let nextEndpoint = GenericEndpoint(
|
|
path: "/nextpage/playlists/\(id)",
|
|
queryItems: [URLQueryItem(name: "nextpage", value: token)],
|
|
headers: headers
|
|
)
|
|
let nextResponse: PipedPlaylistNextPageResponse = try await httpClient.fetch(nextEndpoint, baseURL: instance.url)
|
|
let pageStreams = nextResponse.relatedStreams ?? []
|
|
if pageStreams.isEmpty { break }
|
|
allStreams.append(contentsOf: pageStreams)
|
|
nextpage = nextResponse.nextpage
|
|
page += 1
|
|
}
|
|
|
|
return Self.buildUserPlaylistSnapshot(
|
|
firstPage: firstResponse,
|
|
allStreams: allStreams,
|
|
playlistID: id,
|
|
instanceURL: instance.url
|
|
)
|
|
}
|
|
|
|
/// Builds a snapshot from an already-fetched first page plus every stream
|
|
/// paged in after it.
|
|
///
|
|
/// Pure: the network path and ``userPlaylistSnapshot(fromPageJSON:playlistID:instanceURL:)``
|
|
/// both funnel through here, so positions are assigned in exactly one place.
|
|
private static func buildUserPlaylistSnapshot(
|
|
firstPage: PipedPlaylistResponse,
|
|
allStreams: [PipedVideoItem],
|
|
playlistID: String,
|
|
instanceURL: URL
|
|
) -> RemotePlaylistSnapshot {
|
|
let playlist = PipedPlaylistResponse(
|
|
name: firstPage.name,
|
|
description: firstPage.description,
|
|
uploader: firstPage.uploader,
|
|
uploaderUrl: firstPage.uploaderUrl,
|
|
uploaderAvatar: firstPage.uploaderAvatar,
|
|
videos: firstPage.videos,
|
|
relatedStreams: allStreams,
|
|
thumbnailUrl: firstPage.thumbnailUrl,
|
|
nextpage: nil
|
|
).toPlaylist(instanceURL: instanceURL, playlistID: playlistID)
|
|
|
|
// Positions run 0-based across every page in fetch order and are taken
|
|
// over the same list the playlist displays, so `entries[n]` and
|
|
// `playlist.videos[n]` are always the same row. Piped removes by
|
|
// position, so these ids die with this snapshot.
|
|
let entries = playlist.videos.enumerated().map { offset, video in
|
|
RemotePlaylistEntry(
|
|
id: .piped(playlistID: playlistID, position: offset),
|
|
video: video,
|
|
position: offset
|
|
)
|
|
}
|
|
|
|
return RemotePlaylistSnapshot(playlist: playlist, entries: entries)
|
|
}
|
|
|
|
/// Runs the snapshot assembly over raw page payloads, decoding them the way
|
|
/// the network path does. Lets the paging and position rules be exercised
|
|
/// without a server: the first payload is a `/playlists/{id}` response,
|
|
/// every later one a `/nextpage/playlists/{id}` response.
|
|
static func userPlaylistSnapshot(
|
|
fromPageJSON pages: [Data],
|
|
playlistID: String,
|
|
instanceURL: URL
|
|
) throws -> RemotePlaylistSnapshot {
|
|
let decoder = HTTPClient.makeAPIDecoder()
|
|
guard let firstData = pages.first else {
|
|
throw APIError.decodingError("No playlist pages to decode")
|
|
}
|
|
|
|
let firstPage = try decoder.decode(PipedPlaylistResponse.self, from: firstData)
|
|
var allStreams = firstPage.relatedStreams ?? []
|
|
for data in pages.dropFirst() {
|
|
let nextPage = try decoder.decode(PipedPlaylistNextPageResponse.self, from: data)
|
|
allStreams.append(contentsOf: nextPage.relatedStreams ?? [])
|
|
}
|
|
|
|
return buildUserPlaylistSnapshot(
|
|
firstPage: firstPage,
|
|
allStreams: allStreams,
|
|
playlistID: playlistID,
|
|
instanceURL: instanceURL
|
|
)
|
|
}
|
|
}
|
|
|
|
// 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.fallbackChain(for: thumbnail.flatMap { URL(string: $0) }),
|
|
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: [PipedVideoItem]?
|
|
|
|
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, skipping malformed items, limiting to 12
|
|
let related: [Video]? = relatedStreams?.compactMap { item -> Video? in
|
|
if case .video(let video) = item {
|
|
return video.toVideo(instanceURL: instanceURL)
|
|
}
|
|
return nil
|
|
}.prefix(12).map { $0 }
|
|
|
|
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.fallbackChain(for: url)
|
|
}
|
|
// 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
|
|
}
|
|
|
|
let audioCodec: String? = (videoOnly ?? true) ? nil : "aac"
|
|
|
|
return Stream(
|
|
url: streamUrl,
|
|
resolution: resolution,
|
|
format: format ?? "unknown",
|
|
videoCodec: codec,
|
|
audioCodec: audioCodec,
|
|
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.fallbackChain(for: thumbnail.flatMap { URL(string: $0) }),
|
|
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: [PipedVideoItem]?
|
|
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: [PipedVideoItem]
|
|
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?
|
|
}
|
|
|
|
/// Wrapper for PipedVideo that gracefully handles malformed items.
|
|
private enum PipedVideoItem: 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: [PipedVideoItem]?
|
|
let thumbnailUrl: String?
|
|
let nextpage: 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
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Response from `/nextpage/playlists/{id}?nextpage=...` for paginated playlist videos.
|
|
private struct PipedPlaylistNextPageResponse: Decodable, Sendable {
|
|
let relatedStreams: [PipedVideoItem]?
|
|
let nextpage: String?
|
|
}
|
|
|
|
/// 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
|
|
)
|
|
}
|
|
}
|