import AVFoundation import Foundation import Siesta import SwiftyJSON final class PipedAPI: Service, ObservableObject, VideosAPI { @Published var account: Account! var anonymousAccount: Account { .init(instanceID: account.instance.id, name: "Anonymous", url: account.instance.apiURL) } init(account: Account? = nil) { super.init() guard account != nil else { return } setAccount(account!) } func setAccount(_ account: Account) { self.account = account configure() } func configure() { configure { $0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"]) } configureTransformer(pathPattern("channel/*")) { (content: Entity) -> Channel? in PipedAPI.extractChannel(from: content.json) } configureTransformer(pathPattern("playlists/*")) { (content: Entity) -> ChannelPlaylist? in PipedAPI.extractChannelPlaylist(from: content.json) } configureTransformer(pathPattern("streams/*")) { (content: Entity) -> Video? in PipedAPI.extractVideo(from: content.json) } configureTransformer(pathPattern("trending")) { (content: Entity) -> [Video] in PipedAPI.extractVideos(from: content.json) } configureTransformer(pathPattern("search")) { (content: Entity) -> [ContentItem] in PipedAPI.extractContentItems(from: content.json.dictionaryValue["items"]!) } configureTransformer(pathPattern("suggestions")) { (content: Entity) -> [String] in content.json.arrayValue.map(String.init) } } func channel(_ id: String) -> Resource { resource(baseURL: account.url, path: "channel/\(id)") } 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) -> Resource { resource(baseURL: account.instance.apiURL, path: "search") .withParam("q", query.query) .withParam("filter", "") } 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 { false } var subscriptions: Resource? { nil } var feed: Resource? { nil } var home: Resource? { nil } var popular: Resource? { nil } var playlists: Resource? { nil } func channelSubscription(_: String) -> Resource? { nil } func playlistVideo(_: String, _: String) -> Resource? { nil } func playlistVideos(_: String) -> Resource? { nil } private func pathPattern(_ path: String) -> String { "**\(path)" } private static func extractContentItem(from content: JSON) -> ContentItem? { let details = content.dictionaryValue let url: String! = details["url"]?.string let contentType: ContentItem.ContentType if !url.isNil { 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 = PipedAPI.extractVideo(from: content) { return ContentItem(video: video) } case .playlist: if let playlist = PipedAPI.extractChannelPlaylist(from: content) { return ContentItem(playlist: playlist) } case .channel: if let channel = PipedAPI.extractChannel(from: content) { return ContentItem(channel: channel) } } return nil } private static func extractContentItems(from content: JSON) -> [ContentItem] { content.arrayValue.compactMap { PipedAPI.extractContentItem(from: $0) } } private static func extractChannel(from content: JSON) -> Channel? { let attributes = content.dictionaryValue guard let id = attributes["id"]?.stringValue ?? (attributes["url"] ?? attributes["uploaderUrl"])?.stringValue.components(separatedBy: "/").last else { return nil } let subscriptionsCount = attributes["subscriberCount"]?.intValue ?? attributes["subscribers"]?.intValue var videos = [Video]() if let relatedStreams = attributes["relatedStreams"] { videos = PipedAPI.extractVideos(from: relatedStreams) } return Channel( id: id, name: attributes["name"]!.stringValue, thumbnailURL: attributes["thumbnail"]?.url, subscriptionsCount: subscriptionsCount, videos: videos ) } static func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist? { let details = json.dictionaryValue let id = details["url"]?.stringValue.components(separatedBy: "?list=").last ?? UUID().uuidString let thumbnailURL = details["thumbnail"]?.url ?? details["thumbnailUrl"]?.url var videos = [Video]() if let relatedStreams = details["relatedStreams"] { videos = PipedAPI.extractVideos(from: relatedStreams) } return ChannelPlaylist( id: id, title: details["name"]!.stringValue, thumbnailURL: thumbnailURL, channel: extractChannel(from: json)!, videos: videos, videosCount: details["videos"]?.int ) } private static func extractVideo(from content: JSON) -> Video? { let details = content.dictionaryValue let url = details["url"]?.string if !url.isNil { guard url!.contains("/watch") else { return nil } } let channelId = details["uploaderUrl"]!.stringValue.components(separatedBy: "/").last! let thumbnails: [Thumbnail] = Thumbnail.Quality.allCases.compactMap { if let url = PipedAPI.buildThumbnailURL(from: content, quality: $0) { return Thumbnail(url: url, quality: $0) } return nil } let author = details["uploaderName"]?.stringValue ?? details["uploader"]!.stringValue return Video( videoID: PipedAPI.extractID(from: content), title: details["title"]!.stringValue, author: author, length: details["duration"]!.doubleValue, published: details["uploadedDate"]?.stringValue ?? details["uploadDate"]!.stringValue, views: details["views"]!.intValue, description: PipedAPI.extractDescription(from: content), channel: Channel(id: channelId, name: author), thumbnails: thumbnails, likes: details["likes"]?.int, dislikes: details["dislikes"]?.int, streams: extractStreams(from: content) ) } private static func extractID(from content: JSON) -> Video.ID { content.dictionaryValue["url"]?.stringValue.components(separatedBy: "?v=").last ?? extractThumbnailURL(from: content)!.relativeString.components(separatedBy: "/")[4] } private static func extractThumbnailURL(from content: JSON) -> URL? { content.dictionaryValue["thumbnail"]?.url! ?? content.dictionaryValue["thumbnailUrl"]!.url! } private static func buildThumbnailURL(from content: JSON, quality: Thumbnail.Quality) -> URL? { let thumbnailURL = extractThumbnailURL(from: content) guard !thumbnailURL.isNil else { return nil } return URL(string: thumbnailURL! .absoluteString .replacingOccurrences(of: "hqdefault", with: quality.filename) .replacingOccurrences(of: "maxresdefault", with: quality.filename) )! } private static func extractDescription(from content: JSON) -> String? { guard var description = content.dictionaryValue["description"]?.string else { return nil } description = description.replacingOccurrences( of: "
|
|
", with: "\n", options: .regularExpression, range: nil ) description = description.replacingOccurrences( of: "<[^>]+>", with: "", options: .regularExpression, range: nil ) return description } private static func extractVideos(from content: JSON) -> [Video] { content.arrayValue.compactMap(extractVideo(from:)) } private static func extractStreams(from content: JSON) -> [Stream] { var streams = [Stream]() if let hlsURL = content.dictionaryValue["hls"]?.url { streams.append(Stream(hlsURL: hlsURL)) } guard let audioStream = PipedAPI.compatibleAudioStreams(from: content).first else { return streams } let videoStreams = PipedAPI.compatibleVideoStream(from: content) videoStreams.forEach { videoStream in let audioAsset = AVURLAsset(url: audioStream.dictionaryValue["url"]!.url!) let videoAsset = AVURLAsset(url: videoStream.dictionaryValue["url"]!.url!) let videoOnly = videoStream.dictionaryValue["videoOnly"]?.boolValue ?? true let resolution = Stream.Resolution.from(resolution: videoStream.dictionaryValue["quality"]!.stringValue) if videoOnly { streams.append( Stream(audioAsset: audioAsset, videoAsset: videoAsset, resolution: resolution, kind: .adaptive) ) } else { streams.append( SingleAssetStream(avAsset: videoAsset, resolution: resolution, kind: .stream) ) } } return streams } private static func compatibleAudioStreams(from content: JSON) -> [JSON] { content .dictionaryValue["audioStreams"]? .arrayValue .filter { $0.dictionaryValue["format"]?.stringValue == "M4A" } .sorted { $0.dictionaryValue["bitrate"]?.intValue ?? 0 > $1.dictionaryValue["bitrate"]?.intValue ?? 0 } ?? [] } private static func compatibleVideoStream(from content: JSON) -> [JSON] { content .dictionaryValue["videoStreams"]? .arrayValue .filter { $0.dictionaryValue["format"] == "MPEG_4" } ?? [] } }