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,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
|
+
};
|