From 0bc4a677d4433bc3b4f12d6101ccd15f10176f7e Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Sun, 22 May 2022 00:29:51 +0200 Subject: [PATCH] Create/delete Piped playlists and add/remove videos to Piped playlists --- Model/Applications/InvidiousAPI.swift | 60 +++++++++++++++++++++ Model/Applications/PipedAPI.swift | 71 +++++++++++++++++++++++++ Model/Applications/VideosAPI.swift | 28 ++++++++++ Model/Applications/VideosApp.swift | 8 +++ Model/PlaylistsModel.swift | 26 ++++----- Model/Video.swift | 2 +- Shared/Playlists/PlaylistFormView.swift | 62 +++++++++------------ Shared/Views/PlaylistVideosView.swift | 24 ++++++++- Shared/Views/VideoContextMenuView.swift | 2 +- 9 files changed, 227 insertions(+), 56 deletions(-) diff --git a/Model/Applications/InvidiousAPI.swift b/Model/Applications/InvidiousAPI.swift index e224bd66..76b6ae72 100644 --- a/Model/Applications/InvidiousAPI.swift +++ b/Model/Applications/InvidiousAPI.swift @@ -239,6 +239,66 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { 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)")) } diff --git a/Model/Applications/PipedAPI.swift b/Model/Applications/PipedAPI.swift index d53454b5..b70bc29f 100644 --- a/Model/Applications/PipedAPI.swift +++ b/Model/Applications/PipedAPI.swift @@ -43,6 +43,11 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { self.extractChannelPlaylist(from: content.json) } + configureTransformer(pathPattern("user/playlists/create")) { (_: Entity) in } + configureTransformer(pathPattern("user/playlists/delete")) { (_: Entity) in } + configureTransformer(pathPattern("user/playlists/add")) { (_: Entity) in } + configureTransformer(pathPattern("user/playlists/remove")) { (_: Entity) in } + configureTransformer(pathPattern("streams/*")) { (content: Entity) -> Video? in self.extractVideo(from: content.json) } @@ -193,6 +198,72 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { func playlistVideo(_: String, _: String) -> Resource? { nil } func playlistVideos(_: String) -> Resource? { nil } + func addVideoToPlaylist( + _ videoID: String, + _ playlistID: String, + onFailure: @escaping (RequestError) -> Void = { _ in }, + onSuccess: @escaping () -> Void = {} + ) { + let resource = resource(baseURL: account.instance.apiURL, path: "user/playlists/add") + let body = ["videoId": videoID, "playlistId": playlistID] + + 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 = resource(baseURL: account.instance.apiURL, path: "user/playlists/remove") + let body: [String: Any] = ["index": Int(index)!, "playlistId": playlistID] + + resource + .request(.post, json: body) + .onSuccess { _ in onSuccess() } + .onFailure(onFailure) + } + + func playlistForm( + _ name: String, + _: String, + playlist: Playlist?, + onFailure: @escaping (RequestError) -> Void, + onSuccess: @escaping (Playlist?) -> Void + ) { + let body = ["name": name] + let resource = playlist.isNil ? resource(baseURL: account.instance.apiURL, path: "user/playlists/create") : nil + + resource? + .request(.post, json: body) + .onSuccess { response in + if let modifiedPlaylist: Playlist = response.typedContent() { + onSuccess(modifiedPlaylist) + } else { + onSuccess(nil) + } + } + .onFailure(onFailure) + } + + func deletePlaylist( + _ playlist: Playlist, + onFailure: @escaping (RequestError) -> Void, + onSuccess: @escaping () -> Void + ) { + let resource = resource(baseURL: account.instance.apiURL, path: "user/playlists/delete") + let body = ["playlistId": playlist.id] + + resource + .request(.post, json: body) + .onSuccess { _ in onSuccess() } + .onFailure(onFailure) + } + func comments(_ id: Video.ID, page: String?) -> Resource? { let path = page.isNil ? "comments/\(id)" : "nextpage/comments/\(id)" let resource = resource(baseURL: account.url, path: path) diff --git a/Model/Applications/VideosAPI.swift b/Model/Applications/VideosAPI.swift index 6369baf7..10d6edf9 100644 --- a/Model/Applications/VideosAPI.swift +++ b/Model/Applications/VideosAPI.swift @@ -27,6 +27,34 @@ protocol VideosAPI { func playlistVideo(_ playlistID: String, _ videoID: String) -> Resource? func playlistVideos(_ id: String) -> Resource? + func addVideoToPlaylist( + _ videoID: String, + _ playlistID: String, + onFailure: @escaping (RequestError) -> Void, + onSuccess: @escaping () -> Void + ) + + func removeVideoFromPlaylist( + _ index: String, + _ playlistID: String, + onFailure: @escaping (RequestError) -> Void, + onSuccess: @escaping () -> Void + ) + + func playlistForm( + _ name: String, + _ visibility: String, + playlist: Playlist?, + onFailure: @escaping (RequestError) -> Void, + onSuccess: @escaping (Playlist?) -> Void + ) + + func deletePlaylist( + _ playlist: Playlist, + onFailure: @escaping (RequestError) -> Void, + onSuccess: @escaping () -> Void + ) + func channelPlaylist(_ id: String) -> Resource? func loadDetails(_ item: PlayerQueueItem, completionHandler: @escaping (PlayerQueueItem) -> Void) diff --git a/Model/Applications/VideosApp.swift b/Model/Applications/VideosApp.swift index 0ea6fc35..9692dcc8 100644 --- a/Model/Applications/VideosApp.swift +++ b/Model/Applications/VideosApp.swift @@ -39,6 +39,14 @@ enum VideosApp: String, CaseIterable { self == .invidious } + var userPlaylistsHaveVisibility: Bool { + self == .invidious + } + + var userPlaylistsAreEditable: Bool { + self == .invidious + } + var hasFrontendURL: Bool { self == .piped } diff --git a/Model/PlaylistsModel.swift b/Model/PlaylistsModel.swift index 3ada3535..b98e09c3 100644 --- a/Model/PlaylistsModel.swift +++ b/Model/PlaylistsModel.swift @@ -4,6 +4,7 @@ import SwiftUI final class PlaylistsModel: ObservableObject { @Published var playlists = [Playlist]() + @Published var reloadPlaylists = false var accounts = AccountsModel() @@ -58,24 +59,17 @@ final class PlaylistsModel: ObservableObject { onSuccess: @escaping () -> Void = {}, onFailure: @escaping (RequestError) -> Void = { _ in } ) { - let resource = accounts.api.playlistVideos(playlistID) - let body = ["videoId": videoID] - - resource? - .request(.post, json: body) - .onSuccess { _ in - self.load(force: true) - onSuccess() - } - .onFailure(onFailure) + accounts.api.addVideoToPlaylist(videoID, playlistID, onFailure: onFailure) { + self.load(force: true, onSuccess: onSuccess) + } } - func removeVideo(videoIndexID: String, playlistID: Playlist.ID, onSuccess: @escaping () -> Void = {}) { - let resource = accounts.api.playlistVideo(playlistID, videoIndexID) - - resource?.request(.delete).onSuccess { _ in - self.load(force: true) - onSuccess() + func removeVideo(index: String, playlistID: Playlist.ID, onSuccess: @escaping () -> Void = {}) { + accounts.api.removeVideoFromPlaylist(index, playlistID, onFailure: { _ in }) { + self.load(force: true) { + self.reloadPlaylists.toggle() + onSuccess() + } } } diff --git a/Model/Video.swift b/Model/Video.swift index 29e1462b..6b534d38 100644 --- a/Model/Video.swift +++ b/Model/Video.swift @@ -17,7 +17,7 @@ struct Video: Identifiable, Equatable, Hashable { var genre: String? // index used when in the Playlist - let indexID: String? + var indexID: String? var live: Bool var upcoming: Bool diff --git a/Shared/Playlists/PlaylistFormView.swift b/Shared/Playlists/PlaylistFormView.swift index 48b33756..d6180f78 100644 --- a/Shared/Playlists/PlaylistFormView.swift +++ b/Shared/Playlists/PlaylistFormView.swift @@ -43,9 +43,12 @@ struct PlaylistFormView: View { TextField("Name", text: $name, onCommit: validate) .frame(maxWidth: 450) .padding(.leading, 10) + .disabled(editing && !accounts.app.userPlaylistsAreEditable) - visibilityFormItem - .pickerStyle(.segmented) + if accounts.app.userPlaylistsHaveVisibility { + visibilityFormItem + .pickerStyle(.segmented) + } } #if os(macOS) .padding(.horizontal) @@ -59,7 +62,7 @@ struct PlaylistFormView: View { Spacer() Button("Save", action: submitForm) - .disabled(!valid) + .disabled(!valid || (editing && !accounts.app.userPlaylistsAreEditable)) .alert(isPresented: $presentingErrorAlert) { Alert( title: Text("Error when accessing playlist"), @@ -75,7 +78,7 @@ struct PlaylistFormView: View { #if os(iOS) .padding(.vertical) #else - .frame(width: 400, height: 150) + .frame(width: 400, height: accounts.app.userPlaylistsHaveVisibility ? 150 : 120) #endif #else @@ -119,6 +122,7 @@ struct PlaylistFormView: View { .frame(maxWidth: .infinity, alignment: .leading) TextField("Playlist Name", text: $name, onCommit: validate) + .disabled(editing && !accounts.app.userPlaylistsAreEditable) } HStack { @@ -132,7 +136,8 @@ struct PlaylistFormView: View { HStack { Spacer() - Button("Save", action: submitForm).disabled(!valid) + Button("Save", action: submitForm) + .disabled(!valid || (editing && !accounts.app.userPlaylistsAreEditable)) } .padding(.top, 40) @@ -172,27 +177,15 @@ struct PlaylistFormView: View { return } - let body = ["title": name, "privacy": visibility.rawValue] + accounts.api.playlistForm(name, visibility.rawValue, playlist: playlist, onFailure: { error in + formError = "(\(error.httpStatusCode ?? -1)) \(error.userMessage)" + presentingErrorAlert = true + }) { modifiedPlaylist in + self.playlist = modifiedPlaylist + playlists.load(force: true) - resource? - .request(editing ? .patch : .post, json: body) - .onSuccess { response in - if let modifiedPlaylist: Playlist = response.typedContent() { - playlist = modifiedPlaylist - } - - playlists.load(force: true) - - presentationMode.wrappedValue.dismiss() - } - .onFailure { error in - formError = "(\(error.httpStatusCode ?? -1)) \(error.userMessage)" - presentingErrorAlert = true - } - } - - var resource: Resource? { - editing ? accounts.api.playlist(playlist.id) : accounts.api.playlists + presentationMode.wrappedValue.dismiss() + } } var visibilityFormItem: some View { @@ -236,17 +229,14 @@ struct PlaylistFormView: View { } func deletePlaylistAndDismiss() { - accounts.api.playlist(playlist.id)? - .request(.delete) - .onSuccess { _ in - playlist = nil - playlists.load(force: true) - presentationMode.wrappedValue.dismiss() - } - .onFailure { error in - formError = "(\(error.httpStatusCode ?? -1)) \(error.localizedDescription)" - presentingErrorAlert = true - } + accounts.api.deletePlaylist(playlist, onFailure: { error in + formError = "(\(error.httpStatusCode ?? -1)) \(error.localizedDescription)" + presentingErrorAlert = true + }) { + playlist = nil + playlists.load(force: true) + presentationMode.wrappedValue.dismiss() + } } } diff --git a/Shared/Views/PlaylistVideosView.swift b/Shared/Views/PlaylistVideosView.swift index 73232c4d..92ffac77 100644 --- a/Shared/Views/PlaylistVideosView.swift +++ b/Shared/Views/PlaylistVideosView.swift @@ -6,11 +6,28 @@ struct PlaylistVideosView: View { @Environment(\.inNavigationView) private var inNavigationView @EnvironmentObject private var player + @EnvironmentObject private var model @StateObject private var store = Store() var contentItems: [ContentItem] { - ContentItem.array(of: playlist.videos.isEmpty ? (store.item?.videos ?? []) : playlist.videos) + var videos = playlist.videos + + if videos.isEmpty { + videos = store.item?.videos ?? [] + if !player.accounts.app.userPlaylistsEndpointIncludesVideos { + var i = 0 + + for index in videos.indices { + var video = videos[index] + video.indexID = "\(i)" + i += 1 + videos[index] = video + } + } + } + + return ContentItem.array(of: videos) } private var resource: Resource? { @@ -33,9 +50,12 @@ struct PlaylistVideosView: View { VerticalCells(items: contentItems) .onAppear { if !player.accounts.app.userPlaylistsEndpointIncludesVideos { - resource?.loadIfNeeded() + resource?.load() } } + .onChange(of: model.reloadPlaylists) { _ in + resource?.load() + } #if !os(tvOS) .navigationTitle("\(playlist.title) Playlist") #endif diff --git a/Shared/Views/VideoContextMenuView.swift b/Shared/Views/VideoContextMenuView.swift index fc093c10..9cff70c2 100644 --- a/Shared/Views/VideoContextMenuView.swift +++ b/Shared/Views/VideoContextMenuView.swift @@ -201,7 +201,7 @@ struct VideoContextMenuView: View { func removeFromPlaylistButton(playlistID: String) -> some View { Button { - playlists.removeVideo(videoIndexID: video.indexID!, playlistID: playlistID) + playlists.removeVideo(index: video.indexID!, playlistID: playlistID) } label: { Label("Remove from playlist", systemImage: "text.badge.minus") }