Browse Source

Optimize large playlist save, add error fallback

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

96
src/api/api.ts

@ -476,6 +476,39 @@ export const updatePlaylistSongs = async (id: string, entry: any[]) => {
return data; return data;
}; };
export const updatePlaylistSongsLg = async (
playlistId: string,
entry: any[]
) => {
const entryIds = _.map(entry, 'id');
// Set these in chunks so the api doesn't break
// Testing on the airsonic api broke around ~350 entries
const entryIdChunks = _.chunk(entryIds, 325);
const res: any[] = [];
for (let i = 0; i < entryIdChunks.length; i += 1) {
const params = new URLSearchParams();
params.append('playlistId', playlistId);
_.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]));
}
const { data } = await api.get(`/updatePlaylist`, {
params,
});
res.push(data);
}
return res;
};
export const deletePlaylist = async (id: string) => { export const deletePlaylist = async (id: string) => {
const { data } = await api.get(`/deletePlaylist`, { const { data } = await api.get(`/deletePlaylist`, {
params: { params: {
@ -496,62 +529,13 @@ export const createPlaylist = async (name: string) => {
return data; return data;
}; };
export const clearPlaylist = async (id: string, entryCount: number) => { export const clearPlaylist = async (playlistId: string) => {
const mockEntries = _.range(entryCount); // Specifying the playlistId without any songs will empty the existing playlist
const { data } = await api.get(`/createPlaylist`, {
// Set these in chunks so the api doesn't break params: {
const entryChunks = _.chunk(mockEntries, 325); playlistId,
},
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 api.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);
let data;
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]));
}
data = (
await api.get(`/updatePlaylist`, {
params,
})
).data;
}
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