diff --git a/package-lock.json b/package-lock.json index d63fee3..295372a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "beatdrop", - "version": "2.5.2", + "version": "2.5.5", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 8b72e87..74748d0 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "beatdrop", "description": "A desktop app for downloading Beat Saber songs.", "author": "Nathaniel Johns (StarGazer1258)", - "version": "2.5.2", + "version": "2.5.5", "private": false, "license": "CC-BY-NC-SA-4.0", "repository": { diff --git a/src/actions/detailsActions.js b/src/actions/detailsActions.js index 22c4dab..6d16c41 100644 --- a/src/actions/detailsActions.js +++ b/src/actions/detailsActions.js @@ -2,6 +2,7 @@ import { LOAD_DETAILS, CLEAR_DETAILS, SET_DETAILS_LOADING, SET_VIEW, DISPLAY_WAR import { SONG_DETAILS } from '../views' import AdmZip from 'adm-zip' +import { hashAndWriteToMetadata } from './queueActions' const { remote } = window.require('electron') const fs = remote.require('fs') const path = remote.require('path') @@ -10,7 +11,7 @@ const path = remote.require('path') * Loads and presents the details page for a song from a file. * @param {string} file The path to the song */ -export const loadDetailsFromFile = file => dispatch => { +export const loadDetailsFromFile = file => (dispatch, getState) => { dispatch({ type: CLEAR_DETAILS }) @@ -27,19 +28,23 @@ export const loadDetailsFromFile = file => dispatch => { let details = JSON.parse(data) let dir = path.dirname(file) details.coverURL = `file://${ path.join(dir, (details.coverImagePath || details._coverImageFilename)) }` - details.file = path.join(dir, 'info.json' || 'info.dat') - dispatch({ - type: LOAD_DETAILS, - payload: details - }) - dispatch({ - type: LOAD_DETAILS, - payload: { audioSource: `file://${ path.join(dir, details._songFilename) }` } - }) - dispatch({ - type: SET_DETAILS_LOADING, - payload: false - }) + details.file = path.join(dir, 'info.dat') + hashAndWriteToMetadata(path.join(dir, 'info.dat'))(dispatch, getState) + .then(hash => { + details.hash = hash + dispatch({ + type: LOAD_DETAILS, + payload: details + }) + dispatch({ + type: LOAD_DETAILS, + payload: { audioSource: `file://${ path.join(dir, details._songFilename) }` } + }) + dispatch({ + type: SET_DETAILS_LOADING, + payload: false + }) + }) }) } diff --git a/src/actions/playlistsActions.js b/src/actions/playlistsActions.js index 4062574..68cdc00 100644 --- a/src/actions/playlistsActions.js +++ b/src/actions/playlistsActions.js @@ -1,11 +1,11 @@ import { FETCH_LOCAL_PLAYLISTS, LOAD_NEW_PLAYLIST_IMAGE, SET_NEW_PLAYLIST_OPEN, SET_PLAYLIST_PICKER_OPEN, CLEAR_PLAYLIST_DIALOG, LOAD_PLAYLIST_DETAILS, LOAD_PLAYLIST_SONGS, CLEAR_PLAYLIST_DETAILS, SET_PLAYLIST_EDITING, SET_VIEW, SET_LOADING, DISPLAY_WARNING } from './types' import { PLAYLIST_LIST, PLAYLIST_DETAILS } from '../views' import { defaultPlaylistIcon } from '../b64Assets' +import { hashAndWriteToMetadata } from './queueActions'; const { remote } = window.require('electron') const fs = remote.require('fs') const path = remote.require('path') -const md5 = remote.require('md5') export const fetchLocalPlaylists = (doSetView) => (dispatch, getState) => { let state = getState() @@ -313,51 +313,19 @@ export const addSongToPlaylist = (song, playlistFile) => (dispatch, getState) => return } let playlist = JSON.parse(data) - if(song.hash || song.hashMd5) { - if(song.key) { - playlist.songs.push({ - hash: song.hash || song.hashMd5, - key: song.key, - songName: song.name || song.songName - }) - } else { - playlist.songs.push({ - hash: song.hash || song.hashMd5, - songName: song.name || song.songName - }) - } + if(song.hash) { + playlist.songs.push({ + hash: song.hash, + songName: song.name || song._songName + }) } else { - if(song.file) { - let file = song.file - delete song.file - let to_hash = '' - for(let i = 0; i < song.difficultyLevels.length; i++) { - try { - to_hash += fs.readFileSync(path.join(path.dirname(file), song.difficultyLevels[i].jsonPath), 'UTF8') - } catch(err) { - dispatch({ - type: DISPLAY_WARNING, - action: { text: 'Error reading difficulty level information, the song\'s files may be corrupt. Try redownloading the song and try again.' } - }) - return - } - } - let hash = md5(to_hash) - song.hash = hash - fs.writeFile(file, JSON.stringify(song), 'UTF8', (err) => { if(err) return }) - if(song.key) { - playlist.songs.push({ - hash: hash, - key: song.key, - songName: song.name || song.songName - }) - } else { + hashAndWriteToMetadata(song.file)(dispatch, getState) + .then(hash => { playlist.songs.push({ - hash: hash, - songName: song.name || song.songName + hash, + songName: song.name || song._songName }) - } - } + }) } fs.writeFile(playlistFile, JSON.stringify(playlist), 'UTF8', (err) => { diff --git a/src/actions/queueActions.js b/src/actions/queueActions.js index 720f9ad..432690c 100644 --- a/src/actions/queueActions.js +++ b/src/actions/queueActions.js @@ -5,7 +5,7 @@ import { isModInstalled, installEssentialMods } from './modActions'; const { remote } = window.require('electron') const fs = remote.require('fs') const path = remote.require('path') -const md5 = remote.require('md5') +const crypto = remote.require('crypto') const AdmZip = remote.require('adm-zip') const request = remote.require('request') const rimraf = remote.require('rimraf') @@ -85,26 +85,12 @@ export const downloadSong = (identity) => (dispatch, getState) => { } let zip = new AdmZip(data) let zipEntries = zip.getEntries() - let infoEntry, infoObject + let infoEntry for(let i = 0; i < zipEntries.length; i++) { - if(zipEntries[i].entryName.substr(zipEntries[i].entryName.length - 9, 9) === 'info.json' || zipEntries[i].entryName.substr(zipEntries[i].entryName.length - 8, 8) === 'info.dat') { + if(zipEntries[i].entryName.split(path.sep).pop() === 'info.dat') { infoEntry = zipEntries[i] } } - try { - infoObject = JSON.parse(infoEntry.getData().toString('UTF8')) - } catch(err) { - dispatch({ - type: DISPLAY_WARNING, - payload: { - text: `There was an error unpacking the song "${song.name}." The song's files may be corrupt or use formatting other than UTF-8 (Why UTF-8? The IETF says so! https://tools.ietf.org/html/rfc8259#section-8.1). Please try again and contact the song's uploader, ${song.uploader.username}, if problem persists.` - } - }) - return - } - infoObject.key = song.key - infoObject.hash = hash - zip.updateFile(infoEntry.entryName, JSON.stringify(infoObject)) let extractTo switch(getState().settings.folderStructure) { case 'keySongNameArtistName': @@ -121,6 +107,42 @@ export const downloadSong = (identity) => (dispatch, getState) => { break } zip.extractAllTo(path.join(getState().settings.installationDirectory, 'Beat Saber_Data', 'CustomLevels', extractTo)) + let metadataFile = path.join(getState().settings.installationDirectory, 'Beat Saber_Data', 'CustomLevels', extractTo, 'metadata.dat') + fs.access(metadataFile, accessErr => { + if(accessErr) { + fs.writeFile(metadataFile, JSON.stringify({ key: song.key, hash: song.hash, downloadTime: utc }), err => { + if(err) { + dispatch({ + type: DISPLAY_WARNING, + payload: { + text: `Failed to write metadata file for ${ song.name }. Go to settings and press "Scan for Songs" to try again.` + } + }) + } + }) + return + } + fs.readFile(metadataFile, 'UTF-8', (err, metadata) => { + if(err) { + dispatch({ + type: DISPLAY_WARNING, + payload: { + text: `Failed to read metadata file for ${ song.name }. Go to settings and press "Scan for Songs" to try again.` + } + }) + } + fs.writeFile(metadataFile, JSON.stringify({ ...JSON.parse(metadata), key: song.key, hash: song.hash, downloadTime: utc }), err => { + if(err) { + dispatch({ + type: DISPLAY_WARNING, + payload: { + text: `Failed to write metadata file for ${ song.name }. Go to settings and press "Scan for Songs" to try again.` + } + }) + } + }) + }) + }) dispatch({ type: SET_DOWNLOADED_SONGS, payload: [...getState().songs.downloadedSongs, { hash, file: path.join(getState().settings.installationDirectory, 'Beat Saber_Data', 'CustomLevels', extractTo, infoEntry.entryName) }] @@ -393,6 +415,65 @@ export const deleteSong = (identity) => (dispatch, getState) => { }) } +export const hashAndWriteToMetadata = (infoFile) => dispatch => { + return new Promise((resolve, reject) => { + let metadataFile = path.join(path.dirname(infoFile), 'metadata.dat') + fs.readFile(infoFile, { encoding: 'UTF-8' }, (infoReadErr, infoData) => { // Read the info.dat file + if(infoReadErr) { + dispatch({ + type: DISPLAY_WARNING, + payload: { + text: `Failed to read info file ${ infoFile }. Go to settings and press "Scan for Songs" to try again.` + } + }) + reject(infoReadErr) + } + let song = JSON.parse(infoData), + dataToHash = '', + fileToHash, + hash + fs.readFile(metadataFile, 'UTF-8', (readMetadataErr, metadataData) => { + if(readMetadataErr || !JSON.parse(metadataData).hasOwnProperty('hash')) { + try { + dataToHash += infoData + for(let set = 0; set < song._difficultyBeatmapSets.length; set++) { + for (let map = 0; map < song._difficultyBeatmapSets[set]._difficultyBeatmaps.length; map++) { + fileToHash = path.join(path.dirname(infoFile), song._difficultyBeatmapSets[set]._difficultyBeatmaps[map]._beatmapFilename) + dataToHash += fs.readFileSync(fileToHash, 'UTF8') + } + } + hash = crypto.createHash('sha1') // Calculate song hash + .update(dataToHash) + .digest('hex') + } catch(hashingErr) { + dispatch({ + type: DISPLAY_WARNING, + payload: { + text: `Failed to calculate hash: ${ fileToHash } could not be accessed.` + } + }) + reject(hashingErr) + } + fs.writeFile(metadataFile, JSON.stringify({ ...(readMetadataErr ? {} : JSON.parse(metadataData)), hash: (readMetadataErr ? hash : JSON.parse(metadataData).hash), scannedTime: Date.now() }), writeErr => { // Save metadata.dat file + if(writeErr) { + dispatch({ + type: DISPLAY_WARNING, + payload: { + text: `Failed to write metadata file for ${ song.name }. Go to settings and press "Scan for Songs" to try again.` + } + }) + reject(writeErr) + } + }) + } else { + hash = JSON.parse(metadataData).hash + } + resolve(hash) + }) + }) + }) +} + export const checkDownloadedSongs = () => (dispatch, getState) => { let discoveredFiles = 0, processedFiles = 0 dispatch({ @@ -403,7 +484,7 @@ export const checkDownloadedSongs = () => (dispatch, getState) => { type: SET_PROCESSED_FILES, payload: processedFiles }) - const walk = function(pathName, cb) { + function walk(pathName, cb) { let songs = [] fs.readdir(pathName, (err, files) => { if(err) return cb(err) @@ -427,55 +508,37 @@ export const checkDownloadedSongs = () => (dispatch, getState) => { if(!--pending) cb(null, songs) }) } else { - if(files[i].toLowerCase() === 'info.dat' || files[i].toLowerCase() === 'info.json') { - fs.readFile(file, { encoding: 'UTF-8' }, (err, data) => { - if(err) return cb(err) - let song = JSON.parse(data) - if(song.hasOwnProperty('hash')) { - songs.push({ hash: song.hash, file }) - dispatch({ - type: SET_PROCESSED_FILES, - payload: ++processedFiles - }) - if(!--pending) cb(null, songs) - } else { - let to_hash = '' - try { - for (let i = 0; i < song.difficultyLevels.length; i++) { - to_hash += fs.readFileSync(path.join(path.dirname(file), song.difficultyLevels[i].jsonPath), 'UTF8') - } - let hash = md5(to_hash) - song.hash = hash - fs.writeFile(file, JSON.stringify(song), 'UTF8', (err) => { if(err) return }) + switch(files[i].toLowerCase()) { + case 'info.dat': // In case of an info file + hashAndWriteToMetadata(file)(dispatch, getState) + .then(hash => { songs.push({ hash, file }) - } catch(err) { dispatch({ - type: DISPLAY_WARNING, - payload: { - text: `Failed to generate hash: a file could not be accessed.` - } + type: SET_PROCESSED_FILES, + payload: ++processedFiles }) - } - dispatch({ - type: SET_PROCESSED_FILES, - payload: ++processedFiles + if(!--pending) cb(null, songs) + }, _ => { + dispatch({ + type: SET_PROCESSED_FILES, + payload: ++processedFiles + }) + if(!--pending) cb(null, songs) }) - if(!--pending) cb(null, songs) - } - }) - } else { - dispatch({ - type: SET_PROCESSED_FILES, - payload: ++processedFiles - }) - if(!--pending) cb(null, songs) + break + default: + dispatch({ + type: SET_PROCESSED_FILES, + payload: ++processedFiles + }) + if(!--pending) cb(null, songs) + break } } }) } }) } - dispatch({ type: SET_SCANNING_FOR_SONGS, payload: true @@ -483,18 +546,18 @@ export const checkDownloadedSongs = () => (dispatch, getState) => { walk(path.join(getState().settings.installationDirectory, 'Beat Saber_Data', 'CustomLevels'), (err, songs) => { if (err) { dispatch({ + type: DISPLAY_WARNING, + payload: { + text: `Could not find CustomLevels folder. Please make sure you have your installation directory and type are set correctly.` + } + }) + fs.mkdir(path.join(getState().settings.installationDirectory, 'Beat Saber_Data', 'CustomLevels'), () => { + dispatch({ type: DISPLAY_WARNING, payload: { - text: `Could not find Custom Levels directory. Please make sure you have your installation directory and type are set correctly.` + text: `Attempted to create CustomLevels folder and failed, likely due to permissions.` } - }) - fs.mkdir(path.join(getState().settings.installationDirectory, 'Beat Saber_Data', 'CustomLevels'), () => { - dispatch({ - type: DISPLAY_WARNING, - payload: { - text: `Attempted to create Custom levels folder and failed, likely due to permissions.` - } - }) + }) }) } dispatch({ diff --git a/src/actions/songListActions.js b/src/actions/songListActions.js index 93ff542..0bc928e 100644 --- a/src/actions/songListActions.js +++ b/src/actions/songListActions.js @@ -1,6 +1,7 @@ import { FETCH_NEW, FETCH_TOP_DOWNLOADS, FETCH_TOP_FINISHED, FETCH_LOCAL_SONGS, ADD_BSABER_RATING, SET_SCROLLTOP, SET_LOADING, SET_LOADING_MORE, LOAD_MORE, SET_RESOURCE, SET_VIEW, DISPLAY_WARNING } from './types' import { SONG_LIST } from '../views' import { BEATSAVER, LIBRARY } from '../constants/resources' +import { hashAndWriteToMetadata } from './queueActions'; const { remote } = window.require('electron') const fs = remote.require('fs') @@ -178,29 +179,43 @@ export const fetchLocalSongs = () => (dispatch, getState) => { for(let i = 0; i < downloadedSongs.length; i++) { fs.readFile(downloadedSongs[i].file, 'UTF-8', (err, data) => { if(err) { + dispatch({ + type: DISPLAY_WARNING, + payload: { + text: `Failed to read ${ downloadedSongs[i].file }: ${ err }` + } + }) return } let song try { song = JSON.parse(data) } catch(err) { + dispatch({ + type: DISPLAY_WARNING, + payload: { + text: `Failed to parse song: ${ err }` + } + }) return } song.coverUrl = `file://${ path.join(path.dirname(downloadedSongs[i].file), (song.coverImagePath || song._coverImageFilename)) }` song.file = downloadedSongs[i].file - songs.push(song) - console.log(song) - if(i >= downloadedSongs.length - 1) { - console.log('Bam!') - dispatch({ - type: FETCH_LOCAL_SONGS, - payload: songs - }) - dispatch({ - type: SET_LOADING, - payload: false + hashAndWriteToMetadata(downloadedSongs[i].file)(dispatch, getState) + .then(hash => { + song.hash = hash + songs.push(song) + if(i >= downloadedSongs.length - 1) { + dispatch({ + type: FETCH_LOCAL_SONGS, + payload: songs + }) + dispatch({ + type: SET_LOADING, + payload: false + }) + } }) - } }) } } diff --git a/src/components/PlaylistPicker.js b/src/components/PlaylistPicker.js new file mode 100644 index 0000000..72a5287 --- /dev/null +++ b/src/components/PlaylistPicker.js @@ -0,0 +1,58 @@ +import React, { Component } from 'react' +import '../css/PlaylistPicker.scss' + +import Button from './Button' + +import addIcon from '../assets/add-filled.png' +import { defaultPlaylistIcon } from '../b64Assets' + +import { connect } from 'react-redux' +import { setPlaylistPickerOpen, setNewPlaylistDialogOpen, clearPlaylistDialog, createNewPlaylist, addSongToPlaylist, loadPlaylistCoverImage } from '../actions/playlistsActions' + +class PlaylistPicker extends Component { + render() { + return ( + <> +
+
+

Add to playlist:

+
+ {this.props.playlists.map((playlist, i) => { + return
{ this.props.addSongToPlaylist(this.props.song, playlist.file); this.props.setPlaylistPickerOpen(false) } }>
{playlist.playlistTitle}
{playlist.playlistAuthor}
{playlist.songs.length} Songs
+ })} +
{ this.props.setNewPlaylistDialogOpen(true); this.props.setPlaylistPickerOpen(false) } }>
Create New
+
+
+
+
+
+
+

New Playlist

+ + + {this.props.loadPlaylistCoverImage(e.target.files[0].path || this.props.settings.newCoverImageSource)} } />
+
+
+ +

+ +

+ +