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,559 @@
1
+ /**
2
+ * RetroArch Playlist Reader
3
+ *
4
+ * Reads and parses RetroArch-compatible .lpl playlist files.
5
+ * Supports both JSON format (1.5+) and converts entries to RomInfo for the browser.
6
+ */
7
+
8
+ import { readFileSync, existsSync, statSync, readdirSync, writeFileSync } from 'fs';
9
+ import { join, dirname, basename, extname, resolve } from 'path';
10
+ import { isPlaylistFile } from '..';
11
+ import type { PlaylistFile, PlaylistEntry } from '..';
12
+ import type { RomInfo, RomMetadata } from '../../romScanner';
13
+ import { sortRoms } from '../../romScanner';
14
+ import { findMatchingCores } from '../../coreRegistry';
15
+ import { PLAYLIST_EXTENSION, RETROARCH_DATABASE_NAMES } from '..';
16
+ import { resolvePath } from '../utils';
17
+ import { getSaveStateService, getBatterySaveService } from '../../serviceProvider';
18
+ import { logger } from '@/utils/logger';
19
+ import { getErrorMessage } from '@/utils/getErrorMessage';
20
+
21
+ // =============================================================================
22
+ // Types
23
+ // =============================================================================
24
+
25
+ /**
26
+ * Result of reading a playlist file
27
+ */
28
+ export interface PlaylistReadResult {
29
+ /** Whether the read was successful */
30
+ success: boolean;
31
+ /** The parsed playlist, if successful */
32
+ playlist?: PlaylistFile;
33
+ /** Path to the playlist file */
34
+ path: string;
35
+ /** Error message if read failed */
36
+ error?: string;
37
+ }
38
+
39
+ /**
40
+ * Information about a discovered playlist
41
+ */
42
+ export interface PlaylistInfo {
43
+ /** Full path to the playlist file */
44
+ path: string;
45
+ /** Playlist filename */
46
+ filename: string;
47
+ /** System name derived from db_name (e.g., "Nintendo - NES") */
48
+ systemName: string;
49
+ /** Number of entries in the playlist */
50
+ entryCount: number;
51
+ /** Last modified date of the playlist file */
52
+ modified: Date;
53
+ }
54
+
55
+ /**
56
+ * Options for converting playlist entries to RomInfo
57
+ */
58
+ export interface ConversionOptions {
59
+ /** Base directory to resolve relative paths against */
60
+ baseDirectory?: string;
61
+ /** Whether to validate that ROM files exist */
62
+ validateFiles?: boolean;
63
+ /** Whether to check for save states */
64
+ checkSaveStates?: boolean;
65
+ /**
66
+ * Whether to persist migrated lastPlayed dates to the playlist file.
67
+ * When enabled, ROMs with save states but no last_played in the playlist
68
+ * will have their last_played updated from the save state's savedAt timestamp.
69
+ * Default: true
70
+ */
71
+ persistMigratedDates?: boolean;
72
+ }
73
+
74
+ // =============================================================================
75
+ // Constants
76
+ // =============================================================================
77
+
78
+ /** Default file size for entries where the file doesn't exist */
79
+ const UNKNOWN_FILE_SIZE = 0;
80
+
81
+ /** Minimum playlist version we support */
82
+ const MIN_PLAYLIST_VERSION = '1.0';
83
+
84
+ /** Seconds per minute */
85
+ const SECONDS_PER_MINUTE = 60;
86
+
87
+ /** Seconds per hour */
88
+ const SECONDS_PER_HOUR = 3600;
89
+
90
+ // =============================================================================
91
+ // Helper Functions
92
+ // =============================================================================
93
+
94
+ /**
95
+ * Convert RetroArch runtime fields to total seconds
96
+ */
97
+ const parseRuntimeSeconds = (entry: PlaylistEntry): number | undefined => {
98
+ const hours = entry.runtime_hours ?? 0;
99
+ const minutes = entry.runtime_minutes ?? 0;
100
+ const seconds = entry.runtime_seconds ?? 0;
101
+
102
+ // If all are 0 or undefined, return undefined
103
+ if (hours === 0 && minutes === 0 && seconds === 0) {
104
+ return undefined;
105
+ }
106
+
107
+ return hours * SECONDS_PER_HOUR + minutes * SECONDS_PER_MINUTE + seconds;
108
+ };
109
+
110
+ /**
111
+ * Convert RetroArch last_played fields to a Date object
112
+ */
113
+ const parseLastPlayed = (entry: PlaylistEntry): Date | undefined => {
114
+ const year = entry.last_played_year;
115
+ const month = entry.last_played_month;
116
+ const day = entry.last_played_day;
117
+
118
+ // Year is required for a valid date
119
+ if (year === undefined || year === 0) {
120
+ return undefined;
121
+ }
122
+
123
+ // Create date with available fields (default to 1 for missing month/day)
124
+ return new Date(
125
+ year,
126
+ (month ?? 1) - 1, // JavaScript months are 0-indexed
127
+ day ?? 1,
128
+ entry.last_played_hour ?? 0,
129
+ entry.last_played_minute ?? 0,
130
+ entry.last_played_second ?? 0
131
+ );
132
+ };
133
+
134
+ /**
135
+ * Convert a Date to RetroArch last_played fields
136
+ */
137
+ const dateToLastPlayed = (date: Date): {
138
+ year: number;
139
+ month: number;
140
+ day: number;
141
+ hour: number;
142
+ minute: number;
143
+ second: number;
144
+ } => ({
145
+ year: date.getFullYear(),
146
+ month: date.getMonth() + 1, // JavaScript months are 0-indexed
147
+ day: date.getDate(),
148
+ hour: date.getHours(),
149
+ minute: date.getMinutes(),
150
+ second: date.getSeconds(),
151
+ });
152
+
153
+ /**
154
+ * Extract system name from database name (e.g., "Nintendo - NES.lpl" -> "Nintendo - NES")
155
+ */
156
+ const getSystemNameFromDbName = (dbName: string): string => {
157
+ if (!dbName) {
158
+ return 'Unknown System';
159
+ }
160
+ // Remove .lpl extension if present
161
+ return dbName.endsWith(PLAYLIST_EXTENSION)
162
+ ? dbName.slice(0, -PLAYLIST_EXTENSION.length)
163
+ : dbName;
164
+ };
165
+
166
+ /**
167
+ * Get system ID from database name by looking up in our mappings
168
+ */
169
+ const getSystemIdFromDbName = (dbName: string): string => {
170
+ // Find extension that maps to this db_name
171
+ for (const [key, value] of Object.entries(RETROARCH_DATABASE_NAMES)) {
172
+ if (value === dbName && key.startsWith('.')) {
173
+ // Return the extension without the dot as a fallback system ID
174
+ return key.slice(1);
175
+ }
176
+ }
177
+ return 'unknown';
178
+ };
179
+
180
+ /**
181
+ * Format file size to human-readable string
182
+ */
183
+ const formatSize = (bytes: number): string => {
184
+ const KB = 1024;
185
+ const MB = KB * KB;
186
+ if (bytes < KB) {
187
+ return `${bytes} B`;
188
+ }
189
+ if (bytes < MB) {
190
+ return `${(bytes / KB).toFixed(1)} KB`;
191
+ }
192
+ return `${(bytes / MB).toFixed(1)} MB`;
193
+ };
194
+
195
+ /**
196
+ * Check if a file exists and get its stats
197
+ */
198
+ const getFileStats = (filePath: string): { exists: boolean; size: number; modified: Date } | null => {
199
+ try {
200
+ if (existsSync(filePath)) {
201
+ const stats = statSync(filePath);
202
+ if (stats.isFile()) {
203
+ return {
204
+ exists: true,
205
+ size: stats.size,
206
+ modified: stats.mtime,
207
+ };
208
+ }
209
+ }
210
+ } catch {
211
+ // File doesn't exist or can't be read
212
+ }
213
+ return null;
214
+ };
215
+
216
+ // =============================================================================
217
+ // Main Functions
218
+ // =============================================================================
219
+
220
+ /**
221
+ * Read and parse a playlist file
222
+ */
223
+ export const readPlaylist = (playlistPath: string): PlaylistReadResult => {
224
+ try {
225
+ if (!existsSync(playlistPath)) {
226
+ return {
227
+ success: false,
228
+ path: playlistPath,
229
+ error: 'Playlist file not found',
230
+ };
231
+ }
232
+
233
+ const content = readFileSync(playlistPath, 'utf-8');
234
+
235
+ // Try to parse as JSON
236
+ let parsed: unknown;
237
+ try {
238
+ parsed = JSON.parse(content);
239
+ } catch {
240
+ return {
241
+ success: false,
242
+ path: playlistPath,
243
+ error: 'Invalid JSON format',
244
+ };
245
+ }
246
+
247
+ if (!isPlaylistFile(parsed)) {
248
+ return {
249
+ success: false,
250
+ path: playlistPath,
251
+ error: 'Missing or invalid items array',
252
+ };
253
+ }
254
+
255
+ const playlist = parsed;
256
+
257
+ // Validate required fields
258
+ if (!playlist.version) {
259
+ return {
260
+ success: false,
261
+ path: playlistPath,
262
+ error: 'Missing version field',
263
+ };
264
+ }
265
+
266
+ // Check version compatibility
267
+ const version = parseFloat(playlist.version);
268
+ if (version < parseFloat(MIN_PLAYLIST_VERSION)) {
269
+ return {
270
+ success: false,
271
+ path: playlistPath,
272
+ error: `Unsupported playlist version: ${playlist.version}`,
273
+ };
274
+ }
275
+
276
+ // Log successful playlist load (RetroArch-style)
277
+ logger.info(`Loading playlist file: "${playlistPath}"`, 'Playlist');
278
+
279
+ return {
280
+ success: true,
281
+ playlist,
282
+ path: playlistPath,
283
+ };
284
+ } catch (err) {
285
+ logger.warn(`Failed to read playlist: "${playlistPath}"`, 'Playlist');
286
+ return {
287
+ success: false,
288
+ path: playlistPath,
289
+ error: getErrorMessage(err),
290
+ };
291
+ }
292
+ };
293
+
294
+ /**
295
+ * Convert a playlist entry to RomInfo for the browser
296
+ */
297
+ export const playlistEntryToRomInfo = (
298
+ entry: PlaylistEntry,
299
+ _playlist: PlaylistFile,
300
+ playlistPath: string,
301
+ options: ConversionOptions = {}
302
+ ): RomInfo | null => {
303
+ const {
304
+ baseDirectory,
305
+ validateFiles = true,
306
+ checkSaveStates = true,
307
+ } = options;
308
+
309
+ // Resolve the ROM path
310
+ const playlistDir = dirname(playlistPath);
311
+ const resolvedPath = resolvePath(entry.path, baseDirectory ?? playlistDir);
312
+
313
+ // Get file stats if validation is enabled
314
+ const fileStats = validateFiles ? getFileStats(resolvedPath) : null;
315
+
316
+ // Skip entries where the file doesn't exist (if validation enabled)
317
+ if (validateFiles && !fileStats) {
318
+ return null;
319
+ }
320
+
321
+ const ext = extname(resolvedPath).toLowerCase();
322
+ const filename = basename(resolvedPath);
323
+
324
+ // Get matching cores for this file
325
+ const matchingCores = findMatchingCores(resolvedPath);
326
+ const coreIds = matchingCores.map(c => c.id);
327
+
328
+ // Determine system info
329
+ const systemName = getSystemNameFromDbName(entry.db_name);
330
+ let systemId = getSystemIdFromDbName(entry.db_name);
331
+
332
+ // If we have matching cores, use the first one's info
333
+ if (matchingCores.length > 0) {
334
+ systemId = matchingCores[0].id;
335
+ }
336
+
337
+ // Check for save data using services
338
+ const saveStateInfo = checkSaveStates
339
+ ? getSaveStateService().checkExists(resolvedPath)
340
+ : { exists: false };
341
+ const batterySaveInfo = checkSaveStates
342
+ ? getBatterySaveService().checkExists(resolvedPath)
343
+ : { exists: false };
344
+
345
+ // Build metadata from the playlist entry
346
+ const metadata: RomMetadata = {
347
+ title: entry.label || undefined,
348
+ };
349
+
350
+ // Parse runtime data from playlist entry
351
+ const runtimeSeconds = parseRuntimeSeconds(entry);
352
+ const lastPlayed = parseLastPlayed(entry);
353
+
354
+ return {
355
+ path: resolvedPath,
356
+ filename,
357
+ extension: ext,
358
+ size: fileStats?.size ?? UNKNOWN_FILE_SIZE,
359
+ sizeFormatted: formatSize(fileStats?.size ?? UNKNOWN_FILE_SIZE),
360
+ modified: fileStats?.modified ?? new Date(),
361
+ system: systemName,
362
+ systemId,
363
+ coreCount: matchingCores.length,
364
+ coreIds,
365
+ metadata,
366
+ hasSaveState: saveStateInfo.exists,
367
+ saveStateDate: saveStateInfo.date,
368
+ hasBatterySave: batterySaveInfo.exists,
369
+ batterySaveDate: batterySaveInfo.date,
370
+ runtimeSeconds,
371
+ lastPlayed,
372
+ label: entry.label || undefined,
373
+ };
374
+ };
375
+
376
+ /**
377
+ * Convert all entries in a playlist to RomInfo array
378
+ */
379
+ export const playlistToRomInfoArray = (
380
+ playlist: PlaylistFile,
381
+ playlistPath: string,
382
+ options: ConversionOptions = {}
383
+ ): RomInfo[] => {
384
+ const results: RomInfo[] = [];
385
+ const { persistMigratedDates = true } = options;
386
+
387
+ // Track entries that need last_played migration (entry index -> Date)
388
+ const migrationsNeeded = new Map<number, Date>();
389
+
390
+ for (let i = 0; i < playlist.items.length; i++) {
391
+ const entry = playlist.items[i];
392
+ const romInfo = playlistEntryToRomInfo(entry, playlist, playlistPath, options);
393
+ if (romInfo) {
394
+ results.push(romInfo);
395
+
396
+ // Check if this entry was migrated (had no last_played but now has lastPlayed)
397
+ const hadLastPlayed = entry.last_played_year !== undefined && entry.last_played_year !== 0;
398
+ if (!hadLastPlayed && romInfo.lastPlayed) {
399
+ migrationsNeeded.set(i, romInfo.lastPlayed);
400
+ }
401
+ }
402
+ }
403
+
404
+ // Batch update playlist with migrated last_played dates
405
+ if (persistMigratedDates && migrationsNeeded.size > 0) {
406
+ try {
407
+ // Read the playlist fresh to avoid stale data
408
+ const content = readFileSync(playlistPath, 'utf-8');
409
+ const parsed: unknown = JSON.parse(content);
410
+
411
+ if (isPlaylistFile(parsed)) {
412
+ // Update all migrated entries
413
+ for (const [entryIndex, lastPlayed] of migrationsNeeded) {
414
+ if (entryIndex < parsed.items.length) {
415
+ const entry = parsed.items[entryIndex];
416
+ const fields = dateToLastPlayed(lastPlayed);
417
+ entry.last_played_year = fields.year;
418
+ entry.last_played_month = fields.month;
419
+ entry.last_played_day = fields.day;
420
+ entry.last_played_hour = fields.hour;
421
+ entry.last_played_minute = fields.minute;
422
+ entry.last_played_second = fields.second;
423
+ }
424
+ }
425
+
426
+ // Write back the updated playlist
427
+ writeFileSync(playlistPath, JSON.stringify(parsed, null, 2), 'utf-8');
428
+ }
429
+ } catch {
430
+ // Silently fail - migration is best-effort
431
+ }
432
+ }
433
+
434
+ sortRoms(results);
435
+
436
+ return results;
437
+ };
438
+
439
+ /**
440
+ * Find all playlist files in a directory
441
+ */
442
+ export const findPlaylistsInDirectory = (directory: string): PlaylistInfo[] => {
443
+ const playlists: PlaylistInfo[] = [];
444
+
445
+ try {
446
+ const entries = readdirSync(directory);
447
+
448
+ for (const entry of entries) {
449
+ if (!entry.toLowerCase().endsWith(PLAYLIST_EXTENSION)) {
450
+ continue;
451
+ }
452
+
453
+ const fullPath = join(directory, entry);
454
+
455
+ try {
456
+ const stats = statSync(fullPath);
457
+ if (!stats.isFile()) {
458
+ continue;
459
+ }
460
+
461
+ // Read the playlist to get entry count and system name
462
+ const result = readPlaylist(fullPath);
463
+ if (!result.success || !result.playlist) {
464
+ continue;
465
+ }
466
+
467
+ // Derive system name from filename or first entry's db_name
468
+ let systemName = basename(entry, PLAYLIST_EXTENSION);
469
+ if (result.playlist.items.length > 0 && result.playlist.items[0].db_name) {
470
+ systemName = getSystemNameFromDbName(result.playlist.items[0].db_name);
471
+ }
472
+
473
+ playlists.push({
474
+ path: fullPath,
475
+ filename: entry,
476
+ systemName,
477
+ entryCount: result.playlist.items.length,
478
+ modified: stats.mtime,
479
+ });
480
+ } catch {
481
+ // Skip files we can't read
482
+ }
483
+ }
484
+ } catch {
485
+ // Directory doesn't exist or can't be read
486
+ }
487
+
488
+ // Sort by system name
489
+ playlists.sort((a, b) => a.systemName.localeCompare(b.systemName));
490
+
491
+ return playlists;
492
+ };
493
+
494
+ /**
495
+ * Find playlists that contain ROMs from a specific directory
496
+ */
497
+ export const findPlaylistsForDirectory = (
498
+ romDirectory: string,
499
+ playlistDirectory: string
500
+ ): PlaylistInfo[] => {
501
+ const allPlaylists = findPlaylistsInDirectory(playlistDirectory);
502
+ const matchingPlaylists: PlaylistInfo[] = [];
503
+
504
+ const normalizedRomDir = resolve(romDirectory).toLowerCase();
505
+
506
+ for (const playlistInfo of allPlaylists) {
507
+ const result = readPlaylist(playlistInfo.path);
508
+ if (!result.success || !result.playlist) {
509
+ continue;
510
+ }
511
+
512
+ // Check if any entry's path starts with the ROM directory
513
+ const hasMatchingEntries = result.playlist.items.some(entry => {
514
+ const resolvedPath = resolvePath(entry.path, dirname(playlistInfo.path));
515
+ return resolve(resolvedPath).toLowerCase().startsWith(normalizedRomDir);
516
+ });
517
+
518
+ if (hasMatchingEntries) {
519
+ matchingPlaylists.push(playlistInfo);
520
+ }
521
+ }
522
+
523
+ return matchingPlaylists;
524
+ };
525
+
526
+ /**
527
+ * Load ROMs from all playlists in a directory
528
+ */
529
+ export const loadRomsFromPlaylists = (
530
+ playlistDirectory: string,
531
+ options: ConversionOptions = {}
532
+ ): RomInfo[] => {
533
+ const playlists = findPlaylistsInDirectory(playlistDirectory);
534
+ const allRoms: RomInfo[] = [];
535
+ const seenPaths = new Set<string>();
536
+
537
+ for (const playlistInfo of playlists) {
538
+ const result = readPlaylist(playlistInfo.path);
539
+ if (!result.success || !result.playlist) {
540
+ continue;
541
+ }
542
+
543
+ const roms = playlistToRomInfoArray(result.playlist, playlistInfo.path, options);
544
+
545
+ // Deduplicate by path
546
+ for (const rom of roms) {
547
+ const normalizedPath = rom.path.toLowerCase();
548
+ if (!seenPaths.has(normalizedPath)) {
549
+ seenPaths.add(normalizedPath);
550
+ allRoms.push(rom);
551
+ }
552
+ }
553
+ }
554
+
555
+ // Re-sort the combined list
556
+ sortRoms(allRoms);
557
+
558
+ return allRoms;
559
+ };