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