emoemu 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +77 -0
- package/.node-version +1 -0
- package/CLAUDE.md +435 -0
- package/README.md +404 -0
- package/TODO.md +655 -0
- package/dist/index.cjs +25108 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +25085 -0
- package/docs/building-libretro-cores-arm-mac.md +237 -0
- package/docs/config-file-format.md +488 -0
- package/docs/cores-trd.md +425 -0
- package/docs/headless-hardware-rendering-trd.md +676 -0
- package/docs/libretro-cores-trd.md +997 -0
- package/docs/mupen64-software-rendering-trd.md +751 -0
- package/docs/n64-support-trd.md +306 -0
- package/docs/native-rendering-trd.md +540 -0
- package/docs/native-ui-rendering-trd.md +1195 -0
- package/docs/netplay-trd.md +665 -0
- package/docs/retroarch-netplay-docs.md +277 -0
- package/docs/save-state-format.md +666 -0
- package/eslint.config.js +111 -0
- package/icon/icon.png +0 -0
- package/icon/icon.pxd +0 -0
- package/package.json +63 -0
- package/pnpm-workspace.yaml +10 -0
- package/src/Emulator/consts.ts +14 -0
- package/src/Emulator/index.ts +2496 -0
- package/src/Emulator/saveState/index.ts +155 -0
- package/src/Emulator/screenshot/index.ts +160 -0
- package/src/Emulator/terminalDimensions/index.ts +79 -0
- package/src/Emulator/types.ts +83 -0
- package/src/cli/commands/consts.ts +10 -0
- package/src/cli/commands/index.ts +462 -0
- package/src/cli/parseArgs/consts.ts +17 -0
- package/src/cli/parseArgs/index.ts +457 -0
- package/src/cli/parseArgs/types.ts +61 -0
- package/src/cli/runEmulator/index.ts +406 -0
- package/src/cli/runEmulator/types.ts +7 -0
- package/src/consts.ts +19 -0
- package/src/core/button/consts.ts +35 -0
- package/src/core/button/index.ts +123 -0
- package/src/core/core.ts +300 -0
- package/src/core/index.ts +19 -0
- package/src/cores/libretro/api/index.ts +334 -0
- package/src/cores/libretro/api/types.ts +148 -0
- package/src/cores/libretro/callbacks/consts.ts +41 -0
- package/src/cores/libretro/callbacks/index.ts +456 -0
- package/src/cores/libretro/consts.ts +45 -0
- package/src/cores/libretro/coreOptions/consts.ts +36 -0
- package/src/cores/libretro/coreOptions/index.ts +222 -0
- package/src/cores/libretro/environment/consts.ts +118 -0
- package/src/cores/libretro/environment/index.ts +1095 -0
- package/src/cores/libretro/index.ts +937 -0
- package/src/cores/libretro/loader/index.ts +496 -0
- package/src/cores/libretro/pixelFormat/consts.ts +43 -0
- package/src/cores/libretro/pixelFormat/index.ts +397 -0
- package/src/cores/libretro/types.ts +339 -0
- package/src/frontend/AudioManager/index.ts +420 -0
- package/src/frontend/SettingsManager/index.ts +250 -0
- package/src/frontend/config/index.ts +608 -0
- package/src/frontend/config/tests.ts +354 -0
- package/src/frontend/config/types.ts +36 -0
- package/src/frontend/consts.ts +114 -0
- package/src/frontend/coreBuilder/index.ts +644 -0
- package/src/frontend/coreBuilder/types.ts +15 -0
- package/src/frontend/coreDownloader/index.ts +620 -0
- package/src/frontend/coreDownloader/types.ts +17 -0
- package/src/frontend/corePreferences/index.ts +69 -0
- package/src/frontend/coreRegistry/index.ts +276 -0
- package/src/frontend/directoryCache/index.ts +75 -0
- package/src/frontend/index.ts +79 -0
- package/src/frontend/notifications/consts.ts +14 -0
- package/src/frontend/notifications/index.ts +250 -0
- package/src/frontend/playlist/consts.ts +168 -0
- package/src/frontend/playlist/index.ts +899 -0
- package/src/frontend/playlist/labelFormatter/consts.ts +15 -0
- package/src/frontend/playlist/labelFormatter/index.ts +155 -0
- package/src/frontend/playlist/labelFormatter/tests.ts +153 -0
- package/src/frontend/playlist/reader/index.ts +559 -0
- package/src/frontend/playlist/sync/index.ts +511 -0
- package/src/frontend/playlist/systemLookup/index.ts +233 -0
- package/src/frontend/playlist/utils/index.ts +50 -0
- package/src/frontend/romScanner/consts.ts +348 -0
- package/src/frontend/romScanner/index.ts +1957 -0
- package/src/frontend/saveServices/consts.ts +2 -0
- package/src/frontend/saveServices/index.ts +313 -0
- package/src/frontend/serviceProvider/index.ts +108 -0
- package/src/frontend/serviceProvider/types.ts +13 -0
- package/src/index.ts +428 -0
- package/src/input/Controller/consts.ts +50 -0
- package/src/input/Controller/index.ts +81 -0
- package/src/input/GamepadManager/consts.ts +22 -0
- package/src/input/GamepadManager/index.ts +418 -0
- package/src/input/InputManager/consts.ts +86 -0
- package/src/input/InputManager/index.ts +593 -0
- package/src/input/InputMapper/consts.ts +33 -0
- package/src/input/InputMapper/index.ts +436 -0
- package/src/input/consts.ts +410 -0
- package/src/input/gamepadProfiles/index.ts +1100 -0
- package/src/input/index.ts +38 -0
- package/src/input/inputUtils/index.ts +31 -0
- package/src/netplay/FrameBuffer/consts.ts +2 -0
- package/src/netplay/FrameBuffer/index.ts +364 -0
- package/src/netplay/FrameBuffer/tests.ts +286 -0
- package/src/netplay/InputBuffer/consts.ts +7 -0
- package/src/netplay/InputBuffer/index.ts +347 -0
- package/src/netplay/InputBuffer/tests.ts +166 -0
- package/src/netplay/NetplayClient/index.ts +976 -0
- package/src/netplay/NetplayConnection/index.ts +536 -0
- package/src/netplay/NetplayDiscovery/consts.ts +41 -0
- package/src/netplay/NetplayDiscovery/index.ts +525 -0
- package/src/netplay/NetplayServer/index.ts +1407 -0
- package/src/netplay/SyncManager/index.ts +984 -0
- package/src/netplay/SyncManager/tests.ts +419 -0
- package/src/netplay/consts.ts +371 -0
- package/src/netplay/crc32/consts.ts +14 -0
- package/src/netplay/crc32/index.ts +97 -0
- package/src/netplay/crc32/tests.ts +40 -0
- package/src/netplay/index.ts +41 -0
- package/src/netplay/netplayLogger/consts.ts +30 -0
- package/src/netplay/netplayLogger/index.ts +345 -0
- package/src/netplay/protocol/consts.ts +86 -0
- package/src/netplay/protocol/index.ts +1280 -0
- package/src/netplay/protocol/tests.ts +606 -0
- package/src/netplay/protocol/types.ts +20 -0
- package/src/netplay/types.ts +395 -0
- package/src/rendering/KittyRenderer/index.ts +616 -0
- package/src/rendering/NativeRenderer/index.ts +279 -0
- package/src/rendering/NativeRenderer/tests.ts +133 -0
- package/src/rendering/TerminalRenderer/index.ts +770 -0
- package/src/rendering/consts.ts +401 -0
- package/src/rendering/fonts/CozetteVector.ttf +0 -0
- package/src/rendering/index.ts +26 -0
- package/src/rendering/nativeUi/NativeWindowManager/index.ts +158 -0
- package/src/rendering/nativeUi/NativeWindowManager/tests.ts +81 -0
- package/src/rendering/nativeUi/consts.ts +6 -0
- package/src/rendering/nativeUi/index.ts +20 -0
- package/src/rendering/postProcessing/consts.ts +38 -0
- package/src/rendering/postProcessing/index.ts +923 -0
- package/src/rendering/shared/ansi/consts.ts +12 -0
- package/src/rendering/shared/ansi/index.ts +104 -0
- package/src/rendering/shared/consts.ts +2 -0
- package/src/rendering/shared/fitToTerminal/index.ts +67 -0
- package/src/ui/AddRomsPrompt/consts.ts +13 -0
- package/src/ui/AddRomsPrompt/index.tsx +781 -0
- package/src/ui/App/consts.ts +2 -0
- package/src/ui/App/index.tsx +456 -0
- package/src/ui/AppCapabilities/index.tsx +67 -0
- package/src/ui/ConfigContext/index.tsx +56 -0
- package/src/ui/CoreManager/consts.ts +11 -0
- package/src/ui/CoreManager/index.tsx +779 -0
- package/src/ui/CoreSelector/consts.ts +2 -0
- package/src/ui/CoreSelector/index.tsx +251 -0
- package/src/ui/DialogContainer/index.tsx +42 -0
- package/src/ui/DialogOptionsList/index.tsx +61 -0
- package/src/ui/DuplicateCrcPrompt/consts.ts +5 -0
- package/src/ui/DuplicateCrcPrompt/index.tsx +146 -0
- package/src/ui/GamepadContext/consts.ts +15 -0
- package/src/ui/GamepadContext/index.tsx +295 -0
- package/src/ui/NativeDialog/index.tsx +120 -0
- package/src/ui/NetplayDisconnectedDialog/index.tsx +93 -0
- package/src/ui/NetplayPauseMenu/consts.ts +2 -0
- package/src/ui/NetplayPauseMenu/index.tsx +97 -0
- package/src/ui/RomBrowser/NetplayPanel/consts.ts +24 -0
- package/src/ui/RomBrowser/NetplayPanel/index.tsx +520 -0
- package/src/ui/RomBrowser/SettingsPanel/index.tsx +478 -0
- package/src/ui/RomBrowser/consts.ts +61 -0
- package/src/ui/RomBrowser/index.tsx +1164 -0
- package/src/ui/RomBrowser/settingsConfig/index.ts +320 -0
- package/src/ui/RomBrowser/types.ts +67 -0
- package/src/ui/SaveStateDialog/consts.ts +2 -0
- package/src/ui/SaveStateDialog/index.tsx +225 -0
- package/src/ui/WarningDialog/index.tsx +113 -0
- package/src/ui/consts.ts +27 -0
- package/src/ui/hooks/useClearTerminal/consts.ts +2 -0
- package/src/ui/hooks/useClearTerminal/index.ts +37 -0
- package/src/ui/hooks/useDialogNavigation/index.ts +99 -0
- package/src/ui/hooks/useGamepad/consts.ts +21 -0
- package/src/ui/hooks/useGamepad/index.ts +194 -0
- package/src/ui/index.ts +27 -0
- package/src/utils/buffer/consts.ts +17 -0
- package/src/utils/buffer/index.ts +129 -0
- package/src/utils/color/consts.ts +58 -0
- package/src/utils/color/index.ts +183 -0
- package/src/utils/compression/consts.ts +50 -0
- package/src/utils/compression/index.ts +101 -0
- package/src/utils/consts.ts +2 -0
- package/src/utils/crc32/consts.ts +22 -0
- package/src/utils/crc32/index.ts +83 -0
- package/src/utils/ensureDirectory/index.ts +10 -0
- package/src/utils/format/consts.ts +8 -0
- package/src/utils/format/index.ts +53 -0
- package/src/utils/getErrorMessage/index.ts +10 -0
- package/src/utils/index.ts +113 -0
- package/src/utils/ini/index.ts +200 -0
- package/src/utils/kitty/consts.ts +13 -0
- package/src/utils/kitty/index.ts +181 -0
- package/src/utils/logger/consts.ts +35 -0
- package/src/utils/logger/index.ts +217 -0
- package/src/utils/paths/consts.ts +18 -0
- package/src/utils/paths/index.ts +151 -0
- package/src/utils/png/consts.ts +34 -0
- package/src/utils/png/index.ts +131 -0
- package/src/utils/readJsonFile/index.ts +16 -0
- package/src/utils/rotateLogFile/index.ts +44 -0
- package/src/utils/safeClose/index.ts +10 -0
- package/src/utils/terminal/consts.ts +8 -0
- package/src/utils/terminal/index.ts +102 -0
- package/src/utils/thumbnailRenderer/consts.ts +2 -0
- package/src/utils/thumbnailRenderer/index.ts +147 -0
- package/src/utils/typedError/index.ts +26 -0
- package/tsconfig.json +31 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,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
|
+
};
|