From faf2469e04e5d1435cef43175d3cfd9642f750d1 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Fri, 9 Dec 2022 01:15:19 +0100 Subject: [PATCH] Initial PeerTube Support --- Fixtures/Instance+Fixtures.swift | 2 +- Fixtures/Video+Fixtures.swift | 1 + Fixtures/View+Fixtures.swift | 1 + Model/Accounts/Account.swift | 14 +- Model/Accounts/AccountValidator.swift | 11 + Model/Accounts/AccountsBridge.swift | 4 +- Model/Accounts/AccountsModel.swift | 15 +- Model/Accounts/Instance.swift | 28 +- Model/Accounts/InstancesBridge.swift | 4 +- Model/Accounts/InstancesModel.swift | 2 +- Model/Applications/InvidiousAPI.swift | 18 +- Model/Applications/PeerTubeAPI.swift | 592 ++++++++++++++++++ Model/Applications/PipedAPI.swift | 7 + Model/Applications/VideosAPI.swift | 2 + Model/Applications/VideosApp.swift | 15 +- Model/CommentsModel.swift | 2 +- Model/HistoryModel.swift | 28 +- Model/InstancesManifest.swift | 4 +- Model/ManifestedInstance.swift | 4 +- Model/OpenVideosModel.swift | 2 +- Model/Player/Backends/MPVClient.swift | 10 +- Model/Player/PlayerModel.swift | 6 +- Model/Player/PlayerQueue.swift | 24 +- Model/Player/PlayerStreams.swift | 2 +- Model/Search/SearchModel.swift | 8 + Model/SingleAssetStream.swift | 4 +- Model/Video.swift | 40 +- Model/Watch.swift | 14 +- .../Yattee.xcdatamodel/contents | 7 +- Shared/Home/HistoryView.swift | 1 - Shared/Navigation/AppSidebarNavigation.swift | 2 +- Shared/OpenURLHandler.swift | 4 +- Shared/Search/SearchView.swift | 7 +- Shared/Settings/AccountForm.swift | 4 +- Shared/Settings/LocationsSettings.swift | 2 +- Shared/Videos/VideoCell.swift | 2 +- Shared/Views/ShareButton.swift | 5 +- Shared/Views/VideoContextMenuView.swift | 2 +- Yattee.xcodeproj/project.pbxproj | 8 + 39 files changed, 816 insertions(+), 92 deletions(-) create mode 100644 Model/Applications/PeerTubeAPI.swift diff --git a/Fixtures/Instance+Fixtures.swift b/Fixtures/Instance+Fixtures.swift index eb088b2a..d1c0b89e 100644 --- a/Fixtures/Instance+Fixtures.swift +++ b/Fixtures/Instance+Fixtures.swift @@ -2,6 +2,6 @@ import Foundation extension Instance { static var fixture: Instance { - Instance(app: .invidious, name: "Home", apiURL: "https://invidious.home.net") + Instance(app: .invidious, name: "Home", apiURLString: "https://invidious.home.net") } } diff --git a/Fixtures/Video+Fixtures.swift b/Fixtures/Video+Fixtures.swift index 27030523..318b5d8a 100644 --- a/Fixtures/Video+Fixtures.swift +++ b/Fixtures/Video+Fixtures.swift @@ -10,6 +10,7 @@ extension Video { let chapterImageURL = URL(string: "https://pipedproxy.kavin.rocks/vi/rr2XfL_df3o/hqdefault_29633.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg%3D%3D&rs=AOn4CLDFDm9D5SvsIA7D3v5n5KZahLs_UA&host=i.ytimg.com")! return Video( + app: .invidious, videoID: fixtureID, title: "Relaxing Piano Music to feel good", author: "Fancy Videotuber", diff --git a/Fixtures/View+Fixtures.swift b/Fixtures/View+Fixtures.swift index 81d0837d..1e4b4c48 100644 --- a/Fixtures/View+Fixtures.swift +++ b/Fixtures/View+Fixtures.swift @@ -33,6 +33,7 @@ struct FixtureEnvironmentObjectsModifier: ViewModifier { player.currentItem = PlayerQueueItem( Video( + app: .invidious, videoID: "https://a/b/c", title: "Video Name", author: "", diff --git a/Model/Accounts/Account.swift b/Model/Accounts/Account.swift index 1c1156ef..0bcf2d41 100644 --- a/Model/Accounts/Account.swift +++ b/Model/Accounts/Account.swift @@ -8,7 +8,7 @@ struct Account: Defaults.Serializable, Hashable, Identifiable { var app: VideosApp? let instanceID: String? var name: String? - let url: String + let urlString: String var username: String var password: String? let anonymous: Bool @@ -20,7 +20,7 @@ struct Account: Defaults.Serializable, Hashable, Identifiable { app: VideosApp? = nil, instanceID: String? = nil, name: String? = nil, - url: String? = nil, + urlString: String? = nil, username: String? = nil, password: String? = nil, anonymous: Bool = false, @@ -29,10 +29,10 @@ struct Account: Defaults.Serializable, Hashable, Identifiable { ) { self.anonymous = anonymous - self.id = id ?? (anonymous ? "anonymous-\(instanceID ?? url ?? UUID().uuidString)" : UUID().uuidString) + self.id = id ?? (anonymous ? "anonymous-\(instanceID ?? urlString ?? UUID().uuidString)" : UUID().uuidString) self.instanceID = instanceID self.name = name - self.url = url ?? "" + self.urlString = urlString ?? "" self.username = username ?? "" self.password = password ?? "" self.country = country @@ -40,6 +40,10 @@ struct Account: Defaults.Serializable, Hashable, Identifiable { self.app = app ?? instance.app } + var url: URL! { + URL(string: urlString) + } + var token: String? { KeychainModel.shared.getAccountKey(self, "token") } @@ -49,7 +53,7 @@ struct Account: Defaults.Serializable, Hashable, Identifiable { } var instance: Instance! { - Defaults[.instances].first { $0.id == instanceID } ?? Instance(app: app ?? .invidious, name: url, apiURL: url) + Defaults[.instances].first { $0.id == instanceID } ?? Instance(app: app ?? .invidious, name: urlString, apiURLString: urlString) } var isPublic: Bool { diff --git a/Model/Accounts/AccountValidator.swift b/Model/Accounts/AccountValidator.swift index 7792abc6..b696596b 100644 --- a/Model/Accounts/AccountValidator.swift +++ b/Model/Accounts/AccountValidator.swift @@ -56,12 +56,23 @@ final class AccountValidator: Service { case .piped: return resource("/streams/dQw4w9WgXcQ") + + case .peerTube: + // TODO: fixme + return resource("") + + case .local: + return resource("") } } func validateInstance() { reset() + app.wrappedValue = .peerTube + setValidationResult(true) + return + guard let app = appsToValidateInstance.popLast() else { return } tryValidatingUsing(app) } diff --git a/Model/Accounts/AccountsBridge.swift b/Model/Accounts/AccountsBridge.swift index f2d84414..36ffe850 100644 --- a/Model/Accounts/AccountsBridge.swift +++ b/Model/Accounts/AccountsBridge.swift @@ -14,7 +14,7 @@ struct AccountsBridge: Defaults.Bridge { "id": value.id, "instanceID": value.instanceID ?? "", "name": value.name ?? "", - "apiURL": value.url, + "apiURL": value.urlString, "username": value.username, "password": value.password ?? "" ] @@ -34,6 +34,6 @@ struct AccountsBridge: Defaults.Bridge { let name = object["name"] ?? "" let password = object["password"] - return Account(id: id, instanceID: instanceID, name: name, url: url, username: username, password: password) + return Account(id: id, instanceID: instanceID, name: name, urlString: url, username: username, password: password) } } diff --git a/Model/Accounts/AccountsModel.swift b/Model/Accounts/AccountsModel.swift index 55101765..d29ec348 100644 --- a/Model/Accounts/AccountsModel.swift +++ b/Model/Accounts/AccountsModel.swift @@ -9,6 +9,7 @@ final class AccountsModel: ObservableObject { @Published private var invidious = InvidiousAPI() @Published private var piped = PipedAPI() + @Published private var peerTube = PeerTubeAPI() @Published var publicAccount: Account? @@ -31,15 +32,19 @@ final class AccountsModel: ObservableObject { } var app: VideosApp { - current?.instance?.app ?? .invidious + current?.instance?.app ?? .local } - var api: VideosAPI { + var api: VideosAPI! { switch app { case .piped: return piped case .invidious: return invidious + case .peerTube: + return peerTube + default: + return nil } } @@ -83,10 +88,14 @@ final class AccountsModel: ObservableObject { } switch account.instance.app { + case .local: + return case .invidious: invidious.setAccount(account) case .piped: piped.setAccount(account) + case .peerTube: + peerTube.setAccount(account) } Defaults[.lastAccountIsPublic] = account.isPublic @@ -102,7 +111,7 @@ final class AccountsModel: ObservableObject { } static func add(instance: Instance, name: String, username: String, password: String) -> Account { - let account = Account(instanceID: instance.id, name: name, url: instance.apiURL) + let account = Account(instanceID: instance.id, name: name, urlString: instance.apiURLString) Defaults[.accounts].append(account) setCredentials(account, username: username, password: password) diff --git a/Model/Accounts/Instance.swift b/Model/Accounts/Instance.swift index 4275f134..e587718b 100644 --- a/Model/Accounts/Instance.swift +++ b/Model/Accounts/Instance.swift @@ -7,25 +7,33 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable { let app: VideosApp let id: String let name: String - let apiURL: String + let apiURLString: String var frontendURL: String? var proxiesVideos: Bool - init(app: VideosApp, id: String? = nil, name: String, apiURL: String, frontendURL: String? = nil, proxiesVideos: Bool = false) { + init(app: VideosApp, id: String? = nil, name: String? = nil, apiURLString: String, frontendURL: String? = nil, proxiesVideos: Bool = false) { self.app = app self.id = id ?? UUID().uuidString - self.name = name - self.apiURL = apiURL + self.name = name ?? app.rawValue + self.apiURLString = apiURLString self.frontendURL = frontendURL self.proxiesVideos = proxiesVideos } - var anonymous: VideosAPI { + var apiURL: URL! { + URL(string: apiURLString) + } + + var anonymous: VideosAPI! { switch app { case .invidious: return InvidiousAPI(account: anonymousAccount) case .piped: return PipedAPI(account: anonymousAccount) + case .peerTube: + return PeerTubeAPI(account: anonymousAccount) + case .local: + return nil } } @@ -34,23 +42,23 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable { } var longDescription: String { - name.isEmpty ? "\(app.name) - \(apiURL)" : "\(app.name) - \(name) (\(apiURL))" + name.isEmpty ? "\(app.name) - \(apiURLString)" : "\(app.name) - \(name) (\(apiURL))" } var shortDescription: String { - name.isEmpty ? apiURL : name + name.isEmpty ? apiURLString : name } var anonymousAccount: Account { - Account(instanceID: id, name: "Anonymous".localized(), url: apiURL, anonymous: true) + Account(instanceID: id, name: "Anonymous".localized(), urlString: apiURLString, anonymous: true) } var urlComponents: URLComponents { - URLComponents(string: apiURL)! + URLComponents(url: apiURL, resolvingAgainstBaseURL: false)! } var frontendHost: String? { - guard let url = app == .invidious ? apiURL : frontendURL else { + guard let url = app == .invidious ? apiURLString : frontendURL else { return nil } diff --git a/Model/Accounts/InstancesBridge.swift b/Model/Accounts/InstancesBridge.swift index ba4d77c3..0e49a019 100644 --- a/Model/Accounts/InstancesBridge.swift +++ b/Model/Accounts/InstancesBridge.swift @@ -14,7 +14,7 @@ struct InstancesBridge: Defaults.Bridge { "app": value.app.rawValue, "id": value.id, "name": value.name, - "apiURL": value.apiURL, + "apiURL": value.apiURLString, "frontendURL": value.frontendURL ?? "", "proxiesVideos": value.proxiesVideos ? "true" : "false" ] @@ -34,6 +34,6 @@ struct InstancesBridge: Defaults.Bridge { let frontendURL: String? = object["frontendURL"]!.isEmpty ? nil : object["frontendURL"] let proxiesVideos = object["proxiesVideos"] == "true" - return Instance(app: app, id: id, name: name, apiURL: apiURL, frontendURL: frontendURL, proxiesVideos: proxiesVideos) + return Instance(app: app, id: id, name: name, apiURLString: apiURL, frontendURL: frontendURL, proxiesVideos: proxiesVideos) } } diff --git a/Model/Accounts/InstancesModel.swift b/Model/Accounts/InstancesModel.swift index 542a43b0..4b70360c 100644 --- a/Model/Accounts/InstancesModel.swift +++ b/Model/Accounts/InstancesModel.swift @@ -38,7 +38,7 @@ final class InstancesModel: ObservableObject { func add(app: VideosApp, name: String, url: String) -> Instance { let instance = Instance( - app: app, id: UUID().uuidString, name: name, apiURL: standardizedURL(url) + app: app, id: UUID().uuidString, name: name, apiURLString: standardizedURL(url) ) Defaults[.instances].append(instance) diff --git a/Model/Applications/InvidiousAPI.swift b/Model/Applications/InvidiousAPI.swift index c30e311c..cce44dbb 100644 --- a/Model/Applications/InvidiousAPI.swift +++ b/Model/Applications/InvidiousAPI.swift @@ -9,9 +9,12 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { static let basePath = "/api/v1" @Published var account: Account! - @Published var validInstance = true + static func withAnonymousAccountForInstanceURL(_ url: URL) -> InvidiousAPI { + .init(account: Instance(app: .invidious, apiURLString: url.absoluteString).anonymousAccount) + } + var signedIn: Bool { guard let account else { return false } @@ -452,7 +455,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { } static func proxiedAsset(instance: Instance, asset: AVURLAsset) -> AVURLAsset? { - guard let instanceURLComponents = URLComponents(string: instance.apiURL), + guard let instanceURLComponents = URLComponents(url: instance.apiURL, resolvingAgainstBaseURL: false), var urlComponents = URLComponents(url: asset.url, resolvingAgainstBaseURL: false) else { return nil } urlComponents.scheme = instanceURLComponents.scheme @@ -487,7 +490,9 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { let description = json["description"].stringValue return Video( - id: id, + instanceID: account.instanceID, + app: .invidious, + instanceURL: account.instance.apiURL, videoID: videoID, title: json["title"].stringValue, author: json["author"].stringValue, @@ -518,7 +523,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { // append protocol to unproxied thumbnail URL if it's missing if thumbnailURL.count > 2, String(thumbnailURL[.. [Captions] { content["captions"].arrayValue.compactMap { details in - let baseURL = account.url - guard let url = URL(string: baseURL + details["url"].stringValue) else { return nil } + guard let url = URL(string: details["url"].stringValue, relativeTo: account.url) else { return nil } return Captions( label: details["label"].stringValue, diff --git a/Model/Applications/PeerTubeAPI.swift b/Model/Applications/PeerTubeAPI.swift new file mode 100644 index 00000000..0ef53327 --- /dev/null +++ b/Model/Applications/PeerTubeAPI.swift @@ -0,0 +1,592 @@ +import Alamofire +import AVKit +import Defaults +import Foundation +import Siesta +import SwiftyJSON + +final class PeerTubeAPI: Service, ObservableObject, VideosAPI { + static let basePath = "/api/v1" + + @Published var account: Account! + + @Published var validInstance = true + + var signedIn: Bool { + guard let account else { return false } + + return !account.anonymous && !(account.token?.isEmpty ?? true) + } + + static func withAnonymousAccountForInstanceURL(_ url: URL) -> PeerTubeAPI { + .init(account: Instance(app: .peerTube, apiURLString: url.absoluteString).anonymousAccount) + } + + 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 + + validInstance = account.anonymous + + configure() + + if !account.anonymous { + validate() + } + } + + func validate() { + validateInstance() + validateSID() + } + + func validateInstance() { + guard !validInstance else { + return + } + + home? + .load() + .onSuccess { _ in + self.validInstance = true + } + .onFailure { _ in + self.validInstance = false + } + } + + func validateSID() { + guard signedIn, !(account.token?.isEmpty ?? true) else { + return + } + + feed? + .load() + .onFailure { _ in + self.updateToken(force: true) + } + } + + 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("videos"), requestMethods: [.get]) { (content: Entity) -> [Video] in + content.json.dictionaryValue["data"]?.arrayValue.map(self.extractVideo) ?? [] + } + + configureTransformer(pathPattern("search/videos"), requestMethods: [.get]) { (content: Entity) -> SearchPage in + let results = content.json.dictionaryValue["data"]?.arrayValue.compactMap { json -> ContentItem in .init(video: self.extractVideo(from: json)) } ?? [] + 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(String.init) + } + + 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 + // hacky, to verify if possible to get it in easier way + Playlist(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) -> Channel in + self.extractChannel(from: content.json) + } + + configureTransformer(pathPattern("channels/*/latest"), requestMethods: [.get]) { (content: Entity) -> [Video] in + content.json.arrayValue.map(self.extractVideo) + } + + configureTransformer(pathPattern("channels/*/playlists"), requestMethods: [.get]) { (content: Entity) -> [ContentItem] in + let playlists = (content.json.dictionaryValue["playlists"]?.arrayValue ?? []).compactMap { self.extractChannelPlaylist(from: $0) } + return ContentItem.array(of: playlists) + } + + 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) + } + + updateToken() + } + + 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)/videos") + .withParam("isLocal", "true") +// .withParam("type", category?.name) +// .withParam("region", country.rawValue) + } + + var home: Resource? { + resource(baseURL: account.url, path: "/feed/subscriptions") + } + + var feed: Resource? { + resource(baseURL: account.url, path: "\(Self.basePath)/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?) -> Resource { + if contentType == .playlists { + return resource(baseURL: account.url, path: basePathAppending("channels/\(id)/playlists")) + } + return resource(baseURL: account.url, path: basePathAppending("channels/\(id)")) + } + + 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/videos")) + .withParam("search", 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) + } + + static func proxiedAsset(instance: Instance, asset: AVURLAsset) -> AVURLAsset? { + guard let instanceURLComponents = URLComponents(string: instance.apiURLString), + 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 id = json["uuid"].stringValue + let url = json["url"].url + let dateFormatter = ISO8601DateFormatter() + dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let publishedAt = dateFormatter.date(from: json["publishedAt"].stringValue) + + return Video( + instanceID: account.instanceID, + app: .peerTube, + instanceURL: account.instance.apiURL, + id: id, + videoID: id, + videoURL: url, + title: json["name"].stringValue, + author: json["channel"].dictionaryValue["name"]?.stringValue ?? "", + length: json["duration"].doubleValue, + views: json["views"].intValue, + description: json["description"].stringValue, + channel: extractChannel(from: json["channel"]), + thumbnails: extractThumbnails(from: json), + live: json["isLive"].boolValue, + publishedAt: publishedAt, + likes: json["likes"].int, + dislikes: json["dislikes"].int, + streams: extractStreams(from: json) +// related: extractRelated(from: json), +// chapters: extractChapters(from: description), +// captions: extractCaptions(from: json) + ) + } + + func extractChannel(from json: JSON) -> Channel { + Channel( + id: json["id"].stringValue, + name: json["name"].stringValue + ) + } + + 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] { + if let thumbnailPath = details["thumbnailPath"].string { + return [Thumbnail(url: URL(string: thumbnailPath, relativeTo: account.url)!, quality: .medium)] + } + return [] + } + + private func extractStreams(from json: JSON) -> [Stream] { + let hls = extractHLSStreams(from: json) + + if json["isLive"].boolValue { + return hls + } + + return extractFormatStreams(from: json) + + extractAdaptiveFormats(from: json) + + hls + } + + private func extractFormatStreams(from json: JSON) -> [Stream] { + var streams = [Stream]() + if let fileURL = json.dictionaryValue["streamingPlaylists"]?.arrayValue.first? + .dictionaryValue["files"]?.arrayValue.first? + .dictionaryValue["fileUrl"]?.url + { + streams.append(SingleAssetStream(instance: account.instance, avAsset: AVURLAsset(url: fileURL), resolution: .hd720p30, kind: .stream)) + } + + return streams + } + + private func extractAdaptiveFormats(from json: JSON) -> [Stream] { + json.dictionaryValue["files"]?.arrayValue.compactMap { file in + if let resolution = file.dictionaryValue["resolution"]?.dictionaryValue["label"]?.stringValue, let url = file.dictionaryValue["fileUrl"]?.url { + return SingleAssetStream(instance: account.instance, avAsset: AVURLAsset(url: url), resolution: Stream.Resolution.from(resolution: resolution), kind: .adaptive, videoFormat: "mp4") + } + + return nil + } ?? [] + } + + private func extractHLSStreams(from content: JSON) -> [Stream] { + if let hlsURL = content.dictionaryValue["streamingPlaylists"]?.arrayValue.first?.dictionaryValue["playlistUrl"]?.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 ?? "" + return Comment( + id: UUID().uuidString, + author: author, + authorAvatarURL: authorAvatarURL, + time: details["publishedText"]?.string ?? "", + pinned: false, + hearted: false, + likeCount: details["likeCount"]?.int ?? 0, + text: details["content"]?.string ?? "", + repliesPage: details["replies"]?.dictionaryValue["continuation"]?.string, + channel: Channel(id: channelId, name: author) + ) + } + + private func extractCaptions(from content: JSON) -> [Captions] { + content["captions"].arrayValue.compactMap { _ in + nil +// let baseURL = account.url +// guard let url = URL(string: baseURL + details["url"].stringValue) else { return nil } +// +// return Captions( +// label: details["label"].stringValue, +// code: details["language_code"].stringValue, +// url: url +// ) + } + } +} diff --git a/Model/Applications/PipedAPI.swift b/Model/Applications/PipedAPI.swift index 2a0223ed..404d63f8 100644 --- a/Model/Applications/PipedAPI.swift +++ b/Model/Applications/PipedAPI.swift @@ -9,6 +9,10 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { @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() @@ -473,6 +477,9 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { } return Video( + instanceID: account.instanceID, + app: .piped, + instanceURL: account.instance.apiURL, videoID: extractID(from: content), title: details["title"]?.string ?? "", author: author, diff --git a/Model/Applications/VideosAPI.swift b/Model/Applications/VideosAPI.swift index a3484386..817e4ffd 100644 --- a/Model/Applications/VideosAPI.swift +++ b/Model/Applications/VideosAPI.swift @@ -6,6 +6,8 @@ protocol VideosAPI { var account: Account! { get } var signedIn: Bool { get } + static func withAnonymousAccountForInstanceURL(_ url: URL) -> Self + func channel(_ id: String, contentType: Channel.ContentType, data: String?) -> Resource func channelByName(_ name: String) -> Resource? func channelByUsername(_ username: String) -> Resource? diff --git a/Model/Applications/VideosApp.swift b/Model/Applications/VideosApp.swift index 35cd9cdb..d98137d4 100644 --- a/Model/Applications/VideosApp.swift +++ b/Model/Applications/VideosApp.swift @@ -1,14 +1,17 @@ import Foundation enum VideosApp: String, CaseIterable { - case invidious, piped + case local + case invidious + case piped + case peerTube var name: String { rawValue.capitalized } var supportsAccounts: Bool { - true + self != .local } var supportsPopular: Bool { @@ -19,6 +22,10 @@ enum VideosApp: String, CaseIterable { self == .invidious } + var supportsSearchSuggestions: Bool { + self != .peerTube + } + var supportsSubscriptions: Bool { supportsAccounts } @@ -28,7 +35,7 @@ enum VideosApp: String, CaseIterable { } var supportsUserPlaylists: Bool { - true + self != .local } var userPlaylistsEndpointIncludesVideos: Bool { @@ -64,6 +71,6 @@ enum VideosApp: String, CaseIterable { } var supportsOpeningVideosByID: Bool { - true + self != .local } } diff --git a/Model/CommentsModel.swift b/Model/CommentsModel.swift index 39e62f77..bcd77bf8 100644 --- a/Model/CommentsModel.swift +++ b/Model/CommentsModel.swift @@ -42,7 +42,7 @@ final class CommentsModel: ObservableObject { firstPage = page.isNil || page!.isEmpty - player.playerAPI.comments(video.videoID, page: page)? + player.playerAPI(video).comments(video.videoID, page: page)? .load() .onSuccess { [weak self] response in if let page: CommentsPage = response.typedContent() { diff --git a/Model/HistoryModel.swift b/Model/HistoryModel.swift index eeb541c6..15d0a0e6 100644 --- a/Model/HistoryModel.swift +++ b/Model/HistoryModel.swift @@ -2,6 +2,7 @@ import CoreData import CoreMedia import Defaults import Foundation +import Siesta import SwiftyJSON extension PlayerModel { @@ -9,18 +10,18 @@ extension PlayerModel { historyVideos.first { $0.videoID == id } } - func loadHistoryVideoDetails(_ id: Video.ID) { - guard historyVideo(id).isNil else { + func loadHistoryVideoDetails(_ watch: Watch) { + logger.info("id: \(watch.videoID), instance \(watch.instanceURL), app \(watch.appName)") + guard historyVideo(watch.videoID).isNil else { return } - if !Video.VideoID.isValid(id), let url = URL(string: id) { + if !Video.VideoID.isValid(watch.videoID), let url = URL(string: watch.videoID) { historyVideos.append(.local(url)) return } - playerAPI.video(id) - .load() + playerAPI(watch.video).video(watch.videoID).load() .onSuccess { [weak self] response in guard let self else { return } @@ -29,26 +30,23 @@ extension PlayerModel { } } .onCompletion { _ in - self.logger.info("LOADED history details: \(id)") + self.logger.info("LOADED history details: \(watch.videoID)") - if self.historyItemBeingLoaded == id { + if self.historyItemBeingLoaded == watch.videoID { self.logger.info("setting no history loaded") self.historyItemBeingLoaded = nil } - if let id = self.historyItemsToLoad.popLast() { - self.loadHistoryVideoDetails(id) + if let watch = self.historyItemsToLoad.popLast() { + self.loadHistoryVideoDetails(watch) } } } func updateWatch(finished: Bool = false) { - guard let id = currentVideo?.videoID, - Defaults[.saveHistory] - else { - return - } + guard let currentVideo, saveHistory else { return } + let id = currentVideo.videoID let time = backend.currentTime let seconds = time?.seconds ?? 0 @@ -70,6 +68,8 @@ extension PlayerModel { } watch = Watch(context: self.backgroundContext) watch.videoID = id + watch.appName = currentVideo.app.rawValue + watch.instanceURL = currentVideo.instanceURL } else { watch = results?.first } diff --git a/Model/InstancesManifest.swift b/Model/InstancesManifest.swift index a1717816..0522c1ac 100644 --- a/Model/InstancesManifest.swift +++ b/Model/InstancesManifest.swift @@ -63,8 +63,8 @@ final class InstancesManifest: Service, ObservableObject { var regionInstances = instances.filter { $0.region == region } if let publicAccountUrl = AccountsModel.shared.publicAccount?.url { - countryInstances = countryInstances.filter { $0.url.absoluteString != publicAccountUrl } - regionInstances = regionInstances.filter { $0.url.absoluteString != publicAccountUrl } + countryInstances = countryInstances.filter { $0.url != publicAccountUrl } + regionInstances = regionInstances.filter { $0.url != publicAccountUrl } } var instance: ManifestedInstance? diff --git a/Model/ManifestedInstance.swift b/Model/ManifestedInstance.swift index 545e4685..76c3720b 100644 --- a/Model/ManifestedInstance.swift +++ b/Model/ManifestedInstance.swift @@ -9,7 +9,7 @@ struct ManifestedInstance: Identifiable, Hashable { let url: URL var instance: Instance { - .init(app: app, name: "Public - \(country)", apiURL: url.absoluteString) + .init(app: app, name: "Public - \(country)", apiURLString: url.absoluteString) } var location: String { @@ -21,7 +21,7 @@ struct ManifestedInstance: Identifiable, Hashable { id: UUID().uuidString, app: app, name: location, - url: url.absoluteString, + urlString: url.absoluteString, anonymous: true, country: country, region: region diff --git a/Model/OpenVideosModel.swift b/Model/OpenVideosModel.swift index edc473b2..4a6289bf 100644 --- a/Model/OpenVideosModel.swift +++ b/Model/OpenVideosModel.swift @@ -128,7 +128,7 @@ struct OpenVideosModel { let parser = URLParser(url: url) if parser.destination == .video, let id = parser.videoID { - video = Video(videoID: id) + video = Video(app: .local, videoID: id) logger.info("identified remote video: \(id)") } else { video = .local(url) diff --git a/Model/Player/Backends/MPVClient.swift b/Model/Player/Backends/MPVClient.swift index 1e750bc8..457d658b 100644 --- a/Model/Player/Backends/MPVClient.swift +++ b/Model/Player/Backends/MPVClient.swift @@ -65,6 +65,7 @@ final class MPVClient: ObservableObject { checkError(mpv_set_option_string(mpv, "keep-open", "yes")) checkError(mpv_set_option_string(mpv, "hwdec", machine == "x86_64" ? "no" : "auto-safe")) checkError(mpv_set_option_string(mpv, "vo", "libmpv")) + checkError(mpv_set_option_string(mpv, "demuxer-lavf-analyzeduration", "1")) checkError(mpv_initialize(mpv)) @@ -134,8 +135,9 @@ final class MPVClient: ObservableObject { var args = [url.absoluteString] var options = [String]() - if let time { - args.append("replace") + args.append("replace") + + if let time, time.seconds > 0 { options.append("start=\(Int(time.seconds))") } @@ -148,9 +150,11 @@ final class MPVClient: ObservableObject { } if forceSeekable { - options.append("force-seekable=yes") +// options.append("stream-lavf-o=seekable=0") } + options.append("stream-lavf-o=seekable=0") + if !options.isEmpty { args.append(options.joined(separator: ",")) } diff --git a/Model/Player/PlayerModel.swift b/Model/Player/PlayerModel.swift index a03b28a0..f4b81322 100644 --- a/Model/Player/PlayerModel.swift +++ b/Model/Player/PlayerModel.swift @@ -99,7 +99,7 @@ final class PlayerModel: ObservableObject { @Published var queueItemBeingLoaded: PlayerQueueItem? @Published var queueItemsToLoad = [PlayerQueueItem]() @Published var historyItemBeingLoaded: Video.ID? - @Published var historyItemsToLoad = [Video.ID]() + @Published var historyItemsToLoad = [Watch]() @Published var preservedTime: CMTime? @@ -125,6 +125,7 @@ final class PlayerModel: ObservableObject { @Default(.rotateToPortraitOnExitFullScreen) private var rotateToPortraitOnExitFullScreen #endif + var accounts: AccountsModel { .shared } var comments: CommentsModel { .shared } var controls: PlayerControlsModel { .shared } var playerTime: PlayerTimeModel { .shared } @@ -152,6 +153,7 @@ final class PlayerModel: ObservableObject { } }} + @Default(.saveHistory) var saveHistory @Default(.saveLastPlayed) var saveLastPlayed @Default(.lastPlayed) var lastPlayed @Default(.qualityProfiles) var qualityProfiles @@ -709,7 +711,7 @@ final class PlayerModel: ObservableObject { DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in guard let self else { return } - self.playerAPI.loadDetails(item, completionHandler: { newItem in + self.playerAPI(item.video).loadDetails(item, completionHandler: { newItem in guard newItem.videoID == self.autoplayItem?.videoID else { return } self.autoplayItem = newItem self.updateRemoteCommandCenter() diff --git a/Model/Player/PlayerQueue.swift b/Model/Player/PlayerQueue.swift index 3ccde343..60dd5d19 100644 --- a/Model/Player/PlayerQueue.swift +++ b/Model/Player/PlayerQueue.swift @@ -83,11 +83,21 @@ extension PlayerModel { } var playerInstance: Instance? { - InstancesModel.shared.forPlayer ?? AccountsModel.shared.current?.instance ?? InstancesModel.shared.all.first + InstancesModel.shared.forPlayer ?? accounts.current?.instance ?? InstancesModel.shared.all.first } - var playerAPI: VideosAPI { - playerInstance?.anonymous ?? AccountsModel.shared.api + func playerAPI(_ video: Video) -> VideosAPI! { + guard let url = video.instanceURL else { return nil } + switch video.app { + case .local: + return nil + case .peerTube: + return PeerTubeAPI.withAnonymousAccountForInstanceURL(url) + case .invidious: + return InvidiousAPI.withAnonymousAccountForInstanceURL(url) + case .piped: + return PipedAPI.withAnonymousAccountForInstanceURL(url) + } } var qualityProfile: QualityProfile? { @@ -155,7 +165,7 @@ extension PlayerModel { currentItem.playbackTime = time let playTime = currentItem.shouldRestartPlaying ? CMTime.zero : time - playerAPI.loadDetails(currentItem, failureHandler: { self.videoLoadFailureHandler($0, video: self.currentItem.video) }) { newItem in + playerAPI(newItem.video).loadDetails(currentItem, failureHandler: { self.videoLoadFailureHandler($0, video: self.currentItem.video) }) { newItem in self.playItem(newItem, at: playTime) } } @@ -198,7 +208,7 @@ extension PlayerModel { } if loadDetails { - playerAPI.loadDetails(item, failureHandler: { self.videoLoadFailureHandler($0, video: video) }) { [weak self] newItem in + playerAPI(item.video).loadDetails(item, failureHandler: { self.videoLoadFailureHandler($0, video: video) }) { [weak self] newItem in guard let self else { return } videoDetailsLoadHandler(newItem.video, newItem) @@ -269,7 +279,7 @@ extension PlayerModel { } func loadQueueVideoDetails(_ item: PlayerQueueItem) { - guard !AccountsModel.shared.current.isNil, !item.hasDetailsLoaded else { return } + guard !accounts.current.isNil, !item.hasDetailsLoaded else { return } let videoID = item.video?.videoID ?? item.videoID @@ -282,7 +292,7 @@ extension PlayerModel { return } - playerAPI.loadDetails(item, completionHandler: { [weak self] newItem in + playerAPI(item.video).loadDetails(item, completionHandler: { [weak self] newItem in guard let self else { return } self.queue.filter { $0.videoID == item.videoID }.forEach { item in diff --git a/Model/Player/PlayerStreams.swift b/Model/Player/PlayerStreams.swift index 9970dbc0..d8c7e941 100644 --- a/Model/Player/PlayerStreams.swift +++ b/Model/Player/PlayerStreams.swift @@ -21,7 +21,7 @@ extension PlayerModel { guard let playerInstance else { return } logger.info("loading streams from \(playerInstance.description)") - fetchStreams(playerAPI.video(video.videoID), instance: playerInstance, video: video) + fetchStreams(playerAPI(video).video(video.videoID), instance: playerInstance, video: video) } private func fetchStreams( diff --git a/Model/Search/SearchModel.swift b/Model/Search/SearchModel.swift index a4568d8a..314daad4 100644 --- a/Model/Search/SearchModel.swift +++ b/Model/Search/SearchModel.swift @@ -23,6 +23,10 @@ final class SearchModel: ObservableObject { resource?.isLoading ?? false } + func reloadQuery() { + changeQuery() + } + func changeQuery(_ changeHandler: @escaping (SearchQuery) -> Void = { _ in }) { changeHandler(query) @@ -78,6 +82,10 @@ final class SearchModel: ObservableObject { }} func loadSuggestions(_ query: String) { + guard accounts.app.supportsSearchSuggestions else { + querySuggestions.removeAll() + return + } suggestionsDebouncer.callback = { guard !query.isEmpty else { return } DispatchQueue.main.async { diff --git a/Model/SingleAssetStream.swift b/Model/SingleAssetStream.swift index 8f3ba5a9..d195f5bc 100644 --- a/Model/SingleAssetStream.swift +++ b/Model/SingleAssetStream.swift @@ -4,9 +4,9 @@ import Foundation final class SingleAssetStream: Stream { var avAsset: AVURLAsset - init(instance: Instance? = nil, avAsset: AVURLAsset, resolution: Resolution, kind: Kind, encoding: String = "") { + init(instance: Instance? = nil, avAsset: AVURLAsset, resolution: Resolution, kind: Kind, encoding: String = "", videoFormat: String? = nil) { self.avAsset = avAsset - super.init(instance: instance, audioAsset: avAsset, videoAsset: avAsset, resolution: resolution, kind: kind, encoding: encoding) + super.init(instance: instance, audioAsset: avAsset, videoAsset: avAsset, resolution: resolution, kind: kind, encoding: encoding, videoFormat: videoFormat) } } diff --git a/Model/Video.swift b/Model/Video.swift index 11966950..f5da2622 100644 --- a/Model/Video.swift +++ b/Model/Video.swift @@ -7,12 +7,25 @@ import SwiftyJSON struct Video: Identifiable, Equatable, Hashable { enum VideoID { static func isValid(_ id: Video.ID) -> Bool { + isYouTube(id) || isPeerTube(id) + } + + static func isYouTube(_ id: Video.ID) -> Bool { id.count == 11 } + + static func isPeerTube(_ id: Video.ID) -> Bool { + id.count == 36 + } } - let id: String - let videoID: String + var instanceID: Instance.ID? + var app: VideosApp + var instanceURL: URL? + + var id: String + var videoID: String + var videoURL: URL? var title: String var thumbnails: [Thumbnail] var author: String @@ -43,8 +56,12 @@ struct Video: Identifiable, Equatable, Hashable { var captions = [Captions]() init( + instanceID: Instance.ID? = nil, + app: VideosApp, + instanceURL: URL? = nil, id: String? = nil, videoID: String, + videoURL: URL? = nil, title: String = "", author: String = "", length: TimeInterval = .zero, @@ -66,8 +83,12 @@ struct Video: Identifiable, Equatable, Hashable { chapters: [Chapter] = [], captions: [Captions] = [] ) { + self.instanceID = instanceID + self.app = app + self.instanceURL = instanceURL self.id = id ?? UUID().uuidString self.videoID = videoID + self.videoURL = videoURL self.title = title self.author = author self.length = length @@ -92,11 +113,24 @@ struct Video: Identifiable, Equatable, Hashable { static func local(_ url: URL) -> Video { Video( + app: .local, videoID: url.absoluteString, streams: [.init(localURL: url)] ) } + var instance: Instance! { + if let instance = InstancesModel.shared.find(instanceID) { + return instance + } + + if let url = instanceURL?.absoluteString { + return Instance(app: app, id: instanceID, apiURLString: url, proxiesVideos: false) + } + + return nil + } + var isLocal: Bool { !VideoID.isValid(videoID) } @@ -118,7 +152,7 @@ struct Video: Identifiable, Equatable, Hashable { } var publishedDate: String? { - (published.isEmpty || published == "0 seconds ago") ? nil : published + (published.isEmpty || published == "0 seconds ago") ? publishedAt?.timeIntervalSince1970.formattedAsRelativeTime() : published } var viewsCount: String? { diff --git a/Model/Watch.swift b/Model/Watch.swift index dc8a4091..f1b7b00a 100644 --- a/Model/Watch.swift +++ b/Model/Watch.swift @@ -14,7 +14,7 @@ extension Watch { NSFetchRequest(entityName: "Watch") } - @nonobjc class func markAsWatched(videoID: String, duration: Double, context: NSManagedObjectContext) { + @nonobjc class func markAsWatched(videoID: String, account: Account, duration: Double, context: NSManagedObjectContext) { let watchFetchRequest = Watch.fetchRequest() watchFetchRequest.predicate = NSPredicate(format: "videoID = %@", videoID as String) @@ -26,6 +26,8 @@ extension Watch { if results?.isEmpty ?? true { watch = Watch(context: context) watch?.videoID = videoID + watch?.appName = account.app?.rawValue + watch?.instanceURL = account.url } else { watch = results?.first } @@ -46,6 +48,14 @@ extension Watch { @NSManaged var watchedAt: Date? @NSManaged var stoppedAt: Double + @NSManaged var appName: String? + @NSManaged var instanceURL: URL? + + var app: VideosApp! { + guard let appName else { return nil } + return .init(rawValue: appName) + } + var progress: Double { guard videoDuration.isFinite, !videoDuration.isZero else { return 0 @@ -83,6 +93,6 @@ extension Watch { return .local(url) } - return Video(videoID: videoID) + return Video(app: app, instanceURL: instanceURL, videoID: videoID) } } diff --git a/Model/Yattee.xcdatamodeld/Yattee.xcdatamodel/contents b/Model/Yattee.xcdatamodeld/Yattee.xcdatamodel/contents index c337b8c4..03cffc53 100644 --- a/Model/Yattee.xcdatamodeld/Yattee.xcdatamodel/contents +++ b/Model/Yattee.xcdatamodeld/Yattee.xcdatamodel/contents @@ -1,6 +1,8 @@ - + + + @@ -11,7 +13,4 @@ - - - \ No newline at end of file diff --git a/Shared/Home/HistoryView.swift b/Shared/Home/HistoryView.swift index a10d2875..48ce7689 100644 --- a/Shared/Home/HistoryView.swift +++ b/Shared/Home/HistoryView.swift @@ -34,7 +34,6 @@ struct HistoryView: View { .onAppear { visibleWatches .prefix(Self.detailsPreloadLimit) - .map(\.videoID) .forEach(player.loadHistoryVideoDetails) } #if os(tvOS) diff --git a/Shared/Navigation/AppSidebarNavigation.swift b/Shared/Navigation/AppSidebarNavigation.swift index ec6d7e2f..a94569b1 100644 --- a/Shared/Navigation/AppSidebarNavigation.swift +++ b/Shared/Navigation/AppSidebarNavigation.swift @@ -82,7 +82,7 @@ struct AppSidebarNavigation: View { .help( "Switch Instances and Accounts\n" + "Current Instance: \n" + - "\(accounts.current?.url ?? "Not Set")\n" + + "\(accounts.current?.urlString ?? "Not Set")\n" + "Current User: \(accounts.current?.description ?? "Not set")" ) } diff --git a/Shared/OpenURLHandler.swift b/Shared/OpenURLHandler.swift index 749a94fb..27466e96 100644 --- a/Shared/OpenURLHandler.swift +++ b/Shared/OpenURLHandler.swift @@ -113,9 +113,9 @@ struct OpenURLHandler { Windows.main.open() #endif - player.videoBeingOpened = Video(videoID: id) + player.videoBeingOpened = Video(app: accounts.current.app!, videoID: id) - player.playerAPI.video(id) + player.playerAPI(player.videoBeingOpened!).video(id) .load() .onSuccess { response in if let video: Video = response.typedContent() { diff --git a/Shared/Search/SearchView.swift b/Shared/Search/SearchView.swift index 2524d129..6e54bf56 100644 --- a/Shared/Search/SearchView.swift +++ b/Shared/Search/SearchView.swift @@ -58,7 +58,7 @@ struct SearchView: View { VStack { SearchTextField(favoriteItem: $favoriteItem) - if state.query.query != state.queryText { + if accounts.app.supportsSearchSuggestions, state.query.query != state.queryText { SearchSuggestions() .opacity(state.queryText.isEmpty ? 0 : 1) } else { @@ -72,7 +72,7 @@ struct SearchView: View { results #if os(macOS) - if state.query.query != state.queryText { + if accounts.app.supportsSearchSuggestions, state.query.query != state.queryText { HStack { Spacer() SearchSuggestions() @@ -122,6 +122,9 @@ struct SearchView: View { state.store.replace(ContentItem.array(of: videos)) } } + .onChange(of: accounts.current) { _ in + state.reloadQuery() + } .onChange(of: state.queryText) { newQuery in if newQuery.isEmpty { favoriteItem = nil diff --git a/Shared/Settings/AccountForm.swift b/Shared/Settings/AccountForm.swift index ab014f39..ca3ae6eb 100644 --- a/Shared/Settings/AccountForm.swift +++ b/Shared/Settings/AccountForm.swift @@ -143,8 +143,8 @@ struct AccountForm: View { private var validator: AccountValidator { AccountValidator( app: .constant(instance.app), - url: instance.apiURL, - account: Account(instanceID: instance.id, url: instance.apiURL, username: username, password: password), + url: instance.apiURLString, + account: Account(instanceID: instance.id, urlString: instance.apiURLString, username: username, password: password), id: $username, isValid: $isValid, isValidated: $isValidated, diff --git a/Shared/Settings/LocationsSettings.swift b/Shared/Settings/LocationsSettings.swift index fcf1e55b..907d2cff 100644 --- a/Shared/Settings/LocationsSettings.swift +++ b/Shared/Settings/LocationsSettings.swift @@ -100,7 +100,7 @@ struct LocationsSettings: View { @ViewBuilder var countryFooter: some View { if let account = accounts.current { let locationType = account.isPublic ? (account.country ?? "Unknown") : "Custom".localized() - let description = account.isPublic ? account.url : account.instance?.description ?? "unknown".localized() + let description = account.isPublic ? account.urlString : account.instance?.description ?? "unknown".localized() Text("Current: \(locationType)\n\(description)") .foregroundColor(.secondary) diff --git a/Shared/Videos/VideoCell.swift b/Shared/Videos/VideoCell.swift index 7a2440fd..84299299 100644 --- a/Shared/Videos/VideoCell.swift +++ b/Shared/Videos/VideoCell.swift @@ -14,7 +14,7 @@ struct VideoCell: View { @Environment(\.verticalSizeClass) private var verticalSizeClass #endif - @ObservedObject var thumbnails = ThumbnailsModel.shared + @ObservedObject private var thumbnails = ThumbnailsModel.shared @Default(.channelOnThumbnail) private var channelOnThumbnail @Default(.timeOnThumbnail) private var timeOnThumbnail diff --git a/Shared/Views/ShareButton.swift b/Shared/Views/ShareButton.swift index cd96cb04..953ff608 100644 --- a/Shared/Views/ShareButton.swift +++ b/Shared/Views/ShareButton.swift @@ -20,6 +20,7 @@ struct ShareButton: View { } @ViewBuilder var body: some View { + // TODO: this should work with other content item types if let video = contentItem.video, !video.localStreamIsFile { Menu { if video.localStreamIsRemoteURL { @@ -44,7 +45,7 @@ struct ShareButton: View { private var instanceActions: some View { Group { Button(labelForShareURL(accounts.app.name)) { - if let url = player.playerAPI.shareURL(contentItem) { + if let url = player.playerAPI(contentItem.video).shareURL(contentItem) { shareAction(url) } else { navigation.presentAlert( @@ -57,7 +58,7 @@ struct ShareButton: View { if contentItemIsPlayerCurrentVideo { Button(labelForShareURL(accounts.app.name, withTime: true)) { shareAction( - player.playerAPI.shareURL( + player.playerAPI(player.currentVideo!).shareURL( contentItem, time: player.backend.currentTime )! diff --git a/Shared/Views/VideoContextMenuView.swift b/Shared/Views/VideoContextMenuView.swift index f9d36a3f..c0697593 100644 --- a/Shared/Views/VideoContextMenuView.swift +++ b/Shared/Views/VideoContextMenuView.swift @@ -148,7 +148,7 @@ struct VideoContextMenuView: View { var markAsWatchedButton: some View { Button { - Watch.markAsWatched(videoID: video.videoID, duration: video.length, context: backgroundContext) + Watch.markAsWatched(videoID: video.videoID, account: accounts.current, duration: video.length, context: backgroundContext) } label: { Label("Mark as watched", systemImage: "checkmark.circle.fill") } diff --git a/Yattee.xcodeproj/project.pbxproj b/Yattee.xcodeproj/project.pbxproj index fcd1c5f6..12c6e4d8 100644 --- a/Yattee.xcodeproj/project.pbxproj +++ b/Yattee.xcodeproj/project.pbxproj @@ -456,6 +456,9 @@ 376A33E42720CB35000C1D6B /* Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376A33E32720CB35000C1D6B /* Account.swift */; }; 376A33E52720CB35000C1D6B /* Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376A33E32720CB35000C1D6B /* Account.swift */; }; 376A33E62720CB35000C1D6B /* Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376A33E32720CB35000C1D6B /* Account.swift */; }; + 376B0560293FF45F0062AC78 /* PeerTubeAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376B055F293FF45F0062AC78 /* PeerTubeAPI.swift */; }; + 376B0561293FF45F0062AC78 /* PeerTubeAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376B055F293FF45F0062AC78 /* PeerTubeAPI.swift */; }; + 376B0562293FF45F0062AC78 /* PeerTubeAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376B055F293FF45F0062AC78 /* PeerTubeAPI.swift */; }; 376B2E0726F920D600B1D64D /* SignInRequiredView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376B2E0626F920D600B1D64D /* SignInRequiredView.swift */; }; 376B2E0826F920D600B1D64D /* SignInRequiredView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376B2E0626F920D600B1D64D /* SignInRequiredView.swift */; }; 376B2E0926F920D600B1D64D /* SignInRequiredView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376B2E0626F920D600B1D64D /* SignInRequiredView.swift */; }; @@ -1194,6 +1197,7 @@ 3769C02D2779F18600DDB3EA /* PlaceholderProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceholderProgressView.swift; sourceTree = ""; }; 376A33DF2720CAD6000C1D6B /* VideosApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideosApp.swift; sourceTree = ""; }; 376A33E32720CB35000C1D6B /* Account.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Account.swift; sourceTree = ""; }; + 376B055F293FF45F0062AC78 /* PeerTubeAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerTubeAPI.swift; sourceTree = ""; }; 376B2E0626F920D600B1D64D /* SignInRequiredView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInRequiredView.swift; sourceTree = ""; }; 376BE50627347B57009AD608 /* SettingsHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsHeader.swift; sourceTree = ""; }; 376BE50A27349108009AD608 /* BrowsingSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowsingSettings.swift; sourceTree = ""; }; @@ -1780,6 +1784,7 @@ isa = PBXGroup; children = ( 37977582268922F600DD52A8 /* InvidiousAPI.swift */, + 376B055F293FF45F0062AC78 /* PeerTubeAPI.swift */, 3700155A271B0D4D0049C794 /* PipedAPI.swift */, 37D526DD2720AC4400ED2F5E /* VideosAPI.swift */, 376A33DF2720CAD6000C1D6B /* VideosApp.swift */, @@ -2886,6 +2891,7 @@ 374924F029216C630017D862 /* VideoActions.swift in Sources */, 37169AA62729E2CC0011DE61 /* AccountsBridge.swift in Sources */, 37C7A1DA267CACF50010EAD6 /* TrendingCountry.swift in Sources */, + 376B0560293FF45F0062AC78 /* PeerTubeAPI.swift in Sources */, 37E80F40287B472300561799 /* ScrollContentBackground+Backport.swift in Sources */, 375EC959289EEB8200751258 /* QualityProfileForm.swift in Sources */, 37D2E0D028B67DBC00F64D52 /* AnimationCompletionObserverModifier.swift in Sources */, @@ -3254,6 +3260,7 @@ 3797758C2689345500DD52A8 /* Store.swift in Sources */, 371B7E622759706A00D21217 /* CommentsView.swift in Sources */, 374AB3D828BCAF0000DF56FB /* SeekModel.swift in Sources */, + 376B0561293FF45F0062AC78 /* PeerTubeAPI.swift in Sources */, 375EC95E289EEEE000751258 /* QualityProfile.swift in Sources */, 37141674267A8E10006CA35D /* Country.swift in Sources */, 3703100027B04DCC00ECDDAA /* PlayerControls.swift in Sources */, @@ -3461,6 +3468,7 @@ 3765788B2685471400D4EA09 /* Playlist.swift in Sources */, 376A33E22720CAD6000C1D6B /* VideosApp.swift in Sources */, 373CFADD269663F1003CB2C6 /* Thumbnail.swift in Sources */, + 376B0562293FF45F0062AC78 /* PeerTubeAPI.swift in Sources */, 37E64DD326D597EB00C71877 /* SubscriptionsModel.swift in Sources */, 3752069B285E8DD300CA655F /* Chapter.swift in Sources */, 37B044B926F7AB9000E1419D /* SettingsView.swift in Sources */,