diff --git a/package.json b/package.json index 1404e34..f3493ff 100644 --- a/package.json +++ b/package.json @@ -252,6 +252,7 @@ "@reduxjs/toolkit": "^1.6.1", "array-move": "^3.0.1", "axios": "^0.21.1", + "axios-retry": "^3.1.9", "chart.js": "^3.5.1", "electron-debug": "^3.1.0", "electron-localshortcut": "^3.2.1", diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index 997c67e..3ec7542 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -71,6 +71,7 @@ const miscState: General = { show: false, }, modalPages: [], + isProcessingPlaylist: [], }; const mockInitialState = { diff --git a/src/api/api.ts b/src/api/api.ts index b0fa5b1..86265e2 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -1,6 +1,8 @@ +/* eslint-disable no-await-in-loop */ import axios from 'axios'; import _ from 'lodash'; import { nanoid } from 'nanoid/non-secure'; +import axiosRetry from 'axios-retry'; const getAuth = () => { const serverConfig = { @@ -42,6 +44,32 @@ api.interceptors.response.use( } ); +axiosRetry(api, { + retries: 3, +}); + +export const playlistApi = axios.create({ + baseURL: API_BASE_URL, +}); + +playlistApi.interceptors.response.use( + (res) => { + // Return the subsonic response directly + res.data = res.data['subsonic-response']; + return res; + }, + (err) => { + return Promise.reject(err); + } +); + +axiosRetry(playlistApi, { + retries: 3, + retryCondition: (e: any) => { + return e.status !== 'ok'; + }, +}); + const authParams = { u: auth.username, s: auth.salt, @@ -407,9 +435,84 @@ export const updatePlaylistSongs = async (id: string, entry: any[]) => { playlistParams.append(key, value); }); - const { data } = await api.get(`/createPlaylist?`, { + const { data } = await api.get(`/createPlaylist`, { params: playlistParams, }); return data; }; + +export const deletePlaylist = async (id: string) => { + const { data } = await api.get(`/deletePlaylist`, { + params: { + id, + }, + }); + + return data; +}; + +export const createPlaylist = async (name: string) => { + const { data } = await api.get(`/createPlaylist`, { + params: { + name, + }, + }); + + return data; +}; + +export const clearPlaylist = async (id: string, entryCount: number) => { + const mockEntries = _.range(entryCount); + + // Set these in chunks so the api doesn't break + const entryChunks = _.chunk(mockEntries, 325); + + let data; + for (let i = 0; i < entryChunks.length; i += 1) { + const params = new URLSearchParams(); + const chunkIndexRange = _.range(entryChunks[i].length); + + params.append('playlistId', id); + _.mapValues(authParams, (value: string, key: string) => { + params.append(key, value); + }); + + for (let x = 0; x < chunkIndexRange.length; x += 1) { + params.append('songIndexToRemove', String(x)); + } + + data = ( + await playlistApi.get(`/updatePlaylist`, { + params, + }) + ).data; + } + + // Use this to check for permission or other errors + return data; +}; + +export const populatePlaylist = async (id: string, entry: any[]) => { + const entryIds = _.map(entry, 'id'); + + // Set these in chunks so the api doesn't break + const entryIdChunks = _.chunk(entryIds, 325); + + for (let i = 0; i < entryIdChunks.length; i += 1) { + const params = new URLSearchParams(); + + params.append('playlistId', id); + _.mapValues(authParams, (value: string, key: string) => { + params.append(key, value); + }); + + for (let x = 0; x < entryIdChunks[i].length; x += 1) { + params.append('songIdToAdd', String(entryIdChunks[i][x])); + } + + await playlistApi.get(`/updatePlaylist`, { + params, + }); + } +}; diff --git a/src/components/playlist/PlaylistView.tsx b/src/components/playlist/PlaylistView.tsx index 088fb53..cdf3fee 100644 --- a/src/components/playlist/PlaylistView.tsx +++ b/src/components/playlist/PlaylistView.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'; import settings from 'electron-settings'; import { ButtonToolbar } from 'rsuite'; import { useQuery, useQueryClient } from 'react-query'; -import { useParams } from 'react-router-dom'; +import { useParams, useHistory } from 'react-router-dom'; import { DeleteButton, EditButton, @@ -11,7 +11,13 @@ import { SaveButton, UndoButton, } from '../shared/ToolbarButtons'; -import { getPlaylist, updatePlaylistSongs } from '../../api/api'; +import { + clearPlaylist, + deletePlaylist, + getPlaylist, + populatePlaylist, + updatePlaylistSongs, +} from '../../api/api'; import { useAppDispatch, useAppSelector } from '../../redux/hooks'; import { fixPlayer2Index, @@ -35,6 +41,10 @@ import PageLoader from '../loader/PageLoader'; import GenericPageHeader from '../layout/GenericPageHeader'; import { setStatus } from '../../redux/playerSlice'; import { notifyToast } from '../shared/toast'; +import { + addProcessingPlaylist, + removeProcessingPlaylist, +} from '../../redux/miscSlice'; interface PlaylistParams { id: string; @@ -42,20 +52,20 @@ interface PlaylistParams { const PlaylistView = ({ ...rest }) => { const dispatch = useAppDispatch(); + const history = useHistory(); const queryClient = useQueryClient(); const { id } = useParams(); const playlistId = rest.id ? rest.id : id; const { isLoading, isError, data, error }: any = useQuery( ['playlist', playlistId], () => getPlaylist(playlistId), - { - refetchOnWindowFocus: false, - } + { refetchOnWindowFocus: false } ); const [localPlaylistData, setLocalPlaylistData] = useState(data); const [isModified, setIsModified] = useState(false); const playQueue = useAppSelector((state) => state.playQueue); const multiSelect = useAppSelector((state) => state.multiSelect); + const misc = useAppSelector((state) => state.misc); const [searchQuery, setSearchQuery] = useState(''); const filteredData = useSearchQuery(searchQuery, localPlaylistData, [ 'title', @@ -128,15 +138,46 @@ const PlaylistView = ({ ...rest }) => { }; const handleSave = async () => { + dispatch(clearSelected()); + dispatch(addProcessingPlaylist(data.id)); try { - const res = await updatePlaylistSongs(data.id, localPlaylistData); + // Smaller playlists can use the safe /createPlaylist method of saving + if (localPlaylistData.length <= 400) { + const res = await updatePlaylistSongs(data.id, localPlaylistData); + if (res.status === 'failed') { + notifyToast('error', res.error.message); + } else { + await queryClient.refetchQueries(['playlist'], { + active: true, + }); + } + // For larger playlists, we'll need to split the request into smaller chunks to save + } else { + const res = await clearPlaylist(data.id, localPlaylistData.length); + + if (res.status === 'failed') { + notifyToast('error', res.error.message); + } else { + await populatePlaylist(data.id, localPlaylistData); + await queryClient.refetchQueries(['playlist'], { + active: true, + }); + } + } + } catch (err) { + console.log(err); + } + dispatch(removeProcessingPlaylist(data.id)); + }; + + const handleDelete = async () => { + try { + const res = await deletePlaylist(data.id); if (res.status === 'failed') { notifyToast('error', res.error.message); } else { - await queryClient.refetchQueries(['playlist'], { - active: true, - }); + history.push('/playlist'); } } catch (err) { console.log(err); @@ -196,17 +237,31 @@ const PlaylistView = ({ ...rest }) => { setLocalPlaylistData(data?.song)} /> - - + + diff --git a/src/redux/miscSlice.ts b/src/redux/miscSlice.ts index 9e20631..daeec47 100644 --- a/src/redux/miscSlice.ts +++ b/src/redux/miscSlice.ts @@ -17,6 +17,7 @@ export interface General { font: string; modal: Modal; modalPages: ModalPage[]; + isProcessingPlaylist: string[]; } const initialState: General = { @@ -27,12 +28,25 @@ const initialState: General = { currentPageIndex: undefined, }, modalPages: [], + isProcessingPlaylist: [], }; const miscSlice = createSlice({ name: 'misc', initialState, reducers: { + addProcessingPlaylist: (state, action: PayloadAction) => { + state.isProcessingPlaylist.push(action.payload); + }, + + removeProcessingPlaylist: (state, action: PayloadAction) => { + const filtered = state.isProcessingPlaylist.filter( + (id: string) => id !== action.payload + ); + + state.isProcessingPlaylist = filtered; + }, + setTheme: (state, action: PayloadAction) => { state.theme = action.payload; }, @@ -95,5 +109,7 @@ export const { addModalPage, incrementModalPage, decrementModalPage, + addProcessingPlaylist, + removeProcessingPlaylist, } = miscSlice.actions; export default miscSlice.reducer; diff --git a/yarn.lock b/yarn.lock index 4b68f61..58f6f94 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2751,6 +2751,13 @@ axe-core@^4.0.2: resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.1.0.tgz#93d395e6262ecdde5cb52a5d06533d0a0c7bb4cd" integrity sha512-9atDIOTDLsWL+1GbBec6omflaT5Cxh88J0GtJtGfCVIXpI02rXHkju59W5mMqWa7eiC5OR168v3TK3kUKBW98g== +axios-retry@^3.1.9: + version "3.1.9" + resolved "https://registry.yarnpkg.com/axios-retry/-/axios-retry-3.1.9.tgz#6c30fc9aeb4519aebaec758b90ef56fa03fe72e8" + integrity sha512-NFCoNIHq8lYkJa6ku4m+V1837TP6lCa7n79Iuf8/AqATAHYB0ISaAS1eyIenDOfHOLtym34W65Sjke2xjg2fsA== + dependencies: + is-retry-allowed "^1.1.0" + axios@^0.21.1: version "0.21.1" resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.1.tgz#22563481962f4d6bde9a76d516ef0e5d3c09b2b8" @@ -7175,6 +7182,11 @@ is-resolvable@^1.0.0: resolved "https://registry.yarnpkg.com/is-resolvable/-/is-resolvable-1.1.0.tgz#fb18f87ce1feb925169c9a407c19318a3206ed88" integrity sha512-qgDYXFSR5WvEfuS5dMj6oTMEbrrSaM0CrFk2Yiq/gXnBvD9pMa2jGXxyhGLfvhZpuMZe18CJpFxAt3CRs42NMg== +is-retry-allowed@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/is-retry-allowed/-/is-retry-allowed-1.2.0.tgz#d778488bd0a4666a3be8a1482b9f2baafedea8b4" + integrity sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg== + is-stream@^1.0.1, is-stream@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44"