emoemu 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +77 -0
- package/.node-version +1 -0
- package/CLAUDE.md +435 -0
- package/README.md +404 -0
- package/TODO.md +655 -0
- package/dist/index.cjs +25108 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +25085 -0
- package/docs/building-libretro-cores-arm-mac.md +237 -0
- package/docs/config-file-format.md +488 -0
- package/docs/cores-trd.md +425 -0
- package/docs/headless-hardware-rendering-trd.md +676 -0
- package/docs/libretro-cores-trd.md +997 -0
- package/docs/mupen64-software-rendering-trd.md +751 -0
- package/docs/n64-support-trd.md +306 -0
- package/docs/native-rendering-trd.md +540 -0
- package/docs/native-ui-rendering-trd.md +1195 -0
- package/docs/netplay-trd.md +665 -0
- package/docs/retroarch-netplay-docs.md +277 -0
- package/docs/save-state-format.md +666 -0
- package/eslint.config.js +111 -0
- package/icon/icon.png +0 -0
- package/icon/icon.pxd +0 -0
- package/package.json +63 -0
- package/pnpm-workspace.yaml +10 -0
- package/src/Emulator/consts.ts +14 -0
- package/src/Emulator/index.ts +2496 -0
- package/src/Emulator/saveState/index.ts +155 -0
- package/src/Emulator/screenshot/index.ts +160 -0
- package/src/Emulator/terminalDimensions/index.ts +79 -0
- package/src/Emulator/types.ts +83 -0
- package/src/cli/commands/consts.ts +10 -0
- package/src/cli/commands/index.ts +462 -0
- package/src/cli/parseArgs/consts.ts +17 -0
- package/src/cli/parseArgs/index.ts +457 -0
- package/src/cli/parseArgs/types.ts +61 -0
- package/src/cli/runEmulator/index.ts +406 -0
- package/src/cli/runEmulator/types.ts +7 -0
- package/src/consts.ts +19 -0
- package/src/core/button/consts.ts +35 -0
- package/src/core/button/index.ts +123 -0
- package/src/core/core.ts +300 -0
- package/src/core/index.ts +19 -0
- package/src/cores/libretro/api/index.ts +334 -0
- package/src/cores/libretro/api/types.ts +148 -0
- package/src/cores/libretro/callbacks/consts.ts +41 -0
- package/src/cores/libretro/callbacks/index.ts +456 -0
- package/src/cores/libretro/consts.ts +45 -0
- package/src/cores/libretro/coreOptions/consts.ts +36 -0
- package/src/cores/libretro/coreOptions/index.ts +222 -0
- package/src/cores/libretro/environment/consts.ts +118 -0
- package/src/cores/libretro/environment/index.ts +1095 -0
- package/src/cores/libretro/index.ts +937 -0
- package/src/cores/libretro/loader/index.ts +496 -0
- package/src/cores/libretro/pixelFormat/consts.ts +43 -0
- package/src/cores/libretro/pixelFormat/index.ts +397 -0
- package/src/cores/libretro/types.ts +339 -0
- package/src/frontend/AudioManager/index.ts +420 -0
- package/src/frontend/SettingsManager/index.ts +250 -0
- package/src/frontend/config/index.ts +608 -0
- package/src/frontend/config/tests.ts +354 -0
- package/src/frontend/config/types.ts +36 -0
- package/src/frontend/consts.ts +114 -0
- package/src/frontend/coreBuilder/index.ts +644 -0
- package/src/frontend/coreBuilder/types.ts +15 -0
- package/src/frontend/coreDownloader/index.ts +620 -0
- package/src/frontend/coreDownloader/types.ts +17 -0
- package/src/frontend/corePreferences/index.ts +69 -0
- package/src/frontend/coreRegistry/index.ts +276 -0
- package/src/frontend/directoryCache/index.ts +75 -0
- package/src/frontend/index.ts +79 -0
- package/src/frontend/notifications/consts.ts +14 -0
- package/src/frontend/notifications/index.ts +250 -0
- package/src/frontend/playlist/consts.ts +168 -0
- package/src/frontend/playlist/index.ts +899 -0
- package/src/frontend/playlist/labelFormatter/consts.ts +15 -0
- package/src/frontend/playlist/labelFormatter/index.ts +155 -0
- package/src/frontend/playlist/labelFormatter/tests.ts +153 -0
- package/src/frontend/playlist/reader/index.ts +559 -0
- package/src/frontend/playlist/sync/index.ts +511 -0
- package/src/frontend/playlist/systemLookup/index.ts +233 -0
- package/src/frontend/playlist/utils/index.ts +50 -0
- package/src/frontend/romScanner/consts.ts +348 -0
- package/src/frontend/romScanner/index.ts +1957 -0
- package/src/frontend/saveServices/consts.ts +2 -0
- package/src/frontend/saveServices/index.ts +313 -0
- package/src/frontend/serviceProvider/index.ts +108 -0
- package/src/frontend/serviceProvider/types.ts +13 -0
- package/src/index.ts +428 -0
- package/src/input/Controller/consts.ts +50 -0
- package/src/input/Controller/index.ts +81 -0
- package/src/input/GamepadManager/consts.ts +22 -0
- package/src/input/GamepadManager/index.ts +418 -0
- package/src/input/InputManager/consts.ts +86 -0
- package/src/input/InputManager/index.ts +593 -0
- package/src/input/InputMapper/consts.ts +33 -0
- package/src/input/InputMapper/index.ts +436 -0
- package/src/input/consts.ts +410 -0
- package/src/input/gamepadProfiles/index.ts +1100 -0
- package/src/input/index.ts +38 -0
- package/src/input/inputUtils/index.ts +31 -0
- package/src/netplay/FrameBuffer/consts.ts +2 -0
- package/src/netplay/FrameBuffer/index.ts +364 -0
- package/src/netplay/FrameBuffer/tests.ts +286 -0
- package/src/netplay/InputBuffer/consts.ts +7 -0
- package/src/netplay/InputBuffer/index.ts +347 -0
- package/src/netplay/InputBuffer/tests.ts +166 -0
- package/src/netplay/NetplayClient/index.ts +976 -0
- package/src/netplay/NetplayConnection/index.ts +536 -0
- package/src/netplay/NetplayDiscovery/consts.ts +41 -0
- package/src/netplay/NetplayDiscovery/index.ts +525 -0
- package/src/netplay/NetplayServer/index.ts +1407 -0
- package/src/netplay/SyncManager/index.ts +984 -0
- package/src/netplay/SyncManager/tests.ts +419 -0
- package/src/netplay/consts.ts +371 -0
- package/src/netplay/crc32/consts.ts +14 -0
- package/src/netplay/crc32/index.ts +97 -0
- package/src/netplay/crc32/tests.ts +40 -0
- package/src/netplay/index.ts +41 -0
- package/src/netplay/netplayLogger/consts.ts +30 -0
- package/src/netplay/netplayLogger/index.ts +345 -0
- package/src/netplay/protocol/consts.ts +86 -0
- package/src/netplay/protocol/index.ts +1280 -0
- package/src/netplay/protocol/tests.ts +606 -0
- package/src/netplay/protocol/types.ts +20 -0
- package/src/netplay/types.ts +395 -0
- package/src/rendering/KittyRenderer/index.ts +616 -0
- package/src/rendering/NativeRenderer/index.ts +279 -0
- package/src/rendering/NativeRenderer/tests.ts +133 -0
- package/src/rendering/TerminalRenderer/index.ts +770 -0
- package/src/rendering/consts.ts +401 -0
- package/src/rendering/fonts/CozetteVector.ttf +0 -0
- package/src/rendering/index.ts +26 -0
- package/src/rendering/nativeUi/NativeWindowManager/index.ts +158 -0
- package/src/rendering/nativeUi/NativeWindowManager/tests.ts +81 -0
- package/src/rendering/nativeUi/consts.ts +6 -0
- package/src/rendering/nativeUi/index.ts +20 -0
- package/src/rendering/postProcessing/consts.ts +38 -0
- package/src/rendering/postProcessing/index.ts +923 -0
- package/src/rendering/shared/ansi/consts.ts +12 -0
- package/src/rendering/shared/ansi/index.ts +104 -0
- package/src/rendering/shared/consts.ts +2 -0
- package/src/rendering/shared/fitToTerminal/index.ts +67 -0
- package/src/ui/AddRomsPrompt/consts.ts +13 -0
- package/src/ui/AddRomsPrompt/index.tsx +781 -0
- package/src/ui/App/consts.ts +2 -0
- package/src/ui/App/index.tsx +456 -0
- package/src/ui/AppCapabilities/index.tsx +67 -0
- package/src/ui/ConfigContext/index.tsx +56 -0
- package/src/ui/CoreManager/consts.ts +11 -0
- package/src/ui/CoreManager/index.tsx +779 -0
- package/src/ui/CoreSelector/consts.ts +2 -0
- package/src/ui/CoreSelector/index.tsx +251 -0
- package/src/ui/DialogContainer/index.tsx +42 -0
- package/src/ui/DialogOptionsList/index.tsx +61 -0
- package/src/ui/DuplicateCrcPrompt/consts.ts +5 -0
- package/src/ui/DuplicateCrcPrompt/index.tsx +146 -0
- package/src/ui/GamepadContext/consts.ts +15 -0
- package/src/ui/GamepadContext/index.tsx +295 -0
- package/src/ui/NativeDialog/index.tsx +120 -0
- package/src/ui/NetplayDisconnectedDialog/index.tsx +93 -0
- package/src/ui/NetplayPauseMenu/consts.ts +2 -0
- package/src/ui/NetplayPauseMenu/index.tsx +97 -0
- package/src/ui/RomBrowser/NetplayPanel/consts.ts +24 -0
- package/src/ui/RomBrowser/NetplayPanel/index.tsx +520 -0
- package/src/ui/RomBrowser/SettingsPanel/index.tsx +478 -0
- package/src/ui/RomBrowser/consts.ts +61 -0
- package/src/ui/RomBrowser/index.tsx +1164 -0
- package/src/ui/RomBrowser/settingsConfig/index.ts +320 -0
- package/src/ui/RomBrowser/types.ts +67 -0
- package/src/ui/SaveStateDialog/consts.ts +2 -0
- package/src/ui/SaveStateDialog/index.tsx +225 -0
- package/src/ui/WarningDialog/index.tsx +113 -0
- package/src/ui/consts.ts +27 -0
- package/src/ui/hooks/useClearTerminal/consts.ts +2 -0
- package/src/ui/hooks/useClearTerminal/index.ts +37 -0
- package/src/ui/hooks/useDialogNavigation/index.ts +99 -0
- package/src/ui/hooks/useGamepad/consts.ts +21 -0
- package/src/ui/hooks/useGamepad/index.ts +194 -0
- package/src/ui/index.ts +27 -0
- package/src/utils/buffer/consts.ts +17 -0
- package/src/utils/buffer/index.ts +129 -0
- package/src/utils/color/consts.ts +58 -0
- package/src/utils/color/index.ts +183 -0
- package/src/utils/compression/consts.ts +50 -0
- package/src/utils/compression/index.ts +101 -0
- package/src/utils/consts.ts +2 -0
- package/src/utils/crc32/consts.ts +22 -0
- package/src/utils/crc32/index.ts +83 -0
- package/src/utils/ensureDirectory/index.ts +10 -0
- package/src/utils/format/consts.ts +8 -0
- package/src/utils/format/index.ts +53 -0
- package/src/utils/getErrorMessage/index.ts +10 -0
- package/src/utils/index.ts +113 -0
- package/src/utils/ini/index.ts +200 -0
- package/src/utils/kitty/consts.ts +13 -0
- package/src/utils/kitty/index.ts +181 -0
- package/src/utils/logger/consts.ts +35 -0
- package/src/utils/logger/index.ts +217 -0
- package/src/utils/paths/consts.ts +18 -0
- package/src/utils/paths/index.ts +151 -0
- package/src/utils/png/consts.ts +34 -0
- package/src/utils/png/index.ts +131 -0
- package/src/utils/readJsonFile/index.ts +16 -0
- package/src/utils/rotateLogFile/index.ts +44 -0
- package/src/utils/safeClose/index.ts +10 -0
- package/src/utils/terminal/consts.ts +8 -0
- package/src/utils/terminal/index.ts +102 -0
- package/src/utils/thumbnailRenderer/consts.ts +2 -0
- package/src/utils/thumbnailRenderer/index.ts +147 -0
- package/src/utils/typedError/index.ts +26 -0
- package/tsconfig.json +31 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,899 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RetroArch Playlist Generator
|
|
3
|
+
*
|
|
4
|
+
* Generates RetroArch-compatible .lpl playlist files from scanned ROMs.
|
|
5
|
+
*
|
|
6
|
+
* Reference: https://docs.libretro.com/guides/roms-playlists-thumbnails/
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { writeFileSync, existsSync, readdirSync } from 'fs';
|
|
10
|
+
import { readJsonFile } from '../../utils/readJsonFile';
|
|
11
|
+
import { ensureDirectory } from '../../utils/ensureDirectory';
|
|
12
|
+
import { join, dirname, basename, resolve, isAbsolute } from 'path';
|
|
13
|
+
import type { RomInfo } from '../romScanner';
|
|
14
|
+
import { calculateFileCrc32 } from '../../utils/crc32';
|
|
15
|
+
import {
|
|
16
|
+
PLAYLIST_VERSION,
|
|
17
|
+
LABEL_DISPLAY_MODE_DEFAULT,
|
|
18
|
+
THUMBNAIL_MODE_DEFAULT,
|
|
19
|
+
SORT_MODE_ALPHABETICAL,
|
|
20
|
+
RETROARCH_DATABASE_NAMES,
|
|
21
|
+
DEFAULT_DATABASE_NAME,
|
|
22
|
+
WINDOWS_PATH_SEP,
|
|
23
|
+
UNIX_PATH_SEP,
|
|
24
|
+
PLAYLIST_EXTENSION,
|
|
25
|
+
} from './consts';
|
|
26
|
+
import { readPlaylist } from './reader';
|
|
27
|
+
import { normalizePath } from './utils';
|
|
28
|
+
import { formatRomLabel } from './labelFormatter';
|
|
29
|
+
import { getErrorMessage } from '../../utils/getErrorMessage';
|
|
30
|
+
import { secondsToHms, SECONDS_PER_HOUR, SECONDS_PER_MINUTE } from '../../utils/format';
|
|
31
|
+
import { isPlainObject } from 'remeda';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* RetroArch uses "DETECT" as a placeholder when values are unknown.
|
|
35
|
+
* This ensures compatibility with RetroArch's playlist format.
|
|
36
|
+
*/
|
|
37
|
+
const DETECT = 'DETECT';
|
|
38
|
+
|
|
39
|
+
// =============================================================================
|
|
40
|
+
// CRC Cache Types
|
|
41
|
+
// =============================================================================
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Cache mapping absolute ROM paths to their CRC32 checksums.
|
|
45
|
+
* Used to avoid recomputing CRC32 when updating existing playlists.
|
|
46
|
+
*/
|
|
47
|
+
export type CrcCache = Map<string, string>;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Location of a playlist entry for fast lookups.
|
|
51
|
+
*/
|
|
52
|
+
export interface PlaylistEntryLocation {
|
|
53
|
+
/** Path to the playlist file */
|
|
54
|
+
playlistPath: string;
|
|
55
|
+
/** Index of the entry within the playlist's items array */
|
|
56
|
+
entryIndex: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Index mapping normalized ROM paths to their playlist locations.
|
|
61
|
+
* Enables O(1) lookups instead of O(n) searches across all playlists.
|
|
62
|
+
*/
|
|
63
|
+
export type PlaylistIndex = Map<string, PlaylistEntryLocation>;
|
|
64
|
+
|
|
65
|
+
// =============================================================================
|
|
66
|
+
// Types
|
|
67
|
+
// =============================================================================
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A single entry in a RetroArch playlist
|
|
71
|
+
*/
|
|
72
|
+
export interface PlaylistEntry {
|
|
73
|
+
/** Path to the ROM file */
|
|
74
|
+
path: string;
|
|
75
|
+
/** Display label (game title) */
|
|
76
|
+
label: string;
|
|
77
|
+
/** Path to the libretro core, or "DETECT" for auto-detection */
|
|
78
|
+
core_path: string;
|
|
79
|
+
/** Name of the libretro core, or "DETECT" for auto-detection */
|
|
80
|
+
core_name: string;
|
|
81
|
+
/** CRC32 checksum as 8-char uppercase hex, or "DETECT" to skip validation */
|
|
82
|
+
crc32: string;
|
|
83
|
+
/** Associated database name (system playlist name) */
|
|
84
|
+
db_name: string;
|
|
85
|
+
|
|
86
|
+
// Runtime logging fields (RetroArch compatible)
|
|
87
|
+
/** Total runtime hours */
|
|
88
|
+
runtime_hours?: number;
|
|
89
|
+
/** Total runtime minutes (0-59) */
|
|
90
|
+
runtime_minutes?: number;
|
|
91
|
+
/** Total runtime seconds (0-59) */
|
|
92
|
+
runtime_seconds?: number;
|
|
93
|
+
|
|
94
|
+
// Last played timestamp fields (RetroArch compatible)
|
|
95
|
+
/** Year last played */
|
|
96
|
+
last_played_year?: number;
|
|
97
|
+
/** Month last played (1-12) */
|
|
98
|
+
last_played_month?: number;
|
|
99
|
+
/** Day last played (1-31) */
|
|
100
|
+
last_played_day?: number;
|
|
101
|
+
/** Hour last played (0-23) */
|
|
102
|
+
last_played_hour?: number;
|
|
103
|
+
/** Minute last played (0-59) */
|
|
104
|
+
last_played_minute?: number;
|
|
105
|
+
/** Second last played (0-59) */
|
|
106
|
+
last_played_second?: number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* RetroArch playlist file format (JSON, version 1.5+)
|
|
111
|
+
*/
|
|
112
|
+
export interface PlaylistFile {
|
|
113
|
+
/** Playlist format version */
|
|
114
|
+
version: string;
|
|
115
|
+
/** Default core path for all entries, or "DETECT" for per-entry detection */
|
|
116
|
+
default_core_path: string;
|
|
117
|
+
/** Default core name for all entries, or "DETECT" for per-entry detection */
|
|
118
|
+
default_core_name: string;
|
|
119
|
+
/** Label display mode (0 = default) */
|
|
120
|
+
label_display_mode: number;
|
|
121
|
+
/** Right thumbnail mode (0 = boxart) */
|
|
122
|
+
right_thumbnail_mode: number;
|
|
123
|
+
/** Left thumbnail mode (0 = boxart) */
|
|
124
|
+
left_thumbnail_mode: number;
|
|
125
|
+
/** Sort mode (0 = alphabetical) */
|
|
126
|
+
sort_mode: number;
|
|
127
|
+
/** Playlist entries */
|
|
128
|
+
items: PlaylistEntry[];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Validates that a parsed JSON value has the basic structure of a PlaylistFile.
|
|
133
|
+
* Checks for an object with an items array.
|
|
134
|
+
*/
|
|
135
|
+
export const isPlaylistFile = (value: unknown): value is PlaylistFile => {
|
|
136
|
+
return isPlainObject(value) && Array.isArray(value.items);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Options for playlist generation
|
|
141
|
+
*/
|
|
142
|
+
export interface PlaylistOptions {
|
|
143
|
+
/** Default core path (optional, auto-detected from ROMs if not specified) */
|
|
144
|
+
defaultCorePath?: string;
|
|
145
|
+
/** Default core name (optional, auto-detected from ROMs if not specified) */
|
|
146
|
+
defaultCoreName?: string;
|
|
147
|
+
/** Use Windows path separators (backslash) */
|
|
148
|
+
windowsPaths?: boolean;
|
|
149
|
+
/** Custom label generator function */
|
|
150
|
+
labelGenerator?: (rom: RomInfo) => string;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Result of playlist generation
|
|
155
|
+
*/
|
|
156
|
+
export interface PlaylistGenerationResult {
|
|
157
|
+
/** Whether generation was successful */
|
|
158
|
+
success: boolean;
|
|
159
|
+
/** Path to the generated playlist file */
|
|
160
|
+
outputPath?: string;
|
|
161
|
+
/** Number of entries in the playlist */
|
|
162
|
+
entryCount: number;
|
|
163
|
+
/** Error message if generation failed */
|
|
164
|
+
error?: string;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// =============================================================================
|
|
168
|
+
// Helper Functions
|
|
169
|
+
// =============================================================================
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Get the RetroArch database name for a ROM based on its extension or system ID.
|
|
173
|
+
*/
|
|
174
|
+
export const getDatabaseName = (extension: string, systemId?: string): string => {
|
|
175
|
+
// Try system ID first (more specific)
|
|
176
|
+
if (systemId && RETROARCH_DATABASE_NAMES[systemId]) {
|
|
177
|
+
return RETROARCH_DATABASE_NAMES[systemId];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Try extension (normalized to lowercase with dot)
|
|
181
|
+
const ext = extension.startsWith('.') ? extension.toLowerCase() : `.${extension.toLowerCase()}`;
|
|
182
|
+
return RETROARCH_DATABASE_NAMES[ext] ?? DEFAULT_DATABASE_NAME;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Get the RetroArch system name (without .lpl extension) for a ROM.
|
|
187
|
+
* Used for thumbnail directory naming.
|
|
188
|
+
*
|
|
189
|
+
* Example: ".nes" → "Nintendo - Nintendo Entertainment System"
|
|
190
|
+
*/
|
|
191
|
+
export const getSystemName = (extension: string, systemId?: string): string => {
|
|
192
|
+
const dbName = getDatabaseName(extension, systemId);
|
|
193
|
+
return dbName.endsWith(PLAYLIST_EXTENSION)
|
|
194
|
+
? dbName.slice(0, -PLAYLIST_EXTENSION.length)
|
|
195
|
+
: dbName;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Convert a path to use the specified separator style.
|
|
200
|
+
*/
|
|
201
|
+
const convertPathSeparators = (path: string, useWindows: boolean): string => {
|
|
202
|
+
if (useWindows) {
|
|
203
|
+
return path.replace(/\//g, WINDOWS_PATH_SEP);
|
|
204
|
+
}
|
|
205
|
+
return path.replace(/\\/g, UNIX_PATH_SEP);
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Generate a display label for a ROM.
|
|
210
|
+
* Prefers embedded title from metadata, falls back to filename without extension.
|
|
211
|
+
* Note: Labels are formatted by createPlaylistEntry, so this just returns the raw value.
|
|
212
|
+
*/
|
|
213
|
+
const defaultLabelGenerator = (rom: RomInfo): string => {
|
|
214
|
+
// Use embedded title if available
|
|
215
|
+
if (rom.metadata.title) {
|
|
216
|
+
return rom.metadata.title;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Fall back to filename without extension
|
|
220
|
+
return basename(rom.filename, rom.extension);
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Build a CRC cache from an existing playlist's entries.
|
|
225
|
+
* Maps absolute ROM paths to their CRC32 checksums.
|
|
226
|
+
* Skips entries with "DETECT" as the CRC value (meaning not yet computed).
|
|
227
|
+
*/
|
|
228
|
+
export const buildCrcCacheFromPlaylist = (
|
|
229
|
+
playlist: PlaylistFile,
|
|
230
|
+
playlistPath: string
|
|
231
|
+
): CrcCache => {
|
|
232
|
+
const cache: CrcCache = new Map();
|
|
233
|
+
const playlistDir = dirname(playlistPath);
|
|
234
|
+
|
|
235
|
+
for (const entry of playlist.items) {
|
|
236
|
+
// Skip entries without a real CRC value
|
|
237
|
+
if (!entry.crc32 || entry.crc32 === DETECT) {
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Resolve the path to absolute if needed
|
|
242
|
+
let absolutePath = entry.path;
|
|
243
|
+
if (!isAbsolute(entry.path)) {
|
|
244
|
+
// Relative path - resolve relative to playlist location
|
|
245
|
+
absolutePath = resolve(playlistDir, entry.path);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Normalize the path for consistent cache lookups (handles case-insensitive FS)
|
|
249
|
+
cache.set(normalizePath(absolutePath), entry.crc32);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return cache;
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Build a CRC cache from all playlists in a directory.
|
|
257
|
+
* Combines CRC32 values from all playlist files into a single lookup map.
|
|
258
|
+
*
|
|
259
|
+
* @param playlistDirectory - Directory containing playlist files
|
|
260
|
+
* @returns Combined cache mapping normalized ROM paths to CRC32 checksums
|
|
261
|
+
*/
|
|
262
|
+
export const buildCrcCacheFromDirectory = (playlistDirectory: string): CrcCache => {
|
|
263
|
+
const cache: CrcCache = new Map();
|
|
264
|
+
|
|
265
|
+
if (!existsSync(playlistDirectory)) {
|
|
266
|
+
return cache;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const files = readdirSync(playlistDirectory);
|
|
270
|
+
|
|
271
|
+
for (const file of files) {
|
|
272
|
+
if (!file.toLowerCase().endsWith(PLAYLIST_EXTENSION)) {
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const playlistPath = join(playlistDirectory, file);
|
|
277
|
+
|
|
278
|
+
const parsed = readJsonFile(playlistPath);
|
|
279
|
+
|
|
280
|
+
if (!isPlaylistFile(parsed)) {
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Merge CRCs from this playlist into the cache
|
|
285
|
+
const playlistCache = buildCrcCacheFromPlaylist(parsed, playlistPath);
|
|
286
|
+
for (const [path, crc] of playlistCache) {
|
|
287
|
+
cache.set(path, crc);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return cache;
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Build an index of ROM paths to their playlist locations.
|
|
296
|
+
* Enables O(1) lookups for runtime updates instead of O(n) searches.
|
|
297
|
+
*
|
|
298
|
+
* @param playlistDirectory - Directory containing playlist files
|
|
299
|
+
* @returns Index mapping normalized ROM paths to playlist locations
|
|
300
|
+
*/
|
|
301
|
+
export const buildPlaylistIndex = (playlistDirectory: string): PlaylistIndex => {
|
|
302
|
+
const index: PlaylistIndex = new Map();
|
|
303
|
+
|
|
304
|
+
if (!existsSync(playlistDirectory)) {
|
|
305
|
+
return index;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const files = readdirSync(playlistDirectory);
|
|
309
|
+
|
|
310
|
+
for (const file of files) {
|
|
311
|
+
if (!file.toLowerCase().endsWith(PLAYLIST_EXTENSION)) {
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const playlistPath = join(playlistDirectory, file);
|
|
316
|
+
|
|
317
|
+
const parsed = readJsonFile(playlistPath);
|
|
318
|
+
|
|
319
|
+
if (!isPlaylistFile(parsed)) {
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const playlistDir = dirname(playlistPath);
|
|
324
|
+
|
|
325
|
+
for (let entryIndex = 0; entryIndex < parsed.items.length; entryIndex++) {
|
|
326
|
+
const entry = parsed.items[entryIndex];
|
|
327
|
+
|
|
328
|
+
// Resolve the entry path to absolute if needed
|
|
329
|
+
let absolutePath = entry.path;
|
|
330
|
+
if (!isAbsolute(entry.path)) {
|
|
331
|
+
absolutePath = resolve(playlistDir, entry.path);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Normalize for consistent lookups (handles case-insensitive FS via realpathSync)
|
|
335
|
+
index.set(normalizePath(absolutePath), { playlistPath, entryIndex });
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return index;
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Convert a Date to RetroArch last_played fields
|
|
345
|
+
*/
|
|
346
|
+
const dateToLastPlayed = (date: Date): {
|
|
347
|
+
year: number;
|
|
348
|
+
month: number;
|
|
349
|
+
day: number;
|
|
350
|
+
hour: number;
|
|
351
|
+
minute: number;
|
|
352
|
+
second: number;
|
|
353
|
+
} => ({
|
|
354
|
+
year: date.getFullYear(),
|
|
355
|
+
month: date.getMonth() + 1, // JavaScript months are 0-indexed
|
|
356
|
+
day: date.getDate(),
|
|
357
|
+
hour: date.getHours(),
|
|
358
|
+
minute: date.getMinutes(),
|
|
359
|
+
second: date.getSeconds(),
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// =============================================================================
|
|
363
|
+
// Main Functions
|
|
364
|
+
// =============================================================================
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Create a playlist entry from a RomInfo object.
|
|
368
|
+
*/
|
|
369
|
+
export const createPlaylistEntry = (
|
|
370
|
+
rom: RomInfo,
|
|
371
|
+
options: PlaylistOptions = {}
|
|
372
|
+
): PlaylistEntry => {
|
|
373
|
+
const {
|
|
374
|
+
windowsPaths = false,
|
|
375
|
+
labelGenerator = defaultLabelGenerator,
|
|
376
|
+
} = options;
|
|
377
|
+
|
|
378
|
+
// Convert path separators if needed
|
|
379
|
+
const romPath = convertPathSeparators(rom.path, windowsPaths);
|
|
380
|
+
|
|
381
|
+
// Use CRC32 from RomInfo if available (calculated during scan),
|
|
382
|
+
// otherwise compute it
|
|
383
|
+
const fileCrc32 = rom.crc32 ?? calculateFileCrc32(rom.path);
|
|
384
|
+
|
|
385
|
+
// Use DETECT for core_path and core_name by default (matches RetroArch behavior).
|
|
386
|
+
// Users can set specific cores later via RetroArch's Playlist Management menu.
|
|
387
|
+
// Always format the label to ensure consistent formatting regardless of source
|
|
388
|
+
const entry: PlaylistEntry = {
|
|
389
|
+
path: romPath,
|
|
390
|
+
label: formatRomLabel(labelGenerator(rom)),
|
|
391
|
+
core_path: DETECT,
|
|
392
|
+
core_name: DETECT,
|
|
393
|
+
crc32: fileCrc32 ?? DETECT,
|
|
394
|
+
db_name: getDatabaseName(rom.extension, rom.systemId),
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
// Add runtime data if available
|
|
398
|
+
if (rom.runtimeSeconds !== undefined && rom.runtimeSeconds > 0) {
|
|
399
|
+
const runtime = secondsToHms(rom.runtimeSeconds);
|
|
400
|
+
entry.runtime_hours = runtime.hours;
|
|
401
|
+
entry.runtime_minutes = runtime.minutes;
|
|
402
|
+
entry.runtime_seconds = runtime.seconds;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Add last played data if available
|
|
406
|
+
if (rom.lastPlayed !== undefined) {
|
|
407
|
+
const lastPlayed = dateToLastPlayed(rom.lastPlayed);
|
|
408
|
+
entry.last_played_year = lastPlayed.year;
|
|
409
|
+
entry.last_played_month = lastPlayed.month;
|
|
410
|
+
entry.last_played_day = lastPlayed.day;
|
|
411
|
+
entry.last_played_hour = lastPlayed.hour;
|
|
412
|
+
entry.last_played_minute = lastPlayed.minute;
|
|
413
|
+
entry.last_played_second = lastPlayed.second;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return entry;
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Generate a RetroArch-compatible playlist from a list of ROMs.
|
|
421
|
+
*/
|
|
422
|
+
export const generatePlaylist = (
|
|
423
|
+
roms: RomInfo[],
|
|
424
|
+
options: PlaylistOptions = {}
|
|
425
|
+
): PlaylistFile => {
|
|
426
|
+
const {
|
|
427
|
+
defaultCorePath: providedCorePath,
|
|
428
|
+
defaultCoreName: providedCoreName,
|
|
429
|
+
} = options;
|
|
430
|
+
|
|
431
|
+
// Create entries
|
|
432
|
+
const items = roms.map(rom => createPlaylistEntry(rom, options));
|
|
433
|
+
|
|
434
|
+
// Use DETECT by default for playlist-level core settings (matches RetroArch behavior).
|
|
435
|
+
// Users can set specific cores later via RetroArch's Playlist Management menu.
|
|
436
|
+
const playlist: PlaylistFile = {
|
|
437
|
+
version: PLAYLIST_VERSION,
|
|
438
|
+
default_core_path: providedCorePath ?? DETECT,
|
|
439
|
+
default_core_name: providedCoreName ?? DETECT,
|
|
440
|
+
label_display_mode: LABEL_DISPLAY_MODE_DEFAULT,
|
|
441
|
+
right_thumbnail_mode: THUMBNAIL_MODE_DEFAULT,
|
|
442
|
+
left_thumbnail_mode: THUMBNAIL_MODE_DEFAULT,
|
|
443
|
+
sort_mode: SORT_MODE_ALPHABETICAL,
|
|
444
|
+
items,
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
return playlist;
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Write a playlist to a file.
|
|
452
|
+
*
|
|
453
|
+
* @param playlist - The playlist to write
|
|
454
|
+
* @param outputPath - Path to the output .lpl file
|
|
455
|
+
* @returns Result of the write operation
|
|
456
|
+
*/
|
|
457
|
+
export const writePlaylist = (
|
|
458
|
+
playlist: PlaylistFile,
|
|
459
|
+
outputPath: string
|
|
460
|
+
): PlaylistGenerationResult => {
|
|
461
|
+
try {
|
|
462
|
+
// Ensure output path has .lpl extension
|
|
463
|
+
let finalPath = outputPath;
|
|
464
|
+
if (!finalPath.toLowerCase().endsWith(PLAYLIST_EXTENSION)) {
|
|
465
|
+
finalPath += PLAYLIST_EXTENSION;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
ensureDirectory(dirname(finalPath));
|
|
469
|
+
|
|
470
|
+
// Write with pretty-printing for readability
|
|
471
|
+
const json = JSON.stringify(playlist, null, 2);
|
|
472
|
+
writeFileSync(finalPath, json, 'utf-8');
|
|
473
|
+
|
|
474
|
+
return {
|
|
475
|
+
success: true,
|
|
476
|
+
outputPath: finalPath,
|
|
477
|
+
entryCount: playlist.items.length,
|
|
478
|
+
};
|
|
479
|
+
} catch (err) {
|
|
480
|
+
return {
|
|
481
|
+
success: false,
|
|
482
|
+
entryCount: 0,
|
|
483
|
+
error: getErrorMessage(err),
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Generate and write a playlist from ROMs in a single operation.
|
|
490
|
+
* If a playlist already exists at the output path, new entries are merged
|
|
491
|
+
* with existing entries (avoiding duplicates by path). CRC32 values from
|
|
492
|
+
* existing entries are cached to avoid recomputing them.
|
|
493
|
+
*
|
|
494
|
+
* @param roms - Array of RomInfo objects to include in the playlist
|
|
495
|
+
* @param outputPath - Path to the output .lpl file
|
|
496
|
+
* @param options - Playlist generation options
|
|
497
|
+
* @returns Result of the generation and write operation
|
|
498
|
+
*/
|
|
499
|
+
export const generateAndWritePlaylist = (
|
|
500
|
+
roms: RomInfo[],
|
|
501
|
+
outputPath: string,
|
|
502
|
+
options: PlaylistOptions = {}
|
|
503
|
+
): PlaylistGenerationResult => {
|
|
504
|
+
if (roms.length === 0) {
|
|
505
|
+
return {
|
|
506
|
+
success: false,
|
|
507
|
+
entryCount: 0,
|
|
508
|
+
error: 'No ROMs provided for playlist generation',
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Ensure output path has .lpl extension for lookup
|
|
513
|
+
let playlistPath = outputPath;
|
|
514
|
+
if (!playlistPath.toLowerCase().endsWith(PLAYLIST_EXTENSION)) {
|
|
515
|
+
playlistPath += PLAYLIST_EXTENSION;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Try to load existing playlist
|
|
519
|
+
const existingResult = readPlaylist(playlistPath);
|
|
520
|
+
const existingPlaylist = existingResult.success ? existingResult.playlist : null;
|
|
521
|
+
|
|
522
|
+
// Generate new entries
|
|
523
|
+
const newPlaylist = generatePlaylist(roms, options);
|
|
524
|
+
|
|
525
|
+
// If no existing playlist, just write the new one
|
|
526
|
+
if (!existingPlaylist) {
|
|
527
|
+
return writePlaylist(newPlaylist, outputPath);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Build a set of existing normalized paths to avoid duplicates
|
|
531
|
+
const playlistDir = dirname(playlistPath);
|
|
532
|
+
const existingPaths = new Set<string>();
|
|
533
|
+
for (const entry of existingPlaylist.items) {
|
|
534
|
+
const resolvedPath = isAbsolute(entry.path)
|
|
535
|
+
? entry.path
|
|
536
|
+
: resolve(playlistDir, entry.path);
|
|
537
|
+
existingPaths.add(normalizePath(resolvedPath));
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Filter new entries to only those not already in the playlist
|
|
541
|
+
const newEntries = newPlaylist.items.filter(entry => {
|
|
542
|
+
const resolvedPath = isAbsolute(entry.path)
|
|
543
|
+
? entry.path
|
|
544
|
+
: resolve(playlistDir, entry.path);
|
|
545
|
+
return !existingPaths.has(normalizePath(resolvedPath));
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
// Merge: keep existing entries and add new ones
|
|
549
|
+
const mergedPlaylist: PlaylistFile = {
|
|
550
|
+
...existingPlaylist,
|
|
551
|
+
items: [...existingPlaylist.items, ...newEntries],
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
const result = writePlaylist(mergedPlaylist, outputPath);
|
|
555
|
+
|
|
556
|
+
// Return the count of entries actually added, not the total
|
|
557
|
+
return {
|
|
558
|
+
...result,
|
|
559
|
+
entryCount: newEntries.length,
|
|
560
|
+
};
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Generate playlists grouped by system.
|
|
565
|
+
* Creates one playlist per system (e.g., "Nintendo - NES.lpl", "Sega - Genesis.lpl").
|
|
566
|
+
*
|
|
567
|
+
* @param roms - Array of RomInfo objects
|
|
568
|
+
* @param outputDirectory - Directory to write playlist files
|
|
569
|
+
* @param options - Playlist generation options
|
|
570
|
+
* @returns Array of results, one per generated playlist
|
|
571
|
+
*/
|
|
572
|
+
export const generatePlaylistsBySystem = (
|
|
573
|
+
roms: RomInfo[],
|
|
574
|
+
outputDirectory: string,
|
|
575
|
+
options: PlaylistOptions = {}
|
|
576
|
+
): PlaylistGenerationResult[] => {
|
|
577
|
+
// Group ROMs by database name (system)
|
|
578
|
+
const romsBySystem = new Map<string, RomInfo[]>();
|
|
579
|
+
|
|
580
|
+
for (const rom of roms) {
|
|
581
|
+
const dbName = getDatabaseName(rom.extension, rom.systemId);
|
|
582
|
+
const existing = romsBySystem.get(dbName) ?? [];
|
|
583
|
+
existing.push(rom);
|
|
584
|
+
romsBySystem.set(dbName, existing);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Generate a playlist for each system
|
|
588
|
+
const results: PlaylistGenerationResult[] = [];
|
|
589
|
+
|
|
590
|
+
for (const [dbName, systemRoms] of romsBySystem) {
|
|
591
|
+
const outputPath = join(outputDirectory, dbName);
|
|
592
|
+
const result = generateAndWritePlaylist(systemRoms, outputPath, options);
|
|
593
|
+
results.push(result);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
return results;
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Generate a single consolidated playlist with all ROMs.
|
|
601
|
+
*
|
|
602
|
+
* @param roms - Array of RomInfo objects
|
|
603
|
+
* @param outputPath - Path to the output .lpl file
|
|
604
|
+
* @param options - Playlist generation options
|
|
605
|
+
* @returns Result of the generation
|
|
606
|
+
*/
|
|
607
|
+
export const generateConsolidatedPlaylist = (
|
|
608
|
+
roms: RomInfo[],
|
|
609
|
+
outputPath: string,
|
|
610
|
+
options: PlaylistOptions = {}
|
|
611
|
+
): PlaylistGenerationResult => {
|
|
612
|
+
return generateAndWritePlaylist(roms, outputPath, options);
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Result of updating a playlist entry
|
|
617
|
+
*/
|
|
618
|
+
export interface PlaylistUpdateResult {
|
|
619
|
+
/** Whether the update was successful */
|
|
620
|
+
success: boolean;
|
|
621
|
+
/** Path to the updated playlist file */
|
|
622
|
+
playlistPath?: string;
|
|
623
|
+
/** Error message if update failed */
|
|
624
|
+
error?: string;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Update a playlist entry's runtime and write back to disk.
|
|
629
|
+
* Internal helper used by updatePlaylistRuntime.
|
|
630
|
+
*/
|
|
631
|
+
const updateEntryAndWrite = (
|
|
632
|
+
playlistPath: string,
|
|
633
|
+
entryIndex: number,
|
|
634
|
+
sessionSeconds: number,
|
|
635
|
+
lastPlayed: Date
|
|
636
|
+
): PlaylistUpdateResult => {
|
|
637
|
+
const parsed = readJsonFile(playlistPath);
|
|
638
|
+
|
|
639
|
+
if (!isPlaylistFile(parsed) || entryIndex >= parsed.items.length) {
|
|
640
|
+
return { success: false, error: 'Invalid playlist or entry index' };
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
try {
|
|
644
|
+
|
|
645
|
+
const entry = parsed.items[entryIndex];
|
|
646
|
+
|
|
647
|
+
// Calculate new runtime (add to existing)
|
|
648
|
+
const existingHours = entry.runtime_hours ?? 0;
|
|
649
|
+
const existingMinutes = entry.runtime_minutes ?? 0;
|
|
650
|
+
const existingSeconds = entry.runtime_seconds ?? 0;
|
|
651
|
+
const existingTotalSeconds = existingHours * SECONDS_PER_HOUR +
|
|
652
|
+
existingMinutes * SECONDS_PER_MINUTE +
|
|
653
|
+
existingSeconds;
|
|
654
|
+
const newTotalSeconds = existingTotalSeconds + sessionSeconds;
|
|
655
|
+
const newRuntime = secondsToHms(newTotalSeconds);
|
|
656
|
+
|
|
657
|
+
entry.runtime_hours = newRuntime.hours;
|
|
658
|
+
entry.runtime_minutes = newRuntime.minutes;
|
|
659
|
+
entry.runtime_seconds = newRuntime.seconds;
|
|
660
|
+
|
|
661
|
+
// Update last_played
|
|
662
|
+
const lastPlayedFields = dateToLastPlayed(lastPlayed);
|
|
663
|
+
entry.last_played_year = lastPlayedFields.year;
|
|
664
|
+
entry.last_played_month = lastPlayedFields.month;
|
|
665
|
+
entry.last_played_day = lastPlayedFields.day;
|
|
666
|
+
entry.last_played_hour = lastPlayedFields.hour;
|
|
667
|
+
entry.last_played_minute = lastPlayedFields.minute;
|
|
668
|
+
entry.last_played_second = lastPlayedFields.second;
|
|
669
|
+
|
|
670
|
+
// Write back
|
|
671
|
+
const json = JSON.stringify(parsed, null, 2);
|
|
672
|
+
writeFileSync(playlistPath, json, 'utf-8');
|
|
673
|
+
|
|
674
|
+
return { success: true, playlistPath };
|
|
675
|
+
} catch (err) {
|
|
676
|
+
return {
|
|
677
|
+
success: false,
|
|
678
|
+
error: getErrorMessage(err),
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Update only the last_played fields of a playlist entry (no runtime changes).
|
|
685
|
+
* Internal helper used by updateLastPlayed.
|
|
686
|
+
*/
|
|
687
|
+
const updateLastPlayedOnly = (
|
|
688
|
+
playlistPath: string,
|
|
689
|
+
entryIndex: number,
|
|
690
|
+
lastPlayed: Date
|
|
691
|
+
): PlaylistUpdateResult => {
|
|
692
|
+
const parsed = readJsonFile(playlistPath);
|
|
693
|
+
|
|
694
|
+
if (!isPlaylistFile(parsed) || entryIndex >= parsed.items.length) {
|
|
695
|
+
return { success: false, error: 'Invalid playlist or entry index' };
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
try {
|
|
699
|
+
|
|
700
|
+
const entry = parsed.items[entryIndex];
|
|
701
|
+
|
|
702
|
+
// Update last_played fields only
|
|
703
|
+
const lastPlayedFields = dateToLastPlayed(lastPlayed);
|
|
704
|
+
entry.last_played_year = lastPlayedFields.year;
|
|
705
|
+
entry.last_played_month = lastPlayedFields.month;
|
|
706
|
+
entry.last_played_day = lastPlayedFields.day;
|
|
707
|
+
entry.last_played_hour = lastPlayedFields.hour;
|
|
708
|
+
entry.last_played_minute = lastPlayedFields.minute;
|
|
709
|
+
entry.last_played_second = lastPlayedFields.second;
|
|
710
|
+
|
|
711
|
+
// Write back
|
|
712
|
+
const json = JSON.stringify(parsed, null, 2);
|
|
713
|
+
writeFileSync(playlistPath, json, 'utf-8');
|
|
714
|
+
|
|
715
|
+
return { success: true, playlistPath };
|
|
716
|
+
} catch (err) {
|
|
717
|
+
return {
|
|
718
|
+
success: false,
|
|
719
|
+
error: getErrorMessage(err),
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Search for a ROM entry across all playlists (O(n) fallback).
|
|
726
|
+
* Internal helper used when no index is provided.
|
|
727
|
+
*/
|
|
728
|
+
const findRomInPlaylists = (
|
|
729
|
+
normalizedRomPath: string,
|
|
730
|
+
playlistDirectory: string
|
|
731
|
+
): PlaylistEntryLocation | null => {
|
|
732
|
+
const files = readdirSync(playlistDirectory);
|
|
733
|
+
|
|
734
|
+
for (const file of files) {
|
|
735
|
+
if (!file.toLowerCase().endsWith(PLAYLIST_EXTENSION)) {
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const playlistPath = join(playlistDirectory, file);
|
|
740
|
+
|
|
741
|
+
const parsed = readJsonFile(playlistPath);
|
|
742
|
+
|
|
743
|
+
if (!isPlaylistFile(parsed)) {
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const entryIndex = parsed.items.findIndex(entry => {
|
|
748
|
+
let entryPath = entry.path;
|
|
749
|
+
if (!isAbsolute(entryPath)) {
|
|
750
|
+
entryPath = resolve(dirname(playlistPath), entryPath);
|
|
751
|
+
}
|
|
752
|
+
return normalizePath(entryPath) === normalizedRomPath;
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
if (entryIndex !== -1) {
|
|
756
|
+
return { playlistPath, entryIndex };
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
return null;
|
|
761
|
+
};
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Update runtime and last_played data for a ROM in a playlist.
|
|
765
|
+
*
|
|
766
|
+
* This function finds the playlist containing the ROM, updates the entry's
|
|
767
|
+
* runtime (adding to existing) and last_played (replacing), then writes back.
|
|
768
|
+
*
|
|
769
|
+
* When an index is provided, uses O(1) lookup. Otherwise falls back to O(n) search.
|
|
770
|
+
*
|
|
771
|
+
* @param romPath - Absolute path to the ROM file
|
|
772
|
+
* @param playlistDirectory - Directory containing playlist files
|
|
773
|
+
* @param sessionSeconds - Seconds played in this session (added to existing runtime)
|
|
774
|
+
* @param lastPlayed - When the session ended (optional, defaults to now)
|
|
775
|
+
* @param index - Optional playlist index for O(1) lookups (from buildPlaylistIndex)
|
|
776
|
+
* @returns Result of the update operation
|
|
777
|
+
*/
|
|
778
|
+
export const updatePlaylistRuntime = (
|
|
779
|
+
romPath: string,
|
|
780
|
+
playlistDirectory: string,
|
|
781
|
+
sessionSeconds: number,
|
|
782
|
+
lastPlayed: Date = new Date(),
|
|
783
|
+
index?: PlaylistIndex
|
|
784
|
+
): PlaylistUpdateResult => {
|
|
785
|
+
try {
|
|
786
|
+
if (!existsSync(playlistDirectory)) {
|
|
787
|
+
return { success: false, error: 'Playlist directory not found' };
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const normalizedRomPath = normalizePath(romPath);
|
|
791
|
+
|
|
792
|
+
// Use index for O(1) lookup if available, otherwise fall back to O(n) search
|
|
793
|
+
const location = index?.get(normalizedRomPath) ?? findRomInPlaylists(normalizedRomPath, playlistDirectory);
|
|
794
|
+
|
|
795
|
+
if (!location) {
|
|
796
|
+
return { success: false, error: 'ROM not found in any playlist' };
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
return updateEntryAndWrite(location.playlistPath, location.entryIndex, sessionSeconds, lastPlayed);
|
|
800
|
+
} catch (err) {
|
|
801
|
+
return {
|
|
802
|
+
success: false,
|
|
803
|
+
error: getErrorMessage(err),
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
};
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* Update only the last_played date for a ROM in a playlist.
|
|
810
|
+
*
|
|
811
|
+
* Used to migrate save state timestamps to playlist last_played fields
|
|
812
|
+
* for ROMs that don't have this data yet.
|
|
813
|
+
*
|
|
814
|
+
* @param romPath - Absolute path to the ROM file
|
|
815
|
+
* @param playlistDirectory - Directory containing playlist files
|
|
816
|
+
* @param lastPlayed - The date to set as last_played
|
|
817
|
+
* @param index - Optional playlist index for O(1) lookups (from buildPlaylistIndex)
|
|
818
|
+
*/
|
|
819
|
+
export const updateLastPlayed = (
|
|
820
|
+
romPath: string,
|
|
821
|
+
playlistDirectory: string,
|
|
822
|
+
lastPlayed: Date,
|
|
823
|
+
index?: PlaylistIndex
|
|
824
|
+
): PlaylistUpdateResult => {
|
|
825
|
+
try {
|
|
826
|
+
if (!existsSync(playlistDirectory)) {
|
|
827
|
+
return { success: false, error: 'Playlist directory not found' };
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
const normalizedRomPath = normalizePath(romPath);
|
|
831
|
+
|
|
832
|
+
// Use index for O(1) lookup if available, otherwise fall back to O(n) search
|
|
833
|
+
const location = index?.get(normalizedRomPath) ?? findRomInPlaylists(normalizedRomPath, playlistDirectory);
|
|
834
|
+
|
|
835
|
+
if (!location) {
|
|
836
|
+
return { success: false, error: 'ROM not found in any playlist' };
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
return updateLastPlayedOnly(location.playlistPath, location.entryIndex, lastPlayed);
|
|
840
|
+
} catch (err) {
|
|
841
|
+
return {
|
|
842
|
+
success: false,
|
|
843
|
+
error: getErrorMessage(err),
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
};
|
|
847
|
+
|
|
848
|
+
// Re-export constants
|
|
849
|
+
export * from './consts';
|
|
850
|
+
|
|
851
|
+
// Re-export reader functions
|
|
852
|
+
export {
|
|
853
|
+
readPlaylist,
|
|
854
|
+
playlistEntryToRomInfo,
|
|
855
|
+
playlistToRomInfoArray,
|
|
856
|
+
findPlaylistsInDirectory,
|
|
857
|
+
findPlaylistsForDirectory,
|
|
858
|
+
loadRomsFromPlaylists,
|
|
859
|
+
} from './reader';
|
|
860
|
+
export type {
|
|
861
|
+
PlaylistReadResult,
|
|
862
|
+
PlaylistInfo,
|
|
863
|
+
ConversionOptions,
|
|
864
|
+
} from './reader';
|
|
865
|
+
|
|
866
|
+
// Re-export utility functions
|
|
867
|
+
export { normalizePath, resolvePath } from './utils';
|
|
868
|
+
|
|
869
|
+
// Re-export sync functions
|
|
870
|
+
export {
|
|
871
|
+
analyzePlaylistSync,
|
|
872
|
+
syncPlaylists,
|
|
873
|
+
countPlaylistEntries,
|
|
874
|
+
} from './sync';
|
|
875
|
+
export type {
|
|
876
|
+
SyncAnalysis,
|
|
877
|
+
SyncResult,
|
|
878
|
+
SyncOptions,
|
|
879
|
+
MissingEntry,
|
|
880
|
+
MovedRom,
|
|
881
|
+
PlaylistEntryWithContext,
|
|
882
|
+
DuplicateCrcRom,
|
|
883
|
+
DuplicateCrcChoice,
|
|
884
|
+
DuplicateDecision,
|
|
885
|
+
} from './sync';
|
|
886
|
+
|
|
887
|
+
// Re-export label formatter
|
|
888
|
+
export { formatRomLabel } from './labelFormatter';
|
|
889
|
+
|
|
890
|
+
// Re-export system lookup utilities
|
|
891
|
+
export {
|
|
892
|
+
getSystemInfo,
|
|
893
|
+
getVendor,
|
|
894
|
+
getSystemByExtension,
|
|
895
|
+
getAllVendors,
|
|
896
|
+
getSystemsForVendor,
|
|
897
|
+
getExtensionsForSystem,
|
|
898
|
+
} from './systemLookup';
|
|
899
|
+
export type { SystemInfo } from './systemLookup';
|