From 95d3f252d2cfde52faa8c21ce04943def2449983 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:25:36 +0000 Subject: [PATCH] Add CHD audio codec selection and 1G1R collection filter - Support configurable CD audio compression codec (FLAC vs Vorbis) for chdman createcd in core CLI and GUI Settings. - Implement 1G1R (1 Game 1 ROM) collection filter in core, CLI (--1g1r, --priority), and GUI Tools panel. - Add unit tests for CHD audio codec selection and 1G1R title parsing / regional priority sorting. Co-authored-by: ClausValcaTD <193546948+ClausValcaTD@users.noreply.github.com> --- core/bin/openrom.dart | 51 ++++ core/lib/openrom_core.dart | 1 + core/lib/src/converter.dart | 19 +- core/lib/src/one_g_one_r.dart | 250 ++++++++++++++++++ core/test/chd_audio_codec_test.dart | 81 ++++++ core/test/one_g_one_r_test.dart | 141 +++++++++++ gui/lib/app.dart | 6 +- gui/lib/controllers/home_controller.dart | 2 + gui/lib/models/conversion_job.dart | 2 + gui/lib/screens/settings_screen.dart | 50 ++++ gui/lib/screens/tools_screen.dart | 308 ++++++++++++++++++++++- gui/lib/services/core_bridge.dart | 4 + gui/lib/services/tools_service.dart | 73 ++++-- 13 files changed, 970 insertions(+), 18 deletions(-) create mode 100644 core/lib/src/one_g_one_r.dart create mode 100644 core/test/chd_audio_codec_test.dart create mode 100644 core/test/one_g_one_r_test.dart diff --git a/core/bin/openrom.dart b/core/bin/openrom.dart index b62d7c5..19df28b 100644 --- a/core/bin/openrom.dart +++ b/core/bin/openrom.dart @@ -106,6 +106,17 @@ ArgParser buildParser() { 'media', help: 'media type override for CHD creation: cd or dvd', ) + ..addOption( + 'audio-codec', + defaultsTo: 'flac', + help: 'CD audio codec for CHD: flac | vorbis', + ) + ..addOption('1g1r', help: 'filter 1G1R duplicates in a folder') + ..addOption( + 'priority', + defaultsTo: 'USA,Europe,Japan', + help: 'region priority order for 1G1R filter (comma-separated)', + ) ..addFlag( 'list-dats', negatable: false, @@ -510,6 +521,44 @@ Future main(List args) async { } } + if (results['1g1r'] != null) { + final folder = results['1g1r'] as String; + final priorityStr = (results['priority'] as String?) ?? 'USA,Europe,Japan'; + final priorityList = priorityStr.split(',').map((s) => s.trim()).toList(); + final dry = results['dry-run'] == true; + + try { + final summary = filter1G1R( + folderPath: folder, + regionPriority: priorityList, + dryRun: dry, + ); + if (isJson) { + _jsonPrint({'type': 'done', 'success': true, ...summary.toJson()}); + } else { + print('1G1R Filter ${dry ? "[DRY RUN] " : ""}Results for $folder:'); + print(' Total Files: ${summary.totalFiles}'); + print(' Kept: ${summary.keptCount}'); + print(' Moved Duplicates: ${summary.movedCount}'); + for (final r in summary.results) { + if (r.isWinner) { + print(' ✅ Kept: ${r.filename}'); + } else { + print(' 📁 Duplicate -> ${r.filename} (${r.reason})'); + } + } + } + return 0; + } catch (e) { + if (isJson) { + _jsonPrint({'type': 'done', 'success': false, 'error': e.toString()}); + } else { + print('❌ 1G1R filter failed: $e'); + } + return 2; + } + } + if (results['import-dat'] != null) { final dat = results['import-dat'] as String; try { @@ -780,6 +829,7 @@ Future main(List args) async { verify: verifyVal, forcePlatform: forcePlat, media: mediaVal, + audioCodec: results['audio-codec'] as String? ?? 'flac', ); jobs.add(job); } else if (folder != null) { @@ -793,6 +843,7 @@ Future main(List args) async { verify: verifyVal, forcePlatform: forcePlat, media: mediaVal, + audioCodec: results['audio-codec'] as String? ?? 'flac', ); jobs.add(job); } diff --git a/core/lib/openrom_core.dart b/core/lib/openrom_core.dart index ab17408..41ac2d8 100644 --- a/core/lib/openrom_core.dart +++ b/core/lib/openrom_core.dart @@ -19,3 +19,4 @@ export 'src/validator.dart'; export 'src/logger.dart'; export 'src/online_prefs.dart'; export 'src/version_reader.dart'; +export 'src/one_g_one_r.dart'; diff --git a/core/lib/src/converter.dart b/core/lib/src/converter.dart index c8b7dc4..dd0028b 100644 --- a/core/lib/src/converter.dart +++ b/core/lib/src/converter.dart @@ -55,6 +55,7 @@ class ConversionJob { final bool verify; final String? forcePlatform; final String? media; + final String? audioCodec; String status; double progress; final List logLines; @@ -70,6 +71,7 @@ class ConversionJob { this.verify = false, this.forcePlatform, this.media, + this.audioCodec, this.status = 'Queued', this.progress = 0.0, List? logLines, @@ -255,6 +257,21 @@ class Converter { final compressionMap = subCmd == 'createcd' ? chdCdCompression : chdDvdCompression; final defaultCodec = subCmd == 'createcd' ? 'cdlz' : 'zlib'; + + String compressionCodec; + if (subCmd == 'createcd') { + final codecOption = job.audioCodec?.trim().toLowerCase(); + if (codecOption == 'vorbis') { + compressionCodec = 'cdlz,cdav'; + } else if (codecOption == 'flac') { + compressionCodec = 'cdlz,cdfl'; + } else { + compressionCodec = compressionMap[job.compression] ?? defaultCodec; + } + } else { + compressionCodec = compressionMap[job.compression] ?? defaultCodec; + } + final cmd = [ chdman, subCmd, @@ -263,7 +280,7 @@ class Converter { '-o', out, '--compression', - compressionMap[job.compression] ?? defaultCodec, + compressionCodec, ]; if (fmt == 'BIN') { diff --git a/core/lib/src/one_g_one_r.dart b/core/lib/src/one_g_one_r.dart new file mode 100644 index 0000000..c8eee25 --- /dev/null +++ b/core/lib/src/one_g_one_r.dart @@ -0,0 +1,250 @@ +// OpenROM — Universal ROM Compression Suite +// M5 Dev | GPL v3 + +import 'dart:io'; +import 'package:path/path.dart' as p; + +class OneGOneRFileResult { + final String originalPath; + final String filename; + final String cleanTitle; + final bool isWinner; + final String? destinationPath; + final String reason; + + OneGOneRFileResult({ + required this.originalPath, + required this.filename, + required this.cleanTitle, + required this.isWinner, + this.destinationPath, + required this.reason, + }); + + Map toJson() => { + 'original_path': originalPath, + 'filename': filename, + 'clean_title': cleanTitle, + 'is_winner': isWinner, + 'destination_path': destinationPath, + 'reason': reason, + }; +} + +class OneGOneRSummary { + final int totalFiles; + final int keptCount; + final int movedCount; + final List results; + + OneGOneRSummary({ + required this.totalFiles, + required this.keptCount, + required this.movedCount, + required this.results, + }); + + Map toJson() => { + 'total_files': totalFiles, + 'kept_count': keptCount, + 'moved_count': movedCount, + 'results': results.map((r) => r.toJson()).toList(), + }; +} + +String extractCleanTitle(String filename) { + // Remove file extension + String title = p.basenameWithoutExtension(filename); + // Remove parenthesized ( ... ) and bracketed [ ... ] tags + title = title.replaceAll(RegExp(r'[\(\[][^\)\]]*[\)\]]'), ''); + // Trim extra spaces + return title.trim(); +} + +int _getRegionRank(String filename, List regionPriority) { + final nameLower = filename.toLowerCase(); + + // Extract text inside parentheses and brackets + final tags = RegExp( + r'[\(\[][^\)\]]*[\)\]]', + ).allMatches(nameLower).map((m) => m.group(0)!).toList(); + + for (int i = 0; i < regionPriority.length; i++) { + final region = regionPriority[i].trim().toLowerCase(); + if (region.isEmpty) continue; + + for (final tag in tags) { + if (_tagMatchesRegion(tag, region)) { + return i; + } + } + } + return 9999; +} + +bool _tagMatchesRegion(String tagContent, String region) { + if (region == 'usa' || region == 'us') { + return tagContent.contains('usa') || + tagContent.contains('us,') || + tagContent.contains('us)') || + tagContent.contains('america'); + } + if (region == 'europe' || region == 'eur' || region == 'eu') { + return tagContent.contains('europe') || + tagContent.contains('eur') || + tagContent.contains('eu,') || + tagContent.contains('eu)'); + } + if (region == 'japan' || region == 'jpn' || region == 'jp') { + return tagContent.contains('japan') || + tagContent.contains('jpn') || + tagContent.contains('jp,') || + tagContent.contains('jp)'); + } + if (region == 'world') { + return tagContent.contains('world'); + } + return tagContent.contains(region); +} + +int _getRevisionLevel(String filename) { + final nameLower = filename.toLowerCase(); + + // Match Rev X, Revision X + final revMatch = RegExp( + r'\b(?:rev|revision)[\s\._]*([0-9a-z]+)\b', + ).firstMatch(nameLower); + if (revMatch != null) { + final val = revMatch.group(1)!; + final parsed = int.tryParse(val); + if (parsed != null) return parsed; + if (val == 'a') return 1; + if (val == 'b') return 2; + if (val == 'c') return 3; + return 1; + } + + // Match v1.1, v1.2, v2.0 + final vMatch = RegExp(r'\bv(\d+)(?:\.(\d+))?\b').firstMatch(nameLower); + if (vMatch != null) { + final major = int.tryParse(vMatch.group(1) ?? '1') ?? 1; + final minor = int.tryParse(vMatch.group(2) ?? '0') ?? 0; + return major * 10 + minor; + } + + return 0; +} + +int _getDumpScore(String filename) { + int score = 0; + if (filename.contains('[!]')) score += 10; + if (filename.contains('[b') || filename.contains('[h')) score -= 10; + return score; +} + +OneGOneRSummary filter1G1R({ + required String folderPath, + List regionPriority = const ['USA', 'Europe', 'Japan'], + bool dryRun = false, + bool moveDuplicates = true, +}) { + final dir = Directory(folderPath); + if (!dir.existsSync()) { + throw FileSystemException('Folder does not exist', folderPath); + } + + final files = dir + .listSync(recursive: false) + .whereType() + .where((f) => !p.basename(f.path).startsWith('.')) + .toList(); + + final Map> clusters = {}; + + for (final file in files) { + final fname = p.basename(file.path); + final clean = extractCleanTitle(fname); + if (clean.isEmpty) continue; + clusters.putIfAbsent(clean, () => []).add(file); + } + + final results = []; + int keptCount = 0; + int movedCount = 0; + + final dupDir = Directory(p.join(folderPath, '_duplicates')); + + clusters.forEach((cleanTitle, fileList) { + if (fileList.isEmpty) return; + + // Sort candidates so the top candidate is index 0 + fileList.sort((a, b) { + final fnameA = p.basename(a.path); + final fnameB = p.basename(b.path); + + // 1. Region rank (lower index is better) + final rankA = _getRegionRank(fnameA, regionPriority); + final rankB = _getRegionRank(fnameB, regionPriority); + if (rankA != rankB) return rankA.compareTo(rankB); + + // 2. Revision level (higher is better) + final revA = _getRevisionLevel(fnameA); + final revB = _getRevisionLevel(fnameB); + if (revA != revB) return revB.compareTo(revA); + + // 3. Dump score + final dumpA = _getDumpScore(fnameA); + final dumpB = _getDumpScore(fnameB); + if (dumpA != dumpB) return dumpB.compareTo(dumpA); + + // Tie breaker: alphabetical + return fnameA.compareTo(fnameB); + }); + + final winner = fileList.first; + final winnerName = p.basename(winner.path); + + results.add( + OneGOneRFileResult( + originalPath: winner.path, + filename: winnerName, + cleanTitle: cleanTitle, + isWinner: true, + reason: 'Preferred regional/revision version', + ), + ); + keptCount++; + + for (int i = 1; i < fileList.length; i++) { + final dup = fileList[i]; + final dupName = p.basename(dup.path); + final destPath = p.join(dupDir.path, dupName); + + if (!dryRun && moveDuplicates) { + if (!dupDir.existsSync()) { + dupDir.createSync(recursive: true); + } + dup.renameSync(destPath); + } + + results.add( + OneGOneRFileResult( + originalPath: dup.path, + filename: dupName, + cleanTitle: cleanTitle, + isWinner: false, + destinationPath: destPath, + reason: 'Duplicate of $winnerName', + ), + ); + movedCount++; + } + }); + + return OneGOneRSummary( + totalFiles: files.length, + keptCount: keptCount, + movedCount: movedCount, + results: results, + ); +} diff --git a/core/test/chd_audio_codec_test.dart b/core/test/chd_audio_codec_test.dart new file mode 100644 index 0000000..85b87c9 --- /dev/null +++ b/core/test/chd_audio_codec_test.dart @@ -0,0 +1,81 @@ +import 'dart:io'; + +import 'package:openrom_core/openrom_core.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +void main() { + test('chd_audio_codec_flac_default', () { + final tmpDir = Directory.systemTemp.createTempSync('chd_codec_flac_'); + try { + final cuePath = p.join(tmpDir.path, 'game.cue'); + File(cuePath).writeAsStringSync( + 'FILE "game.bin" BINARY\n TRACK 01 MODE1/2352\n INDEX 01 00:00:00'); + File(p.join(tmpDir.path, 'game.bin')) + .writeAsBytesSync(List.filled(100, 0)); + + final mockChdman = p.join(tmpDir.path, 'mock_chdman'); + File(mockChdman).writeAsStringSync('#!/bin/sh\nexit 0'); + if (!Platform.isWindows) { + Process.runSync('chmod', ['+x', mockChdman]); + } + saveConfig({'chdman': mockChdman}); + + final job = ConversionJob( + filepath: cuePath, + outputDir: tmpDir.path, + targetFormat: 'CHD', + audioCodec: 'flac', + ); + + final logs = []; + final converter = Converter(onLogCb: (msg) => logs.add(msg)); + converter.convert(job); + + expect(job.audioCodec, equals('flac')); + final cmdLog = + logs.firstWhere((l) => l.contains('cmd:'), orElse: () => ''); + expect(cmdLog, contains('--compression cdlz,cdfl')); + } finally { + saveConfig({}); + tmpDir.deleteSync(recursive: true); + } + }); + + test('chd_audio_codec_vorbis', () { + final tmpDir = Directory.systemTemp.createTempSync('chd_codec_vorbis_'); + try { + final cuePath = p.join(tmpDir.path, 'game.cue'); + File(cuePath).writeAsStringSync( + 'FILE "game.bin" BINARY\n TRACK 01 MODE1/2352\n INDEX 01 00:00:00'); + File(p.join(tmpDir.path, 'game.bin')) + .writeAsBytesSync(List.filled(100, 0)); + + final mockChdman = p.join(tmpDir.path, 'mock_chdman'); + File(mockChdman).writeAsStringSync('#!/bin/sh\nexit 0'); + if (!Platform.isWindows) { + Process.runSync('chmod', ['+x', mockChdman]); + } + saveConfig({'chdman': mockChdman}); + + final job = ConversionJob( + filepath: cuePath, + outputDir: tmpDir.path, + targetFormat: 'CHD', + audioCodec: 'vorbis', + ); + + final logs = []; + final converter = Converter(onLogCb: (msg) => logs.add(msg)); + converter.convert(job); + + expect(job.audioCodec, equals('vorbis')); + final cmdLog = + logs.firstWhere((l) => l.contains('cmd:'), orElse: () => ''); + expect(cmdLog, contains('--compression cdlz,cdav')); + } finally { + saveConfig({}); + tmpDir.deleteSync(recursive: true); + } + }); +} diff --git a/core/test/one_g_one_r_test.dart b/core/test/one_g_one_r_test.dart new file mode 100644 index 0000000..f81ea44 --- /dev/null +++ b/core/test/one_g_one_r_test.dart @@ -0,0 +1,141 @@ +import 'dart:io'; + +import 'package:openrom_core/openrom_core.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +void main() { + test('extractCleanTitle removes extension and tags', () { + expect( + extractCleanTitle('Super Mario World (USA) (Rev 1).sfc'), + equals('Super Mario World'), + ); + expect( + extractCleanTitle('Sonic the Hedgehog (Europe) [!].bin'), + equals('Sonic the Hedgehog'), + ); + expect( + extractCleanTitle('Chrono Trigger (Japan) (v1.1).smc'), + equals('Chrono Trigger'), + ); + }); + + test('1G1R region priority selection USA over EUR and JPN', () { + final tmpDir = Directory.systemTemp.createTempSync('one_g_one_r_test_'); + try { + final usaFile = p.join(tmpDir.path, 'Super Mario World (USA).sfc'); + final eurFile = p.join(tmpDir.path, 'Super Mario World (Europe).sfc'); + final jpnFile = p.join(tmpDir.path, 'Super Mario World (Japan).sfc'); + + File(usaFile).writeAsBytesSync([1, 2, 3]); + File(eurFile).writeAsBytesSync([1, 2, 3]); + File(jpnFile).writeAsBytesSync([1, 2, 3]); + + final summary = filter1G1R( + folderPath: tmpDir.path, + regionPriority: ['USA', 'Europe', 'Japan'], + dryRun: true, + ); + + expect(summary.totalFiles, equals(3)); + expect(summary.keptCount, equals(1)); + expect(summary.movedCount, equals(2)); + + final winner = summary.results.firstWhere((r) => r.isWinner); + expect(winner.filename, equals('Super Mario World (USA).sfc')); + + final dupes = summary.results + .where((r) => !r.isWinner) + .map((r) => r.filename) + .toList(); + expect(dupes, contains('Super Mario World (Europe).sfc')); + expect(dupes, contains('Super Mario World (Japan).sfc')); + } finally { + tmpDir.deleteSync(recursive: true); + } + }); + + test('1G1R prefers revision version over base version', () { + final tmpDir = Directory.systemTemp.createTempSync('one_g_one_r_rev_test_'); + try { + final baseFile = p.join(tmpDir.path, 'Zelda (USA).sfc'); + final revFile = p.join(tmpDir.path, 'Zelda (USA) (Rev 1).sfc'); + + File(baseFile).writeAsBytesSync([1, 2, 3]); + File(revFile).writeAsBytesSync([1, 2, 3]); + + final summary = filter1G1R( + folderPath: tmpDir.path, + regionPriority: ['USA', 'Europe', 'Japan'], + dryRun: true, + ); + + expect(summary.keptCount, equals(1)); + expect(summary.movedCount, equals(1)); + + final winner = summary.results.firstWhere((r) => r.isWinner); + expect(winner.filename, equals('Zelda (USA) (Rev 1).sfc')); + } finally { + tmpDir.deleteSync(recursive: true); + } + }); + + test('1G1R moves duplicates when dryRun is false', () { + final tmpDir = Directory.systemTemp.createTempSync( + 'one_g_one_r_move_test_', + ); + try { + final usaFile = p.join(tmpDir.path, 'Metroid (USA).sfc'); + final eurFile = p.join(tmpDir.path, 'Metroid (Europe).sfc'); + + File(usaFile).writeAsBytesSync([1, 2, 3]); + File(eurFile).writeAsBytesSync([1, 2, 3]); + + final summary = filter1G1R( + folderPath: tmpDir.path, + regionPriority: ['USA', 'Europe'], + dryRun: false, + moveDuplicates: true, + ); + + expect(summary.movedCount, equals(1)); + expect(File(usaFile).existsSync(), isTrue); + expect(File(eurFile).existsSync(), isFalse); + + final dupPath = p.join( + tmpDir.path, + '_duplicates', + 'Metroid (Europe).sfc', + ); + expect(File(dupPath).existsSync(), isTrue); + } finally { + tmpDir.deleteSync(recursive: true); + } + }); + + test('1G1R dryRun does not move files or create duplicates folder', () { + final tmpDir = Directory.systemTemp.createTempSync('one_g_one_r_dry_test_'); + try { + final usaFile = p.join(tmpDir.path, 'Donkey Kong (USA).sfc'); + final eurFile = p.join(tmpDir.path, 'Donkey Kong (Europe).sfc'); + + File(usaFile).writeAsBytesSync([1, 2, 3]); + File(eurFile).writeAsBytesSync([1, 2, 3]); + + final summary = filter1G1R( + folderPath: tmpDir.path, + regionPriority: ['USA', 'Europe'], + dryRun: true, + ); + + expect(summary.movedCount, equals(1)); + expect(File(usaFile).existsSync(), isTrue); + expect(File(eurFile).existsSync(), isTrue); + + final dupDir = Directory(p.join(tmpDir.path, '_duplicates')); + expect(dupDir.existsSync(), isFalse); + } finally { + tmpDir.deleteSync(recursive: true); + } + }); +} diff --git a/gui/lib/app.dart b/gui/lib/app.dart index 4385af3..1c5b665 100644 --- a/gui/lib/app.dart +++ b/gui/lib/app.dart @@ -41,6 +41,7 @@ class _OpenROMAppState extends State { String _compression = 'Normal'; bool _verify = false; String _outputDir = ''; + String _audioCodec = 'flac'; @override void initState() { @@ -77,6 +78,7 @@ class _OpenROMAppState extends State { compression: _compression, verify: _verify, outputDir: _outputDir, + audioCodec: _audioCodec, onPromptMedia: (job) async { return await showDialog( context: context, @@ -121,12 +123,14 @@ class _OpenROMAppState extends State { theme: theme, themeService: widget.themeService, localeProvider: widget.localeProvider, - onSettingsChanged: (format, compression, verify, outputDir, sameFolder) { + onSettingsChanged: + (format, compression, verify, outputDir, sameFolder, audioCodec) { setState(() { _format = format; _compression = compression; _verify = verify; _outputDir = outputDir; + _audioCodec = audioCodec; }); }, ); diff --git a/gui/lib/controllers/home_controller.dart b/gui/lib/controllers/home_controller.dart index 017d60a..3693a66 100644 --- a/gui/lib/controllers/home_controller.dart +++ b/gui/lib/controllers/home_controller.dart @@ -76,6 +76,7 @@ class HomeController extends ChangeNotifier { String compression = 'Normal', bool verify = false, String outputDir = '', + String audioCodec = 'flac', Future Function(ConversionJob job)? onPromptMedia, void Function(OpenROMException e)? onError, }) async { @@ -95,6 +96,7 @@ class HomeController extends ChangeNotifier { } job.compression = compression; job.verify = verify; + job.audioCodec = audioCodec; job.estimatedOutputSize = ConversionJob.estimateSize( job.romFile.fileSizeBytes ?? 0, job.targetFormat, diff --git a/gui/lib/models/conversion_job.dart b/gui/lib/models/conversion_job.dart index 9334a30..29eef76 100644 --- a/gui/lib/models/conversion_job.dart +++ b/gui/lib/models/conversion_job.dart @@ -13,6 +13,7 @@ class ConversionJob { String compression; bool verify; String? media; + String? audioCodec; JobStatus status; double progress; List logs; @@ -27,6 +28,7 @@ class ConversionJob { this.compression = 'Normal', this.verify = false, this.media, + this.audioCodec = 'flac', this.status = JobStatus.queued, this.progress = 0.0, List? logs, diff --git a/gui/lib/screens/settings_screen.dart b/gui/lib/screens/settings_screen.dart index c7678a9..9d2a7ba 100644 --- a/gui/lib/screens/settings_screen.dart +++ b/gui/lib/screens/settings_screen.dart @@ -23,6 +23,7 @@ class SettingsScreen extends StatefulWidget { bool verify, String outputDir, bool sameFolder, + String audioCodec, ) onSettingsChanged; const SettingsScreen({ @@ -40,6 +41,7 @@ class SettingsScreen extends StatefulWidget { class _SettingsScreenState extends State { String _selectedFormat = 'CHD'; String _selectedCompression = 'Normal'; + String _selectedAudioCodec = 'flac'; bool _verifyAfterConversion = false; bool _sameFolderAsSource = true; String _outputDestination = ''; @@ -83,6 +85,7 @@ class _SettingsScreenState extends State { setState(() { _selectedFormat = prefs.getString('default_format') ?? 'CHD'; _selectedCompression = prefs.getString('default_compression') ?? 'Normal'; + _selectedAudioCodec = prefs.getString('cd_audio_codec') ?? 'flac'; _verifyAfterConversion = prefs.getBool('verify_conversion') ?? false; _sameFolderAsSource = prefs.getBool('same_folder') ?? true; _outputDestination = prefs.getString('output_dir') ?? ''; @@ -105,6 +108,7 @@ class _SettingsScreenState extends State { final prefs = await SharedPreferences.getInstance(); await prefs.setString('default_format', _selectedFormat); await prefs.setString('default_compression', _selectedCompression); + await prefs.setString('cd_audio_codec', _selectedAudioCodec); await prefs.setBool('verify_conversion', _verifyAfterConversion); await prefs.setBool('same_folder', _sameFolderAsSource); await prefs.setString('output_dir', _outputDestination); @@ -126,6 +130,7 @@ class _SettingsScreenState extends State { _verifyAfterConversion, _sameFolderAsSource ? '' : _outputDestination, _sameFolderAsSource, + _selectedAudioCodec, ); } @@ -293,6 +298,51 @@ class _SettingsScreenState extends State { ), const SizedBox(height: 24), + // CD Audio Codec + _buildSectionTitle('CD Audio Codec (CHD)'), + Wrap( + spacing: 12, + children: [ + ChoiceChip( + label: const Text('Lossless (FLAC) - Recommended'), + selected: _selectedAudioCodec == 'flac', + selectedColor: theme.accent, + backgroundColor: theme.surface, + labelStyle: TextStyle( + color: _selectedAudioCodec == 'flac' + ? Colors.white + : theme.textPrimary, + fontWeight: FontWeight.bold, + ), + onSelected: (selected) { + if (selected) { + setState(() => _selectedAudioCodec = 'flac'); + _saveSettings(); + } + }, + ), + ChoiceChip( + label: const Text('Maximum Compression (Vorbis)'), + selected: _selectedAudioCodec == 'vorbis', + selectedColor: theme.accent, + backgroundColor: theme.surface, + labelStyle: TextStyle( + color: _selectedAudioCodec == 'vorbis' + ? Colors.white + : theme.textPrimary, + fontWeight: FontWeight.bold, + ), + onSelected: (selected) { + if (selected) { + setState(() => _selectedAudioCodec = 'vorbis'); + _saveSettings(); + } + }, + ), + ], + ), + const SizedBox(height: 24), + // Verification Switch _buildSectionTitle(l10n.postProcessing), Material( diff --git a/gui/lib/screens/tools_screen.dart b/gui/lib/screens/tools_screen.dart index f29caf6..cda599a 100644 --- a/gui/lib/screens/tools_screen.dart +++ b/gui/lib/screens/tools_screen.dart @@ -33,7 +33,7 @@ class _ToolsScreenState extends State @override void initState() { super.initState(); - _tabController = TabController(length: 7, vsync: this); + _tabController = TabController(length: 8, vsync: this); } @override @@ -84,6 +84,10 @@ class _ToolsScreenState extends State icon: const Icon(Icons.drive_file_rename_outline), text: 'ROM Renamer', ), + Tab( + icon: const Icon(Icons.cleaning_services_outlined), + text: '1G1R Cleaner', + ), ], ), ), @@ -98,6 +102,7 @@ class _ToolsScreenState extends State _HeaderRemoverTab(theme: widget.theme), _CueEditorTab(theme: widget.theme), _RomRenamerTab(theme: widget.theme), + _OneGOneRCleanerTab(theme: widget.theme), ], ), ), @@ -2444,3 +2449,304 @@ class _RomRenamerTabState extends State<_RomRenamerTab> { ); } } + +// ── Tab 8: 1G1R Cleaner ─────────────────────────────────────────────────────── + +class _OneGOneRCleanerTab extends StatefulWidget { + final ThemeConfig theme; + const _OneGOneRCleanerTab({required this.theme}); + + @override + State<_OneGOneRCleanerTab> createState() => _OneGOneRCleanerTabState(); +} + +class _OneGOneRCleanerTabState extends State<_OneGOneRCleanerTab> { + final _service = ToolsService(); + String? _romFolder; + final List _regionPriority = ['USA', 'EUR', 'JPN', 'World']; + bool _dryRun = true; + bool _isProcessing = false; + String _statusMsg = ''; + Map? _results; + + Future _pickFolder() async { + final path = await FilePicker.platform.getDirectoryPath(); + if (path != null) setState(() => _romFolder = path); + } + + Future _process1G1R() async { + if (_romFolder == null) return; + setState(() { + _isProcessing = true; + _statusMsg = + _dryRun ? 'Simulating 1G1R filter...' : 'Cleaning duplicates...'; + _results = null; + }); + try { + final res = await _service.filter1G1R( + folderPath: _romFolder!, + regionPriority: _regionPriority, + dryRun: _dryRun, + ); + setState(() { + _results = res; + final moved = res['moved_count'] ?? 0; + _statusMsg = _dryRun + ? '$moved duplicates would be moved to _duplicates/' + : '$moved duplicates moved to _duplicates/'; + }); + } on OpenROMException catch (e) { + if (mounted) { + showOpenROMError(context, e.error, details: e.details); + } + } catch (e) { + setState(() => _statusMsg = 'Error: $e'); + } finally { + setState(() => _isProcessing = false); + } + } + + @override + Widget build(BuildContext context) { + final theme = widget.theme; + + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.cleaning_services, color: theme.accent, size: 28), + const SizedBox(width: 10), + Text( + '1G1R Collection Cleaner', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: theme.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 16), + + // Folder Selection + Text( + 'ROM Collection Folder', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: theme.textSecondary, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + ElevatedButton.icon( + onPressed: _pickFolder, + icon: const Icon(Icons.folder_open, size: 16), + label: const Text('Select Folder'), + ), + const SizedBox(width: 12), + if (_romFolder != null) + Expanded( + child: Text( + _romFolder!, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 12, color: theme.textSecondary), + ), + ), + ], + ), + const SizedBox(height: 16), + + // Region Priority + Text( + 'Region Priority Order', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: theme.textSecondary, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: List.generate(_regionPriority.length, (idx) { + final reg = _regionPriority[idx]; + return Chip( + avatar: CircleAvatar( + backgroundColor: theme.accent, + child: Text( + '${idx + 1}', + style: const TextStyle(fontSize: 11, color: Colors.black), + ), + ), + label: Text(reg), + backgroundColor: theme.surface, + labelStyle: TextStyle( + color: theme.textPrimary, + fontWeight: FontWeight.bold, + ), + onDeleted: _regionPriority.length > 1 + ? () { + setState(() => _regionPriority.removeAt(idx)); + } + : null, + ); + }), + ), + const SizedBox(height: 16), + + // Settings & Action + Row( + children: [ + Checkbox( + value: _dryRun, + activeColor: theme.accent, + onChanged: (v) => setState(() => _dryRun = v ?? true), + ), + Text( + 'Dry Run / Preview', + style: TextStyle(color: theme.textPrimary), + ), + const Spacer(), + ElevatedButton.icon( + onPressed: (_romFolder != null && !_isProcessing) + ? _process1G1R + : null, + style: ElevatedButton.styleFrom( + backgroundColor: theme.accent, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), + ), + icon: _isProcessing + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.black, + ), + ) + : const Icon(Icons.filter_list, size: 18), + label: Text( + _dryRun ? 'Preview Collection' : 'Process Collection', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + ], + ), + + if (_statusMsg.isNotEmpty) ...[ + const SizedBox(height: 12), + Text( + _statusMsg, + style: TextStyle( + fontSize: 13, + color: theme.accent, + fontStyle: FontStyle.italic, + ), + ), + ], + + // Results View + if (_results != null) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.white10), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _statTile( + 'Total Files', + '${_results!["total_files"]}', + theme.textPrimary, + ), + _statTile( + 'Kept ROMs', + '${_results!["kept_count"]}', + Colors.greenAccent, + ), + _statTile( + 'Duplicates', + '${_results!["moved_count"]}', + Colors.orangeAccent, + ), + ], + ), + ), + const SizedBox(height: 12), + Expanded( + child: ListView.builder( + itemCount: (_results!['results'] as List? ?? []).length, + itemBuilder: (context, i) { + final item = (_results!['results'] as List)[i]; + final isWinner = item['is_winner'] == true; + return ListTile( + dense: true, + leading: Icon( + isWinner + ? Icons.check_circle + : Icons.drive_file_move_outlined, + color: + isWinner ? Colors.greenAccent : Colors.orangeAccent, + size: 18, + ), + title: Text( + item['filename'] ?? '', + style: TextStyle( + fontSize: 13, + color: theme.textPrimary, + fontWeight: + isWinner ? FontWeight.bold : FontWeight.normal, + ), + ), + subtitle: Text( + isWinner + ? 'Kept (${item["clean_title"]})' + : 'Duplicate -> _duplicates/', + style: TextStyle( + fontSize: 11, + color: theme.textSecondary, + ), + ), + ); + }, + ), + ), + ] else + const Expanded(child: SizedBox()), + ], + ), + ); + } + + Widget _statTile(String label, String value, Color color) { + return Column( + children: [ + Text( + value, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: color, + ), + ), + Text( + label, + style: TextStyle(fontSize: 12, color: widget.theme.textSecondary), + ), + ], + ); + } +} diff --git a/gui/lib/services/core_bridge.dart b/gui/lib/services/core_bridge.dart index b3dabc3..e57ef20 100644 --- a/gui/lib/services/core_bridge.dart +++ b/gui/lib/services/core_bridge.dart @@ -174,6 +174,10 @@ class CoreBridge { args.addAll(['--media', job.media!]); } + if (job.audioCodec != null && job.audioCodec!.isNotEmpty) { + args.addAll(['--audio-codec', job.audioCodec!]); + } + if (outputDir.isNotEmpty) { args.addAll(['--output', outputDir]); } diff --git a/gui/lib/services/tools_service.dart b/gui/lib/services/tools_service.dart index cebe213..44d3ab2 100644 --- a/gui/lib/services/tools_service.dart +++ b/gui/lib/services/tools_service.dart @@ -81,9 +81,9 @@ class ToolsService { final result = await Process.run(corePath, args); if (result.exitCode == 0) { - final lines = LineSplitter.split(result.stdout.toString()) - .where((l) => l.trim().isNotEmpty) - .toList(); + final lines = LineSplitter.split( + result.stdout.toString(), + ).where((l) => l.trim().isNotEmpty).toList(); for (final line in lines.reversed) { try { final Map event = jsonDecode(line); @@ -226,9 +226,9 @@ class ToolsService { final result = await Process.run(corePath, args); if (result.exitCode == 0) { - final lines = LineSplitter.split(result.stdout.toString()) - .where((l) => l.trim().isNotEmpty) - .toList(); + final lines = LineSplitter.split( + result.stdout.toString(), + ).where((l) => l.trim().isNotEmpty).toList(); for (final line in lines.reversed) { try { final Map event = jsonDecode(line); @@ -288,9 +288,9 @@ class ToolsService { final result = await Process.run(corePath, args); if (result.exitCode == 0) { - final lines = LineSplitter.split(result.stdout.toString()) - .where((l) => l.trim().isNotEmpty) - .toList(); + final lines = LineSplitter.split( + result.stdout.toString(), + ).where((l) => l.trim().isNotEmpty).toList(); for (final line in lines.reversed) { try { final Map event = jsonDecode(line); @@ -333,9 +333,9 @@ class ToolsService { final result = await Process.run(corePath, args); if (result.exitCode == 0) { - final lines = LineSplitter.split(result.stdout.toString()) - .where((l) => l.trim().isNotEmpty) - .toList(); + final lines = LineSplitter.split( + result.stdout.toString(), + ).where((l) => l.trim().isNotEmpty).toList(); for (final line in lines.reversed) { try { final Map event = jsonDecode(line); @@ -375,9 +375,9 @@ class ToolsService { final result = await Process.run(corePath, args); if (result.exitCode == 0) { - final lines = LineSplitter.split(result.stdout.toString()) - .where((l) => l.trim().isNotEmpty) - .toList(); + final lines = LineSplitter.split( + result.stdout.toString(), + ).where((l) => l.trim().isNotEmpty).toList(); for (final line in lines.reversed) { try { final Map event = jsonDecode(line); @@ -512,4 +512,47 @@ class ToolsService { } return results; } + + /// Run 1G1R filter on a ROM collection folder. + Future> filter1G1R({ + required String folderPath, + List? regionPriority, + bool dryRun = true, + }) async { + if (!await CoreBridge.coreExists()) { + throw OpenROMException(OpenROMError.coreNotFound); + } + final corePath = await CoreBridge.getCoreExecutablePath(); + final List args = []; + if (corePath.endsWith('python3') || corePath.endsWith('python')) { + args.add('main.py'); + } + args.addAll(['--json', '--1g1r', folderPath]); + if (regionPriority != null && regionPriority.isNotEmpty) { + args.addAll(['--priority', regionPriority.join(',')]); + } + if (dryRun) { + args.add('--dry-run'); + } + + final result = await Process.run(corePath, args); + if (result.exitCode == 0) { + final lines = LineSplitter.split( + result.stdout.toString(), + ).where((l) => l.trim().isNotEmpty).toList(); + for (final line in lines.reversed) { + try { + final Map event = jsonDecode(line); + if (event['type'] == 'done' && event['success'] == true) { + return event; + } + } catch (_) {} + } + } + final err = result.stderr.toString().trim(); + throw OpenROMException( + OpenROMError.conversionFailed, + details: err.isNotEmpty ? err : '1G1R filter failed', + ); + } }