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) -> [Video] in content.json.arrayValue.map(self.extractVideo) } configureTransformer(pathPattern("trending"), requestMethods: [.get]) { (content: Entity) -> [Video] in content.json.arrayValue.map(self.extractVideo) } configureTransformer(pathPattern("search"), requestMethods: [.get]) { (content: Entity) -> 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) -> [String] in if let suggestions = content.json.dictionaryValue["suggestions"] { return suggestions.arrayValue.map(\.stringValue).map(\.replacingHTMLEntities) } return [] } configureTransformer(pathPattern("auth/playlists"), requestMethods: [.get]) { (content: Entity) -> [Playlist] in content.json.arrayValue.map(self.extractPlaylist) } configureTransformer(pathPattern("auth/playlists/*"), requestMethods: [.get]) { (content: Entity) -> Playlist in self.extractPlaylist(from: content.json) } configureTransformer(pathPattern("auth/playlists"), requestMethods: [.post, .patch]) { (content: Entity) -> Playlist in self.extractPlaylist(from: JSON(parseJSON: String(data: content.content, encoding: .utf8)!)) } configureTransformer(pathPattern("auth/feed"), requestMethods: [.get]) { (content: Entity) -> [Video] in if let feedVideos = content.json.dictionaryValue["videos"] { return feedVideos.arrayValue.map(self.extractVideo) } return [] } configureTransformer(pathPattern("auth/subscriptions"), requestMethods: [.get]) { (content: Entity) -> [Channel] in content.json.arrayValue.map(self.extractChannel) } configureTransformer(pathPattern("channels/*"), requestMethods: [.get]) { (content: Entity) -> ChannelPage in self.extractChannelPage(from: content.json, forceNotLast: true) } configureTransformer(pathPattern("channels/*/videos"), requestMethods: [.get]) { (content: Entity) -> ChannelPage in self.extractChannelPage(from: content.json) } configureTransformer(pathPattern("channels/*/latest"), requestMethods: [.get]) { (content: Entity) -> [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) -> ChannelPage in self.extractChannelPage(from: content.json) } } configureTransformer(pathPattern("playlists/*"), requestMethods: [.get]) { (content: Entity) -> ChannelPlaylist in self.extractChannelPlaylist(from: content.json) } configureTransformer(pathPattern("videos/*"), requestMethods: [.get]) { (content: Entity) -> Video in self.extractVideo(from: content.json) } configureTransformer(pathPattern("comments/*")) { (content: Entity) -> 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?, 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=(?[^;]*);"# 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[.. 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 } } }