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;
};
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) => {
const { data } = await api.get(`/deletePlaylist`, {
params: {
@ -496,62 +529,13 @@ export const createPlaylist = async (name: string) => {
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 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;
}
export const clearPlaylist = async (playlistId: string) => {
// Specifying the playlistId without any songs will empty the existing playlist
const { data } = await api.get(`/createPlaylist`, {
params: {
playlistId,
},
});
return data;
};

80
src/components/playlist/PlaylistView.tsx

@ -1,4 +1,7 @@
import React, { useEffect, useState } from 'react';
import _ from 'lodash';
import fs from 'fs';
import path from 'path';
import settings from 'electron-settings';
import { ButtonToolbar } from 'rsuite';
import { useQuery, useQueryClient } from 'react-query';
@ -15,7 +18,7 @@ import {
clearPlaylist,
deletePlaylist,
getPlaylist,
populatePlaylist,
updatePlaylistSongsLg,
updatePlaylistSongs,
} from '../../api/api';
import { useAppDispatch, useAppSelector } from '../../redux/hooks';
@ -33,7 +36,11 @@ import {
clearSelected,
setIsDragging,
} from '../../redux/multiSelectSlice';
import { moveToIndex } from '../../shared/utils';
import {
createRecoveryFile,
getRecoveryPath,
moveToIndex,
} from '../../shared/utils';
import useSearchQuery from '../../hooks/useSearchQuery';
import GenericPage from '../layout/GenericPage';
import ListViewType from '../viewtypes/ListViewType';
@ -67,12 +74,24 @@ const PlaylistView = ({ ...rest }) => {
const multiSelect = useAppSelector((state) => state.multiSelect);
const misc = useAppSelector((state) => state.misc);
const [searchQuery, setSearchQuery] = useState('');
const [recoveryPath, setRecoveryPath] = useState('');
const [needsRecovery, setNeedsRecovery] = useState(false);
const filteredData = useSearchQuery(searchQuery, localPlaylistData, [
'title',
'artist',
'album',
]);
useEffect(() => {
const recoveryFilePath = path.join(
getRecoveryPath(),
`playlist_${data?.id}.json`
);
setRecoveryPath(recoveryFilePath);
setNeedsRecovery(fs.existsSync(recoveryFilePath));
}, [data?.id]);
useEffect(() => {
// Set the local playlist data on any changes
setLocalPlaylistData(data?.song);
@ -137,13 +156,18 @@ const PlaylistView = ({ ...rest }) => {
}
};
const handleSave = async () => {
const handleSave = async (recovery: boolean) => {
dispatch(clearSelected());
dispatch(addProcessingPlaylist(data.id));
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
if (localPlaylistData.length <= 400) {
const res = await updatePlaylistSongs(data.id, localPlaylistData);
if (playlistData.length <= 400 && !recovery) {
res = await updatePlaylistSongs(data.id, playlistData);
if (res.status === 'failed') {
notifyToast('error', res.error.message);
} else {
@ -151,21 +175,43 @@ const PlaylistView = ({ ...rest }) => {
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);
// 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') {
notifyToast('error', res.error.message);
} 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'], {
active: true,
});
}
}
} catch (err) {
console.log(err);
notifyToast('error', err);
}
dispatch(removeProcessingPlaylist(data.id));
};
@ -180,7 +226,7 @@ const PlaylistView = ({ ...rest }) => {
history.push('/playlist');
}
} catch (err) {
console.log(err);
notifyToast('error', err);
}
};
@ -236,18 +282,24 @@ const PlaylistView = ({ ...rest }) => {
/>
<SaveButton
size="lg"
color={isModified ? 'green' : undefined}
text={needsRecovery ? 'Recover playlist' : undefined}
color={
needsRecovery ? 'red' : isModified ? 'green' : undefined
}
disabled={
!isModified ||
(!needsRecovery && !isModified) ||
misc.isProcessingPlaylist.includes(data?.id)
}
loading={misc.isProcessingPlaylist.includes(data?.id)}
onClick={handleSave}
onClick={() => handleSave(needsRecovery)}
/>
<UndoButton
size="lg"
color={isModified ? 'green' : undefined}
color={
needsRecovery ? 'red' : isModified ? 'green' : undefined
}
disabled={
needsRecovery ||
!isModified ||
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 (
<CustomTooltip text="Save" placement="bottom">
<CustomTooltip text={text || 'Save'} placement="bottom">
<StyledIconButton tabIndex={0} icon={<Icon icon="save" />} {...rest} />
</CustomTooltip>
);

16
src/shared/utils.ts

@ -30,6 +30,22 @@ export const getSongCachePath = () => {
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[]) => {
let currentIndex = array.length;
let randomIndex;

Loading…
Cancel
Save