diff --git a/Model/Applications/InvidiousAPI.swift b/Model/Applications/InvidiousAPI.swift index 5659169c..17710806 100644 --- a/Model/Applications/InvidiousAPI.swift +++ b/Model/Applications/InvidiousAPI.swift @@ -27,10 +27,15 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { self.account = account signedIn = false + if account.anonymous { + validInstance = true + return + } + validInstance = false - signedIn = !account.anonymous configure() + validate() } func validate() { @@ -81,11 +86,11 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { } configureTransformer(pathPattern("popular"), requestMethods: [.get]) { (content: Entity) -> [Video] in - content.json.arrayValue.map(InvidiousAPI.extractVideo) + content.json.arrayValue.map(self.extractVideo) } configureTransformer(pathPattern("trending"), requestMethods: [.get]) { (content: Entity) -> [Video] in - content.json.arrayValue.map(InvidiousAPI.extractVideo) + content.json.arrayValue.map(self.extractVideo) } configureTransformer(pathPattern("search"), requestMethods: [.get]) { (content: Entity) -> [ContentItem] in @@ -93,11 +98,11 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { let type = $0.dictionaryValue["type"]?.stringValue if type == "channel" { - return ContentItem(channel: InvidiousAPI.extractChannel(from: $0)) + return ContentItem(channel: self.extractChannel(from: $0)) } else if type == "playlist" { - return ContentItem(playlist: InvidiousAPI.extractChannelPlaylist(from: $0)) + return ContentItem(playlist: self.extractChannelPlaylist(from: $0)) } - return ContentItem(video: InvidiousAPI.extractVideo(from: $0)) + return ContentItem(video: self.extractVideo(from: $0)) } } @@ -110,11 +115,11 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { } configureTransformer(pathPattern("auth/playlists"), requestMethods: [.get]) { (content: Entity) -> [Playlist] in - content.json.arrayValue.map(Playlist.init) + content.json.arrayValue.map(self.extractPlaylist) } configureTransformer(pathPattern("auth/playlists/*"), requestMethods: [.get]) { (content: Entity) -> Playlist in - Playlist(content.json) + self.extractPlaylist(from: content.json) } configureTransformer(pathPattern("auth/playlists"), requestMethods: [.post, .patch]) { (content: Entity) -> Playlist in @@ -124,30 +129,30 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { configureTransformer(pathPattern("auth/feed"), requestMethods: [.get]) { (content: Entity) -> [Video] in if let feedVideos = content.json.dictionaryValue["videos"] { - return feedVideos.arrayValue.map(InvidiousAPI.extractVideo) + return feedVideos.arrayValue.map(self.extractVideo) } return [] } configureTransformer(pathPattern("auth/subscriptions"), requestMethods: [.get]) { (content: Entity) -> [Channel] in - content.json.arrayValue.map(InvidiousAPI.extractChannel) + content.json.arrayValue.map(self.extractChannel) } configureTransformer(pathPattern("channels/*"), requestMethods: [.get]) { (content: Entity) -> Channel in - InvidiousAPI.extractChannel(from: content.json) + self.extractChannel(from: content.json) } configureTransformer(pathPattern("channels/*/latest"), requestMethods: [.get]) { (content: Entity) -> [Video] in - content.json.arrayValue.map(InvidiousAPI.extractVideo) + content.json.arrayValue.map(self.extractVideo) } configureTransformer(pathPattern("playlists/*"), requestMethods: [.get]) { (content: Entity) -> ChannelPlaylist in - InvidiousAPI.extractChannelPlaylist(from: content.json) + self.extractChannelPlaylist(from: content.json) } configureTransformer(pathPattern("videos/*"), requestMethods: [.get]) { (content: Entity) -> Video in - InvidiousAPI.extractVideo(from: content.json) + self.extractVideo(from: content.json) } } @@ -292,7 +297,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { return AVURLAsset(url: url) } - static func extractVideo(from json: JSON) -> Video { + func extractVideo(from json: JSON) -> Video { let indexID: String? var id: Video.ID var publishedAt: Date? @@ -335,8 +340,15 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { ) } - static func extractChannel(from json: JSON) -> Channel { - let thumbnailURL = "https:\(json["authorThumbnails"].arrayValue.first?.dictionaryValue["url"]?.stringValue ?? "")" + func extractChannel(from json: JSON) -> Channel { + var thumbnailURL = json["authorThumbnails"].arrayValue.last?.dictionaryValue["url"]?.stringValue ?? "" + + // append https protocol to unproxied thumbnail URL if it's missing + if thumbnailURL.count > 2, + String(thumbnailURL[.. ChannelPlaylist { + func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist { let details = json.dictionaryValue return ChannelPlaylist( id: details["playlistId"]!.stringValue, title: details["title"]!.stringValue, thumbnailURL: details["playlistThumbnail"]?.url, channel: extractChannel(from: json), - videos: details["videos"]?.arrayValue.compactMap(InvidiousAPI.extractVideo) ?? [] + videos: details["videos"]?.arrayValue.compactMap(extractVideo) ?? [] ) } - private static func extractThumbnails(from details: JSON) -> [Thumbnail] { + private func extractThumbnails(from details: JSON) -> [Thumbnail] { details["videoThumbnails"].arrayValue.map { json in Thumbnail(url: json["url"].url!, quality: .init(rawValue: json["quality"].string!)!) } } - private static func extractStreams(from json: JSON) -> [Stream] { + private func extractStreams(from json: JSON) -> [Stream] { extractFormatStreams(from: json["formatStreams"].arrayValue) + extractAdaptiveFormats(from: json["adaptiveFormats"].arrayValue) } - private static func extractFormatStreams(from streams: [JSON]) -> [Stream] { + private func extractFormatStreams(from streams: [JSON]) -> [Stream] { streams.map { SingleAssetStream( avAsset: AVURLAsset(url: $0["url"].url!), @@ -381,7 +393,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { } } - private static func extractAdaptiveFormats(from streams: [JSON]) -> [Stream] { + private func extractAdaptiveFormats(from streams: [JSON]) -> [Stream] { let audioAssetURL = streams.first { $0["type"].stringValue.starts(with: "audio/mp4") } guard audioAssetURL != nil else { return [] @@ -400,10 +412,20 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { } } - private static func extractRelated(from content: JSON) -> [Video] { + private func extractRelated(from content: JSON) -> [Video] { content .dictionaryValue["recommendedVideos"]? .arrayValue .compactMap(extractVideo(from:)) ?? [] } + + private func extractPlaylist(from content: JSON) -> Playlist { + .init( + id: content["playlistId"].stringValue, + title: content["title"].stringValue, + visibility: content["isListed"].boolValue ? .public : .private, + updated: content["updated"].doubleValue, + videos: content["videos"].arrayValue.map { extractVideo(from: $0) } + ) + } } diff --git a/Model/Applications/PipedAPI.swift b/Model/Applications/PipedAPI.swift index d834a260..1b05600e 100644 --- a/Model/Applications/PipedAPI.swift +++ b/Model/Applications/PipedAPI.swift @@ -40,23 +40,23 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { } configureTransformer(pathPattern("channel/*")) { (content: Entity) -> Channel? in - PipedAPI.extractChannel(from: content.json) + self.extractChannel(from: content.json) } configureTransformer(pathPattern("playlists/*")) { (content: Entity) -> ChannelPlaylist? in - PipedAPI.extractChannelPlaylist(from: content.json) + self.extractChannelPlaylist(from: content.json) } configureTransformer(pathPattern("streams/*")) { (content: Entity) -> Video? in - PipedAPI.extractVideo(from: content.json) + self.extractVideo(from: content.json) } configureTransformer(pathPattern("trending")) { (content: Entity) -> [Video] in - PipedAPI.extractVideos(from: content.json) + self.extractVideos(from: content.json) } configureTransformer(pathPattern("search")) { (content: Entity) -> [ContentItem] in - PipedAPI.extractContentItems(from: content.json.dictionaryValue["items"]!) + self.extractContentItems(from: content.json.dictionaryValue["items"]!) } configureTransformer(pathPattern("suggestions")) { (content: Entity) -> [String] in @@ -64,16 +64,16 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { } configureTransformer(pathPattern("subscriptions")) { (content: Entity) -> [Channel] in - content.json.arrayValue.map { PipedAPI.extractChannel(from: $0)! } + content.json.arrayValue.map { self.extractChannel(from: $0)! } } configureTransformer(pathPattern("feed")) { (content: Entity) -> [Video] in - content.json.arrayValue.map { PipedAPI.extractVideo(from: $0)! } + content.json.arrayValue.map { self.extractVideo(from: $0)! } } configureTransformer(pathPattern("comments/*")) { (content: Entity) -> CommentsPage in let details = content.json.dictionaryValue - let comments = details["comments"]?.arrayValue.map { PipedAPI.extractComment(from: $0)! } ?? [] + let comments = details["comments"]?.arrayValue.map { self.extractComment(from: $0)! } ?? [] let nextPage = details["nextpage"]?.stringValue let disabled = details["disabled"]?.boolValue ?? false @@ -86,7 +86,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { } func needsAuthorization(_ url: URL) -> Bool { - PipedAPI.authorizedEndpoints.contains { url.absoluteString.contains($0) } + Self.authorizedEndpoints.contains { url.absoluteString.contains($0) } } func updateToken() { @@ -190,7 +190,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { "**\(path)" } - private static func extractContentItem(from content: JSON) -> ContentItem? { + private func extractContentItem(from content: JSON) -> ContentItem? { let details = content.dictionaryValue let url: String! = details["url"]?.string @@ -210,17 +210,17 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { switch contentType { case .video: - if let video = PipedAPI.extractVideo(from: content) { + if let video = extractVideo(from: content) { return ContentItem(video: video) } case .playlist: - if let playlist = PipedAPI.extractChannelPlaylist(from: content) { + if let playlist = extractChannelPlaylist(from: content) { return ContentItem(playlist: playlist) } case .channel: - if let channel = PipedAPI.extractChannel(from: content) { + if let channel = extractChannel(from: content) { return ContentItem(channel: channel) } } @@ -228,11 +228,11 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { return nil } - private static func extractContentItems(from content: JSON) -> [ContentItem] { - content.arrayValue.compactMap { PipedAPI.extractContentItem(from: $0) } + private func extractContentItems(from content: JSON) -> [ContentItem] { + content.arrayValue.compactMap { extractContentItem(from: $0) } } - private static func extractChannel(from content: JSON) -> Channel? { + private func extractChannel(from content: JSON) -> Channel? { let attributes = content.dictionaryValue guard let id = attributes["id"]?.stringValue ?? (attributes["url"] ?? attributes["uploaderUrl"])?.stringValue.components(separatedBy: "/").last @@ -244,25 +244,28 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { var videos = [Video]() if let relatedStreams = attributes["relatedStreams"] { - videos = PipedAPI.extractVideos(from: relatedStreams) + videos = extractVideos(from: relatedStreams) } + let name = attributes["name"]?.stringValue ?? attributes["uploaderName"]?.stringValue ?? attributes["uploader"]?.stringValue ?? "" + let thumbnailURL = attributes["avatarUrl"]?.url ?? attributes["uploaderAvatar"]?.url ?? attributes["avatar"]?.url ?? attributes["thumbnail"]?.url + return Channel( id: id, - name: attributes["name"]!.stringValue, - thumbnailURL: attributes["thumbnail"]?.url, + name: name, + thumbnailURL: thumbnailURL, subscriptionsCount: subscriptionsCount, videos: videos ) } - static func extractChannelPlaylist(from json: JSON) -> ChannelPlaylist? { + 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) + videos = extractVideos(from: relatedStreams) } return ChannelPlaylist( id: id, @@ -274,7 +277,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { ) } - private static func extractVideo(from content: JSON) -> Video? { + private func extractVideo(from content: JSON) -> Video? { let details = content.dictionaryValue let url = details["url"]?.string @@ -287,7 +290,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { let channelId = details["uploaderUrl"]!.stringValue.components(separatedBy: "/").last! let thumbnails: [Thumbnail] = Thumbnail.Quality.allCases.compactMap { - if let url = PipedAPI.buildThumbnailURL(from: content, quality: $0) { + if let url = buildThumbnailURL(from: content, quality: $0) { return Thumbnail(url: url, quality: $0) } @@ -295,18 +298,20 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { } let author = details["uploaderName"]?.stringValue ?? details["uploader"]!.stringValue + let authorThumbnailURL = details["avatarUrl"]?.url ?? details["uploaderAvatar"]?.url ?? details["avatar"]?.url + let published = (details["uploadedDate"] ?? details["uploadDate"])?.stringValue ?? (details["uploaded"]!.double! / 1000).formattedAsRelativeTime()! return Video( - videoID: PipedAPI.extractID(from: content), + videoID: extractID(from: content), title: details["title"]!.stringValue, author: author, length: details["duration"]!.doubleValue, published: published, views: details["views"]!.intValue, - description: PipedAPI.extractDescription(from: content), - channel: Channel(id: channelId, name: author), + description: extractDescription(from: content), + channel: Channel(id: channelId, name: author, thumbnailURL: authorThumbnailURL), thumbnails: thumbnails, likes: details["likes"]?.int, dislikes: details["dislikes"]?.int, @@ -315,16 +320,16 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { ) } - private static func extractID(from content: JSON) -> Video.ID { + private 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? { + private 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? { + private func buildThumbnailURL(from content: JSON, quality: Thumbnail.Quality) -> URL? { let thumbnailURL = extractThumbnailURL(from: content) guard !thumbnailURL.isNil else { return nil @@ -337,7 +342,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { )! } - private static func extractDescription(from content: JSON) -> String? { + private func extractDescription(from content: JSON) -> String? { guard var description = content.dictionaryValue["description"]?.string else { return nil } @@ -359,22 +364,22 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { return description } - private static func extractVideos(from content: JSON) -> [Video] { + private func extractVideos(from content: JSON) -> [Video] { content.arrayValue.compactMap(extractVideo(from:)) } - private static func extractStreams(from content: JSON) -> [Stream] { + private 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 { + guard let audioStream = compatibleAudioStreams(from: content).first else { return streams } - let videoStreams = PipedAPI.compatibleVideoStream(from: content) + let videoStreams = compatibleVideoStream(from: content) videoStreams.forEach { videoStream in let audioAsset = AVURLAsset(url: audioStream.dictionaryValue["url"]!.url!) @@ -397,14 +402,14 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { return streams } - private static func extractRelated(from content: JSON) -> [Video] { + private func extractRelated(from content: JSON) -> [Video] { content .dictionaryValue["relatedStreams"]? .arrayValue .compactMap(extractVideo(from:)) ?? [] } - private static func compatibleAudioStreams(from content: JSON) -> [JSON] { + private func compatibleAudioStreams(from content: JSON) -> [JSON] { content .dictionaryValue["audioStreams"]? .arrayValue @@ -414,14 +419,14 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { } ?? [] } - private static func compatibleVideoStream(from content: JSON) -> [JSON] { + private func compatibleVideoStream(from content: JSON) -> [JSON] { content .dictionaryValue["videoStreams"]? .arrayValue .filter { $0.dictionaryValue["format"] == "MPEG_4" } ?? [] } - private static func extractComment(from content: JSON) -> Comment? { + private func extractComment(from content: JSON) -> Comment? { let details = content.dictionaryValue let author = details["author"]?.stringValue ?? "" let commentorUrl = details["commentorUrl"]?.stringValue diff --git a/Model/Playlist.swift b/Model/Playlist.swift index 8f57ec09..590edd7a 100644 --- a/Model/Playlist.swift +++ b/Model/Playlist.swift @@ -22,11 +22,12 @@ struct Playlist: Identifiable, Equatable, Hashable { var videos = [Video]() - init(id: String, title: String, visibility: Visibility, updated: TimeInterval) { + init(id: String, title: String, visibility: Visibility, updated: TimeInterval, videos: [Video] = []) { self.id = id self.title = title self.visibility = visibility self.updated = updated + self.videos = videos } init(_ json: JSON) { @@ -34,7 +35,6 @@ struct Playlist: Identifiable, Equatable, Hashable { title = json["title"].stringValue visibility = json["isListed"].boolValue ? .public : .private updated = json["updated"].doubleValue - videos = json["videos"].arrayValue.map { InvidiousAPI.extractVideo(from: $0) } } static func == (lhs: Playlist, rhs: Playlist) -> Bool {