From 735b682dabb78ff71db69dfc42352009c3915326 Mon Sep 17 00:00:00 2001 From: ERROR0cai Date: Mon, 24 Aug 2026 14:01:24 +0800 Subject: [PATCH 01/11] fix: Fix incorrect matches caused by name containment, add similarity scoring algorithm, add adjustable match threshold slider, add full Chinese localization support --- Localization/en_US.xaml | 166 +++++++++++++++++ Localization/zh_CN.xaml | 166 +++++++++++++++++ OrganizerService.cs | 294 +++++++++++++++++++++--------- Settings.cs | 5 + Views/EmulatorsView.xaml | 22 +-- Views/FullscreenReviewWindow.xaml | 22 +-- Views/ReviewWindow.xaml | 22 +-- Views/SettingsTabView.xaml | 6 +- Views/SettingsView.xaml | 178 ++++++++++-------- extension.yaml | 16 +- manifest.yaml | 18 +- 11 files changed, 703 insertions(+), 212 deletions(-) create mode 100644 Localization/en_US.xaml create mode 100644 Localization/zh_CN.xaml diff --git a/Localization/en_US.xaml b/Localization/en_US.xaml new file mode 100644 index 0000000..b3d8355 --- /dev/null +++ b/Localization/en_US.xaml @@ -0,0 +1,166 @@ + + + + + General + Emulators + + + + + Folders + Source folder (Game Bar / ShareX output): + Browse + Additional source folders (one per line): + Destination base folder (game subfolders live here): + + + The folder where your capture tool saves screenshots. GameSnap watches this folder in real time and moves files as they arrive. Xbox Game Bar: Settings → Gaming → Captures ShareX: Task Settings → Override Screenshots Folder + Full path to the folder where your capture tool drops screenshots. + Optional extra folders to monitor. Useful if you use multiple capture tools that save to different locations (e.g. ShareX and Game Bar simultaneously). + One folder path per line. All folders are monitored with the same rules as the primary source folder. + The parent folder that contains one subfolder per game. Example structure: D:\Screenshots\ Elden Ring\ Cyberpunk 2077\ Celeste\ GameSnap matches screenshots to these subfolders automatically. + Parent folder containing your per-game subfolders. + + + Detection + Use Playnite game detection (recommended) + Use active window fallback + Emulator process prefixes (comma-separated) + Auto-create game folders when a game starts + + + When a game is running in Playnite, screenshots are automatically assigned to that game. Also learns aliases for future use without Playnite running. + Assigns screenshots to the currently running game and learns the alias automatically. + If Playnite detection is off or no game is running, GameSnap reads the active window title and tries to match it to a game folder. Less reliable than Playnite detection. + Secondary detection using the active window title. Less reliable — use Playnite detection when possible. + Files whose name starts with one of these prefixes (e.g. 'retroarch') skip the dictionary and active-window detection entirely and use only the game currently running in Playnite. Use this for emulators that only expose the core name (not the ROM name) in the window title/process — otherwise the dictionary would learn the wrong game and keep reusing it for every ROM. + Automatically creates a subfolder in the destination base using the game name from Playnite whenever you start playing a game for the first time. Disabled by default. + Disabled by default. Creates the game folder on first play — no manual folder creation needed. + + + Unmatched Files + Move unmatched screenshots to a separate folder + Folder name: + + + Instead of leaving unrecognized screenshots in the source folder, moves them to a dedicated folder so you can review and assign them later using the Review window (Extensions → GameSnap → Review unmatched screenshots). + Use 'Review unmatched screenshots' from the GameSnap menu to assign them to games manually. + Name of the subfolder created inside the destination base for unmatched files. + Default: _Unmatched. The folder is created automatically inside the destination base. + + + Notifications + Show Playnite notification when screenshots are organized + + + Shows a notification in Playnite's notification area (bell icon) with a summary of how many screenshots were organized and which games they went to. + Example: 'Organized 3 screenshot(s): Elden Ring (2) | Celeste (1)' + + + Rename Pattern + + + Pattern used to rename screenshots when moving them. Use tokens to build the filename. Tokens: {game} → game folder name {date} → yyyy-MM-dd {time} → HH_mm_ss {datetime} → yyyy-MM-dd_HH_mm_ss {original} → original filename without extension + Tokens: {game} {date} {time} {datetime} {original} Example: {game}_{date}_{time} → EldenRing_2026-05-20_14_30_00.png + + + Backup + Enable automatic backup (disabled by default) + Backup folder: + + + After moving a screenshot to its game folder, GameSnap also copies it to the backup folder. Useful for cloud drives or external HDDs. Disabled by default. + Copies each organized screenshot to the backup folder after moving. Mirrors the same game subfolder structure. + Destination for backup copies. Can be a cloud-synced folder (Google Drive, OneDrive) or an external drive. + Full path to the backup destination folder. + + + ScreenshotsVisualizer Integration + Auto-refresh ScreenshotsVisualizer after organizing (disabled by default) + + + When enabled, GameSnap automatically notifies ScreenshotsVisualizer to rescan a game whenever a screenshot is moved to its folder. Screenshots appear in the viewer instantly without any manual refresh. Requires ScreenshotsVisualizer to be installed — has no effect otherwise. + Pairs with ScreenshotsVisualizer by Lacro59. Set the Global screenshots path in SV to your destination folder with the {Name} token. + + + Steam Screenshots + Enable Steam screenshot support (disabled by default) + Steam path (leave empty to auto-detect): + + + Monitors Steam's userdata folder and moves screenshots to the matching game folder in your destination base. GameSnap uses your Playnite library to resolve Steam AppIDs to game names — no API key needed. Disabled by default. + Reads from Steam's userdata folder. Game names are resolved using your Playnite library — only works for games already added to Playnite. + Leave empty to detect Steam automatically from the Windows registry. Only set this manually if auto-detection fails. + Example: C:\Program Files (x86)\Steam + + + Polling interval (seconds): + (backup scan in addition to real-time file watcher) + + + How often GameSnap does a full scan of the source folder as a backup, in addition to the real-time file watcher. Increase this value if you notice high CPU usage. + Default: 30 seconds. The file watcher handles most cases instantly — polling is just a safety net. + + + File Extensions + Image extensions (comma separated): + Video extensions (comma separated): + + + File extensions treated as images. These are moved directly to the game folder. + Default: .png, .jpg, .jpeg + File extensions treated as videos. These are moved to a 'Videos' subfolder inside the game folder. + Default: .mp4, .wmv — moved to GameFolder\Videos\ + + + Tools + Open dictionary.txt + Open log + + + Opens the alias dictionary in Notepad. Add custom mappings between filename prefixes and game names. GameSnap also writes to this file automatically when it learns new aliases. + Opens the GameSnap log file. Shows all moves, learned aliases, fallback detections, and errors. Useful for debugging unmatched files. + + + Enable emulator screenshot support + When enabled, GameSnap will monitor the screenshot folders of all active emulators below and organize screenshots just like Game Bar or ShareX captures. + Enable individual emulators below. GameSnap auto-detects standard install locations. + Tip: Enable 'Auto-create game folders' in the General tab so folders are created automatically the first time you play a game on an emulator. + This reads the emulator's own native screenshot folder. Screenshots must be taken with the emulator's built-in screenshot function (e.g. F9 in RetroArch) — captures made with ShareX or Xbox Game Bar won't show up here. For those, use 'Emulator process prefixes' in the Detection tab instead. + Browse + + Add emulator + Add a custom emulator not in the list above. + Remove selected + Remove the last user-added emulator. + + + GameSnap — Review Unmatched Screenshots + Review Unmatched Screenshots + Unmatched Files + Select a file to preview + Assign to Game + Search games... + Assign to selected game + Delete file + Skip + Close + + + Review Unmatched Screenshots + Select a file to preview + Search & Assign + Close + Skip + Select game + Back + Navigate + SPACE + CLEAR + + + Match similarity threshold: + Higher values require closer matches. Lower values are more permissive. Files below this score go to _Unmatched. Default: 50% + \ No newline at end of file diff --git a/Localization/zh_CN.xaml b/Localization/zh_CN.xaml new file mode 100644 index 0000000..7fbe3dd --- /dev/null +++ b/Localization/zh_CN.xaml @@ -0,0 +1,166 @@ + + + + + 常规 + 模拟器 + + + + + 文件夹 + 源文件夹(游戏栏 / ShareX 输出目录): + 浏览 + 附加源文件夹(每行一个): + 目标基础文件夹(游戏子文件夹位于此处): + + + 截图工具保存截图的文件夹。GameSnap 会实时监控此文件夹并在文件到达时自动移动。 Xbox 游戏栏:设置 → 游戏 → 捕获 ShareX:任务设置 → 覆盖截图文件夹 + 截图工具存放截图的完整路径。 + 可选的额外监控文件夹。如果您使用多个截图工具并保存到不同位置(例如同时使用 ShareX 和游戏栏),此选项很有用。 + 每行一个文件夹路径。所有文件夹都使用与主源文件夹相同的规则进行监控。 + 包含每个游戏一个子文件夹的父文件夹。 结构示例: D:\Screenshots\ 艾尔登法环\ 赛博朋克 2077\ 蔚蓝\ GameSnap 会自动将截图匹配到这些子文件夹。 + 包含每个游戏子文件夹的父文件夹。 + + + 检测 + 使用 Playnite 游戏检测(推荐) + 使用活动窗口回退方案 + 模拟器进程前缀(逗号分隔) + 游戏启动时自动创建游戏文件夹 + + + 当 Playnite 中有游戏正在运行时,截图会自动分配给该游戏。同时会学习别名,以便在 Playnite 未运行时使用。 + 将截图分配给当前正在运行的游戏,并自动学习别名。 + 如果 Playnite 检测关闭或没有游戏在运行,GameSnap 会读取活动窗口标题并尝试匹配到游戏文件夹。可靠性低于 Playnite 检测。 + 使用活动窗口标题进行辅助检测。可靠性较低 — 请尽可能使用 Playnite 检测。 + 文件名以这些前缀开头(例如 'retroarch')的文件会完全跳过字典和活动窗口检测,仅使用 Playnite 中当前运行的游戏。用于那些窗口标题/进程中只显示核心名称(不显示 ROM 名称)的模拟器 — 否则字典会学习错误的游戏名称,并导致所有 ROM 都复用同一个游戏文件夹。 + 每当您首次启动游戏时,自动在目标基础文件夹中创建以游戏名称命名的子文件夹。默认禁用。 + 默认禁用。首次游玩时创建游戏文件夹 — 无需手动创建文件夹。 + + + 未匹配文件 + 将未匹配的截图移动到单独的文件夹 + 文件夹名称: + + + 不将无法识别的截图留在源文件夹中,而是将它们移动到一个专用文件夹,方便您稍后通过"回顾"窗口进行审查和手动分配(扩展 → GameSnap → 回顾未匹配截图)。 + 使用 GameSnap 菜单中的"回顾未匹配截图"来手动将它们分配给游戏。 + 在目标基础文件夹内为未匹配文件创建的子文件夹名称。 + 默认值:_Unmatched。该文件夹会在目标基础文件夹内自动创建。 + + + 通知 + 截图整理完成后显示 Playnite 通知 + + + 在 Playnite 的通知区域(铃铛图标)显示通知,汇总整理了多张截图以及它们分配到的游戏。 + 例如:"已整理 3 张截图:艾尔登法环 (2) | 蔚蓝 (1)" + + + 重命名模式 + + + 移动截图时用于重命名的模式。使用令牌构建文件名。 令牌: {game} → 游戏文件夹名称 {date} → yyyy-MM-dd {time} → HH_mm_ss {datetime} → yyyy-MM-dd_HH_mm_ss {original} → 原始文件名(不含扩展名) + 令牌:{game} {date} {time} {datetime} {original} 示例:{game}_{date}_{time} → 艾尔登法环_2026-05-20_14_30_00.png + + + 备份 + 启用自动备份(默认禁用) + 备份文件夹: + + + 将截图移动到游戏文件夹后,GameSnap 还会将其复制到备份文件夹。适用于云盘或外置硬盘。默认禁用。 + 每张整理后的截图在移动后会复制到备份文件夹。保持相同的游戏子文件夹结构。 + 备份副本的目标位置。可以是云同步文件夹(Google Drive、OneDrive)或外置硬盘。 + 备份目标文件夹的完整路径。 + + + ScreenshotsVisualizer 集成 + 整理后自动刷新 ScreenshotsVisualizer(默认禁用) + + + 启用后,每当截图被移动到游戏文件夹时,GameSnap 会自动通知 ScreenshotsVisualizer 重新扫描该游戏。截图会立即显示在查看器中,无需手动刷新。需要已安装 ScreenshotsVisualizer — 否则无效。 + 与 Lacro59 的 ScreenshotsVisualizer 配合使用。在 SV 中将全局截图路径设置为您的目标文件夹,并使用 {Name} 令牌。 + + + Steam 截图 + 启用 Steam 截图支持(默认禁用) + Steam 路径(留空自动检测): + + + 监控 Steam 的 userdata 文件夹,将截图移动到目标基础文件夹中匹配的游戏文件夹。GameSnap 使用您的 Playnite 库将 Steam AppID 解析为游戏名称 — 无需 API 密钥。默认禁用。 + 从 Steam 的 userdata 文件夹读取。游戏名称使用您的 Playnite 库解析 — 仅对已添加到 Playnite 的游戏有效。 + 留空以从 Windows 注册表自动检测 Steam。仅当自动检测失败时才手动设置。 + 示例:C:\Program Files (x86)\Steam + + + 轮询间隔(秒): + (除实时文件监控外的备份扫描) + + + 除了实时文件监控器外,GameSnap 多久对源文件夹进行一次完整扫描作为备份。如果发现 CPU 使用率过高,可增大此值。 + 默认值:30 秒。文件监控器会即时处理大多数情况 — 轮询仅作为安全网。 + + + 文件扩展名 + 图片扩展名(逗号分隔): + 视频扩展名(逗号分隔): + + + 被视为图片的文件扩展名。这些文件会直接移动到游戏文件夹。 + 默认值:.png, .jpg, .jpeg + 被视为视频的文件扩展名。这些文件会移动到游戏文件夹内的 'Videos' 子文件夹。 + 默认值:.mp4, .wmv — 移动到 游戏文件夹\Videos\ + + + 工具 + 打开 dictionary.txt + 打开日志 + + + 在记事本中打开别名词典。添加文件名前缀与游戏名称之间的自定义映射。GameSnap 在学习新别名时也会自动写入此文件。 + 打开 GameSnap 日志文件。显示所有移动记录、学习的别名、回退检测和错误信息。对调试未匹配文件很有用。 + + + 启用模拟器截图支持 + 启用后,GameSnap 将监控下方所有已激活模拟器的截图文件夹,并像游戏栏或 ShareX 截图一样进行整理。 + 在下方启用单个模拟器。GameSnap 会自动检测标准安装位置。 + 提示:在"常规"选项卡中启用"游戏启动时自动创建游戏文件夹",这样在模拟器上首次游玩游戏时会自动创建文件夹。 + 读取模拟器自身的本地截图文件夹。截图必须使用模拟器内置的截图功能(例如 RetroArch 中的 F9)拍摄 — 使用 ShareX 或 Xbox 游戏栏捕获的截图不会出现在这里。对于这些情况,请在"检测"选项卡中使用"模拟器进程前缀"。 + 浏览 + + 添加模拟器 + 添加一个不在上方列表中的自定义模拟器。 + 删除选中的 + 删除最后一个用户添加的模拟器。 + + + GameSnap — 回顾未匹配截图 + 回顾未匹配截图 + 未匹配文件 + 选择文件以预览 + 分配给游戏 + 搜索游戏... + 分配给所选游戏 + 删除文件 + 跳过 + 关闭 + + + 回顾未匹配截图 + 选择文件以预览 + 搜索并分配 + 关闭 + 跳过 + 选择游戏 + 返回 + 导航 + 空格 + 清除 + + + 匹配相似度阈值: + 数值越高要求匹配越严格,数值越低越宽松。低于此分数的文件会被移到 _Unmatched 文件夹。默认:50% + \ No newline at end of file diff --git a/OrganizerService.cs b/OrganizerService.cs index e028c77..d6561fb 100644 --- a/OrganizerService.cs +++ b/OrganizerService.cs @@ -38,10 +38,7 @@ public OrganizerService(GameSnapSettings settings, DictionaryService dictionary, // ────────────────────────────────────────────── // Entry point — chamado pelo watcher e pelo loop // ────────────────────────────────────────────── - // Steam service reference (set by plugin) public SteamService? SteamService { get; set; } - - // Emulator service reference (set by plugin) public EmulatorService? EmulatorService { get; set; } public void Organize() @@ -68,29 +65,29 @@ public void Organize() if (!Directory.Exists(_settings.DestinationBase)) return; var folders = LoadFolders(); - var counts = new Dictionary(StringComparer.OrdinalIgnoreCase); + var counts = new Dictionary(StringComparer.OrdinalIgnoreCase); + var matchScores = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var source in allSources) { if (!Directory.Exists(source)) continue; foreach (var file in Directory.GetFiles(source)) { - TryOrganizeFile(file, dict, folders, counts); + TryOrganizeFile(file, dict, folders, counts, matchScores); } } if (counts.Count > 0) { + var total = counts.Values.Sum(); var summary = string.Join(" | ", counts.Select(kv => $"{kv.Key} ({kv.Value})")); - _logger.Info($"Organized: {summary}"); + var scoreSummary = string.Join(" | ", matchScores.Select(kv => $"{kv.Key} ({kv.Value}%)")); - // Dispara notificação agregada + _logger.Info($"Organized: {summary}"); OnFileMoved?.Invoke( "GameSnap", - $"Organized {counts.Values.Sum()} screenshot(s): {summary}" + $"Organized {total} screenshot(s): {summary} [Score: {scoreSummary}]" ); - - // Notifica ScreenshotsVisualizer com a lista de jogos afetados OnGamesOrganized?.Invoke(counts.Keys.ToList()); } } @@ -112,7 +109,8 @@ private void OrganizeSteam() if (pending.Count == 0) return; var folders = LoadFolders(); - var counts = new Dictionary(StringComparer.OrdinalIgnoreCase); + var counts = new Dictionary(StringComparer.OrdinalIgnoreCase); + var matchScores = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var ss in pending) { @@ -127,11 +125,8 @@ private void OrganizeSteam() continue; } - var normGame = DictionaryService.Normalize(gameName); - var match = folders - .Where(f => f.NameNorm.Contains(normGame) || normGame.Contains(f.NameNorm)) - .OrderByDescending(f => f.NameNorm.Length) - .FirstOrDefault(); + var matchResult = FindBestMatchByScore(gameName, folders, _settings.MatchThreshold); + var match = matchResult.Folder; if (match == null) { @@ -141,8 +136,14 @@ private void OrganizeSteam() continue; } - var ext = Path.GetExtension(ss.FilePath).ToLowerInvariant(); - var date = GetBestDate(ss.FilePath); + // Record match score + if (!matchScores.ContainsKey(match.NameOriginal) || matchResult.Score > matchScores[match.NameOriginal]) + { + matchScores[match.NameOriginal] = matchResult.Score; + } + + var ext = Path.GetExtension(ss.FilePath).ToLowerInvariant(); + var date = GetBestDate(ss.FilePath); var destName = BuildDestName(match.NameOriginal, date, Path.GetFileNameWithoutExtension(ss.FilePath), ext); var destPath = Path.Combine(match.Path, destName); @@ -168,7 +169,7 @@ private void OrganizeSteam() counts[match.NameOriginal] = current + 1; _logger.Write(LogType.Move, - $"Steam: {Path.GetFileName(ss.FilePath)} → {match.NameOriginal}"); + $"Steam: {Path.GetFileName(ss.FilePath)} → {match.NameOriginal} (Score: {matchResult.Score}%)"); } catch (Exception ex) { @@ -179,8 +180,10 @@ private void OrganizeSteam() if (counts.Count > 0) { + var total = counts.Values.Sum(); var summary = string.Join(" | ", counts.Select(kv => $"{kv.Key} ({kv.Value})")); - OnFileMoved?.Invoke("GameSnap", $"Steam: {counts.Values.Sum()} screenshot(s): {summary}"); + var scoreSummary = string.Join(" | ", matchScores.Select(kv => $"{kv.Key} ({kv.Value}%)")); + OnFileMoved?.Invoke("GameSnap", $"Steam: {total} screenshot(s): {summary} [Score: {scoreSummary}]"); } } @@ -195,36 +198,29 @@ private void OrganizeEmulators(Dictionary dict) if (pending.Count == 0) return; var folders = LoadFolders(); - var counts = new Dictionary(StringComparer.OrdinalIgnoreCase); + var counts = new Dictionary(StringComparer.OrdinalIgnoreCase); + var matchScores = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var ss in pending) { if (_processed.Contains(ss.FilePath)) continue; - // Consulta o dicionário primeiro — resolve nomes internos de ROM - // (ex: "mslug", "garou") que não batem por aproximação com o - // título de exibição do Playnite. Mesmo dicionário usado pelo - // fluxo do ShareX (WindowFallback/Playnite), sem aprendizado - // automático aqui (o resolvedor de emulador já é mais específico). var resolvedName = ss.GameName; var normCandidate = DictionaryService.Normalize(ss.GameName); if (dict.TryGetValue(normCandidate, out var fromDict)) resolvedName = fromDict; - var normGame = DictionaryService.Normalize(resolvedName); - var match = folders - .Where(f => f.NameNorm.Contains(normGame) || normGame.Contains(f.NameNorm)) - .OrderByDescending(f => f.NameNorm.Length) - .FirstOrDefault(); + var matchResult = FindBestMatchByScore(resolvedName, folders, _settings.MatchThreshold); + var match = matchResult.Folder; if (match == null) { // Auto-create if enabled if (_settings.AutoCreateFolders) { - var invalid = Path.GetInvalidFileNameChars(); + var invalid = Path.GetInvalidFileNameChars(); var folderName = string.Concat(resolvedName.Split(invalid)).Trim(); - var newPath = Path.Combine(_settings.DestinationBase, folderName); + var newPath = Path.Combine(_settings.DestinationBase, folderName); Directory.CreateDirectory(newPath); folders = LoadFolders(); // refresh match = folders.FirstOrDefault(f => @@ -240,8 +236,14 @@ private void OrganizeEmulators(Dictionary dict) } } - var ext = Path.GetExtension(ss.FilePath).ToLowerInvariant(); - var date = GetBestDate(ss.FilePath); + // Record match score + if (!matchScores.ContainsKey(match.NameOriginal) || matchResult.Score > matchScores[match.NameOriginal]) + { + matchScores[match.NameOriginal] = matchResult.Score; + } + + var ext = Path.GetExtension(ss.FilePath).ToLowerInvariant(); + var date = GetBestDate(ss.FilePath); var destName = BuildDestName(match.NameOriginal, date, Path.GetFileNameWithoutExtension(ss.FilePath), ext); var destPath = Path.Combine(match.Path, destName); @@ -267,7 +269,7 @@ private void OrganizeEmulators(Dictionary dict) counts[match.NameOriginal] = current + 1; _logger.Write(LogType.Move, - $"Emulator [{ss.Emulator}]: {Path.GetFileName(ss.FilePath)} → {match.NameOriginal}"); + $"Emulator [{ss.Emulator}]: {Path.GetFileName(ss.FilePath)} → {match.NameOriginal} (Score: {matchResult.Score}%)"); } catch (Exception ex) { @@ -277,8 +279,10 @@ private void OrganizeEmulators(Dictionary dict) if (counts.Count > 0) { + var total = counts.Values.Sum(); var summary = string.Join(" | ", counts.Select(kv => $"{kv.Key} ({kv.Value})")); - OnFileMoved?.Invoke("GameSnap", $"Emulators: {counts.Values.Sum()} screenshot(s): {summary}"); + var scoreSummary = string.Join(" | ", matchScores.Select(kv => $"{kv.Key} ({kv.Value}%)")); + OnFileMoved?.Invoke("GameSnap", $"Emulators: {total} screenshot(s): {summary} [Score: {scoreSummary}]"); } } @@ -289,7 +293,8 @@ private void TryOrganizeFile( string filePath, Dictionary dict, List folders, - Dictionary counts) + Dictionary counts, + Dictionary matchScores) { if (_processed.Contains(filePath)) return; @@ -303,16 +308,13 @@ private void TryOrganizeFile( System.Threading.Thread.Sleep(800); var fileName = Path.GetFileName(filePath); - var prefix = GetPrefix(fileName); - var normPfx = DictionaryService.Normalize(prefix); + var prefix = GetPrefix(fileName); + var normPfx = DictionaryService.Normalize(prefix); - string? game = null; - string method = "UNKNOWN"; + string? game = null; + string method = "UNKNOWN"; - // 0. Bypass de emulador — prefixos como "retroarch" identificam o CORE, não a ROM. - // Um único prefixo serve pra vários jogos diferentes, então dicionário e janela - // ativa não podem ser usados (nem aprendidos) para esses prefixos: a única fonte - // confiável é o jogo que o Playnite diz estar rodando agora. + // 0. Bypass de emulador bool isEmulatorPrefix = _settings.EmulatorPrefixes .Any(p => normPfx.Equals(DictionaryService.Normalize(p), StringComparison.OrdinalIgnoreCase)); @@ -320,7 +322,7 @@ private void TryOrganizeFile( { if (!string.IsNullOrEmpty(_currentGame)) { - game = _currentGame; + game = _currentGame; method = "EMULATOR-PLAYNITE"; } else @@ -335,16 +337,15 @@ private void TryOrganizeFile( // 1. Dicionário if (game == null && dict.TryGetValue(normPfx, out var fromDict)) { - game = fromDict; + game = fromDict; method = "DICTIONARY"; } // 2. Playnite if (game == null && _settings.UsePlayniteDetection && !string.IsNullOrEmpty(_currentGame)) { - game = _currentGame; + game = _currentGame; method = "PLAYNITE"; - // Auto-learn: save prefix → game mapping so future files skip detection if (!string.IsNullOrEmpty(prefix) && prefix.Length > 2) { _dictionary.SaveAlias(prefix, _currentGame); @@ -352,13 +353,9 @@ private void TryOrganizeFile( } } - // 3. Janela ativa — nunca roda para prefixos de emulador (isEmulatorPrefix já - // retornou acima quando não há jogo ativo, então chegar aqui com game == null - // e isEmulatorPrefix == true não deveria acontecer, mas o guard abaixo garante isso. - // Opção D: só ativa durante sessão de jogo (entre OnGameStarted e OnGameStopped) - // Opção C: só ativa se o prefixo já existe no dicionário (jogo conhecido fora do Playnite) - bool inGameSession = _currentGame != null; - bool prefixKnown = dict.ContainsKey(normPfx); + // 3. Janela ativa + bool inGameSession = _currentGame != null; + bool prefixKnown = dict.ContainsKey(normPfx); bool canUseFallback = _settings.UseWindowFallback && (inGameSession || prefixKnown); if (game == null && canUseFallback) @@ -368,30 +365,27 @@ private void TryOrganizeFile( { var normWin = DictionaryService.Normalize(win); - // Blacklist expandida — rejeita janelas que claramente não são jogos bool blocked = _settings.WindowBlacklist.Any(b => normWin.IndexOf(b, StringComparison.OrdinalIgnoreCase) >= 0); - // Rejeita títulos com padrões típicos de sistema/browser bool looksLikeSystem = normWin.Contains("explorador de arquivos") || normWin.Contains("file explorer") || - normWin.Contains("mais guias") || // "e 3 mais guias" + normWin.Contains("mais guias") || normWin.Contains("more tabs") || normWin.Contains("google drive") || normWin.Contains("onedrive") || normWin.Contains("hotmail") || - normWin.Contains("playnite") || // "+Playnite", "Playnite", etc. + normWin.Contains("playnite") || normWin.Contains("gmail") || normWin.Contains("outlook") || normWin.Contains(" - explorador") || normWin.Contains(" - explorer") || - normWin.Contains("playnite") || // evita pasta do Playnite normWin.Length < 3; if (!blocked && !looksLikeSystem) { - game = win; + game = win; method = "WINDOW"; _logger.Write(LogType.Fallback, $"Prefix: {prefix}\nDetected: {win}"); } @@ -411,30 +405,34 @@ private void TryOrganizeFile( return; } - // Encontra pasta de destino - var normGame = DictionaryService.Normalize(game); - var match = folders - .Where(f => f.NameNorm.Contains(normGame) || normGame.Contains(f.NameNorm)) - .OrderByDescending(f => f.NameNorm.Length) - .FirstOrDefault(); + // Encontra pasta de destino usando similaridade + var matchResult = FindBestMatchByScore(game, folders, _settings.MatchThreshold); + var match = matchResult.Folder; + var matchScore = matchResult.Score; + var matchType = matchResult.MatchType; if (match == null) { - _logger.Write(LogType.Error, $"File: {fileName}\nGame: {game}\nNo folder found"); + _logger.Write(LogType.Error, $"File: {fileName}\nGame: {game}\nNo folder found (score: {matchScore})"); TryMoveToUnmatched(filePath, ext); return; } + // Record match score + if (!matchScores.ContainsKey(match.NameOriginal) || matchResult.Score > matchScores[match.NameOriginal]) + { + matchScores[match.NameOriginal] = matchResult.Score; + } + // Destino final var destDir = isVideo ? EnsureDir(Path.Combine(match.Path, "Videos")) : match.Path; - var date = GetBestDate(filePath); + var date = GetBestDate(filePath); var destName = BuildDestName(match.NameOriginal, date, Path.GetFileNameWithoutExtension(fileName), ext); var destPath = Path.Combine(destDir, destName); - // Evita colisão int i = 1; while (File.Exists(destPath)) { @@ -447,7 +445,6 @@ private void TryOrganizeFile( { File.Move(filePath, destPath); - // Backup opcional if (_settings.EnableBackup && !string.IsNullOrEmpty(_settings.BackupFolder)) TryBackup(destPath, match.NameOriginal, isVideo); @@ -456,7 +453,7 @@ private void TryOrganizeFile( int current = counts.ContainsKey(match.NameOriginal) ? counts[match.NameOriginal] : 0; counts[match.NameOriginal] = current + 1; - _logger.Write(LogType.Move, $"File: {fileName}\nGame: {game}\nMethod: {method}"); + _logger.Write(LogType.Move, $"File: {fileName}\nGame: {game}\nMethod: {method}\nScore: {matchScore}% ({matchType})"); } catch (Exception ex) { @@ -504,8 +501,8 @@ private void TryBackup(string sourcePath, string gameName, bool isVideo) try { var backupGame = EnsureDir(Path.Combine(_settings.BackupFolder, gameName)); - var backupDir = isVideo ? EnsureDir(Path.Combine(backupGame, "Videos")) : backupGame; - var destPath = Path.Combine(backupDir, Path.GetFileName(sourcePath)); + var backupDir = isVideo ? EnsureDir(Path.Combine(backupGame, "Videos")) : backupGame; + var destPath = Path.Combine(backupDir, Path.GetFileName(sourcePath)); if (!File.Exists(destPath)) File.Copy(sourcePath, destPath); @@ -526,9 +523,9 @@ private string BuildDestName(string gameName, DateTime date, string originalName : _settings.RenamePattern; var result = pattern - .Replace("{game}", SanitizeFileName(gameName)) - .Replace("{date}", date.ToString("yyyy-MM-dd")) - .Replace("{time}", date.ToString("HH_mm_ss")) + .Replace("{game}", SanitizeFileName(gameName)) + .Replace("{date}", date.ToString("yyyy-MM-dd")) + .Replace("{time}", date.ToString("HH_mm_ss")) .Replace("{datetime}", date.ToString("yyyy-MM-dd_HH_mm_ss")) .Replace("{original}", SanitizeFileName(originalName)); @@ -551,8 +548,8 @@ private List LoadFolders() .Select(d => new FolderEntry { NameOriginal = Path.GetFileName(d), - NameNorm = DictionaryService.Normalize(Path.GetFileName(d)), - Path = d + NameNorm = DictionaryService.Normalize(Path.GetFileName(d)), + Path = d }) .ToList(); } @@ -608,11 +605,140 @@ private static string GetActiveWindowTitle() return sb.ToString(); } + // ────────────────────────────────────────────── + // Inner classes + // ────────────────────────────────────────────── + private class FolderEntry { public string NameOriginal { get; set; } = ""; - public string NameNorm { get; set; } = ""; - public string Path { get; set; } = ""; + public string NameNorm { get; set; } = ""; + public string Path { get; set; } = ""; + } + + private class MatchResult + { + public FolderEntry? Folder { get; set; } + public int Score { get; set; } + public string MatchType { get; set; } = "none"; + public bool IsMatch => Folder != null && Score > 0; + } + + // ────────────────────────────────────────────── + // Similarity score matching + // ────────────────────────────────────────────── + + private MatchResult FindBestMatchByScore(string gameName, List folders, int minScore = 50) + { + if (string.IsNullOrEmpty(gameName) || folders.Count == 0) + return new MatchResult { Score = 0, MatchType = "none" }; + + var normGame = DictionaryService.Normalize(gameName); + var candidates = new List<(FolderEntry Folder, int Score, string MatchType)>(); + + foreach (var folder in folders) + { + int score = 0; + string matchType = "none"; + var normFolder = folder.NameNorm; + + // ─── 1. Exact match (100 points) ─── + if (string.Equals(normFolder, normGame, StringComparison.OrdinalIgnoreCase)) + { + score = 100; + matchType = "exact"; + candidates.Add((folder, score, matchType)); + continue; + } + + // ─── 2. Containment match ─── + if (normFolder.Contains(normGame)) + { + var ratio = (double)normGame.Length / normFolder.Length; + score = 60 + (int)(ratio * 20); + matchType = "folder_contains_game"; + } + else if (normGame.Contains(normFolder)) + { + var ratio = (double)normFolder.Length / normGame.Length; + score = 50 + (int)(ratio * 30); + matchType = "game_contains_folder"; + } + + // ─── 3. Word match ─── + var gameWords = normGame.Split(new[] { ' ', '-', '_', '.' }, StringSplitOptions.RemoveEmptyEntries); + var folderWords = normFolder.Split(new[] { ' ', '-', '_', '.' }, StringSplitOptions.RemoveEmptyEntries); + + if (gameWords.Length > 0 && folderWords.Length > 0) + { + var commonWords = gameWords.Intersect(folderWords, StringComparer.OrdinalIgnoreCase).ToList(); + var commonCount = commonWords.Count; + + if (commonCount > 0) + { + var maxWords = Math.Max(gameWords.Length, folderWords.Length); + var wordScore = (int)((double)commonCount / maxWords * 40); + + if (score > 0) + { + score = Math.Min(score + wordScore / 2, 95); + } + else + { + score = 30 + wordScore; + matchType = "word_match"; + } + } + } + + // ─── 4. Prefix match ─── + if (normGame.StartsWith(normFolder, StringComparison.OrdinalIgnoreCase) || + normFolder.StartsWith(normGame, StringComparison.OrdinalIgnoreCase)) + { + if (score < 70) score = 70; + matchType = "prefix_match"; + } + + if (score > 0) + { + candidates.Add((folder, score, matchType)); + } + } + + // ─── 5. Sort by score descending ─── + var best = candidates + .OrderByDescending(c => c.Score) + .FirstOrDefault(); + + if (best.Folder == null || best.Score < minScore) + { + _logger.Write(LogType.Info, + best.Folder == null + ? $"No match found for '{gameName}'" + : $"Match score {best.Score} below threshold {minScore} for '{gameName}' → no match"); + return new MatchResult { Score = best.Score, MatchType = best.MatchType }; + } + + _logger.Write(LogType.Info, + $"Best match for '{gameName}': '{best.Folder.NameOriginal}' (score: {best.Score}, type: {best.MatchType})"); + + // Check for candidates with close scores (difference <= 5) + var closeCandidates = candidates + .Where(c => c.Score >= best.Score - 5 && c.Folder != best.Folder) + .ToList(); + if (closeCandidates.Any()) + { + var others = string.Join(", ", closeCandidates.Select(c => $"{c.Folder.NameOriginal}({c.Score})")); + _logger.Write(LogType.Info, + $"⚠️ Multiple close matches for '{gameName}': {others}. Selected: {best.Folder.NameOriginal}"); + } + + return new MatchResult + { + Folder = best.Folder, + Score = best.Score, + MatchType = best.MatchType + }; } } -} +} \ No newline at end of file diff --git a/Settings.cs b/Settings.cs index 288b10e..cb1b764 100644 --- a/Settings.cs +++ b/Settings.cs @@ -86,6 +86,11 @@ public class GameSnapSettings : ObservableObject // do arquivo é sempre o mesmo independente da ROM rodando. private List _emulatorPrefixes = new List(); public List EmulatorPrefixes { get => _emulatorPrefixes; set => SetValue(ref _emulatorPrefixes, value); } + + // ─── New: Match similarity threshold ─── + // Files below this score will not be matched (0-100), default 50 + private int _matchThreshold = 50; + public int MatchThreshold { get => _matchThreshold; set => SetValue(ref _matchThreshold, value); } } public class GameSnapSettingsViewModel : ObservableObject, ISettings diff --git a/Views/EmulatorsView.xaml b/Views/EmulatorsView.xaml index 27d6468..b33a67b 100644 --- a/Views/EmulatorsView.xaml +++ b/Views/EmulatorsView.xaml @@ -17,11 +17,11 @@ - - + @@ -34,7 +34,7 @@ + Text="{DynamicResource LOCGameSnap_Emulators_AutoCreateWarning}"/> @@ -45,7 +45,7 @@ + ToolTip="{DynamicResource LOCGameSnap_Emulators_ItemTooltip}">