Files
yattee/Model/Applications/PipedAPI.swift
Arkadiusz Fal 5b18c3c114 Add multi-track audio support for Piped backend
Extract and provide all available audio tracks (ORIGINAL, DUBBED, etc.) from Piped API instead of only using the first ORIGINAL track. This allows users to select between different audio languages and track types.

Changes:
- Extract all M4A audio tracks grouped by type and language
- Keep highest bitrate stream for each unique track combination
- Sort tracks with ORIGINAL first, then others alphabetically
- Pass audio tracks array to Stream for player selection

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 18:14:34 +01:00

891 lines
32 KiB
Swift

import Alamofire
import AVFoundation
import Foundation
import Siesta
import SwiftyJSON
final class PipedAPI: Service, ObservableObject, VideosAPI {
static var disallowedVideoCodecs = ["av01"]
static var authorizedEndpoints = ["subscriptions", "subscribe", "unsubscribe", "user/playlists"]
static var contentItemsKeys = ["items", "content", "relatedStreams"]
@Published var account: Account!
static func withAnonymousAccountForInstanceURL(_ url: URL) -> PipedAPI {
.init(account: Instance(app: .piped, apiURLString: url.absoluteString).anonymousAccount)
}
init(account: Account? = nil) {
super.init()
guard account != nil else {
return
}
setAccount(account!)
}
func setAccount(_ account: Account) {
self.account = account
configure()
}
func configure() {
invalidateConfiguration()
configure {
$0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"])
}
configure(whenURLMatches: { url in self.needsAuthorization(url) }) {
$0.headers["Authorization"] = self.account.token
}
configureTransformer(pathPattern("channel/*")) { (content: Entity<JSON>) -> ChannelPage in
let nextPage = content.json.dictionaryValue["nextpage"]?.string
let channel = self.extractChannel(from: content.json)
return ChannelPage(
results: self.extractContentItems(from: self.contentItemsDictionary(from: content.json)),
channel: channel,
nextPage: nextPage,
last: nextPage.isNil
)
}
configureTransformer(pathPattern("/nextpage/channel/*")) { (content: Entity<JSON>) -> ChannelPage in
let nextPage = content.json.dictionaryValue["nextpage"]?.string
return ChannelPage(
results: self.extractContentItems(from: self.contentItemsDictionary(from: content.json)),
channel: self.extractChannel(from: content.json),
nextPage: nextPage,
last: nextPage.isNil
)
}
configureTransformer(pathPattern("channels/tabs*")) { (content: Entity<JSON>) -> [ContentItem] in
(content.json.dictionaryValue["content"]?.arrayValue ?? []).compactMap { self.extractContentItem(from: $0) }
}
configureTransformer(pathPattern("c/*")) { (content: Entity<JSON>) -> Channel? in
self.extractChannel(from: content.json)
}
configureTransformer(pathPattern("user/*")) { (content: Entity<JSON>) -> Channel? in
self.extractChannel(from: content.json)
}
configureTransformer(pathPattern("playlists/*")) { (content: Entity<JSON>) -> ChannelPlaylist? in
self.extractChannelPlaylist(from: content.json)
}
configureTransformer(pathPattern("user/playlists/create")) { (_: Entity<JSON>) in }
configureTransformer(pathPattern("user/playlists/delete")) { (_: Entity<JSON>) in }
configureTransformer(pathPattern("user/playlists/add")) { (_: Entity<JSON>) in }
configureTransformer(pathPattern("user/playlists/remove")) { (_: Entity<JSON>) in }
configureTransformer(pathPattern("streams/*")) { (content: Entity<JSON>) -> Video? in
self.extractVideo(from: content.json)
}
configureTransformer(pathPattern("trending")) { (content: Entity<JSON>) -> [Video] in
self.extractVideos(from: content.json)
}
configureTransformer(pathPattern("search")) { (content: Entity<JSON>) -> SearchPage in
let nextPage = content.json.dictionaryValue["nextpage"]?.string
return SearchPage(
results: self.extractContentItems(from: content.json.dictionaryValue["items"]!),
nextPage: nextPage,
last: nextPage == "null"
)
}
configureTransformer(pathPattern("suggestions")) { (content: Entity<JSON>) -> [String] in
content.json.arrayValue.map(String.init)
}
configureTransformer(pathPattern("subscriptions")) { (content: Entity<JSON>) -> [Channel] in
content.json.arrayValue.compactMap { self.extractChannel(from: $0) }
}
configureTransformer(pathPattern("feed")) { (content: Entity<JSON>) -> [Video] in
content.json.arrayValue.compactMap { self.extractVideo(from: $0) }
}
configureTransformer(pathPattern("comments/*")) { (content: Entity<JSON>?) -> CommentsPage in
guard let details = content?.json.dictionaryValue else {
return CommentsPage(comments: [], nextPage: nil, disabled: true)
}
let comments = details["comments"]?.arrayValue.compactMap { self.extractComment(from: $0) } ?? []
let nextPage = details["nextpage"]?.string
let disabled = details["disabled"]?.bool ?? false
return CommentsPage(comments: comments, nextPage: nextPage, disabled: disabled)
}
configureTransformer(pathPattern("user/playlists")) { (content: Entity<JSON>) -> [Playlist] in
content.json.arrayValue.compactMap { self.extractUserPlaylist(from: $0) }
}
if account.token.isNil || account.token!.isEmpty {
updateToken()
} else {
FeedModel.shared.onAccountChange()
SubscribedChannelsModel.shared.onAccountChange()
PlaylistsModel.shared.onAccountChange()
}
}
func needsAuthorization(_ url: URL) -> Bool {
Self.authorizedEndpoints.contains { url.absoluteString.contains($0) }
}
func updateToken() {
let (username, password) = AccountsModel.getCredentials(account)
guard !account.anonymous,
let username,
let password
else {
return
}
AF.request(
login.url,
method: .post,
parameters: ["username": username, "password": password],
encoding: JSONEncoding.default
)
.responseDecodable(of: JSON.self) { [weak self] response in
guard let self else {
return
}
switch response.result {
case let .success(value):
let json = JSON(value)
let token = json.dictionaryValue["token"]?.string ?? ""
if let error = json.dictionaryValue["error"]?.string {
NavigationModel.shared.presentAlert(
title: "Account Error",
message: error
)
} else if !token.isEmpty {
AccountsModel.setToken(self.account, token)
self.objectWillChange.send()
} else {
NavigationModel.shared.presentAlert(
title: "Account Error",
message: "Could not update your token."
)
}
self.configure()
case let .failure(error):
NavigationModel.shared.presentAlert(
title: "Account Error",
message: error.localizedDescription
)
}
}
}
var login: Resource {
resource(baseURL: account.url, path: "login")
}
func channel(_ id: String, contentType: Channel.ContentType, data: String?, page: String?) -> Resource {
let path = page.isNil ? "channel" : "nextpage/channel"
var channel: Siesta.Resource
if contentType == .videos || data.isNil {
channel = resource(baseURL: account.url, path: "\(path)/\(id)")
} else {
channel = resource(baseURL: account.url, path: "channels/tabs")
.withParam("data", data)
}
if let page, !page.isEmpty {
channel = channel.withParam("nextpage", page)
}
return channel
}
func channelByName(_ name: String) -> Resource? {
resource(baseURL: account.url, path: "c/\(name)")
}
func channelByUsername(_ username: String) -> Resource? {
resource(baseURL: account.url, path: "user/\(username)")
}
func channelVideos(_ id: String) -> Resource {
channel(id, contentType: .videos)
}
func channelPlaylist(_ id: String) -> Resource? {
resource(baseURL: account.url, path: "playlists/\(id)")
}
func trending(country: Country, category _: TrendingCategory? = nil) -> Resource {
resource(baseURL: account.instance.apiURL, path: "trending")
.withParam("region", country.rawValue)
}
func search(_ query: SearchQuery, page: String?) -> Resource {
let path = page.isNil ? "search" : "nextpage/search"
let resource = resource(baseURL: account.instance.apiURL, path: path)
.withParam("q", query.query)
.withParam("filter", "all")
if page.isNil {
return resource
}
return resource.withParam("nextpage", page)
}
func searchSuggestions(query: String) -> Resource {
resource(baseURL: account.instance.apiURL, path: "suggestions")
.withParam("query", query.lowercased())
}
func video(_ id: Video.ID) -> Resource {
resource(baseURL: account.instance.apiURL, path: "streams/\(id)")
}
var signedIn: Bool {
guard let account else {
return false
}
return !account.anonymous && !(account.token?.isEmpty ?? true)
}
var subscriptions: Resource? {
resource(baseURL: account.instance.apiURL, path: "subscriptions")
}
func feed(_: Int?) -> Resource? {
resource(baseURL: account.instance.apiURL, path: "feed")
.withParam("authToken", account.token)
}
var home: Resource? { nil }
var popular: Resource? { nil }
var playlists: Resource? {
resource(baseURL: account.instance.apiURL, path: "user/playlists")
}
func subscribe(_ channelID: String, onCompletion: @escaping () -> Void = {}) {
resource(baseURL: account.instance.apiURL, path: "subscribe")
.request(.post, json: ["channelId": channelID])
.onCompletion { _ in onCompletion() }
}
func unsubscribe(_ channelID: String, onCompletion: @escaping () -> Void = {}) {
resource(baseURL: account.instance.apiURL, path: "unsubscribe")
.request(.post, json: ["channelId": channelID])
.onCompletion { _ in onCompletion() }
}
func playlist(_ id: String) -> Resource? {
channelPlaylist(id)
}
func playlistVideo(_: String, _: String) -> Resource? { nil }
func playlistVideos(_: String) -> Resource? { nil }
func addVideoToPlaylist(
_ videoID: String,
_ playlistID: String,
onFailure: @escaping (RequestError) -> Void = { _ in },
onSuccess: @escaping () -> Void = {}
) {
let resource = resource(baseURL: account.instance.apiURL, path: "user/playlists/add")
let body = ["videoId": videoID, "playlistId": playlistID]
resource
.request(.post, json: body)
.onSuccess { _ in onSuccess() }
.onFailure(onFailure)
}
func removeVideoFromPlaylist(
_ index: String,
_ playlistID: String,
onFailure: @escaping (RequestError) -> Void,
onSuccess: @escaping () -> Void
) {
let resource = resource(baseURL: account.instance.apiURL, path: "user/playlists/remove")
let body: [String: Any] = ["index": Int(index)!, "playlistId": playlistID]
resource
.request(.post, json: body)
.onSuccess { _ in onSuccess() }
.onFailure(onFailure)
}
func playlistForm(
_ name: String,
_: String,
playlist: Playlist?,
onFailure: @escaping (RequestError) -> Void,
onSuccess: @escaping (Playlist?) -> Void
) {
let body = ["name": name]
let resource = playlist.isNil ? resource(baseURL: account.instance.apiURL, path: "user/playlists/create") : nil
resource?
.request(.post, json: body)
.onSuccess { response in
if let modifiedPlaylist: Playlist = response.typedContent() {
onSuccess(modifiedPlaylist)
} else {
onSuccess(nil)
}
}
.onFailure(onFailure)
}
func deletePlaylist(
_ playlist: Playlist,
onFailure: @escaping (RequestError) -> Void,
onSuccess: @escaping () -> Void
) {
let resource = resource(baseURL: account.instance.apiURL, path: "user/playlists/delete")
let body = ["playlistId": playlist.id]
resource
.request(.post, json: body)
.onSuccess { _ in onSuccess() }
.onFailure(onFailure)
}
func comments(_ id: Video.ID, page: String?) -> Resource? {
let path = page.isNil ? "comments/\(id)" : "nextpage/comments/\(id)"
let resource = resource(baseURL: account.url, path: path)
if page.isNil {
return resource
}
return resource.withParam("nextpage", page)
}
private func pathPattern(_ path: String) -> String {
"**\(path)"
}
private func extractContentItem(from content: JSON) -> ContentItem? {
let details = content.dictionaryValue
let contentType: ContentItem.ContentType
if let url = details["url"]?.string {
if url.contains("/playlist") {
contentType = .playlist
} else if url.contains("/channel") {
contentType = .channel
} else {
contentType = .video
}
} else {
contentType = .video
}
switch contentType {
case .video:
if let video = extractVideo(from: content) {
return ContentItem(video: video)
}
case .playlist:
if let playlist = extractChannelPlaylist(from: content) {
return ContentItem(playlist: playlist)
}
case .channel:
if let channel = extractChannel(from: content) {
return ContentItem(channel: channel)
}
default:
return nil
}
return nil
}
private func extractContentItems(from content: JSON) -> [ContentItem] {
content.arrayValue.compactMap { extractContentItem(from: $0) }
}
private func extractChannel(from content: JSON) -> Channel? {
let attributes = content.dictionaryValue
guard let id = attributes["id"]?.string ??
(attributes["url"] ?? attributes["uploaderUrl"])?.string?.components(separatedBy: "/").last
else {
return nil
}
let subscriptionsCount = attributes["subscriberCount"]?.int ?? attributes["subscribers"]?.int
var videos = [Video]()
if let relatedStreams = attributes["relatedStreams"] {
videos = extractVideos(from: relatedStreams)
}
let name = attributes["name"]?.string ??
attributes["uploaderName"]?.string ??
attributes["uploader"]?.string ?? ""
let thumbnailURL = attributes["avatarUrl"]?.url ??
attributes["uploaderAvatar"]?.url ??
attributes["avatar"]?.url ??
attributes["thumbnail"]?.url
let tabs = attributes["tabs"]?.arrayValue.compactMap { tab in
let name = tab["name"].string
let data = tab["data"].string
if let name, let data, let type = Channel.ContentType(rawValue: name) {
return Channel.Tab(contentType: type, data: data)
}
return nil
} ?? [Channel.Tab]()
return Channel(
app: .piped,
id: id,
name: name,
bannerURL: attributes["bannerUrl"]?.url,
thumbnailURL: thumbnailURL,
subscriptionsCount: subscriptionsCount,
verified: attributes["verified"]?.bool,
videos: videos,
tabs: tabs
)
}
func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist? {
let details = json.dictionaryValue
let id = details["url"]?.stringValue.components(separatedBy: "?list=").last
let thumbnailURL = details["thumbnail"]?.url ?? details["thumbnailUrl"]?.url
var videos = [Video]()
if let relatedStreams = details["relatedStreams"] {
videos = extractVideos(from: relatedStreams)
}
return ChannelPlaylist(
id: id ?? UUID().uuidString,
title: details["name"]?.string ?? "",
thumbnailURL: thumbnailURL,
channel: extractChannel(from: json),
videos: videos,
videosCount: details["videos"]?.int
)
}
static func nonProxiedAsset(asset: AVURLAsset, completion: @escaping (AVURLAsset?) -> Void) {
guard var urlComponents = URLComponents(url: asset.url, resolvingAgainstBaseURL: false) else {
completion(asset)
return
}
guard let hostItem = urlComponents.queryItems?.first(where: { $0.name == "host" }),
let hostValue = hostItem.value
else {
completion(asset)
return
}
urlComponents.host = hostValue
guard let newUrl = urlComponents.url else {
completion(asset)
return
}
completion(AVURLAsset(url: newUrl))
}
// Overload used for hlsURLS
static func nonProxiedAsset(url: URL, completion: @escaping (AVURLAsset?) -> Void) {
let asset = AVURLAsset(url: url)
nonProxiedAsset(asset: asset, completion: completion)
}
private func extractVideo(from content: JSON) -> Video? {
let details = content.dictionaryValue
if let url = details["url"]?.string {
guard url.contains("/watch") else {
return nil
}
}
let channelId = details["uploaderUrl"]?.string?.components(separatedBy: "/").last ?? "unknown"
let thumbnails: [Thumbnail] = Thumbnail.Quality.allCases.compactMap {
if let url = buildThumbnailURL(from: content, quality: $0) {
return Thumbnail(url: url, quality: $0)
}
return nil
}
let author = details["uploaderName"]?.string ?? details["uploader"]?.string ?? ""
let authorThumbnailURL = details["avatarUrl"]?.url ?? details["uploaderAvatar"]?.url ?? details["avatar"]?.url
let subscriptionsCount = details["uploaderSubscriberCount"]?.int
let uploaded = details["uploaded"]?.double
var published = (uploaded.isNil || uploaded == -1) ? nil : (uploaded! / 1000).formattedAsRelativeTime()
var publishedAt: Date?
let dateFormatter = ISO8601DateFormatter()
dateFormatter.formatOptions = [.withInternetDateTime]
if published.isNil,
let date = details["uploadDate"]?.string,
let formattedDate = dateFormatter.date(from: date)
{
publishedAt = formattedDate
published = ""
} else if published.isNil {
published = (details["uploadedDate"] ?? details["uploadDate"])?.string ?? ""
}
let live = details["livestream"]?.bool ?? (details["duration"]?.int == -1)
let description = extractDescription(from: content) ?? ""
var chapters = extractChapters(from: content)
if chapters.isEmpty, !description.isEmpty {
chapters = extractChapters(from: description)
}
let length = details["duration"]?.double ?? 0
return Video(
instanceID: account.instanceID,
app: .piped,
instanceURL: account.instance.apiURL,
videoID: extractID(from: content),
title: details["title"]?.string ?? "",
author: author,
length: length,
published: published ?? "",
views: details["views"]?.int ?? 0,
description: description,
channel: Channel(app: .piped, id: channelId, name: author, thumbnailURL: authorThumbnailURL, subscriptionsCount: subscriptionsCount),
thumbnails: thumbnails,
live: live,
short: details["isShort"]?.bool ?? (length <= Video.shortLength),
publishedAt: publishedAt,
likes: details["likes"]?.int,
dislikes: details["dislikes"]?.int,
streams: extractStreams(from: content),
related: extractRelated(from: content),
chapters: extractChapters(from: content),
captions: extractCaptions(from: content)
)
}
private func extractID(from content: JSON) -> Video.ID {
content.dictionaryValue["url"]?.string?.components(separatedBy: "?v=").last ??
extractThumbnailURL(from: content)?.relativeString.components(separatedBy: "/")[4] ?? ""
}
private func extractThumbnailURL(from content: JSON) -> URL? {
content.dictionaryValue["thumbnail"]?.url ?? content.dictionaryValue["thumbnailUrl"]?.url
}
private func buildThumbnailURL(from content: JSON, quality: Thumbnail.Quality) -> URL? {
guard let thumbnailURL = extractThumbnailURL(from: content) else {
return nil
}
return URL(
string: thumbnailURL
.absoluteString
.replacingOccurrences(of: "hqdefault", with: quality.filename)
.replacingOccurrences(of: "maxresdefault", with: quality.filename)
)
}
private func extractUserPlaylist(from json: JSON) -> Playlist? {
let id = json["id"].string ?? ""
let title = json["name"].string ?? ""
let visibility = Playlist.Visibility.private
return Playlist(id: id, title: title, visibility: visibility)
}
private func extractDescription(from content: JSON) -> String? {
guard let description = content.dictionaryValue["description"]?.string else { return nil }
return replaceHTML(description)
}
private func replaceHTML(_ string: String) -> String {
var string = string.replacingOccurrences(
of: "<br/>|<br />|<br>",
with: "\n",
options: .regularExpression,
range: nil
)
let linkRegex = #"(<a\s+(?:[^>]*?\s+)?href=\"[^"]*\">[^<]*<\/a>)"#
let hrefRegex = #"href=\"([^"]*)\">"#
guard let hrefRegex = try? NSRegularExpression(pattern: hrefRegex) else { return string }
string = string.replacingMatches(regex: linkRegex) { matchingGroup in
let results = hrefRegex.matches(in: matchingGroup, range: NSRange(matchingGroup.startIndex..., in: matchingGroup))
if let result = results.first {
if let swiftRange = Range(result.range(at: 1), in: matchingGroup) {
return String(matchingGroup[swiftRange])
}
}
return matchingGroup
}
string = string
.replacingOccurrences(of: "&amp;", with: "&")
.replacingOccurrences(of: "&nbsp;", with: " ")
.replacingOccurrences(
of: "<[^>]+>",
with: "",
options: .regularExpression,
range: nil
)
return string
}
private func extractVideos(from content: JSON) -> [Video] {
content.arrayValue.compactMap(extractVideo(from:))
}
private func extractStreams(from content: JSON) -> [Stream] {
var streams = [Stream]()
if let hlsURL = content.dictionaryValue["hls"]?.url {
streams.append(Stream(instance: account.instance, hlsURL: hlsURL))
}
// Extract all M4A audio streams, sorted by bitrate (highest first)
let allAudioStreams = content
.dictionaryValue["audioStreams"]?
.arrayValue
.filter { $0.dictionaryValue["format"]?.string == "M4A" }
.sorted {
$0.dictionaryValue["bitrate"]?.int ?? 0 >
$1.dictionaryValue["bitrate"]?.int ?? 0
} ?? []
// Group audio streams by track type and language, keeping highest bitrate for each
var audioTracksByType = [String: JSON]()
for audioStream in allAudioStreams {
let trackType = audioStream.dictionaryValue["audioTrackType"]?.string
let trackLocale = audioStream.dictionaryValue["audioTrackLocale"]?.string
// Create a unique key for this audio track combination
let key = "\(trackType ?? "ORIGINAL")_\(trackLocale ?? "")"
// Only keep the first (highest bitrate) stream for each unique track type/locale combination
if audioTracksByType[key] == nil {
audioTracksByType[key] = audioStream
}
}
// Convert to Stream.AudioTrack array
let audioTracks: [Stream.AudioTrack] = audioTracksByType.values.compactMap { audioStream in
guard let url = audioStream.dictionaryValue["url"]?.url else {
return nil
}
let trackType = audioStream.dictionaryValue["audioTrackType"]?.string
let trackLocale = audioStream.dictionaryValue["audioTrackLocale"]?.string
return Stream.AudioTrack(
url: url,
content: trackType,
language: trackLocale
)
}.sorted { track1, track2 in
// Sort: ORIGINAL first, then DUBBED, then others
if track1.content == "ORIGINAL" && track2.content != "ORIGINAL" {
return true
} else if track1.content != "ORIGINAL" && track2.content == "ORIGINAL" {
return false
} else {
// If both are same type, sort by language
return (track1.language ?? "") < (track2.language ?? "")
}
}
// Fallback to first audio stream if no tracks were extracted
guard !audioTracks.isEmpty else {
return streams
}
let videoStreams = content.dictionaryValue["videoStreams"]?.arrayValue ?? []
for videoStream in videoStreams {
let videoCodec = videoStream.dictionaryValue["codec"]?.string ?? ""
if Self.disallowedVideoCodecs.contains(where: videoCodec.contains) {
continue
}
guard let videoAssetUrl = videoStream.dictionaryValue["url"]?.url else {
continue
}
// Use the first (ORIGINAL) audio track as default
let defaultAudioAsset = AVURLAsset(url: audioTracks[0].url)
let videoAsset = AVURLAsset(url: videoAssetUrl)
let videoOnly = videoStream.dictionaryValue["videoOnly"]?.bool ?? true
let quality = videoStream.dictionaryValue["quality"]?.string ?? "unknown"
let qualityComponents = quality.components(separatedBy: "p")
let fps = qualityComponents.count > 1 ? Int(qualityComponents[1]) : 30
let resolution = Stream.Resolution.from(resolution: quality, fps: fps)
let videoFormat = videoStream.dictionaryValue["format"]?.string
let bitrate = videoStream.dictionaryValue["bitrate"]?.int
var requestRange: String?
if let initStart = videoStream.dictionaryValue["initStart"]?.int,
let initEnd = videoStream.dictionaryValue["initEnd"]?.int
{
requestRange = "\(initStart)-\(initEnd)"
} else if let indexStart = videoStream.dictionaryValue["indexStart"]?.int,
let indexEnd = videoStream.dictionaryValue["indexEnd"]?.int
{
requestRange = "\(indexStart)-\(indexEnd)"
} else {
requestRange = nil
}
if videoOnly {
streams.append(
Stream(
instance: account.instance,
audioAsset: defaultAudioAsset,
videoAsset: videoAsset,
resolution: resolution,
kind: .adaptive,
videoFormat: videoFormat,
bitrate: bitrate,
requestRange: requestRange,
audioTracks: audioTracks
)
)
} else {
streams.append(
SingleAssetStream(
instance: account.instance,
avAsset: videoAsset,
resolution: resolution,
kind: .stream
)
)
}
}
return streams
}
private func extractRelated(from content: JSON) -> [Video] {
content
.dictionaryValue["relatedStreams"]?
.arrayValue
.compactMap(extractVideo(from:)) ?? []
}
private func extractComment(from content: JSON) -> Comment? {
let details = content.dictionaryValue
let author = details["author"]?.string ?? ""
let commentorUrl = details["commentorUrl"]?.string
let channelId = commentorUrl?.components(separatedBy: "/")[2] ?? ""
let commentText = extractCommentText(from: details["commentText"]?.stringValue)
let commentId = details["commentId"]?.string ?? UUID().uuidString
// Sanity checks: return nil if required data is missing
if commentText.isEmpty || commentId.isEmpty || author.isEmpty {
return nil
}
return Comment(
id: commentId,
author: author,
authorAvatarURL: details["thumbnail"]?.string ?? "",
time: details["commentedTime"]?.string ?? "",
pinned: details["pinned"]?.bool ?? false,
hearted: details["hearted"]?.bool ?? false,
likeCount: details["likeCount"]?.int ?? 0,
text: commentText,
repliesPage: details["repliesPage"]?.string,
channel: Channel(app: .piped, id: channelId, name: author)
)
}
private func extractCommentText(from string: String?) -> String {
guard let string, !string.isEmpty else { return "" }
return replaceHTML(string)
}
private func extractChapters(from content: JSON) -> [Chapter] {
guard let chapters = content.dictionaryValue["chapters"]?.array else {
return .init()
}
return chapters.compactMap { chapter in
guard let title = chapter["title"].string,
let image = chapter["image"].url,
let start = chapter["start"].double
else {
return nil
}
return Chapter(title: title, image: image, start: start)
}
}
private func extractCaptions(from content: JSON) -> [Captions] {
content["subtitles"].arrayValue.compactMap { details in
guard let url = details["url"].url,
let code = details["code"].string,
let label = details["name"].string,
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
else { return nil }
components.queryItems = components.queryItems?.map { item in
item.name == "fmt" ? URLQueryItem(name: "fmt", value: "srt") : item
}
guard let newUrl = components.url else { return nil }
return Captions(label: label, code: code, url: newUrl)
}
}
private func contentItemsDictionary(from content: JSON) -> JSON {
if let key = Self.contentItemsKeys.first(where: { content.dictionaryValue.keys.contains($0) }),
let items = content.dictionaryValue[key]
{
return items
}
return .null
}
}