Create/delete Piped playlists and add/remove videos to Piped playlists

This commit is contained in:
Arkadiusz Fal 2022-05-22 00:29:51 +02:00
parent b374f82da4
commit 0bc4a677d4
9 changed files with 227 additions and 56 deletions

View File

@ -239,6 +239,66 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
playlist(playlistID)?.child("videos").child(videoID) 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? { func channelPlaylist(_ id: String) -> Resource? {
resource(baseURL: account.url, path: basePathAppending("playlists/\(id)")) resource(baseURL: account.url, path: basePathAppending("playlists/\(id)"))
} }

View File

@ -43,6 +43,11 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
self.extractChannelPlaylist(from: content.json) self.extractChannelPlaylist(from: content.json)
} }
configureTransformer(pathPattern("user/playlists/create")) { (_: Entity<JSON>) in }
configureTransformer(pathPattern("user/playlists/delete")) { (_: Entity<JSON>) in }
configureTransformer(pathPattern("user/playlists/add")) { (_: Entity<JSON>) in }
configureTransformer(pathPattern("user/playlists/remove")) { (_: Entity<JSON>) in }
configureTransformer(pathPattern("streams/*")) { (content: Entity<JSON>) -> Video? in configureTransformer(pathPattern("streams/*")) { (content: Entity<JSON>) -> Video? in
self.extractVideo(from: content.json) self.extractVideo(from: content.json)
} }
@ -193,6 +198,72 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
func playlistVideo(_: String, _: String) -> Resource? { nil } func playlistVideo(_: String, _: String) -> Resource? { nil }
func playlistVideos(_: 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? { func comments(_ id: Video.ID, page: String?) -> Resource? {
let path = page.isNil ? "comments/\(id)" : "nextpage/comments/\(id)" let path = page.isNil ? "comments/\(id)" : "nextpage/comments/\(id)"
let resource = resource(baseURL: account.url, path: path) let resource = resource(baseURL: account.url, path: path)

View File

@ -27,6 +27,34 @@ protocol VideosAPI {
func playlistVideo(_ playlistID: String, _ videoID: String) -> Resource? func playlistVideo(_ playlistID: String, _ videoID: String) -> Resource?
func playlistVideos(_ id: 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 channelPlaylist(_ id: String) -> Resource?
func loadDetails(_ item: PlayerQueueItem, completionHandler: @escaping (PlayerQueueItem) -> Void) func loadDetails(_ item: PlayerQueueItem, completionHandler: @escaping (PlayerQueueItem) -> Void)

View File

@ -39,6 +39,14 @@ enum VideosApp: String, CaseIterable {
self == .invidious self == .invidious
} }
var userPlaylistsHaveVisibility: Bool {
self == .invidious
}
var userPlaylistsAreEditable: Bool {
self == .invidious
}
var hasFrontendURL: Bool { var hasFrontendURL: Bool {
self == .piped self == .piped
} }

View File

@ -4,6 +4,7 @@ import SwiftUI
final class PlaylistsModel: ObservableObject { final class PlaylistsModel: ObservableObject {
@Published var playlists = [Playlist]() @Published var playlists = [Playlist]()
@Published var reloadPlaylists = false
var accounts = AccountsModel() var accounts = AccountsModel()
@ -58,24 +59,17 @@ final class PlaylistsModel: ObservableObject {
onSuccess: @escaping () -> Void = {}, onSuccess: @escaping () -> Void = {},
onFailure: @escaping (RequestError) -> Void = { _ in } onFailure: @escaping (RequestError) -> Void = { _ in }
) { ) {
let resource = accounts.api.playlistVideos(playlistID) accounts.api.addVideoToPlaylist(videoID, playlistID, onFailure: onFailure) {
let body = ["videoId": videoID] self.load(force: true, onSuccess: onSuccess)
}
resource?
.request(.post, json: body)
.onSuccess { _ in
self.load(force: true)
onSuccess()
}
.onFailure(onFailure)
} }
func removeVideo(videoIndexID: String, playlistID: Playlist.ID, onSuccess: @escaping () -> Void = {}) { func removeVideo(index: String, playlistID: Playlist.ID, onSuccess: @escaping () -> Void = {}) {
let resource = accounts.api.playlistVideo(playlistID, videoIndexID) accounts.api.removeVideoFromPlaylist(index, playlistID, onFailure: { _ in }) {
self.load(force: true) {
resource?.request(.delete).onSuccess { _ in self.reloadPlaylists.toggle()
self.load(force: true) onSuccess()
onSuccess() }
} }
} }

View File

@ -17,7 +17,7 @@ struct Video: Identifiable, Equatable, Hashable {
var genre: String? var genre: String?
// index used when in the Playlist // index used when in the Playlist
let indexID: String? var indexID: String?
var live: Bool var live: Bool
var upcoming: Bool var upcoming: Bool

View File

@ -43,9 +43,12 @@ struct PlaylistFormView: View {
TextField("Name", text: $name, onCommit: validate) TextField("Name", text: $name, onCommit: validate)
.frame(maxWidth: 450) .frame(maxWidth: 450)
.padding(.leading, 10) .padding(.leading, 10)
.disabled(editing && !accounts.app.userPlaylistsAreEditable)
visibilityFormItem if accounts.app.userPlaylistsHaveVisibility {
.pickerStyle(.segmented) visibilityFormItem
.pickerStyle(.segmented)
}
} }
#if os(macOS) #if os(macOS)
.padding(.horizontal) .padding(.horizontal)
@ -59,7 +62,7 @@ struct PlaylistFormView: View {
Spacer() Spacer()
Button("Save", action: submitForm) Button("Save", action: submitForm)
.disabled(!valid) .disabled(!valid || (editing && !accounts.app.userPlaylistsAreEditable))
.alert(isPresented: $presentingErrorAlert) { .alert(isPresented: $presentingErrorAlert) {
Alert( Alert(
title: Text("Error when accessing playlist"), title: Text("Error when accessing playlist"),
@ -75,7 +78,7 @@ struct PlaylistFormView: View {
#if os(iOS) #if os(iOS)
.padding(.vertical) .padding(.vertical)
#else #else
.frame(width: 400, height: 150) .frame(width: 400, height: accounts.app.userPlaylistsHaveVisibility ? 150 : 120)
#endif #endif
#else #else
@ -119,6 +122,7 @@ struct PlaylistFormView: View {
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
TextField("Playlist Name", text: $name, onCommit: validate) TextField("Playlist Name", text: $name, onCommit: validate)
.disabled(editing && !accounts.app.userPlaylistsAreEditable)
} }
HStack { HStack {
@ -132,7 +136,8 @@ struct PlaylistFormView: View {
HStack { HStack {
Spacer() Spacer()
Button("Save", action: submitForm).disabled(!valid) Button("Save", action: submitForm)
.disabled(!valid || (editing && !accounts.app.userPlaylistsAreEditable))
} }
.padding(.top, 40) .padding(.top, 40)
@ -172,27 +177,15 @@ struct PlaylistFormView: View {
return 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? presentationMode.wrappedValue.dismiss()
.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
} }
var visibilityFormItem: some View { var visibilityFormItem: some View {
@ -236,17 +229,14 @@ struct PlaylistFormView: View {
} }
func deletePlaylistAndDismiss() { func deletePlaylistAndDismiss() {
accounts.api.playlist(playlist.id)? accounts.api.deletePlaylist(playlist, onFailure: { error in
.request(.delete) formError = "(\(error.httpStatusCode ?? -1)) \(error.localizedDescription)"
.onSuccess { _ in presentingErrorAlert = true
playlist = nil }) {
playlists.load(force: true) playlist = nil
presentationMode.wrappedValue.dismiss() playlists.load(force: true)
} presentationMode.wrappedValue.dismiss()
.onFailure { error in }
formError = "(\(error.httpStatusCode ?? -1)) \(error.localizedDescription)"
presentingErrorAlert = true
}
} }
} }

View File

@ -6,11 +6,28 @@ struct PlaylistVideosView: View {
@Environment(\.inNavigationView) private var inNavigationView @Environment(\.inNavigationView) private var inNavigationView
@EnvironmentObject<PlayerModel> private var player @EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<PlaylistsModel> private var model
@StateObject private var store = Store<ChannelPlaylist>() @StateObject private var store = Store<ChannelPlaylist>()
var contentItems: [ContentItem] { 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? { private var resource: Resource? {
@ -33,9 +50,12 @@ struct PlaylistVideosView: View {
VerticalCells(items: contentItems) VerticalCells(items: contentItems)
.onAppear { .onAppear {
if !player.accounts.app.userPlaylistsEndpointIncludesVideos { if !player.accounts.app.userPlaylistsEndpointIncludesVideos {
resource?.loadIfNeeded() resource?.load()
} }
} }
.onChange(of: model.reloadPlaylists) { _ in
resource?.load()
}
#if !os(tvOS) #if !os(tvOS)
.navigationTitle("\(playlist.title) Playlist") .navigationTitle("\(playlist.title) Playlist")
#endif #endif

View File

@ -201,7 +201,7 @@ struct VideoContextMenuView: View {
func removeFromPlaylistButton(playlistID: String) -> some View { func removeFromPlaylistButton(playlistID: String) -> some View {
Button { Button {
playlists.removeVideo(videoIndexID: video.indexID!, playlistID: playlistID) playlists.removeVideo(index: video.indexID!, playlistID: playlistID)
} label: { } label: {
Label("Remove from playlist", systemImage: "text.badge.minus") Label("Remove from playlist", systemImage: "text.badge.minus")
} }