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.
Files changed (213) hide show
  1. package/.claude/settings.local.json +77 -0
  2. package/.node-version +1 -0
  3. package/CLAUDE.md +435 -0
  4. package/README.md +404 -0
  5. package/TODO.md +655 -0
  6. package/dist/index.cjs +25108 -0
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.js +25085 -0
  9. package/docs/building-libretro-cores-arm-mac.md +237 -0
  10. package/docs/config-file-format.md +488 -0
  11. package/docs/cores-trd.md +425 -0
  12. package/docs/headless-hardware-rendering-trd.md +676 -0
  13. package/docs/libretro-cores-trd.md +997 -0
  14. package/docs/mupen64-software-rendering-trd.md +751 -0
  15. package/docs/n64-support-trd.md +306 -0
  16. package/docs/native-rendering-trd.md +540 -0
  17. package/docs/native-ui-rendering-trd.md +1195 -0
  18. package/docs/netplay-trd.md +665 -0
  19. package/docs/retroarch-netplay-docs.md +277 -0
  20. package/docs/save-state-format.md +666 -0
  21. package/eslint.config.js +111 -0
  22. package/icon/icon.png +0 -0
  23. package/icon/icon.pxd +0 -0
  24. package/package.json +63 -0
  25. package/pnpm-workspace.yaml +10 -0
  26. package/src/Emulator/consts.ts +14 -0
  27. package/src/Emulator/index.ts +2496 -0
  28. package/src/Emulator/saveState/index.ts +155 -0
  29. package/src/Emulator/screenshot/index.ts +160 -0
  30. package/src/Emulator/terminalDimensions/index.ts +79 -0
  31. package/src/Emulator/types.ts +83 -0
  32. package/src/cli/commands/consts.ts +10 -0
  33. package/src/cli/commands/index.ts +462 -0
  34. package/src/cli/parseArgs/consts.ts +17 -0
  35. package/src/cli/parseArgs/index.ts +457 -0
  36. package/src/cli/parseArgs/types.ts +61 -0
  37. package/src/cli/runEmulator/index.ts +406 -0
  38. package/src/cli/runEmulator/types.ts +7 -0
  39. package/src/consts.ts +19 -0
  40. package/src/core/button/consts.ts +35 -0
  41. package/src/core/button/index.ts +123 -0
  42. package/src/core/core.ts +300 -0
  43. package/src/core/index.ts +19 -0
  44. package/src/cores/libretro/api/index.ts +334 -0
  45. package/src/cores/libretro/api/types.ts +148 -0
  46. package/src/cores/libretro/callbacks/consts.ts +41 -0
  47. package/src/cores/libretro/callbacks/index.ts +456 -0
  48. package/src/cores/libretro/consts.ts +45 -0
  49. package/src/cores/libretro/coreOptions/consts.ts +36 -0
  50. package/src/cores/libretro/coreOptions/index.ts +222 -0
  51. package/src/cores/libretro/environment/consts.ts +118 -0
  52. package/src/cores/libretro/environment/index.ts +1095 -0
  53. package/src/cores/libretro/index.ts +937 -0
  54. package/src/cores/libretro/loader/index.ts +496 -0
  55. package/src/cores/libretro/pixelFormat/consts.ts +43 -0
  56. package/src/cores/libretro/pixelFormat/index.ts +397 -0
  57. package/src/cores/libretro/types.ts +339 -0
  58. package/src/frontend/AudioManager/index.ts +420 -0
  59. package/src/frontend/SettingsManager/index.ts +250 -0
  60. package/src/frontend/config/index.ts +608 -0
  61. package/src/frontend/config/tests.ts +354 -0
  62. package/src/frontend/config/types.ts +36 -0
  63. package/src/frontend/consts.ts +114 -0
  64. package/src/frontend/coreBuilder/index.ts +644 -0
  65. package/src/frontend/coreBuilder/types.ts +15 -0
  66. package/src/frontend/coreDownloader/index.ts +620 -0
  67. package/src/frontend/coreDownloader/types.ts +17 -0
  68. package/src/frontend/corePreferences/index.ts +69 -0
  69. package/src/frontend/coreRegistry/index.ts +276 -0
  70. package/src/frontend/directoryCache/index.ts +75 -0
  71. package/src/frontend/index.ts +79 -0
  72. package/src/frontend/notifications/consts.ts +14 -0
  73. package/src/frontend/notifications/index.ts +250 -0
  74. package/src/frontend/playlist/consts.ts +168 -0
  75. package/src/frontend/playlist/index.ts +899 -0
  76. package/src/frontend/playlist/labelFormatter/consts.ts +15 -0
  77. package/src/frontend/playlist/labelFormatter/index.ts +155 -0
  78. package/src/frontend/playlist/labelFormatter/tests.ts +153 -0
  79. package/src/frontend/playlist/reader/index.ts +559 -0
  80. package/src/frontend/playlist/sync/index.ts +511 -0
  81. package/src/frontend/playlist/systemLookup/index.ts +233 -0
  82. package/src/frontend/playlist/utils/index.ts +50 -0
  83. package/src/frontend/romScanner/consts.ts +348 -0
  84. package/src/frontend/romScanner/index.ts +1957 -0
  85. package/src/frontend/saveServices/consts.ts +2 -0
  86. package/src/frontend/saveServices/index.ts +313 -0
  87. package/src/frontend/serviceProvider/index.ts +108 -0
  88. package/src/frontend/serviceProvider/types.ts +13 -0
  89. package/src/index.ts +428 -0
  90. package/src/input/Controller/consts.ts +50 -0
  91. package/src/input/Controller/index.ts +81 -0
  92. package/src/input/GamepadManager/consts.ts +22 -0
  93. package/src/input/GamepadManager/index.ts +418 -0
  94. package/src/input/InputManager/consts.ts +86 -0
  95. package/src/input/InputManager/index.ts +593 -0
  96. package/src/input/InputMapper/consts.ts +33 -0
  97. package/src/input/InputMapper/index.ts +436 -0
  98. package/src/input/consts.ts +410 -0
  99. package/src/input/gamepadProfiles/index.ts +1100 -0
  100. package/src/input/index.ts +38 -0
  101. package/src/input/inputUtils/index.ts +31 -0
  102. package/src/netplay/FrameBuffer/consts.ts +2 -0
  103. package/src/netplay/FrameBuffer/index.ts +364 -0
  104. package/src/netplay/FrameBuffer/tests.ts +286 -0
  105. package/src/netplay/InputBuffer/consts.ts +7 -0
  106. package/src/netplay/InputBuffer/index.ts +347 -0
  107. package/src/netplay/InputBuffer/tests.ts +166 -0
  108. package/src/netplay/NetplayClient/index.ts +976 -0
  109. package/src/netplay/NetplayConnection/index.ts +536 -0
  110. package/src/netplay/NetplayDiscovery/consts.ts +41 -0
  111. package/src/netplay/NetplayDiscovery/index.ts +525 -0
  112. package/src/netplay/NetplayServer/index.ts +1407 -0
  113. package/src/netplay/SyncManager/index.ts +984 -0
  114. package/src/netplay/SyncManager/tests.ts +419 -0
  115. package/src/netplay/consts.ts +371 -0
  116. package/src/netplay/crc32/consts.ts +14 -0
  117. package/src/netplay/crc32/index.ts +97 -0
  118. package/src/netplay/crc32/tests.ts +40 -0
  119. package/src/netplay/index.ts +41 -0
  120. package/src/netplay/netplayLogger/consts.ts +30 -0
  121. package/src/netplay/netplayLogger/index.ts +345 -0
  122. package/src/netplay/protocol/consts.ts +86 -0
  123. package/src/netplay/protocol/index.ts +1280 -0
  124. package/src/netplay/protocol/tests.ts +606 -0
  125. package/src/netplay/protocol/types.ts +20 -0
  126. package/src/netplay/types.ts +395 -0
  127. package/src/rendering/KittyRenderer/index.ts +616 -0
  128. package/src/rendering/NativeRenderer/index.ts +279 -0
  129. package/src/rendering/NativeRenderer/tests.ts +133 -0
  130. package/src/rendering/TerminalRenderer/index.ts +770 -0
  131. package/src/rendering/consts.ts +401 -0
  132. package/src/rendering/fonts/CozetteVector.ttf +0 -0
  133. package/src/rendering/index.ts +26 -0
  134. package/src/rendering/nativeUi/NativeWindowManager/index.ts +158 -0
  135. package/src/rendering/nativeUi/NativeWindowManager/tests.ts +81 -0
  136. package/src/rendering/nativeUi/consts.ts +6 -0
  137. package/src/rendering/nativeUi/index.ts +20 -0
  138. package/src/rendering/postProcessing/consts.ts +38 -0
  139. package/src/rendering/postProcessing/index.ts +923 -0
  140. package/src/rendering/shared/ansi/consts.ts +12 -0
  141. package/src/rendering/shared/ansi/index.ts +104 -0
  142. package/src/rendering/shared/consts.ts +2 -0
  143. package/src/rendering/shared/fitToTerminal/index.ts +67 -0
  144. package/src/ui/AddRomsPrompt/consts.ts +13 -0
  145. package/src/ui/AddRomsPrompt/index.tsx +781 -0
  146. package/src/ui/App/consts.ts +2 -0
  147. package/src/ui/App/index.tsx +456 -0
  148. package/src/ui/AppCapabilities/index.tsx +67 -0
  149. package/src/ui/ConfigContext/index.tsx +56 -0
  150. package/src/ui/CoreManager/consts.ts +11 -0
  151. package/src/ui/CoreManager/index.tsx +779 -0
  152. package/src/ui/CoreSelector/consts.ts +2 -0
  153. package/src/ui/CoreSelector/index.tsx +251 -0
  154. package/src/ui/DialogContainer/index.tsx +42 -0
  155. package/src/ui/DialogOptionsList/index.tsx +61 -0
  156. package/src/ui/DuplicateCrcPrompt/consts.ts +5 -0
  157. package/src/ui/DuplicateCrcPrompt/index.tsx +146 -0
  158. package/src/ui/GamepadContext/consts.ts +15 -0
  159. package/src/ui/GamepadContext/index.tsx +295 -0
  160. package/src/ui/NativeDialog/index.tsx +120 -0
  161. package/src/ui/NetplayDisconnectedDialog/index.tsx +93 -0
  162. package/src/ui/NetplayPauseMenu/consts.ts +2 -0
  163. package/src/ui/NetplayPauseMenu/index.tsx +97 -0
  164. package/src/ui/RomBrowser/NetplayPanel/consts.ts +24 -0
  165. package/src/ui/RomBrowser/NetplayPanel/index.tsx +520 -0
  166. package/src/ui/RomBrowser/SettingsPanel/index.tsx +478 -0
  167. package/src/ui/RomBrowser/consts.ts +61 -0
  168. package/src/ui/RomBrowser/index.tsx +1164 -0
  169. package/src/ui/RomBrowser/settingsConfig/index.ts +320 -0
  170. package/src/ui/RomBrowser/types.ts +67 -0
  171. package/src/ui/SaveStateDialog/consts.ts +2 -0
  172. package/src/ui/SaveStateDialog/index.tsx +225 -0
  173. package/src/ui/WarningDialog/index.tsx +113 -0
  174. package/src/ui/consts.ts +27 -0
  175. package/src/ui/hooks/useClearTerminal/consts.ts +2 -0
  176. package/src/ui/hooks/useClearTerminal/index.ts +37 -0
  177. package/src/ui/hooks/useDialogNavigation/index.ts +99 -0
  178. package/src/ui/hooks/useGamepad/consts.ts +21 -0
  179. package/src/ui/hooks/useGamepad/index.ts +194 -0
  180. package/src/ui/index.ts +27 -0
  181. package/src/utils/buffer/consts.ts +17 -0
  182. package/src/utils/buffer/index.ts +129 -0
  183. package/src/utils/color/consts.ts +58 -0
  184. package/src/utils/color/index.ts +183 -0
  185. package/src/utils/compression/consts.ts +50 -0
  186. package/src/utils/compression/index.ts +101 -0
  187. package/src/utils/consts.ts +2 -0
  188. package/src/utils/crc32/consts.ts +22 -0
  189. package/src/utils/crc32/index.ts +83 -0
  190. package/src/utils/ensureDirectory/index.ts +10 -0
  191. package/src/utils/format/consts.ts +8 -0
  192. package/src/utils/format/index.ts +53 -0
  193. package/src/utils/getErrorMessage/index.ts +10 -0
  194. package/src/utils/index.ts +113 -0
  195. package/src/utils/ini/index.ts +200 -0
  196. package/src/utils/kitty/consts.ts +13 -0
  197. package/src/utils/kitty/index.ts +181 -0
  198. package/src/utils/logger/consts.ts +35 -0
  199. package/src/utils/logger/index.ts +217 -0
  200. package/src/utils/paths/consts.ts +18 -0
  201. package/src/utils/paths/index.ts +151 -0
  202. package/src/utils/png/consts.ts +34 -0
  203. package/src/utils/png/index.ts +131 -0
  204. package/src/utils/readJsonFile/index.ts +16 -0
  205. package/src/utils/rotateLogFile/index.ts +44 -0
  206. package/src/utils/safeClose/index.ts +10 -0
  207. package/src/utils/terminal/consts.ts +8 -0
  208. package/src/utils/terminal/index.ts +102 -0
  209. package/src/utils/thumbnailRenderer/consts.ts +2 -0
  210. package/src/utils/thumbnailRenderer/index.ts +147 -0
  211. package/src/utils/typedError/index.ts +26 -0
  212. package/tsconfig.json +31 -0
  213. 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';