-
-
Notifications
You must be signed in to change notification settings - Fork 14
commit 4347a99
abduznik edited this page May 23, 2026
·
1 revision
Commit: 4347a999a6d373fe33d2726b26b25cb74caf076d
Author: abduznik
Date: 2026-04-28
Why: Adds a new feature or capability to the application.
.gitignore | 1 +
lib/core/romm/rom_scanner_service.dart | 112 +++++++++++------
lib/core/storage/directory_service.dart | 175 ++++++++++++++++++++-------
test/unit/firmware_service_test.mocks.dart | 7 +-
test/unit/save_sync_service_test.mocks.dart | 7 +-
test/unit/strategy_registry_test.mocks.dart | 7 +-
test/widgets/library_screen_test.mocks.dart | 7 +-
test/widgets/settings_screen_test.mocks.dart | 7 +-
8 files changed, 233 insertions(+), 90 deletions(-)
.gitignorelib/core/romm/rom_scanner_service.dartlib/core/storage/directory_service.darttest/unit/firmware_service_test.mocks.darttest/unit/save_sync_service_test.mocks.darttest/unit/strategy_registry_test.mocks.darttest/widgets/library_screen_test.mocks.darttest/widgets/settings_screen_test.mocks.dart
diff --git a/.gitignore b/.gitignore
index 52ef2f8..e0c58d2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,7 @@
.svn/
.swiftpm/
migrate_working_dir/
+openapi.json
# IntelliJ related
*.iml
diff --git a/lib/core/romm/rom_scanner_service.dart b/lib/core/romm/rom_scanner_service.dart
index 6f2dde4..2f295c0 100644
--- a/lib/core/romm/rom_scanner_service.dart
+++ b/lib/core/romm/rom_scanner_service.dart
@@ -74,7 +74,7 @@ class RomScannerService {
debugPrint('[RomScanner] Processing $platformSlug (ID: $platformId)...');
- // 1. Fetch all games for this platform from RomM (Bulk fetch is much faster)
+ // 1. Fetch all games for this platform from RomM
final List<Game> platformGames = [];
try {
int offset = 0;
@@ -94,74 +94,108 @@ class RomScannerService {
continue;
}
- // 2. Scan the directory
- final List<FileSystemEntity> entities = await dir.list().toList();
- final scannedFiles = entities.whereType<File>().toList();
-
- // 3. Match new files
- for (final file in scannedFiles) {
- final filePath = file.path;
- if (mappings.containsKey(filePath)) continue; // Already mapped
+ // 2. Build File System Index for this platform
+ final index = await FileSystemIndex.build(dir.path);
+ final romsSubDir = p.join(dir.path, 'roms');
+ FileSystemIndex? romsIndex;
+ if (await Directory(romsSubDir).exists()) {
+ romsIndex = await FileSystemIndex.build(romsSubDir);
+ }
+
+ final Set<String> mappedPathsInThisDir = {};
+ final Set<String> matchedRomIdsInThisDir = {};
+ final Set<String> allGlobalMappedIds = mappings.values.toSet();
+
+ // PASS 1: File-centric matching (Identify existing files)
+ final allLocalEntities = [...index.files.values, ...index.dirs.values];
+ if (romsIndex != null) {
+ allLocalEntities.addAll([...romsIndex.files.values, ...romsIndex.dirs.values]);
+ }
+
+ for (final entityPath in allLocalEntities) {
+ if (mappings.containsKey(entityPath)) {
+ mappedPathsInThisDir.add(entityPath);
+ matchedRomIdsInThisDir.add(mappings[entityPath]!);
+ continue;
+ }
- final fileName = p.basename(filePath);
- final fileNameNoExt = p.basenameWithoutExtension(filePath).toLowerCase();
+ final fileName = p.basename(entityPath);
+ final fileNameNoExt = p.basenameWithoutExtension(entityPath).toLowerCase();
Game? matchedGame;
- // MATCHING STRATEGY 1: Exact Filename/FSName match (100% Certainty)
+ // Strategy A: Exact match against RomM filenames/fsnames
matchedGame = platformGames.cast<Game?>().firstWhere(
(g) => g?.fileName == fileName || g?.fsName == fileName,
orElse: () => null,
);
- // MATCHING STRATEGY 2: Clean name match (Ignoring tags and special chars)
+ // Strategy B: Clean name match
if (matchedGame == null) {
final fNameClean = _cleanName(fileNameNoExt);
matchedGame = platformGames.cast<Game?>().firstWhere((g) {
if (g == null) return false;
-
- // Try matching against Title
if (_cleanName(g.name) == fNameClean) return true;
-
- // Try matching against internal FileName/FSName (without extension)
final gFileNoExt = p.basenameWithoutExtension(g.fileName ?? '').toLowerCase();
if (_cleanName(gFileNoExt) == fNameClean) return true;
-
final gFsNoExt = p.basenameWithoutExtension(g.fsName ?? '').toLowerCase();
if (_cleanName(gFsNoExt) == fNameClean) return true;
-
return false;
}, orElse: () => null);
}
if (matchedGame != null) {
- debugPrint('[Scanner] Mapped: $fileName -> ${matchedGame.name}');
- await _mappingService.updateMapping(filePath, matchedGame.id);
- yield RomSyncResult(filePath, matchedGame.id, game: matchedGame);
+ debugPrint('[Scanner] File Match: $fileName -> ${matchedGame.name}');
+ await _mappingService.updateMapping(entityPath, matchedGame.id);
+ mappedPathsInThisDir.add(entityPath);
+ matchedRomIdsInThisDir.add(matchedGame.id);
+ allGlobalMappedIds.add(matchedGame.id);
+ yield RomSyncResult(entityPath, matchedGame.id, game: matchedGame);
} else {
- debugPrint('[Scanner] No local match for: $fileName. Trying API search...');
- try {
- final results = await _rommService.searchRoms(search: fileName, platformId: platformId);
- if (results.isNotEmpty) {
- final game = results.first;
- debugPrint('[Scanner] API Match: $fileName -> ${game.name}');
- await _mappingService.updateMapping(filePath, game.id);
- yield RomSyncResult(filePath, game.id, game: game);
- } else {
- debugPrint('[Scanner] FAILED to identify: $fileName');
+ // Strategy C: File size match (Very strong indicator if in the correct platform folder)
+ final fileSize = index.fileSizes[entityPath] ?? (romsIndex?.fileSizes[entityPath] ?? 0);
+ if (fileSize > 1024 * 1024) { // Only for files > 1MB to avoid collisions on small files
+ matchedGame = platformGames.cast<Game?>().firstWhere(
+ (g) => g?.fileSize == fileSize,
+ orElse: () => null,
+ );
+ if (matchedGame != null) {
+ debugPrint('[Scanner] Size Match: $fileName -> ${matchedGame.name} ($fileSize bytes)');
+ await _mappingService.updateMapping(entityPath, matchedGame.id);
+ mappedPathsInThisDir.add(entityPath);
+ matchedRomIdsInThisDir.add(matchedGame.id);
+ allGlobalMappedIds.add(matchedGame.id);
+ yield RomSyncResult(entityPath, matchedGame.id, game: matchedGame);
}
- } catch (e) {
- debugPrint('[Scanner] Error searching for $fileName: $e');
}
}
}
- // 4. Identify REMOVALS for this platform
- final Set<String> currentFiles = scannedFiles.map((f) => f.path).toSet();
+ // PASS 2: Game-centric matching (Reverse Mapping)
+ // For any game in library NOT yet matched, try to find it on disk using DirectoryService logic
+ for (final game in platformGames) {
+ if (allGlobalMappedIds.contains(game.id)) continue;
+
+ // Use robust DirectoryService logic with pre-built indices
+ String? foundPath = await _directoryService.findExistingRomPath(game, index: index);
+ if (foundPath == null && romsIndex != null) {
+ foundPath = await _directoryService.findExistingRomPath(game, index: romsIndex);
+ }
+
+ if (foundPath != null) {
+ debugPrint('[Scanner] REVERSE Match: ${game.name} -> $foundPath');
+ await _mappingService.updateMapping(foundPath, game.id);
+ mappedPathsInThisDir.add(foundPath);
+ matchedRomIdsInThisDir.add(game.id);
+ allGlobalMappedIds.add(game.id);
+ yield RomSyncResult(foundPath, game.id, game: game);
+ }
+ }
+
+ // 3. Identify REMOVALS for this platform
final platformMappings = mappings.entries.where((e) => p.isWithin(dir.path, e.key));
-
for (final entry in platformMappings) {
- if (!currentFiles.contains(entry.key)) {
+ if (!mappedPathsInThisDir.contains(entry.key) && !await File(entry.key).exists() && !await Directory(entry.key).exists()) {
await _mappingService.removeMapping(entry.key);
yield RomSyncResult(entry.key, entry.value, isRemoved: true);
}
@@ -189,7 +223,7 @@ class RomScannerService {
if (existingPath != null) {
// Verify if the file still exists
- if (await File(existingPath).exists()) {
+ if (await File(existingPath).exists() || await Directory(existingPath).exists()) {
debugPrint('[RomScanner] Single Sync: ${game.name} already correctly mapped.');
return;
} else {
diff --git a/lib/core/storage/directory_service.dart b/lib/core/storage/directory_service.dart
index c75554c..b01f152 100644
--- a/lib/core/storage/directory_service.dart
+++ b/lib/core/storage/directory_service.dart
@@ -1,5 +1,5 @@
import 'dart:io' as io;
-import 'dart:io' show Directory, File, Process;
+import 'dart:io' show Directory, File, Process, FileSystemEntity;
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart';
@@ -23,6 +23,50 @@ class StorageStatus {
bool get hasError => error != StorageError.none;
}
+class FileSystemIndex {
+ final String rootPath;
+ final Map<String, String> files; // lowercase name -> absolute path
+ final Map<String, String> dirs; // lowercase name -> absolute path
+ final Map<String, int> fileSizes; // absolute path -> size
+
+ FileSystemIndex({
+ required this.rootPath,
+ required this.files,
+ required this.dirs,
+ required this.fileSizes,
+ });
+
+ static Future<FileSystemIndex> build(String path) async {
+ final Map<String, String> files = {};
+ final Map<String, String> dirs = {};
+ final Map<String, int> fileSizes = {};
+
+ final rootDir = io.Directory(path);
+ if (await rootDir.exists()) {
+ try {
+ await for (final entity in rootDir.list(recursive: false)) {
+ final name = p.basename(entity.path).toLowerCase();
+ if (entity is io.File) {
+ files[name] = p.absolute(entity.path);
+ try {
+ fileSizes[p.absolute(entity.path)] = await entity.length();
+ } catch (_) {}
+ } else if (entity is io.Directory) {
+ dirs[name] = p.absolute(entity.path);
+ }
+ }
+ } catch (_) {}
+ }
+
+ return FileSystemIndex(
+ rootPath: path,
+ files: files,
+ dirs: dirs,
+ fileSizes: fileSizes,
+ );
+ }
+}
+
class DirectoryService {
static const String _romsRootPathKey = 'romsRootPath';
static const String _emulatorsRootPathKey = 'emulatorsRootPath';
@@ -404,45 +448,89 @@ class DirectoryService {
/// Tries to find the actual ROM file on disk.
/// First checks the exact path, then tries common extensions for the platform.
/// Returns the found path or null if not found.
- Future<String?> findExistingRomPath(Game game) async {
+ Future<String?> findExistingRomPath(Game game, {FileSystemIndex? index}) async {
final romDir = await getRomDirectory(game);
- final baseName = game.fsName ?? game.fileName ?? game.name.replaceAll(RegExp(r'[<>:"/\\|?*]'), ' ').replaceAll(RegExp(r'\s+'), ' ').trim();
+ final platformLower = game.platformSlug?.toLowerCase();
+
+ // Names to check (in order of priority)
+ final namesToCheck = <String>[];
+ if (game.fsName != null) namesToCheck.add(game.fsName!);
+ if (game.fileName != null) namesToCheck.add(game.fileName!);
+
+ final sanitizedName = game.name.replaceAll(RegExp(r'[<>:"/\\|?*]'), ' ').replaceAll(RegExp(r'\s+'), ' ').trim();
+ namesToCheck.add(sanitizedName);
+
+ // 1. Check using Index if provided (Case-Insensitive & Fast)
+ if (index != null && (index.rootPath == romDir || index.rootPath == p.join(romDir, 'roms'))) {
+ for (final name in namesToCheck) {
+ final lowerName = name.toLowerCase();
+
+ // Try exact name match (files)
+ if (index.files.containsKey(lowerName)) return index.files[lowerName];
+
+ // Try name match (dirs)
+ if (index.dirs.containsKey(lowerName)) {
+ final found = await _findMainRomInFolder(game, index.dirs[lowerName]!);
+ if (found != null) return found;
+ }
+
+ // Try with extensions
+ final extensions = _platformExtensions[platformLower] ?? [];
+ for (final ext in extensions) {
+ final nameWithExt = lowerName.endsWith(ext.toLowerCase()) ? lowerName : '$lowerName${ext.toLowerCase()}';
+ if (index.files.containsKey(nameWithExt)) return index.files[nameWithExt];
+ }
+ }
+
+ // Fuzzy match in index
+ for (final name in namesToCheck) {
+ final lowerName = name.toLowerCase();
+ for (final entry in index.files.entries) {
+ if (entry.key.startsWith(lowerName) && !entry.key.endsWith('.part')) {
+ return entry.value;
+ }
+ }
+ }
+ }
+
+ // 2. Fallback to manual scanning (Legacy/Direct)
+ final baseName = game.fsName ?? game.fileName ?? sanitizedName;
- // 1. Check exact path first (file or directory) in primary platform folder
+ // Check exact path first (Case-sensitive check)
final exactPath = p.join(romDir, baseName);
if (await File(exactPath).exists()) return p.absolute(exactPath);
- // 2. Check for "roms/" subfolder (common in some RomM structures)
- final romsSubDir = p.join(romDir, 'roms');
- if (await Directory(romsSubDir).exists()) {
- final subExactPath = p.join(romsSubDir, baseName);
- if (await File(subExactPath).exists()) return p.absolute(subExactPath);
+ // Case-insensitive check by scanning parent directory manually if index not available
+ final parentDir = Directory(romDir);
+ if (await parentDir.exists()) {
+ try {
+ await for (final entity in parentDir.list()) {
+ final fname = p.basename(entity.path);
+ if (fname.toLowerCase() == baseName.toLowerCase()) {
+ if (entity is File) return p.absolute(entity.path);
+ if (entity is Directory) {
+ final found = await _findMainRomInFolder(game, entity.path);
+ if (found != null) return found;
+ }
+ }
+ }
+ } catch (_) {}
}
// 3. Search for multi-file folder (sanitized game name)
- final folderName = game.name.replaceAll(RegExp(r'[<>:"/\\|?*]'), ' ').replaceAll(RegExp(r'\s+'), ' ').trim();
-
- // Try both romDir and romDir/roms
+ final folderName = sanitizedName;
final searchDirs = [romDir, p.join(romDir, 'roms')];
for (final dirPath in searchDirs) {
- final parentDir = Directory(dirPath);
- if (!await parentDir.exists()) continue;
-
- // Check direct folder match
- final candidateFolder = Directory(p.join(dirPath, folderName));
- if (await candidateFolder.exists()) {
- final found = await _findMainRomInFolder(game, candidateFolder.path);
- if (found != null) return found;
- }
+ final pDir = Directory(dirPath);
+ if (!await pDir.exists()) continue;
- // Fuzzy folder match (e.g. "Captain Toad Treasure Tracker" matches "Captain Toad_ Treasure Tracker")
+ // Check direct folder match (Case-insensitive)
try {
- await for (final entity in parentDir.list()) {
+ await for (final entity in pDir.list()) {
if (entity is Directory) {
final dName = p.basename(entity.path);
- final sanitizedDName = dName.replaceAll(RegExp(r'[<>:"/\\|?*]'), ' ').replaceAll(RegExp(r'\s+'), ' ').trim();
- if (sanitizedDName.toLowerCase() == folderName.toLowerCase()) {
+ if (dName.toLowerCase() == folderName.toLowerCase()) {
final found = await _findMainRomInFolder(game, entity.path);
if (found != null) return found;
}
@@ -451,34 +539,39 @@ class DirectoryService {
} catch (_) {}
}
- // 4. Try common extensions for this platform
- final extensions = _platformExtensions[game.platformSlug?.toLowerCase()] ?? [];
+ // 4. Try common extensions for this platform (Case-insensitive)
+ final extensions = _platformExtensions[platformLower] ?? [];
for (final dirPath in searchDirs) {
- final parentDir = Directory(dirPath);
- if (!await parentDir.exists()) continue;
+ final pDir = Directory(dirPath);
+ if (!await pDir.exists()) continue;
- for (final ext in extensions) {
- if (baseName.toLowerCase().endsWith(ext.toLowerCase())) continue;
- final candidate = p.join(dirPath, '$baseName$ext');
- if (await File(candidate).exists()) return p.absolute(candidate);
- }
+ try {
+ final List<FileSystemEntity> entities = await pDir.list().toList();
+ for (final ext in extensions) {
+ for (final entity in entities) {
+ if (entity is File) {
+ final fname = p.basename(entity.path).toLowerCase();
+ final target = '$baseName$ext'.toLowerCase();
+ if (fname == target || fname == baseName.toLowerCase()) {
+ return p.absolute(entity.path);
+ }
+ }
+ }
+ }
+ } catch (_) {}
}
// 5. Scan directory for fuzzy file match
for (final dirPath in searchDirs) {
- final parentDir = Directory(dirPath);
- if (!await parentDir.exists()) continue;
+ final pDir = Directory(dirPath);
+ if (!await pDir.exists()) continue;
try {
- await for (final entity in parentDir.list()) {
+ await for (final entity in pDir.list()) {
if (entity is File) {
final fname = p.basename(entity.path);
final sanitizedFName = fname.replaceAll(RegExp(r'[<>:"/\\|?*]'), ' ').replaceAll(RegExp(r'\s+'), ' ').trim();
if (sanitizedFName.toLowerCase().startsWith(baseName.toLowerCase()) && !fname.toLowerCase().endsWith('.part')) {
- // Prioritize .nds for NDS
- if ((game.platformSlug?.toLowerCase() == 'nds' || game.platformSlug?.toLowerCase() == 'nintendo-ds') && fname.toLowerCase().endsWith('.nds')) {
- return p.absolute(entity.path);
- }
return p.absolute(entity.path);
}
}
diff --git a/test/unit/firmware_service_test.mocks.dart b/test/unit/firmware_service_test.mocks.dart
index 1778509..7d1511b 100644
--- a/test/unit/firmware_service_test.mocks.dart
+++ b/test/unit/firmware_service_test.mocks.dart
@@ -704,9 +704,12 @@ class MockDirectoryService extends _i1.Mock implements _i4.DirectoryService {
as _i8.Future<String>);
@override
- _i8.Future<String?> findExistingRomPath(_i3.Game? game) =>
+ _i8.Future<String?> findExistingRomPath(
+ _i3.Game? game, {
+ _i4.FileSystemIndex? index,
+ }) =>
(super.noSuchMethod(
- Invocation.method(#findExistingRomPath, [game]),
+ Invocation.method(#findExistingRomPath, [game], {#index: index}),
returnValue: _i8.Future<String?>.value(),
)
as _i8.Future<String?>);
diff --git a/test/unit/save_sync_service_test.mocks.dart b/test/unit/save_sync_service_test.mocks.dart
index b2e14ec..2abedcb 100644
--- a/test/unit/save_sync_service_test.mocks.dart
+++ b/test/unit/save_sync_service_test.mocks.dart
@@ -704,9 +704,12 @@ class MockDirectoryService extends _i1.Mock implements _i4.DirectoryService {
as _i8.Future<String>);
@override
- _i8.Future<String?> findExistingRomPath(_i3.Game? game) =>
+ _i8.Future<String?> findExistingRomPath(
+ _i3.Game? game, {
+ _i4.FileSystemIndex? index,
+ }) =>
(super.noSuchMethod(
- Invocation.method(#findExistingRomPath, [game]),
+ Invocation.method(#findExistingRomPath, [game], {#index: index}),
returnValue: _i8.Future<String?>.value(),
)
as _i8.Future<String?>);
diff --git a/test/unit/strategy_registry_test.mocks.dart b/test/unit/strategy_registry_test.mocks.dart
index 8c464e6..e58387b 100644
--- a/test/unit/strategy_registry_test.mocks.dart
+++ b/test/unit/strategy_registry_test.mocks.dart
@@ -324,9 +324,12 @@ class MockDirectoryService extends _i1.Mock implements _i2.DirectoryService {
as _i5.Future<String>);
@override
- _i5.Future<String?> findExistingRomPath(_i6.Game? game) =>
+ _i5.Future<String?> findExistingRomPath(
+ _i6.Game? game, {
+ _i2.FileSystemIndex? index,
+ }) =>
(super.noSuchMethod(
- Invocation.method(#findExistingRomPath, [game]),
+ Invocation.method(#findExistingRomPath, [game], {#index: index}),
returnValue: _i5.Future<String?>.value(),
)
as _i5.Future<String?>);
diff --git a/test/widgets/library_screen_test.mocks.dart b/test/widgets/library_screen_test.mocks.dart
index 53764f3..9494c14 100644
--- a/test/widgets/library_screen_test.mocks.dart
+++ b/test/widgets/library_screen_test.mocks.dart
@@ -703,9 +703,12 @@ class MockDirectoryService extends _i1.Mock implements _i4.DirectoryService {
as _i8.Future<String>);
@override
- _i8.Future<String?> findExistingRomPath(_i3.Game? game) =>
+ _i8.Future<String?> findExistingRomPath(
+ _i3.Game? game, {
+ _i4.FileSystemIndex? index,
+ }) =>
(super.noSuchMethod(
- Invocation.method(#findExistingRomPath, [game]),
+ Invocation.method(#findExistingRomPath, [game], {#index: index}),
returnValue: _i8.Future<String?>.value(),
)
as _i8.Future<String?>);
diff --git a/test/widgets/settings_screen_test.mocks.dart b/test/widgets/settings_screen_test.mocks.dart
index b056927..bf31bfa 100644
--- a/test/widgets/settings_screen_test.mocks.dart
+++ b/test/widgets/settings_screen_test.mocks.dart
@@ -341,9 +341,12 @@ class MockDirectoryService extends _i1.Mock implements _i2.DirectoryService {
as _i7.Future<String>);
@override
- _i7.Future<String?> findExistingRomPath(_i5.Game? game) =>
+ _i7.Future<String?> findExistingRomPath(
+ _i5.Game? game, {
+ _i2.FileSystemIndex? index,
+ }) =>
(super.noSuchMethod(
- Invocation.method(#findExistingRomPath, [game]),
+ Invocation.method(#findExistingRomPath, [game], {#index: index}),
returnValue: _i7.Future<String?>.value(),
)
as _i7.Future<String?>);