|
@ -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) |
|
|
} |
|
|
} |
|
|