mirror of
https://github.com/yattee/yattee.git
synced 2024-12-13 13:50:32 +05:30
7e3e393c65
Invidious, by design, has no images attached to chapters, in contrast to Piped. Since the majority of videos with chapters don't have chapter-specific images and only use the videos' thumbnail, there is no difference here when compared to Piped's native thumbnail support.
784 lines
28 KiB
Swift
784 lines
28 KiB
Swift
import Alamofire
|
|
import AVKit
|
|
import Defaults
|
|
import Foundation
|
|
import Siesta
|
|
import SwiftyJSON
|
|
|
|
final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
|
static let basePath = "/api/v1"
|
|
|
|
@Published var account: Account!
|
|
|
|
static func withAnonymousAccountForInstanceURL(_ url: URL) -> InvidiousAPI {
|
|
.init(account: Instance(app: .invidious, apiURLString: url.absoluteString).anonymousAccount)
|
|
}
|
|
|
|
var signedIn: Bool {
|
|
guard let account else { return false }
|
|
|
|
return !account.anonymous && !(account.token?.isEmpty ?? true)
|
|
}
|
|
|
|
init(account: Account? = nil) {
|
|
super.init()
|
|
|
|
guard !account.isNil else {
|
|
self.account = .init(name: "Empty")
|
|
return
|
|
}
|
|
|
|
setAccount(account!)
|
|
}
|
|
|
|
func setAccount(_ account: Account) {
|
|
self.account = account
|
|
|
|
configure()
|
|
}
|
|
|
|
func configure() {
|
|
invalidateConfiguration()
|
|
|
|
configure {
|
|
if let cookie = self.cookieHeader {
|
|
$0.headers["Cookie"] = cookie
|
|
}
|
|
$0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"])
|
|
}
|
|
|
|
configure("**", requestMethods: [.post]) {
|
|
$0.pipeline[.parsing].removeTransformers()
|
|
}
|
|
|
|
configureTransformer(pathPattern("popular"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
|
|
content.json.arrayValue.map(self.extractVideo)
|
|
}
|
|
|
|
configureTransformer(pathPattern("trending"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
|
|
content.json.arrayValue.map(self.extractVideo)
|
|
}
|
|
|
|
configureTransformer(pathPattern("search"), requestMethods: [.get]) { (content: Entity<JSON>) -> SearchPage in
|
|
let results = content.json.arrayValue.compactMap { json -> ContentItem? in
|
|
let type = json.dictionaryValue["type"]?.string
|
|
|
|
if type == "channel" {
|
|
return ContentItem(channel: self.extractChannel(from: json))
|
|
}
|
|
if type == "playlist" {
|
|
return ContentItem(playlist: self.extractChannelPlaylist(from: json))
|
|
}
|
|
if type == "video" {
|
|
return ContentItem(video: self.extractVideo(from: json))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
return SearchPage(results: results, last: results.isEmpty)
|
|
}
|
|
|
|
configureTransformer(pathPattern("search/suggestions"), requestMethods: [.get]) { (content: Entity<JSON>) -> [String] in
|
|
if let suggestions = content.json.dictionaryValue["suggestions"] {
|
|
return suggestions.arrayValue.map { $0.stringValue.replacingHTMLEntities }
|
|
}
|
|
|
|
return []
|
|
}
|
|
|
|
configureTransformer(pathPattern("auth/playlists"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Playlist] in
|
|
content.json.arrayValue.map(self.extractPlaylist)
|
|
}
|
|
|
|
configureTransformer(pathPattern("auth/playlists/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Playlist in
|
|
self.extractPlaylist(from: content.json)
|
|
}
|
|
|
|
configureTransformer(pathPattern("auth/playlists"), requestMethods: [.post, .patch]) { (content: Entity<Data>) -> Playlist in
|
|
self.extractPlaylist(from: JSON(parseJSON: String(data: content.content, encoding: .utf8)!))
|
|
}
|
|
|
|
configureTransformer(pathPattern("auth/feed"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
|
|
if let feedVideos = content.json.dictionaryValue["videos"] {
|
|
return feedVideos.arrayValue.map(self.extractVideo)
|
|
}
|
|
|
|
return []
|
|
}
|
|
|
|
configureTransformer(pathPattern("auth/subscriptions"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Channel] in
|
|
content.json.arrayValue.map(self.extractChannel)
|
|
}
|
|
|
|
configureTransformer(pathPattern("channels/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> ChannelPage in
|
|
self.extractChannelPage(from: content.json, forceNotLast: true)
|
|
}
|
|
|
|
configureTransformer(pathPattern("channels/*/videos"), requestMethods: [.get]) { (content: Entity<JSON>) -> ChannelPage in
|
|
self.extractChannelPage(from: content.json)
|
|
}
|
|
|
|
configureTransformer(pathPattern("channels/*/latest"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Video] in
|
|
content.json.dictionaryValue["videos"]?.arrayValue.map(self.extractVideo) ?? []
|
|
}
|
|
|
|
for type in ["latest", "playlists", "streams", "shorts", "channels", "videos", "releases", "podcasts"] {
|
|
configureTransformer(pathPattern("channels/*/\(type)"), requestMethods: [.get]) { (content: Entity<JSON>) -> ChannelPage in
|
|
self.extractChannelPage(from: content.json)
|
|
}
|
|
}
|
|
|
|
configureTransformer(pathPattern("playlists/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> ChannelPlaylist in
|
|
self.extractChannelPlaylist(from: content.json)
|
|
}
|
|
|
|
configureTransformer(pathPattern("videos/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> Video in
|
|
self.extractVideo(from: content.json)
|
|
}
|
|
|
|
configureTransformer(pathPattern("comments/*")) { (content: Entity<JSON>) -> CommentsPage in
|
|
let details = content.json.dictionaryValue
|
|
let comments = details["comments"]?.arrayValue.compactMap { self.extractComment(from: $0) } ?? []
|
|
let nextPage = details["continuation"]?.string
|
|
let disabled = !details["error"].isNil
|
|
|
|
return CommentsPage(comments: comments, nextPage: nextPage, disabled: disabled)
|
|
}
|
|
|
|
if account.token.isNil || account.token!.isEmpty {
|
|
updateToken()
|
|
} else {
|
|
FeedModel.shared.onAccountChange()
|
|
SubscribedChannelsModel.shared.onAccountChange()
|
|
PlaylistsModel.shared.onAccountChange()
|
|
}
|
|
}
|
|
|
|
func updateToken(force: Bool = false) {
|
|
let (username, password) = AccountsModel.getCredentials(account)
|
|
guard !account.anonymous,
|
|
(account.token?.isEmpty ?? true) || force
|
|
else {
|
|
return
|
|
}
|
|
|
|
guard let username,
|
|
let password,
|
|
!username.isEmpty,
|
|
!password.isEmpty
|
|
else {
|
|
NavigationModel.shared.presentAlert(
|
|
title: "Account Error",
|
|
message: "Remove and add your account again in Settings."
|
|
)
|
|
return
|
|
}
|
|
|
|
let presentTokenUpdateFailedAlert: (AFDataResponse<Data?>?, String?) -> Void = { response, message in
|
|
NavigationModel.shared.presentAlert(
|
|
title: "Account Error",
|
|
message: message ?? "\(response?.response?.statusCode ?? -1) - \(response?.error?.errorDescription ?? "unknown")\nIf this issue persists, try removing and adding your account again in Settings."
|
|
)
|
|
}
|
|
|
|
AF
|
|
.request(login.url, method: .post, parameters: ["email": username, "password": password], encoding: URLEncoding.default)
|
|
.redirect(using: .doNotFollow)
|
|
.response { response in
|
|
guard let headers = response.response?.headers,
|
|
let cookies = headers["Set-Cookie"]
|
|
else {
|
|
presentTokenUpdateFailedAlert(response, nil)
|
|
return
|
|
}
|
|
|
|
let sidRegex = #"SID=(?<sid>[^;]*);"#
|
|
guard let sidRegex = try? NSRegularExpression(pattern: sidRegex),
|
|
let match = sidRegex.matches(in: cookies, range: NSRange(cookies.startIndex..., in: cookies)).first
|
|
else {
|
|
presentTokenUpdateFailedAlert(nil, String(format: "Could not extract SID from received cookies: %@".localized(), cookies))
|
|
return
|
|
}
|
|
|
|
let matchRange = match.range(withName: "sid")
|
|
|
|
if let substringRange = Range(matchRange, in: cookies) {
|
|
let sid = String(cookies[substringRange])
|
|
AccountsModel.setToken(self.account, sid)
|
|
self.objectWillChange.send()
|
|
} else {
|
|
presentTokenUpdateFailedAlert(nil, String(format: "Could not extract SID from received cookies: %@".localized(), cookies))
|
|
}
|
|
|
|
self.configure()
|
|
}
|
|
}
|
|
|
|
var login: Resource {
|
|
resource(baseURL: account.url, path: "login")
|
|
}
|
|
|
|
private func pathPattern(_ path: String) -> String {
|
|
"**\(Self.basePath)/\(path)"
|
|
}
|
|
|
|
private func basePathAppending(_ path: String) -> String {
|
|
"\(Self.basePath)/\(path)"
|
|
}
|
|
|
|
private var cookieHeader: String? {
|
|
guard let token = account?.token, !token.isEmpty else { return nil }
|
|
return "SID=\(token)"
|
|
}
|
|
|
|
var popular: Resource? {
|
|
resource(baseURL: account.url, path: "\(Self.basePath)/popular")
|
|
}
|
|
|
|
func trending(country: Country, category: TrendingCategory?) -> Resource {
|
|
resource(baseURL: account.url, path: "\(Self.basePath)/trending")
|
|
.withParam("type", category?.type)
|
|
.withParam("region", country.rawValue)
|
|
}
|
|
|
|
var home: Resource? {
|
|
resource(baseURL: account.url, path: "/feed/subscriptions")
|
|
}
|
|
|
|
func feed(_ page: Int?) -> Resource? {
|
|
resource(baseURL: account.url, path: "\(Self.basePath)/auth/feed")
|
|
.withParam("page", String(page ?? 1))
|
|
}
|
|
|
|
var feed: Resource? {
|
|
resource(baseURL: account.url, path: basePathAppending("auth/feed"))
|
|
}
|
|
|
|
var subscriptions: Resource? {
|
|
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
|
|
}
|
|
|
|
func subscribe(_ channelID: String, onCompletion: @escaping () -> Void = {}) {
|
|
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
|
|
.child(channelID)
|
|
.request(.post)
|
|
.onCompletion { _ in onCompletion() }
|
|
}
|
|
|
|
func unsubscribe(_ channelID: String, onCompletion: @escaping () -> Void) {
|
|
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
|
|
.child(channelID)
|
|
.request(.delete)
|
|
.onCompletion { _ in onCompletion() }
|
|
}
|
|
|
|
func channel(_ id: String, contentType: Channel.ContentType, data _: String?, page: String?) -> Resource {
|
|
if page.isNil, contentType == .videos {
|
|
return resource(baseURL: account.url, path: basePathAppending("channels/\(id)"))
|
|
}
|
|
|
|
var resource = resource(baseURL: account.url, path: basePathAppending("channels/\(id)/\(contentType.invidiousID)"))
|
|
|
|
if let page, !page.isEmpty {
|
|
resource = resource.withParam("continuation", page)
|
|
}
|
|
|
|
return resource
|
|
}
|
|
|
|
func channelByName(_: String) -> Resource? {
|
|
nil
|
|
}
|
|
|
|
func channelByUsername(_: String) -> Resource? {
|
|
nil
|
|
}
|
|
|
|
func channelVideos(_ id: String) -> Resource {
|
|
resource(baseURL: account.url, path: basePathAppending("channels/\(id)/latest"))
|
|
}
|
|
|
|
func video(_ id: String) -> Resource {
|
|
resource(baseURL: account.url, path: basePathAppending("videos/\(id)"))
|
|
}
|
|
|
|
var playlists: Resource? {
|
|
if account.isNil || account.anonymous {
|
|
return nil
|
|
}
|
|
|
|
return resource(baseURL: account.url, path: basePathAppending("auth/playlists"))
|
|
}
|
|
|
|
func playlist(_ id: String) -> Resource? {
|
|
resource(baseURL: account.url, path: basePathAppending("auth/playlists/\(id)"))
|
|
}
|
|
|
|
func playlistVideos(_ id: String) -> Resource? {
|
|
playlist(id)?.child("videos")
|
|
}
|
|
|
|
func playlistVideo(_ playlistID: String, _ videoID: String) -> Resource? {
|
|
playlist(playlistID)?.child("videos").child(videoID)
|
|
}
|
|
|
|
func addVideoToPlaylist(
|
|
_ videoID: String,
|
|
_ playlistID: String,
|
|
onFailure: @escaping (RequestError) -> Void = { _ in },
|
|
onSuccess: @escaping () -> Void = {}
|
|
) {
|
|
let resource = playlistVideos(playlistID)
|
|
let body = ["videoId": videoID]
|
|
|
|
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 = playlistVideo(playlistID, index)
|
|
|
|
resource?
|
|
.request(.delete)
|
|
.onSuccess { _ in onSuccess() }
|
|
.onFailure(onFailure)
|
|
}
|
|
|
|
func playlistForm(
|
|
_ name: String,
|
|
_ visibility: String,
|
|
playlist: Playlist?,
|
|
onFailure: @escaping (RequestError) -> Void,
|
|
onSuccess: @escaping (Playlist?) -> Void
|
|
) {
|
|
let body = ["title": name, "privacy": visibility]
|
|
let resource = !playlist.isNil ? self.playlist(playlist!.id) : playlists
|
|
|
|
resource?
|
|
.request(!playlist.isNil ? .patch : .post, json: body)
|
|
.onSuccess { response in
|
|
if let modifiedPlaylist: Playlist = response.typedContent() {
|
|
onSuccess(modifiedPlaylist)
|
|
}
|
|
}
|
|
.onFailure(onFailure)
|
|
}
|
|
|
|
func deletePlaylist(
|
|
_ playlist: Playlist,
|
|
onFailure: @escaping (RequestError) -> Void,
|
|
onSuccess: @escaping () -> Void
|
|
) {
|
|
self.playlist(playlist.id)?
|
|
.request(.delete)
|
|
.onSuccess { _ in onSuccess() }
|
|
.onFailure(onFailure)
|
|
}
|
|
|
|
func channelPlaylist(_ id: String) -> Resource? {
|
|
resource(baseURL: account.url, path: basePathAppending("playlists/\(id)"))
|
|
}
|
|
|
|
func search(_ query: SearchQuery, page: String?) -> Resource {
|
|
var resource = resource(baseURL: account.url, path: basePathAppending("search"))
|
|
.withParam("q", searchQuery(query.query))
|
|
.withParam("sort_by", query.sortBy.parameter)
|
|
.withParam("type", "all")
|
|
|
|
if let date = query.date, date != .any {
|
|
resource = resource.withParam("date", date.rawValue)
|
|
}
|
|
|
|
if let duration = query.duration, duration != .any {
|
|
resource = resource.withParam("duration", duration.rawValue)
|
|
}
|
|
|
|
if let page {
|
|
resource = resource.withParam("page", page)
|
|
}
|
|
|
|
return resource
|
|
}
|
|
|
|
func searchSuggestions(query: String) -> Resource {
|
|
resource(baseURL: account.url, path: basePathAppending("search/suggestions"))
|
|
.withParam("q", query.lowercased())
|
|
}
|
|
|
|
func comments(_ id: Video.ID, page: String?) -> Resource? {
|
|
let resource = resource(baseURL: account.url, path: basePathAppending("comments/\(id)"))
|
|
guard let page else { return resource }
|
|
|
|
return resource.withParam("continuation", page)
|
|
}
|
|
|
|
private func searchQuery(_ query: String) -> String {
|
|
var searchQuery = query
|
|
|
|
let url = URLComponents(string: query)
|
|
|
|
if url != nil,
|
|
url!.host == "youtu.be"
|
|
{
|
|
searchQuery = url!.path.replacingOccurrences(of: "/", with: "")
|
|
}
|
|
|
|
let queryItem = url?.queryItems?.first { item in item.name == "v" }
|
|
if let id = queryItem?.value {
|
|
searchQuery = id
|
|
}
|
|
|
|
return searchQuery
|
|
}
|
|
|
|
static func proxiedAsset(instance: Instance, asset: AVURLAsset) -> AVURLAsset? {
|
|
guard let instanceURLComponents = URLComponents(url: instance.apiURL, resolvingAgainstBaseURL: false),
|
|
var urlComponents = URLComponents(url: asset.url, resolvingAgainstBaseURL: false) else { return nil }
|
|
|
|
urlComponents.scheme = instanceURLComponents.scheme
|
|
urlComponents.host = instanceURLComponents.host
|
|
|
|
guard let url = urlComponents.url else {
|
|
return nil
|
|
}
|
|
|
|
return AVURLAsset(url: url)
|
|
}
|
|
|
|
func extractVideo(from json: JSON) -> Video {
|
|
let indexID: String?
|
|
var id: Video.ID
|
|
var published = json["publishedText"].stringValue
|
|
var publishedAt: Date?
|
|
|
|
if let publishedInterval = json["published"].double {
|
|
publishedAt = Date(timeIntervalSince1970: publishedInterval)
|
|
published = ""
|
|
}
|
|
|
|
let videoID = json["videoId"].stringValue
|
|
|
|
if let index = json["indexId"].string {
|
|
indexID = index
|
|
id = videoID + index
|
|
} else {
|
|
indexID = nil
|
|
id = videoID
|
|
}
|
|
|
|
let description = json["description"].stringValue
|
|
let length = json["lengthSeconds"].doubleValue
|
|
|
|
return Video(
|
|
instanceID: account.instanceID,
|
|
app: .invidious,
|
|
instanceURL: account.instance.apiURL,
|
|
id: id,
|
|
videoID: videoID,
|
|
title: json["title"].stringValue,
|
|
author: json["author"].stringValue,
|
|
length: length,
|
|
published: published,
|
|
views: json["viewCount"].intValue,
|
|
description: description,
|
|
genre: json["genre"].stringValue,
|
|
channel: extractChannel(from: json),
|
|
thumbnails: extractThumbnails(from: json),
|
|
indexID: indexID,
|
|
live: json["liveNow"].boolValue,
|
|
upcoming: json["isUpcoming"].boolValue,
|
|
short: length <= Video.shortLength,
|
|
publishedAt: publishedAt,
|
|
likes: json["likeCount"].int,
|
|
dislikes: json["dislikeCount"].int,
|
|
keywords: json["keywords"].arrayValue.compactMap { $0.string },
|
|
streams: extractStreams(from: json),
|
|
related: extractRelated(from: json),
|
|
chapters: createChapters(from: description, thumbnails: json),
|
|
captions: extractCaptions(from: json)
|
|
)
|
|
}
|
|
|
|
func extractChannel(from json: JSON) -> Channel {
|
|
var thumbnailURL = json["authorThumbnails"].arrayValue.last?.dictionaryValue["url"]?.string ?? ""
|
|
|
|
// append protocol to unproxied thumbnail URL if it's missing
|
|
if thumbnailURL.count > 2,
|
|
String(thumbnailURL[..<thumbnailURL.index(thumbnailURL.startIndex, offsetBy: 2)]) == "//",
|
|
let accountUrlComponents = URLComponents(string: account.urlString)
|
|
{
|
|
thumbnailURL = "\(accountUrlComponents.scheme ?? "https"):\(thumbnailURL)"
|
|
}
|
|
|
|
let tabs = json["tabs"].arrayValue.compactMap { name in
|
|
if let name = name.string, let type = Channel.ContentType.from(name) {
|
|
return Channel.Tab(contentType: type, data: "")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
return Channel(
|
|
app: .invidious,
|
|
id: json["authorId"].stringValue,
|
|
name: json["author"].stringValue,
|
|
bannerURL: json["authorBanners"].arrayValue.first?.dictionaryValue["url"]?.url,
|
|
thumbnailURL: URL(string: thumbnailURL),
|
|
description: json["description"].stringValue,
|
|
subscriptionsCount: json["subCount"].int,
|
|
subscriptionsText: json["subCountText"].string,
|
|
totalViews: json["totalViews"].int,
|
|
videos: json.dictionaryValue["latestVideos"]?.arrayValue.map(extractVideo) ?? [],
|
|
tabs: tabs
|
|
)
|
|
}
|
|
|
|
func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist {
|
|
let details = json.dictionaryValue
|
|
return ChannelPlaylist(
|
|
id: details["playlistId"]?.string ?? details["mixId"]?.string ?? UUID().uuidString,
|
|
title: details["title"]?.stringValue ?? "",
|
|
thumbnailURL: details["playlistThumbnail"]?.url,
|
|
channel: extractChannel(from: json),
|
|
videos: details["videos"]?.arrayValue.compactMap(extractVideo) ?? [],
|
|
videosCount: details["videoCount"]?.int
|
|
)
|
|
}
|
|
|
|
private func extractThumbnails(from details: JSON) -> [Thumbnail] {
|
|
details["videoThumbnails"].arrayValue.compactMap { json in
|
|
guard let url = json["url"].url,
|
|
var components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
|
let quality = json["quality"].string,
|
|
let accountUrlComponents = URLComponents(string: account.urlString)
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
// some of instances are not configured properly and return thumbnails links
|
|
// with incorrect scheme
|
|
components.scheme = accountUrlComponents.scheme
|
|
|
|
guard let thumbnailUrl = components.url else {
|
|
return nil
|
|
}
|
|
|
|
return Thumbnail(url: thumbnailUrl, quality: .init(rawValue: quality)!)
|
|
}
|
|
}
|
|
|
|
private func createChapters(from description: String, thumbnails: JSON) -> [Chapter] {
|
|
var chapters = extractChapters(from: description)
|
|
|
|
if !chapters.isEmpty {
|
|
let thumbnailsData = extractThumbnails(from: thumbnails)
|
|
let thumbnailURL = thumbnailsData.first { $0.quality == .medium }?.url
|
|
|
|
for chapter in chapters.indices {
|
|
if let url = thumbnailURL {
|
|
chapters[chapter].image = url
|
|
}
|
|
}
|
|
}
|
|
return chapters
|
|
}
|
|
|
|
private static var contentItemsKeys = ["items", "videos", "latestVideos", "playlists", "relatedChannels"]
|
|
|
|
private func extractChannelPage(from json: JSON, forceNotLast: Bool = false) -> ChannelPage {
|
|
let nextPage = json.dictionaryValue["continuation"]?.string
|
|
var contentItems = [ContentItem]()
|
|
|
|
if let key = Self.contentItemsKeys.first(where: { json.dictionaryValue.keys.contains($0) }),
|
|
let items = json.dictionaryValue[key]
|
|
{
|
|
contentItems = extractContentItems(from: items)
|
|
}
|
|
|
|
var last = false
|
|
if !forceNotLast {
|
|
last = nextPage?.isEmpty ?? true
|
|
}
|
|
|
|
return ChannelPage(
|
|
results: contentItems,
|
|
channel: extractChannel(from: json),
|
|
nextPage: nextPage,
|
|
last: last
|
|
)
|
|
}
|
|
|
|
private func extractStreams(from json: JSON) -> [Stream] {
|
|
let hls = extractHLSStreams(from: json)
|
|
if json["liveNow"].boolValue {
|
|
return hls
|
|
}
|
|
|
|
return extractFormatStreams(from: json["formatStreams"].arrayValue) +
|
|
extractAdaptiveFormats(from: json["adaptiveFormats"].arrayValue) +
|
|
hls
|
|
}
|
|
|
|
private func extractFormatStreams(from streams: [JSON]) -> [Stream] {
|
|
streams.compactMap { stream in
|
|
guard let streamURL = stream["url"].url else {
|
|
return nil
|
|
}
|
|
|
|
return SingleAssetStream(
|
|
instance: account.instance,
|
|
avAsset: AVURLAsset(url: streamURL),
|
|
resolution: Stream.Resolution.from(resolution: stream["resolution"].string ?? ""),
|
|
kind: .stream,
|
|
encoding: stream["encoding"].string ?? ""
|
|
)
|
|
}
|
|
}
|
|
|
|
private func extractAdaptiveFormats(from streams: [JSON]) -> [Stream] {
|
|
let audioStreams = streams
|
|
.filter { $0["type"].stringValue.starts(with: "audio/mp4") }
|
|
.sorted {
|
|
$0.dictionaryValue["bitrate"]?.int ?? 0 >
|
|
$1.dictionaryValue["bitrate"]?.int ?? 0
|
|
}
|
|
guard let audioStream = audioStreams.first else {
|
|
return .init()
|
|
}
|
|
|
|
let videoStreams = streams.filter { $0["type"].stringValue.starts(with: "video/") }
|
|
|
|
return videoStreams.compactMap { videoStream in
|
|
guard let audioAssetURL = audioStream["url"].url,
|
|
let videoAssetURL = videoStream["url"].url
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
return Stream(
|
|
instance: account.instance,
|
|
audioAsset: AVURLAsset(url: audioAssetURL),
|
|
videoAsset: AVURLAsset(url: videoAssetURL),
|
|
resolution: Stream.Resolution.from(resolution: videoStream["resolution"].stringValue),
|
|
kind: .adaptive,
|
|
encoding: videoStream["encoding"].string,
|
|
videoFormat: videoStream["type"].string,
|
|
bitrate: videoStream["bitrate"].int,
|
|
requestRange: videoStream["init"].string ?? videoStream["index"].string
|
|
)
|
|
}
|
|
}
|
|
|
|
private func extractHLSStreams(from content: JSON) -> [Stream] {
|
|
if let hlsURL = content.dictionaryValue["hlsUrl"]?.url {
|
|
return [Stream(instance: account.instance, hlsURL: hlsURL)]
|
|
}
|
|
|
|
return []
|
|
}
|
|
|
|
private func extractRelated(from content: JSON) -> [Video] {
|
|
content
|
|
.dictionaryValue["recommendedVideos"]?
|
|
.arrayValue
|
|
.compactMap(extractVideo(from:)) ?? []
|
|
}
|
|
|
|
private func extractPlaylist(from content: JSON) -> Playlist {
|
|
let id = content["playlistId"].stringValue
|
|
return Playlist(
|
|
id: id,
|
|
title: content["title"].stringValue,
|
|
visibility: content["isListed"].boolValue ? .public : .private,
|
|
editable: id.starts(with: "IV"),
|
|
updated: content["updated"].doubleValue,
|
|
videos: content["videos"].arrayValue.map { extractVideo(from: $0) }
|
|
)
|
|
}
|
|
|
|
private func extractComment(from content: JSON) -> Comment? {
|
|
let details = content.dictionaryValue
|
|
let author = details["author"]?.string ?? ""
|
|
let channelId = details["authorId"]?.string ?? UUID().uuidString
|
|
let authorAvatarURL = details["authorThumbnails"]?.arrayValue.last?.dictionaryValue["url"]?.string ?? ""
|
|
let htmlContent = details["contentHtml"]?.string ?? ""
|
|
let decodedContent = decodeHtml(htmlContent)
|
|
return Comment(
|
|
id: UUID().uuidString,
|
|
author: author,
|
|
authorAvatarURL: authorAvatarURL,
|
|
time: details["publishedText"]?.string ?? "",
|
|
pinned: false,
|
|
hearted: false,
|
|
likeCount: details["likeCount"]?.int ?? 0,
|
|
text: decodedContent,
|
|
repliesPage: details["replies"]?.dictionaryValue["continuation"]?.string,
|
|
channel: Channel(app: .invidious, id: channelId, name: author)
|
|
)
|
|
}
|
|
|
|
private func decodeHtml(_ htmlEncodedString: String) -> String {
|
|
if let data = htmlEncodedString.data(using: .utf8) {
|
|
let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [
|
|
.documentType: NSAttributedString.DocumentType.html,
|
|
.characterEncoding: String.Encoding.utf8.rawValue
|
|
]
|
|
if let attributedString = try? NSAttributedString(data: data, options: options, documentAttributes: nil) {
|
|
return attributedString.string
|
|
}
|
|
}
|
|
return htmlEncodedString
|
|
}
|
|
|
|
private func extractCaptions(from content: JSON) -> [Captions] {
|
|
content["captions"].arrayValue.compactMap { details in
|
|
guard let url = URL(string: details["url"].stringValue, relativeTo: account.url) else { return nil }
|
|
|
|
return Captions(
|
|
label: details["label"].stringValue,
|
|
code: details["language_code"].stringValue,
|
|
url: url
|
|
)
|
|
}
|
|
}
|
|
|
|
private func extractContentItems(from json: JSON) -> [ContentItem] {
|
|
json.arrayValue.compactMap { extractContentItem(from: $0) }
|
|
}
|
|
|
|
private func extractContentItem(from json: JSON) -> ContentItem? {
|
|
let type = json.dictionaryValue["type"]?.string
|
|
|
|
if type == "channel" {
|
|
return ContentItem(channel: extractChannel(from: json))
|
|
}
|
|
if type == "playlist" {
|
|
return ContentItem(playlist: extractChannelPlaylist(from: json))
|
|
}
|
|
if type == "video" {
|
|
return ContentItem(video: extractVideo(from: json))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
}
|
|
|
|
extension Channel.ContentType {
|
|
var invidiousID: String {
|
|
switch self {
|
|
case .livestreams:
|
|
return "streams"
|
|
default:
|
|
return rawValue
|
|
}
|
|
}
|
|
}
|