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,511 @@
1
+ /**
2
+ * Playlist Synchronization
3
+ *
4
+ * Detects and syncs changes between ROM directories and playlists:
5
+ * - Adds new ROM files not in any playlist
6
+ * - Removes playlist entries for files that no longer exist
7
+ * - Detects moves (same CRC32, different path) and updates paths while preserving metadata
8
+ */
9
+
10
+ import { existsSync, unlinkSync } from 'fs';
11
+ import { dirname, basename } from 'path';
12
+ import type { RomInfo, ScanProgress } from '../../romScanner';
13
+ import { scanDirectoryAsync } from '../../romScanner';
14
+ import type { PlaylistFile, PlaylistEntry } from '..';
15
+ import {
16
+ readPlaylist,
17
+ writePlaylist,
18
+ generatePlaylistsBySystem,
19
+ findPlaylistsInDirectory,
20
+ } from '..';
21
+ import { normalizePath, resolvePath } from '../utils';
22
+ import { formatRomLabel } from '../labelFormatter';
23
+
24
+ // =============================================================================
25
+ // Types
26
+ // =============================================================================
27
+
28
+ /**
29
+ * A playlist entry with context about which playlist it belongs to
30
+ */
31
+ export interface PlaylistEntryWithContext {
32
+ /** The playlist entry */
33
+ entry: PlaylistEntry;
34
+ /** Path to the playlist file */
35
+ playlistPath: string;
36
+ /** Index within the playlist's items array */
37
+ entryIndex: number;
38
+ }
39
+
40
+ /**
41
+ * Entry in a playlist whose file no longer exists
42
+ */
43
+ export interface MissingEntry {
44
+ /** The playlist entry */
45
+ entry: PlaylistEntry;
46
+ /** Path to the playlist file */
47
+ playlistPath: string;
48
+ /** Index within the playlist's items array */
49
+ entryIndex: number;
50
+ /** Normalized path to the missing file */
51
+ normalizedPath: string;
52
+ }
53
+
54
+ /**
55
+ * A ROM that was moved (detected via CRC32 match)
56
+ */
57
+ export interface MovedRom {
58
+ /** The ROM found at the new location */
59
+ rom: RomInfo;
60
+ /** The original playlist entry (at old location) */
61
+ originalEntry: MissingEntry;
62
+ }
63
+
64
+ /**
65
+ * A ROM that has the same CRC32 as an existing entry,
66
+ * but both files exist (different from a move where original is missing)
67
+ */
68
+ export interface DuplicateCrcRom {
69
+ /** The new ROM being imported */
70
+ newRom: RomInfo;
71
+ /** The existing playlist entry with matching CRC32 */
72
+ existingEntry: PlaylistEntryWithContext;
73
+ /** The shared CRC32 value */
74
+ crc32: string;
75
+ }
76
+
77
+ /**
78
+ * Result of analyzing playlists for synchronization
79
+ */
80
+ export interface SyncAnalysis {
81
+ /** ROMs in directory but not in any playlist */
82
+ newRoms: RomInfo[];
83
+ /** Playlist entries with no file on disk */
84
+ missingEntries: MissingEntry[];
85
+ /** ROMs that were moved (matched by CRC32) */
86
+ movedRoms: MovedRom[];
87
+ /** ROMs with same CRC32 as existing entries (both files exist) */
88
+ duplicateCrcRoms: DuplicateCrcRom[];
89
+ /** Whether any sync action is needed */
90
+ needsSync: boolean;
91
+ }
92
+
93
+ /**
94
+ * Options for syncing playlists
95
+ */
96
+ export interface SyncOptions {
97
+ /** Delete empty playlists after removing entries */
98
+ deleteEmptyPlaylists?: boolean;
99
+ }
100
+
101
+ /**
102
+ * Result of synchronizing playlists
103
+ */
104
+ export interface SyncResult {
105
+ /** Whether sync completed successfully */
106
+ success: boolean;
107
+ /** Number of ROMs added to playlists */
108
+ added: number;
109
+ /** Number of entries removed from playlists */
110
+ removed: number;
111
+ /** Number of entries updated (moved ROMs) */
112
+ moved: number;
113
+ /** Number of duplicate entries where path was updated */
114
+ duplicatesUpdated: number;
115
+ /** Number of duplicate entries that were skipped */
116
+ duplicatesSkipped: number;
117
+ /** Any errors that occurred */
118
+ errors: string[];
119
+ }
120
+
121
+ /**
122
+ * Choice for how to handle a duplicate CRC ROM
123
+ */
124
+ export type DuplicateCrcChoice = 'update' | 'skip';
125
+
126
+ /**
127
+ * A decision made by the user for a duplicate CRC ROM
128
+ */
129
+ export interface DuplicateDecision {
130
+ duplicate: DuplicateCrcRom;
131
+ choice: DuplicateCrcChoice;
132
+ }
133
+
134
+ // =============================================================================
135
+ // Helper Functions
136
+ // =============================================================================
137
+
138
+ /**
139
+ * Load all playlists from a directory and return entries that are within the target ROM directory
140
+ */
141
+ const loadPlaylistEntriesForDirectory = (
142
+ playlistDirectory: string,
143
+ romDirectory: string
144
+ ): { entries: PlaylistEntryWithContext[]; playlists: Map<string, PlaylistFile> } => {
145
+ const entries: PlaylistEntryWithContext[] = [];
146
+ const playlists = new Map<string, PlaylistFile>();
147
+ const normalizedRomDir = normalizePath(romDirectory).toLowerCase();
148
+
149
+ const playlistFiles = findPlaylistsInDirectory(playlistDirectory);
150
+
151
+ for (const playlistInfo of playlistFiles) {
152
+ const result = readPlaylist(playlistInfo.path);
153
+ if (!result.success || !result.playlist) {
154
+ continue;
155
+ }
156
+
157
+ playlists.set(playlistInfo.path, result.playlist);
158
+ const playlistDir = dirname(playlistInfo.path);
159
+
160
+ for (let i = 0; i < result.playlist.items.length; i++) {
161
+ const entry = result.playlist.items[i];
162
+ const resolvedPath = resolvePath(entry.path, playlistDir);
163
+ const normalizedEntryPath = normalizePath(resolvedPath).toLowerCase();
164
+
165
+ // Only include entries within the target ROM directory
166
+ if (normalizedEntryPath.startsWith(normalizedRomDir)) {
167
+ entries.push({
168
+ entry,
169
+ playlistPath: playlistInfo.path,
170
+ entryIndex: i,
171
+ });
172
+ }
173
+ }
174
+ }
175
+
176
+ return { entries, playlists };
177
+ };
178
+
179
+ /**
180
+ * Build a path index from playlist entries for O(1) lookups
181
+ */
182
+ const buildPathIndex = (entries: PlaylistEntryWithContext[]): Map<string, PlaylistEntryWithContext> => {
183
+ const index = new Map<string, PlaylistEntryWithContext>();
184
+
185
+ for (const entryWithContext of entries) {
186
+ const playlistDir = dirname(entryWithContext.playlistPath);
187
+ const resolvedPath = resolvePath(entryWithContext.entry.path, playlistDir);
188
+ const normalizedPath = normalizePath(resolvedPath);
189
+ index.set(normalizedPath, entryWithContext);
190
+ }
191
+
192
+ return index;
193
+ };
194
+
195
+ // =============================================================================
196
+ // Main Functions
197
+ // =============================================================================
198
+
199
+ /**
200
+ * Analyze a directory and its playlists for synchronization needs.
201
+ *
202
+ * @param romDirectory - Directory containing ROM files
203
+ * @param playlistDirectory - Directory containing playlist files
204
+ * @param scanDepth - Max depth for scanning subdirectories
205
+ * @param onProgress - Optional progress callback
206
+ * @param signal - Optional abort signal for cancellation
207
+ * @returns Analysis of what needs to be synced
208
+ */
209
+ export const analyzePlaylistSync = async (
210
+ romDirectory: string,
211
+ playlistDirectory: string,
212
+ scanDepth: number,
213
+ onProgress?: (progress: ScanProgress) => void,
214
+ signal?: AbortSignal
215
+ ): Promise<SyncAnalysis> => {
216
+ // Load all playlist entries for this directory
217
+ const { entries } = loadPlaylistEntriesForDirectory(playlistDirectory, romDirectory);
218
+
219
+ // Build indexes for fast lookups
220
+ const pathIndex = buildPathIndex(entries);
221
+
222
+ // Scan directory for current ROMs (CRC cache built automatically from playlists)
223
+ const currentRoms = await scanDirectoryAsync(romDirectory, scanDepth, onProgress, signal);
224
+
225
+ // Find new ROMs (in filesystem, not in playlists)
226
+ const newRoms: RomInfo[] = [];
227
+ const currentRomPaths = new Set<string>();
228
+
229
+ for (const rom of currentRoms) {
230
+ const normalizedPath = normalizePath(rom.path);
231
+ currentRomPaths.add(normalizedPath);
232
+
233
+ if (!pathIndex.has(normalizedPath)) {
234
+ newRoms.push(rom);
235
+ }
236
+ }
237
+
238
+ // Find missing entries (in playlists, file doesn't exist)
239
+ const missingEntries: MissingEntry[] = [];
240
+
241
+ for (const entryWithContext of entries) {
242
+ const playlistDir = dirname(entryWithContext.playlistPath);
243
+ const resolvedPath = resolvePath(entryWithContext.entry.path, playlistDir);
244
+ const normalizedPath = normalizePath(resolvedPath);
245
+
246
+ // Check if file still exists
247
+ if (!currentRomPaths.has(normalizedPath) && !existsSync(resolvedPath)) {
248
+ missingEntries.push({
249
+ entry: entryWithContext.entry,
250
+ playlistPath: entryWithContext.playlistPath,
251
+ entryIndex: entryWithContext.entryIndex,
252
+ normalizedPath,
253
+ });
254
+ }
255
+ }
256
+
257
+ // Match moves: missing entries with same CRC32 as new ROMs
258
+ const movedRoms: MovedRom[] = [];
259
+ const matchedNewRomPaths = new Set<string>();
260
+ const matchedMissingIndices = new Set<number>();
261
+
262
+ for (let i = 0; i < missingEntries.length; i++) {
263
+ const missing = missingEntries[i];
264
+ const missingCrc = missing.entry.crc32;
265
+
266
+ // Skip if no valid CRC
267
+ if (!missingCrc || missingCrc === 'DETECT') {
268
+ continue;
269
+ }
270
+
271
+ // Find a new ROM with matching CRC
272
+ for (const rom of newRoms) {
273
+ if (matchedNewRomPaths.has(rom.path)) {
274
+ continue;
275
+ }
276
+
277
+ if (rom.crc32 === missingCrc) {
278
+ movedRoms.push({
279
+ rom,
280
+ originalEntry: missing,
281
+ });
282
+ matchedNewRomPaths.add(rom.path);
283
+ matchedMissingIndices.add(i);
284
+ break; // Only match first hit
285
+ }
286
+ }
287
+ }
288
+
289
+ // Filter out matched items from newRoms and missingEntries
290
+ const filteredNewRoms = newRoms.filter(rom => !matchedNewRomPaths.has(rom.path));
291
+ const filteredMissingEntries = missingEntries.filter((_, i) => !matchedMissingIndices.has(i));
292
+
293
+ // Build CRC index from ALL existing playlist entries (not just missing ones)
294
+ const existingCrcIndex = new Map<string, PlaylistEntryWithContext>();
295
+ for (const entryWithContext of entries) {
296
+ const crc = entryWithContext.entry.crc32;
297
+ if (crc && crc !== 'DETECT') {
298
+ existingCrcIndex.set(crc, entryWithContext);
299
+ }
300
+ }
301
+
302
+ // Detect duplicate CRCs: new ROMs that match existing entries where file still exists
303
+ const duplicateCrcRoms: DuplicateCrcRom[] = [];
304
+ const duplicateNewRomPaths = new Set<string>();
305
+
306
+ for (const rom of filteredNewRoms) {
307
+ if (!rom.crc32 || rom.crc32 === 'DETECT') {
308
+ continue;
309
+ }
310
+
311
+ const existingEntry = existingCrcIndex.get(rom.crc32);
312
+ if (existingEntry) {
313
+ // Verify existing file still exists (not a move scenario)
314
+ const playlistDir = dirname(existingEntry.playlistPath);
315
+ const resolvedPath = resolvePath(existingEntry.entry.path, playlistDir);
316
+ if (existsSync(resolvedPath)) {
317
+ duplicateCrcRoms.push({
318
+ newRom: rom,
319
+ existingEntry,
320
+ crc32: rom.crc32,
321
+ });
322
+ duplicateNewRomPaths.add(rom.path);
323
+ }
324
+ }
325
+ }
326
+
327
+ // Filter duplicates from newRoms - they'll be handled separately via prompt
328
+ const finalNewRoms = filteredNewRoms.filter(rom => !duplicateNewRomPaths.has(rom.path));
329
+
330
+ return {
331
+ newRoms: finalNewRoms,
332
+ missingEntries: filteredMissingEntries,
333
+ movedRoms,
334
+ duplicateCrcRoms,
335
+ needsSync: finalNewRoms.length > 0 || filteredMissingEntries.length > 0 ||
336
+ movedRoms.length > 0 || duplicateCrcRoms.length > 0,
337
+ };
338
+ };
339
+
340
+ /**
341
+ * Apply synchronization changes to playlists.
342
+ *
343
+ * @param analysis - The sync analysis result
344
+ * @param romDirectory - Directory containing ROM files
345
+ * @param playlistDirectory - Directory containing playlist files
346
+ * @param options - Sync options
347
+ * @param duplicateDecisions - User decisions for duplicate CRC ROMs
348
+ * @returns Result of the sync operation
349
+ */
350
+ export const syncPlaylists = (
351
+ analysis: SyncAnalysis,
352
+ _romDirectory: string,
353
+ playlistDirectory: string,
354
+ options: SyncOptions = {},
355
+ duplicateDecisions?: DuplicateDecision[]
356
+ ): SyncResult => {
357
+ const { deleteEmptyPlaylists = true } = options;
358
+ const errors: string[] = [];
359
+ let added = 0;
360
+ let removed = 0;
361
+ let moved = 0;
362
+ let duplicatesUpdated = 0;
363
+ let duplicatesSkipped = 0;
364
+
365
+ // Track playlists that need to be rewritten
366
+ const modifiedPlaylists = new Map<string, PlaylistFile>();
367
+
368
+ // Helper to get or load a playlist
369
+ const getPlaylist = (playlistPath: string): PlaylistFile | null => {
370
+ if (modifiedPlaylists.has(playlistPath)) {
371
+ return modifiedPlaylists.get(playlistPath)!;
372
+ }
373
+ const result = readPlaylist(playlistPath);
374
+ if (result.success && result.playlist) {
375
+ modifiedPlaylists.set(playlistPath, result.playlist);
376
+ return result.playlist;
377
+ }
378
+ return null;
379
+ };
380
+
381
+ // 1. Handle moves: update path field, preserve runtime/last_played
382
+ for (const movedRom of analysis.movedRoms) {
383
+ const playlist = getPlaylist(movedRom.originalEntry.playlistPath);
384
+ if (!playlist) {
385
+ errors.push(`Failed to load playlist: ${movedRom.originalEntry.playlistPath}`);
386
+ continue;
387
+ }
388
+
389
+ const entry = playlist.items[movedRom.originalEntry.entryIndex];
390
+ // Update path to new location
391
+ entry.path = movedRom.rom.path;
392
+ // Label might have changed if title was extracted differently
393
+ if (movedRom.rom.metadata.title) {
394
+ entry.label = formatRomLabel(movedRom.rom.metadata.title);
395
+ } else {
396
+ entry.label = formatRomLabel(basename(movedRom.rom.filename, movedRom.rom.extension));
397
+ }
398
+ moved++;
399
+ }
400
+
401
+ // 1b. Handle duplicate decisions: update path or skip
402
+ const duplicateUpdatedPlaylists = new Set<string>();
403
+ if (duplicateDecisions) {
404
+ for (const { duplicate, choice } of duplicateDecisions) {
405
+ if (choice === 'update') {
406
+ const playlist = getPlaylist(duplicate.existingEntry.playlistPath);
407
+ if (playlist) {
408
+ const entry = playlist.items[duplicate.existingEntry.entryIndex];
409
+ entry.path = duplicate.newRom.path;
410
+ if (duplicate.newRom.metadata.title) {
411
+ entry.label = formatRomLabel(duplicate.newRom.metadata.title);
412
+ }
413
+ duplicateUpdatedPlaylists.add(duplicate.existingEntry.playlistPath);
414
+ duplicatesUpdated++;
415
+ }
416
+ } else {
417
+ duplicatesSkipped++;
418
+ }
419
+ }
420
+ }
421
+
422
+ // 2. Remove missing entries
423
+ // Group by playlist and sort indices descending to remove from end first
424
+ const removalsPerPlaylist = new Map<string, number[]>();
425
+
426
+ for (const missing of analysis.missingEntries) {
427
+ const existing = removalsPerPlaylist.get(missing.playlistPath) ?? [];
428
+ existing.push(missing.entryIndex);
429
+ removalsPerPlaylist.set(missing.playlistPath, existing);
430
+ }
431
+
432
+ for (const [playlistPath, indices] of removalsPerPlaylist) {
433
+ const playlist = getPlaylist(playlistPath);
434
+ if (!playlist) {
435
+ errors.push(`Failed to load playlist: ${playlistPath}`);
436
+ continue;
437
+ }
438
+
439
+ // Sort descending and remove
440
+ const sortedIndices = [...indices].sort((a, b) => b - a);
441
+ for (const index of sortedIndices) {
442
+ if (index < playlist.items.length) {
443
+ playlist.items.splice(index, 1);
444
+ removed++;
445
+ }
446
+ }
447
+ }
448
+
449
+ // 3. Add new ROMs using generatePlaylistsBySystem
450
+ // ROMs already have crc32 set from scanning, no cache needed
451
+ if (analysis.newRoms.length > 0) {
452
+ const results = generatePlaylistsBySystem(analysis.newRoms, playlistDirectory);
453
+
454
+ for (const result of results) {
455
+ if (result.success) {
456
+ added += result.entryCount;
457
+ } else if (result.error) {
458
+ errors.push(result.error);
459
+ }
460
+ }
461
+ }
462
+
463
+ // 4. Write modified playlists
464
+ for (const [playlistPath, playlist] of modifiedPlaylists) {
465
+ // Skip if playlist is unchanged (only loaded but not modified)
466
+ const hasRemovals = removalsPerPlaylist.has(playlistPath);
467
+ const hasMoves = analysis.movedRoms.some(m => m.originalEntry.playlistPath === playlistPath);
468
+ const hasDuplicateUpdates = duplicateUpdatedPlaylists.has(playlistPath);
469
+
470
+ if (!hasRemovals && !hasMoves && !hasDuplicateUpdates) {
471
+ continue;
472
+ }
473
+
474
+ // Delete empty playlists
475
+ if (playlist.items.length === 0 && deleteEmptyPlaylists) {
476
+ try {
477
+ unlinkSync(playlistPath);
478
+ } catch (err) {
479
+ errors.push(`Failed to delete empty playlist: ${playlistPath}`);
480
+ }
481
+ continue;
482
+ }
483
+
484
+ // Write the updated playlist
485
+ const writeResult = writePlaylist(playlist, playlistPath);
486
+ if (!writeResult.success && writeResult.error) {
487
+ errors.push(writeResult.error);
488
+ }
489
+ }
490
+
491
+ return {
492
+ success: errors.length === 0,
493
+ added,
494
+ removed,
495
+ moved,
496
+ duplicatesUpdated,
497
+ duplicatesSkipped,
498
+ errors,
499
+ };
500
+ };
501
+
502
+ /**
503
+ * Get a count of entries in playlists for a specific directory
504
+ */
505
+ export const countPlaylistEntries = (
506
+ playlistDirectory: string,
507
+ romDirectory: string
508
+ ): number => {
509
+ const { entries } = loadPlaylistEntriesForDirectory(playlistDirectory, romDirectory);
510
+ return entries.length;
511
+ };