Browse Source

update playlist save, add playlist delete

- split logic between saving small/large playlists
- add axios retry
master
jeffvli 3 years ago
parent
commit
cf9b8a0c8c
  1. 1
      package.json
  2. 1
      src/__tests__/App.test.tsx
  3. 105
      src/api/api.ts
  4. 81
      src/components/playlist/PlaylistView.tsx
  5. 16
      src/redux/miscSlice.ts
  6. 12
      yarn.lock

1
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",

1
src/__tests__/App.test.tsx

@ -71,6 +71,7 @@ const miscState: General = {
show: false,
},
modalPages: [],
isProcessingPlaylist: [],
};
const mockInitialState = {

105
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,
});
}
};

81
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<PlaylistParams>();
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 }) => {
<SaveButton
size="lg"
color={isModified ? 'green' : undefined}
disabled={!isModified}
disabled={
!isModified ||
misc.isProcessingPlaylist.includes(data?.id)
}
loading={misc.isProcessingPlaylist.includes(data?.id)}
onClick={handleSave}
/>
<UndoButton
size="lg"
color={isModified ? 'green' : undefined}
disabled={!isModified}
disabled={
!isModified ||
misc.isProcessingPlaylist.includes(data?.id)
}
onClick={() => setLocalPlaylistData(data?.song)}
/>
<EditButton size="lg" />
<DeleteButton size="lg" />
<EditButton
size="lg"
disabled={misc.isProcessingPlaylist.includes(data?.id)}
/>
<DeleteButton
size="lg"
onClick={handleDelete}
disabled={misc.isProcessingPlaylist.includes(data?.id)}
/>
</ButtonToolbar>
</div>
</div>

16
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<string>) => {
state.isProcessingPlaylist.push(action.payload);
},
removeProcessingPlaylist: (state, action: PayloadAction<string>) => {
const filtered = state.isProcessingPlaylist.filter(
(id: string) => id !== action.payload
);
state.isProcessingPlaylist = filtered;
},
setTheme: (state, action: PayloadAction<string>) => {
state.theme = action.payload;
},
@ -95,5 +109,7 @@ export const {
addModalPage,
incrementModalPage,
decrementModalPage,
addProcessingPlaylist,
removeProcessingPlaylist,
} = miscSlice.actions;
export default miscSlice.reducer;

12
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"

Loading…
Cancel
Save