243 lines
8.9 KiB
JavaScript
243 lines
8.9 KiB
JavaScript
|
import hashApiClient from '../network/clients/beatmaps/api-hash';
|
||
|
import keyApiClient from '../network/clients/beatmaps/api-key';
|
||
|
import {PRIORITY} from '../network/queues/http-queue';
|
||
|
import log from '../utils/logger'
|
||
|
import {SsrHttpNotFoundError, SsrNetworkError} from '../network/errors'
|
||
|
import songsBeatMapsRepository from "../db/repository/songs-beatmaps";
|
||
|
import cacheRepository from "../db/repository/cache";
|
||
|
import {addToDate, dateFromString, HOUR} from '../utils/date'
|
||
|
import {capitalize, opt} from '../utils/js'
|
||
|
|
||
|
const BM_SUSPENSION_KEY = 'bmSuspension';
|
||
|
const BM_NOT_FOUND_KEY = 'bm404';
|
||
|
const BM_NOT_FOUND_HOURS_BETWEEN_COUNTS = 1;
|
||
|
|
||
|
const INVALID_NOTES_COUNT_FIXES = {
|
||
|
'e738b38b594861745bfb0473c66ca5cca15072ff': [
|
||
|
{type: 'Standard', diff: "ExpertPlus", notes: 942}
|
||
|
]
|
||
|
}
|
||
|
|
||
|
export default () => {
|
||
|
const cacheSongInfo = async (songInfo, originalHash) => {
|
||
|
if (!songInfo) return null;
|
||
|
|
||
|
const hash = originalHash && originalHash.length ? originalHash : songInfo.hash;
|
||
|
|
||
|
if (!hash || !songInfo.key) return null;
|
||
|
|
||
|
songInfo.hash = hash.toLowerCase();
|
||
|
songInfo.key = songInfo.key.toLowerCase();
|
||
|
|
||
|
delete songInfo.description;
|
||
|
|
||
|
await songsBeatMapsRepository().set(songInfo);
|
||
|
|
||
|
return songInfo;
|
||
|
}
|
||
|
|
||
|
const isSuspended = bsSuspension => !!bsSuspension && bsSuspension.activeTo > new Date() && bsSuspension.started > addToDate(-24 * HOUR);
|
||
|
const getCurrentSuspension = async () => cacheRepository().get(BM_SUSPENSION_KEY);
|
||
|
const prolongSuspension = async bsSuspension => {
|
||
|
const current = new Date();
|
||
|
|
||
|
const suspension = isSuspended(bsSuspension) ? bsSuspension : {started: current, activeTo: new Date(), count: 0};
|
||
|
|
||
|
suspension.activeTo = addToDate(Math.pow(2, suspension.count) * HOUR, suspension.activeTo);
|
||
|
suspension.count++;
|
||
|
|
||
|
return await cacheRepository().set(suspension, BM_SUSPENSION_KEY);
|
||
|
}
|
||
|
|
||
|
const get404Hashes = async () => cacheRepository().get(BM_NOT_FOUND_KEY);
|
||
|
const set404Hashes = async hashes => cacheRepository().set(hashes, BM_NOT_FOUND_KEY);
|
||
|
const setHashNotFound = async hash => {
|
||
|
let songs404 = await get404Hashes();
|
||
|
if (!songs404) songs404 = {};
|
||
|
|
||
|
const item = songs404[hash] ? songs404[hash] : {firstTry: new Date(), recentTry: null, count: 0};
|
||
|
|
||
|
if (!item.recentTry || addToDate(BM_NOT_FOUND_HOURS_BETWEEN_COUNTS * HOUR, item.recentTry) < new Date()) {
|
||
|
item.recentTry = new Date();
|
||
|
item.count++;
|
||
|
|
||
|
songs404[hash] = item;
|
||
|
|
||
|
await set404Hashes(songs404);
|
||
|
}
|
||
|
}
|
||
|
const isHashUnavailable = async hash => {
|
||
|
const songs404 = await get404Hashes();
|
||
|
return songs404 && songs404[hash] && songs404[hash].count >= 3;
|
||
|
}
|
||
|
|
||
|
const fixInvalidNotesCount = (hash, songInfo) => {
|
||
|
if (!hash) return songInfo;
|
||
|
|
||
|
if (INVALID_NOTES_COUNT_FIXES[hash] && songInfo?.versions)
|
||
|
songInfo.versions.forEach(si => {
|
||
|
if (!si?.diffs) return;
|
||
|
|
||
|
si.diffs.forEach(d => {
|
||
|
const newNotesCnt = INVALID_NOTES_COUNT_FIXES[hash].find(f => f.type === d?.characteristic && f.diff === d?.difficulty);
|
||
|
if (!newNotesCnt) return;
|
||
|
|
||
|
d.notes = newNotesCnt.notes;
|
||
|
})
|
||
|
})
|
||
|
|
||
|
return songInfo;
|
||
|
}
|
||
|
|
||
|
const fetchSong = async (songInfo, fetchFunc, forceUpdate = false, cacheOnly = false, errSongId = '', hash = null) => {
|
||
|
if (!forceUpdate && songInfo) return fixInvalidNotesCount(hash, songInfo);
|
||
|
|
||
|
if(cacheOnly) return null;
|
||
|
|
||
|
let bsSuspension = await getCurrentSuspension();
|
||
|
|
||
|
try {
|
||
|
if (isSuspended(bsSuspension) || (hash && await isHashUnavailable(hash))) return null;
|
||
|
|
||
|
const songInfo = await fetchFunc();
|
||
|
if (!songInfo) {
|
||
|
log.warn(`Song "${errSongId}" is no longer available at BeatSaver.`);
|
||
|
return null;
|
||
|
}
|
||
|
|
||
|
return fixInvalidNotesCount(hash, cacheSongInfo(songInfo, hash));
|
||
|
} catch (err) {
|
||
|
if (hash && err instanceof SsrHttpNotFoundError) {
|
||
|
await setHashNotFound(hash);
|
||
|
}
|
||
|
|
||
|
if (err instanceof SsrNetworkError && err.message === 'Network error') {
|
||
|
try {await prolongSuspension(bsSuspension)} catch {}
|
||
|
}
|
||
|
|
||
|
log.warn(`Error fetching BeatSaver song "${errSongId}"`);
|
||
|
|
||
|
return null;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
const byHash = async (hash, forceUpdate = false, cacheOnly = false, signal = null, priority = PRIORITY.FG_LOW) => {
|
||
|
hash = hash.toLowerCase();
|
||
|
|
||
|
const songInfo = await songsBeatMapsRepository().get(hash);
|
||
|
|
||
|
return fetchSong(songInfo, () => hashApiClient.getProcessed({hash, signal, priority}), forceUpdate, cacheOnly, hash, hash)
|
||
|
}
|
||
|
|
||
|
const byKey = async (key, forceUpdate = false, cacheOnly = false, signal = null, priority = PRIORITY.FG_LOW) => {
|
||
|
key = key.toLowerCase();
|
||
|
|
||
|
const songInfo = await songsBeatMapsRepository().getFromIndex('songs-beatmaps-key', key);
|
||
|
|
||
|
return fetchSong(songInfo, () => keyApiClient.getProcessed({key, signal, priority}), forceUpdate, cacheOnly, key)
|
||
|
}
|
||
|
|
||
|
const convertOldBeatSaverToBeatMaps = song => {
|
||
|
let {key, hash, name, metadata: {characteristics}} = song;
|
||
|
|
||
|
if (!key || !hash || !name || !characteristics || !Array.isArray(characteristics)) return null;
|
||
|
|
||
|
if (hash.toLowerCase) hash = hash.toLowerCase();
|
||
|
|
||
|
const diffs = characteristics.reduce((diffs, ch) => {
|
||
|
if (!ch.name || !ch.difficulties) return diffs;
|
||
|
const characteristic = ch.name;
|
||
|
|
||
|
return diffs.concat(
|
||
|
Object.entries(ch.difficulties)
|
||
|
.map(([difficulty, obj]) => {
|
||
|
if (!obj) return null;
|
||
|
difficulty = capitalize(difficulty);
|
||
|
|
||
|
const seconds = opt(obj, 'length', null);
|
||
|
const notes = opt(obj, 'notes', null)
|
||
|
|
||
|
const nps = notes && seconds ? notes / seconds : null;
|
||
|
|
||
|
return {
|
||
|
njs: opt(obj, 'njs', null),
|
||
|
offset: opt(obj, 'njsOffset', null),
|
||
|
notes,
|
||
|
bombs: opt(obj, 'bombs', null),
|
||
|
obstacles: opt(obj, 'obstacles', null),
|
||
|
nps,
|
||
|
length: opt(obj, 'duration', null),
|
||
|
characteristic,
|
||
|
difficulty,
|
||
|
events: null,
|
||
|
chroma: null,
|
||
|
me: null,
|
||
|
ne: null,
|
||
|
cinema: null,
|
||
|
seconds,
|
||
|
paritySummary: {
|
||
|
errors: null,
|
||
|
warns: null,
|
||
|
resets: null,
|
||
|
},
|
||
|
stars: null,
|
||
|
};
|
||
|
}))
|
||
|
.filter(diff => diff)
|
||
|
}, []);
|
||
|
|
||
|
return {
|
||
|
lastUpdated: dateFromString(opt(song, 'uploaded', new Date())),
|
||
|
oldBeatSaverId: opt(song, '_id', null),
|
||
|
id: key,
|
||
|
hash,
|
||
|
key,
|
||
|
name,
|
||
|
description: '',
|
||
|
uploader: {
|
||
|
id: null,
|
||
|
name: opt(song, 'uploader.username', null),
|
||
|
hash: null,
|
||
|
avatar: null
|
||
|
},
|
||
|
metadata: {
|
||
|
bpm: opt(song, 'metadata.bpm', null),
|
||
|
duration: opt(song, 'metadata.duration', null),
|
||
|
songName: opt(song, 'metadata.songName', ''),
|
||
|
songSubName: opt(song, 'metadata.songSubName', ''),
|
||
|
songAuthorName: opt(song, 'metadata.songAuthorName', ''),
|
||
|
levelAuthorName: opt(song, 'metadata.levelAuthorName', '')
|
||
|
},
|
||
|
stats: {
|
||
|
plays: opt(song, 'stats.plays', 0),
|
||
|
downloads: opt(song, 'stats.downloads', 0),
|
||
|
upvotes: opt(song, 'stats.upVotes', 0),
|
||
|
downvotes: opt(song, 'stats.downVotes', 0),
|
||
|
score: null
|
||
|
},
|
||
|
uploaded: opt(song, 'uploaded', null),
|
||
|
automapper: !!opt(song, 'metadata.automapper', false),
|
||
|
ranked: null,
|
||
|
qualified: null,
|
||
|
versions: [
|
||
|
{
|
||
|
hash,
|
||
|
key,
|
||
|
state: "Published",
|
||
|
createdAt: opt(song, 'uploaded', null),
|
||
|
sageScore: null,
|
||
|
diffs,
|
||
|
downloadURL: `https://cdn.beatsaver.com/${hash}.zip`,
|
||
|
coverURL: `https://cdn.beatsaver.com/${hash}.jpg`,
|
||
|
previewURL: `https://cdn.beatsaver.com/${hash}.mp3`
|
||
|
}
|
||
|
]
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return {
|
||
|
byHash,
|
||
|
byKey,
|
||
|
convertOldBeatSaverToBeatMaps
|
||
|
}
|
||
|
}
|