mirror of
https://github.com/yattee/yattee.git
synced 2025-11-12 21:28:42 +00:00
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>
891 lines
32 KiB
Swift
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: "&", with: "&")
|
|
.replacingOccurrences(of: " ", 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
|
|
}
|
|
}
|