Skip to content

commit 4347a99

abduznik edited this page May 23, 2026 · 1 revision

feat: improve offline game detection with dual-pass scanner and reverse mapping

Commit: 4347a999a6d373fe33d2726b26b25cb74caf076d

Author: abduznik

Date: 2026-04-28

Why: Adds a new feature or capability to the application.

Files Changed

.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(-)
  • .gitignore
  • lib/core/romm/rom_scanner_service.dart
  • lib/core/storage/directory_service.dart
  • test/unit/firmware_service_test.mocks.dart
  • test/unit/save_sync_service_test.mocks.dart
  • test/unit/strategy_registry_test.mocks.dart
  • test/widgets/library_screen_test.mocks.dart
  • test/widgets/settings_screen_test.mocks.dart

Diff

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?>);

Clone this wiki locally