Browse Source

Optimize large playlist save, add error fallback

master
jeffvli 3 years ago
parent
commit
b8bdc30397
  1. 94
      src/api/api.ts
  2. 80
      src/components/playlist/PlaylistView.tsx
  3. 4
      src/components/shared/ToolbarButtons.tsx
  4. 16
      src/shared/utils.ts

94
src/api/api.ts

@ -476,82 +476,66 @@ export const updatePlaylistSongs = async (id: string, entry: any[]) => {
return data; return data;
}; };
export const deletePlaylist = async (id: string) => { export const updatePlaylistSongsLg = async (
const { data } = await api.get(`/deletePlaylist`, { playlistId: string,
params: { entry: any[]
id, ) => {
}, const entryIds = _.map(entry, '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 // Set these in chunks so the api doesn't break
const entryChunks = _.chunk(mockEntries, 325); // Testing on the airsonic api broke around ~350 entries
const entryIdChunks = _.chunk(entryIds, 325);
let data; const res: any[] = [];
for (let i = 0; i < entryChunks.length; i += 1) { for (let i = 0; i < entryIdChunks.length; i += 1) {
const params = new URLSearchParams(); const params = new URLSearchParams();
const chunkIndexRange = _.range(entryChunks[i].length);
params.append('playlistId', id); params.append('playlistId', playlistId);
_.mapValues(authParams, (value: string, key: string) => { _.mapValues(authParams, (value: string, key: string) => {
params.append(key, value); params.append(key, value);
}); });
for (let x = 0; x < chunkIndexRange.length; x += 1) { for (let x = 0; x < entryIdChunks[i].length; x += 1) {
params.append('songIndexToRemove', String(x)); params.append('songIdToAdd', String(entryIdChunks[i][x]));
} }
data = ( const { data } = await api.get(`/updatePlaylist`, {
await api.get(`/updatePlaylist`, {
params, params,
}) });
).data;
res.push(data);
} }
// Use this to check for permission or other errors return res;
return data;
}; };
export const populatePlaylist = async (id: string, entry: any[]) => { export const deletePlaylist = async (id: string) => {
const entryIds = _.map(entry, 'id'); const { data } = await api.get(`/deletePlaylist`, {
params: {
// Set these in chunks so the api doesn't break id,
const entryIdChunks = _.chunk(entryIds, 325); },
});
let data; return data;
for (let i = 0; i < entryIdChunks.length; i += 1) { };
const params = new URLSearchParams();
params.append('playlistId', id); export const createPlaylist = async (name: string) => {
_.mapValues(authParams, (value: string, key: string) => { const { data } = await api.get(`/createPlaylist`, {
params.append(key, value); params: {
name,
},
}); });
for (let x = 0; x < entryIdChunks[i].length; x += 1) { return data;
params.append('songIdToAdd', String(entryIdChunks[i][x])); };
}
data = ( export const clearPlaylist = async (playlistId: string) => {
await api.get(`/updatePlaylist`, { // Specifying the playlistId without any songs will empty the existing playlist
params, const { data } = await api.get(`/createPlaylist`, {
}) params: {
).data; playlistId,
} },
});
return data; return data;
}; };

80
src/components/playlist/PlaylistView.tsx

@ -1,4 +1,7 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import _ from 'lodash';
import fs from 'fs';
import path from 'path';
import settings from 'electron-settings'; import settings from 'electron-settings';
import { ButtonToolbar } from 'rsuite'; import { ButtonToolbar } from 'rsuite';
import { useQuery, useQueryClient } from 'react-query'; import { useQuery, useQueryClient } from 'react-query';
@ -15,7 +18,7 @@ import {
clearPlaylist, clearPlaylist,
deletePlaylist, deletePlaylist,
getPlaylist, getPlaylist,
populatePlaylist, updatePlaylistSongsLg,
updatePlaylistSongs, updatePlaylistSongs,
} from '../../api/api'; } from '../../api/api';
import { useAppDispatch, useAppSelector } from '../../redux/hooks'; import { useAppDispatch, useAppSelector } from '../../redux/hooks';
@ -33,7 +36,11 @@ import {
clearSelected, clearSelected,
setIsDragging, setIsDragging,
} from '../../redux/multiSelectSlice'; } from '../../redux/multiSelectSlice';
import { moveToIndex } from '../../shared/utils'; import {
createRecoveryFile,
getRecoveryPath,
moveToIndex,
} from '../../shared/utils';
import useSearchQuery from '../../hooks/useSearchQuery'; import useSearchQuery from '../../hooks/useSearchQuery';
import GenericPage from '../layout/GenericPage'; import GenericPage from '../layout/GenericPage';
import ListViewType from '../viewtypes/ListViewType'; import ListViewType from '../viewtypes/ListViewType';
@ -67,12 +74,24 @@ const PlaylistView = ({ ...rest }) => {
const multiSelect = useAppSelector((state) => state.multiSelect); const multiSelect = useAppSelector((state) => state.multiSelect);
const misc = useAppSelector((state) => state.misc); const misc = useAppSelector((state) => state.misc);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [recoveryPath, setRecoveryPath] = useState('');
const [needsRecovery, setNeedsRecovery] = useState(false);
const filteredData = useSearchQuery(searchQuery, localPlaylistData, [ const filteredData = useSearchQuery(searchQuery, localPlaylistData, [
'title', 'title',
'artist', 'artist',
'album', 'album',
]); ]);
useEffect(() => {
const recoveryFilePath = path.join(
getRecoveryPath(),
`playlist_${data?.id}.json`
);
setRecoveryPath(recoveryFilePath);
setNeedsRecovery(fs.existsSync(recoveryFilePath));
}, [data?.id]);
useEffect(() => { useEffect(() => {
// Set the local playlist data on any changes // Set the local playlist data on any changes
setLocalPlaylistData(data?.song); setLocalPlaylistData(data?.song);
@ -137,13 +156,18 @@ const PlaylistView = ({ ...rest }) => {
} }
}; };
const handleSave = async () => { const handleSave = async (recovery: boolean) => {
dispatch(clearSelected()); dispatch(clearSelected());
dispatch(addProcessingPlaylist(data.id)); dispatch(addProcessingPlaylist(data.id));
try { try {
let res;
const playlistData = recovery
? JSON.parse(fs.readFileSync(recoveryPath, { encoding: 'utf-8' }))
: localPlaylistData;
// Smaller playlists can use the safe /createPlaylist method of saving // Smaller playlists can use the safe /createPlaylist method of saving
if (localPlaylistData.length <= 400) { if (playlistData.length <= 400 && !recovery) {
const res = await updatePlaylistSongs(data.id, localPlaylistData); res = await updatePlaylistSongs(data.id, playlistData);
if (res.status === 'failed') { if (res.status === 'failed') {
notifyToast('error', res.error.message); notifyToast('error', res.error.message);
} else { } else {
@ -151,21 +175,43 @@ const PlaylistView = ({ ...rest }) => {
active: true, active: true,
}); });
} }
// For larger playlists, we'll need to split the request into smaller chunks to save
} else { } else {
const res = await clearPlaylist(data.id, localPlaylistData.length); // For larger playlists, we'll need to first clear out the playlist and then re-populate it
// Tested on Airsonic instances, /createPlaylist fails with around ~350+ songId params
res = await clearPlaylist(data.id);
if (res.status === 'failed') { if (res.status === 'failed') {
notifyToast('error', res.error.message); notifyToast('error', res.error.message);
} else { } else {
await populatePlaylist(data.id, localPlaylistData); res = await updatePlaylistSongsLg(data.id, playlistData);
if (_.map(res, 'status').includes('failed')) {
res.forEach((response) => {
if (response.status === 'failed') {
return notifyToast('error', response.error);
}
return false;
});
// If there are any failures (network, etc.), then we'll need a way to recover the playlist.
// Write the localPlaylistData to a file so we can re-run the save command.
createRecoveryFile(data.id, 'playlist', playlistData);
setNeedsRecovery(true);
}
if (recovery) {
// If the recovery succeeds, we can remove the recovery file
fs.unlinkSync(recoveryPath);
setNeedsRecovery(false);
}
await queryClient.refetchQueries(['playlist'], { await queryClient.refetchQueries(['playlist'], {
active: true, active: true,
}); });
} }
} }
} catch (err) { } catch (err) {
console.log(err); notifyToast('error', err);
} }
dispatch(removeProcessingPlaylist(data.id)); dispatch(removeProcessingPlaylist(data.id));
}; };
@ -180,7 +226,7 @@ const PlaylistView = ({ ...rest }) => {
history.push('/playlist'); history.push('/playlist');
} }
} catch (err) { } catch (err) {
console.log(err); notifyToast('error', err);
} }
}; };
@ -236,18 +282,24 @@ const PlaylistView = ({ ...rest }) => {
/> />
<SaveButton <SaveButton
size="lg" size="lg"
color={isModified ? 'green' : undefined} text={needsRecovery ? 'Recover playlist' : undefined}
color={
needsRecovery ? 'red' : isModified ? 'green' : undefined
}
disabled={ disabled={
!isModified || (!needsRecovery && !isModified) ||
misc.isProcessingPlaylist.includes(data?.id) misc.isProcessingPlaylist.includes(data?.id)
} }
loading={misc.isProcessingPlaylist.includes(data?.id)} loading={misc.isProcessingPlaylist.includes(data?.id)}
onClick={handleSave} onClick={() => handleSave(needsRecovery)}
/> />
<UndoButton <UndoButton
size="lg" size="lg"
color={isModified ? 'green' : undefined} color={
needsRecovery ? 'red' : isModified ? 'green' : undefined
}
disabled={ disabled={
needsRecovery ||
!isModified || !isModified ||
misc.isProcessingPlaylist.includes(data?.id) misc.isProcessingPlaylist.includes(data?.id)
} }

4
src/components/shared/ToolbarButtons.tsx

@ -42,9 +42,9 @@ export const PlayShuffleAppendButton = ({ ...rest }) => {
); );
}; };
export const SaveButton = ({ ...rest }) => { export const SaveButton = ({ text, ...rest }: any) => {
return ( return (
<CustomTooltip text="Save" placement="bottom"> <CustomTooltip text={text || 'Save'} placement="bottom">
<StyledIconButton tabIndex={0} icon={<Icon icon="save" />} {...rest} /> <StyledIconButton tabIndex={0} icon={<Icon icon="save" />} {...rest} />
</CustomTooltip> </CustomTooltip>
); );

16
src/shared/utils.ts

@ -30,6 +30,22 @@ export const getSongCachePath = () => {
return path.join(getRootCachePath(), 'song'); return path.join(getRootCachePath(), 'song');
}; };
export const getRecoveryPath = () => {
return path.join(getRootCachePath(), '__recovery');
};
export const createRecoveryFile = (id: any, type: string, data: any) => {
const recoveryPath = getRecoveryPath();
if (!fs.existsSync(recoveryPath)) {
fs.mkdirSync(recoveryPath, { recursive: true });
}
const filePath = path.join(recoveryPath, `${type}_${id}.json`);
fs.writeFileSync(filePath, JSON.stringify(data, null, 4), 'utf-8');
};
export const shuffle = (array: any[]) => { export const shuffle = (array: any[]) => {
let currentIndex = array.length; let currentIndex = array.length;
let randomIndex; let randomIndex;

Loading…
Cancel
Save