[feat] add support for the 'info.dat' v4 format

This commit is contained in:
MathieuG-P
2024-10-12 18:38:07 +02:00
parent 99d125d35d
commit 5e33efbb06
10 changed files with 230 additions and 59 deletions
@@ -1,6 +1,6 @@
import path from "path";
import { BSVersion } from "shared/bs-version.interface";
import { BsvMapDetail, RawMapInfoData } from "shared/models/maps";
import { BsvMapDetail } from "shared/models/maps";
import { BsmLocalMap, BsmLocalMapsProgress, DeleteMapsProgress } from "shared/models/maps/bsm-local-map.interface";
import { BSLocalVersionService } from "../bs-local-version.service";
import { InstallationLocationService } from "../installation-location.service";
@@ -22,6 +22,7 @@ import { FolderLinkerService } from "../folder-linker.service";
import { allSettled } from "../../../shared/helpers/promise.helpers";
import { splitIntoChunk } from "../../../shared/helpers/array.helpers";
import { IpcService } from "../ipc.service";
import { parseMapInfoDat } from "../../../shared/parsers/maps/map-info.parser";
export class LocalMapsManagerService {
private static instance: LocalMapsManagerService;
@@ -84,27 +85,37 @@ export class LocalMapsManagerService {
}
private async computeMapHash(mapPath: string, rawInfoString: string): Promise<string> {
const mapRawInfo: RawMapInfoData = JSON.parse(rawInfoString);
const mapInfo = parseMapInfoDat(JSON.parse(rawInfoString));
const shasum = crypto.createHash("sha1");
shasum.update(rawInfoString);
const hashFile = (filePath: string): Promise<void> => {
return new Promise<void>((resolve, reject) => {
const stream = createReadStream(filePath);
stream.on("data", data => shasum.update(data));
stream.on("data", (data: Buffer) => shasum.update(data as unknown as Uint8Array));
stream.on("error", reject);
stream.on("close", resolve);
});
};
for (const set of mapRawInfo._difficultyBeatmapSets) {
for (const diff of set._difficultyBeatmaps) {
const diffFilePath = path.join(mapPath, diff._beatmapFilename);
for (const diff of mapInfo.difficulties) {
if(diff.beatmapFilename){
const diffFilePath = path.join(mapPath, diff.beatmapFilename);
await hashFile(diffFilePath);
}
if(diff.lightshowDataFilename) {
const lightshowFilePath = path.join(mapPath, diff.lightshowDataFilename);
await hashFile(lightshowFilePath);
}
}
return shasum.digest("hex");
const hash = shasum.digest("hex");
console.log(mapPath, hash);
return hash;
}
private async loadMapInfoFromPath(mapPath: string): Promise<BsmLocalMap> {
@@ -116,14 +127,14 @@ export class LocalMapsManagerService {
}
const rawInfoString = await readFile(infoFile, { encoding: "utf-8" });
const mapInfo = parseMapInfoDat(JSON.parse(rawInfoString));
const rawInfo: RawMapInfoData = JSON.parse(rawInfoString);
const coverUrl = new URL(`file:///${path.join(mapPath, rawInfo._coverImageFilename)}`).href;
const songUrl = new URL(`file:///${path.join(mapPath, rawInfo._songFilename)}`).href;
const coverUrl = new URL(`file:///${path.join(mapPath, mapInfo.coverImageFilename)}`).href;
const songUrl = new URL(`file:///${path.join(mapPath, mapInfo.songFilename)}`).href;
const hash = await this.computeMapHash(mapPath, rawInfoString);
return { rawInfo, coverUrl, songUrl, hash, path: mapPath };
return { mapInfo, coverUrl, songUrl, hash, path: mapPath };
}
private async downloadMapZip(zipUrl: string): Promise<{ zip: StreamZip.StreamZipAsync; zipPath: string }> {
@@ -147,7 +158,7 @@ export class LocalMapsManagerService {
});
}
public getMaps(version?: BSVersion): Observable<BsmLocalMapsProgress> {
@@ -261,7 +272,7 @@ export class LocalMapsManagerService {
if(!exists){ return null; }
return this.loadMapInfoFromPath(mapPath);
}).catch(() => null);
if(map.versions.every(version => version.hash === installedMap?.hash)) {
return installedMap;
}
@@ -358,7 +358,7 @@ export const LocalMapsListPanel = forwardRef(({ version, className, filter, sear
}
const searchCheck = (() => {
return ((map.rawInfo?._songName ?? map.bsaverInfo?.name) || "")?.toLowerCase().includes(search.toLowerCase()) || ((map.rawInfo?._songAuthorName ?? map.bsaverInfo?.metadata?.songAuthorName) || "")?.toLowerCase().includes(search.toLowerCase()) || ((map.rawInfo?._levelAuthorName ?? map.bsaverInfo?.metadata?.levelAuthorName) || "")?.toLowerCase().includes(search.toLowerCase());
return ((map.mapInfo?.songName ?? map.bsaverInfo?.name) || "")?.toLowerCase().includes(search.toLowerCase()) || ((map.mapInfo?.songAuthorName ?? map.bsaverInfo?.metadata?.songAuthorName) || "")?.toLowerCase().includes(search.toLowerCase()) || ((map.mapInfo?.levelMappers.at(0) ?? map.bsaverInfo?.metadata?.levelAuthorName) || "")?.toLowerCase().includes(search.toLowerCase());
})();
if (!searchCheck) {
@@ -26,43 +26,40 @@ export const MapsRow = memo(({ maps, style, selectedMaps$, onMapSelect, onMapDel
if (map.bsaverInfo?.versions[0]?.diffs) {
map.bsaverInfo.versions[0].diffs.forEach(diff => {
const arr = res.get(diff.characteristic) || [];
const diffName = map.rawInfo._difficultyBeatmapSets.find(set => set._beatmapCharacteristicName === diff.characteristic)._difficultyBeatmaps.find(rawDiff => rawDiff._difficulty === diff.difficulty)?._customData?._difficultyLabel || diff.difficulty;
const diffName = map.mapInfo.difficulties.find(set => set.characteristic === diff.characteristic && set.difficulty === diff.difficulty).difficultyLabel || diff.difficulty;
arr.push({ name: diffName, type: diff.difficulty, stars: diff.stars });
res.set(diff.characteristic, arr);
});
return res;
}
map.rawInfo._difficultyBeatmapSets.forEach(set => {
set._difficultyBeatmaps.forEach(diff => {
const arr = res.get(set._beatmapCharacteristicName) || [];
arr.push({ name: diff._customData?._difficultyLabel || diff._difficulty, type: diff._difficulty, stars: null });
res.set(set._beatmapCharacteristicName, arr);
});
map.mapInfo.difficulties.forEach(diff => {
const arr = res.get(diff.characteristic) || [];
arr.push({ name: diff.difficultyLabel || diff.difficulty, type: diff.difficulty, stars: null });
});
return res;
};
const renderMapItem = (map: BsmLocalMap) => {
return <MapItem
key={map.hash}
hash={map.hash}
title={map.rawInfo._songName}
coverUrl={map.coverUrl}
songUrl={map.songUrl}
autor={map.rawInfo._levelAuthorName}
songAutor={map.rawInfo._songAuthorName}
bpm={map.rawInfo._beatsPerMinute}
duration={map.bsaverInfo?.metadata?.duration}
selected={selectedMaps.some(selected => selected.hash === map.hash)}
diffs={extractMapDiffs(map)} mapId={map.bsaverInfo?.id}
ranked={map.bsaverInfo?.ranked}
autorId={map.bsaverInfo?.uploader?.id}
likes={map.bsaverInfo?.stats?.upvotes}
createdAt={map.bsaverInfo?.createdAt}
onDelete={onMapDelete}
onSelected={onMapSelect}
return <MapItem
key={map.hash}
hash={map.hash}
title={map.mapInfo.songName}
coverUrl={map.coverUrl}
songUrl={map.songUrl}
autor={map.mapInfo.levelMappers.at(0)}
songAutor={map.mapInfo.songAuthorName}
bpm={map.mapInfo.beatsPerMinute}
duration={map.bsaverInfo?.metadata?.duration}
selected={selectedMaps.some(selected => selected.hash === map.hash)}
diffs={extractMapDiffs(map)} mapId={map.bsaverInfo?.id}
ranked={map.bsaverInfo?.ranked}
autorId={map.bsaverInfo?.uploader?.id}
likes={map.bsaverInfo?.stats?.upvotes}
createdAt={map.bsaverInfo?.createdAt}
onDelete={onMapDelete}
onSelected={onMapSelect}
callBackParam={map}
/>;
};
@@ -11,7 +11,7 @@ import BeatConflict from "../../../../../assets/images/apngs/beat-conflict.png";
import { useService } from "renderer/hooks/use-service.hook";
export const DeleteMapsModal: ModalComponent<void, { linked: boolean; maps: BsmLocalMap[] }> = ({ resolver, data: { linked, maps } }) => {
const config = useService(ConfigurationService);
const t = useTranslation();
@@ -34,7 +34,7 @@ export const DeleteMapsModal: ModalComponent<void, { linked: boolean; maps: BsmL
<form className="text-gray-800 dark:text-gray-200">
<h1 className="text-3xl uppercase tracking-wide w-full text-center">{t(titleText)}</h1>
<BsmImage className="mx-auto h-24" image={BeatConflict} />
<p className="max-w-sm w-full">{t(descText, multiple ? { nb: maps.length.toString() } : { name: maps.at(0).rawInfo._songName })}</p>
<p className="max-w-sm w-full">{t(descText, multiple ? { nb: maps.length.toString() } : { name: maps.at(0).mapInfo.songName })}</p>
{linked && (
<p className="text-sm italic mt-2 cursor-help w-fit" title={t(infoTitleText)}>
{t(infoText)}
@@ -1,11 +1,11 @@
import { BsvMapDetail } from "./beat-saver.model";
import { RawMapInfoData } from "./raw-map.model";
import { MapInfo } from "./info/map-info.model";
export interface BsmLocalMap {
hash: string;
coverUrl: string;
songUrl: string;
rawInfo: RawMapInfoData;
mapInfo: MapInfo;
bsaverInfo?: BsvMapDetail;
path: string;
}
-1
View File
@@ -1,2 +1 @@
export { RawMapInfoData, RawMapDifficulty, RawDifficultySet } from "./raw-map.model";
export { BsvInstant, BsvMapDetail, BsvMapDetailMetadata, BsvMapDifficulty, BsvMapParitySummary, BsvMapStats, BsvMapTestplay, BsvMapVersion, BsvUserDetail } from "./beat-saver.model";
@@ -0,0 +1,42 @@
import { BsvMapCharacteristic, BsvMapDifficultyType } from "../beat-saver.model";
import { RawMapInfoDataV2 } from "./raw-map-info-v2.model";
import { RawMapInfoDataV4 } from "./raw-map-info-v4.model";
export type AnyRawMapInfo = RawMapInfoDataV2 | RawMapInfoDataV4;
// interfaces/mapInfo.ts
export interface MapInfo {
version: string;
songName: string;
songSubName?: string;
songAuthorName: string;
levelMappers: string[];
levelLighters: string[];
beatsPerMinute: number;
shuffle?: number;
shufflePeriod?: number;
previewStartTime: number;
previewDuration: number;
songFilename: string;
songPreviewFilename: string;
coverImageFilename: string;
environmentNames: string[];
difficulties: MapDifficulty[];
}
export interface MapDifficulty {
characteristic: BsvMapCharacteristic;
difficulty: BsvMapDifficultyType;
difficultyLabel?: string;
beatmapFilename: string;
noteJumpMovementSpeed: number;
noteJumpStartBeatOffset: number;
beatmapColorSchemeIdx?: number;
environmentNameIdx?: number;
// Additional fields from version 4.0.0
beatmapAuthors?: {
mappers: string[];
lighters: string[];
};
lightshowDataFilename?: string;
}
@@ -1,6 +1,6 @@
import { BsvMapCharacteristic, BsvMapDifficultyType } from "./beat-saver.model";
import { BsvMapCharacteristic, BsvMapDifficultyType } from "../beat-saver.model";
export interface RawMapInfoData<T = unknown> {
export interface RawMapInfoDataV2 {
_version: string;
_songName: string;
_songSubName: string;
@@ -16,24 +16,24 @@ export interface RawMapInfoData<T = unknown> {
_environmentName: string;
_allDirectionsEnvironmentName: string;
_songTimeOffset: number;
_customData: T;
_difficultyBeatmapSets: RawDifficultySet[];
_difficultyBeatmapSets: RawDifficultySetV2[];
// Additional fields for 2.1.0
_environmentNames?: string[];
_colorSchemes?: unknown[];
}
export interface RawDifficultySet {
interface RawDifficultySetV2 {
_beatmapCharacteristicName: BsvMapCharacteristic;
_difficultyBeatmaps: RawMapDifficulty[];
_difficultyBeatmaps: RawMapDifficultyV2[];
}
export interface RawMapDifficulty {
interface RawMapDifficultyV2 {
_difficulty: BsvMapDifficultyType;
_difficultyRank: string;
_difficultyRank: number;
_beatmapFilename: string;
_noteJumpMovementSpeed: number;
_noteJumpStartBeatOffset: number;
_customData?: RawMapDifficultyCustomData;
}
export interface RawMapDifficultyCustomData {
_difficultyLabel?: string;
_beatmapColorSchemeIdx?: number;
_environmentNameIdx?: number;
_customData?: { _difficultyLabel?: string; };
}
@@ -0,0 +1,40 @@
import { BsvMapCharacteristic, BsvMapDifficultyType } from "../beat-saver.model";
// interfaces/version4.ts
export interface RawMapInfoDataV4 {
version: string;
song: {
title: string;
subTitle: string;
author: string;
};
audio: {
songFilename: string;
songDuration: number;
audioDataFilename: string;
bpm: number;
lufs: number;
previewStartTime: number;
previewDuration: number;
};
songPreviewFilename: string;
coverImageFilename: string;
environmentNames: string[];
colorSchemes: unknown[];
difficultyBeatmaps: RawMapDifficultyV4[];
}
interface RawMapDifficultyV4 {
characteristic: BsvMapCharacteristic;
difficulty: BsvMapDifficultyType;
beatmapAuthors: {
mappers: string[];
lighters: string[];
};
environmentNameIdx: number;
beatmapColorSchemeIdx: number;
noteJumpMovementSpeed: number;
noteJumpStartBeatOffset: number;
beatmapDataFilename: string;
lightshowDataFilename: string;
}
@@ -0,0 +1,82 @@
import { MapDifficulty, MapInfo, AnyRawMapInfo } from "shared/models/maps/info/map-info.model";
import { RawMapInfoDataV2 } from "shared/models/maps/info/raw-map-info-v2.model";
import { RawMapInfoDataV4 } from "shared/models/maps/info/raw-map-info-v4.model";
function parseVersion2(data: RawMapInfoDataV2): MapInfo {
return {
version: data._version,
songName: data._songName,
songSubName: data._songSubName,
songAuthorName: data._songAuthorName,
levelMappers: [data._levelAuthorName],
levelLighters: [],
beatsPerMinute: data._beatsPerMinute,
shuffle: data._shuffle,
shufflePeriod: data._shufflePeriod,
previewStartTime: data._previewStartTime,
previewDuration: data._previewDuration,
songFilename: data._songFilename,
songPreviewFilename: data._songFilename,
coverImageFilename: data._coverImageFilename,
environmentNames: data._environmentNames || [data._environmentName],
difficulties: data._difficultyBeatmapSets.flatMap(set =>
set._difficultyBeatmaps.map<MapDifficulty>((diff) => ({
characteristic: set._beatmapCharacteristicName,
difficulty: diff._difficulty,
difficultyLabel: diff._customData?._difficultyLabel,
beatmapFilename: diff._beatmapFilename,
noteJumpMovementSpeed: diff._noteJumpMovementSpeed,
noteJumpStartBeatOffset: diff._noteJumpStartBeatOffset,
beatmapColorSchemeIdx: diff._beatmapColorSchemeIdx,
environmentNameIdx: diff._environmentNameIdx,
}))
),
};
}
function parseVersion4(data: RawMapInfoDataV4): MapInfo {
return {
version: data.version,
songName: data.song.title,
songSubName: data.song.subTitle,
songAuthorName: data.song.author,
levelMappers: data.difficultyBeatmaps.flatMap(diff => diff.beatmapAuthors.mappers),
levelLighters: data.difficultyBeatmaps.flatMap(diff => diff.beatmapAuthors.lighters),
beatsPerMinute: data.audio.bpm,
previewStartTime: data.audio.previewStartTime,
previewDuration: data.audio.previewDuration,
songFilename: data.audio.songFilename,
songPreviewFilename: data.songPreviewFilename,
coverImageFilename: data.coverImageFilename,
environmentNames: data.environmentNames,
difficulties: data.difficultyBeatmaps.map<MapDifficulty>(diff => ({
characteristic: diff.characteristic,
difficulty: diff.difficulty,
beatmapFilename: diff.beatmapDataFilename,
noteJumpMovementSpeed: diff.noteJumpMovementSpeed,
noteJumpStartBeatOffset: diff.noteJumpStartBeatOffset,
beatmapColorSchemeIdx: diff.beatmapColorSchemeIdx,
environmentNameIdx: diff.environmentNameIdx,
beatmapAuthors: diff.beatmapAuthors,
lightshowDataFilename: diff.lightshowDataFilename,
})),
};
}
export function parseMapInfoDat(info: AnyRawMapInfo): MapInfo | never {
const version = (info as RawMapInfoDataV2)?._version || (info as RawMapInfoDataV4)?.version;
if(!version) {
throw new Error('Cannot determine info.dat version');
}
if (version.startsWith('2.')) {
return parseVersion2(info as RawMapInfoDataV2);
}
if (version.startsWith('4.')) {
return parseVersion4(info as RawMapInfoDataV4);
}
throw new Error(`Unsupported info.dat version: ${version}`);
}