From 2476d04b34e42119e227282e1c995a2bcd83de7b Mon Sep 17 00:00:00 2001 From: pfuenzle Date: Sat, 18 Jul 2026 23:37:09 +0000 Subject: [PATCH 01/42] Preserve platform when adding discovered games --- .../src/AddGame/AddNewGame/AddNewGameModalContent.test.tsx | 1 + .../DiscoverGame/AddNewDiscoverGameModalContentConnector.tsx | 5 +++++ frontend/src/Store/Actions/discoverGameActions.ts | 2 ++ 3 files changed, 8 insertions(+) diff --git a/frontend/src/AddGame/AddNewGame/AddNewGameModalContent.test.tsx b/frontend/src/AddGame/AddNewGame/AddNewGameModalContent.test.tsx index 7c08f7b3..c7776ed0 100644 --- a/frontend/src/AddGame/AddNewGame/AddNewGameModalContent.test.tsx +++ b/frontend/src/AddGame/AddNewGame/AddNewGameModalContent.test.tsx @@ -230,6 +230,7 @@ describe('AddNewGameModalContent', () => { expect( screen.getByTestId('form-input-qualityProfileId') ).toBeInTheDocument(); + expect(screen.getByTestId('form-input-platform')).toBeInTheDocument(); expect(screen.getByTestId('form-input-tags')).toBeInTheDocument(); }); diff --git a/frontend/src/DiscoverGame/AddNewDiscoverGameModalContentConnector.tsx b/frontend/src/DiscoverGame/AddNewDiscoverGameModalContentConnector.tsx index a83c71f0..7b964ffc 100644 --- a/frontend/src/DiscoverGame/AddNewDiscoverGameModalContentConnector.tsx +++ b/frontend/src/DiscoverGame/AddNewDiscoverGameModalContentConnector.tsx @@ -17,6 +17,7 @@ interface DiscoverGameDefaults { qualityProfileId: number; minimumAvailability: string; searchForGame: boolean; + platform: string; tags: number[]; } @@ -87,6 +88,7 @@ function AddNewDiscoverGameModalContentConnector({ qualityProfileId, minimumAvailability, searchForGame, + platform, tags, } = settings; @@ -107,6 +109,7 @@ function AddNewDiscoverGameModalContentConnector({ qualityProfileId: qualityProfileId.value, minimumAvailability: minimumAvailability.value, searchForGame: searchForGame.value, + platform: platform.value, tags: tags.value, }) ); @@ -121,6 +124,7 @@ function AddNewDiscoverGameModalContentConnector({ qualityProfileId, minimumAvailability, searchForGame, + platform, tags, onModalClose, ]); @@ -140,6 +144,7 @@ function AddNewDiscoverGameModalContentConnector({ qualityProfileId={qualityProfileId} minimumAvailability={minimumAvailability} searchForGame={searchForGame} + platform={platform} tags={tags} folder={folder} onModalClose={onModalClose} diff --git a/frontend/src/Store/Actions/discoverGameActions.ts b/frontend/src/Store/Actions/discoverGameActions.ts index d630be8d..d789fb8d 100644 --- a/frontend/src/Store/Actions/discoverGameActions.ts +++ b/frontend/src/Store/Actions/discoverGameActions.ts @@ -41,6 +41,7 @@ interface DiscoverGameDefaults { qualityProfileId: number; minimumAvailability: string; searchForGame: boolean; + platform: string; tags: number[]; } @@ -178,6 +179,7 @@ export const defaultState: DiscoverGameState = { qualityProfileId: 0, minimumAvailability: 'released', searchForGame: true, + platform: 'unknown', tags: [], }, From 0f74bf3a4244bff6b4026f3f32a876ec515b90a5 Mon Sep 17 00:00:00 2001 From: pfuenzle Date: Sat, 18 Jul 2026 23:40:52 +0000 Subject: [PATCH 02/42] Add No-Intro ROM verification support --- docs/rom-verification-adr.md | 103 +++++++ .../MediaManagement/Naming/Naming.tsx | 25 ++ .../RomCatalog/NoIntroRomCatalog.test.ts | 46 +++ .../typings/RomCatalog/NoIntroRomCatalog.ts | 54 ++++ frontend/src/typings/Settings/NamingConfig.ts | 3 + .../Config/NamingConfigResource.cs | 1 + .../Config/NamingExampleResource.cs | 2 + .../RomCatalog/NoIntroCatalogController.cs | 59 ++++ .../RomCatalog/NoIntroCatalogResource.cs | 188 +++++++++++++ .../NoIntroCatalogControllerFixture.cs | 100 +++++++ .../RenameProfileNamingBehaviorFixture.cs | 62 ++++ .../NoIntroCatalogPersistenceFixture.cs | 57 ++++ .../NoIntroCatalogSyncServiceFixture.cs | 98 +++++++ .../NoIntroComponentClassifierFixture.cs | 150 ++++++++++ .../RomCatalog/NoIntroEndToEndFixture.cs | 144 ++++++++++ ...NoIntroVerificationRenameProfileFixture.cs | 159 +++++++++++ .../NoIntroVerificationServiceFixture.cs | 163 +++++++++++ .../Migration/007_add_nointro_catalog.cs | 70 +++++ ...008_add_rename_profile_to_naming_config.cs | 19 ++ ...canonical_name_to_nointro_catalog_entry.cs | 17 ++ src/NzbDrone.Core/Datastore/TableMapping.cs | 8 + src/NzbDrone.Core/Localization/Core/en.json | 5 + src/NzbDrone.Core/Organizer/NamingConfig.cs | 9 + .../NoIntroCatalogDocumentClient.cs | 24 ++ .../RomCatalog/NoIntroCatalogEntry.cs | 15 + .../NoIntroCatalogEntryRepository.cs | 30 ++ .../RomCatalog/NoIntroCatalogHash.cs | 13 + .../NoIntroCatalogHashRepository.cs | 35 +++ .../RomCatalog/NoIntroCatalogPlan.cs | 31 ++ .../RomCatalog/NoIntroCatalogSnapshot.cs | 26 ++ .../NoIntroCatalogSnapshotParser.cs | 84 ++++++ .../RomCatalog/NoIntroCatalogSource.cs | 16 ++ .../NoIntroCatalogSourceRepository.cs | 17 ++ .../RomCatalog/NoIntroCatalogSyncCommand.cs | 21 ++ .../RomCatalog/NoIntroCatalogSyncService.cs | 121 ++++++++ .../NoIntroComponentClassification.cs | 8 + .../RomCatalog/NoIntroComponentClassifier.cs | 244 ++++++++++++++++ .../NoIntroRenameProfileEvaluator.cs | 40 +++ .../RomCatalog/NoIntroRomComponentType.cs | 12 + .../RomCatalog/NoIntroRomHasher.cs | 54 ++++ .../RomCatalog/NoIntroSystemMapping.cs | 13 + .../RomCatalog/NoIntroVerificationResult.cs | 23 ++ .../NoIntroVerificationResultRepository.cs | 17 ++ .../RomCatalog/NoIntroVerificationService.cs | 266 ++++++++++++++++++ .../RomCatalog/NoIntroVerificationSet.cs | 12 + .../NoIntroVerificationSetRepository.cs | 17 ++ .../RomCatalog/NoIntroVerificationSnapshot.cs | 14 + .../NoIntroVerificationSnapshotRepository.cs | 17 ++ .../RomCatalog/NoIntroVerificationStatus.cs | 10 + 49 files changed, 2722 insertions(+) create mode 100644 docs/rom-verification-adr.md create mode 100644 frontend/src/typings/RomCatalog/NoIntroRomCatalog.test.ts create mode 100644 frontend/src/typings/RomCatalog/NoIntroRomCatalog.ts create mode 100644 src/Gamarr.Api.V3/RomCatalog/NoIntroCatalogController.cs create mode 100644 src/Gamarr.Api.V3/RomCatalog/NoIntroCatalogResource.cs create mode 100644 src/NzbDrone.Api.Test/RomCatalog/NoIntroCatalogControllerFixture.cs create mode 100644 src/NzbDrone.Core.Test/Organizer/RenameProfileNamingBehaviorFixture.cs create mode 100644 src/NzbDrone.Core.Test/RomCatalog/NoIntroCatalogPersistenceFixture.cs create mode 100644 src/NzbDrone.Core.Test/RomCatalog/NoIntroCatalogSyncServiceFixture.cs create mode 100644 src/NzbDrone.Core.Test/RomCatalog/NoIntroComponentClassifierFixture.cs create mode 100644 src/NzbDrone.Core.Test/RomCatalog/NoIntroEndToEndFixture.cs create mode 100644 src/NzbDrone.Core.Test/RomCatalog/NoIntroVerificationRenameProfileFixture.cs create mode 100644 src/NzbDrone.Core.Test/RomCatalog/NoIntroVerificationServiceFixture.cs create mode 100644 src/NzbDrone.Core/Datastore/Migration/007_add_nointro_catalog.cs create mode 100644 src/NzbDrone.Core/Datastore/Migration/008_add_rename_profile_to_naming_config.cs create mode 100644 src/NzbDrone.Core/Datastore/Migration/009_add_parent_canonical_name_to_nointro_catalog_entry.cs create mode 100644 src/NzbDrone.Core/RomCatalog/NoIntroCatalogDocumentClient.cs create mode 100644 src/NzbDrone.Core/RomCatalog/NoIntroCatalogEntry.cs create mode 100644 src/NzbDrone.Core/RomCatalog/NoIntroCatalogEntryRepository.cs create mode 100644 src/NzbDrone.Core/RomCatalog/NoIntroCatalogHash.cs create mode 100644 src/NzbDrone.Core/RomCatalog/NoIntroCatalogHashRepository.cs create mode 100644 src/NzbDrone.Core/RomCatalog/NoIntroCatalogPlan.cs create mode 100644 src/NzbDrone.Core/RomCatalog/NoIntroCatalogSnapshot.cs create mode 100644 src/NzbDrone.Core/RomCatalog/NoIntroCatalogSnapshotParser.cs create mode 100644 src/NzbDrone.Core/RomCatalog/NoIntroCatalogSource.cs create mode 100644 src/NzbDrone.Core/RomCatalog/NoIntroCatalogSourceRepository.cs create mode 100644 src/NzbDrone.Core/RomCatalog/NoIntroCatalogSyncCommand.cs create mode 100644 src/NzbDrone.Core/RomCatalog/NoIntroCatalogSyncService.cs create mode 100644 src/NzbDrone.Core/RomCatalog/NoIntroComponentClassification.cs create mode 100644 src/NzbDrone.Core/RomCatalog/NoIntroComponentClassifier.cs create mode 100644 src/NzbDrone.Core/RomCatalog/NoIntroRenameProfileEvaluator.cs create mode 100644 src/NzbDrone.Core/RomCatalog/NoIntroRomComponentType.cs create mode 100644 src/NzbDrone.Core/RomCatalog/NoIntroRomHasher.cs create mode 100644 src/NzbDrone.Core/RomCatalog/NoIntroSystemMapping.cs create mode 100644 src/NzbDrone.Core/RomCatalog/NoIntroVerificationResult.cs create mode 100644 src/NzbDrone.Core/RomCatalog/NoIntroVerificationResultRepository.cs create mode 100644 src/NzbDrone.Core/RomCatalog/NoIntroVerificationService.cs create mode 100644 src/NzbDrone.Core/RomCatalog/NoIntroVerificationSet.cs create mode 100644 src/NzbDrone.Core/RomCatalog/NoIntroVerificationSetRepository.cs create mode 100644 src/NzbDrone.Core/RomCatalog/NoIntroVerificationSnapshot.cs create mode 100644 src/NzbDrone.Core/RomCatalog/NoIntroVerificationSnapshotRepository.cs create mode 100644 src/NzbDrone.Core/RomCatalog/NoIntroVerificationStatus.cs diff --git a/docs/rom-verification-adr.md b/docs/rom-verification-adr.md new file mode 100644 index 00000000..4ac2fd80 --- /dev/null +++ b/docs/rom-verification-adr.md @@ -0,0 +1,103 @@ +# ADR, No-Intro ROM verification architecture contract + +## Status + +Approved for Todo 1 planning and implementation follow-up. + +## Context + +Gamarr needs a generic No-Intro verification design for ROM libraries that works across No-Intro-backed systems without bending current game file behavior into ROM-specific rules. The architecture scope is generic No-Intro systems, with Game Boy Advance and Nintendo DS used as the first validation examples because they already show the ZIP-heavy and mixed ZIP plus raw shapes that v1 must handle. Switch is deferred because its TitleID-driven metadata model is a separate problem. + +## Decision summary + +Gamarr will add a separate ROM inventory and verification aggregate for No-Intro-backed systems. It will not implicitly reuse `GameFileId`, `HasFile`, or existing wanted semantics. This ADR acts as the migration guard for those legacy concepts. ZIP verification is read-only and does not imply extraction or import overhaul. Catalog truth comes from automatic pinned upstream DAT or catalog sync with visible source, version, and last-sync metadata, plus a manual refresh action. + +## Data model boundary + +The No-Intro feature owns a separate ROM inventory and verification aggregate. + +That aggregate is responsible for: + +- catalog source identity, pinned upstream release metadata, sync timestamps, and sync failure state +- canonical catalog entries and their hash truth +- managed library membership for verification roots and system scope +- per-library-file verification results for raw files and ZIP-contained ROM payloads +- orthogonal duplicate and missing-set reporting +- expected filename resolution for the selected rename profile + +The existing game and game-file model remains separate. + +- Do not implicitly reuse `GameFileId` as the identity of a verified ROM record. +- Do not implicitly reuse `HasFile` as proof that a ROM is verified or present in a managed verification set. +- Do not reuse existing wanted semantics. `Missing` for ROM verification is a separate verification-set concept and not a replacement for existing wanted or monitored flows. + +If later implementation needs any compatibility bridge, it must be an explicit migration path, not hidden semantic reuse. + +## Verification status table + +| Status | Scope | Meaning | Notes | +| --- | --- | --- | --- | +| Verified | per file | The ROM payload hash matches a loaded No-Intro catalog entry and the filename matches the selected rename profile | Name checks are profile-relative | +| Name mismatch | per file | The ROM payload hash matches a loaded No-Intro catalog entry, but the filename or path shape differs from the selected rename profile | `Name mismatch` is relative to the selected rename profile | +| Unknown | per file | The ROM payload hash does not match any loaded No-Intro catalog entry | Not a naming issue | +| Bad dump | per file | The ROM payload hash matches a known bad, overdump, headered, or otherwise non-good catalog entry, or a deterministic bad-dump rule | Takes precedence over Verified in summary views | +| Duplicate | orthogonal flag | More than one managed library file resolves to the same canonical No-Intro entry | Does not replace the per-file verification state | +| Missing | verification set | A catalog entry expected by the managed verification set is absent from the managed library scope | `Missing` is scoped to the managed verification set | + +Summary precedence for the primary per-file state is: Bad dump, Unknown, Name mismatch, Verified. Duplicate and Missing remain orthogonal signals. + +## ZIP/raw matching rules + +The authoritative verification unit is the normalized catalog ROM entry derived from raw ROM bytes. + +- Loose files are hashed from the raw ROM payload. +- ZIP verification is read-only and does not imply extraction, import, or archive-management overhaul. +- ZIP-contained matching hashes the selected ROM payload inside the archive against the same catalog truth used for loose files. +- Raw and ZIP matches converge on the same canonical ROM entry when the payload bytes are the same. +- V1 must classify ambiguous or unsupported multi-member archives deterministically instead of guessing. +- A successful ZIP match does not grant any separate import semantics, extracted-file lifecycle, or ownership change. + +## V1 enablement matrix + +| Area | V1 decision | +| --- | --- | +| Architecture scope | Generic No-Intro systems architecture scope | +| Initial validation examples | GBA and Nintendo DS | +| Catalog source | Automatic pinned upstream DAT or catalog sync | +| Catalog visibility | Show source, version, last-sync, last-attempt, and refresh failure state | +| Refresh behavior | Keep prior good catalog data visible on failed refresh, allow manual refresh | +| Verification inputs | Raw ROM files and ZIP-contained ROM payloads | +| Naming behavior | Keep current Gamarr naming as default, add No-Intro-aware profile evaluation | +| Missing scope | Managed verification set only | +| ZIP behavior | Read-only verification only | +| Switch | Deferred | + +## Automatic upstream catalog policy + +The approved owner decision is automatic pinned upstream DAT or catalog sync, not an unresolved default. + +- The system stores the upstream source identity, pinned version or revision, last successful sync, last attempted sync, and failure state. +- The UI and API must expose visible source, version, and last-sync details. +- Manual refresh is available. +- Failed refresh attempts must not silently replace or clear the prior good catalog snapshot. + +## Validation examples + +Game Boy Advance and Nintendo DS are the first validation examples for v1 because they prove the generic design against real library shapes. + +- GBA validates ZIP-heavy libraries, numbered by-id naming, and non-retail subsets. +- Nintendo DS validates mixed raw `.nds` and ZIP libraries plus additional subset folders. +- These examples validate the architecture. They do not narrow the architecture to only those two systems. + +## Explicit non-goals + +- No implicit reuse of `GameFileId`, `HasFile`, or existing wanted semantics. +- No archive extraction workflow change. +- No ROM import overhaul. +- No forced bulk rename of existing ROM libraries. +- No assumption that current folder names are authoritative catalog truth. +- No Switch implementation in v1. Switch is deferred. + +## Consequences + +This boundary keeps today’s game and file behavior stable while making room for ROM-specific verification truth, duplicate detection, missing-set reporting, and profile-relative naming checks. It also keeps ZIP support narrow and safe, because verification stays read-only in v1. diff --git a/frontend/src/Settings/MediaManagement/Naming/Naming.tsx b/frontend/src/Settings/MediaManagement/Naming/Naming.tsx index c76ba4e3..3a4bbfec 100644 --- a/frontend/src/Settings/MediaManagement/Naming/Naming.tsx +++ b/frontend/src/Settings/MediaManagement/Naming/Naming.tsx @@ -126,6 +126,18 @@ function Naming() { }, ]; + const renameProfileOptions = [ + { key: 'gamarr', value: translate('RenameProfileGamarr') }, + { + key: 'noIntroPreserveById', + value: translate('RenameProfileNoIntroPreserveById'), + }, + { + key: 'noIntroCanonical', + value: translate('RenameProfileNoIntroCanonical'), + }, + ]; + const standardGameFormatHelpTexts = []; const standardGameFormatErrors = []; const gameFolderFormatHelpTexts = []; @@ -175,6 +187,19 @@ function Naming() { /> + + {translate('RenameProfile')} + + + + {translate('ReplaceIllegalCharacters')} diff --git a/frontend/src/typings/RomCatalog/NoIntroRomCatalog.test.ts b/frontend/src/typings/RomCatalog/NoIntroRomCatalog.test.ts new file mode 100644 index 00000000..81faa65c --- /dev/null +++ b/frontend/src/typings/RomCatalog/NoIntroRomCatalog.test.ts @@ -0,0 +1,46 @@ +import { + type NoIntroCatalogPlan, + noIntroRomComponentTypes, + noIntroVerificationStatuses, +} from './NoIntroRomCatalog'; + +describe('NoIntroRomCatalog', () => { + it('keeps Download Play as a mapped component and standalone products separate', () => { + const plan: NoIntroCatalogPlan = { + games: [ + { + systemKey: 'nintendo-ds', + gameTitle: 'Mario Kart DS', + regionLanguageComponents: [ + { + slotLabel: 'USA', + canonicalName: 'Mario Kart DS (USA)', + componentType: 'retailRom', + }, + ], + downloadPlayComponents: [ + { + slotLabel: 'Download Play', + canonicalName: 'Mario Kart DS (Download Play)', + componentType: 'multiboot', + }, + ], + }, + ], + standaloneGames: [ + { + title: + 'Game Boy Advance Video - Cartoon Network Collection - Volume 1 (USA)', + componentType: 'video', + }, + ], + }; + + expect(noIntroVerificationStatuses).toContain('nameMismatch'); + expect(noIntroRomComponentTypes).toContain('multiboot'); + expect(plan.games[0].downloadPlayComponents[0].slotLabel).toBe( + 'Download Play' + ); + expect(plan.standaloneGames[0].componentType).toBe('video'); + }); +}); diff --git a/frontend/src/typings/RomCatalog/NoIntroRomCatalog.ts b/frontend/src/typings/RomCatalog/NoIntroRomCatalog.ts new file mode 100644 index 00000000..d5c35b63 --- /dev/null +++ b/frontend/src/typings/RomCatalog/NoIntroRomCatalog.ts @@ -0,0 +1,54 @@ +export const noIntroVerificationStatuses = [ + 'verified', + 'nameMismatch', + 'unknown', + 'badDump', + 'missing', + 'duplicate', +] as const; + +export type NoIntroVerificationStatus = + (typeof noIntroVerificationStatuses)[number]; + +export const noIntroRomComponentTypes = [ + 'retailRom', + 'eReaderCards', + 'multiboot', + 'video', + 'bios', + 'romhackOrUnverified', +] as const; + +export type NoIntroRomComponentType = (typeof noIntroRomComponentTypes)[number]; + +export interface NoIntroCatalogComponentSlot { + readonly slotLabel: string; + readonly canonicalName: string; + readonly componentType: NoIntroRomComponentType; +} + +export interface NoIntroCatalogGamePlan { + readonly systemKey: string; + readonly gameTitle: string; + readonly regionLanguageComponents: readonly NoIntroCatalogComponentSlot[]; + readonly downloadPlayComponents: readonly NoIntroCatalogComponentSlot[]; +} + +export interface NoIntroCatalogStandalonePlan { + readonly title: string; + readonly componentType: NoIntroRomComponentType; +} + +export interface NoIntroCatalogPlan { + readonly games: readonly NoIntroCatalogGamePlan[]; + readonly standaloneGames: readonly NoIntroCatalogStandalonePlan[]; +} + +export interface NoIntroVerificationResult { + readonly id: number; + readonly verificationStatus: NoIntroVerificationStatus; + readonly actualFileName: string; + readonly expectedFileName?: string; + readonly isDuplicate: boolean; + readonly isMissing: boolean; +} diff --git a/frontend/src/typings/Settings/NamingConfig.ts b/frontend/src/typings/Settings/NamingConfig.ts index 3e1de019..c5975bd5 100644 --- a/frontend/src/typings/Settings/NamingConfig.ts +++ b/frontend/src/typings/Settings/NamingConfig.ts @@ -1,8 +1,11 @@ type ColonReplacementFormat = 'delete' | 'dash' | 'spaceDash' | 'spaceDashSpace' | 'smart'; +type RenameProfile = 'gamarr' | 'noIntroPreserveById' | 'noIntroCanonical'; + export default interface NamingConfig { renameGames: boolean; + renameProfile: RenameProfile; replaceIllegalCharacters: boolean; colonReplacementFormat: ColonReplacementFormat; standardGameFormat: string; diff --git a/src/Gamarr.Api.V3/Config/NamingConfigResource.cs b/src/Gamarr.Api.V3/Config/NamingConfigResource.cs index 1eb2fabf..dac45012 100644 --- a/src/Gamarr.Api.V3/Config/NamingConfigResource.cs +++ b/src/Gamarr.Api.V3/Config/NamingConfigResource.cs @@ -6,6 +6,7 @@ namespace Gamarr.Api.V3.Config public class NamingConfigResource : RestResource { public bool RenameGames { get; set; } + public RenameProfile RenameProfile { get; set; } public bool ReplaceIllegalCharacters { get; set; } public ColonReplacementFormat ColonReplacementFormat { get; set; } public string StandardGameFormat { get; set; } diff --git a/src/Gamarr.Api.V3/Config/NamingExampleResource.cs b/src/Gamarr.Api.V3/Config/NamingExampleResource.cs index c03d10a7..d1368642 100644 --- a/src/Gamarr.Api.V3/Config/NamingExampleResource.cs +++ b/src/Gamarr.Api.V3/Config/NamingExampleResource.cs @@ -17,6 +17,7 @@ public static NamingConfigResource ToResource(this NamingConfig model) Id = model.Id, RenameGames = model.RenameGames, + RenameProfile = model.RenameProfile, ReplaceIllegalCharacters = model.ReplaceIllegalCharacters, ColonReplacementFormat = model.ColonReplacementFormat, StandardGameFormat = model.StandardGameFormat, @@ -31,6 +32,7 @@ public static NamingConfig ToModel(this NamingConfigResource resource) Id = resource.Id, RenameGames = resource.RenameGames, + RenameProfile = resource.RenameProfile, ReplaceIllegalCharacters = resource.ReplaceIllegalCharacters, ColonReplacementFormat = resource.ColonReplacementFormat, StandardGameFormat = resource.StandardGameFormat, diff --git a/src/Gamarr.Api.V3/RomCatalog/NoIntroCatalogController.cs b/src/Gamarr.Api.V3/RomCatalog/NoIntroCatalogController.cs new file mode 100644 index 00000000..bff9a189 --- /dev/null +++ b/src/Gamarr.Api.V3/RomCatalog/NoIntroCatalogController.cs @@ -0,0 +1,59 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Mvc; +using Gamarr.Http; +using NzbDrone.Core.RomCatalog; + +namespace Gamarr.Api.V3.RomCatalog +{ + [V3ApiController("romcatalog")] + public class NoIntroCatalogController : Controller + { + private readonly INoIntroCatalogSourceRepository _sourceRepository; + private readonly INoIntroCatalogEntryRepository _entryRepository; + private readonly INoIntroVerificationResultRepository _resultRepository; + private readonly INoIntroComponentClassifier _componentClassifier; + + public NoIntroCatalogController( + INoIntroCatalogSourceRepository sourceRepository, + INoIntroCatalogEntryRepository entryRepository, + INoIntroVerificationResultRepository resultRepository, + INoIntroComponentClassifier componentClassifier) + { + _sourceRepository = sourceRepository; + _entryRepository = entryRepository; + _resultRepository = resultRepository; + _componentClassifier = componentClassifier; + } + + [HttpGet("source")] + [Produces("application/json")] + public List GetSources() + { + return _sourceRepository.All().Select(x => x.ToResource()).ToList(); + } + + [HttpGet("entry")] + [Produces("application/json")] + public List GetEntries([FromQuery] int catalogSourceId) + { + var entries = catalogSourceId > 0 ? _entryRepository.GetBySourceId(catalogSourceId) : _entryRepository.All(); + return entries.Select(x => x.ToResource()).ToList(); + } + + [HttpGet("verification")] + [Produces("application/json")] + public List GetVerificationResults() + { + return _resultRepository.All().Select(x => x.ToResource()).ToList(); + } + + [HttpGet("componentplan")] + [Produces("application/json")] + public NoIntroCatalogPlanResource GetComponentPlan([FromQuery] int catalogSourceId) + { + var entries = catalogSourceId > 0 ? _entryRepository.GetBySourceId(catalogSourceId) : _entryRepository.All(); + return _componentClassifier.BuildCatalogPlan(entries).ToResource(); + } + } +} diff --git a/src/Gamarr.Api.V3/RomCatalog/NoIntroCatalogResource.cs b/src/Gamarr.Api.V3/RomCatalog/NoIntroCatalogResource.cs new file mode 100644 index 00000000..fe78e6b9 --- /dev/null +++ b/src/Gamarr.Api.V3/RomCatalog/NoIntroCatalogResource.cs @@ -0,0 +1,188 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Gamarr.Http.REST; +using NzbDrone.Core.Games; +using NzbDrone.Core.RomCatalog; + +namespace Gamarr.Api.V3.RomCatalog +{ + public class NoIntroCatalogSourceResource : RestResource + { + public string Name { get; set; } + public string SourceUrl { get; set; } + public string PinnedRevision { get; set; } + public string CatalogVersion { get; set; } + public DateTime? LastSuccessfulSync { get; set; } + public DateTime? LastAttemptedSync { get; set; } + public string LastSyncError { get; set; } + } + + public class NoIntroCatalogEntryResource : RestResource + { + public int CatalogSourceId { get; set; } + public string SystemKey { get; set; } + public string CanonicalName { get; set; } + public string ParentCanonicalName { get; set; } + public string CanonicalFileName { get; set; } + public PlatformFamily PlatformFamily { get; set; } + } + + public class NoIntroCatalogPlanResource + { + public List Games { get; set; } = new List(); + public List StandaloneGames { get; set; } = new List(); + } + + public class NoIntroCatalogGamePlanResource + { + public string SystemKey { get; set; } + public string GameTitle { get; set; } + public List RegionLanguageComponents { get; set; } = new List(); + public List DownloadPlayComponents { get; set; } = new List(); + } + + public class NoIntroCatalogStandalonePlanResource + { + public string Title { get; set; } + public NoIntroRomComponentType ComponentType { get; set; } + } + + public class NoIntroCatalogComponentSlotResource + { + public string SlotLabel { get; set; } + public string CanonicalName { get; set; } + public NoIntroRomComponentType ComponentType { get; set; } + } + + public class NoIntroVerificationResultResource : RestResource + { + public int SnapshotId { get; set; } + public int VerificationSetId { get; set; } + public int? CatalogEntryId { get; set; } + public string RelativePath { get; set; } + public string ArchivePath { get; set; } + public string MemberPath { get; set; } + public string ActualFileName { get; set; } + public string ExpectedFileName { get; set; } + public string HashType { get; set; } + public string HashValue { get; set; } + public NoIntroVerificationStatus VerificationStatus { get; set; } + public bool IsDuplicate { get; set; } + public bool IsMissing { get; set; } + public DateTime VerifiedAt { get; set; } + } + + public static class NoIntroCatalogResourceMapper + { + public static NoIntroCatalogSourceResource ToResource(this NoIntroCatalogSource model) + { + if (model == null) + { + return null; + } + + return new NoIntroCatalogSourceResource + { + Id = model.Id, + Name = model.Name, + SourceUrl = model.SourceUrl, + PinnedRevision = model.PinnedRevision, + CatalogVersion = model.CatalogVersion, + LastSuccessfulSync = model.LastSuccessfulSync, + LastAttemptedSync = model.LastAttemptedSync, + LastSyncError = model.LastSyncError + }; + } + + public static NoIntroCatalogEntryResource ToResource(this NoIntroCatalogEntry model) + { + if (model == null) + { + return null; + } + + return new NoIntroCatalogEntryResource + { + Id = model.Id, + CatalogSourceId = model.CatalogSourceId, + SystemKey = model.SystemKey, + CanonicalName = model.CanonicalName, + ParentCanonicalName = model.ParentCanonicalName, + CanonicalFileName = model.CanonicalFileName, + PlatformFamily = model.PlatformFamily + }; + } + + public static NoIntroVerificationResultResource ToResource(this NoIntroVerificationResult model) + { + if (model == null) + { + return null; + } + + return new NoIntroVerificationResultResource + { + Id = model.Id, + SnapshotId = model.SnapshotId, + VerificationSetId = model.VerificationSetId, + CatalogEntryId = model.CatalogEntryId, + RelativePath = model.RelativePath, + ArchivePath = model.ArchivePath, + MemberPath = model.MemberPath, + ActualFileName = model.ActualFileName, + ExpectedFileName = model.ExpectedFileName, + HashType = model.HashType, + HashValue = model.HashValue, + VerificationStatus = model.VerificationStatus, + IsDuplicate = model.IsDuplicate, + IsMissing = model.IsMissing, + VerifiedAt = model.VerifiedAt + }; + } + + public static NoIntroCatalogPlanResource ToResource(this NoIntroCatalogPlan model) + { + if (model == null) + { + return null; + } + + return new NoIntroCatalogPlanResource + { + Games = model.Games.Select(ToResource).ToList(), + StandaloneGames = model.StandaloneGames.Select(ToResource).ToList() + }; + } + + private static NoIntroCatalogGamePlanResource ToResource(this NoIntroCatalogGamePlan model) + { + return new NoIntroCatalogGamePlanResource + { + SystemKey = model.SystemKey, + GameTitle = model.GameTitle, + RegionLanguageComponents = model.RegionLanguageComponents.Select(ToResource).ToList(), + DownloadPlayComponents = model.DownloadPlayComponents.Select(ToResource).ToList() + }; + } + + private static NoIntroCatalogStandalonePlanResource ToResource(this NoIntroCatalogStandalonePlan model) + { + return new NoIntroCatalogStandalonePlanResource + { + Title = model.Title, + ComponentType = model.ComponentType + }; + } + + private static NoIntroCatalogComponentSlotResource ToResource(this NoIntroCatalogComponentSlot model) + { + return new NoIntroCatalogComponentSlotResource + { + SlotLabel = model.SlotLabel, + CanonicalName = model.CanonicalName, + ComponentType = model.ComponentType + }; + } + } +} diff --git a/src/NzbDrone.Api.Test/RomCatalog/NoIntroCatalogControllerFixture.cs b/src/NzbDrone.Api.Test/RomCatalog/NoIntroCatalogControllerFixture.cs new file mode 100644 index 00000000..d311022e --- /dev/null +++ b/src/NzbDrone.Api.Test/RomCatalog/NoIntroCatalogControllerFixture.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using FluentAssertions; +using NUnit.Framework; +using Gamarr.Api.V3.RomCatalog; +using NzbDrone.Core.Games; +using NzbDrone.Core.RomCatalog; +using NzbDrone.Test.Common; + +namespace NzbDrone.Api.Test.RomCatalog +{ + [TestFixture] + public class NoIntroCatalogControllerFixture : TestBase + { + [Test] + public void NoIntroApi_should_surface_catalog_source_metadata_and_verification_rows() + { + Mocker.GetMock() + .Setup(x => x.All()) + .Returns(new List + { + new NoIntroCatalogSource + { + Id = 7, + Name = "No-Intro", + SourceUrl = "https://example.invalid/gba.dat", + CatalogVersion = "2026-07-18", + LastSuccessfulSync = new DateTime(2026, 7, 18, 0, 0, 0, DateTimeKind.Utc) + } + }); + + Mocker.GetMock() + .Setup(x => x.All()) + .Returns(new List + { + new NoIntroVerificationResult + { + Id = 11, + SnapshotId = 2, + VerificationSetId = 3, + ActualFileName = "0001 - F-Zero.zip", + ExpectedFileName = "F-Zero.zip", + VerificationStatus = NoIntroVerificationStatus.NameMismatch, + IsDuplicate = true, + VerifiedAt = new DateTime(2026, 7, 18, 1, 0, 0, DateTimeKind.Utc) + } + }); + + Subject.GetSources().Should().ContainSingle(x => x.Id == 7 && x.CatalogVersion == "2026-07-18" && x.LastSuccessfulSync.HasValue); + Subject.GetVerificationResults().Should().ContainSingle(x => x.Id == 11 && x.VerificationStatus == NoIntroVerificationStatus.NameMismatch && x.IsDuplicate); + } + + [Test] + public void NoIntroApi_should_surface_region_download_play_and_standalone_component_plan() + { + var entries = new List + { + Entry(1, "nintendo-ds", "Mario Kart DS (USA)"), + ChildEntry(1, "nintendo-ds", "Mario Kart DS (Download Play)", "Mario Kart DS"), + Entry(1, "nintendo-ds", "Mario Party DS (Download Play)"), + Entry(1, "nintendo-gba", "Pokemon Emerald Version (Germany)") + }; + + Mocker.GetMock() + .Setup(x => x.GetBySourceId(1)) + .Returns(entries); + + Mocker.GetMock() + .Setup(x => x.BuildCatalogPlan(entries)) + .Returns(new NoIntroComponentClassifier().BuildCatalogPlan(entries)); + + var plan = Subject.GetComponentPlan(1); + + plan.Games.Should().ContainSingle(x => x.GameTitle == "Mario Kart DS") + .Subject.DownloadPlayComponents.Should().ContainSingle(x => x.SlotLabel == "Download Play"); + plan.Games.Should().ContainSingle(x => x.GameTitle == "Pokemon Emerald Version") + .Subject.RegionLanguageComponents.Should().ContainSingle(x => x.SlotLabel == "Germany"); + plan.StandaloneGames.Should().ContainSingle(x => x.Title == "Mario Party DS (Download Play)" && x.ComponentType == NoIntroRomComponentType.Multiboot); + } + + private static NoIntroCatalogEntry Entry(int sourceId, string systemKey, string canonicalName) + { + return new NoIntroCatalogEntry + { + CatalogSourceId = sourceId, + SystemKey = systemKey, + CanonicalName = canonicalName, + CanonicalFileName = $"{canonicalName}.zip", + PlatformFamily = PlatformFamily.Nintendo + }; + } + + private static NoIntroCatalogEntry ChildEntry(int sourceId, string systemKey, string canonicalName, string parentCanonicalName) + { + var entry = Entry(sourceId, systemKey, canonicalName); + entry.ParentCanonicalName = parentCanonicalName; + return entry; + } + } +} diff --git a/src/NzbDrone.Core.Test/Organizer/RenameProfileNamingBehaviorFixture.cs b/src/NzbDrone.Core.Test/Organizer/RenameProfileNamingBehaviorFixture.cs new file mode 100644 index 00000000..983ef85f --- /dev/null +++ b/src/NzbDrone.Core.Test/Organizer/RenameProfileNamingBehaviorFixture.cs @@ -0,0 +1,62 @@ +using System.Linq; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.CustomFormats; +using NzbDrone.Core.Games; +using NzbDrone.Core.Games.Translations; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Organizer; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.Organizer +{ + [TestFixture] + public class RenameProfileNamingBehaviorFixture : CoreTest + { + private NamingConfig _namingConfig; + + [SetUp] + public void Setup() + { + _namingConfig = NamingConfig.Default; + _namingConfig.RenameGames = true; + _namingConfig.RenameProfile = RenameProfile.Gamarr; + + Mocker.GetMock() + .Setup(x => x.GetConfig()) + .Returns(_namingConfig); + + Mocker.GetMock() + .Setup(x => x.Get(It.IsAny())) + .Returns(quality => Quality.DefaultQualityDefinitions.Single(x => x.Quality == quality)); + + Mocker.GetMock() + .Setup(x => x.All()) + .Returns(new System.Collections.Generic.List()); + + Mocker.GetMock() + .Setup(x => x.GetAllTranslationsForGameMetadata(It.IsAny())) + .Returns(new System.Collections.Generic.List()); + } + + [Test] + public void RenameProfile_should_preserve_existing_default_file_name_builder_output_for_normal_gamarr_profile() + { + var game = new Game + { + Title = "South Park", + Year = 1998 + }; + + var gameFile = new GameFile + { + Quality = new QualityModel(Quality.Uplay) + }; + + Subject.BuildFileName(game, gameFile) + .Should().Be("South Park (1998) Uplay"); + } + } +} diff --git a/src/NzbDrone.Core.Test/RomCatalog/NoIntroCatalogPersistenceFixture.cs b/src/NzbDrone.Core.Test/RomCatalog/NoIntroCatalogPersistenceFixture.cs new file mode 100644 index 00000000..aa8f741a --- /dev/null +++ b/src/NzbDrone.Core.Test/RomCatalog/NoIntroCatalogPersistenceFixture.cs @@ -0,0 +1,57 @@ +using System; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.RomCatalog; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.RomCatalog +{ + [TestFixture] + public class NoIntroCatalogPersistenceFixture : DbTest + { + [Test] + public void should_persist_generic_nointro_catalog_models_without_gamefile_semantics() + { + var sourceRepository = Mocker.Resolve(); + var entryRepository = Mocker.Resolve(); + var resultRepository = Mocker.Resolve(); + + var source = sourceRepository.Insert(new NoIntroCatalogSource + { + Name = "No-Intro", + SourceUrl = "https://example.invalid/nointro.dat", + PinnedRevision = "rev-1", + CatalogVersion = "2026-07-01" + }); + + var entry = entryRepository.Insert(new NoIntroCatalogEntry + { + CatalogSourceId = source.Id, + SystemKey = "nintendo-gba", + CanonicalName = "F-Zero for Game Boy Advance (Japan)", + CanonicalFileName = "0001 - F-Zero for Game Boy Advance (Japan).zip" + }); + + var result = resultRepository.Insert(new NoIntroVerificationResult + { + SnapshotId = 1, + VerificationSetId = 1, + CatalogEntryId = entry.Id, + RelativePath = "GBA (by-id)/0001 - F-Zero for Game Boy Advance (Japan).zip", + ActualFileName = "0001 - F-Zero for Game Boy Advance (Japan).zip", + ExpectedFileName = "0001 - F-Zero for Game Boy Advance (Japan).zip", + HashType = "sha1", + HashValue = "abc123", + VerificationStatus = NoIntroVerificationStatus.Verified, + IsDuplicate = false, + IsMissing = false, + VerifiedAt = DateTime.UtcNow + }); + + source.Id.Should().BeGreaterThan(0); + entry.Id.Should().BeGreaterThan(0); + result.Id.Should().BeGreaterThan(0); + result.CatalogEntryId.Should().Be(entry.Id); + } + } +} diff --git a/src/NzbDrone.Core.Test/RomCatalog/NoIntroCatalogSyncServiceFixture.cs b/src/NzbDrone.Core.Test/RomCatalog/NoIntroCatalogSyncServiceFixture.cs new file mode 100644 index 00000000..ceed3440 --- /dev/null +++ b/src/NzbDrone.Core.Test/RomCatalog/NoIntroCatalogSyncServiceFixture.cs @@ -0,0 +1,98 @@ +using System; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.RomCatalog; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.RomCatalog +{ + [TestFixture] + public class NoIntroCatalogSyncServiceFixture : DbTest + { + private NoIntroCatalogSourceRepository _sourceRepository; + private NoIntroCatalogEntryRepository _entryRepository; + private NoIntroCatalogHashRepository _hashRepository; + private NoIntroCatalogSyncService _subject; + + [SetUp] + public void Setup() + { + _sourceRepository = Mocker.Resolve(); + _entryRepository = Mocker.Resolve(); + _hashRepository = Mocker.Resolve(); + _subject = Mocker.Resolve(); + } + + [Test] + public void sync_should_ingest_snapshot_and_update_metadata() + { + var source = _sourceRepository.Insert(new NoIntroCatalogSource + { + Name = "No-Intro", + SourceUrl = "https://example.invalid/gba.dat", + PinnedRevision = "pin-1" + }); + + Mocker.GetMock() + .Setup(x => x.Fetch(source.SourceUrl)) + .Returns("
Nintendo - Game Boy Advance2026.07
"); + + _subject.Sync(source.Id); + + var storedSource = _sourceRepository.Get(source.Id); + storedSource.CatalogVersion.Should().Be("2026.07"); + storedSource.LastSuccessfulSync.Should().NotBeNull(); + storedSource.LastSyncError.Should().BeNull(); + + var entry = _entryRepository.All().Should().ContainSingle().Subject; + entry.CatalogSourceId.Should().Be(source.Id); + entry.SystemKey.Should().Be("nintendo---game-boy-advance"); + + _hashRepository.All().Should().Contain(x => x.CatalogEntryId == entry.Id && x.HashType == "crc32" && x.HashValue == "ABCDEF12"); + } + + [Test] + public void sync_failure_should_preserve_existing_catalog_and_record_failure() + { + var source = _sourceRepository.Insert(new NoIntroCatalogSource + { + Name = "No-Intro", + SourceUrl = "https://example.invalid/gba.dat", + PinnedRevision = "pin-1", + CatalogVersion = "old" + }); + + var existingEntry = _entryRepository.Insert(new NoIntroCatalogEntry + { + CatalogSourceId = source.Id, + SystemKey = "nintendo-gba", + CanonicalName = "Existing Entry", + CanonicalFileName = "Existing Entry.gba" + }); + + _hashRepository.Insert(new NoIntroCatalogHash + { + CatalogEntryId = existingEntry.Id, + HashType = "sha1", + HashValue = "oldhash", + IsPrimary = true + }); + + Mocker.GetMock() + .Setup(x => x.Fetch(source.SourceUrl)) + .Throws(new InvalidOperationException("boom")); + + Action act = () => _subject.Sync(source.Id); + + act.Should().Throw(); + + _entryRepository.All().Should().ContainSingle(x => x.Id == existingEntry.Id); + _hashRepository.All().Should().ContainSingle(x => x.HashValue == "oldhash"); + + var storedSource = _sourceRepository.Get(source.Id); + storedSource.LastAttemptedSync.Should().NotBeNull(); + storedSource.LastSyncError.Should().Be("boom"); + storedSource.CatalogVersion.Should().Be("old"); + } + } +} diff --git a/src/NzbDrone.Core.Test/RomCatalog/NoIntroComponentClassifierFixture.cs b/src/NzbDrone.Core.Test/RomCatalog/NoIntroComponentClassifierFixture.cs new file mode 100644 index 00000000..a1cef7de --- /dev/null +++ b/src/NzbDrone.Core.Test/RomCatalog/NoIntroComponentClassifierFixture.cs @@ -0,0 +1,150 @@ +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.RomCatalog; + +namespace NzbDrone.Core.Test.RomCatalog +{ + [TestFixture] + public class NoIntroComponentClassifierFixture + { + private NoIntroComponentClassifier _subject; + + [SetUp] + public void Setup() + { + _subject = new NoIntroComponentClassifier(); + } + + [TestCase("GBA (by-id)/0001 - F-Zero for Game Boy Advance (Japan).zip", "0001 - F-Zero for Game Boy Advance (Japan).zip", NoIntroRomComponentType.RetailRom, false)] + [TestCase("GBA (e-Reader)/Animal Crossing-e - Series 1 - A-001 - K.K. Slider (USA).zip", "Animal Crossing-e - Series 1 - A-001 - K.K. Slider (USA).zip", NoIntroRomComponentType.EReaderCards, false)] + [TestCase("GBA (Multiboot)/Animal Crossing - Balloon Fight (USA, Europe).gba", "Animal Crossing - Balloon Fight (USA, Europe).gba", NoIntroRomComponentType.Multiboot, false)] + [TestCase("GBA (Play-Yan)/Nintendo - Game Boy Advance (Play-Yan).zip", "Nintendo - Game Boy Advance (Play-Yan).zip", NoIntroRomComponentType.Video, false)] + [TestCase("GBA (Video)/Game Boy Advance Video - Cartoon Network Collection - Volume 1 (USA, Europe).gba", "Game Boy Advance Video - Cartoon Network Collection - Volume 1 (USA, Europe).gba", NoIntroRomComponentType.Video, false)] + [TestCase("Download Play/Mario Kart DS Demo.nds", "Mario Kart DS Demo.nds", NoIntroRomComponentType.Multiboot, false)] + [TestCase("DSvision SD cards/Media Title.nds", "Media Title.nds", NoIntroRomComponentType.Video, false)] + [TestCase("GBA (by-id)/xB02 - [BIOS] Game Boy Advance (World).zip", "xB02 - [BIOS] Game Boy Advance (World).zip", NoIntroRomComponentType.Bios, false)] + [TestCase("Unknown Folder/Prototype Build.nds", "Prototype Build.nds", NoIntroRomComponentType.RomhackOrUnverified, true)] + public void should_classify_known_component_shapes(string relativePath, string fileName, NoIntroRomComponentType expectedType, bool expectedFallback) + { + var result = _subject.Classify(relativePath, fileName); + + result.ComponentType.Should().Be(expectedType); + result.IsFallback.Should().Be(expectedFallback); + } + + [Test] + public void should_build_RegionLanguageComponents_from_catalog_confirmed_entries_only() + { + var plan = _subject.BuildCatalogPlan(new[] + { + Entry("nintendo-gba", "Pokemon Emerald Version (USA)"), + Entry("nintendo-gba", "Pokemon Emerald Version (Germany)"), + Entry("nintendo-gba", "Pokemon Emerald Version (France)"), + Entry("nintendo-gba", "Pokemon Emerald Version (Spain)"), + Entry("nintendo-gba", "Pokemon Emerald Version (Italy)") + }); + + var game = plan.Games.Should().ContainSingle(x => x.SystemKey == "nintendo-gba" && x.GameTitle == "Pokemon Emerald Version").Subject; + + game.RegionLanguageComponents.Should().HaveCount(5); + game.RegionLanguageComponents.Select(x => x.SlotLabel).Should().BeEquivalentTo("USA", "Germany", "France", "Spain", "Italy"); + game.DownloadPlayComponents.Should().BeEmpty(); + plan.StandaloneGames.Should().BeEmpty(); + } + + [Test] + public void should_build_DownloadPlayComponents_only_when_parent_mapping_is_explicit() + { + var plan = _subject.BuildCatalogPlan(new[] + { + Entry("nintendo-ds", "Mario Kart DS (USA)"), + Entry("nintendo-ds", "Mario Kart DS (Download Play)", parentCanonicalName: "Mario Kart DS") + }); + + var game = plan.Games.Should().ContainSingle(x => x.SystemKey == "nintendo-ds" && x.GameTitle == "Mario Kart DS").Subject; + + game.RegionLanguageComponents.Should().ContainSingle(x => x.SlotLabel == "USA"); + game.DownloadPlayComponents.Should().ContainSingle(x => x.SlotLabel == "Download Play"); + plan.StandaloneGames.Should().BeEmpty(); + } + + [Test] + public void should_build_NoIntroStandaloneGames_for_clean_standalone_products() + { + var plan = _subject.BuildCatalogPlan(new[] + { + Entry("nintendo-gba", "Game Boy Advance Video - Cartoon Network Collection - Volume 1 (USA)"), + Entry("nintendo-gba", "Nintendo - Game Boy Advance (Play-Yan)"), + Entry("nintendo-ds", "DSvision SD cards - Aquarium Tour (Japan)") + }); + + plan.Games.Should().BeEmpty(); + plan.StandaloneGames.Select(x => x.Title).Should().BeEquivalentTo( + "Game Boy Advance Video - Cartoon Network Collection - Volume 1 (USA)", + "Nintendo - Game Boy Advance (Play-Yan)", + "DSvision SD cards - Aquarium Tour (Japan)"); + } + + [Test] + public void should_build_NoPhantomRegionComponents_when_catalog_release_is_missing() + { + var plan = _subject.BuildCatalogPlan(new[] + { + Entry("nintendo-gba", "Advance Wars (USA)"), + Entry("nintendo-gba", "Advance Wars (Germany)") + }); + + var game = plan.Games.Should().ContainSingle(x => x.GameTitle == "Advance Wars").Subject; + var labels = game.RegionLanguageComponents.Select(x => x.SlotLabel).ToList(); + + labels.Should().BeEquivalentTo("USA", "Germany"); + labels.Should().NotContain(new[] { "Europe", "Japan", "France", "Italy", "Spain" }); + } + + [Test] + public void should_build_NoUnmappedDownloadPlayComponents_as_children() + { + var plan = _subject.BuildCatalogPlan(new[] + { + Entry("nintendo-ds", "Mario Party DS (Download Play)") + }); + + plan.Games.Should().BeEmpty(); + plan.StandaloneGames.Should().ContainSingle(x => x.Title == "Mario Party DS (Download Play)" && x.ComponentType == NoIntroRomComponentType.Multiboot); + } + + [Test] + public void should_build_StandaloneProductsNotBaseGameVariants() + { + var plan = _subject.BuildCatalogPlan(new[] + { + Entry("nintendo-ds", "Pokemon Dash (USA)"), + Entry("nintendo-gba", "Nintendo - Game Boy Advance (BIOS) (World)"), + Entry("nintendo-ds", "New Super Mario Bros. Demo (Kiosk)"), + Entry("nintendo-ds", "DSvision SD cards - Aquarium Tour (Japan)") + }); + + var game = plan.Games.Should().ContainSingle(x => x.GameTitle == "Pokemon Dash").Subject; + + game.RegionLanguageComponents.Should().ContainSingle(x => x.SlotLabel == "USA"); + game.DownloadPlayComponents.Should().BeEmpty(); + + plan.StandaloneGames.Select(x => x.Title).Should().BeEquivalentTo( + "Nintendo - Game Boy Advance (BIOS) (World)", + "New Super Mario Bros. Demo (Kiosk)", + "DSvision SD cards - Aquarium Tour (Japan)"); + } + + private static NoIntroCatalogEntry Entry(string systemKey, string canonicalName, string parentCanonicalName = null) + { + return new NoIntroCatalogEntry + { + SystemKey = systemKey, + CanonicalName = canonicalName, + CanonicalFileName = $"{canonicalName}.zip", + ParentCanonicalName = parentCanonicalName + }; + } + } +} diff --git a/src/NzbDrone.Core.Test/RomCatalog/NoIntroEndToEndFixture.cs b/src/NzbDrone.Core.Test/RomCatalog/NoIntroEndToEndFixture.cs new file mode 100644 index 00000000..7e14056f --- /dev/null +++ b/src/NzbDrone.Core.Test/RomCatalog/NoIntroEndToEndFixture.cs @@ -0,0 +1,144 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Games; +using NzbDrone.Core.Organizer; +using NzbDrone.Core.RomCatalog; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.RomCatalog +{ + [TestFixture] + public class NoIntroEndToEndFixture : DbTest + { + private NoIntroCatalogSourceRepository _sourceRepository; + private NoIntroCatalogEntryRepository _entryRepository; + private NoIntroCatalogHashRepository _hashRepository; + private NoIntroVerificationSetRepository _verificationSetRepository; + private NoIntroVerificationResultRepository _resultRepository; + private NoIntroVerificationService _verificationService; + private NoIntroComponentClassifier _componentClassifier; + private string _tempRoot; + + [SetUp] + public void Setup() + { + _sourceRepository = Mocker.Resolve(); + _entryRepository = Mocker.Resolve(); + _hashRepository = Mocker.Resolve(); + _verificationSetRepository = Mocker.Resolve(); + _resultRepository = Mocker.Resolve(); + _componentClassifier = new NoIntroComponentClassifier(); + + Mocker.GetMock() + .Setup(x => x.GetConfig()) + .Returns(new NamingConfig { RenameProfile = RenameProfile.NoIntroPreserveById }); + + _verificationService = Mocker.Resolve(); + _tempRoot = Path.Combine(TempFolder, "nointro-e2e"); + Directory.CreateDirectory(_tempRoot); + } + + [Test] + public void EndToEndNoIntro_should_validate_gba_nds_components_and_standalone_products() + { + var source = _sourceRepository.Insert(new NoIntroCatalogSource + { + Name = "No-Intro", + SourceUrl = "https://example.invalid/nointro.dat", + CatalogVersion = "fixture" + }); + + var fzeroBytes = new byte[] { 1, 2, 3, 4 }; + var marioKartBytes = new byte[] { 5, 6, 7, 8 }; + var fzero = InsertEntry(source.Id, "nintendo-gba", "F-Zero for Game Boy Advance (Japan)", "F-Zero for Game Boy Advance (Japan).zip"); + var marioKart = InsertEntry(source.Id, "nintendo-ds", "Mario Kart DS (USA)", "Mario Kart DS (USA).nds"); + + InsertHash(fzero.Id, fzeroBytes); + InsertHash(marioKart.Id, marioKartBytes); + InsertEntry(source.Id, "nintendo-ds", "Mario Kart DS (Download Play)", "Mario Kart DS (Download Play).nds", "Mario Kart DS"); + InsertEntry(source.Id, "nintendo-gba", "Pokemon Emerald Version (Germany)", "Pokemon Emerald Version (Germany).zip"); + InsertEntry(source.Id, "nintendo-gba", "Game Boy Advance Video - Cartoon Network Collection - Volume 1 (USA)", "Game Boy Advance Video - Cartoon Network Collection - Volume 1 (USA).zip"); + + var verificationSet = _verificationSetRepository.Insert(new NoIntroVerificationSet + { + CatalogSourceId = source.Id, + SystemKey = "mixed-fixture", + RootPath = _tempRoot, + Enabled = true + }); + + var fzeroZip = WriteZip("Nintendo/Game Boy Advance/GBA (by-id)/0001 - F-Zero for Game Boy Advance (Japan).zip", "F-Zero for Game Boy Advance (Japan).gba", fzeroBytes); + var marioKartRaw = WriteBytes("Nintendo/Nintendo DS/nds/Mario Kart DS (USA).nds", marioKartBytes); + + _verificationService.Verify(verificationSet.Id, new[] { fzeroZip, marioKartRaw }); + var results = _resultRepository.All().ToList(); + var plan = _componentClassifier.BuildCatalogPlan(_entryRepository.GetBySourceId(source.Id)); + + results.Should().Contain(x => x.CatalogEntryId == fzero.Id && x.VerificationStatus == NoIntroVerificationStatus.Verified); + results.Should().Contain(x => x.CatalogEntryId == marioKart.Id && x.VerificationStatus == NoIntroVerificationStatus.Verified); + plan.Games.Should().ContainSingle(x => x.GameTitle == "Mario Kart DS") + .Subject.DownloadPlayComponents.Should().ContainSingle(x => x.SlotLabel == "Download Play"); + plan.Games.Should().ContainSingle(x => x.GameTitle == "Pokemon Emerald Version") + .Subject.RegionLanguageComponents.Should().ContainSingle(x => x.SlotLabel == "Germany"); + plan.StandaloneGames.Should().ContainSingle(x => x.Title.StartsWith("Game Boy Advance Video", StringComparison.Ordinal)); + } + + private NoIntroCatalogEntry InsertEntry(int sourceId, string systemKey, string canonicalName, string fileName) + { + return _entryRepository.Insert(new NoIntroCatalogEntry + { + CatalogSourceId = sourceId, + SystemKey = systemKey, + CanonicalName = canonicalName, + CanonicalFileName = fileName, + PlatformFamily = PlatformFamily.Nintendo + }); + } + + private NoIntroCatalogEntry InsertEntry(int sourceId, string systemKey, string canonicalName, string fileName, string parentCanonicalName) + { + var entry = InsertEntry(sourceId, systemKey, canonicalName, fileName); + entry.ParentCanonicalName = parentCanonicalName; + return _entryRepository.Update(entry); + } + + private void InsertHash(int entryId, byte[] content) + { + using var sha1 = System.Security.Cryptography.SHA1.Create(); + + _hashRepository.Insert(new NoIntroCatalogHash + { + CatalogEntryId = entryId, + HashType = "sha1", + HashValue = BitConverter.ToString(sha1.ComputeHash(content)).Replace("-", string.Empty), + IsPrimary = true, + IsBadDump = false + }); + } + + private string WriteBytes(string relativePath, byte[] content) + { + var path = Path.Combine(_tempRoot, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(path)); + File.WriteAllBytes(path, content); + return path; + } + + private string WriteZip(string relativePath, string memberName, byte[] content) + { + var path = Path.Combine(_tempRoot, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(path)); + + using var archive = ZipFile.Open(path, ZipArchiveMode.Create); + var entry = archive.CreateEntry(memberName); + using var stream = entry.Open(); + stream.Write(content, 0, content.Length); + + return path; + } + } +} diff --git a/src/NzbDrone.Core.Test/RomCatalog/NoIntroVerificationRenameProfileFixture.cs b/src/NzbDrone.Core.Test/RomCatalog/NoIntroVerificationRenameProfileFixture.cs new file mode 100644 index 00000000..396f55a7 --- /dev/null +++ b/src/NzbDrone.Core.Test/RomCatalog/NoIntroVerificationRenameProfileFixture.cs @@ -0,0 +1,159 @@ +using System.IO; +using System.IO.Compression; +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Games; +using NzbDrone.Core.Organizer; +using NzbDrone.Core.RomCatalog; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.RomCatalog +{ + [TestFixture] + public class NoIntroVerificationRenameProfileFixture : DbTest + { + private NoIntroCatalogSourceRepository _sourceRepository; + private NoIntroCatalogEntryRepository _entryRepository; + private NoIntroCatalogHashRepository _hashRepository; + private NoIntroVerificationSetRepository _verificationSetRepository; + private NoIntroVerificationResultRepository _resultRepository; + private NoIntroVerificationService _subject; + private string _tempRoot; + + [SetUp] + public void Setup() + { + _sourceRepository = Mocker.Resolve(); + _entryRepository = Mocker.Resolve(); + _hashRepository = Mocker.Resolve(); + _verificationSetRepository = Mocker.Resolve(); + _resultRepository = Mocker.Resolve(); + _subject = Mocker.Resolve(); + _tempRoot = Path.Combine(TempFolder, "rename-profile-roms"); + Directory.CreateDirectory(_tempRoot); + } + + [Test] + public void RenameProfile_should_keep_by_id_file_verified_when_preserve_profile_is_selected() + { + SetRenameProfile(RenameProfile.NoIntroPreserveById); + var verificationSet = CreateVerificationSet(); + var zipPath = CreateVerifiedZip("0001 - F-Zero for Game Boy Advance (Japan).zip"); + + _subject.Verify(verificationSet.Id, new[] { zipPath }); + + var result = _resultRepository.All().Single(x => !x.IsMissing); + result.VerificationStatus.Should().Be(NoIntroVerificationStatus.Verified); + result.ExpectedFileName.Should().Be("0001 - F-Zero for Game Boy Advance (Japan).zip"); + } + + [Test] + public void RenameProfile_should_mark_same_by_id_file_as_mismatch_when_canonical_profile_is_selected() + { + SetRenameProfile(RenameProfile.NoIntroCanonical); + var verificationSet = CreateVerificationSet(); + var zipPath = CreateVerifiedZip("0001 - F-Zero for Game Boy Advance (Japan).zip"); + + _subject.Verify(verificationSet.Id, new[] { zipPath }); + + var result = _resultRepository.All().Single(x => !x.IsMissing); + result.VerificationStatus.Should().Be(NoIntroVerificationStatus.NameMismatch); + result.ExpectedFileName.Should().Be("F-Zero for Game Boy Advance (Japan).zip"); + } + + [Test] + public void RenameProfile_should_keep_existing_default_behavior_under_normal_gamarr_profile() + { + SetRenameProfile(RenameProfile.Gamarr); + var verificationSet = CreateVerificationSet(); + var zipPath = CreateVerifiedZip("0001 - F-Zero for Game Boy Advance (Japan).zip"); + + _subject.Verify(verificationSet.Id, new[] { zipPath }); + + var result = _resultRepository.All().Single(x => !x.IsMissing); + result.VerificationStatus.Should().Be(NoIntroVerificationStatus.NameMismatch); + result.ExpectedFileName.Should().Be("F-Zero for Game Boy Advance (Japan).zip"); + } + + [Test] + public void RenameProfileFailure_should_not_mark_by_id_file_as_name_mismatch_when_preserve_profile_is_selected() + { + SetRenameProfile(RenameProfile.NoIntroPreserveById); + var verificationSet = CreateVerificationSet(); + var zipPath = CreateVerifiedZip("0001 - F-Zero for Game Boy Advance (Japan).zip"); + + _subject.Verify(verificationSet.Id, new[] { zipPath }); + + var result = _resultRepository.All().Single(x => !x.IsMissing); + result.VerificationStatus.Should().NotBe(NoIntroVerificationStatus.NameMismatch); + result.ExpectedFileName.Should().Be("0001 - F-Zero for Game Boy Advance (Japan).zip"); + } + + private void SetRenameProfile(RenameProfile renameProfile) + { + Mocker.GetMock() + .Setup(x => x.GetConfig()) + .Returns(new NamingConfig + { + RenameProfile = renameProfile + }); + } + + private NoIntroVerificationSet CreateVerificationSet() + { + var source = _sourceRepository.Insert(new NoIntroCatalogSource + { + Name = "No-Intro", + SourceUrl = "https://example.invalid/catalog.dat" + }); + + var entry = _entryRepository.Insert(new NoIntroCatalogEntry + { + CatalogSourceId = source.Id, + SystemKey = "nintendo-gba", + CanonicalName = "F-Zero for Game Boy Advance (Japan)", + CanonicalFileName = "F-Zero for Game Boy Advance (Japan).zip", + PlatformFamily = PlatformFamily.Nintendo + }); + + InsertSha1Hash(entry.Id, new byte[] { 1, 2, 3, 4 }); + + return _verificationSetRepository.Insert(new NoIntroVerificationSet + { + CatalogSourceId = source.Id, + SystemKey = "nintendo-gba", + RootPath = _tempRoot, + Enabled = true + }); + } + + private void InsertSha1Hash(int entryId, byte[] content) + { + using var sha1 = System.Security.Cryptography.SHA1.Create(); + var hash = System.BitConverter.ToString(sha1.ComputeHash(content)).Replace("-", string.Empty); + + _hashRepository.Insert(new NoIntroCatalogHash + { + CatalogEntryId = entryId, + HashType = "sha1", + HashValue = hash, + IsPrimary = true, + IsBadDump = false + }); + } + + private string CreateVerifiedZip(string archiveName) + { + var path = Path.Combine(_tempRoot, archiveName); + var content = new byte[] { 1, 2, 3, 4 }; + + using var archive = ZipFile.Open(path, ZipArchiveMode.Create); + var entry = archive.CreateEntry("F-Zero.gba"); + using var stream = entry.Open(); + stream.Write(content, 0, content.Length); + + return path; + } + } +} diff --git a/src/NzbDrone.Core.Test/RomCatalog/NoIntroVerificationServiceFixture.cs b/src/NzbDrone.Core.Test/RomCatalog/NoIntroVerificationServiceFixture.cs new file mode 100644 index 00000000..ec38f376 --- /dev/null +++ b/src/NzbDrone.Core.Test/RomCatalog/NoIntroVerificationServiceFixture.cs @@ -0,0 +1,163 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Games; +using NzbDrone.Core.Organizer; +using NzbDrone.Core.RomCatalog; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.RomCatalog +{ + [TestFixture] + public class NoIntroVerificationServiceFixture : DbTest + { + private NoIntroCatalogSourceRepository _sourceRepository; + private NoIntroCatalogEntryRepository _entryRepository; + private NoIntroCatalogHashRepository _hashRepository; + private NoIntroVerificationSetRepository _verificationSetRepository; + private NoIntroVerificationResultRepository _resultRepository; + private NoIntroVerificationService _subject; + private string _tempRoot; + + [SetUp] + public void Setup() + { + _sourceRepository = Mocker.Resolve(); + _entryRepository = Mocker.Resolve(); + _hashRepository = Mocker.Resolve(); + _verificationSetRepository = Mocker.Resolve(); + _resultRepository = Mocker.Resolve(); + Mocker.GetMock() + .Setup(x => x.GetConfig()) + .Returns(NamingConfig.Default); + + _subject = Mocker.Resolve(); + _tempRoot = Path.Combine(TempFolder, "nointro-roms"); + Directory.CreateDirectory(_tempRoot); + } + + [Test] + public void should_verify_raw_and_zip_roms_and_mark_duplicates_missing_and_bad_dumps() + { + var source = _sourceRepository.Insert(new NoIntroCatalogSource + { + Name = "No-Intro", + SourceUrl = "https://example.invalid/catalog.dat" + }); + + var goodBytes = new byte[] { 1, 2, 3, 4 }; + var badBytes = new byte[] { 9, 9, 9, 9 }; + + var verifiedEntry = _entryRepository.Insert(new NoIntroCatalogEntry + { + CatalogSourceId = source.Id, + SystemKey = "nintendo-gba", + CanonicalName = "Verified Game", + CanonicalFileName = "Verified Game.gba", + PlatformFamily = PlatformFamily.Nintendo + }); + + InsertSha1Hash(verifiedEntry.Id, goodBytes, false); + + var badDumpEntry = _entryRepository.Insert(new NoIntroCatalogEntry + { + CatalogSourceId = source.Id, + SystemKey = "nintendo-gba", + CanonicalName = "Bad Dump Game", + CanonicalFileName = "Bad Dump Game.gba", + PlatformFamily = PlatformFamily.Nintendo + }); + + InsertSha1Hash(badDumpEntry.Id, badBytes, true); + + var missingEntry = _entryRepository.Insert(new NoIntroCatalogEntry + { + CatalogSourceId = source.Id, + SystemKey = "nintendo-gba", + CanonicalName = "Missing Game", + CanonicalFileName = "Missing Game.gba", + PlatformFamily = PlatformFamily.Nintendo + }); + + InsertSha1Hash(missingEntry.Id, new byte[] { 7, 7, 7, 7 }, false); + + var verificationSet = _verificationSetRepository.Insert(new NoIntroVerificationSet + { + CatalogSourceId = source.Id, + SystemKey = "nintendo-gba", + RootPath = _tempRoot, + Enabled = true + }); + + var rawPath = WriteBytes("Verified Game.gba", goodBytes); + var mismatchZip = WriteZip("0001 - Verified Game.zip", "Verified Game.gba", goodBytes); + var badDumpPath = WriteBytes("Bad Dump Game.gba", badBytes); + var unknownPath = WriteBytes("Unknown Game.gba", new byte[] { 5, 6, 7, 8 }); + var ambiguousZip = WriteMultiZip("Ambiguous.zip", ("one.gba", goodBytes), ("two.gba", badBytes)); + + _subject.Verify(verificationSet.Id, new[] { rawPath, mismatchZip, badDumpPath, unknownPath, ambiguousZip }); + + var results = _resultRepository.All().ToList(); + + results.Should().Contain(x => x.ActualFileName == "Verified Game.gba" && x.VerificationStatus == NoIntroVerificationStatus.Verified && x.IsDuplicate); + results.Should().Contain(x => x.ActualFileName == "0001 - Verified Game.zip" && x.VerificationStatus == NoIntroVerificationStatus.NameMismatch && x.IsDuplicate); + results.Should().Contain(x => x.ActualFileName == "Bad Dump Game.gba" && x.VerificationStatus == NoIntroVerificationStatus.BadDump); + results.Should().Contain(x => x.ActualFileName == "Unknown Game.gba" && x.VerificationStatus == NoIntroVerificationStatus.Unknown && !x.IsMissing); + results.Should().Contain(x => x.ActualFileName == "Ambiguous.zip" && x.VerificationStatus == NoIntroVerificationStatus.Unknown && x.MemberPath == null); + results.Should().Contain(x => x.CatalogEntryId == missingEntry.Id && x.IsMissing); + } + + private void InsertSha1Hash(int entryId, byte[] content, bool isBadDump) + { + using var sha1 = System.Security.Cryptography.SHA1.Create(); + var hash = BitConverter.ToString(sha1.ComputeHash(content)).Replace("-", string.Empty); + + _hashRepository.Insert(new NoIntroCatalogHash + { + CatalogEntryId = entryId, + HashType = "sha1", + HashValue = hash, + IsPrimary = true, + IsBadDump = isBadDump + }); + } + + private string WriteBytes(string fileName, byte[] content) + { + var path = Path.Combine(_tempRoot, fileName); + File.WriteAllBytes(path, content); + return path; + } + + private string WriteZip(string archiveName, string memberName, byte[] content) + { + var path = Path.Combine(_tempRoot, archiveName); + + using var archive = ZipFile.Open(path, ZipArchiveMode.Create); + var entry = archive.CreateEntry(memberName); + using var stream = entry.Open(); + stream.Write(content, 0, content.Length); + + return path; + } + + private string WriteMultiZip(string archiveName, params (string Name, byte[] Content)[] members) + { + var path = Path.Combine(_tempRoot, archiveName); + + using var archive = ZipFile.Open(path, ZipArchiveMode.Create); + + foreach (var member in members) + { + var entry = archive.CreateEntry(member.Name); + using var stream = entry.Open(); + stream.Write(member.Content, 0, member.Content.Length); + } + + return path; + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/007_add_nointro_catalog.cs b/src/NzbDrone.Core/Datastore/Migration/007_add_nointro_catalog.cs new file mode 100644 index 00000000..f7f494c8 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/007_add_nointro_catalog.cs @@ -0,0 +1,70 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(7)] + public class add_nointro_catalog : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Create.TableForModel("NoIntroCatalogSources") + .WithColumn("Name").AsString().NotNullable() + .WithColumn("SourceUrl").AsString().NotNullable() + .WithColumn("PinnedRevision").AsString().Nullable() + .WithColumn("CatalogVersion").AsString().Nullable() + .WithColumn("LastSuccessfulSync").AsDateTime().Nullable() + .WithColumn("LastAttemptedSync").AsDateTime().Nullable() + .WithColumn("LastSyncError").AsString().Nullable(); + + Create.TableForModel("NoIntroCatalogEntries") + .WithColumn("CatalogSourceId").AsInt32().NotNullable().Indexed() + .WithColumn("SystemKey").AsString().NotNullable().Indexed() + .WithColumn("CanonicalName").AsString().NotNullable() + .WithColumn("CanonicalFileName").AsString().NotNullable() + .WithColumn("PlatformFamily").AsInt32().NotNullable(); + + Create.TableForModel("NoIntroCatalogHashes") + .WithColumn("CatalogEntryId").AsInt32().NotNullable().Indexed() + .WithColumn("HashType").AsString().NotNullable() + .WithColumn("HashValue").AsString().NotNullable().Indexed() + .WithColumn("IsPrimary").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("IsBadDump").AsBoolean().NotNullable().WithDefaultValue(false); + + Create.TableForModel("NoIntroSystemMappings") + .WithColumn("SystemKey").AsString().NotNullable().Indexed() + .WithColumn("DisplayName").AsString().NotNullable() + .WithColumn("PlatformFamily").AsInt32().NotNullable() + .WithColumn("RootRelativePathPattern").AsString().Nullable(); + + Create.TableForModel("NoIntroVerificationSets") + .WithColumn("CatalogSourceId").AsInt32().NotNullable().Indexed() + .WithColumn("SystemKey").AsString().NotNullable().Indexed() + .WithColumn("RootPath").AsString().NotNullable() + .WithColumn("Enabled").AsBoolean().NotNullable().WithDefaultValue(true); + + Create.TableForModel("NoIntroVerificationSnapshots") + .WithColumn("VerificationSetId").AsInt32().NotNullable().Indexed() + .WithColumn("CatalogSourceId").AsInt32().NotNullable().Indexed() + .WithColumn("CatalogRevision").AsString().Nullable() + .WithColumn("StartedAt").AsDateTime().NotNullable() + .WithColumn("CompletedAt").AsDateTime().Nullable(); + + Create.TableForModel("NoIntroVerificationResults") + .WithColumn("SnapshotId").AsInt32().NotNullable().Indexed() + .WithColumn("VerificationSetId").AsInt32().NotNullable().Indexed() + .WithColumn("CatalogEntryId").AsInt32().Nullable().Indexed() + .WithColumn("RelativePath").AsString().NotNullable() + .WithColumn("ArchivePath").AsString().Nullable() + .WithColumn("MemberPath").AsString().Nullable() + .WithColumn("ActualFileName").AsString().NotNullable() + .WithColumn("ExpectedFileName").AsString().Nullable() + .WithColumn("HashType").AsString().Nullable() + .WithColumn("HashValue").AsString().Nullable().Indexed() + .WithColumn("VerificationStatus").AsInt32().NotNullable() + .WithColumn("IsDuplicate").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("IsMissing").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("VerifiedAt").AsDateTime().NotNullable(); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/008_add_rename_profile_to_naming_config.cs b/src/NzbDrone.Core/Datastore/Migration/008_add_rename_profile_to_naming_config.cs new file mode 100644 index 00000000..ee9de96b --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/008_add_rename_profile_to_naming_config.cs @@ -0,0 +1,19 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; +using NzbDrone.Core.Organizer; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(8)] + public class add_rename_profile_to_naming_config : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("NamingConfig") + .AddColumn("RenameProfile") + .AsInt32() + .NotNullable() + .WithDefaultValue((int)RenameProfile.Gamarr); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/009_add_parent_canonical_name_to_nointro_catalog_entry.cs b/src/NzbDrone.Core/Datastore/Migration/009_add_parent_canonical_name_to_nointro_catalog_entry.cs new file mode 100644 index 00000000..d99e0e8f --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/009_add_parent_canonical_name_to_nointro_catalog_entry.cs @@ -0,0 +1,17 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(9)] + public class add_parent_canonical_name_to_nointro_catalog_entry : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("NoIntroCatalogEntries") + .AddColumn("ParentCanonicalName") + .AsString() + .Nullable(); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/TableMapping.cs b/src/NzbDrone.Core/Datastore/TableMapping.cs index 306f3c9b..aec6ed26 100644 --- a/src/NzbDrone.Core/Datastore/TableMapping.cs +++ b/src/NzbDrone.Core/Datastore/TableMapping.cs @@ -40,6 +40,7 @@ using NzbDrone.Core.Qualities; using NzbDrone.Core.RemotePathMappings; using NzbDrone.Core.RootFolders; +using NzbDrone.Core.RomCatalog; using NzbDrone.Core.Tags; using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Update.History; @@ -153,6 +154,13 @@ public static void Map() .Ignore(e => e.RemoteGame); Mapper.Entity("RemotePathMappings").RegisterModel(); + Mapper.Entity("NoIntroCatalogSources").RegisterModel(); + Mapper.Entity("NoIntroCatalogEntries").RegisterModel(); + Mapper.Entity("NoIntroCatalogHashes").RegisterModel(); + Mapper.Entity("NoIntroSystemMappings").RegisterModel(); + Mapper.Entity("NoIntroVerificationSets").RegisterModel(); + Mapper.Entity("NoIntroVerificationSnapshots").RegisterModel(); + Mapper.Entity("NoIntroVerificationResults").RegisterModel(); Mapper.Entity("Tags").RegisterModel(); Mapper.Entity("ReleaseProfiles").RegisterModel(); diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 4ec2038e..c1d544b8 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -1686,6 +1686,11 @@ "RenameFiles": "Rename Files", "RenameGames": "Rename Games", "RenameGamesHelpText": "{appName} will use the existing file name if renaming is disabled", + "RenameProfile": "Rename Profile", + "RenameProfileGamarr": "Gamarr", + "RenameProfileHelpText": "Choose how verified ROM filenames are evaluated. Normal Gamarr naming remains the default.", + "RenameProfileNoIntroCanonical": "No-Intro Canonical", + "RenameProfileNoIntroPreserveById": "No-Intro Preserve By-ID", "Renamed": "Renamed", "Reorder": "Reorder", "Repack": "Repack", diff --git a/src/NzbDrone.Core/Organizer/NamingConfig.cs b/src/NzbDrone.Core/Organizer/NamingConfig.cs index e124bf94..0352490a 100644 --- a/src/NzbDrone.Core/Organizer/NamingConfig.cs +++ b/src/NzbDrone.Core/Organizer/NamingConfig.cs @@ -2,11 +2,19 @@ namespace NzbDrone.Core.Organizer { + public enum RenameProfile + { + Gamarr = 0, + NoIntroPreserveById = 1, + NoIntroCanonical = 2 + } + public class NamingConfig : ModelBase { public static NamingConfig Default => new NamingConfig { RenameGames = false, + RenameProfile = RenameProfile.Gamarr, ReplaceIllegalCharacters = true, ColonReplacementFormat = ColonReplacementFormat.Smart, GameFolderFormat = "{Game Title} ({Release Year})", @@ -14,6 +22,7 @@ public class NamingConfig : ModelBase }; public bool RenameGames { get; set; } + public RenameProfile RenameProfile { get; set; } public bool ReplaceIllegalCharacters { get; set; } public ColonReplacementFormat ColonReplacementFormat { get; set; } public string StandardGameFormat { get; set; } diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroCatalogDocumentClient.cs b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogDocumentClient.cs new file mode 100644 index 00000000..6f41579d --- /dev/null +++ b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogDocumentClient.cs @@ -0,0 +1,24 @@ +using NzbDrone.Common.Http; + +namespace NzbDrone.Core.RomCatalog +{ + public interface INoIntroCatalogDocumentClient + { + string Fetch(string sourceUrl); + } + + public class NoIntroCatalogDocumentClient : INoIntroCatalogDocumentClient + { + private readonly IHttpClient _httpClient; + + public NoIntroCatalogDocumentClient(IHttpClient httpClient) + { + _httpClient = httpClient; + } + + public string Fetch(string sourceUrl) + { + return _httpClient.Get(new HttpRequest(sourceUrl)).Content; + } + } +} diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroCatalogEntry.cs b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogEntry.cs new file mode 100644 index 00000000..74a1aeed --- /dev/null +++ b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogEntry.cs @@ -0,0 +1,15 @@ +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Games; + +namespace NzbDrone.Core.RomCatalog +{ + public class NoIntroCatalogEntry : ModelBase + { + public int CatalogSourceId { get; set; } + public string SystemKey { get; set; } + public string CanonicalName { get; set; } + public string ParentCanonicalName { get; set; } + public string CanonicalFileName { get; set; } + public PlatformFamily PlatformFamily { get; set; } + } +} diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroCatalogEntryRepository.cs b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogEntryRepository.cs new file mode 100644 index 00000000..1ca510b3 --- /dev/null +++ b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogEntryRepository.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Messaging.Events; + +namespace NzbDrone.Core.RomCatalog +{ + public interface INoIntroCatalogEntryRepository : IBasicRepository + { + List GetBySourceId(int catalogSourceId); + void DeleteBySourceId(int catalogSourceId); + } + + public class NoIntroCatalogEntryRepository : BasicRepository, INoIntroCatalogEntryRepository + { + public NoIntroCatalogEntryRepository(IMainDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + + public List GetBySourceId(int catalogSourceId) + { + return Query(x => x.CatalogSourceId == catalogSourceId); + } + + public void DeleteBySourceId(int catalogSourceId) + { + Delete(x => x.CatalogSourceId == catalogSourceId); + } + } +} diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroCatalogHash.cs b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogHash.cs new file mode 100644 index 00000000..0c556df2 --- /dev/null +++ b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogHash.cs @@ -0,0 +1,13 @@ +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.RomCatalog +{ + public class NoIntroCatalogHash : ModelBase + { + public int CatalogEntryId { get; set; } + public string HashType { get; set; } + public string HashValue { get; set; } + public bool IsPrimary { get; set; } + public bool IsBadDump { get; set; } + } +} diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroCatalogHashRepository.cs b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogHashRepository.cs new file mode 100644 index 00000000..228298ca --- /dev/null +++ b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogHashRepository.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Messaging.Events; + +namespace NzbDrone.Core.RomCatalog +{ + public interface INoIntroCatalogHashRepository : IBasicRepository + { + List GetByEntryIds(List entryIds); + void DeleteByEntryIds(List entryIds); + } + + public class NoIntroCatalogHashRepository : BasicRepository, INoIntroCatalogHashRepository + { + public NoIntroCatalogHashRepository(IMainDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + + public List GetByEntryIds(List entryIds) + { + return entryIds.Count == 0 ? new List() : Query(x => entryIds.Contains(x.CatalogEntryId)); + } + + public void DeleteByEntryIds(List entryIds) + { + if (entryIds.Count == 0) + { + return; + } + + Delete(x => entryIds.Contains(x.CatalogEntryId)); + } + } +} diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroCatalogPlan.cs b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogPlan.cs new file mode 100644 index 00000000..82d3af6e --- /dev/null +++ b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogPlan.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.RomCatalog +{ + public class NoIntroCatalogPlan + { + public List Games { get; set; } = new List(); + public List StandaloneGames { get; set; } = new List(); + } + + public class NoIntroCatalogGamePlan + { + public string SystemKey { get; set; } + public string GameTitle { get; set; } + public List RegionLanguageComponents { get; set; } = new List(); + public List DownloadPlayComponents { get; set; } = new List(); + } + + public class NoIntroCatalogStandalonePlan + { + public string Title { get; set; } + public NoIntroRomComponentType ComponentType { get; set; } + } + + public class NoIntroCatalogComponentSlot + { + public string SlotLabel { get; set; } + public string CanonicalName { get; set; } + public NoIntroRomComponentType ComponentType { get; set; } + } +} diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSnapshot.cs b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSnapshot.cs new file mode 100644 index 00000000..19957d7d --- /dev/null +++ b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSnapshot.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.RomCatalog +{ + public class NoIntroCatalogSnapshot + { + public string CatalogVersion { get; set; } + public string SystemKey { get; set; } + public List Entries { get; set; } = new List(); + } + + public class NoIntroCatalogSnapshotEntry + { + public string CanonicalName { get; set; } + public string CanonicalFileName { get; set; } + public List Hashes { get; set; } = new List(); + } + + public class NoIntroCatalogSnapshotHash + { + public string HashType { get; set; } + public string HashValue { get; set; } + public bool IsPrimary { get; set; } + public bool IsBadDump { get; set; } + } +} diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSnapshotParser.cs b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSnapshotParser.cs new file mode 100644 index 00000000..12e814e9 --- /dev/null +++ b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSnapshotParser.cs @@ -0,0 +1,84 @@ +using System; +using System.Linq; +using System.Xml.Linq; + +namespace NzbDrone.Core.RomCatalog +{ + public interface INoIntroCatalogSnapshotParser + { + NoIntroCatalogSnapshot Parse(string content); + } + + public class NoIntroCatalogSnapshotParser : INoIntroCatalogSnapshotParser + { + public NoIntroCatalogSnapshot Parse(string content) + { + var document = XDocument.Parse(content); + var root = document.Element("datafile") ?? throw new InvalidOperationException("Unsupported No-Intro catalog format"); + var header = root.Element("header"); + + var systemName = header?.Element("name")?.Value ?? "unknown"; + var version = header?.Element("version")?.Value; + + var snapshot = new NoIntroCatalogSnapshot + { + CatalogVersion = version, + SystemKey = NormalizeSystemKey(systemName) + }; + + foreach (var game in root.Elements("game")) + { + var canonicalName = game.Attribute("name")?.Value; + + if (string.IsNullOrWhiteSpace(canonicalName)) + { + continue; + } + + var entry = new NoIntroCatalogSnapshotEntry + { + CanonicalName = canonicalName, + CanonicalFileName = game.Elements("rom").FirstOrDefault()?.Attribute("name")?.Value ?? canonicalName + }; + + foreach (var rom in game.Elements("rom")) + { + AddHash(entry, "crc32", rom.Attribute("crc")?.Value, true, IsBadDump(rom)); + AddHash(entry, "md5", rom.Attribute("md5")?.Value, false, IsBadDump(rom)); + AddHash(entry, "sha1", rom.Attribute("sha1")?.Value, false, IsBadDump(rom)); + } + + snapshot.Entries.Add(entry); + } + + return snapshot; + } + + private static void AddHash(NoIntroCatalogSnapshotEntry entry, string hashType, string hashValue, bool isPrimary, bool isBadDump) + { + if (string.IsNullOrWhiteSpace(hashValue)) + { + return; + } + + entry.Hashes.Add(new NoIntroCatalogSnapshotHash + { + HashType = hashType, + HashValue = hashValue, + IsPrimary = isPrimary, + IsBadDump = isBadDump + }); + } + + private static bool IsBadDump(XElement rom) + { + var status = rom.Attribute("status")?.Value; + return status != null && !status.Equals("verified", StringComparison.OrdinalIgnoreCase) && !status.Equals("good", StringComparison.OrdinalIgnoreCase); + } + + private static string NormalizeSystemKey(string value) + { + return string.Concat(value.Trim().ToLowerInvariant().Select(c => char.IsLetterOrDigit(c) ? c : '-')).Trim('-'); + } + } +} diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSource.cs b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSource.cs new file mode 100644 index 00000000..799afb28 --- /dev/null +++ b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSource.cs @@ -0,0 +1,16 @@ +using System; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.RomCatalog +{ + public class NoIntroCatalogSource : ModelBase + { + public string Name { get; set; } + public string SourceUrl { get; set; } + public string PinnedRevision { get; set; } + public string CatalogVersion { get; set; } + public DateTime? LastSuccessfulSync { get; set; } + public DateTime? LastAttemptedSync { get; set; } + public string LastSyncError { get; set; } + } +} diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSourceRepository.cs b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSourceRepository.cs new file mode 100644 index 00000000..4e0abd6a --- /dev/null +++ b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSourceRepository.cs @@ -0,0 +1,17 @@ +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Messaging.Events; + +namespace NzbDrone.Core.RomCatalog +{ + public interface INoIntroCatalogSourceRepository : IBasicRepository + { + } + + public class NoIntroCatalogSourceRepository : BasicRepository, INoIntroCatalogSourceRepository + { + public NoIntroCatalogSourceRepository(IMainDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + } +} diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSyncCommand.cs b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSyncCommand.cs new file mode 100644 index 00000000..115dbf58 --- /dev/null +++ b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSyncCommand.cs @@ -0,0 +1,21 @@ +using NzbDrone.Core.Messaging.Commands; + +namespace NzbDrone.Core.RomCatalog +{ + public class NoIntroCatalogSyncCommand : Command + { + public int? CatalogSourceId { get; set; } + + public NoIntroCatalogSyncCommand() + { + } + + public NoIntroCatalogSyncCommand(int? catalogSourceId) + { + CatalogSourceId = catalogSourceId; + } + + public override bool SendUpdatesToClient => true; + public override bool IsTypeExclusive => true; + } +} diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSyncService.cs b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSyncService.cs new file mode 100644 index 00000000..41653fae --- /dev/null +++ b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSyncService.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NLog; +using NzbDrone.Core.Messaging.Commands; + +namespace NzbDrone.Core.RomCatalog +{ + public interface INoIntroCatalogSyncService + { + void Sync(int? catalogSourceId = null); + } + + public class NoIntroCatalogSyncService : IExecute, INoIntroCatalogSyncService + { + private readonly NoIntroCatalogSourceRepository _sourceRepository; + private readonly NoIntroCatalogEntryRepository _entryRepository; + private readonly NoIntroCatalogHashRepository _hashRepository; + private readonly INoIntroCatalogDocumentClient _documentClient; + private readonly NoIntroCatalogSnapshotParser _snapshotParser; + private readonly Logger _logger; + + public NoIntroCatalogSyncService( + NoIntroCatalogSourceRepository sourceRepository, + NoIntroCatalogEntryRepository entryRepository, + NoIntroCatalogHashRepository hashRepository, + INoIntroCatalogDocumentClient documentClient, + NoIntroCatalogSnapshotParser snapshotParser, + Logger logger) + { + _sourceRepository = sourceRepository; + _entryRepository = entryRepository; + _hashRepository = hashRepository; + _documentClient = documentClient; + _snapshotParser = snapshotParser; + _logger = logger; + } + + public void Execute(NoIntroCatalogSyncCommand message) + { + Sync(message.CatalogSourceId); + } + + public void Sync(int? catalogSourceId = null) + { + var sources = catalogSourceId.HasValue + ? new List { _sourceRepository.Get(catalogSourceId.Value) } + : _sourceRepository.All().ToList(); + + foreach (var source in sources) + { + SyncSource(source); + } + } + + private void SyncSource(NoIntroCatalogSource source) + { + source.LastAttemptedSync = DateTime.UtcNow; + _sourceRepository.Update(source); + + try + { + var snapshot = _snapshotParser.Parse(_documentClient.Fetch(source.SourceUrl)); + ReplaceCatalog(source, snapshot); + + source.CatalogVersion = snapshot.CatalogVersion; + source.LastSuccessfulSync = DateTime.UtcNow; + source.LastSyncError = null; + _sourceRepository.Update(source); + } + catch (Exception ex) + { + source.LastSyncError = ex.Message; + _sourceRepository.Update(source); + _logger.Warn(ex, "Failed syncing No-Intro catalog source {0}", source.SourceUrl); + throw; + } + } + + private void ReplaceCatalog(NoIntroCatalogSource source, NoIntroCatalogSnapshot snapshot) + { + var existingEntries = _entryRepository.GetBySourceId(source.Id); + var existingEntryIds = existingEntries.Select(x => x.Id).ToList(); + + if (existingEntryIds.Count > 0) + { + _hashRepository.DeleteByEntryIds(existingEntryIds); + _entryRepository.DeleteBySourceId(source.Id); + } + + var entries = snapshot.Entries.Select(entry => new NoIntroCatalogEntry + { + CatalogSourceId = source.Id, + SystemKey = snapshot.SystemKey, + CanonicalName = entry.CanonicalName, + CanonicalFileName = entry.CanonicalFileName + }).ToList(); + + _entryRepository.InsertMany(entries); + + var hashes = new List(); + + for (var i = 0; i < entries.Count; i++) + { + hashes.AddRange(snapshot.Entries[i].Hashes.Select(hash => new NoIntroCatalogHash + { + CatalogEntryId = entries[i].Id, + HashType = hash.HashType, + HashValue = hash.HashValue, + IsPrimary = hash.IsPrimary, + IsBadDump = hash.IsBadDump + })); + } + + if (hashes.Count > 0) + { + _hashRepository.InsertMany(hashes); + } + } + } +} diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroComponentClassification.cs b/src/NzbDrone.Core/RomCatalog/NoIntroComponentClassification.cs new file mode 100644 index 00000000..83d08735 --- /dev/null +++ b/src/NzbDrone.Core/RomCatalog/NoIntroComponentClassification.cs @@ -0,0 +1,8 @@ +namespace NzbDrone.Core.RomCatalog +{ + public class NoIntroComponentClassification + { + public NoIntroRomComponentType ComponentType { get; set; } + public bool IsFallback { get; set; } + } +} diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroComponentClassifier.cs b/src/NzbDrone.Core/RomCatalog/NoIntroComponentClassifier.cs new file mode 100644 index 00000000..46283715 --- /dev/null +++ b/src/NzbDrone.Core/RomCatalog/NoIntroComponentClassifier.cs @@ -0,0 +1,244 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace NzbDrone.Core.RomCatalog +{ + public interface INoIntroComponentClassifier + { + NoIntroComponentClassification Classify(string relativePath, string fileName); + NoIntroCatalogPlan BuildCatalogPlan(IEnumerable entries); + } + + public class NoIntroComponentClassifier : INoIntroComponentClassifier + { + public NoIntroComponentClassification Classify(string relativePath, string fileName) + { + var path = relativePath ?? string.Empty; + var name = fileName ?? string.Empty; + var combined = $"{path}/{name}"; + + if (Contains(combined, "[BIOS]") || Contains(combined, "/BIOS/") || EndsWithFolder(path, "BIOS")) + { + return Exact(NoIntroRomComponentType.Bios); + } + + if (Contains(combined, "Romhack") || Contains(combined, "/Romhacks/")) + { + return Exact(NoIntroRomComponentType.RomhackOrUnverified); + } + + if (Contains(path, "GBA (e-Reader)")) + { + return Exact(NoIntroRomComponentType.EReaderCards); + } + + if (Contains(path, "GBA (Multiboot)") || Contains(path, "Download Play")) + { + return Exact(NoIntroRomComponentType.Multiboot); + } + + if (Contains(path, "GBA (Video)") || Contains(path, "GBA (Play-Yan)") || Contains(path, "DSvision SD cards")) + { + return Exact(NoIntroRomComponentType.Video); + } + + if (Contains(path, "GBA") || Contains(path, "GBA (by-id)") || Contains(path, "/zip/") || Contains(path, "/nds/") || EndsWithFolder(path, "zip") || EndsWithFolder(path, "nds")) + { + return Exact(NoIntroRomComponentType.RetailRom); + } + + return Fallback(NoIntroRomComponentType.RomhackOrUnverified); + } + + public NoIntroCatalogPlan BuildCatalogPlan(IEnumerable entries) + { + var plan = new NoIntroCatalogPlan(); + + foreach (var entry in entries ?? Enumerable.Empty()) + { + AddEntry(plan, entry); + } + + return plan; + } + + private static void AddEntry(NoIntroCatalogPlan plan, NoIntroCatalogEntry entry) + { + var canonicalName = entry.CanonicalName ?? string.Empty; + + if (IsDownloadPlay(canonicalName)) + { + AddDownloadPlay(plan, entry, canonicalName); + return; + } + + if (IsStandaloneProduct(canonicalName)) + { + plan.StandaloneGames.Add(new NoIntroCatalogStandalonePlan + { + Title = canonicalName, + ComponentType = ClassifyStandaloneProduct(canonicalName) + }); + + return; + } + + var region = TryParseRegion(canonicalName); + + if (region == null) + { + plan.StandaloneGames.Add(new NoIntroCatalogStandalonePlan + { + Title = canonicalName, + ComponentType = NoIntroRomComponentType.RetailRom + }); + + return; + } + + GetOrAddGame(plan, entry.SystemKey, region.GameTitle).RegionLanguageComponents.Add(new NoIntroCatalogComponentSlot + { + SlotLabel = region.SlotLabel, + CanonicalName = canonicalName, + ComponentType = NoIntroRomComponentType.RetailRom + }); + } + + private static void AddDownloadPlay(NoIntroCatalogPlan plan, NoIntroCatalogEntry entry, string canonicalName) + { + if (string.IsNullOrWhiteSpace(entry.ParentCanonicalName)) + { + plan.StandaloneGames.Add(new NoIntroCatalogStandalonePlan + { + Title = canonicalName, + ComponentType = NoIntroRomComponentType.Multiboot + }); + + return; + } + + GetOrAddGame(plan, entry.SystemKey, entry.ParentCanonicalName).DownloadPlayComponents.Add(new NoIntroCatalogComponentSlot + { + SlotLabel = "Download Play", + CanonicalName = canonicalName, + ComponentType = NoIntroRomComponentType.Multiboot + }); + } + + private static NoIntroCatalogGamePlan GetOrAddGame(NoIntroCatalogPlan plan, string systemKey, string gameTitle) + { + var game = plan.Games.SingleOrDefault(x => + x.SystemKey.Equals(systemKey, StringComparison.OrdinalIgnoreCase) && + x.GameTitle.Equals(gameTitle, StringComparison.Ordinal)); + + if (game != null) + { + return game; + } + + game = new NoIntroCatalogGamePlan + { + SystemKey = systemKey, + GameTitle = gameTitle + }; + + plan.Games.Add(game); + return game; + } + + private static RegionRelease TryParseRegion(string canonicalName) + { + var closeIndex = canonicalName.LastIndexOf(')'); + var openIndex = canonicalName.LastIndexOf('('); + + if (openIndex < 1 || closeIndex != canonicalName.Length - 1 || openIndex >= closeIndex) + { + return null; + } + + var label = canonicalName.Substring(openIndex + 1, closeIndex - openIndex - 1); + var title = canonicalName.Substring(0, openIndex).TrimEnd(); + + if (string.IsNullOrWhiteSpace(label) || string.IsNullOrWhiteSpace(title)) + { + return null; + } + + return new RegionRelease + { + GameTitle = title, + SlotLabel = label + }; + } + + private static bool IsDownloadPlay(string canonicalName) + { + return Contains(canonicalName, "Download Play"); + } + + private static bool IsStandaloneProduct(string canonicalName) + { + return Contains(canonicalName, "Game Boy Advance Video") || + Contains(canonicalName, "Play-Yan") || + Contains(canonicalName, "DSvision") || + Contains(canonicalName, "[BIOS]") || + Contains(canonicalName, "(BIOS)") || + Contains(canonicalName, " Demo") || + Contains(canonicalName, " Prototype") || + Contains(canonicalName, "(Kiosk)") || + Contains(canonicalName, "Not for Resale"); + } + + private static NoIntroRomComponentType ClassifyStandaloneProduct(string canonicalName) + { + if (Contains(canonicalName, "[BIOS]") || Contains(canonicalName, "(BIOS)")) + { + return NoIntroRomComponentType.Bios; + } + + if (Contains(canonicalName, "Game Boy Advance Video") || Contains(canonicalName, "Play-Yan") || Contains(canonicalName, "DSvision")) + { + return NoIntroRomComponentType.Video; + } + + return NoIntroRomComponentType.RomhackOrUnverified; + } + + private static NoIntroComponentClassification Exact(NoIntroRomComponentType componentType) + { + return new NoIntroComponentClassification + { + ComponentType = componentType, + IsFallback = false + }; + } + + private static NoIntroComponentClassification Fallback(NoIntroRomComponentType componentType) + { + return new NoIntroComponentClassification + { + ComponentType = componentType, + IsFallback = true + }; + } + + private static bool Contains(string value, string pattern) + { + return value.Contains(pattern, StringComparison.OrdinalIgnoreCase); + } + + private static bool EndsWithFolder(string path, string folderName) + { + return path.EndsWith($"/{folderName}", StringComparison.OrdinalIgnoreCase) || + path.EndsWith($"\\{folderName}", StringComparison.OrdinalIgnoreCase) || + path.Equals(folderName, StringComparison.OrdinalIgnoreCase); + } + + private class RegionRelease + { + public string GameTitle { get; set; } + public string SlotLabel { get; set; } + } + } +} diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroRenameProfileEvaluator.cs b/src/NzbDrone.Core/RomCatalog/NoIntroRenameProfileEvaluator.cs new file mode 100644 index 00000000..d1fdc7c2 --- /dev/null +++ b/src/NzbDrone.Core/RomCatalog/NoIntroRenameProfileEvaluator.cs @@ -0,0 +1,40 @@ +using System; +using NzbDrone.Core.Organizer; + +namespace NzbDrone.Core.RomCatalog +{ + public static class NoIntroRenameProfileEvaluator + { + public static string GetExpectedFileName(NoIntroCatalogEntry catalogEntry, string actualFileName, RenameProfile renameProfile) + { + if (renameProfile == RenameProfile.NoIntroPreserveById && IsByIdFileName(actualFileName, catalogEntry.CanonicalFileName)) + { + return actualFileName; + } + + return catalogEntry.CanonicalFileName; + } + + public static bool MatchesProfile(NoIntroCatalogEntry catalogEntry, string actualFileName, RenameProfile renameProfile) + { + if (actualFileName.Equals(catalogEntry.CanonicalFileName, StringComparison.Ordinal)) + { + return true; + } + + return renameProfile == RenameProfile.NoIntroPreserveById && + IsByIdFileName(actualFileName, catalogEntry.CanonicalFileName); + } + + private static bool IsByIdFileName(string actualFileName, string canonicalFileName) + { + if (string.IsNullOrWhiteSpace(actualFileName) || string.IsNullOrWhiteSpace(canonicalFileName)) + { + return false; + } + + var prefix = $" - {canonicalFileName}"; + return actualFileName.EndsWith(prefix, StringComparison.Ordinal); + } + } +} diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroRomComponentType.cs b/src/NzbDrone.Core/RomCatalog/NoIntroRomComponentType.cs new file mode 100644 index 00000000..9aa303ac --- /dev/null +++ b/src/NzbDrone.Core/RomCatalog/NoIntroRomComponentType.cs @@ -0,0 +1,12 @@ +namespace NzbDrone.Core.RomCatalog +{ + public enum NoIntroRomComponentType + { + RetailRom = 0, + EReaderCards = 1, + Multiboot = 2, + Video = 3, + Bios = 4, + RomhackOrUnverified = 5 + } +} diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroRomHasher.cs b/src/NzbDrone.Core/RomCatalog/NoIntroRomHasher.cs new file mode 100644 index 00000000..7ab02299 --- /dev/null +++ b/src/NzbDrone.Core/RomCatalog/NoIntroRomHasher.cs @@ -0,0 +1,54 @@ +using System; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using ICSharpCode.SharpZipLib.Checksum; + +namespace NzbDrone.Core.RomCatalog +{ + public static class NoIntroRomHasher + { + public static NoIntroHashTriplet Compute(Stream stream) + { + using var md5 = MD5.Create(); + using var sha1 = SHA1.Create(); + var crc = new Crc32(); + var buffer = new byte[8192]; + + int bytesRead; + + while ((bytesRead = stream.Read(buffer, 0, buffer.Length)) > 0) + { + md5.TransformBlock(buffer, 0, bytesRead, null, 0); + sha1.TransformBlock(buffer, 0, bytesRead, null, 0); + crc.Update(buffer.Take(bytesRead).ToArray()); + } + + md5.TransformFinalBlock(Array.Empty(), 0, 0); + sha1.TransformFinalBlock(Array.Empty(), 0, 0); + + return new NoIntroHashTriplet + { + Md5 = ToHex(md5.Hash), + Sha1 = ToHex(sha1.Hash), + Crc32 = crc.Value.ToString("X8"), + PreferredHashType = "sha1", + PreferredHashValue = ToHex(sha1.Hash) + }; + } + + private static string ToHex(byte[] bytes) + { + return BitConverter.ToString(bytes).Replace("-", string.Empty); + } + } + + public class NoIntroHashTriplet + { + public string Md5 { get; set; } + public string Sha1 { get; set; } + public string Crc32 { get; set; } + public string PreferredHashType { get; set; } + public string PreferredHashValue { get; set; } + } +} diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroSystemMapping.cs b/src/NzbDrone.Core/RomCatalog/NoIntroSystemMapping.cs new file mode 100644 index 00000000..9de93df5 --- /dev/null +++ b/src/NzbDrone.Core/RomCatalog/NoIntroSystemMapping.cs @@ -0,0 +1,13 @@ +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Games; + +namespace NzbDrone.Core.RomCatalog +{ + public class NoIntroSystemMapping : ModelBase + { + public string SystemKey { get; set; } + public string DisplayName { get; set; } + public PlatformFamily PlatformFamily { get; set; } + public string RootRelativePathPattern { get; set; } + } +} diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroVerificationResult.cs b/src/NzbDrone.Core/RomCatalog/NoIntroVerificationResult.cs new file mode 100644 index 00000000..02c7e070 --- /dev/null +++ b/src/NzbDrone.Core/RomCatalog/NoIntroVerificationResult.cs @@ -0,0 +1,23 @@ +using System; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.RomCatalog +{ + public class NoIntroVerificationResult : ModelBase + { + public int SnapshotId { get; set; } + public int VerificationSetId { get; set; } + public int? CatalogEntryId { get; set; } + public string RelativePath { get; set; } + public string ArchivePath { get; set; } + public string MemberPath { get; set; } + public string ActualFileName { get; set; } + public string ExpectedFileName { get; set; } + public string HashType { get; set; } + public string HashValue { get; set; } + public NoIntroVerificationStatus VerificationStatus { get; set; } + public bool IsDuplicate { get; set; } + public bool IsMissing { get; set; } + public DateTime VerifiedAt { get; set; } + } +} diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroVerificationResultRepository.cs b/src/NzbDrone.Core/RomCatalog/NoIntroVerificationResultRepository.cs new file mode 100644 index 00000000..57957c24 --- /dev/null +++ b/src/NzbDrone.Core/RomCatalog/NoIntroVerificationResultRepository.cs @@ -0,0 +1,17 @@ +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Messaging.Events; + +namespace NzbDrone.Core.RomCatalog +{ + public interface INoIntroVerificationResultRepository : IBasicRepository + { + } + + public class NoIntroVerificationResultRepository : BasicRepository, INoIntroVerificationResultRepository + { + public NoIntroVerificationResultRepository(IMainDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + } +} diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroVerificationService.cs b/src/NzbDrone.Core/RomCatalog/NoIntroVerificationService.cs new file mode 100644 index 00000000..c260fa9c --- /dev/null +++ b/src/NzbDrone.Core/RomCatalog/NoIntroVerificationService.cs @@ -0,0 +1,266 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using ICSharpCode.SharpZipLib.Zip; +using NzbDrone.Core.Organizer; + +namespace NzbDrone.Core.RomCatalog +{ + public interface INoIntroVerificationService + { + NoIntroVerificationSnapshot Verify(int verificationSetId, IEnumerable filePaths); + } + + public class NoIntroVerificationService : INoIntroVerificationService + { + private readonly NoIntroVerificationSetRepository _verificationSetRepository; + private readonly NoIntroVerificationSnapshotRepository _snapshotRepository; + private readonly NoIntroVerificationResultRepository _resultRepository; + private readonly NoIntroCatalogEntryRepository _entryRepository; + private readonly NoIntroCatalogHashRepository _hashRepository; + private readonly INamingConfigService _namingConfigService; + + public NoIntroVerificationService( + NoIntroVerificationSetRepository verificationSetRepository, + NoIntroVerificationSnapshotRepository snapshotRepository, + NoIntroVerificationResultRepository resultRepository, + NoIntroCatalogEntryRepository entryRepository, + NoIntroCatalogHashRepository hashRepository, + INamingConfigService namingConfigService) + { + _verificationSetRepository = verificationSetRepository; + _snapshotRepository = snapshotRepository; + _resultRepository = resultRepository; + _entryRepository = entryRepository; + _hashRepository = hashRepository; + _namingConfigService = namingConfigService; + } + + public NoIntroVerificationSnapshot Verify(int verificationSetId, IEnumerable filePaths) + { + var verificationSet = _verificationSetRepository.Get(verificationSetId); + var snapshot = _snapshotRepository.Insert(new NoIntroVerificationSnapshot + { + VerificationSetId = verificationSet.Id, + CatalogSourceId = verificationSet.CatalogSourceId, + CatalogRevision = string.Empty, + StartedAt = DateTime.UtcNow + }); + + var entries = _entryRepository.GetBySourceId(verificationSet.CatalogSourceId); + var entryIds = entries.Select(x => x.Id).ToList(); + var hashes = _hashRepository.GetByEntryIds(entryIds); + var renameProfile = _namingConfigService.GetConfig().RenameProfile; + var hashMap = hashes.GroupBy(x => $"{x.HashType}:{x.HashValue}".ToLowerInvariant()) + .ToDictionary(x => x.Key, x => x.First()); + var entryMap = entries.ToDictionary(x => x.Id); + + var results = filePaths.Select(path => VerifyPath(snapshot, verificationSet, path, hashMap, entryMap, renameProfile)).ToList(); + MarkDuplicates(results); + results.AddRange(BuildMissingResults(snapshot, verificationSet, entries, results)); + + _resultRepository.InsertMany(results); + + snapshot.CompletedAt = DateTime.UtcNow; + return _snapshotRepository.Update(snapshot); + } + + private static NoIntroVerificationResult VerifyPath( + NoIntroVerificationSnapshot snapshot, + NoIntroVerificationSet verificationSet, + string path, + Dictionary hashMap, + Dictionary entryMap, + RenameProfile renameProfile) + { + return Path.GetExtension(path).Equals(".zip", StringComparison.OrdinalIgnoreCase) + ? VerifyArchive(snapshot, verificationSet, path, hashMap, entryMap, renameProfile) + : VerifyFile(snapshot, verificationSet, path, hashMap, entryMap, renameProfile); + } + + private static NoIntroVerificationResult VerifyFile( + NoIntroVerificationSnapshot snapshot, + NoIntroVerificationSet verificationSet, + string path, + Dictionary hashMap, + Dictionary entryMap, + RenameProfile renameProfile) + { + using var stream = File.OpenRead(path); + var hashes = ComputeHashes(stream); + return BuildMatchedResult(snapshot, verificationSet, path, null, null, Path.GetFileName(path), hashes, hashMap, entryMap, renameProfile); + } + + private static NoIntroVerificationResult VerifyArchive( + NoIntroVerificationSnapshot snapshot, + NoIntroVerificationSet verificationSet, + string path, + Dictionary hashMap, + Dictionary entryMap, + RenameProfile renameProfile) + { + using var fileStream = File.OpenRead(path); + using var zipFile = new ZipFile(fileStream); + var fileEntries = zipFile.Cast().Where(x => x.IsFile).ToList(); + + if (fileEntries.Count != 1) + { + return new NoIntroVerificationResult + { + SnapshotId = snapshot.Id, + VerificationSetId = verificationSet.Id, + CatalogEntryId = null, + RelativePath = GetRelativePath(verificationSet.RootPath, path), + ArchivePath = GetRelativePath(verificationSet.RootPath, path), + MemberPath = null, + ActualFileName = Path.GetFileName(path), + ExpectedFileName = null, + HashType = null, + HashValue = null, + VerificationStatus = NoIntroVerificationStatus.Unknown, + IsDuplicate = false, + IsMissing = false, + VerifiedAt = DateTime.UtcNow + }; + } + + var entry = fileEntries[0]; + using var memberStream = zipFile.GetInputStream(entry); + var hashes = ComputeHashes(memberStream); + + return BuildMatchedResult(snapshot, verificationSet, path, GetRelativePath(verificationSet.RootPath, path), entry.Name, Path.GetFileName(path), hashes, hashMap, entryMap, renameProfile); + } + + private static NoIntroVerificationResult BuildMatchedResult( + NoIntroVerificationSnapshot snapshot, + NoIntroVerificationSet verificationSet, + string fullPath, + string archivePath, + string memberPath, + string actualFileName, + NoIntroHashTriplet hashes, + Dictionary hashMap, + Dictionary entryMap, + RenameProfile renameProfile) + { + var matchedHash = FindMatch(hashes, hashMap); + + if (matchedHash == null) + { + return new NoIntroVerificationResult + { + SnapshotId = snapshot.Id, + VerificationSetId = verificationSet.Id, + RelativePath = GetRelativePath(verificationSet.RootPath, fullPath), + ArchivePath = archivePath, + MemberPath = memberPath, + ActualFileName = actualFileName, + ExpectedFileName = null, + HashType = hashes.PreferredHashType, + HashValue = hashes.PreferredHashValue, + VerificationStatus = NoIntroVerificationStatus.Unknown, + IsDuplicate = false, + IsMissing = false, + VerifiedAt = DateTime.UtcNow + }; + } + + var catalogEntry = entryMap[matchedHash.CatalogEntryId]; + var expectedFileName = NoIntroRenameProfileEvaluator.GetExpectedFileName(catalogEntry, actualFileName, renameProfile); + var verificationStatus = matchedHash.IsBadDump + ? NoIntroVerificationStatus.BadDump + : NoIntroRenameProfileEvaluator.MatchesProfile(catalogEntry, actualFileName, renameProfile) + ? NoIntroVerificationStatus.Verified + : NoIntroVerificationStatus.NameMismatch; + + return new NoIntroVerificationResult + { + SnapshotId = snapshot.Id, + VerificationSetId = verificationSet.Id, + CatalogEntryId = catalogEntry.Id, + RelativePath = GetRelativePath(verificationSet.RootPath, fullPath), + ArchivePath = archivePath, + MemberPath = memberPath, + ActualFileName = actualFileName, + ExpectedFileName = expectedFileName, + HashType = matchedHash.HashType, + HashValue = matchedHash.HashValue, + VerificationStatus = verificationStatus, + IsDuplicate = false, + IsMissing = false, + VerifiedAt = DateTime.UtcNow + }; + } + + private static NoIntroCatalogHash FindMatch(NoIntroHashTriplet hashes, Dictionary hashMap) + { + return TryGetHash("sha1", hashes.Sha1, hashMap) ?? + TryGetHash("md5", hashes.Md5, hashMap) ?? + TryGetHash("crc32", hashes.Crc32, hashMap); + } + + private static NoIntroCatalogHash TryGetHash(string hashType, string hashValue, Dictionary hashMap) + { + if (string.IsNullOrWhiteSpace(hashValue)) + { + return null; + } + + hashMap.TryGetValue($"{hashType}:{hashValue}".ToLowerInvariant(), out var matchedHash); + return matchedHash; + } + + private static void MarkDuplicates(List results) + { + var duplicates = results.Where(x => x.CatalogEntryId.HasValue).GroupBy(x => x.CatalogEntryId.Value).Where(x => x.Count() > 1); + + foreach (var duplicateGroup in duplicates) + { + foreach (var result in duplicateGroup) + { + result.IsDuplicate = true; + } + } + } + + private static List BuildMissingResults( + NoIntroVerificationSnapshot snapshot, + NoIntroVerificationSet verificationSet, + List entries, + List results) + { + var matchedEntryIds = results.Where(x => x.CatalogEntryId.HasValue).Select(x => x.CatalogEntryId.Value).ToHashSet(); + + return entries.Where(entry => !matchedEntryIds.Contains(entry.Id)).Select(entry => new NoIntroVerificationResult + { + SnapshotId = snapshot.Id, + VerificationSetId = verificationSet.Id, + CatalogEntryId = entry.Id, + RelativePath = string.Empty, + ArchivePath = null, + MemberPath = null, + ActualFileName = string.Empty, + ExpectedFileName = entry.CanonicalFileName, + HashType = null, + HashValue = null, + VerificationStatus = NoIntroVerificationStatus.Unknown, + IsDuplicate = false, + IsMissing = true, + VerifiedAt = DateTime.UtcNow + }).ToList(); + } + + private static string GetRelativePath(string rootPath, string fullPath) + { + return fullPath.StartsWith(rootPath, StringComparison.OrdinalIgnoreCase) + ? fullPath.Substring(rootPath.Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + : fullPath; + } + + private static NoIntroHashTriplet ComputeHashes(Stream stream) + { + return NoIntroRomHasher.Compute(stream); + } + } +} diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroVerificationSet.cs b/src/NzbDrone.Core/RomCatalog/NoIntroVerificationSet.cs new file mode 100644 index 00000000..f6f03972 --- /dev/null +++ b/src/NzbDrone.Core/RomCatalog/NoIntroVerificationSet.cs @@ -0,0 +1,12 @@ +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.RomCatalog +{ + public class NoIntroVerificationSet : ModelBase + { + public int CatalogSourceId { get; set; } + public string SystemKey { get; set; } + public string RootPath { get; set; } + public bool Enabled { get; set; } + } +} diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroVerificationSetRepository.cs b/src/NzbDrone.Core/RomCatalog/NoIntroVerificationSetRepository.cs new file mode 100644 index 00000000..61bf1928 --- /dev/null +++ b/src/NzbDrone.Core/RomCatalog/NoIntroVerificationSetRepository.cs @@ -0,0 +1,17 @@ +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Messaging.Events; + +namespace NzbDrone.Core.RomCatalog +{ + public interface INoIntroVerificationSetRepository : IBasicRepository + { + } + + public class NoIntroVerificationSetRepository : BasicRepository, INoIntroVerificationSetRepository + { + public NoIntroVerificationSetRepository(IMainDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + } +} diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroVerificationSnapshot.cs b/src/NzbDrone.Core/RomCatalog/NoIntroVerificationSnapshot.cs new file mode 100644 index 00000000..4d9e4b2d --- /dev/null +++ b/src/NzbDrone.Core/RomCatalog/NoIntroVerificationSnapshot.cs @@ -0,0 +1,14 @@ +using System; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.RomCatalog +{ + public class NoIntroVerificationSnapshot : ModelBase + { + public int VerificationSetId { get; set; } + public int CatalogSourceId { get; set; } + public string CatalogRevision { get; set; } + public DateTime StartedAt { get; set; } + public DateTime? CompletedAt { get; set; } + } +} diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroVerificationSnapshotRepository.cs b/src/NzbDrone.Core/RomCatalog/NoIntroVerificationSnapshotRepository.cs new file mode 100644 index 00000000..24efcb2f --- /dev/null +++ b/src/NzbDrone.Core/RomCatalog/NoIntroVerificationSnapshotRepository.cs @@ -0,0 +1,17 @@ +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Messaging.Events; + +namespace NzbDrone.Core.RomCatalog +{ + public interface INoIntroVerificationSnapshotRepository : IBasicRepository + { + } + + public class NoIntroVerificationSnapshotRepository : BasicRepository, INoIntroVerificationSnapshotRepository + { + public NoIntroVerificationSnapshotRepository(IMainDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + } +} diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroVerificationStatus.cs b/src/NzbDrone.Core/RomCatalog/NoIntroVerificationStatus.cs new file mode 100644 index 00000000..375a9715 --- /dev/null +++ b/src/NzbDrone.Core/RomCatalog/NoIntroVerificationStatus.cs @@ -0,0 +1,10 @@ +namespace NzbDrone.Core.RomCatalog +{ + public enum NoIntroVerificationStatus + { + Verified = 0, + NameMismatch = 1, + Unknown = 2, + BadDump = 3 + } +} From 9a847aad3053d807d4beaa7494d8edb0394f6c8c Mon Sep 17 00:00:00 2001 From: pfuenzle Date: Sun, 19 Jul 2026 11:29:03 +0000 Subject: [PATCH 03/42] Merge latest main for No-Intro branch --- .../{007_add_nointro_catalog.cs => 008_add_nointro_catalog.cs} | 2 +- ...ing_config.cs => 009_add_rename_profile_to_naming_config.cs} | 2 +- ...> 010_add_parent_canonical_name_to_nointro_catalog_entry.cs} | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename src/NzbDrone.Core/Datastore/Migration/{007_add_nointro_catalog.cs => 008_add_nointro_catalog.cs} (99%) rename src/NzbDrone.Core/Datastore/Migration/{008_add_rename_profile_to_naming_config.cs => 009_add_rename_profile_to_naming_config.cs} (96%) rename src/NzbDrone.Core/Datastore/Migration/{009_add_parent_canonical_name_to_nointro_catalog_entry.cs => 010_add_parent_canonical_name_to_nointro_catalog_entry.cs} (95%) diff --git a/src/NzbDrone.Core/Datastore/Migration/007_add_nointro_catalog.cs b/src/NzbDrone.Core/Datastore/Migration/008_add_nointro_catalog.cs similarity index 99% rename from src/NzbDrone.Core/Datastore/Migration/007_add_nointro_catalog.cs rename to src/NzbDrone.Core/Datastore/Migration/008_add_nointro_catalog.cs index f7f494c8..61afda93 100644 --- a/src/NzbDrone.Core/Datastore/Migration/007_add_nointro_catalog.cs +++ b/src/NzbDrone.Core/Datastore/Migration/008_add_nointro_catalog.cs @@ -3,7 +3,7 @@ namespace NzbDrone.Core.Datastore.Migration { - [Migration(7)] + [Migration(8)] public class add_nointro_catalog : NzbDroneMigrationBase { protected override void MainDbUpgrade() diff --git a/src/NzbDrone.Core/Datastore/Migration/008_add_rename_profile_to_naming_config.cs b/src/NzbDrone.Core/Datastore/Migration/009_add_rename_profile_to_naming_config.cs similarity index 96% rename from src/NzbDrone.Core/Datastore/Migration/008_add_rename_profile_to_naming_config.cs rename to src/NzbDrone.Core/Datastore/Migration/009_add_rename_profile_to_naming_config.cs index ee9de96b..ba807acf 100644 --- a/src/NzbDrone.Core/Datastore/Migration/008_add_rename_profile_to_naming_config.cs +++ b/src/NzbDrone.Core/Datastore/Migration/009_add_rename_profile_to_naming_config.cs @@ -4,7 +4,7 @@ namespace NzbDrone.Core.Datastore.Migration { - [Migration(8)] + [Migration(9)] public class add_rename_profile_to_naming_config : NzbDroneMigrationBase { protected override void MainDbUpgrade() diff --git a/src/NzbDrone.Core/Datastore/Migration/009_add_parent_canonical_name_to_nointro_catalog_entry.cs b/src/NzbDrone.Core/Datastore/Migration/010_add_parent_canonical_name_to_nointro_catalog_entry.cs similarity index 95% rename from src/NzbDrone.Core/Datastore/Migration/009_add_parent_canonical_name_to_nointro_catalog_entry.cs rename to src/NzbDrone.Core/Datastore/Migration/010_add_parent_canonical_name_to_nointro_catalog_entry.cs index d99e0e8f..8253e0a6 100644 --- a/src/NzbDrone.Core/Datastore/Migration/009_add_parent_canonical_name_to_nointro_catalog_entry.cs +++ b/src/NzbDrone.Core/Datastore/Migration/010_add_parent_canonical_name_to_nointro_catalog_entry.cs @@ -3,7 +3,7 @@ namespace NzbDrone.Core.Datastore.Migration { - [Migration(9)] + [Migration(10)] public class add_parent_canonical_name_to_nointro_catalog_entry : NzbDroneMigrationBase { protected override void MainDbUpgrade() From ece4a9b66f2af86908d591e854c90a6d9382e48b Mon Sep 17 00:00:00 2001 From: pfuenzle Date: Sun, 19 Jul 2026 13:25:47 +0000 Subject: [PATCH 04/42] Add Nintendo handheld platform families --- .../ParserTests/PlatformParserFixture.cs | 20 ++++--- src/NzbDrone.Core/Games/GamePlatform.cs | 34 +++++++++++- .../MetadataSource/RAWG/RawgProxy.cs | 42 +++++++++++++- src/NzbDrone.Core/Parser/PlatformParser.cs | 55 +++++++++++++++++-- 4 files changed, 137 insertions(+), 14 deletions(-) diff --git a/src/NzbDrone.Core.Test/ParserTests/PlatformParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/PlatformParserFixture.cs index 54577247..7b5b9948 100644 --- a/src/NzbDrone.Core.Test/ParserTests/PlatformParserFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/PlatformParserFixture.cs @@ -40,13 +40,19 @@ public void should_parse_xbox_platform(string postTitle, PlatformFamily expected resultString.Should().Be(expectedString); } - [TestCase("Game Title 2023 Switch NSP", PlatformFamily.Nintendo, "Switch")] - [TestCase("Game.Title.2023.NSW.USA.XCI", PlatformFamily.Nintendo, "Switch")] - [TestCase("Game Title (2023) [Nintendo Switch]", PlatformFamily.Nintendo, "Switch")] - [TestCase("Game.Title.2023.WiiU.USA.WUX", PlatformFamily.Nintendo, "Wii U")] - [TestCase("Game Title 2023 Wii ISO PAL", PlatformFamily.Nintendo, "Wii")] - [TestCase("Game.Title.2023.3DS.USA.CIA", PlatformFamily.Nintendo, "3DS")] - [TestCase("Game Title 2023 NDS USA ROM", PlatformFamily.Nintendo, "NDS")] + [TestCase("Game Title 2023 Switch NSP", PlatformFamily.NintendoSwitch, "Switch")] + [TestCase("Game.Title.2023.NSW.USA.XCI", PlatformFamily.NintendoSwitch, "Switch")] + [TestCase("Game Title (2023) [Nintendo Switch]", PlatformFamily.NintendoSwitch, "Switch")] + [TestCase("Game.Title.2023.WiiU.USA.WUX", PlatformFamily.NintendoWiiU, "Wii U")] + [TestCase("Game Title 2023 Wii ISO PAL", PlatformFamily.NintendoWii, "Wii")] + [TestCase("Game.Title.2023.3DS.USA.CIA", PlatformFamily.Nintendo3DS, "3DS")] + [TestCase("Game Title 2023 NDS USA ROM", PlatformFamily.NintendoDS, "NDS")] + [TestCase("Game Title 2023 GBA USA ROM", PlatformFamily.NintendoGBA, "GBA")] + [TestCase("Game Title 2023 Game Boy Advance USA ROM", PlatformFamily.NintendoGBA, "GBA")] + [TestCase("Game Title 2023 GBC USA ROM", PlatformFamily.NintendoGBC, "GBC")] + [TestCase("Game Title 2023 Game Boy Color USA ROM", PlatformFamily.NintendoGBC, "GBC")] + [TestCase("Game Title 2023 GB USA ROM", PlatformFamily.NintendoGB, "GB")] + [TestCase("Game Title 2023 Game Boy USA ROM", PlatformFamily.NintendoGB, "GB")] public void should_parse_nintendo_platform(string postTitle, PlatformFamily expectedFamily, string expectedString) { var result = PlatformParser.ParsePlatform(postTitle); diff --git a/src/NzbDrone.Core/Games/GamePlatform.cs b/src/NzbDrone.Core/Games/GamePlatform.cs index c7e16399..2aaf6251 100644 --- a/src/NzbDrone.Core/Games/GamePlatform.cs +++ b/src/NzbDrone.Core/Games/GamePlatform.cs @@ -27,7 +27,15 @@ public enum PlatformFamily Atari = 6, Mobile = 7, Linux = 8, - Mac = 9 + Mac = 9, + NintendoSwitch = 10, + NintendoWiiU = 11, + NintendoWii = 12, + Nintendo3DS = 13, + NintendoDS = 14, + NintendoGBA = 15, + NintendoGB = 16, + NintendoGBC = 17 } /// @@ -60,6 +68,30 @@ public static PlatformFamily MapPlatformFamily(int? igdbFamilyId) }; } + public static bool IsNintendoFamily(PlatformFamily platform) + { + return platform is PlatformFamily.Nintendo or + PlatformFamily.NintendoSwitch or + PlatformFamily.NintendoWiiU or + PlatformFamily.NintendoWii or + PlatformFamily.Nintendo3DS or + PlatformFamily.NintendoDS or + PlatformFamily.NintendoGBA or + PlatformFamily.NintendoGB or + PlatformFamily.NintendoGBC; + } + + public static bool PlatformMatches(PlatformFamily wanted, PlatformFamily actual) + { + if (wanted == actual) + { + return true; + } + + return (IsNintendoFamily(wanted) && actual == PlatformFamily.Nintendo) || + (wanted == PlatformFamily.Nintendo && IsNintendoFamily(actual)); + } + /// /// Common IGDB Platform IDs for reference /// diff --git a/src/NzbDrone.Core/MetadataSource/RAWG/RawgProxy.cs b/src/NzbDrone.Core/MetadataSource/RAWG/RawgProxy.cs index fbe590fc..dcb5839f 100644 --- a/src/NzbDrone.Core/MetadataSource/RAWG/RawgProxy.cs +++ b/src/NzbDrone.Core/MetadataSource/RAWG/RawgProxy.cs @@ -538,7 +538,47 @@ private PlatformFamily MapPlatformFamily(string slug) return PlatformFamily.Xbox; } - if (slug.Contains("nintendo") || slug.Contains("switch") || slug.Contains("wii") || slug.Contains("3ds")) + if (slug.Contains("switch")) + { + return PlatformFamily.NintendoSwitch; + } + + if (slug.Contains("wii-u") || slug.Contains("wiiu")) + { + return PlatformFamily.NintendoWiiU; + } + + if (slug.Contains("wii")) + { + return PlatformFamily.NintendoWii; + } + + if (slug.Contains("3ds")) + { + return PlatformFamily.Nintendo3DS; + } + + if (slug.Contains("nintendo-ds") || slug.Contains("nds")) + { + return PlatformFamily.NintendoDS; + } + + if (slug.Contains("game-boy-advance") || slug.Contains("gba")) + { + return PlatformFamily.NintendoGBA; + } + + if (slug.Contains("game-boy-color") || slug.Contains("gbc")) + { + return PlatformFamily.NintendoGBC; + } + + if (slug.Contains("game-boy") || slug.Contains("gb")) + { + return PlatformFamily.NintendoGB; + } + + if (slug.Contains("nintendo")) { return PlatformFamily.Nintendo; } diff --git a/src/NzbDrone.Core/Parser/PlatformParser.cs b/src/NzbDrone.Core/Parser/PlatformParser.cs index e128c30c..42244bc5 100644 --- a/src/NzbDrone.Core/Parser/PlatformParser.cs +++ b/src/NzbDrone.Core/Parser/PlatformParser.cs @@ -68,6 +68,18 @@ public static class PlatformParser @"\b(?:NDS|Nintendo\s*DS)\b|\[(?:NDS|Nintendo\s*DS)\]", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Regex NintendoGBARegex = new Regex( + @"\b(?:GBA|Game\s*Boy\s*Advance|Nintendo\s*Game\s*Boy\s*Advance)\b|\[(?:GBA|Game\s*Boy\s*Advance|Nintendo\s*Game\s*Boy\s*Advance)\]", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private static readonly Regex NintendoGBCRegex = new Regex( + @"\b(?:GBC|Game\s*Boy\s*Color|Nintendo\s*Game\s*Boy\s*Color)\b|\[(?:GBC|Game\s*Boy\s*Color|Nintendo\s*Game\s*Boy\s*Color)\]", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private static readonly Regex NintendoGBRegex = new Regex( + @"\b(?:GB|Game\s*Boy|Nintendo\s*Game\s*Boy)\b|\[(?:GB|Game\s*Boy|Nintendo\s*Game\s*Boy)\]", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + // PC/Mac/Linux platforms private static readonly Regex MacRegex = new Regex( @"\b(?:MAC|macOS|OSX|Mac\s*OS)\b|\[(?:MAC|macOS|OSX|Mac\s*OS)\]", @@ -149,31 +161,49 @@ public static PlatformFamily ParsePlatform(string title) if (SwitchRegex.IsMatch(title)) { Logger.Trace("Detected Nintendo Switch platform in title"); - return PlatformFamily.Nintendo; + return PlatformFamily.NintendoSwitch; } if (WiiURegex.IsMatch(title)) { Logger.Trace("Detected Wii U platform in title"); - return PlatformFamily.Nintendo; + return PlatformFamily.NintendoWiiU; } if (WiiRegex.IsMatch(title)) { Logger.Trace("Detected Wii platform in title"); - return PlatformFamily.Nintendo; + return PlatformFamily.NintendoWii; } if (Nintendo3DSRegex.IsMatch(title)) { Logger.Trace("Detected Nintendo 3DS platform in title"); - return PlatformFamily.Nintendo; + return PlatformFamily.Nintendo3DS; } if (NintendoDSRegex.IsMatch(title)) { Logger.Trace("Detected Nintendo DS platform in title"); - return PlatformFamily.Nintendo; + return PlatformFamily.NintendoDS; + } + + if (NintendoGBARegex.IsMatch(title)) + { + Logger.Trace("Detected Game Boy Advance platform in title"); + return PlatformFamily.NintendoGBA; + } + + if (NintendoGBCRegex.IsMatch(title)) + { + Logger.Trace("Detected Game Boy Color platform in title"); + return PlatformFamily.NintendoGBC; + } + + if (NintendoGBRegex.IsMatch(title)) + { + Logger.Trace("Detected Game Boy platform in title"); + return PlatformFamily.NintendoGB; } // Check Mac/Linux @@ -278,6 +308,21 @@ public static string ParsePlatformString(string title) return "NDS"; } + if (NintendoGBARegex.IsMatch(title)) + { + return "GBA"; + } + + if (NintendoGBCRegex.IsMatch(title)) + { + return "GBC"; + } + + if (NintendoGBRegex.IsMatch(title)) + { + return "GB"; + } + // Mac/Linux if (MacRegex.IsMatch(title)) { From fc41fd6bc26c4d850b694780550c0cbd25231842 Mon Sep 17 00:00:00 2001 From: pfuenzle Date: Sun, 19 Jul 2026 13:25:47 +0000 Subject: [PATCH 05/42] Match Nintendo handheld platform specs --- .../PlatformFamilyListConverterFixture.cs | 8 +++-- .../PlatformSpecificationFixture.cs | 35 +++++++++++++++++++ .../Specifications/PlatformSpecification.cs | 12 +++++-- 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/NzbDrone.Core.Test/Datastore/Converters/PlatformFamilyListConverterFixture.cs b/src/NzbDrone.Core.Test/Datastore/Converters/PlatformFamilyListConverterFixture.cs index 69c9eaff..d12c16c0 100644 --- a/src/NzbDrone.Core.Test/Datastore/Converters/PlatformFamilyListConverterFixture.cs +++ b/src/NzbDrone.Core.Test/Datastore/Converters/PlatformFamilyListConverterFixture.cs @@ -66,11 +66,11 @@ public void should_deserialize_empty_array() [Test] public void should_deserialize_all_platform_families() { - var json = "[\"pc\",\"playStation\",\"xbox\",\"nintendo\",\"linux\",\"mac\",\"mobile\"]"; + var json = "[\"pc\",\"playStation\",\"xbox\",\"nintendo\",\"linux\",\"mac\",\"mobile\",\"nintendoDS\",\"nintendoGBA\",\"nintendoGB\",\"nintendoGBC\"]"; var result = Subject.Parse(json); - result.Should().HaveCount(7); + result.Should().HaveCount(11); result.Should().Contain(PlatformFamily.PC); result.Should().Contain(PlatformFamily.PlayStation); result.Should().Contain(PlatformFamily.Xbox); @@ -78,6 +78,10 @@ public void should_deserialize_all_platform_families() result.Should().Contain(PlatformFamily.Linux); result.Should().Contain(PlatformFamily.Mac); result.Should().Contain(PlatformFamily.Mobile); + result.Should().Contain(PlatformFamily.NintendoDS); + result.Should().Contain(PlatformFamily.NintendoGBA); + result.Should().Contain(PlatformFamily.NintendoGB); + result.Should().Contain(PlatformFamily.NintendoGBC); } [Test] diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/PlatformSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/PlatformSpecificationFixture.cs index 17278669..f21554a5 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/PlatformSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/PlatformSpecificationFixture.cs @@ -86,5 +86,40 @@ public void should_reject_when_non_matching_platform() result.Accepted.Should().BeFalse(); result.Reason.Should().Be(DownloadRejectionReason.WantedPlatform); } + + [Test] + public void should_accept_broad_nintendo_release_for_specific_nintendo_entry() + { + _remoteGame.Game.Platform = PlatformFamily.NintendoDS; + _remoteGame.ParsedGameInfo.Platform = PlatformFamily.Nintendo; + + Subject.IsSatisfiedBy(_remoteGame, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_accept_specific_nintendo_release_for_broad_nintendo_preference() + { + _remoteGame.Game.Platform = PlatformFamily.Unknown; + _remoteGame.ParsedGameInfo.Platform = PlatformFamily.NintendoGBA; + _remoteGame.Game.QualityProfile.PreferredPlatforms = new List + { + PlatformFamily.Nintendo + }; + + Subject.IsSatisfiedBy(_remoteGame, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_accept_game_boy_color_release_for_broad_nintendo_preference() + { + _remoteGame.Game.Platform = PlatformFamily.Unknown; + _remoteGame.ParsedGameInfo.Platform = PlatformFamily.NintendoGBC; + _remoteGame.Game.QualityProfile.PreferredPlatforms = new List + { + PlatformFamily.Nintendo + }; + + Subject.IsSatisfiedBy(_remoteGame, null).Accepted.Should().BeTrue(); + } } } diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/PlatformSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/PlatformSpecification.cs index 9caac931..0c895f02 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/PlatformSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/PlatformSpecification.cs @@ -16,6 +16,14 @@ public class PlatformSpecification : IDownloadDecisionEngineSpecification PlatformFamily.PlayStation, PlatformFamily.Xbox, PlatformFamily.Nintendo, + PlatformFamily.NintendoSwitch, + PlatformFamily.NintendoWiiU, + PlatformFamily.NintendoWii, + PlatformFamily.Nintendo3DS, + PlatformFamily.NintendoDS, + PlatformFamily.NintendoGBA, + PlatformFamily.NintendoGB, + PlatformFamily.NintendoGBC, PlatformFamily.Sega, PlatformFamily.Atari, PlatformFamily.Mobile @@ -43,7 +51,7 @@ public virtual DownloadSpecDecision IsSatisfiedBy(RemoteGame subject, SearchCrit if (gamePlatform != PlatformFamily.Unknown) { - if (releasePlatform == gamePlatform) + if (GamePlatform.PlatformMatches(gamePlatform, releasePlatform)) { return DownloadSpecDecision.Accept(); } @@ -73,7 +81,7 @@ public virtual DownloadSpecDecision IsSatisfiedBy(RemoteGame subject, SearchCrit return DownloadSpecDecision.Accept(); } - if (preferredPlatforms.Contains(releasePlatform)) + if (preferredPlatforms.Any(platform => GamePlatform.PlatformMatches(platform, releasePlatform))) { _logger.Debug("Release platform {0} matches preferred platforms, accepting.", releasePlatform); return DownloadSpecDecision.Accept(); From ec31a0b1a351a554fb4713f5d5cda6cfee05341e Mon Sep 17 00:00:00 2001 From: pfuenzle Date: Sun, 19 Jul 2026 13:25:47 +0000 Subject: [PATCH 06/42] Accept handheld ROM media files --- .../MediaFiles/MediaFileExtensionsFixture.cs | 8 ++++ .../GetVideoFilesFixture.cs | 4 +- .../MediaFiles/DiskScanService.cs | 8 ++-- .../MediaFiles/DownloadedGameImportService.cs | 6 +-- .../MediaFiles/MediaFileExtensions.cs | 37 +++++++++++++++++++ 5 files changed, 55 insertions(+), 8 deletions(-) diff --git a/src/NzbDrone.Core.Test/MediaFiles/MediaFileExtensionsFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/MediaFileExtensionsFixture.cs index 8887571b..1d449cf5 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/MediaFileExtensionsFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/MediaFileExtensionsFixture.cs @@ -19,6 +19,14 @@ public class MediaFileExtensionsFixture : CoreTest [TestCase(@"setup.exe")] [TestCase(@"game.iso")] [TestCase(@"data1.bin")] + [TestCase(@"0201 - Mario Kart DS (Europe) (En,Fr,De,Es,It).nds")] + [TestCase(@"Pokemon Emerald Version (Germany).gba")] + [TestCase(@"Super Mario Bros.nes")] + [TestCase(@"Super Mario World.sfc")] + [TestCase(@"Mario Kart 64.z64")] + [TestCase(@"The Legend of Zelda Breath of the Wild.xci")] + [TestCase(@"Metroid Prime.rvz")] + [TestCase(@"No-Intro archive.zip")] public void should_recognize_game_file(string fileName) { MediaFileExtensions.IsGameFileExtension(Path.GetExtension(fileName)).Should().BeTrue(); diff --git a/src/NzbDrone.Core.Test/ProviderTests/DiskScanProviderTests/GetVideoFilesFixture.cs b/src/NzbDrone.Core.Test/ProviderTests/DiskScanProviderTests/GetVideoFilesFixture.cs index 55da1460..60e73637 100644 --- a/src/NzbDrone.Core.Test/ProviderTests/DiskScanProviderTests/GetVideoFilesFixture.cs +++ b/src/NzbDrone.Core.Test/ProviderTests/DiskScanProviderTests/GetVideoFilesFixture.cs @@ -23,6 +23,8 @@ public void Setup() @"Game2.exe", @"Game3.rar", @"Game4.zip", + @"Mario Kart DS.nds", + @"Pokemon Emerald.gba", @"readme.txt", @"game" }; @@ -80,7 +82,7 @@ public void should_return_video_files_only() var path = @"C:\Test\"; GivenFiles(GetFiles(path)); - Subject.GetVideoFiles(path).Should().HaveCount(4); + Subject.GetVideoFiles(path).Should().HaveCount(6); } [TestCase("Extras")] diff --git a/src/NzbDrone.Core/MediaFiles/DiskScanService.cs b/src/NzbDrone.Core/MediaFiles/DiskScanService.cs index b5095686..7c3c85dd 100644 --- a/src/NzbDrone.Core/MediaFiles/DiskScanService.cs +++ b/src/NzbDrone.Core/MediaFiles/DiskScanService.cs @@ -328,7 +328,7 @@ private void CompletedScanning(Game game, List possibleExtraFiles) public string[] GetVideoFiles(string path, bool allDirectories = true) { - _logger.Debug("Scanning '{0}' for video files", path); + _logger.Debug("Scanning '{0}' for game files", path); var filesOnDisk = _diskProvider.GetFiles(path, allDirectories).ToList(); @@ -336,14 +336,14 @@ public string[] GetVideoFiles(string path, bool allDirectories = true) .ToList(); _logger.Trace("{0} files were found in {1}", filesOnDisk.Count, path); - _logger.Debug("{0} video files were found in {1}", mediaFileList.Count, path); + _logger.Debug("{0} game files were found in {1}", mediaFileList.Count, path); return mediaFileList.ToArray(); } public string[] GetNonVideoFiles(string path, bool allDirectories = true) { - _logger.Debug("Scanning '{0}' for non-video files", path); + _logger.Debug("Scanning '{0}' for non-game files", path); var filesOnDisk = _diskProvider.GetFiles(path, allDirectories).ToList(); @@ -351,7 +351,7 @@ public string[] GetNonVideoFiles(string path, bool allDirectories = true) .ToList(); _logger.Trace("{0} files were found in {1}", filesOnDisk.Count, path); - _logger.Debug("{0} non-video files were found in {1}", mediaFileList.Count, path); + _logger.Debug("{0} non-game files were found in {1}", mediaFileList.Count, path); return mediaFileList.ToArray(); } diff --git a/src/NzbDrone.Core/MediaFiles/DownloadedGameImportService.cs b/src/NzbDrone.Core/MediaFiles/DownloadedGameImportService.cs index 3955f008..63d7af35 100644 --- a/src/NzbDrone.Core/MediaFiles/DownloadedGameImportService.cs +++ b/src/NzbDrone.Core/MediaFiles/DownloadedGameImportService.cs @@ -331,7 +331,7 @@ private List ProcessFile(FileInfo fileInfo, ImportMode importMode, return new List { - new ImportResult(new ImportDecision(new LocalGame { Path = fileInfo.FullName }, new ImportRejection(ImportRejectionReason.InvalidFilePath, "Invalid video file, filename starts with '._'")), "Invalid video file, filename starts with '._'") + new ImportResult(new ImportDecision(new LocalGame { Path = fileInfo.FullName }, new ImportRejection(ImportRejectionReason.InvalidFilePath, "Invalid game file, filename starts with '._'")), "Invalid game file, filename starts with '._'") }; } @@ -364,8 +364,8 @@ private List ProcessFile(FileInfo fileInfo, ImportMode importMode, return new List { new ImportResult(new ImportDecision(new LocalGame { Path = fileInfo.FullName }, - new ImportRejection(ImportRejectionReason.UnsupportedExtension, $"Invalid video file, unsupported extension: '{extension}'")), - $"Invalid video file, unsupported extension: '{extension}'") + new ImportRejection(ImportRejectionReason.UnsupportedExtension, $"Invalid game file, unsupported extension: '{extension}'")), + $"Invalid game file, unsupported extension: '{extension}'") }; } diff --git a/src/NzbDrone.Core/MediaFiles/MediaFileExtensions.cs b/src/NzbDrone.Core/MediaFiles/MediaFileExtensions.cs index fcbd50ee..63339cbf 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaFileExtensions.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaFileExtensions.cs @@ -39,6 +39,43 @@ static MediaFileExtensions() // FitGirl and other repack installer data { ".dat", Quality.Repack }, + { ".gb", Quality.Unknown }, + { ".gbc", Quality.Unknown }, + { ".gba", Quality.Unknown }, + { ".nds", Quality.Unknown }, + { ".dsi", Quality.Unknown }, + { ".3ds", Quality.Unknown }, + { ".cia", Quality.Unknown }, + { ".nes", Quality.Unknown }, + { ".fds", Quality.Unknown }, + { ".sfc", Quality.Unknown }, + { ".smc", Quality.Unknown }, + { ".n64", Quality.Unknown }, + { ".z64", Quality.Unknown }, + { ".v64", Quality.Unknown }, + { ".xci", Quality.Unknown }, + { ".nsp", Quality.Unknown }, + { ".nsz", Quality.Unknown }, + { ".rvz", Quality.ISO }, + { ".wbfs", Quality.ISO }, + { ".gcz", Quality.ISO }, + { ".ciso", Quality.ISO }, + { ".chd", Quality.ISO }, + { ".sms", Quality.Unknown }, + { ".gg", Quality.Unknown }, + { ".sg", Quality.Unknown }, + { ".md", Quality.Unknown }, + { ".gen", Quality.Unknown }, + { ".32x", Quality.Unknown }, + { ".pce", Quality.Unknown }, + { ".a26", Quality.Unknown }, + { ".a52", Quality.Unknown }, + { ".a78", Quality.Unknown }, + { ".lnx", Quality.Unknown }, + { ".ngp", Quality.Unknown }, + { ".ngc", Quality.Unknown }, + { ".ws", Quality.Unknown }, + { ".wsc", Quality.Unknown }, }; } From 039ebebeafa17fb5c695e3f1813ea25ef4660b0c Mon Sep 17 00:00:00 2001 From: pfuenzle Date: Sun, 19 Jul 2026 13:25:47 +0000 Subject: [PATCH 07/42] Parse No-Intro language tags --- .../ParserTests/LanguageParserFixture.cs | 12 ++++++++++++ src/NzbDrone.Core/Parser/LanguageParser.cs | 14 ++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/NzbDrone.Core.Test/ParserTests/LanguageParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/LanguageParserFixture.cs index 0f2e0648..95045ebc 100644 --- a/src/NzbDrone.Core.Test/ParserTests/LanguageParserFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/LanguageParserFixture.cs @@ -77,6 +77,18 @@ public void should_parse_language_french_english(string postTitle) result.Languages.Should().Contain(Language.English); } + [Test] + public void should_parse_nointro_language_code_list() + { + var result = LanguageParser.ParseLanguages("0201 - Mario Kart DS (Europe) (En,Fr,De,Es,It).nds"); + + result.Should().Contain(Language.English); + result.Should().Contain(Language.French); + result.Should().Contain(Language.German); + result.Should().Contain(Language.Spanish); + result.Should().Contain(Language.Italian); + } + [TestCase("Game.Title.1994.Spanish.1080p.XviD-LOL")] [TestCase("Game Title (2020)[BDRemux AVC 1080p][E-AC3 DD Plus 5.1 Castellano-Inglés Subs]")] [TestCase("Game Title (2020) [UHDRemux2160p HDR][DTS-HD MA 5.1 AC3 5.1 Castellano - True-HD 7.1 Atmos Inglés Subs]")] diff --git a/src/NzbDrone.Core/Parser/LanguageParser.cs b/src/NzbDrone.Core/Parser/LanguageParser.cs index 05edfae4..818d7c6d 100644 --- a/src/NzbDrone.Core/Parser/LanguageParser.cs +++ b/src/NzbDrone.Core/Parser/LanguageParser.cs @@ -58,6 +58,7 @@ public static class LanguageParser private static readonly Regex GermanDualLanguageRegex = new (@"(?[a-z]{2,3}(?:,[a-z]{2,3})+)[\)\]]", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex SubtitleLanguageRegex = new Regex(".+?([-_. ](?forced|foreign|default|cc|psdh|sdh))*[-_. ](?[a-z]{2,3})([-_. ](?forced|foreign|default|cc|psdh|sdh))*$", RegexOptions.Compiled | RegexOptions.IgnoreCase); @@ -455,6 +456,19 @@ public static List ParseLanguages(string title) languages.Add(Language.Russian); } + foreach (Match match in NoIntroLanguageListRegex.Matches(title)) + { + foreach (var code in match.Groups["codes"].Value.Split(',')) + { + var language = IsoLanguages.Find(code)?.Language; + + if (language != null) + { + languages.Add(language); + } + } + } + if (!languages.Any()) { languages.Add(Language.Unknown); From bf5f517672647ad4a9aed2289419029ac0064b04 Mon Sep 17 00:00:00 2001 From: pfuenzle Date: Sun, 19 Jul 2026 13:26:04 +0000 Subject: [PATCH 08/42] Seed default No-Intro catalog sources --- .../011_seed_nointro_catalog_sources.cs | 36 +++++++++++++++++++ src/NzbDrone.Core/Jobs/TaskManager.cs | 7 ++++ 2 files changed, 43 insertions(+) create mode 100644 src/NzbDrone.Core/Datastore/Migration/011_seed_nointro_catalog_sources.cs diff --git a/src/NzbDrone.Core/Datastore/Migration/011_seed_nointro_catalog_sources.cs b/src/NzbDrone.Core/Datastore/Migration/011_seed_nointro_catalog_sources.cs new file mode 100644 index 00000000..3ddf1a0b --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/011_seed_nointro_catalog_sources.cs @@ -0,0 +1,36 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(11)] + public class seed_nointro_catalog_sources : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Insert.IntoTable("NoIntroCatalogSources").Row(new + { + Name = "No-Intro Nintendo Game Boy", + SourceUrl = "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Game%20Boy.dat" + }); + + Insert.IntoTable("NoIntroCatalogSources").Row(new + { + Name = "No-Intro Nintendo Game Boy Color", + SourceUrl = "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Game%20Boy%20Color.dat" + }); + + Insert.IntoTable("NoIntroCatalogSources").Row(new + { + Name = "No-Intro Nintendo Game Boy Advance", + SourceUrl = "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Game%20Boy%20Advance.dat" + }); + + Insert.IntoTable("NoIntroCatalogSources").Row(new + { + Name = "No-Intro Nintendo DS", + SourceUrl = "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Nintendo%20DS.dat" + }); + } + } +} diff --git a/src/NzbDrone.Core/Jobs/TaskManager.cs b/src/NzbDrone.Core/Jobs/TaskManager.cs index b6cb0924..0b07c426 100644 --- a/src/NzbDrone.Core/Jobs/TaskManager.cs +++ b/src/NzbDrone.Core/Jobs/TaskManager.cs @@ -16,6 +16,7 @@ using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Games.Commands; +using NzbDrone.Core.RomCatalog; using NzbDrone.Core.Update.Commands; namespace NzbDrone.Core.Jobs @@ -107,6 +108,12 @@ public void Handle(ApplicationStartedEvent message) TypeName = typeof(RefreshCollectionsCommand).FullName }, + new ScheduledTask + { + Interval = 24 * 60, + TypeName = typeof(NoIntroCatalogSyncCommand).FullName + }, + new ScheduledTask { Interval = GetBackupInterval(), From b6b4e92892b3ddac71d85fbf04bd000166749d7d Mon Sep 17 00:00:00 2001 From: pfuenzle Date: Sun, 19 Jul 2026 13:26:04 +0000 Subject: [PATCH 09/42] Add numbered No-Intro catalog fields --- .../RomCatalog/NoIntroCatalogResource.cs | 19 +++++++++++++++++++ .../012_add_numbered_nointro_filenames.cs | 16 ++++++++++++++++ .../RomCatalog/NoIntroCatalogEntry.cs | 2 ++ .../RomCatalog/NoIntroCatalogSnapshot.cs | 3 +++ 4 files changed, 40 insertions(+) create mode 100644 src/NzbDrone.Core/Datastore/Migration/012_add_numbered_nointro_filenames.cs diff --git a/src/Gamarr.Api.V3/RomCatalog/NoIntroCatalogResource.cs b/src/Gamarr.Api.V3/RomCatalog/NoIntroCatalogResource.cs index fe78e6b9..02ada54f 100644 --- a/src/Gamarr.Api.V3/RomCatalog/NoIntroCatalogResource.cs +++ b/src/Gamarr.Api.V3/RomCatalog/NoIntroCatalogResource.cs @@ -25,9 +25,26 @@ public class NoIntroCatalogEntryResource : RestResource public string CanonicalName { get; set; } public string ParentCanonicalName { get; set; } public string CanonicalFileName { get; set; } + public string ReleaseNumber { get; set; } + public string NumberedCanonicalFileName { get; set; } public PlatformFamily PlatformFamily { get; set; } } + public class NoIntroCatalogStatusResource + { + public List Sources { get; set; } = new List(); + } + + public class NoIntroCatalogSourceStatusResource : RestResource + { + public string Name { get; set; } + public string CatalogVersion { get; set; } + public DateTime? LastSuccessfulSync { get; set; } + public DateTime? LastAttemptedSync { get; set; } + public string LastSyncError { get; set; } + public int EntryCount { get; set; } + } + public class NoIntroCatalogPlanResource { public List Games { get; set; } = new List(); @@ -110,6 +127,8 @@ public static NoIntroCatalogEntryResource ToResource(this NoIntroCatalogEntry mo CanonicalName = model.CanonicalName, ParentCanonicalName = model.ParentCanonicalName, CanonicalFileName = model.CanonicalFileName, + ReleaseNumber = model.ReleaseNumber, + NumberedCanonicalFileName = model.NumberedCanonicalFileName, PlatformFamily = model.PlatformFamily }; } diff --git a/src/NzbDrone.Core/Datastore/Migration/012_add_numbered_nointro_filenames.cs b/src/NzbDrone.Core/Datastore/Migration/012_add_numbered_nointro_filenames.cs new file mode 100644 index 00000000..c3e6a8a5 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/012_add_numbered_nointro_filenames.cs @@ -0,0 +1,16 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(12)] + public class add_numbered_nointro_filenames : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("NoIntroCatalogEntries") + .AddColumn("ReleaseNumber").AsString().Nullable() + .AddColumn("NumberedCanonicalFileName").AsString().Nullable(); + } + } +} diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroCatalogEntry.cs b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogEntry.cs index 74a1aeed..b1ce521a 100644 --- a/src/NzbDrone.Core/RomCatalog/NoIntroCatalogEntry.cs +++ b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogEntry.cs @@ -10,6 +10,8 @@ public class NoIntroCatalogEntry : ModelBase public string CanonicalName { get; set; } public string ParentCanonicalName { get; set; } public string CanonicalFileName { get; set; } + public string ReleaseNumber { get; set; } + public string NumberedCanonicalFileName { get; set; } public PlatformFamily PlatformFamily { get; set; } } } diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSnapshot.cs b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSnapshot.cs index 19957d7d..e5c36616 100644 --- a/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSnapshot.cs +++ b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSnapshot.cs @@ -12,7 +12,10 @@ public class NoIntroCatalogSnapshot public class NoIntroCatalogSnapshotEntry { public string CanonicalName { get; set; } + public string ParentCanonicalName { get; set; } public string CanonicalFileName { get; set; } + public string ReleaseNumber { get; set; } + public string NumberedCanonicalFileName { get; set; } public List Hashes { get; set; } = new List(); } From 794a6d9f3b68425b8324bcf89dc21531a9ea980d Mon Sep 17 00:00:00 2001 From: pfuenzle Date: Sun, 19 Jul 2026 13:26:04 +0000 Subject: [PATCH 10/42] Parse numbered No-Intro snapshots --- .../NoIntroCatalogSnapshotParser.cs | 116 +++++++++++++++++- 1 file changed, 111 insertions(+), 5 deletions(-) diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSnapshotParser.cs b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSnapshotParser.cs index 12e814e9..d133d2d6 100644 --- a/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSnapshotParser.cs +++ b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSnapshotParser.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using System.Text.RegularExpressions; using System.Xml.Linq; namespace NzbDrone.Core.RomCatalog @@ -11,8 +12,19 @@ public interface INoIntroCatalogSnapshotParser public class NoIntroCatalogSnapshotParser : INoIntroCatalogSnapshotParser { + private static readonly Regex ClrMameHeaderRegex = new Regex(@"clrmamepro\s*\((?.*?)\)\s*game\s*\(", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Singleline); + private static readonly Regex ClrMameGameRegex = new Regex(@"game\s*\((?.*?)(?=\n\s*game\s*\(|\z)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Singleline); + private static readonly Regex ClrMameRomRegex = new Regex(@"rom\s*\((?[^\r\n]*)\)", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Regex ClrMameFieldRegex = new Regex("(?[a-z0-9_]+)\\s+(?:(?:\"(?[^\"]*)\")|(?[^\\s\\)]+))", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Regex NumberedNameRegex = new Regex(@"^(?(?:xB|x|z)\d{3,4}|\d{4})\s+-\s+(?.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + public NoIntroCatalogSnapshot Parse(string content) { + if (!content.TrimStart().StartsWith("<", StringComparison.Ordinal)) + { + return ParseClrMamePro(content); + } + var document = XDocument.Parse(content); var root = document.Element("datafile") ?? throw new InvalidOperationException("Unsupported No-Intro catalog format"); var header = root.Element("header"); @@ -35,11 +47,10 @@ public NoIntroCatalogSnapshot Parse(string content) continue; } - var entry = new NoIntroCatalogSnapshotEntry - { - CanonicalName = canonicalName, - CanonicalFileName = game.Elements("rom").FirstOrDefault()?.Attribute("name")?.Value ?? canonicalName - }; + var entry = CreateEntry(canonicalName, + game.Attribute("cloneof")?.Value, + game.Elements("rom").FirstOrDefault()?.Attribute("name")?.Value ?? canonicalName, + game.Attribute("id")?.Value); foreach (var rom in game.Elements("rom")) { @@ -54,6 +65,101 @@ public NoIntroCatalogSnapshot Parse(string content) return snapshot; } + private static NoIntroCatalogSnapshot ParseClrMamePro(string content) + { + var header = ClrMameHeaderRegex.Match(content); + var headerFields = header.Success ? ParseFields(header.Groups["body"].Value) : new System.Collections.Generic.Dictionary(); + var systemName = headerFields.TryGetValue("name", out var name) ? name : "unknown"; + var version = headerFields.TryGetValue("version", out var headerVersion) ? headerVersion : null; + + var snapshot = new NoIntroCatalogSnapshot + { + CatalogVersion = version, + SystemKey = NormalizeSystemKey(systemName) + }; + + foreach (Match gameMatch in ClrMameGameRegex.Matches(content)) + { + var body = gameMatch.Groups["body"].Value; + var romMatch = ClrMameRomRegex.Match(body); + var gameFieldsBody = romMatch.Success ? body.Substring(0, romMatch.Index) : body; + var fields = ParseFields(gameFieldsBody); + + if (!fields.TryGetValue("name", out var canonicalName) || string.IsNullOrWhiteSpace(canonicalName)) + { + continue; + } + + var romFields = romMatch.Success ? ParseFields(romMatch.Groups["body"].Value) : new System.Collections.Generic.Dictionary(); + + var entry = CreateEntry(canonicalName, + fields.TryGetValue("cloneof", out var parent) ? parent : null, + romFields.TryGetValue("name", out var fileName) ? fileName : canonicalName, + fields.TryGetValue("id", out var id) ? id : null); + + AddHash(entry, "crc32", romFields.TryGetValue("crc", out var crc) ? crc : null, true, false); + AddHash(entry, "md5", romFields.TryGetValue("md5", out var md5) ? md5 : null, false, false); + AddHash(entry, "sha1", romFields.TryGetValue("sha1", out var sha1) ? sha1 : null, false, false); + + snapshot.Entries.Add(entry); + } + + return snapshot; + } + + private static System.Collections.Generic.Dictionary ParseFields(string body) + { + var fields = new System.Collections.Generic.Dictionary(); + + foreach (Match match in ClrMameFieldRegex.Matches(body)) + { + fields[match.Groups["name"].Value.ToLowerInvariant()] = match.Groups["quoted"].Success ? match.Groups["quoted"].Value : match.Groups["bare"].Value; + } + + return fields; + } + + private static NoIntroCatalogSnapshotEntry CreateEntry(string name, string parentName, string fileName, string id) + { + var nameMatch = NumberedNameRegex.Match(name); + var fileNameMatch = NumberedNameRegex.Match(fileName); + var releaseNumber = id; + var canonicalName = name; + var canonicalFileName = fileName; + + if (nameMatch.Success) + { + releaseNumber = nameMatch.Groups["number"].Value; + canonicalName = nameMatch.Groups["name"].Value; + } + + if (fileNameMatch.Success) + { + releaseNumber ??= fileNameMatch.Groups["number"].Value; + canonicalFileName = fileNameMatch.Groups["name"].Value; + } + + return new NoIntroCatalogSnapshotEntry + { + CanonicalName = canonicalName, + ParentCanonicalName = StripNumberPrefix(parentName), + CanonicalFileName = canonicalFileName, + ReleaseNumber = releaseNumber, + NumberedCanonicalFileName = releaseNumber != null ? fileName : null + }; + } + + private static string StripNumberPrefix(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return value; + } + + var match = NumberedNameRegex.Match(value); + return match.Success ? match.Groups["name"].Value : value; + } + private static void AddHash(NoIntroCatalogSnapshotEntry entry, string hashType, string hashValue, bool isPrimary, bool isBadDump) { if (string.IsNullOrWhiteSpace(hashValue)) From 039a6eb004421fadb3104da25808c8e9bca084d8 Mon Sep 17 00:00:00 2001 From: pfuenzle Date: Sun, 19 Jul 2026 13:26:04 +0000 Subject: [PATCH 11/42] Enrich No-Intro catalogs with release numbers --- .../NoIntroCatalogSyncServiceFixture.cs | 108 +++++++++++ .../NoIntroCatalogDocumentClient.cs | 76 ++++++++ .../RomCatalog/NoIntroCatalogSyncService.cs | 167 +++++++++++++++++- 3 files changed, 350 insertions(+), 1 deletion(-) diff --git a/src/NzbDrone.Core.Test/RomCatalog/NoIntroCatalogSyncServiceFixture.cs b/src/NzbDrone.Core.Test/RomCatalog/NoIntroCatalogSyncServiceFixture.cs index ceed3440..eaf941e7 100644 --- a/src/NzbDrone.Core.Test/RomCatalog/NoIntroCatalogSyncServiceFixture.cs +++ b/src/NzbDrone.Core.Test/RomCatalog/NoIntroCatalogSyncServiceFixture.cs @@ -1,5 +1,7 @@ using System; +using System.Linq; using FluentAssertions; +using Moq; using NUnit.Framework; using NzbDrone.Core.RomCatalog; using NzbDrone.Core.Test.Framework; @@ -51,6 +53,112 @@ public void sync_should_ingest_snapshot_and_update_metadata() _hashRepository.All().Should().Contain(x => x.CatalogEntryId == entry.Id && x.HashType == "crc32" && x.HashValue == "ABCDEF12"); } + [Test] + public void sync_should_preserve_quoted_clrmamepro_rom_filenames() + { + var source = _sourceRepository.Insert(new NoIntroCatalogSource + { + Name = "No-Intro", + SourceUrl = "https://example.invalid/gba.dat" + }); + + Mocker.GetMock() + .Setup(x => x.Fetch(source.SourceUrl)) + .Returns("clrmamepro (\n" + + "\tname \"Nintendo - Game Boy Advance\"\n" + + "\tversion \"2026.07\"\n" + + ")\n" + + "game (\n" + + "\tname \"007 - Everything or Nothing (USA, Europe) (En,Fr,De)\"\n" + + "\trom ( name \"007 - Everything or Nothing (USA, Europe) (En,Fr,De).gba\" size 16777216 crc 1234ABCD md5 0123456789ABCDEF0123456789ABCDEF sha1 0123456789ABCDEF0123456789ABCDEF01234567 )\n" + + ")\n"); + + _subject.Sync(source.Id); + + var entry = _entryRepository.All().Should().ContainSingle().Subject; + entry.CanonicalName.Should().Be("007 - Everything or Nothing (USA, Europe) (En,Fr,De)"); + entry.CanonicalFileName.Should().Be("007 - Everything or Nothing (USA, Europe) (En,Fr,De).gba"); + _hashRepository.All().Should().Contain(x => x.CatalogEntryId == entry.Id && x.HashType == "crc32" && x.HashValue == "1234ABCD"); + } + + [Test] + public void sync_should_enrich_entries_with_datomatic_numbered_filename_by_hash() + { + var source = _sourceRepository.Insert(new NoIntroCatalogSource + { + Name = "No-Intro Nintendo DS", + SourceUrl = "https://example.invalid/nds.dat" + }); + + Mocker.GetMock() + .Setup(x => x.Fetch(source.SourceUrl)) + .Returns("
Nintendo - Nintendo DS2026.05.02
"); + + Mocker.GetMock() + .Setup(x => x.FetchDatOMaticNumbered(28)) + .Returns("
Nintendo - Nintendo DS
"); + + _subject.Sync(source.Id); + + var entry = _entryRepository.All().Should().ContainSingle().Subject; + entry.CanonicalName.Should().Be("Mario Kart DS (Europe) (En,Fr,De,Es,It)"); + entry.CanonicalFileName.Should().Be("Mario Kart DS (Europe) (En,Fr,De,Es,It).nds"); + entry.ReleaseNumber.Should().Be("0201"); + entry.NumberedCanonicalFileName.Should().Be("0201 - Mario Kart DS (Europe) (En,Fr,De,Es,It).nds"); + } + + [Test] + public void sync_should_fallback_to_advanscene_release_number_when_datomatic_fails() + { + var source = _sourceRepository.Insert(new NoIntroCatalogSource + { + Name = "No-Intro Nintendo DS", + SourceUrl = "https://example.invalid/nds.dat" + }); + + Mocker.GetMock() + .Setup(x => x.Fetch(source.SourceUrl)) + .Returns("
Nintendo - Nintendo DS2026.05.02
"); + + Mocker.GetMock() + .Setup(x => x.FetchDatOMaticNumbered(28)) + .Throws(new InvalidOperationException("DAT-o-MATIC did not return a numbered DAT download token")); + + Mocker.GetMock() + .Setup(x => x.FetchAdvanscene("https://advanscene.com/offline/datas/ADVANsCEne_NDS_S.zip")) + .Returns("20194E8127E"); + + _subject.Sync(source.Id); + + var entry = _entryRepository.All().Should().ContainSingle().Subject; + entry.ReleaseNumber.Should().Be("0201"); + entry.NumberedCanonicalFileName.Should().Be("0201 - Mario Kart DS (Europe) (En,Fr,De,Es,It).nds"); + } + + [Test] + public void sync_should_seed_missing_default_game_boy_sources() + { + _sourceRepository.Purge(); + + var existingSource = _sourceRepository.Insert(new NoIntroCatalogSource + { + Name = "No-Intro Nintendo DS", + SourceUrl = "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Nintendo%20DS.dat" + }); + + Mocker.GetMock() + .Setup(x => x.Fetch(It.IsAny())) + .Returns("
Nintendo - Nintendo DS
"); + + _subject.Sync(); + + var sources = _sourceRepository.All().ToList(); + sources.Should().Contain(x => x.Name == "No-Intro Nintendo Game Boy"); + sources.Should().Contain(x => x.Name == "No-Intro Nintendo Game Boy Color"); + sources.Should().Contain(x => x.Name == "No-Intro Nintendo Game Boy Advance"); + sources.Should().ContainSingle(x => x.SourceUrl == existingSource.SourceUrl); + } + [Test] public void sync_failure_should_preserve_existing_catalog_and_record_failure() { diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroCatalogDocumentClient.cs b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogDocumentClient.cs index 6f41579d..528801a5 100644 --- a/src/NzbDrone.Core/RomCatalog/NoIntroCatalogDocumentClient.cs +++ b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogDocumentClient.cs @@ -1,3 +1,8 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text.RegularExpressions; using NzbDrone.Common.Http; namespace NzbDrone.Core.RomCatalog @@ -5,10 +10,13 @@ namespace NzbDrone.Core.RomCatalog public interface INoIntroCatalogDocumentClient { string Fetch(string sourceUrl); + string FetchDatOMaticNumbered(int systemId); + string FetchAdvanscene(string sourceUrl); } public class NoIntroCatalogDocumentClient : INoIntroCatalogDocumentClient { + private static readonly Regex DownloadTokenRegex = new Regex("[0-9a-f]{32})\" value=\"Download!!\"", RegexOptions.Compiled | RegexOptions.IgnoreCase); private readonly IHttpClient _httpClient; public NoIntroCatalogDocumentClient(IHttpClient httpClient) @@ -20,5 +28,73 @@ public string Fetch(string sourceUrl) { return _httpClient.Get(new HttpRequest(sourceUrl)).Content; } + + public string FetchDatOMaticNumbered(int systemId) + { + var prepareUrl = $"https://datomatic.no-intro.org/index.php?page=download&op=dat&s={systemId}"; + _httpClient.Get(new HttpRequest(prepareUrl)); + + var prepareRequest = new HttpRequestBuilder(prepareUrl) + .Post() + .AddFormParameter("system_selection", systemId) + .AddFormParameter("sys_list_order", 1) + .AddFormParameter("format", 0) + .AddFormParameter("naming", 0) + .AddFormParameter("numbered", 1) + .AddFormParameter("inc_bios", 1) + .AddFormParameter("release_1", 1) + .AddFormParameter("release_2", 1) + .AddFormParameter("license_1", 1) + .AddFormParameter("license_2", 1) + .AddFormParameter("license_0", 1) + .AddFormParameter("lifespan_1", 1) + .AddFormParameter("inc_xroms", 1) + .AddFormParameter("inc_zroms", 1) + .AddFormParameter("storage_1", 1) + .AddFormParameter("storage_2", 1) + .AddFormParameter("inc_nodump", 0) + .AddFormParameter("inc_mia", 1) + .AddFormParameter("dat_dl_2026-05-30", "Prepare") + .Build(); + + var prepareResponse = _httpClient.Post(prepareRequest); + var token = DownloadTokenRegex.Matches(prepareResponse.Content) + .Cast() + .Select(x => x.Groups["token"].Value) + .LastOrDefault(); + + if (string.IsNullOrWhiteSpace(token)) + { + throw new InvalidOperationException("DAT-o-MATIC did not return a numbered DAT download token"); + } + + var downloadRequest = new HttpRequestBuilder(prepareResponse.Request.Url.FullUri) + .Post() + .AddFormParameter(token, "Download!!") + .Build(); + var downloadResponse = _httpClient.Post(downloadRequest); + + return ExtractDat(downloadResponse.ResponseData); + } + + public string FetchAdvanscene(string sourceUrl) + { + return ExtractDat(_httpClient.Get(new HttpRequest(sourceUrl)).ResponseData); + } + + private static string ExtractDat(byte[] data) + { + if (data.Length >= 2 && data[0] == 'P' && data[1] == 'K') + { + using var stream = new MemoryStream(data); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read); + var entry = archive.Entries.FirstOrDefault(x => x.Name.EndsWith(".dat", StringComparison.OrdinalIgnoreCase)) ?? archive.Entries.First(); + using var entryStream = entry.Open(); + using var reader = new StreamReader(entryStream); + return reader.ReadToEnd(); + } + + return System.Text.Encoding.UTF8.GetString(data); + } } } diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSyncService.cs b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSyncService.cs index 41653fae..e2bb7f3b 100644 --- a/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSyncService.cs +++ b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSyncService.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Xml.Linq; using NLog; +using NzbDrone.Core.Games; using NzbDrone.Core.Messaging.Commands; namespace NzbDrone.Core.RomCatalog @@ -47,6 +49,11 @@ public void Sync(int? catalogSourceId = null) ? new List { _sourceRepository.Get(catalogSourceId.Value) } : _sourceRepository.All().ToList(); + if (!catalogSourceId.HasValue) + { + EnsureDefaultSources(sources); + } + foreach (var source in sources) { SyncSource(source); @@ -61,6 +68,7 @@ private void SyncSource(NoIntroCatalogSource source) try { var snapshot = _snapshotParser.Parse(_documentClient.Fetch(source.SourceUrl)); + EnrichWithNumberedCatalog(snapshot); ReplaceCatalog(source, snapshot); source.CatalogVersion = snapshot.CatalogVersion; @@ -93,7 +101,11 @@ private void ReplaceCatalog(NoIntroCatalogSource source, NoIntroCatalogSnapshot CatalogSourceId = source.Id, SystemKey = snapshot.SystemKey, CanonicalName = entry.CanonicalName, - CanonicalFileName = entry.CanonicalFileName + ParentCanonicalName = entry.ParentCanonicalName, + PlatformFamily = MapPlatformFamily(snapshot.SystemKey), + CanonicalFileName = entry.CanonicalFileName, + ReleaseNumber = entry.ReleaseNumber, + NumberedCanonicalFileName = entry.NumberedCanonicalFileName }).ToList(); _entryRepository.InsertMany(entries); @@ -117,5 +129,158 @@ private void ReplaceCatalog(NoIntroCatalogSource source, NoIntroCatalogSnapshot _hashRepository.InsertMany(hashes); } } + + private void EnsureDefaultSources(List sources) + { + AddDefaultSource(sources, "No-Intro Nintendo Game Boy", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Game%20Boy.dat"); + AddDefaultSource(sources, "No-Intro Nintendo Game Boy Color", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Game%20Boy%20Color.dat"); + AddDefaultSource(sources, "No-Intro Nintendo Game Boy Advance", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Game%20Boy%20Advance.dat"); + AddDefaultSource(sources, "No-Intro Nintendo DS", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Nintendo%20DS.dat"); + } + + private void AddDefaultSource(List sources, string name, string sourceUrl) + { + if (sources.Any(source => source.SourceUrl == sourceUrl)) + { + return; + } + + sources.Add(_sourceRepository.Insert(new NoIntroCatalogSource + { + Name = name, + SourceUrl = sourceUrl + })); + } + + private void EnrichWithNumberedCatalog(NoIntroCatalogSnapshot snapshot) + { + var systemId = GetDatOMaticSystemId(snapshot.SystemKey); + + if (!systemId.HasValue) + { + return; + } + + try + { + var numberedSnapshot = _snapshotParser.Parse(_documentClient.FetchDatOMaticNumbered(systemId.Value)); + var numberedByHash = numberedSnapshot.Entries + .SelectMany(entry => entry.Hashes.Select(hash => new { Key = HashKey(hash), Entry = entry })) + .GroupBy(x => x.Key) + .ToDictionary(x => x.Key, x => x.First().Entry); + + foreach (var entry in snapshot.Entries) + { + var numberedEntry = entry.Hashes + .Select(hash => numberedByHash.GetValueOrDefault(HashKey(hash))) + .FirstOrDefault(match => match?.NumberedCanonicalFileName != null); + + if (numberedEntry == null) + { + continue; + } + + entry.ReleaseNumber = numberedEntry.ReleaseNumber; + entry.NumberedCanonicalFileName = numberedEntry.NumberedCanonicalFileName; + } + } + catch (Exception ex) + { + _logger.Warn(ex, "Failed enriching No-Intro catalog {0} with DAT-o-MATIC numbered filenames", snapshot.SystemKey); + } + + EnrichWithAdvansceneCatalog(snapshot); + } + + private void EnrichWithAdvansceneCatalog(NoIntroCatalogSnapshot snapshot) + { + var sourceUrl = GetAdvansceneSourceUrl(snapshot.SystemKey); + + if (sourceUrl == null) + { + return; + } + + try + { + var releaseNumbersByCrc = ParseAdvansceneReleaseNumbers(_documentClient.FetchAdvanscene(sourceUrl)); + + foreach (var entry in snapshot.Entries.Where(x => x.NumberedCanonicalFileName == null)) + { + var crc = entry.Hashes.FirstOrDefault(x => x.HashType.Equals("crc32", StringComparison.OrdinalIgnoreCase)); + + if (crc == null || !releaseNumbersByCrc.TryGetValue(crc.HashValue.ToUpperInvariant(), out var releaseNumber)) + { + continue; + } + + entry.ReleaseNumber = releaseNumber; + entry.NumberedCanonicalFileName = $"{releaseNumber} - {entry.CanonicalFileName}"; + } + } + catch (Exception ex) + { + _logger.Warn(ex, "Failed enriching No-Intro catalog {0} with ADVANsCEne release numbers", snapshot.SystemKey); + } + } + + private static Dictionary ParseAdvansceneReleaseNumbers(string content) + { + var document = XDocument.Parse(content); + + return document.Descendants("game") + .Select(game => new + { + ReleaseNumber = FormatReleaseNumber(game.Element("releaseNumber")?.Value), + Crc = game.Element("files")?.Elements("romCRC").FirstOrDefault()?.Value + }) + .Where(x => x.ReleaseNumber != null && !string.IsNullOrWhiteSpace(x.Crc)) + .GroupBy(x => x.Crc.ToUpperInvariant()) + .ToDictionary(x => x.Key, x => x.First().ReleaseNumber); + } + + private static string FormatReleaseNumber(string value) + { + return int.TryParse(value, out var number) ? number.ToString("0000") : null; + } + + private static string HashKey(NoIntroCatalogSnapshotHash hash) + { + return $"{hash.HashType}:{hash.HashValue}".ToLowerInvariant(); + } + + private static int? GetDatOMaticSystemId(string systemKey) + { + return systemKey switch + { + "nintendo---game-boy" => 46, + "nintendo---game-boy-color" => 47, + "nintendo---game-boy-advance" => 23, + "nintendo---nintendo-ds" => 28, + _ => null + }; + } + + private static string GetAdvansceneSourceUrl(string systemKey) + { + return systemKey switch + { + "nintendo---game-boy-advance" => "https://advanscene.com/offline/datas/ADVANsCEne_GBA.zip", + "nintendo---nintendo-ds" => "https://advanscene.com/offline/datas/ADVANsCEne_NDS_S.zip", + _ => null + }; + } + + private static PlatformFamily MapPlatformFamily(string systemKey) + { + return systemKey switch + { + "nintendo---game-boy" => PlatformFamily.NintendoGB, + "nintendo---game-boy-color" => PlatformFamily.NintendoGBC, + "nintendo---game-boy-advance" => PlatformFamily.NintendoGBA, + "nintendo---nintendo-ds" => PlatformFamily.NintendoDS, + _ => PlatformFamily.Nintendo + }; + } } } From ec18bc6b7f6ece1246a8b8539f210d6af24483d5 Mon Sep 17 00:00:00 2001 From: pfuenzle Date: Sun, 19 Jul 2026 13:26:27 +0000 Subject: [PATCH 12/42] Rename files with No-Intro numbered names --- .../FileNameBuilderFixture.cs | 35 +++++++++ .../Organizer/FileNameBuilder.cs | 75 +++++++++++++++++++ .../NoIntroRenameProfileEvaluator.cs | 15 +++- 3 files changed, 123 insertions(+), 2 deletions(-) diff --git a/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs index ac55af45..217fa70f 100644 --- a/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs +++ b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs @@ -12,9 +12,11 @@ using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles.MediaInfo; using NzbDrone.Core.Games; +using NzbDrone.Core.Games.Components; using NzbDrone.Core.Games.Translations; using NzbDrone.Core.Organizer; using NzbDrone.Core.Qualities; +using NzbDrone.Core.RomCatalog; using NzbDrone.Core.Test.Framework; using NzbDrone.Test.Common; @@ -173,6 +175,39 @@ public void should_replace_translated_game_title_with_base_title_if_no_translati .Should().Be("South Park"); } + [Test] + public void should_use_nointro_catalog_filename_when_nointro_profile_is_selected() + { + _namingConfig.RenameProfile = RenameProfile.NoIntroPreserveById; + _gameFile.ComponentId = 50; + _gameFile.RelativePath = "Mario Kart DS (2005) Unknown - Gamarr.nds"; + + Mocker.GetMock() + .Setup(x => x.Get(50)) + .Returns(new GameComponent + { + Id = 50, + Title = "Mario Kart DS (Europe) (En,Fr,De,Es,It)" + }); + + Mocker.GetMock() + .Setup(x => x.All()) + .Returns(new[] + { + new NoIntroCatalogEntry + { + SystemKey = "nintendo---nintendo-ds", + CanonicalName = "Mario Kart DS (Europe) (En,Fr,De,Es,It)", + CanonicalFileName = "Mario Kart DS (Europe) (En,Fr,De,Es,It).nds", + ReleaseNumber = "0201", + NumberedCanonicalFileName = "0201 - Mario Kart DS (Europe) (En,Fr,De,Es,It).nds" + } + }); + + Subject.BuildFileName(_game, _gameFile) + .Should().Be("0201 - Mario Kart DS (Europe) (En,Fr,De,Es,It)"); + } + [Test] public void should_replace_translated_game_title_with_fallback_if_no_translation_exists() { diff --git a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs index 4ff2534a..9840dd7e 100644 --- a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs +++ b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs @@ -12,9 +12,11 @@ using NzbDrone.Core.CustomFormats; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Games; +using NzbDrone.Core.Games.Components; using NzbDrone.Core.Games.Translations; using NzbDrone.Core.Parser; using NzbDrone.Core.Qualities; +using NzbDrone.Core.RomCatalog; namespace NzbDrone.Core.Organizer { @@ -31,6 +33,8 @@ public class FileNameBuilder : IBuildFileNames private readonly IQualityDefinitionService _qualityDefinitionService; private readonly IGameTranslationService _gameTranslationService; private readonly ICustomFormatCalculationService _formatCalculator; + private readonly IGameComponentRepository _componentRepository; + private readonly INoIntroCatalogEntryRepository _noIntroEntryRepository; private readonly Logger _logger; private static readonly Regex TitleRegex = new Regex(@"(?\{(?[-{ ._\[(]*)(?:edition-))?\{(?[-{ ._\[(]*)(?(?:[a-z0-9]+)(?:(?[- ._]+)(?:[a-z0-9]+))?)(?::(?[ ,a-z0-9|+-]+(?[-} ._)\]]*)\}", @@ -86,12 +90,16 @@ public FileNameBuilder(INamingConfigService namingConfigService, IQualityDefinitionService qualityDefinitionService, IGameTranslationService gameTranslationService, ICustomFormatCalculationService formatCalculator, + IGameComponentRepository componentRepository, + INoIntroCatalogEntryRepository noIntroEntryRepository, Logger logger) { _namingConfigService = namingConfigService; _qualityDefinitionService = qualityDefinitionService; _gameTranslationService = gameTranslationService; _formatCalculator = formatCalculator; + _componentRepository = componentRepository; + _noIntroEntryRepository = noIntroEntryRepository; _logger = logger; } @@ -107,6 +115,13 @@ public string BuildFileName(Game game, GameFile gameFile, NamingConfig namingCon return GetOriginalTitle(gameFile, false); } + var noIntroFileName = GetNoIntroFileName(gameFile, namingConfig.RenameProfile); + + if (noIntroFileName.IsNotNullOrWhiteSpace()) + { + return noIntroFileName; + } + if (namingConfig.StandardGameFormat.IsNullOrWhiteSpace()) { throw new NamingFormatException("Standard game format cannot be empty"); @@ -148,6 +163,66 @@ public string BuildFileName(Game game, GameFile gameFile, NamingConfig namingCon return Path.Combine(components.ToArray()); } + private string GetNoIntroFileName(GameFile gameFile, RenameProfile renameProfile) + { + if (renameProfile == RenameProfile.Gamarr || gameFile?.ComponentId <= 0) + { + return null; + } + + var component = _componentRepository.Get(gameFile.ComponentId); + + if (component == null || component.Title.IsNullOrWhiteSpace()) + { + return null; + } + + var catalogEntry = _noIntroEntryRepository.All() + .Where(entry => IsComponentCatalogMatch(component.Title, entry)) + .OrderBy(entry => entry.SystemKey) + .ThenBy(entry => entry.CanonicalName) + .FirstOrDefault(); + + if (catalogEntry == null || catalogEntry.CanonicalFileName.IsNullOrWhiteSpace()) + { + return null; + } + + var actualFileName = GetActualFileName(gameFile); + var expectedFileName = NoIntroRenameProfileEvaluator.GetExpectedFileName(catalogEntry, actualFileName, renameProfile); + return Path.GetFileNameWithoutExtension(expectedFileName); + } + + private static bool IsComponentCatalogMatch(string componentTitle, NoIntroCatalogEntry entry) + { + if (entry == null || entry.CanonicalName.IsNullOrWhiteSpace()) + { + return false; + } + + if (!entry.ParentCanonicalName.IsNullOrWhiteSpace() && entry.ParentCanonicalName == componentTitle) + { + return true; + } + + return entry.CanonicalName == componentTitle || entry.CanonicalName.StartsWith($"{componentTitle} (", StringComparison.Ordinal); + } + + private static string GetActualFileName(GameFile gameFile) + { + if (gameFile.RelativePath.IsNotNullOrWhiteSpace()) + { + return Path.GetFileName(gameFile.RelativePath); + } + + if (gameFile.OriginalFilePath.IsNotNullOrWhiteSpace()) + { + return Path.GetFileName(gameFile.OriginalFilePath); + } + + return Path.GetFileName(gameFile.Path); + } + public string BuildFilePath(Game game, string fileName, string extension) { Ensure.That(extension, () => extension).IsNotNullOrWhiteSpace(); diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroRenameProfileEvaluator.cs b/src/NzbDrone.Core/RomCatalog/NoIntroRenameProfileEvaluator.cs index d1fdc7c2..77e21529 100644 --- a/src/NzbDrone.Core/RomCatalog/NoIntroRenameProfileEvaluator.cs +++ b/src/NzbDrone.Core/RomCatalog/NoIntroRenameProfileEvaluator.cs @@ -7,6 +7,11 @@ public static class NoIntroRenameProfileEvaluator { public static string GetExpectedFileName(NoIntroCatalogEntry catalogEntry, string actualFileName, RenameProfile renameProfile) { + if (renameProfile == RenameProfile.NoIntroPreserveById && !string.IsNullOrWhiteSpace(catalogEntry.NumberedCanonicalFileName)) + { + return catalogEntry.NumberedCanonicalFileName; + } + if (renameProfile == RenameProfile.NoIntroPreserveById && IsByIdFileName(actualFileName, catalogEntry.CanonicalFileName)) { return actualFileName; @@ -22,8 +27,14 @@ public static bool MatchesProfile(NoIntroCatalogEntry catalogEntry, string actua return true; } - return renameProfile == RenameProfile.NoIntroPreserveById && - IsByIdFileName(actualFileName, catalogEntry.CanonicalFileName); + if (renameProfile != RenameProfile.NoIntroPreserveById) + { + return false; + } + + return !string.IsNullOrWhiteSpace(catalogEntry.NumberedCanonicalFileName) + ? actualFileName.Equals(catalogEntry.NumberedCanonicalFileName, StringComparison.Ordinal) + : IsByIdFileName(actualFileName, catalogEntry.CanonicalFileName); } private static bool IsByIdFileName(string actualFileName, string canonicalFileName) From a3355331dd0396b3584dc1bdc7a864d38d5d6a59 Mon Sep 17 00:00:00 2001 From: pfuenzle Date: Sun, 19 Jul 2026 13:26:27 +0000 Subject: [PATCH 13/42] Add No-Intro catalog status API --- .../RomCatalog/NoIntroCatalogController.cs | 26 +++++++++++++++++ .../NoIntroCatalogControllerFixture.cs | 29 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/src/Gamarr.Api.V3/RomCatalog/NoIntroCatalogController.cs b/src/Gamarr.Api.V3/RomCatalog/NoIntroCatalogController.cs index bff9a189..ca18eb2f 100644 --- a/src/Gamarr.Api.V3/RomCatalog/NoIntroCatalogController.cs +++ b/src/Gamarr.Api.V3/RomCatalog/NoIntroCatalogController.cs @@ -33,6 +33,32 @@ public List GetSources() return _sourceRepository.All().Select(x => x.ToResource()).ToList(); } + [HttpGet("status")] + [Produces("application/json")] + public NoIntroCatalogStatusResource GetStatus() + { + var entryCounts = _entryRepository.All() + .GroupBy(x => x.CatalogSourceId) + .ToDictionary(x => x.Key, x => x.Count()); + + return new NoIntroCatalogStatusResource + { + Sources = _sourceRepository.All() + .OrderBy(x => x.Name) + .Select(source => new NoIntroCatalogSourceStatusResource + { + Id = source.Id, + Name = source.Name, + CatalogVersion = source.CatalogVersion, + LastSuccessfulSync = source.LastSuccessfulSync, + LastAttemptedSync = source.LastAttemptedSync, + LastSyncError = source.LastSyncError, + EntryCount = entryCounts.GetValueOrDefault(source.Id) + }) + .ToList() + }; + } + [HttpGet("entry")] [Produces("application/json")] public List GetEntries([FromQuery] int catalogSourceId) diff --git a/src/NzbDrone.Api.Test/RomCatalog/NoIntroCatalogControllerFixture.cs b/src/NzbDrone.Api.Test/RomCatalog/NoIntroCatalogControllerFixture.cs index d311022e..2e46680a 100644 --- a/src/NzbDrone.Api.Test/RomCatalog/NoIntroCatalogControllerFixture.cs +++ b/src/NzbDrone.Api.Test/RomCatalog/NoIntroCatalogControllerFixture.cs @@ -50,6 +50,35 @@ public void NoIntroApi_should_surface_catalog_source_metadata_and_verification_r Subject.GetVerificationResults().Should().ContainSingle(x => x.Id == 11 && x.VerificationStatus == NoIntroVerificationStatus.NameMismatch && x.IsDuplicate); } + [Test] + public void NoIntroApi_should_surface_catalog_status_counts() + { + Mocker.GetMock() + .Setup(x => x.All()) + .Returns(new List + { + new NoIntroCatalogSource + { + Id = 7, + Name = "No-Intro Nintendo DS", + CatalogVersion = "2026.05.02", + LastSuccessfulSync = new DateTime(2026, 7, 19, 0, 0, 0, DateTimeKind.Utc) + } + }); + + Mocker.GetMock() + .Setup(x => x.All()) + .Returns(new List + { + Entry(7, "nintendo---nintendo-ds", "Mario Kart DS (Europe) (En,Fr,De,Es,It)"), + Entry(7, "nintendo---nintendo-ds", "Mario Kart DS (Japan)") + }); + + var status = Subject.GetStatus(); + + status.Sources.Should().ContainSingle(x => x.Name == "No-Intro Nintendo DS" && x.CatalogVersion == "2026.05.02" && x.EntryCount == 2); + } + [Test] public void NoIntroApi_should_surface_region_download_play_and_standalone_component_plan() { From de27534d422e97c01686df02964b20233245733f Mon Sep 17 00:00:00 2001 From: pfuenzle Date: Sun, 19 Jul 2026 13:26:27 +0000 Subject: [PATCH 14/42] Match game components by ROM hash --- .../GameComponents/GameComponentController.cs | 92 ++++++++++++++- .../GameComponents/GameComponentResource.cs | 105 +++++++++++++++++- .../GameComponentResourceMapperFixture.cs | 83 ++++++++++++++ 3 files changed, 273 insertions(+), 7 deletions(-) create mode 100644 src/NzbDrone.Api.Test/GameComponents/GameComponentResourceMapperFixture.cs diff --git a/src/Gamarr.Api.V3/GameComponents/GameComponentController.cs b/src/Gamarr.Api.V3/GameComponents/GameComponentController.cs index 8dcfe35b..5a7c91eb 100644 --- a/src/Gamarr.Api.V3/GameComponents/GameComponentController.cs +++ b/src/Gamarr.Api.V3/GameComponents/GameComponentController.cs @@ -1,9 +1,12 @@ using System.Collections.Generic; using System.Linq; using Microsoft.AspNetCore.Mvc; +using NzbDrone.Common.Disk; +using NzbDrone.Core.Games; using NzbDrone.Core.Games.Components; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Profiles.Qualities; +using NzbDrone.Core.RomCatalog; using Gamarr.Http; using Gamarr.Http.REST; @@ -14,15 +17,30 @@ public class GameComponentController : Controller { private readonly IGameComponentService _componentService; private readonly IMediaFileService _mediaFileService; + private readonly IGameService _gameService; private readonly IQualityProfileService _qualityProfileService; + private readonly INoIntroCatalogEntryRepository _noIntroCatalogEntryRepository; + private readonly INoIntroCatalogSourceRepository _noIntroCatalogSourceRepository; + private readonly INoIntroCatalogHashRepository _noIntroCatalogHashRepository; + private readonly IDiskProvider _diskProvider; public GameComponentController(IGameComponentService componentService, - IMediaFileService mediaFileService, - IQualityProfileService qualityProfileService) + IMediaFileService mediaFileService, + IGameService gameService, + IQualityProfileService qualityProfileService, + INoIntroCatalogEntryRepository noIntroCatalogEntryRepository, + INoIntroCatalogSourceRepository noIntroCatalogSourceRepository, + INoIntroCatalogHashRepository noIntroCatalogHashRepository, + IDiskProvider diskProvider) { _componentService = componentService; _mediaFileService = mediaFileService; + _gameService = gameService; _qualityProfileService = qualityProfileService; + _noIntroCatalogEntryRepository = noIntroCatalogEntryRepository; + _noIntroCatalogSourceRepository = noIntroCatalogSourceRepository; + _noIntroCatalogHashRepository = noIntroCatalogHashRepository; + _diskProvider = diskProvider; } [HttpGet] @@ -35,11 +53,22 @@ public List GetComponents([FromQuery] int gameId) } var files = _mediaFileService.GetFilesByGame(gameId); + var noIntroEntries = _noIntroCatalogEntryRepository.All().ToList(); + var noIntroSources = _noIntroCatalogSourceRepository.All().ToList(); + var noIntroHashes = _noIntroCatalogHashRepository.GetByEntryIds(noIntroEntries.Select(x => x.Id).ToList()); + var game = _gameService.GetGame(gameId); + var context = new GameComponentNoIntroCatalogContext + { + GameFiles = files, + Entries = noIntroEntries, + Sources = noIntroSources, + HashMatches = GetFileHashMatches(files, game, noIntroHashes) + }; return _componentService.GetByGame(gameId) .OrderBy(c => c.ComponentType) .ThenBy(c => c.Title) - .Select(c => c.ToResource(files)) + .Select(c => c.ToResource(context)) .ToList(); } @@ -56,7 +85,62 @@ public GameComponentResource SetComponent(int id, [FromBody] GameComponentResour var component = _componentService.SetComponentOptions(id, resource.Monitored, resource.QualityProfileId); var files = _mediaFileService.GetFilesByGame(component.GameId); - return component.ToResource(files); + return component.ToResource(new GameComponentNoIntroCatalogContext { GameFiles = files }); + } + + private List GetFileHashMatches(List files, Game game, List catalogHashes) + { + var hashByKey = catalogHashes + .GroupBy(x => $"{x.HashType}:{x.HashValue}".ToLowerInvariant()) + .ToDictionary(x => x.Key, x => x.First()); + var matches = new List(); + + foreach (var file in files.Where(x => x.ComponentId > 0)) + { + var path = file.GetPath(game); + + if (!_diskProvider.FileExists(path)) + { + continue; + } + + using var stream = _diskProvider.OpenReadStream(path); + var hashes = NoIntroRomHasher.Compute(stream); + var matchedHash = FindMatch(hashes, hashByKey); + + if (matchedHash == null) + { + continue; + } + + matches.Add(new NoIntroCatalogFileHashMatch + { + GameFileId = file.Id, + CatalogEntryId = matchedHash.CatalogEntryId, + HashType = matchedHash.HashType, + HashValue = matchedHash.HashValue + }); + } + + return matches; + } + + private static NoIntroCatalogHash FindMatch(NoIntroHashTriplet hashes, Dictionary catalogHashes) + { + return TryGetHash("sha1", hashes.Sha1, catalogHashes) ?? + TryGetHash("md5", hashes.Md5, catalogHashes) ?? + TryGetHash("crc32", hashes.Crc32, catalogHashes); + } + + private static NoIntroCatalogHash TryGetHash(string hashType, string hashValue, Dictionary catalogHashes) + { + if (string.IsNullOrWhiteSpace(hashValue)) + { + return null; + } + + catalogHashes.TryGetValue($"{hashType}:{hashValue}".ToLowerInvariant(), out var matchedHash); + return matchedHash; } } } diff --git a/src/Gamarr.Api.V3/GameComponents/GameComponentResource.cs b/src/Gamarr.Api.V3/GameComponents/GameComponentResource.cs index f74eeeaa..bbb382f4 100644 --- a/src/Gamarr.Api.V3/GameComponents/GameComponentResource.cs +++ b/src/Gamarr.Api.V3/GameComponents/GameComponentResource.cs @@ -2,6 +2,7 @@ using System.Linq; using NzbDrone.Core.Games.Components; using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.RomCatalog; using Gamarr.Http.REST; namespace Gamarr.Api.V3.GameComponents @@ -22,13 +23,44 @@ public class GameComponentResource : RestResource // component without a file is "missing". public bool HasFile { get; set; } public long SizeOnDisk { get; set; } + public List NoIntroCatalogMatches { get; set; } = new List(); + } + + public class GameComponentNoIntroCatalogResource : RestResource + { + public int CatalogSourceId { get; set; } + public string SourceName { get; set; } + public string SystemKey { get; set; } + public string CanonicalName { get; set; } + public string CanonicalFileName { get; set; } + public string CatalogVersion { get; set; } + public string LastSyncError { get; set; } + public string HashType { get; set; } + public string HashValue { get; set; } + } + + public class GameComponentNoIntroCatalogContext + { + public List GameFiles { get; set; } = new List(); + public List Entries { get; set; } = new List(); + public List Sources { get; set; } = new List(); + public List HashMatches { get; set; } = new List(); + } + + public class NoIntroCatalogFileHashMatch + { + public int GameFileId { get; set; } + public int CatalogEntryId { get; set; } + public string HashType { get; set; } + public string HashValue { get; set; } } public static class GameComponentResourceMapper { - public static GameComponentResource ToResource(this GameComponent model, List gameFiles) + public static GameComponentResource ToResource(this GameComponent model, GameComponentNoIntroCatalogContext context = null) { - var files = gameFiles?.Where(f => f.ComponentId == model.Id).ToList() ?? new List(); + context ??= new GameComponentNoIntroCatalogContext(); + var files = context.GameFiles.Where(f => f.ComponentId == model.Id).ToList(); return new GameComponentResource { @@ -41,8 +73,75 @@ public static GameComponentResource ToResource(this GameComponent model, List f.Size) + SizeOnDisk = files.Sum(f => f.Size), + NoIntroCatalogMatches = GetNoIntroCatalogMatches(model, files, context) }; } + + private static List GetNoIntroCatalogMatches(GameComponent component, List files, GameComponentNoIntroCatalogContext context) + { + if (context.Entries.Count == 0 || string.IsNullOrWhiteSpace(component.Title)) + { + return new List(); + } + + var sourceById = context.Sources.ToDictionary(x => x.Id); + var exactMatches = GetExactHashMatches(files, context); + var entries = exactMatches.Count > 0 ? exactMatches.Select(x => x.Entry) : context.Entries.Where(entry => IsComponentCatalogMatch(component.Title, entry)); + + return entries + .OrderBy(entry => entry.SystemKey) + .ThenBy(entry => entry.CanonicalName) + .Take(20) + .Select(entry => + { + sourceById.TryGetValue(entry.CatalogSourceId, out var source); + var hashMatch = exactMatches + .Where(x => x.Entry.Id == entry.Id) + .Select(x => x.HashMatch) + .FirstOrDefault(); + + return new GameComponentNoIntroCatalogResource + { + Id = entry.Id, + CatalogSourceId = entry.CatalogSourceId, + SourceName = source?.Name, + SystemKey = entry.SystemKey, + CanonicalName = entry.CanonicalName, + CanonicalFileName = entry.CanonicalFileName, + CatalogVersion = source?.CatalogVersion, + LastSyncError = source?.LastSyncError, + HashType = hashMatch?.HashType, + HashValue = hashMatch?.HashValue + }; + }) + .ToList(); + } + + private static List<(NoIntroCatalogEntry Entry, NoIntroCatalogFileHashMatch HashMatch)> GetExactHashMatches(List files, GameComponentNoIntroCatalogContext context) + { + var fileIds = files.Select(x => x.Id).ToHashSet(); + var entryById = context.Entries.ToDictionary(x => x.Id); + + return context.HashMatches + .Where(match => fileIds.Contains(match.GameFileId) && entryById.ContainsKey(match.CatalogEntryId)) + .Select(match => (entryById[match.CatalogEntryId], match)) + .ToList(); + } + + private static bool IsComponentCatalogMatch(string componentTitle, NoIntroCatalogEntry entry) + { + if (entry == null || string.IsNullOrWhiteSpace(entry.CanonicalName)) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(entry.ParentCanonicalName) && entry.ParentCanonicalName == componentTitle) + { + return true; + } + + return entry.CanonicalName == componentTitle || entry.CanonicalName.StartsWith($"{componentTitle} (", global::System.StringComparison.Ordinal); + } } } diff --git a/src/NzbDrone.Api.Test/GameComponents/GameComponentResourceMapperFixture.cs b/src/NzbDrone.Api.Test/GameComponents/GameComponentResourceMapperFixture.cs new file mode 100644 index 00000000..8034d013 --- /dev/null +++ b/src/NzbDrone.Api.Test/GameComponents/GameComponentResourceMapperFixture.cs @@ -0,0 +1,83 @@ +using System.Collections.Generic; +using FluentAssertions; +using NUnit.Framework; +using Gamarr.Api.V3.GameComponents; +using NzbDrone.Core.Games.Components; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.RomCatalog; + +namespace NzbDrone.Api.Test.GameComponents +{ + [TestFixture] + public class GameComponentResourceMapperFixture + { + [Test] + public void should_prefer_hash_matched_nointro_entry_over_title_matches() + { + var component = new GameComponent + { + Id = 50, + GameId = 51, + Title = "Mario Kart DS" + }; + + var context = new GameComponentNoIntroCatalogContext + { + GameFiles = new List + { + new GameFile + { + Id = 41, + ComponentId = 50, + Size = 33554432 + } + }, + Entries = new List + { + Entry(1, "Mario Kart DS (Europe) (En,Fr,De,Es,It)"), + Entry(2, "Mario Kart DS (Japan)"), + Entry(3, "Mario Kart DS (USA, Australia) (En,Fr,De,Es,It)") + }, + Sources = new List + { + new NoIntroCatalogSource + { + Id = 7, + Name = "No-Intro Nintendo DS", + CatalogVersion = "2026.05.02" + } + }, + HashMatches = new List + { + new NoIntroCatalogFileHashMatch + { + GameFileId = 41, + CatalogEntryId = 1, + HashType = "sha1", + HashValue = "CE97D9B43F0D3CA0D48B781983E8A16F6393378F" + } + } + }; + + var resource = component.ToResource(context); + + resource.NoIntroCatalogMatches.Should().ContainSingle() + .Subject.Should().Match(match => + match.CanonicalFileName == "Mario Kart DS (Europe) (En,Fr,De,Es,It).nds" && + match.HashType == "sha1" && + match.HashValue == "CE97D9B43F0D3CA0D48B781983E8A16F6393378F"); + } + + private static NoIntroCatalogEntry Entry(int id, string canonicalName) + { + return new NoIntroCatalogEntry + { + Id = id, + CatalogSourceId = 7, + SystemKey = "nintendo---nintendo-ds", + CanonicalName = canonicalName, + CanonicalFileName = $"{canonicalName}.nds" + }; + } + } +} From f58f6e37f51ae8634a6971b40feeae5f4b42659b Mon Sep 17 00:00:00 2001 From: pfuenzle Date: Sun, 19 Jul 2026 13:26:56 +0000 Subject: [PATCH 15/42] Show No-Intro matches in components UI --- .../Components/GameComponentsTable.tsx | 93 ++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/frontend/src/Game/Details/Components/GameComponentsTable.tsx b/frontend/src/Game/Details/Components/GameComponentsTable.tsx index 54b29010..54521ea2 100644 --- a/frontend/src/Game/Details/Components/GameComponentsTable.tsx +++ b/frontend/src/Game/Details/Components/GameComponentsTable.tsx @@ -5,6 +5,7 @@ import { GAME_COMPONENT_SEARCH } from 'Commands/commandNames'; import EnhancedSelectInput from 'Components/Form/Select/EnhancedSelectInput'; import Icon from 'Components/Icon'; import Label from 'Components/Label'; +import IconButton from 'Components/Link/IconButton'; import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import MonitorToggleButton from 'Components/MonitorToggleButton'; @@ -18,6 +19,7 @@ import createCommandExecutingSelector from 'Store/Selectors/createCommandExecuti import createAjaxRequest from 'Utilities/createAjaxRequest'; import formatBytes from 'Utilities/Number/formatBytes'; import translate from 'Utilities/String/translate'; +import GameInteractiveSearchModal from '../../Search/GameInteractiveSearchModal'; interface GameComponent { id: number; @@ -30,6 +32,20 @@ interface GameComponent { qualityProfileId: number; hasFile: boolean; sizeOnDisk: number; + noIntroCatalogMatches: NoIntroCatalogMatch[]; +} + +interface NoIntroCatalogMatch { + id: number; + catalogSourceId: number; + sourceName?: string; + systemKey: string; + canonicalName: string; + canonicalFileName: string; + catalogVersion?: string; + lastSyncError?: string; + hashType?: string; + hashValue?: string; } const columns = [ @@ -37,6 +53,7 @@ const columns = [ { name: 'title', label: () => translate('Title'), isVisible: true }, { name: 'size', label: () => translate('Size'), isVisible: true }, { name: 'status', label: () => translate('Status'), isVisible: true }, + { name: 'noIntro', label: () => 'No-Intro', isVisible: true }, { name: 'qualityProfileId', label: () => translate('QualityProfile'), @@ -55,6 +72,13 @@ const typeKinds: Record< dlc: 'primary', }; +const noIntroSystemNames: Record = { + 'nintendo---game-boy': 'Nintendo Game Boy', + 'nintendo---game-boy-color': 'Nintendo Game Boy Color', + 'nintendo---game-boy-advance': 'Nintendo Game Boy Advance', + 'nintendo---nintendo-ds': 'Nintendo DS', +}; + interface GameComponentsTableProps { gameId: number; } @@ -89,6 +113,47 @@ function getStatusIcon(component: GameComponent) { ); } +function getNoIntroStatus(component: GameComponent) { + const matches = component.noIntroCatalogMatches ?? []; + + if (matches.length === 0) { + return '-'; + } + + const match = matches[0]; + + if (match == null) { + return '-'; + } + + const suffix = matches.length > 1 ? ` +${matches.length - 1}` : ''; + const version = match.catalogVersion ? ` (${match.catalogVersion})` : ''; + const systemName = noIntroSystemNames[match.systemKey] ?? match.systemKey; + const title = [ + `${match.sourceName ?? 'No-Intro'}${version}`, + ...matches.flatMap((catalogMatch) => { + const lines = [catalogMatch.canonicalFileName]; + + if (catalogMatch.hashType && catalogMatch.hashValue) { + lines.push( + `${catalogMatch.hashType.toUpperCase()} ${catalogMatch.hashValue}` + ); + } + + return lines; + }), + ].join('\n'); + + return ( + + ); +} + interface GameComponentRowProps { component: GameComponent; isSaving: boolean; @@ -105,6 +170,8 @@ function GameComponentRow({ onProfileChange, }: GameComponentRowProps) { const dispatch = useDispatch(); + const [isInteractiveSearchModalOpen, setIsInteractiveSearchModalOpen] = + useState(false); const isSearching = useSelector( useMemo( @@ -133,6 +200,14 @@ function GameComponentRow({ ); }, [dispatch, component.gameId, component.id]); + const handleInteractiveSearchPress = useCallback(() => { + setIsInteractiveSearchModalOpen(true); + }, []); + + const handleInteractiveSearchModalClose = useCallback(() => { + setIsInteractiveSearchModalOpen(false); + }, []); + const handleProfileChange = useCallback( ({ value }: { value: number }) => { onProfileChange(component.id, value); @@ -156,6 +231,8 @@ function GameComponentRow({ {getStatusIcon(component)} + {getNoIntroStatus(component)} + {component.componentType === 'dlc' ? ( + + + + ); From 474c8ea4f8ee16bd2404508a316bc9c9d85678d7 Mon Sep 17 00:00:00 2001 From: pfuenzle Date: Sun, 19 Jul 2026 13:26:56 +0000 Subject: [PATCH 16/42] Show No-Intro status in metadata settings --- .../Metadata/Options/MetadataOptions.tsx | 74 ++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/frontend/src/Settings/Metadata/Options/MetadataOptions.tsx b/frontend/src/Settings/Metadata/Options/MetadataOptions.tsx index d6e46488..ccf1ac90 100644 --- a/frontend/src/Settings/Metadata/Options/MetadataOptions.tsx +++ b/frontend/src/Settings/Metadata/Options/MetadataOptions.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import Alert from 'Components/Alert'; import FieldSet from 'Components/FieldSet'; @@ -20,6 +20,7 @@ import { OnChildStateChange, SetChildSave, } from 'typings/Settings/SettingsState'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; import translate from 'Utilities/String/translate'; const SECTION = 'metadataOptions'; @@ -45,11 +46,37 @@ interface MetadataOptionsProps { onChildStateChange: OnChildStateChange; } +interface NoIntroCatalogSourceStatus { + id: number; + name: string; + catalogVersion?: string; + lastSuccessfulSync?: string; + lastAttemptedSync?: string; + lastSyncError?: string; + entryCount: number; +} + +interface NoIntroCatalogStatus { + sources: NoIntroCatalogSourceStatus[]; +} + +function fetchNoIntroCatalogStatus() { + return createAjaxRequest({ + url: '/romcatalog/status', + dataType: 'json', + }).request; +} + function MetadataOptions({ setChildSave, onChildStateChange, }: MetadataOptionsProps) { const dispatch = useDispatch(); + const [noIntroStatus, setNoIntroStatus] = + useState(null); + const [noIntroStatusError, setNoIntroStatusError] = useState( + null + ); const { isFetching, isPopulated, @@ -72,6 +99,19 @@ function MetadataOptions({ setChildSave(() => dispatch(saveMetadataOptions())); }, [dispatch, setChildSave]); + useEffect(() => { + fetchNoIntroCatalogStatus() + .done(setNoIntroStatus) + .fail((error: unknown) => { + if (error instanceof Error) { + setNoIntroStatusError(error.message); + return; + } + + setNoIntroStatusError('Unable to load No-Intro catalog status'); + }); + }, []); + useEffect(() => { onChildStateChange({ isSaving, @@ -147,6 +187,38 @@ function MetadataOptions({ {...settings.rawgApiKey} /> + + {noIntroStatusError ? ( + + No-Intro DB + +
{noIntroStatusError}
+
+ ) : null} + + {noIntroStatus ? ( + + No-Intro DB + +
+ {noIntroStatus.sources.map((source) => { + const version = source.catalogVersion ?? 'unknown version'; + const synced = source.lastSuccessfulSync + ? new Date(source.lastSuccessfulSync).toLocaleString() + : 'never synced'; + const status = `${source.name}: ${version}, ${source.entryCount} entries, synced ${synced}`; + + return ( +
+ {source.lastSyncError + ? `${status} (${source.lastSyncError})` + : status} +
+ ); + })} +
+
+ ) : null} ) : null} From b69aeb635aaf715c3aa90c98137879c5426b7018 Mon Sep 17 00:00:00 2001 From: pfuenzle Date: Sun, 19 Jul 2026 13:26:56 +0000 Subject: [PATCH 17/42] Surface Nintendo platform choices in UI --- .../AddGame/AddNewGame/AddNewGameModalContent.tsx | 8 ++++++++ frontend/src/Game/Edit/EditGameModalContent.tsx | 8 ++++++++ .../src/Game/Search/GameInteractiveSearchModal.tsx | 4 ++++ .../Search/GameInteractiveSearchModalContent.tsx | 13 ++++++++++--- .../InteractiveSearch/InteractiveSearchPayload.ts | 1 + .../src/Settings/MediaManagement/Naming/Naming.tsx | 2 +- .../Quality/EditQualityProfileModalContent.tsx | 8 ++++++++ src/NzbDrone.Core/Localization/Core/en.json | 2 +- 8 files changed, 41 insertions(+), 5 deletions(-) diff --git a/frontend/src/AddGame/AddNewGame/AddNewGameModalContent.tsx b/frontend/src/AddGame/AddNewGame/AddNewGameModalContent.tsx index 1dc46555..505a75ad 100644 --- a/frontend/src/AddGame/AddNewGame/AddNewGameModalContent.tsx +++ b/frontend/src/AddGame/AddNewGame/AddNewGameModalContent.tsx @@ -57,6 +57,14 @@ const platformOptions = [ { key: 'playStation', value: 'PlayStation' }, { key: 'xbox', value: 'Xbox' }, { key: 'nintendo', value: 'Nintendo' }, + { key: 'nintendoSwitch', value: 'Nintendo Switch' }, + { key: 'nintendoWiiU', value: 'Nintendo Wii U' }, + { key: 'nintendoWii', value: 'Nintendo Wii' }, + { key: 'nintendo3DS', value: 'Nintendo 3DS' }, + { key: 'nintendoDS', value: 'Nintendo DS' }, + { key: 'nintendoGBA', value: 'Nintendo Game Boy Advance' }, + { key: 'nintendoGBC', value: 'Nintendo Game Boy Color' }, + { key: 'nintendoGB', value: 'Nintendo Game Boy' }, ]; function AddNewGameModalContent(props: AddNewGameModalContentProps) { diff --git a/frontend/src/Game/Edit/EditGameModalContent.tsx b/frontend/src/Game/Edit/EditGameModalContent.tsx index 4816e97d..d822d656 100644 --- a/frontend/src/Game/Edit/EditGameModalContent.tsx +++ b/frontend/src/Game/Edit/EditGameModalContent.tsx @@ -47,6 +47,14 @@ const platformOptions = [ { key: 'playStation', value: 'PlayStation' }, { key: 'xbox', value: 'Xbox' }, { key: 'nintendo', value: 'Nintendo' }, + { key: 'nintendoSwitch', value: 'Nintendo Switch' }, + { key: 'nintendoWiiU', value: 'Nintendo Wii U' }, + { key: 'nintendoWii', value: 'Nintendo Wii' }, + { key: 'nintendo3DS', value: 'Nintendo 3DS' }, + { key: 'nintendoDS', value: 'Nintendo DS' }, + { key: 'nintendoGBA', value: 'Nintendo Game Boy Advance' }, + { key: 'nintendoGBC', value: 'Nintendo Game Boy Color' }, + { key: 'nintendoGB', value: 'Nintendo Game Boy' }, ]; function EditGameModalContent({ diff --git a/frontend/src/Game/Search/GameInteractiveSearchModal.tsx b/frontend/src/Game/Search/GameInteractiveSearchModal.tsx index e24af5c9..d07fe9eb 100644 --- a/frontend/src/Game/Search/GameInteractiveSearchModal.tsx +++ b/frontend/src/Game/Search/GameInteractiveSearchModal.tsx @@ -19,6 +19,8 @@ interface GameInteractiveSearchModalProps extends GameInteractiveSearchModalCont function GameInteractiveSearchModal({ isOpen, gameId, + componentId, + componentTitle, onModalClose, }: GameInteractiveSearchModalProps) { const dispatch = useDispatch(); @@ -42,6 +44,8 @@ function GameInteractiveSearchModal({ > diff --git a/frontend/src/Game/Search/GameInteractiveSearchModalContent.tsx b/frontend/src/Game/Search/GameInteractiveSearchModalContent.tsx index eb6b24dd..53ed1732 100644 --- a/frontend/src/Game/Search/GameInteractiveSearchModalContent.tsx +++ b/frontend/src/Game/Search/GameInteractiveSearchModalContent.tsx @@ -19,11 +19,15 @@ import translate from 'Utilities/String/translate'; export interface GameInteractiveSearchModalContentProps { gameId: number; + componentId?: number; + componentTitle?: string; onModalClose(): void; } function GameInteractiveSearchModalContent({ gameId, + componentId, + componentTitle, onModalClose, }: GameInteractiveSearchModalContentProps) { const dispatch = useDispatch(); @@ -41,19 +45,22 @@ function GameInteractiveSearchModalContent({ }, [dispatch]); const gameTitle = `${title}${year > 0 ? ` (${year})` : ''}`; + const modalTitle = componentTitle + ? `${gameTitle} - ${componentTitle}` + : gameTitle; return ( - {gameTitle + {modalTitle ? translate('InteractiveSearchModalHeaderTitle', { - title: gameTitle, + title: modalTitle, }) : translate('InteractiveSearchModalHeader')} - + diff --git a/frontend/src/InteractiveSearch/InteractiveSearchPayload.ts b/frontend/src/InteractiveSearch/InteractiveSearchPayload.ts index 7217185c..6c96bf19 100644 --- a/frontend/src/InteractiveSearch/InteractiveSearchPayload.ts +++ b/frontend/src/InteractiveSearch/InteractiveSearchPayload.ts @@ -1,5 +1,6 @@ interface GameSearchPayload { gameId: number; + componentId?: number; } type InteractiveSearchPayload = GameSearchPayload; diff --git a/frontend/src/Settings/MediaManagement/Naming/Naming.tsx b/frontend/src/Settings/MediaManagement/Naming/Naming.tsx index 3a4bbfec..57150424 100644 --- a/frontend/src/Settings/MediaManagement/Naming/Naming.tsx +++ b/frontend/src/Settings/MediaManagement/Naming/Naming.tsx @@ -130,7 +130,7 @@ function Naming() { { key: 'gamarr', value: translate('RenameProfileGamarr') }, { key: 'noIntroPreserveById', - value: translate('RenameProfileNoIntroPreserveById'), + value: translate('RenameProfileNoIntroNumbered'), }, { key: 'noIntroCanonical', diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.tsx b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.tsx index 37091a98..a1cc87e7 100644 --- a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.tsx +++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.tsx @@ -31,6 +31,14 @@ const platformOptions = [ { key: 2, value: 'PlayStation', order: 4 }, { key: 3, value: 'Xbox', order: 5 }, { key: 4, value: 'Nintendo', order: 6 }, + { key: 10, value: 'Nintendo Switch', order: 7 }, + { key: 11, value: 'Nintendo Wii U', order: 8 }, + { key: 12, value: 'Nintendo Wii', order: 9 }, + { key: 13, value: 'Nintendo 3DS', order: 10 }, + { key: 14, value: 'Nintendo DS', order: 11 }, + { key: 15, value: 'Nintendo Game Boy Advance', order: 12 }, + { key: 17, value: 'Nintendo Game Boy Color', order: 13 }, + { key: 16, value: 'Nintendo Game Boy', order: 14 }, ]; interface PendingValue { diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index c0b311aa..b4aa6bd2 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -1014,7 +1014,7 @@ "InstanceNameHelpText": "Instance name in tab and for Syslog app name", "InteractiveImport": "Interactive Import", "InteractiveImportLoadError": "Unable to load manual import items", - "InteractiveImportNoFilesFound": "No video files were found in the selected folder", + "InteractiveImportNoFilesFound": "No game files found", "InteractiveImportNoGame": "Game must be chosen for each selected file", "InteractiveImportNoImportMode": "An import mode must be selected", "InteractiveImportNoLanguage": "Language must be chosen for each selected file", From 431bac81d94aa93197e75ac996baf33d57cd9218 Mon Sep 17 00:00:00 2001 From: pfuenzle Date: Sun, 19 Jul 2026 16:16:45 +0000 Subject: [PATCH 18/42] Seed additional No-Intro catalog sources --- .../NoIntroCatalogSyncServiceFixture.cs | 6 +++ ...seed_additional_nointro_catalog_sources.cs | 48 +++++++++++++++++++ .../NoIntroCatalogDocumentClient.cs | 7 +++ .../RomCatalog/NoIntroCatalogSyncService.cs | 42 +++++++++++++++- 4 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 src/NzbDrone.Core/Datastore/Migration/013_seed_additional_nointro_catalog_sources.cs diff --git a/src/NzbDrone.Core.Test/RomCatalog/NoIntroCatalogSyncServiceFixture.cs b/src/NzbDrone.Core.Test/RomCatalog/NoIntroCatalogSyncServiceFixture.cs index eaf941e7..076305f3 100644 --- a/src/NzbDrone.Core.Test/RomCatalog/NoIntroCatalogSyncServiceFixture.cs +++ b/src/NzbDrone.Core.Test/RomCatalog/NoIntroCatalogSyncServiceFixture.cs @@ -156,6 +156,12 @@ public void sync_should_seed_missing_default_game_boy_sources() sources.Should().Contain(x => x.Name == "No-Intro Nintendo Game Boy"); sources.Should().Contain(x => x.Name == "No-Intro Nintendo Game Boy Color"); sources.Should().Contain(x => x.Name == "No-Intro Nintendo Game Boy Advance"); + sources.Should().Contain(x => x.Name == "No-Intro Nintendo DS Download Play"); + sources.Should().Contain(x => x.Name == "No-Intro Nintendo DS DSvision SD Cards" && x.SourceUrl == "datomatic://system/319"); + sources.Should().Contain(x => x.Name == "No-Intro Nintendo Game Boy Advance Multiboot" && x.SourceUrl == "datomatic://system/137"); + sources.Should().Contain(x => x.Name == "No-Intro Nintendo Game Boy Advance e-Reader" && x.SourceUrl == "datomatic://system/41"); + sources.Should().Contain(x => x.Name == "No-Intro Nintendo Game Boy Advance Play-Yan" && x.SourceUrl == "datomatic://system/148"); + sources.Should().Contain(x => x.Name == "No-Intro Nintendo Game Boy Advance Video" && x.SourceUrl == "datomatic://system/297"); sources.Should().ContainSingle(x => x.SourceUrl == existingSource.SourceUrl); } diff --git a/src/NzbDrone.Core/Datastore/Migration/013_seed_additional_nointro_catalog_sources.cs b/src/NzbDrone.Core/Datastore/Migration/013_seed_additional_nointro_catalog_sources.cs new file mode 100644 index 00000000..ade39145 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/013_seed_additional_nointro_catalog_sources.cs @@ -0,0 +1,48 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(13)] + public class seed_additional_nointro_catalog_sources : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Insert.IntoTable("NoIntroCatalogSources").Row(new + { + Name = "No-Intro Nintendo DS Download Play", + SourceUrl = "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Nintendo%20DS%20%28Download%20Play%29.dat" + }); + + Insert.IntoTable("NoIntroCatalogSources").Row(new + { + Name = "No-Intro Nintendo DS DSvision SD Cards", + SourceUrl = "datomatic://system/319" + }); + + Insert.IntoTable("NoIntroCatalogSources").Row(new + { + Name = "No-Intro Nintendo Game Boy Advance Multiboot", + SourceUrl = "datomatic://system/137" + }); + + Insert.IntoTable("NoIntroCatalogSources").Row(new + { + Name = "No-Intro Nintendo Game Boy Advance e-Reader", + SourceUrl = "datomatic://system/41" + }); + + Insert.IntoTable("NoIntroCatalogSources").Row(new + { + Name = "No-Intro Nintendo Game Boy Advance Play-Yan", + SourceUrl = "datomatic://system/148" + }); + + Insert.IntoTable("NoIntroCatalogSources").Row(new + { + Name = "No-Intro Nintendo Game Boy Advance Video", + SourceUrl = "datomatic://system/297" + }); + } + } +} diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroCatalogDocumentClient.cs b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogDocumentClient.cs index 528801a5..5ef0f583 100644 --- a/src/NzbDrone.Core/RomCatalog/NoIntroCatalogDocumentClient.cs +++ b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogDocumentClient.cs @@ -16,6 +16,7 @@ public interface INoIntroCatalogDocumentClient public class NoIntroCatalogDocumentClient : INoIntroCatalogDocumentClient { + private const string DatOMaticSourceUrlPrefix = "datomatic://system/"; private static readonly Regex DownloadTokenRegex = new Regex("[0-9a-f]{32})\" value=\"Download!!\"", RegexOptions.Compiled | RegexOptions.IgnoreCase); private readonly IHttpClient _httpClient; @@ -26,6 +27,12 @@ public NoIntroCatalogDocumentClient(IHttpClient httpClient) public string Fetch(string sourceUrl) { + if (sourceUrl.StartsWith(DatOMaticSourceUrlPrefix, StringComparison.OrdinalIgnoreCase)) + { + var systemId = int.Parse(sourceUrl.Substring(DatOMaticSourceUrlPrefix.Length)); + return FetchDatOMaticNumbered(systemId); + } + return _httpClient.Get(new HttpRequest(sourceUrl)).Content; } diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSyncService.cs b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSyncService.cs index e2bb7f3b..fe56c58b 100644 --- a/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSyncService.cs +++ b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSyncService.cs @@ -56,7 +56,17 @@ public void Sync(int? catalogSourceId = null) foreach (var source in sources) { - SyncSource(source); + try + { + SyncSource(source); + } + catch + { + if (catalogSourceId.HasValue) + { + throw; + } + } } } @@ -136,6 +146,12 @@ private void EnsureDefaultSources(List sources) AddDefaultSource(sources, "No-Intro Nintendo Game Boy Color", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Game%20Boy%20Color.dat"); AddDefaultSource(sources, "No-Intro Nintendo Game Boy Advance", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Game%20Boy%20Advance.dat"); AddDefaultSource(sources, "No-Intro Nintendo DS", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Nintendo%20DS.dat"); + AddDefaultSource(sources, "No-Intro Nintendo DS Download Play", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Nintendo%20DS%20%28Download%20Play%29.dat"); + AddDefaultSource(sources, "No-Intro Nintendo DS DSvision SD Cards", "datomatic://system/319"); + AddDefaultSource(sources, "No-Intro Nintendo Game Boy Advance Multiboot", "datomatic://system/137"); + AddDefaultSource(sources, "No-Intro Nintendo Game Boy Advance e-Reader", "datomatic://system/41"); + AddDefaultSource(sources, "No-Intro Nintendo Game Boy Advance Play-Yan", "datomatic://system/148"); + AddDefaultSource(sources, "No-Intro Nintendo Game Boy Advance Video", "datomatic://system/297"); } private void AddDefaultSource(List sources, string name, string sourceUrl) @@ -256,7 +272,19 @@ private static string HashKey(NoIntroCatalogSnapshotHash hash) "nintendo---game-boy" => 46, "nintendo---game-boy-color" => 47, "nintendo---game-boy-advance" => 23, + "nintendo---game-boy-advance-multiboot" => 137, + "nintendo---game-boy-advance--multiboot" => 137, + "nintendo---game-boy-advance-e-reader" => 41, + "nintendo---game-boy-advance--e-reader" => 41, + "nintendo---game-boy-advance-play-yan" => 148, + "nintendo---game-boy-advance--play-yan" => 148, + "nintendo---game-boy-advance-video" => 297, + "nintendo---game-boy-advance--video" => 297, "nintendo---nintendo-ds" => 28, + "nintendo---nintendo-ds-download-play" => 65, + "nintendo---nintendo-ds--download-play" => 65, + "nintendo---nintendo-ds-dsvision-sd-cards" => 319, + "nintendo---nintendo-ds--dsvision-sd-cards" => 319, _ => null }; } @@ -278,7 +306,19 @@ private static PlatformFamily MapPlatformFamily(string systemKey) "nintendo---game-boy" => PlatformFamily.NintendoGB, "nintendo---game-boy-color" => PlatformFamily.NintendoGBC, "nintendo---game-boy-advance" => PlatformFamily.NintendoGBA, + "nintendo---game-boy-advance-multiboot" => PlatformFamily.NintendoGBA, + "nintendo---game-boy-advance--multiboot" => PlatformFamily.NintendoGBA, + "nintendo---game-boy-advance-e-reader" => PlatformFamily.NintendoGBA, + "nintendo---game-boy-advance--e-reader" => PlatformFamily.NintendoGBA, + "nintendo---game-boy-advance-play-yan" => PlatformFamily.NintendoGBA, + "nintendo---game-boy-advance--play-yan" => PlatformFamily.NintendoGBA, + "nintendo---game-boy-advance-video" => PlatformFamily.NintendoGBA, + "nintendo---game-boy-advance--video" => PlatformFamily.NintendoGBA, "nintendo---nintendo-ds" => PlatformFamily.NintendoDS, + "nintendo---nintendo-ds-download-play" => PlatformFamily.NintendoDS, + "nintendo---nintendo-ds--download-play" => PlatformFamily.NintendoDS, + "nintendo---nintendo-ds-dsvision-sd-cards" => PlatformFamily.NintendoDS, + "nintendo---nintendo-ds--dsvision-sd-cards" => PlatformFamily.NintendoDS, _ => PlatformFamily.Nintendo }; } From 5a69d03abeff10a71de893caa0b0f7b8d8eafa16 Mon Sep 17 00:00:00 2001 From: pfuenzle Date: Sun, 19 Jul 2026 16:16:53 +0000 Subject: [PATCH 19/42] Model No-Intro variants in catalog plans --- .../NoIntroComponentClassifierFixture.cs | 73 ++++++++++- .../RomCatalog/NoIntroComponentClassifier.cs | 120 ++++++++++++++++-- 2 files changed, 181 insertions(+), 12 deletions(-) diff --git a/src/NzbDrone.Core.Test/RomCatalog/NoIntroComponentClassifierFixture.cs b/src/NzbDrone.Core.Test/RomCatalog/NoIntroComponentClassifierFixture.cs index a1cef7de..4de9d20c 100644 --- a/src/NzbDrone.Core.Test/RomCatalog/NoIntroComponentClassifierFixture.cs +++ b/src/NzbDrone.Core.Test/RomCatalog/NoIntroComponentClassifierFixture.cs @@ -53,6 +53,44 @@ public void should_build_RegionLanguageComponents_from_catalog_confirmed_entries plan.StandaloneGames.Should().BeEmpty(); } + [Test] + public void should_group_multi_parenthetical_region_language_releases_under_one_game() + { + var plan = _subject.BuildCatalogPlan(new[] + { + Entry("nintendo-ds", "Mario Kart DS (Europe) (En,Fr,De,Es,It)"), + Entry("nintendo-ds", "Mario Kart DS (USA, Australia) (En,Fr,De,Es,It)"), + Entry("nintendo-ds", "Mario Kart DS (Japan)"), + Entry("nintendo-ds", "Mario Kart DS (Korea)") + }); + + var game = plan.Games.Should().ContainSingle(x => x.SystemKey == "nintendo-ds" && x.GameTitle == "Mario Kart DS").Subject; + + game.RegionLanguageComponents.Select(x => x.SlotLabel).Should().BeEquivalentTo( + "Europe (En,Fr,De,Es,It)", + "USA, Australia (En,Fr,De,Es,It)", + "Japan", + "Korea"); + } + + [Test] + public void should_keep_revision_tags_on_the_region_component_label() + { + var plan = _subject.BuildCatalogPlan(new[] + { + Entry("nintendo-gba", "Example Game (USA)"), + Entry("nintendo-gba", "Example Game (USA) (Rev 1)"), + Entry("nintendo-gba", "Example Game (USA) (Rev 2)") + }); + + var game = plan.Games.Should().ContainSingle(x => x.SystemKey == "nintendo-gba" && x.GameTitle == "Example Game").Subject; + + game.RegionLanguageComponents.Select(x => x.SlotLabel).Should().BeEquivalentTo( + "USA", + "USA (Rev 1)", + "USA (Rev 2)"); + } + [Test] public void should_build_DownloadPlayComponents_only_when_parent_mapping_is_explicit() { @@ -69,6 +107,40 @@ public void should_build_DownloadPlayComponents_only_when_parent_mapping_is_expl plan.StandaloneGames.Should().BeEmpty(); } + [Test] + public void should_build_download_play_source_entries_as_multiboot_game_components() + { + var plan = _subject.BuildCatalogPlan(new[] + { + Entry("nintendo---nintendo-ds--download-play", "Mario Kart DS (Europe) (Demo) (Download Station Vol. 1)"), + Entry("nintendo---nintendo-ds--download-play", "Mario Kart DS (USA) (Demo) (Nintendo Channel)") + }); + + var game = plan.Games.Should().ContainSingle(x => x.SystemKey == "nintendo---nintendo-ds--download-play" && x.GameTitle == "Mario Kart DS").Subject; + + game.RegionLanguageComponents.Select(x => x.SlotLabel).Should().BeEquivalentTo( + "Europe (Demo) (Download Station Vol. 1)", + "USA (Demo) (Nintendo Channel)"); + game.RegionLanguageComponents.Should().OnlyContain(x => x.ComponentType == NoIntroRomComponentType.Multiboot); + plan.StandaloneGames.Should().BeEmpty(); + } + + [Test] + public void should_build_kiosk_releases_as_game_variants() + { + var plan = _subject.BuildCatalogPlan(new[] + { + Entry("nintendo-ds", "New Super Mario Bros. Demo (Kiosk)"), + Entry("nintendo-ds", "Pokemon Distribution 2011 (USA) (Wi-Fi Kiosk) (Save Data)") + }); + + plan.Games.Should().ContainSingle(x => x.GameTitle == "New Super Mario Bros.") + .Subject.RegionLanguageComponents.Should().ContainSingle(x => x.SlotLabel == "Kiosk"); + plan.Games.Should().ContainSingle(x => x.GameTitle == "Pokemon Distribution 2011") + .Subject.RegionLanguageComponents.Should().ContainSingle(x => x.SlotLabel == "USA (Wi-Fi Kiosk) (Save Data)"); + plan.StandaloneGames.Should().BeEmpty(); + } + [Test] public void should_build_NoIntroStandaloneGames_for_clean_standalone_products() { @@ -132,7 +204,6 @@ public void should_build_StandaloneProductsNotBaseGameVariants() plan.StandaloneGames.Select(x => x.Title).Should().BeEquivalentTo( "Nintendo - Game Boy Advance (BIOS) (World)", - "New Super Mario Bros. Demo (Kiosk)", "DSvision SD cards - Aquarium Tour (Japan)"); } diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroComponentClassifier.cs b/src/NzbDrone.Core/RomCatalog/NoIntroComponentClassifier.cs index 46283715..4a6fa928 100644 --- a/src/NzbDrone.Core/RomCatalog/NoIntroComponentClassifier.cs +++ b/src/NzbDrone.Core/RomCatalog/NoIntroComponentClassifier.cs @@ -67,6 +67,12 @@ private static void AddEntry(NoIntroCatalogPlan plan, NoIntroCatalogEntry entry) { var canonicalName = entry.CanonicalName ?? string.Empty; + if (IsDownloadPlaySource(entry.SystemKey)) + { + AddDownloadPlaySourceEntry(plan, entry, canonicalName); + return; + } + if (IsDownloadPlay(canonicalName)) { AddDownloadPlay(plan, entry, canonicalName); @@ -84,7 +90,7 @@ private static void AddEntry(NoIntroCatalogPlan plan, NoIntroCatalogEntry entry) return; } - var region = TryParseRegion(canonicalName); + var region = TryParseRegionRelease(canonicalName); if (region == null) { @@ -147,38 +153,125 @@ private static NoIntroCatalogGamePlan GetOrAddGame(NoIntroCatalogPlan plan, stri return game; } - private static RegionRelease TryParseRegion(string canonicalName) + private static RegionRelease TryParseRegionRelease(string canonicalName) { - var closeIndex = canonicalName.LastIndexOf(')'); - var openIndex = canonicalName.LastIndexOf('('); + var tags = ParseTrailingTags(canonicalName); - if (openIndex < 1 || closeIndex != canonicalName.Length - 1 || openIndex >= closeIndex) + if (tags.Count == 0) { return null; } - var label = canonicalName.Substring(openIndex + 1, closeIndex - openIndex - 1); - var title = canonicalName.Substring(0, openIndex).TrimEnd(); + var title = canonicalName.Substring(0, tags[0].OpenIndex).TrimEnd(); - if (string.IsNullOrWhiteSpace(label) || string.IsNullOrWhiteSpace(title)) + if (string.IsNullOrWhiteSpace(title)) { return null; } return new RegionRelease { - GameTitle = title, - SlotLabel = label + GameTitle = NormalizeGameTitle(title, tags), + SlotLabel = BuildSlotLabel(tags) }; } + private static string NormalizeGameTitle(string title, List tags) + { + if (tags.Any(tag => Contains(tag.Label, "Kiosk")) && title.EndsWith(" Demo", StringComparison.Ordinal)) + { + return title.Substring(0, title.Length - " Demo".Length); + } + + return title; + } + + private static List ParseTrailingTags(string canonicalName) + { + var tags = new List(); + var cursor = canonicalName.Length; + + while (cursor > 0 && canonicalName[cursor - 1] == ')') + { + var openIndex = canonicalName.LastIndexOf('(', cursor - 1); + + if (openIndex < 1 || openIndex >= cursor - 1) + { + break; + } + + var label = canonicalName.Substring(openIndex + 1, cursor - openIndex - 2); + + if (string.IsNullOrWhiteSpace(label)) + { + break; + } + + tags.Insert(0, new ReleaseTag + { + OpenIndex = openIndex, + Label = label + }); + + cursor = openIndex; + + while (cursor > 0 && canonicalName[cursor - 1] == ' ') + { + cursor--; + } + } + + return tags; + } + + private static string BuildSlotLabel(List tags) + { + var first = tags[0].Label; + var rest = tags.Skip(1).Select(tag => $"({tag.Label})"); + + return string.Join(" ", new[] { first }.Concat(rest)); + } + private static bool IsDownloadPlay(string canonicalName) { return Contains(canonicalName, "Download Play"); } + private static bool IsDownloadPlaySource(string systemKey) + { + return Contains(systemKey ?? string.Empty, "download-play"); + } + + private static void AddDownloadPlaySourceEntry(NoIntroCatalogPlan plan, NoIntroCatalogEntry entry, string canonicalName) + { + var region = TryParseRegionRelease(canonicalName); + + if (region == null) + { + plan.StandaloneGames.Add(new NoIntroCatalogStandalonePlan + { + Title = canonicalName, + ComponentType = NoIntroRomComponentType.Multiboot + }); + + return; + } + + GetOrAddGame(plan, entry.SystemKey, region.GameTitle).RegionLanguageComponents.Add(new NoIntroCatalogComponentSlot + { + SlotLabel = region.SlotLabel, + CanonicalName = canonicalName, + ComponentType = NoIntroRomComponentType.Multiboot + }); + } + private static bool IsStandaloneProduct(string canonicalName) { + if (Contains(canonicalName, "(Kiosk)")) + { + return false; + } + return Contains(canonicalName, "Game Boy Advance Video") || Contains(canonicalName, "Play-Yan") || Contains(canonicalName, "DSvision") || @@ -186,7 +279,6 @@ private static bool IsStandaloneProduct(string canonicalName) Contains(canonicalName, "(BIOS)") || Contains(canonicalName, " Demo") || Contains(canonicalName, " Prototype") || - Contains(canonicalName, "(Kiosk)") || Contains(canonicalName, "Not for Resale"); } @@ -240,5 +332,11 @@ private class RegionRelease public string GameTitle { get; set; } public string SlotLabel { get; set; } } + + private class ReleaseTag + { + public int OpenIndex { get; set; } + public string Label { get; set; } + } } } From 5db9469df74f6707f2be7587399093204c4ceff3 Mon Sep 17 00:00:00 2001 From: pfuenzle Date: Sun, 19 Jul 2026 16:17:02 +0000 Subject: [PATCH 20/42] Create No-Intro component slots for games --- .../GameTests/GameComponentServiceFixture.cs | 143 ++++++++++++++++++ .../Games/Components/GameComponent.cs | 7 +- .../Games/Components/GameComponentService.cs | 56 ++++++- .../Components/NoIntroGameComponentPlanner.cs | 137 +++++++++++++++++ 4 files changed, 336 insertions(+), 7 deletions(-) create mode 100644 src/NzbDrone.Core/Games/Components/NoIntroGameComponentPlanner.cs diff --git a/src/NzbDrone.Core.Test/GameTests/GameComponentServiceFixture.cs b/src/NzbDrone.Core.Test/GameTests/GameComponentServiceFixture.cs index 6be18489..b2aae39e 100644 --- a/src/NzbDrone.Core.Test/GameTests/GameComponentServiceFixture.cs +++ b/src/NzbDrone.Core.Test/GameTests/GameComponentServiceFixture.cs @@ -7,6 +7,7 @@ using NzbDrone.Core.Games.Components; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.RomCatalog; using NzbDrone.Core.Test.Framework; namespace NzbDrone.Core.Test.GameTests @@ -55,6 +56,18 @@ private List CapturedInserts() return captured ?? new List(); } + private static NoIntroCatalogEntry Entry(string canonicalName, PlatformFamily platform, string numberedCanonicalFileName = null) + { + return new NoIntroCatalogEntry + { + SystemKey = platform == PlatformFamily.NintendoDS ? "nintendo---nintendo-ds" : "nintendo---game-boy-advance", + CanonicalName = canonicalName, + CanonicalFileName = $"{canonicalName}.nds", + NumberedCanonicalFileName = numberedCanonicalFileName, + PlatformFamily = platform + }; + } + [Test] public void should_create_base_and_metadata_dlc_components() { @@ -86,6 +99,136 @@ public void should_create_update_and_imported_dlc_components_from_files() inserted.Should().Contain(c => c.ComponentType == GameComponentType.Dlc && c.Key == "import:Some.Release.DLC-GRP" && c.Monitored); } + [Test] + public void should_create_nointro_catalog_components_for_matching_game_title_and_platform() + { + _game.Platform = PlatformFamily.NintendoDS; + _game.GameMetadata.Value.Title = "Mario Kart DS"; + _game.GameMetadata.Value.DlcReferences = new List(); + + Mocker.GetMock() + .Setup(r => r.All()) + .Returns(new List + { + Entry("Mario Kart DS (Europe) (En,Fr,De,Es,It)", PlatformFamily.NintendoDS), + Entry("Mario Kart DS (USA, Australia) (En,Fr,De,Es,It)", PlatformFamily.NintendoDS), + Entry("Mario Kart DS (Japan)", PlatformFamily.NintendoDS), + Entry("Pokemon Dash (USA)", PlatformFamily.NintendoDS), + Entry("Mario Kart DS (USA)", PlatformFamily.NintendoGBA) + }); + + var inserted = CapturedInserts(); + + inserted.Should().Contain(c => c.ComponentType == GameComponentType.Base && c.Key == "base"); + inserted.Where(c => c.ComponentType == GameComponentType.NoIntroRetailRom).Select(c => c.Title).Should().BeEquivalentTo( + "Europe (En,Fr,De,Es,It)", + "USA, Australia (En,Fr,De,Es,It)", + "Japan"); + inserted.Where(c => c.ComponentType == GameComponentType.NoIntroRetailRom).Should().OnlyContain(c => c.Monitored && c.Key.StartsWith("nointro:retail:")); + } + + [Test] + public void should_create_download_play_components_for_matching_game_title_and_platform() + { + _game.Platform = PlatformFamily.NintendoDS; + _game.GameMetadata.Value.Title = "Mario Kart DS"; + _game.GameMetadata.Value.DlcReferences = new List(); + + Mocker.GetMock() + .Setup(r => r.All()) + .Returns(new List + { + Entry("Mario Kart DS (Europe) (En,Fr,De,Es,It)", PlatformFamily.NintendoDS), + new NoIntroCatalogEntry + { + SystemKey = "nintendo---nintendo-ds--download-play", + CanonicalName = "Mario Kart DS (Europe) (Demo) (Download Station Vol. 1)", + CanonicalFileName = "Mario Kart DS (Europe) (Demo) (Download Station Vol. 1).nds", + PlatformFamily = PlatformFamily.NintendoDS + } + }); + + var inserted = CapturedInserts(); + + inserted.Should().Contain(c => c.ComponentType == GameComponentType.NoIntroRetailRom && c.Title == "Europe (En,Fr,De,Es,It)"); + inserted.Should().Contain(c => c.ComponentType == GameComponentType.NoIntroMultiboot && c.Title == "Europe (Demo) (Download Station Vol. 1)"); + } + + [Test] + public void should_link_nointro_file_to_matching_catalog_component() + { + _game.Platform = PlatformFamily.NintendoDS; + _game.GameMetadata.Value.Title = "Mario Kart DS"; + _game.GameMetadata.Value.DlcReferences = new List(); + + var baseSlot = new GameComponent { Id = 10, GameId = _game.Id, ComponentType = GameComponentType.Base, Key = "base" }; + var europeSlot = new GameComponent { Id = 11, GameId = _game.Id, ComponentType = GameComponentType.NoIntroRetailRom, Key = "nointro:retail:mario-kart-ds-europe-en-fr-de-es-it" }; + var file = new GameFile { Id = 1, GameId = _game.Id, RelativePath = "0201 - Mario Kart DS (Europe) (En,Fr,De,Es,It).nds" }; + + Mocker.GetMock() + .Setup(r => r.GetByGame(_game.Id)) + .Returns(new List { baseSlot, europeSlot }); + + Mocker.GetMock() + .Setup(m => m.GetFilesByGame(_game.Id)) + .Returns(new List { file }); + + Mocker.GetMock() + .Setup(r => r.All()) + .Returns(new List + { + Entry("Mario Kart DS (Europe) (En,Fr,De,Es,It)", PlatformFamily.NintendoDS, "0201 - Mario Kart DS (Europe) (En,Fr,De,Es,It).nds") + }); + + Subject.EnsureComponents(_game); + + file.ComponentId.Should().Be(11); + Mocker.GetMock() + .Verify(m => m.Update(It.Is>(l => l.Count == 1 && l[0].ComponentId == 11)), Times.Once()); + } + + [Test] + public void should_link_folder_backed_nointro_file_to_matching_catalog_component() + { + _game.Platform = PlatformFamily.NintendoDS; + _game.Path = "/games/Mario Kart DS"; + _game.GameMetadata.Value.Title = "Mario Kart DS"; + _game.GameMetadata.Value.DlcReferences = new List(); + + var baseSlot = new GameComponent { Id = 10, GameId = _game.Id, ComponentType = GameComponentType.Base, Key = "base" }; + var europeSlot = new GameComponent { Id = 11, GameId = _game.Id, ComponentType = GameComponentType.NoIntroRetailRom, Key = "nointro:retail:mario-kart-ds-europe-en-fr-de-es-it", Title = "Europe (En,Fr,De,Es,It)" }; + var file = new GameFile { Id = 1, GameId = _game.Id, RelativePath = string.Empty }; + + Mocker.GetMock() + .Setup(r => r.GetByGame(_game.Id)) + .Returns(new List { baseSlot, europeSlot }); + + Mocker.GetMock() + .Setup(m => m.GetFilesByGame(_game.Id)) + .Returns(new List { file }); + + Mocker.GetMock() + .Setup(d => d.FolderExists(_game.Path)) + .Returns(true); + + Mocker.GetMock() + .Setup(d => d.GetFiles(_game.Path, true)) + .Returns(new List { "/games/Mario Kart DS/0201 - Mario Kart DS (Europe) (En,Fr,De,Es,It).nds" }); + + Mocker.GetMock() + .Setup(r => r.All()) + .Returns(new List + { + Entry("Mario Kart DS (Europe) (En,Fr,De,Es,It)", PlatformFamily.NintendoDS, "0201 - Mario Kart DS (Europe) (En,Fr,De,Es,It).nds") + }); + + Subject.EnsureComponents(_game); + + file.ComponentId.Should().Be(11); + Mocker.GetMock() + .Verify(m => m.Update(It.Is>(l => l.Count == 1 && l[0].ComponentId == 11)), Times.Once()); + } + [Test] public void should_be_idempotent_when_components_already_exist() { diff --git a/src/NzbDrone.Core/Games/Components/GameComponent.cs b/src/NzbDrone.Core/Games/Components/GameComponent.cs index bbb3b5bd..6d09571c 100644 --- a/src/NzbDrone.Core/Games/Components/GameComponent.cs +++ b/src/NzbDrone.Core/Games/Components/GameComponent.cs @@ -7,7 +7,12 @@ public enum GameComponentType { Base = 1, Update = 2, - Dlc = 3 + Dlc = 3, + NoIntroRetailRom = 4, + NoIntroMultiboot = 5, + NoIntroVideo = 6, + NoIntroBios = 7, + NoIntroRomhackOrUnverified = 8 } /// diff --git a/src/NzbDrone.Core/Games/Components/GameComponentService.cs b/src/NzbDrone.Core/Games/Components/GameComponentService.cs index fa0e74c1..0bbfe059 100644 --- a/src/NzbDrone.Core/Games/Components/GameComponentService.cs +++ b/src/NzbDrone.Core/Games/Components/GameComponentService.cs @@ -1,12 +1,15 @@ using System; using System.Collections.Generic; using System.Linq; +using System.IO; using NLog; +using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; using NzbDrone.Core.Games.Events; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles.Events; using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.RomCatalog; namespace NzbDrone.Core.Games.Components { @@ -28,16 +31,24 @@ public class GameComponentService : IGameComponentService, private readonly IGameComponentRepository _componentRepository; private readonly IMediaFileService _mediaFileService; private readonly IGameService _gameService; + private readonly INoIntroCatalogEntryRepository _noIntroCatalogEntryRepository; + private readonly IDiskProvider _diskProvider; + private readonly NoIntroGameComponentPlanner _noIntroComponentPlanner; private readonly Logger _logger; public GameComponentService(IGameComponentRepository componentRepository, - IMediaFileService mediaFileService, - IGameService gameService, - Logger logger) + IMediaFileService mediaFileService, + IGameService gameService, + INoIntroCatalogEntryRepository noIntroCatalogEntryRepository, + IDiskProvider diskProvider, + Logger logger) { _componentRepository = componentRepository; _mediaFileService = mediaFileService; _gameService = gameService; + _noIntroCatalogEntryRepository = noIntroCatalogEntryRepository; + _diskProvider = diskProvider; + _noIntroComponentPlanner = new NoIntroGameComponentPlanner(new NoIntroComponentClassifier()); _logger = logger; } @@ -90,6 +101,7 @@ public void EnsureComponents(Game game) { var existing = _componentRepository.GetByGame(game.Id); var files = _mediaFileService.GetFilesByGame(game.Id); + var noIntroEntries = (_noIntroCatalogEntryRepository.All() ?? Enumerable.Empty()).ToList(); MergeDuplicateDlcSlots(existing, files); @@ -109,9 +121,14 @@ public void EnsureComponents(Game game) } } + foreach (var slot in _noIntroComponentPlanner.GetSlots(game, noIntroEntries)) + { + FindOrStage(existing, toInsert, game, slot.ComponentType, slot.Key, slot.Title, monitored: true); + } + foreach (var file in files) { - var component = GetComponentForFile(existing, toInsert, game, file, baseComponent); + var component = GetComponentForFile(existing, toInsert, game, file, baseComponent, noIntroEntries); if (component != null && file.ComponentId != component.Id) { @@ -135,7 +152,7 @@ public void EnsureComponents(Game game) foreach (var file in files) { - var component = GetComponentForFile(all, new List(), game, file, all.FirstOrDefault(c => c.ComponentType == GameComponentType.Base)); + var component = GetComponentForFile(all, new List(), game, file, all.FirstOrDefault(c => c.ComponentType == GameComponentType.Base), noIntroEntries); if (component is { Id: > 0 } && file.ComponentId != component.Id) { @@ -194,8 +211,16 @@ private void MergeDuplicateDlcSlots(List existing, List } } - private GameComponent GetComponentForFile(List existing, List toInsert, Game game, GameFile file, GameComponent baseComponent) + private GameComponent GetComponentForFile(List existing, List toInsert, Game game, GameFile file, GameComponent baseComponent, List noIntroEntries) { + var noIntroSlot = _noIntroComponentPlanner.FindSlotForFile(game, noIntroEntries, file) ?? + FindSlotForFolderBackedFile(game, noIntroEntries, file); + + if (noIntroSlot != null) + { + return FindOrStage(existing, toInsert, game, noIntroSlot.ComponentType, noIntroSlot.Key, noIntroSlot.Title, monitored: true); + } + if (file.RelativePath.IsNullOrWhiteSpace()) { return baseComponent; @@ -243,6 +268,25 @@ private GameComponent GetComponentForFile(List existing, List noIntroEntries, GameFile file) + { + if (!file.RelativePath.IsNullOrWhiteSpace() || !_diskProvider.FolderExists(game.Path)) + { + return null; + } + + var matchingSlots = _diskProvider.GetFiles(game.Path, true) + .Select(Path.GetFileName) + .Where(name => name.IsNotNullOrWhiteSpace()) + .Select(name => _noIntroComponentPlanner.FindSlotForFileName(game, noIntroEntries, name)) + .Where(slot => slot != null) + .GroupBy(slot => slot.Key) + .Select(group => group.First()) + .ToList(); + + return matchingSlots.Count == 1 ? matchingSlots[0] : null; + } + private static GameComponent FindOrStage(List existing, List toInsert, Game game, GameComponentType type, string key, string title, bool monitored) { var found = existing.FirstOrDefault(c => c.ComponentType == type && c.Key == key) ?? diff --git a/src/NzbDrone.Core/Games/Components/NoIntroGameComponentPlanner.cs b/src/NzbDrone.Core/Games/Components/NoIntroGameComponentPlanner.cs new file mode 100644 index 00000000..8ac141a2 --- /dev/null +++ b/src/NzbDrone.Core/Games/Components/NoIntroGameComponentPlanner.cs @@ -0,0 +1,137 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.RomCatalog; + +namespace NzbDrone.Core.Games.Components +{ + public class NoIntroGameComponentPlanner + { + private readonly INoIntroComponentClassifier _componentClassifier; + + public NoIntroGameComponentPlanner(INoIntroComponentClassifier componentClassifier) + { + _componentClassifier = componentClassifier; + } + + public List GetSlots(Game game, List entries) + { + var platformEntries = entries + .Where(entry => entry.PlatformFamily == game.Platform) + .ToList(); + var plan = _componentClassifier.BuildCatalogPlan(platformEntries); + var gamePlans = plan.Games.Where(x => x.GameTitle == game.Title).ToList(); + + if (gamePlans.Count == 0) + { + return new List(); + } + + var entriesByCanonicalName = platformEntries + .GroupBy(entry => entry.CanonicalName) + .ToDictionary(group => group.Key, group => group.ToList()); + + return gamePlans.SelectMany(gamePlan => gamePlan.RegionLanguageComponents.Concat(gamePlan.DownloadPlayComponents)) + .Select(slot => ToSlot(slot, entriesByCanonicalName)) + .ToList(); + } + + public NoIntroGameComponentSlot FindSlotForFile(Game game, List entries, GameFile file) + { + if (string.IsNullOrWhiteSpace(file.RelativePath)) + { + return null; + } + + var fileName = Path.GetFileName(file.RelativePath.Replace('\\', '/')); + + return FindSlotForFileName(game, entries, fileName); + } + + public NoIntroGameComponentSlot FindSlotForFileName(Game game, List entries, string fileName) + { + if (string.IsNullOrWhiteSpace(fileName)) + { + return null; + } + + return GetSlots(game, entries).FirstOrDefault(slot => slot.FileNames.Contains(fileName)); + } + + private static NoIntroGameComponentSlot ToSlot(NoIntroCatalogComponentSlot slot, Dictionary> entriesByCanonicalName) + { + var componentType = MapComponentType(slot.ComponentType); + entriesByCanonicalName.TryGetValue(slot.CanonicalName, out var entries); + + return new NoIntroGameComponentSlot + { + ComponentType = componentType, + Key = $"nointro:{ComponentKeyPrefix(componentType)}:{Parser.Parser.ToUrlSlug(slot.CanonicalName, true)}", + Title = slot.SlotLabel, + FileNames = BuildFileNames(slot.CanonicalName, entries ?? new List()) + }; + } + + private static HashSet BuildFileNames(string canonicalName, List entries) + { + var fileNames = new HashSet + { + $"{canonicalName}.gb", + $"{canonicalName}.gbc", + $"{canonicalName}.gba", + $"{canonicalName}.nds", + $"{canonicalName}.zip" + }; + + foreach (var entry in entries) + { + AddFileName(fileNames, entry.CanonicalFileName); + AddFileName(fileNames, entry.NumberedCanonicalFileName); + } + + return fileNames; + } + + private static void AddFileName(HashSet fileNames, string fileName) + { + if (!string.IsNullOrWhiteSpace(fileName)) + { + fileNames.Add(fileName); + } + } + + private static GameComponentType MapComponentType(NoIntroRomComponentType componentType) + { + return componentType switch + { + NoIntroRomComponentType.RetailRom => GameComponentType.NoIntroRetailRom, + NoIntroRomComponentType.EReaderCards => GameComponentType.NoIntroMultiboot, + NoIntroRomComponentType.Multiboot => GameComponentType.NoIntroMultiboot, + NoIntroRomComponentType.Video => GameComponentType.NoIntroVideo, + NoIntroRomComponentType.Bios => GameComponentType.NoIntroBios, + _ => GameComponentType.NoIntroRomhackOrUnverified + }; + } + + private static string ComponentKeyPrefix(GameComponentType componentType) + { + return componentType switch + { + GameComponentType.NoIntroRetailRom => "retail", + GameComponentType.NoIntroMultiboot => "multiboot", + GameComponentType.NoIntroVideo => "video", + GameComponentType.NoIntroBios => "bios", + _ => "unverified" + }; + } + } + + public class NoIntroGameComponentSlot + { + public GameComponentType ComponentType { get; set; } + public string Key { get; set; } + public string Title { get; set; } + public HashSet FileNames { get; set; } + } +} From 3ab8857da325840fb9c012205cf0ac3b607e858e Mon Sep 17 00:00:00 2001 From: pfuenzle Date: Sun, 19 Jul 2026 16:17:17 +0000 Subject: [PATCH 21/42] Match No-Intro catalog entries per component --- .../GameComponents/GameComponentResource.cs | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/Gamarr.Api.V3/GameComponents/GameComponentResource.cs b/src/Gamarr.Api.V3/GameComponents/GameComponentResource.cs index bbb382f4..df2a6b15 100644 --- a/src/Gamarr.Api.V3/GameComponents/GameComponentResource.cs +++ b/src/Gamarr.Api.V3/GameComponents/GameComponentResource.cs @@ -87,7 +87,7 @@ private static List GetNoIntroCatalogMatche var sourceById = context.Sources.ToDictionary(x => x.Id); var exactMatches = GetExactHashMatches(files, context); - var entries = exactMatches.Count > 0 ? exactMatches.Select(x => x.Entry) : context.Entries.Where(entry => IsComponentCatalogMatch(component.Title, entry)); + var entries = exactMatches.Count > 0 ? exactMatches.Select(x => x.Entry) : context.Entries.Where(entry => IsComponentCatalogMatch(component, entry)); return entries .OrderBy(entry => entry.SystemKey) @@ -129,19 +129,33 @@ private static List GetNoIntroCatalogMatche .ToList(); } - private static bool IsComponentCatalogMatch(string componentTitle, NoIntroCatalogEntry entry) + private static bool IsComponentCatalogMatch(GameComponent component, NoIntroCatalogEntry entry) { if (entry == null || string.IsNullOrWhiteSpace(entry.CanonicalName)) { return false; } - if (!string.IsNullOrWhiteSpace(entry.ParentCanonicalName) && entry.ParentCanonicalName == componentTitle) + if (IsNoIntroComponent(component)) + { + return component.Key?.EndsWith($":{NzbDrone.Core.Parser.Parser.ToUrlSlug(entry.CanonicalName, true)}", global::System.StringComparison.Ordinal) == true; + } + + if (!string.IsNullOrWhiteSpace(entry.ParentCanonicalName) && entry.ParentCanonicalName == component.Title) { return true; } - return entry.CanonicalName == componentTitle || entry.CanonicalName.StartsWith($"{componentTitle} (", global::System.StringComparison.Ordinal); + return entry.CanonicalName == component.Title || entry.CanonicalName.StartsWith($"{component.Title} (", global::System.StringComparison.Ordinal); + } + + private static bool IsNoIntroComponent(GameComponent component) + { + return component.ComponentType is GameComponentType.NoIntroRetailRom or + GameComponentType.NoIntroMultiboot or + GameComponentType.NoIntroVideo or + GameComponentType.NoIntroBios or + GameComponentType.NoIntroRomhackOrUnverified; } } } From a1d857faf55c9e6ed3fd66791f73b37a43745380 Mon Sep 17 00:00:00 2001 From: pfuenzle Date: Sun, 19 Jul 2026 16:17:24 +0000 Subject: [PATCH 22/42] Expose file component ownership in the API --- .../GameFiles/GameFileController.cs | 39 +++++++++--- .../GameFiles/GameFileResource.cs | 36 ++++++++--- .../GameFileResourceMapperFixture.cs | 59 +++++++++++++++++++ 3 files changed, 117 insertions(+), 17 deletions(-) create mode 100644 src/NzbDrone.Api.Test/GameFiles/GameFileResourceMapperFixture.cs diff --git a/src/Gamarr.Api.V3/GameFiles/GameFileController.cs b/src/Gamarr.Api.V3/GameFiles/GameFileController.cs index 3e5d0a20..524ac1c5 100644 --- a/src/Gamarr.Api.V3/GameFiles/GameFileController.cs +++ b/src/Gamarr.Api.V3/GameFiles/GameFileController.cs @@ -11,6 +11,7 @@ using NzbDrone.Core.MediaFiles.Events; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Games; +using NzbDrone.Core.Games.Components; using NzbDrone.Core.Parser; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Qualities; @@ -30,20 +31,23 @@ public class GameFileController : RestControllerWithSignalR x.Id); + componentsById.TryGetValue(gameFile.ComponentId, out var component); - var resource = gameFile.ToResource(game, _upgradableSpecification, _formatCalculator); + var resource = gameFile.ToResource(game, _upgradableSpecification, _formatCalculator, component); return resource; } @@ -77,8 +83,17 @@ public List GetGameFiles([FromQuery(Name = "gameId")] List e.GameId) - .SelectMany(f => f.ToList() - .ConvertAll(e => e.ToResource(_gameService.GetGame(f.Key), _upgradableSpecification, _formatCalculator))) + .SelectMany(group => + { + var game = _gameService.GetGame(group.Key); + var componentsById = _gameComponentService.GetByGame(group.Key).ToDictionary(x => x.Id); + + return group.Select(file => + { + componentsById.TryGetValue(file.ComponentId, out var component); + return file.ToResource(game, _upgradableSpecification, _formatCalculator, component); + }); + }) .ToList(); } @@ -206,7 +221,13 @@ public object SetPropertiesBulk([FromBody] List resources) var game = _gameService.GetGame(gameFiles.First().GameId); - return Accepted(gameFiles.ConvertAll(f => f.ToResource(game, _upgradableSpecification, _formatCalculator))); + var componentsById = _gameComponentService.GetByGame(game.Id).ToDictionary(x => x.Id); + + return Accepted(gameFiles.ConvertAll(f => + { + componentsById.TryGetValue(f.ComponentId, out var component); + return f.ToResource(game, _upgradableSpecification, _formatCalculator, component); + })); } [NonAction] diff --git a/src/Gamarr.Api.V3/GameFiles/GameFileResource.cs b/src/Gamarr.Api.V3/GameFiles/GameFileResource.cs index 301f628e..ac55a58c 100644 --- a/src/Gamarr.Api.V3/GameFiles/GameFileResource.cs +++ b/src/Gamarr.Api.V3/GameFiles/GameFileResource.cs @@ -3,6 +3,7 @@ using NzbDrone.Common.Extensions; using NzbDrone.Core.CustomFormats; using NzbDrone.Core.DecisionEngine.Specifications; +using NzbDrone.Core.Games.Components; using NzbDrone.Core.Languages; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Qualities; @@ -23,10 +24,9 @@ public class GameFileResource : RestResource public string Edition { get; set; } public string Version { get; set; } - // Derived component classification (#149): "base" (the game folder - // itself), "update" (Updates/ unit), "dlc" (DLC/ unit), - // or "file" (legacy file-based record). public string ComponentType { get; set; } + public string ComponentKey { get; set; } + public string ComponentTitle { get; set; } public List Languages { get; set; } public QualityModel Quality { get; set; } public List CustomFormats { get; set; } @@ -40,7 +40,7 @@ public class GameFileResource : RestResource public static class GameFileResourceMapper { - private static GameFileResource ToResource(this GameFile model) + private static GameFileResource ToResource(this GameFile model, GameComponent component = null) { if (model == null) { @@ -64,14 +64,32 @@ private static GameFileResource ToResource(this GameFile model) ReleaseGroup = model.ReleaseGroup, Edition = model.Edition, Version = model.GameVersion?.ToString(), - ComponentType = GetComponentType(model.RelativePath), + ComponentType = GetComponentType(model.RelativePath, component), + ComponentKey = component?.Key, + ComponentTitle = component?.Title, MediaInfo = model.MediaInfo.ToResource(model.SceneName), OriginalFilePath = model.OriginalFilePath }; } - private static string GetComponentType(string relativePath) + private static string GetComponentType(string relativePath, GameComponent component = null) { + if (component != null) + { + return component.ComponentType switch + { + GameComponentType.Base => "base", + GameComponentType.Update => "update", + GameComponentType.Dlc => "dlc", + GameComponentType.NoIntroRetailRom => "noIntroRetailRom", + GameComponentType.NoIntroMultiboot => "noIntroMultiboot", + GameComponentType.NoIntroVideo => "noIntroVideo", + GameComponentType.NoIntroBios => "noIntroBios", + GameComponentType.NoIntroRomhackOrUnverified => "noIntroRomhackOrUnverified", + _ => "file" + }; + } + if (relativePath.IsNullOrWhiteSpace()) { return "base"; @@ -90,7 +108,7 @@ private static string GetComponentType(string relativePath) return "file"; } - public static GameFileResource ToResource(this GameFile model, NzbDrone.Core.Games.Game game, IUpgradableSpecification upgradableSpecification, ICustomFormatCalculationService formatCalculationService) + public static GameFileResource ToResource(this GameFile model, NzbDrone.Core.Games.Game game, IUpgradableSpecification upgradableSpecification, ICustomFormatCalculationService formatCalculationService, GameComponent component = null) { if (model == null) { @@ -112,7 +130,9 @@ public static GameFileResource ToResource(this GameFile model, NzbDrone.Core.Gam Edition = model.Edition, ReleaseGroup = model.ReleaseGroup, Version = model.GameVersion?.ToString(), - ComponentType = GetComponentType(model.RelativePath), + ComponentType = GetComponentType(model.RelativePath, component), + ComponentKey = component?.Key, + ComponentTitle = component?.Title, MediaInfo = model.MediaInfo.ToResource(model.SceneName), QualityCutoffNotMet = upgradableSpecification?.QualityCutoffNotMet(game.QualityProfile, model.Quality) ?? false, OriginalFilePath = model.OriginalFilePath, diff --git a/src/NzbDrone.Api.Test/GameFiles/GameFileResourceMapperFixture.cs b/src/NzbDrone.Api.Test/GameFiles/GameFileResourceMapperFixture.cs new file mode 100644 index 00000000..439129cf --- /dev/null +++ b/src/NzbDrone.Api.Test/GameFiles/GameFileResourceMapperFixture.cs @@ -0,0 +1,59 @@ +using FluentAssertions; +using NUnit.Framework; +using Gamarr.Api.V3.GameFiles; +using NzbDrone.Core.Games; +using NzbDrone.Core.Games.Components; +using NzbDrone.Core.MediaFiles; + +namespace NzbDrone.Api.Test.GameFiles +{ + [TestFixture] + public class GameFileResourceMapperFixture + { + [Test] + public void should_use_linked_component_metadata_for_folder_backed_nointro_files() + { + var game = new Game { Path = "/games/Mario Kart DS" }; + var gameFile = new GameFile + { + Id = 43, + GameId = 51, + RelativePath = string.Empty, + ComponentId = 53 + }; + var component = new GameComponent + { + Id = 53, + GameId = 51, + ComponentType = GameComponentType.NoIntroRetailRom, + Key = "nointro:retail:mario-kart-ds-europe-en-fr-de-es-it", + Title = "Europe (En,Fr,De,Es,It)" + }; + + var resource = gameFile.ToResource(game, null, null, component); + + resource.ComponentType.Should().Be("noIntroRetailRom"); + resource.ComponentKey.Should().Be(component.Key); + resource.ComponentTitle.Should().Be(component.Title); + } + + [Test] + public void should_fall_back_to_derived_base_type_when_component_is_unknown() + { + var game = new Game { Path = "/games/Mario Kart DS" }; + var gameFile = new GameFile + { + Id = 43, + GameId = 51, + RelativePath = string.Empty, + ComponentId = 0 + }; + + var resource = gameFile.ToResource(game, null, null); + + resource.ComponentType.Should().Be("base"); + resource.ComponentKey.Should().BeNull(); + resource.ComponentTitle.Should().BeNull(); + } + } +} From f9e1d84903ffaef0c64b83f511d80fd0276728f4 Mon Sep 17 00:00:00 2001 From: pfuenzle Date: Sun, 19 Jul 2026 16:17:33 +0000 Subject: [PATCH 23/42] Hide duplicate base rows for No-Intro variants --- .../Components/GameComponentsTable.tsx | 58 +++++++++++++++++-- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/frontend/src/Game/Details/Components/GameComponentsTable.tsx b/frontend/src/Game/Details/Components/GameComponentsTable.tsx index 54521ea2..569325f4 100644 --- a/frontend/src/Game/Details/Components/GameComponentsTable.tsx +++ b/frontend/src/Game/Details/Components/GameComponentsTable.tsx @@ -24,7 +24,15 @@ import GameInteractiveSearchModal from '../../Search/GameInteractiveSearchModal' interface GameComponent { id: number; gameId: number; - componentType: 'base' | 'update' | 'dlc'; + componentType: + | 'base' + | 'update' + | 'dlc' + | 'noIntroRetailRom' + | 'noIntroMultiboot' + | 'noIntroVideo' + | 'noIntroBios' + | 'noIntroRomhackOrUnverified'; key: string; title: string; monitored: boolean; @@ -65,18 +73,46 @@ const columns = [ const typeKinds: Record< GameComponent['componentType'], - 'info' | 'success' | 'primary' + 'info' | 'success' | 'primary' | 'warning' | 'danger' > = { base: 'info', update: 'success', dlc: 'primary', + noIntroRetailRom: 'info', + noIntroMultiboot: 'success', + noIntroVideo: 'primary', + noIntroBios: 'warning', + noIntroRomhackOrUnverified: 'danger', +}; + +const typeLabels: Record = { + base: 'BASE', + update: 'UPDATE', + dlc: 'DLC', + noIntroRetailRom: 'ROM', + noIntroMultiboot: 'MULTIBOOT', + noIntroVideo: 'VIDEO', + noIntroBios: 'BIOS', + noIntroRomhackOrUnverified: 'UNVERIFIED', }; const noIntroSystemNames: Record = { 'nintendo---game-boy': 'Nintendo Game Boy', 'nintendo---game-boy-color': 'Nintendo Game Boy Color', 'nintendo---game-boy-advance': 'Nintendo Game Boy Advance', + 'nintendo---game-boy-advance-multiboot': 'Nintendo GBA Multiboot', + 'nintendo---game-boy-advance--multiboot': 'Nintendo GBA Multiboot', + 'nintendo---game-boy-advance-e-reader': 'Nintendo GBA e-Reader', + 'nintendo---game-boy-advance--e-reader': 'Nintendo GBA e-Reader', + 'nintendo---game-boy-advance-play-yan': 'Nintendo GBA Play-Yan', + 'nintendo---game-boy-advance--play-yan': 'Nintendo GBA Play-Yan', + 'nintendo---game-boy-advance-video': 'Nintendo GBA Video', + 'nintendo---game-boy-advance--video': 'Nintendo GBA Video', 'nintendo---nintendo-ds': 'Nintendo DS', + 'nintendo---nintendo-ds-download-play': 'Nintendo DS Download Play', + 'nintendo---nintendo-ds--download-play': 'Nintendo DS Download Play', + 'nintendo---nintendo-ds-dsvision-sd-cards': 'Nintendo DS DSvision', + 'nintendo---nintendo-ds--dsvision-sd-cards': 'Nintendo DS DSvision', }; interface GameComponentsTableProps { @@ -219,7 +255,7 @@ function GameComponentRow({ @@ -295,6 +331,18 @@ function GameComponentsTable({ gameId }: GameComponentsTableProps) { ); }, [qualityProfiles]); + const visibleComponents = useMemo(() => { + const hasNoIntroVariants = components.some((component) => + component.componentType.startsWith('noIntro') + ); + + if (!hasNoIntroVariants) { + return components; + } + + return components.filter((component) => component.componentType !== 'base'); + }, [components]); + useEffect(() => { let aborted = false; setIsFetching(true); @@ -383,14 +431,14 @@ function GameComponentsTable({ gameId }: GameComponentsTableProps) { return ; } - if (!components.length) { + if (!visibleComponents.length) { return null; } return ( - {components.map((component) => ( + {visibleComponents.map((component) => ( Date: Sun, 19 Jul 2026 16:17:42 +0000 Subject: [PATCH 24/42] Show component ownership in game files --- .../src/GameFile/Editor/GameFileEditorRow.css | 17 ++++++ .../Editor/GameFileEditorRow.css.d.ts | 3 + .../src/GameFile/Editor/GameFileEditorRow.tsx | 58 +++++++++++++++++++ frontend/src/GameFile/GameFile.ts | 3 + frontend/src/Store/Actions/gameFileActions.ts | 6 ++ 5 files changed, 87 insertions(+) diff --git a/frontend/src/GameFile/Editor/GameFileEditorRow.css b/frontend/src/GameFile/Editor/GameFileEditorRow.css index 9e5b5032..7e619227 100644 --- a/frontend/src/GameFile/Editor/GameFileEditorRow.css +++ b/frontend/src/GameFile/Editor/GameFileEditorRow.css @@ -4,6 +4,23 @@ word-break: break-all; } +.component, +.componentContent, +.componentTitle { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; +} + +.componentContent { + align-items: flex-start; + display: flex; + flex-direction: column; + gap: 4px; +} + +.componentTitle { + word-break: break-word; +} + .quality, .formats, .language { diff --git a/frontend/src/GameFile/Editor/GameFileEditorRow.css.d.ts b/frontend/src/GameFile/Editor/GameFileEditorRow.css.d.ts index b1f761e1..5ebda4e1 100644 --- a/frontend/src/GameFile/Editor/GameFileEditorRow.css.d.ts +++ b/frontend/src/GameFile/Editor/GameFileEditorRow.css.d.ts @@ -3,6 +3,9 @@ interface CssExports { 'actions': string; 'age': string; + 'component': string; + 'componentContent': string; + 'componentTitle': string; 'customFormatScore': string; 'dateAdded': string; 'download': string; diff --git a/frontend/src/GameFile/Editor/GameFileEditorRow.tsx b/frontend/src/GameFile/Editor/GameFileEditorRow.tsx index 0b7e61f8..509b95f9 100644 --- a/frontend/src/GameFile/Editor/GameFileEditorRow.tsx +++ b/frontend/src/GameFile/Editor/GameFileEditorRow.tsx @@ -1,4 +1,5 @@ import { useCallback, useState } from 'react'; +import Label from 'Components/Label'; import IconButton from 'Components/Link/IconButton'; import ConfirmModal from 'Components/Modal/ConfirmModal'; import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell'; @@ -14,9 +15,22 @@ import translate from 'Utilities/String/translate'; import FileDetailsModal from '../FileDetailsModal'; import styles from './GameFileEditorRow.css'; +type ComponentLabelKind = + | 'warning' + | 'danger' + | 'default' + | 'disabled' + | 'info' + | 'inverse' + | 'primary' + | 'success' + | 'queue'; + interface GameFileEditorRowProps { id: number; path?: string; + componentType: string; + componentTitle?: string; size: number; relativePath: string; sceneName?: string; @@ -32,6 +46,8 @@ function GameFileEditorRow(props: GameFileEditorRowProps) { const { id, path, + componentType, + componentTitle, relativePath, size, sceneName, @@ -82,6 +98,30 @@ function GameFileEditorRow(props: GameFileEditorRowProps) { path?.split(/[/\\]/).filter(Boolean).pop() || translate('GameFolder'); + const componentKinds: Record = { + base: kinds.INFO, + update: kinds.SUCCESS, + dlc: kinds.PRIMARY, + noIntroRetailRom: kinds.INFO, + noIntroMultiboot: kinds.SUCCESS, + noIntroVideo: kinds.PRIMARY, + noIntroBios: kinds.WARNING, + noIntroRomhackOrUnverified: kinds.DANGER, + file: kinds.DEFAULT, + }; + + const componentLabels: Record = { + base: 'BASE', + update: 'UPDATE', + dlc: 'DLC', + noIntroRetailRom: 'ROM', + noIntroMultiboot: 'MULTIBOOT', + noIntroVideo: 'VIDEO', + noIntroBios: 'BIOS', + noIntroRomhackOrUnverified: 'UNVERIFIED', + file: 'FILE', + }; + return ( {columns.map((column) => { @@ -123,6 +163,24 @@ function GameFileEditorRow(props: GameFileEditorRowProps) { ); } + if (name === 'componentTitle') { + if (!componentTitle) { + return -; + } + + return ( + +
+ + + {componentTitle} +
+
+ ); + } + if (name === 'languages') { return ( diff --git a/frontend/src/GameFile/GameFile.ts b/frontend/src/GameFile/GameFile.ts index 20d39633..be1796a1 100644 --- a/frontend/src/GameFile/GameFile.ts +++ b/frontend/src/GameFile/GameFile.ts @@ -7,6 +7,9 @@ export interface GameFile extends ModelBase { gameId: number; relativePath: string; path: string; + componentType: string; + componentKey?: string; + componentTitle?: string; size: number; dateAdded: string; sceneName: string; diff --git a/frontend/src/Store/Actions/gameFileActions.ts b/frontend/src/Store/Actions/gameFileActions.ts index 9eff99f3..4da7c23b 100644 --- a/frontend/src/Store/Actions/gameFileActions.ts +++ b/frontend/src/Store/Actions/gameFileActions.ts @@ -82,6 +82,12 @@ export const defaultState = { isVisible: true, isSortable: true, }, + { + name: 'componentTitle', + label: () => 'Component', + isVisible: true, + isSortable: true, + }, { name: 'size', label: () => translate('Size'), From adc48d1d1337a4bb80e7dc196259002339083a7a Mon Sep 17 00:00:00 2001 From: pfuenzle Date: Sun, 19 Jul 2026 16:27:59 +0000 Subject: [PATCH 25/42] Match No-Intro variants by alternate titles --- .../Components/GameComponentsTable.tsx | 4 +- .../src/GameFile/Editor/GameFileEditorRow.tsx | 4 +- .../GameTests/GameComponentServiceFixture.cs | 28 +++++++++++ .../Components/NoIntroGameComponentPlanner.cs | 48 ++++++++++++++++++- 4 files changed, 79 insertions(+), 5 deletions(-) diff --git a/frontend/src/Game/Details/Components/GameComponentsTable.tsx b/frontend/src/Game/Details/Components/GameComponentsTable.tsx index 569325f4..acf90851 100644 --- a/frontend/src/Game/Details/Components/GameComponentsTable.tsx +++ b/frontend/src/Game/Details/Components/GameComponentsTable.tsx @@ -89,8 +89,8 @@ const typeLabels: Record = { base: 'BASE', update: 'UPDATE', dlc: 'DLC', - noIntroRetailRom: 'ROM', - noIntroMultiboot: 'MULTIBOOT', + noIntroRetailRom: 'REGIONAL VARIANT', + noIntroMultiboot: 'RELEASE VARIANT', noIntroVideo: 'VIDEO', noIntroBios: 'BIOS', noIntroRomhackOrUnverified: 'UNVERIFIED', diff --git a/frontend/src/GameFile/Editor/GameFileEditorRow.tsx b/frontend/src/GameFile/Editor/GameFileEditorRow.tsx index 509b95f9..ece5bd00 100644 --- a/frontend/src/GameFile/Editor/GameFileEditorRow.tsx +++ b/frontend/src/GameFile/Editor/GameFileEditorRow.tsx @@ -114,8 +114,8 @@ function GameFileEditorRow(props: GameFileEditorRowProps) { base: 'BASE', update: 'UPDATE', dlc: 'DLC', - noIntroRetailRom: 'ROM', - noIntroMultiboot: 'MULTIBOOT', + noIntroRetailRom: 'REGIONAL VARIANT', + noIntroMultiboot: 'RELEASE VARIANT', noIntroVideo: 'VIDEO', noIntroBios: 'BIOS', noIntroRomhackOrUnverified: 'UNVERIFIED', diff --git a/src/NzbDrone.Core.Test/GameTests/GameComponentServiceFixture.cs b/src/NzbDrone.Core.Test/GameTests/GameComponentServiceFixture.cs index b2aae39e..3697396e 100644 --- a/src/NzbDrone.Core.Test/GameTests/GameComponentServiceFixture.cs +++ b/src/NzbDrone.Core.Test/GameTests/GameComponentServiceFixture.cs @@ -4,6 +4,7 @@ using Moq; using NUnit.Framework; using NzbDrone.Core.Games; +using NzbDrone.Core.Games.AlternativeTitles; using NzbDrone.Core.Games.Components; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles.Events; @@ -154,6 +155,33 @@ public void should_create_download_play_components_for_matching_game_title_and_p inserted.Should().Contain(c => c.ComponentType == GameComponentType.NoIntroMultiboot && c.Title == "Europe (Demo) (Download Station Vol. 1)"); } + [Test] + public void should_create_nointro_components_from_alternative_titles() + { + _game.Platform = PlatformFamily.NintendoDS; + _game.GameMetadata.Value.Title = "Pokémon Weiß"; + _game.GameMetadata.Value.OriginalTitle = "Pokémon White Version"; + _game.GameMetadata.Value.DlcReferences = new List(); + _game.GameMetadata.Value.AlternativeTitles = new List + { + new AlternativeTitle("Pokemon White"), + new AlternativeTitle("Pocket Monsters White") + }; + + Mocker.GetMock() + .Setup(r => r.All()) + .Returns(new List + { + Entry("Pokemon - White Version (USA, Europe) (NDSi Enhanced)", PlatformFamily.NintendoDS), + Entry("Pokemon - Black Version (USA, Europe) (NDSi Enhanced)", PlatformFamily.NintendoDS) + }); + + var inserted = CapturedInserts(); + + inserted.Should().Contain(c => c.ComponentType == GameComponentType.NoIntroRetailRom && c.Title == "USA, Europe (NDSi Enhanced)"); + inserted.Where(c => c.ComponentType == GameComponentType.NoIntroRetailRom).Should().ContainSingle(); + } + [Test] public void should_link_nointro_file_to_matching_catalog_component() { diff --git a/src/NzbDrone.Core/Games/Components/NoIntroGameComponentPlanner.cs b/src/NzbDrone.Core/Games/Components/NoIntroGameComponentPlanner.cs index 8ac141a2..31a846e0 100644 --- a/src/NzbDrone.Core/Games/Components/NoIntroGameComponentPlanner.cs +++ b/src/NzbDrone.Core/Games/Components/NoIntroGameComponentPlanner.cs @@ -1,7 +1,9 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using NzbDrone.Common.Extensions; using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Parser; using NzbDrone.Core.RomCatalog; namespace NzbDrone.Core.Games.Components @@ -21,7 +23,8 @@ public List GetSlots(Game game, List entry.PlatformFamily == game.Platform) .ToList(); var plan = _componentClassifier.BuildCatalogPlan(platformEntries); - var gamePlans = plan.Games.Where(x => x.GameTitle == game.Title).ToList(); + var titleKeys = BuildTitleKeys(game); + var gamePlans = plan.Games.Where(x => titleKeys.Contains(CleanTitleKey(x.GameTitle))).ToList(); if (gamePlans.Count == 0) { @@ -37,6 +40,49 @@ public List GetSlots(Game game, List BuildTitleKeys(Game game) + { + var titleKeys = new HashSet(); + + AddTitleKey(titleKeys, game.Title); + AddTitleKey(titleKeys, game.GameMetadata.Value.OriginalTitle); + + foreach (var alternativeTitle in game.GameMetadata.Value.AlternativeTitles) + { + AddTitleKey(titleKeys, alternativeTitle.Title); + } + + return titleKeys; + } + + private static void AddTitleKey(HashSet titleKeys, string title) + { + var titleKey = CleanTitleKey(title); + + if (titleKey.IsNullOrWhiteSpace()) + { + return; + } + + titleKeys.Add(titleKey); + + const string versionSuffix = "version"; + + if (titleKey.EndsWith(versionSuffix)) + { + titleKeys.Add(titleKey.Substring(0, titleKey.Length - versionSuffix.Length)); + } + else + { + titleKeys.Add(titleKey + versionSuffix); + } + } + + private static string CleanTitleKey(string title) + { + return title.CleanGameTitle(); + } + public NoIntroGameComponentSlot FindSlotForFile(Game game, List entries, GameFile file) { if (string.IsNullOrWhiteSpace(file.RelativePath)) From 94a74db131482c1cf147c87a90c1d55a8f6ec2a9 Mon Sep 17 00:00:00 2001 From: pfuenzle Date: Sun, 19 Jul 2026 16:41:06 +0000 Subject: [PATCH 26/42] Add No-Intro 3DS variant catalog support --- .../GameTests/GameComponentServiceFixture.cs | 34 ++++++++++++++++-- .../NoIntroCatalogSyncServiceFixture.cs | 26 ++++++++++++++ .../NoIntroComponentClassifierFixture.cs | 1 + .../014_seed_3ds_nointro_catalog_sources.cs | 36 +++++++++++++++++++ .../Components/NoIntroGameComponentPlanner.cs | 4 +++ .../RomCatalog/NoIntroCatalogSyncService.cs | 10 ++++++ .../RomCatalog/NoIntroComponentClassifier.cs | 12 ++++++- 7 files changed, 119 insertions(+), 4 deletions(-) create mode 100644 src/NzbDrone.Core/Datastore/Migration/014_seed_3ds_nointro_catalog_sources.cs diff --git a/src/NzbDrone.Core.Test/GameTests/GameComponentServiceFixture.cs b/src/NzbDrone.Core.Test/GameTests/GameComponentServiceFixture.cs index 3697396e..6f272c89 100644 --- a/src/NzbDrone.Core.Test/GameTests/GameComponentServiceFixture.cs +++ b/src/NzbDrone.Core.Test/GameTests/GameComponentServiceFixture.cs @@ -57,13 +57,18 @@ private List CapturedInserts() return captured ?? new List(); } - private static NoIntroCatalogEntry Entry(string canonicalName, PlatformFamily platform, string numberedCanonicalFileName = null) + private static NoIntroCatalogEntry Entry(string canonicalName, PlatformFamily platform, string numberedCanonicalFileName = null, string extension = "nds") { return new NoIntroCatalogEntry { - SystemKey = platform == PlatformFamily.NintendoDS ? "nintendo---nintendo-ds" : "nintendo---game-boy-advance", + SystemKey = platform switch + { + PlatformFamily.Nintendo3DS => "nintendo---nintendo-3ds", + PlatformFamily.NintendoDS => "nintendo---nintendo-ds", + _ => "nintendo---game-boy-advance" + }, CanonicalName = canonicalName, - CanonicalFileName = $"{canonicalName}.nds", + CanonicalFileName = $"{canonicalName}.{extension}", NumberedCanonicalFileName = numberedCanonicalFileName, PlatformFamily = platform }; @@ -182,6 +187,29 @@ public void should_create_nointro_components_from_alternative_titles() inserted.Where(c => c.ComponentType == GameComponentType.NoIntroRetailRom).Should().ContainSingle(); } + [Test] + public void should_create_3ds_nointro_components_for_matching_game_title_and_platform() + { + _game.Platform = PlatformFamily.Nintendo3DS; + _game.GameMetadata.Value.Title = "Mario Kart 7"; + _game.GameMetadata.Value.DlcReferences = new List(); + + Mocker.GetMock() + .Setup(r => r.All()) + .Returns(new List + { + Entry("Mario Kart 7 (Europe) (En,Fr,De,Es,It,Nl,Pt,Ru)", PlatformFamily.Nintendo3DS, extension: "3ds"), + Entry("Mario Kart 7 (USA) (En,Fr,Es)", PlatformFamily.Nintendo3DS, extension: "3ds"), + Entry("Mario Kart DS (USA)", PlatformFamily.NintendoDS) + }); + + var inserted = CapturedInserts(); + + inserted.Where(c => c.ComponentType == GameComponentType.NoIntroRetailRom).Select(c => c.Title).Should().BeEquivalentTo( + "Europe (En,Fr,De,Es,It,Nl,Pt,Ru)", + "USA (En,Fr,Es)"); + } + [Test] public void should_link_nointro_file_to_matching_catalog_component() { diff --git a/src/NzbDrone.Core.Test/RomCatalog/NoIntroCatalogSyncServiceFixture.cs b/src/NzbDrone.Core.Test/RomCatalog/NoIntroCatalogSyncServiceFixture.cs index 076305f3..82af8f9b 100644 --- a/src/NzbDrone.Core.Test/RomCatalog/NoIntroCatalogSyncServiceFixture.cs +++ b/src/NzbDrone.Core.Test/RomCatalog/NoIntroCatalogSyncServiceFixture.cs @@ -3,6 +3,7 @@ using FluentAssertions; using Moq; using NUnit.Framework; +using NzbDrone.Core.Games; using NzbDrone.Core.RomCatalog; using NzbDrone.Core.Test.Framework; @@ -157,6 +158,10 @@ public void sync_should_seed_missing_default_game_boy_sources() sources.Should().Contain(x => x.Name == "No-Intro Nintendo Game Boy Color"); sources.Should().Contain(x => x.Name == "No-Intro Nintendo Game Boy Advance"); sources.Should().Contain(x => x.Name == "No-Intro Nintendo DS Download Play"); + sources.Should().Contain(x => x.Name == "No-Intro Nintendo 3DS"); + sources.Should().Contain(x => x.Name == "No-Intro Nintendo 3DS Digital"); + sources.Should().Contain(x => x.Name == "No-Intro New Nintendo 3DS"); + sources.Should().Contain(x => x.Name == "No-Intro New Nintendo 3DS Digital"); sources.Should().Contain(x => x.Name == "No-Intro Nintendo DS DSvision SD Cards" && x.SourceUrl == "datomatic://system/319"); sources.Should().Contain(x => x.Name == "No-Intro Nintendo Game Boy Advance Multiboot" && x.SourceUrl == "datomatic://system/137"); sources.Should().Contain(x => x.Name == "No-Intro Nintendo Game Boy Advance e-Reader" && x.SourceUrl == "datomatic://system/41"); @@ -165,6 +170,27 @@ public void sync_should_seed_missing_default_game_boy_sources() sources.Should().ContainSingle(x => x.SourceUrl == existingSource.SourceUrl); } + [Test] + public void sync_should_map_3ds_sources_to_nintendo_3ds_platform() + { + var source = _sourceRepository.Insert(new NoIntroCatalogSource + { + Name = "No-Intro Nintendo 3DS", + SourceUrl = "https://example.invalid/3ds.dat" + }); + + Mocker.GetMock() + .Setup(x => x.Fetch(source.SourceUrl)) + .Returns("
Nintendo - Nintendo 3DS2026.07
"); + + _subject.Sync(source.Id); + + var entry = _entryRepository.All().Should().ContainSingle().Subject; + entry.SystemKey.Should().Be("nintendo---nintendo-3ds"); + entry.PlatformFamily.Should().Be(PlatformFamily.Nintendo3DS); + entry.CanonicalFileName.Should().Be("Mario Kart 7 (USA) (En,Fr,Es).3ds"); + } + [Test] public void sync_failure_should_preserve_existing_catalog_and_record_failure() { diff --git a/src/NzbDrone.Core.Test/RomCatalog/NoIntroComponentClassifierFixture.cs b/src/NzbDrone.Core.Test/RomCatalog/NoIntroComponentClassifierFixture.cs index 4de9d20c..e9b333c3 100644 --- a/src/NzbDrone.Core.Test/RomCatalog/NoIntroComponentClassifierFixture.cs +++ b/src/NzbDrone.Core.Test/RomCatalog/NoIntroComponentClassifierFixture.cs @@ -17,6 +17,7 @@ public void Setup() } [TestCase("GBA (by-id)/0001 - F-Zero for Game Boy Advance (Japan).zip", "0001 - F-Zero for Game Boy Advance (Japan).zip", NoIntroRomComponentType.RetailRom, false)] + [TestCase("Nintendo 3DS/3ds/Mario Kart 7 (USA) (En,Fr,Es).3ds", "Mario Kart 7 (USA) (En,Fr,Es).3ds", NoIntroRomComponentType.RetailRom, false)] [TestCase("GBA (e-Reader)/Animal Crossing-e - Series 1 - A-001 - K.K. Slider (USA).zip", "Animal Crossing-e - Series 1 - A-001 - K.K. Slider (USA).zip", NoIntroRomComponentType.EReaderCards, false)] [TestCase("GBA (Multiboot)/Animal Crossing - Balloon Fight (USA, Europe).gba", "Animal Crossing - Balloon Fight (USA, Europe).gba", NoIntroRomComponentType.Multiboot, false)] [TestCase("GBA (Play-Yan)/Nintendo - Game Boy Advance (Play-Yan).zip", "Nintendo - Game Boy Advance (Play-Yan).zip", NoIntroRomComponentType.Video, false)] diff --git a/src/NzbDrone.Core/Datastore/Migration/014_seed_3ds_nointro_catalog_sources.cs b/src/NzbDrone.Core/Datastore/Migration/014_seed_3ds_nointro_catalog_sources.cs new file mode 100644 index 00000000..165abda2 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/014_seed_3ds_nointro_catalog_sources.cs @@ -0,0 +1,36 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(14)] + public class seed_3ds_nointro_catalog_sources : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Insert.IntoTable("NoIntroCatalogSources").Row(new + { + Name = "No-Intro Nintendo 3DS", + SourceUrl = "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Nintendo%203DS.dat" + }); + + Insert.IntoTable("NoIntroCatalogSources").Row(new + { + Name = "No-Intro Nintendo 3DS Digital", + SourceUrl = "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Nintendo%203DS%20%28Digital%29.dat" + }); + + Insert.IntoTable("NoIntroCatalogSources").Row(new + { + Name = "No-Intro New Nintendo 3DS", + SourceUrl = "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20New%20Nintendo%203DS.dat" + }); + + Insert.IntoTable("NoIntroCatalogSources").Row(new + { + Name = "No-Intro New Nintendo 3DS Digital", + SourceUrl = "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20New%20Nintendo%203DS%20%28Digital%29.dat" + }); + } + } +} diff --git a/src/NzbDrone.Core/Games/Components/NoIntroGameComponentPlanner.cs b/src/NzbDrone.Core/Games/Components/NoIntroGameComponentPlanner.cs index 31a846e0..abcb2f60 100644 --- a/src/NzbDrone.Core/Games/Components/NoIntroGameComponentPlanner.cs +++ b/src/NzbDrone.Core/Games/Components/NoIntroGameComponentPlanner.cs @@ -127,6 +127,10 @@ private static HashSet BuildFileNames(string canonicalName, List sources) AddDefaultSource(sources, "No-Intro Nintendo Game Boy Color", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Game%20Boy%20Color.dat"); AddDefaultSource(sources, "No-Intro Nintendo Game Boy Advance", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Game%20Boy%20Advance.dat"); AddDefaultSource(sources, "No-Intro Nintendo DS", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Nintendo%20DS.dat"); + AddDefaultSource(sources, "No-Intro Nintendo 3DS", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Nintendo%203DS.dat"); + AddDefaultSource(sources, "No-Intro Nintendo 3DS Digital", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Nintendo%203DS%20%28Digital%29.dat"); + AddDefaultSource(sources, "No-Intro New Nintendo 3DS", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20New%20Nintendo%203DS.dat"); + AddDefaultSource(sources, "No-Intro New Nintendo 3DS Digital", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20New%20Nintendo%203DS%20%28Digital%29.dat"); AddDefaultSource(sources, "No-Intro Nintendo DS Download Play", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Nintendo%20DS%20%28Download%20Play%29.dat"); AddDefaultSource(sources, "No-Intro Nintendo DS DSvision SD Cards", "datomatic://system/319"); AddDefaultSource(sources, "No-Intro Nintendo Game Boy Advance Multiboot", "datomatic://system/137"); @@ -315,6 +319,12 @@ private static PlatformFamily MapPlatformFamily(string systemKey) "nintendo---game-boy-advance-video" => PlatformFamily.NintendoGBA, "nintendo---game-boy-advance--video" => PlatformFamily.NintendoGBA, "nintendo---nintendo-ds" => PlatformFamily.NintendoDS, + "nintendo---nintendo-3ds" => PlatformFamily.Nintendo3DS, + "nintendo---nintendo-3ds-digital" => PlatformFamily.Nintendo3DS, + "nintendo---nintendo-3ds--digital" => PlatformFamily.Nintendo3DS, + "nintendo---new-nintendo-3ds" => PlatformFamily.Nintendo3DS, + "nintendo---new-nintendo-3ds-digital" => PlatformFamily.Nintendo3DS, + "nintendo---new-nintendo-3ds--digital" => PlatformFamily.Nintendo3DS, "nintendo---nintendo-ds-download-play" => PlatformFamily.NintendoDS, "nintendo---nintendo-ds--download-play" => PlatformFamily.NintendoDS, "nintendo---nintendo-ds-dsvision-sd-cards" => PlatformFamily.NintendoDS, diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroComponentClassifier.cs b/src/NzbDrone.Core/RomCatalog/NoIntroComponentClassifier.cs index 4a6fa928..bd484618 100644 --- a/src/NzbDrone.Core/RomCatalog/NoIntroComponentClassifier.cs +++ b/src/NzbDrone.Core/RomCatalog/NoIntroComponentClassifier.cs @@ -43,7 +43,17 @@ public NoIntroComponentClassification Classify(string relativePath, string fileN return Exact(NoIntroRomComponentType.Video); } - if (Contains(path, "GBA") || Contains(path, "GBA (by-id)") || Contains(path, "/zip/") || Contains(path, "/nds/") || EndsWithFolder(path, "zip") || EndsWithFolder(path, "nds")) + if (Contains(path, "GBA") || + Contains(path, "GBA (by-id)") || + Contains(path, "3DS") || + Contains(path, "/zip/") || + Contains(path, "/nds/") || + Contains(path, "/3ds/") || + Contains(path, "/cia/") || + EndsWithFolder(path, "zip") || + EndsWithFolder(path, "nds") || + EndsWithFolder(path, "3ds") || + EndsWithFolder(path, "cia")) { return Exact(NoIntroRomComponentType.RetailRom); } From c1cbdfd40c1fb6b4c51d2f806f10c78cd834e7fa Mon Sep 17 00:00:00 2001 From: pfuenzle Date: Sun, 19 Jul 2026 17:04:13 +0000 Subject: [PATCH 27/42] Add specific Nintendo and Sony platform families --- .../PlatformSpecificationFixture.cs | 13 +++++++ .../ParserTests/PlatformParserFixture.cs | 12 +++---- src/NzbDrone.Core/Games/GamePlatform.cs | 34 +++++++++++++++++-- src/NzbDrone.Core/Parser/PlatformParser.cs | 6 ++-- 4 files changed, 53 insertions(+), 12 deletions(-) diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/PlatformSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/PlatformSpecificationFixture.cs index f21554a5..1622e617 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/PlatformSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/PlatformSpecificationFixture.cs @@ -121,5 +121,18 @@ public void should_accept_game_boy_color_release_for_broad_nintendo_preference() Subject.IsSatisfiedBy(_remoteGame, null).Accepted.Should().BeTrue(); } + + [Test] + public void should_accept_specific_playstation_release_for_broad_playstation_preference() + { + _remoteGame.Game.Platform = PlatformFamily.Unknown; + _remoteGame.ParsedGameInfo.Platform = PlatformFamily.SonyPSP; + _remoteGame.Game.QualityProfile.PreferredPlatforms = new List + { + PlatformFamily.PlayStation + }; + + Subject.IsSatisfiedBy(_remoteGame, null).Accepted.Should().BeTrue(); + } } } diff --git a/src/NzbDrone.Core.Test/ParserTests/PlatformParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/PlatformParserFixture.cs index 7b5b9948..1522c246 100644 --- a/src/NzbDrone.Core.Test/ParserTests/PlatformParserFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/PlatformParserFixture.cs @@ -9,13 +9,13 @@ namespace NzbDrone.Core.Test.ParserTests [TestFixture] public class PlatformParserFixture : CoreTest { - [TestCase("Portal 2 (2011) [Ps3][EUR FREE][MULTi5]", PlatformFamily.PlayStation, "PS3")] - [TestCase("Game Title 2023 PS3 EUR ISO", PlatformFamily.PlayStation, "PS3")] - [TestCase("Game.Title.2023.PlayStation3.EUR.ISO", PlatformFamily.PlayStation, "PS3")] + [TestCase("Portal 2 (2011) [Ps3][EUR FREE][MULTi5]", PlatformFamily.SonyPS3, "PS3")] + [TestCase("Game Title 2023 PS3 EUR ISO", PlatformFamily.SonyPS3, "PS3")] + [TestCase("Game.Title.2023.PlayStation3.EUR.ISO", PlatformFamily.SonyPS3, "PS3")] [TestCase("Game Title (2023) [PS4] [USA]", PlatformFamily.PlayStation, "PS4")] [TestCase("Game.Title.2023.PS5.EUR.PKG", PlatformFamily.PlayStation, "PS5")] - [TestCase("Game Title 2023 PSVita USA VPK", PlatformFamily.PlayStation, "PS Vita")] - [TestCase("Game.Title.2023.PSP.EUR.ISO", PlatformFamily.PlayStation, "PSP")] + [TestCase("Game Title 2023 PSVita USA VPK", PlatformFamily.SonyPSVita, "PS Vita")] + [TestCase("Game.Title.2023.PSP.EUR.ISO", PlatformFamily.SonyPSP, "PSP")] public void should_parse_playstation_platform(string postTitle, PlatformFamily expectedFamily, string expectedString) { var result = PlatformParser.ParsePlatform(postTitle); @@ -105,7 +105,7 @@ public void should_parse_platform_from_full_parser(string postTitle) var result = Parser.Parser.ParseGameTitle(postTitle, true); result.Should().NotBeNull(); - result.Platform.Should().Be(PlatformFamily.PlayStation); + result.Platform.Should().Be(PlatformFamily.SonyPS3); result.PlatformString.Should().Be("PS3"); } } diff --git a/src/NzbDrone.Core/Games/GamePlatform.cs b/src/NzbDrone.Core/Games/GamePlatform.cs index 2aaf6251..54b17c26 100644 --- a/src/NzbDrone.Core/Games/GamePlatform.cs +++ b/src/NzbDrone.Core/Games/GamePlatform.cs @@ -35,7 +35,17 @@ public enum PlatformFamily NintendoDS = 14, NintendoGBA = 15, NintendoGB = 16, - NintendoGBC = 17 + NintendoGBC = 17, + NintendoNES = 18, + NintendoSNES = 19, + NintendoN64 = 20, + NintendoFDS = 21, + NintendoVirtualBoy = 22, + NintendoPokemonMini = 23, + NintendoDSi = 24, + SonyPS3 = 25, + SonyPSP = 26, + SonyPSVita = 27 } /// @@ -75,10 +85,25 @@ PlatformFamily.NintendoSwitch or PlatformFamily.NintendoWiiU or PlatformFamily.NintendoWii or PlatformFamily.Nintendo3DS or + PlatformFamily.NintendoDSi or PlatformFamily.NintendoDS or PlatformFamily.NintendoGBA or PlatformFamily.NintendoGB or - PlatformFamily.NintendoGBC; + PlatformFamily.NintendoGBC or + PlatformFamily.NintendoNES or + PlatformFamily.NintendoSNES or + PlatformFamily.NintendoN64 or + PlatformFamily.NintendoFDS or + PlatformFamily.NintendoVirtualBoy or + PlatformFamily.NintendoPokemonMini; + } + + public static bool IsPlayStationFamily(PlatformFamily platform) + { + return platform is PlatformFamily.PlayStation or + PlatformFamily.SonyPS3 or + PlatformFamily.SonyPSP or + PlatformFamily.SonyPSVita; } public static bool PlatformMatches(PlatformFamily wanted, PlatformFamily actual) @@ -89,7 +114,9 @@ public static bool PlatformMatches(PlatformFamily wanted, PlatformFamily actual) } return (IsNintendoFamily(wanted) && actual == PlatformFamily.Nintendo) || - (wanted == PlatformFamily.Nintendo && IsNintendoFamily(actual)); + (wanted == PlatformFamily.Nintendo && IsNintendoFamily(actual)) || + (IsPlayStationFamily(wanted) && actual == PlatformFamily.PlayStation) || + (wanted == PlatformFamily.PlayStation && IsPlayStationFamily(actual)); } /// @@ -103,6 +130,7 @@ public static class CommonPlatforms public const int PS5 = 167; public const int PS4 = 48; public const int PS3 = 9; + public const int PSP = 38; public const int PSVita = 46; public const int XboxSeriesX = 169; public const int XboxOne = 49; diff --git a/src/NzbDrone.Core/Parser/PlatformParser.cs b/src/NzbDrone.Core/Parser/PlatformParser.cs index 42244bc5..adc3ca42 100644 --- a/src/NzbDrone.Core/Parser/PlatformParser.cs +++ b/src/NzbDrone.Core/Parser/PlatformParser.cs @@ -117,19 +117,19 @@ public static PlatformFamily ParsePlatform(string title) if (PlayStation3Regex.IsMatch(title)) { Logger.Trace("Detected PS3 platform in title"); - return PlatformFamily.PlayStation; + return PlatformFamily.SonyPS3; } if (PSVitaRegex.IsMatch(title)) { Logger.Trace("Detected PS Vita platform in title"); - return PlatformFamily.PlayStation; + return PlatformFamily.SonyPSVita; } if (PSPRegex.IsMatch(title)) { Logger.Trace("Detected PSP platform in title"); - return PlatformFamily.PlayStation; + return PlatformFamily.SonyPSP; } // Check Xbox platforms (most specific first) From 7c7f644f5c158bebe6807de95f17bc6ab9f36338 Mon Sep 17 00:00:00 2001 From: pfuenzle Date: Sun, 19 Jul 2026 17:04:21 +0000 Subject: [PATCH 28/42] Expand No-Intro catalog source defaults --- .../NoIntroCatalogSyncServiceFixture.cs | 29 +++++ ...d_nintendo_sony_nointro_catalog_sources.cs | 31 +++++ .../RomCatalog/NoIntroCatalogDefaults.cs | 115 ++++++++++++++++++ .../RomCatalog/NoIntroCatalogSyncService.cs | 51 +------- 4 files changed, 180 insertions(+), 46 deletions(-) create mode 100644 src/NzbDrone.Core/Datastore/Migration/015_seed_nintendo_sony_nointro_catalog_sources.cs create mode 100644 src/NzbDrone.Core/RomCatalog/NoIntroCatalogDefaults.cs diff --git a/src/NzbDrone.Core.Test/RomCatalog/NoIntroCatalogSyncServiceFixture.cs b/src/NzbDrone.Core.Test/RomCatalog/NoIntroCatalogSyncServiceFixture.cs index 82af8f9b..297557db 100644 --- a/src/NzbDrone.Core.Test/RomCatalog/NoIntroCatalogSyncServiceFixture.cs +++ b/src/NzbDrone.Core.Test/RomCatalog/NoIntroCatalogSyncServiceFixture.cs @@ -162,6 +162,15 @@ public void sync_should_seed_missing_default_game_boy_sources() sources.Should().Contain(x => x.Name == "No-Intro Nintendo 3DS Digital"); sources.Should().Contain(x => x.Name == "No-Intro New Nintendo 3DS"); sources.Should().Contain(x => x.Name == "No-Intro New Nintendo 3DS Digital"); + sources.Should().Contain(x => x.Name == "No-Intro Nintendo Entertainment System"); + sources.Should().Contain(x => x.Name == "No-Intro Super Nintendo Entertainment System"); + sources.Should().Contain(x => x.Name == "No-Intro Nintendo 64"); + sources.Should().Contain(x => x.Name == "No-Intro Nintendo DSi"); + sources.Should().Contain(x => x.Name == "No-Intro Wii Digital"); + sources.Should().Contain(x => x.Name == "No-Intro Wii U Digital"); + sources.Should().Contain(x => x.Name == "No-Intro Sony PlayStation Portable"); + sources.Should().Contain(x => x.Name == "No-Intro Sony PlayStation Vita"); + sources.Should().Contain(x => x.Name == "No-Intro Sony PlayStation 3 PSN"); sources.Should().Contain(x => x.Name == "No-Intro Nintendo DS DSvision SD Cards" && x.SourceUrl == "datomatic://system/319"); sources.Should().Contain(x => x.Name == "No-Intro Nintendo Game Boy Advance Multiboot" && x.SourceUrl == "datomatic://system/137"); sources.Should().Contain(x => x.Name == "No-Intro Nintendo Game Boy Advance e-Reader" && x.SourceUrl == "datomatic://system/41"); @@ -191,6 +200,26 @@ public void sync_should_map_3ds_sources_to_nintendo_3ds_platform() entry.CanonicalFileName.Should().Be("Mario Kart 7 (USA) (En,Fr,Es).3ds"); } + [Test] + public void sync_should_map_sony_sources_to_specific_playstation_platforms() + { + var source = _sourceRepository.Insert(new NoIntroCatalogSource + { + Name = "No-Intro Sony PlayStation Portable", + SourceUrl = "https://example.invalid/psp.dat" + }); + + Mocker.GetMock() + .Setup(x => x.Fetch(source.SourceUrl)) + .Returns("
Sony - PlayStation Portable2026.07
"); + + _subject.Sync(source.Id); + + var entry = _entryRepository.All().Should().ContainSingle().Subject; + entry.SystemKey.Should().Be("sony---playstation-portable"); + entry.PlatformFamily.Should().Be(PlatformFamily.SonyPSP); + } + [Test] public void sync_failure_should_preserve_existing_catalog_and_record_failure() { diff --git a/src/NzbDrone.Core/Datastore/Migration/015_seed_nintendo_sony_nointro_catalog_sources.cs b/src/NzbDrone.Core/Datastore/Migration/015_seed_nintendo_sony_nointro_catalog_sources.cs new file mode 100644 index 00000000..65dd0602 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/015_seed_nintendo_sony_nointro_catalog_sources.cs @@ -0,0 +1,31 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(15)] + public class seed_nintendo_sony_nointro_catalog_sources : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Insert.IntoTable("NoIntroCatalogSources").Row(new { Name = "No-Intro Nintendo Entertainment System", SourceUrl = "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Nintendo%20Entertainment%20System.dat" }); + Insert.IntoTable("NoIntroCatalogSources").Row(new { Name = "No-Intro Super Nintendo Entertainment System", SourceUrl = "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Super%20Nintendo%20Entertainment%20System.dat" }); + Insert.IntoTable("NoIntroCatalogSources").Row(new { Name = "No-Intro Nintendo 64", SourceUrl = "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Nintendo%2064.dat" }); + Insert.IntoTable("NoIntroCatalogSources").Row(new { Name = "No-Intro Nintendo 64DD", SourceUrl = "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Nintendo%2064DD.dat" }); + Insert.IntoTable("NoIntroCatalogSources").Row(new { Name = "No-Intro Family Computer Disk System", SourceUrl = "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Family%20Computer%20Disk%20System.dat" }); + Insert.IntoTable("NoIntroCatalogSources").Row(new { Name = "No-Intro Virtual Boy", SourceUrl = "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Virtual%20Boy.dat" }); + Insert.IntoTable("NoIntroCatalogSources").Row(new { Name = "No-Intro Pokemon Mini", SourceUrl = "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Pokemon%20Mini.dat" }); + Insert.IntoTable("NoIntroCatalogSources").Row(new { Name = "No-Intro Satellaview", SourceUrl = "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Satellaview.dat" }); + Insert.IntoTable("NoIntroCatalogSources").Row(new { Name = "No-Intro Sufami Turbo", SourceUrl = "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Sufami%20Turbo.dat" }); + Insert.IntoTable("NoIntroCatalogSources").Row(new { Name = "No-Intro Nintendo DSi", SourceUrl = "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Nintendo%20DSi.dat" }); + Insert.IntoTable("NoIntroCatalogSources").Row(new { Name = "No-Intro Wii Digital", SourceUrl = "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Wii%20%28Digital%29.dat" }); + Insert.IntoTable("NoIntroCatalogSources").Row(new { Name = "No-Intro Wii U Digital", SourceUrl = "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Wii%20U%20%28Digital%29.dat" }); + Insert.IntoTable("NoIntroCatalogSources").Row(new { Name = "No-Intro Sony PlayStation Portable", SourceUrl = "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Sony%20-%20PlayStation%20Portable.dat" }); + Insert.IntoTable("NoIntroCatalogSources").Row(new { Name = "No-Intro Sony PlayStation Portable PSN", SourceUrl = "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Sony%20-%20PlayStation%20Portable%20%28PSN%29.dat" }); + Insert.IntoTable("NoIntroCatalogSources").Row(new { Name = "No-Intro Sony PlayStation Portable PSX2PSP", SourceUrl = "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Sony%20-%20PlayStation%20Portable%20%28PSX2PSP%29.dat" }); + Insert.IntoTable("NoIntroCatalogSources").Row(new { Name = "No-Intro Sony PlayStation Vita", SourceUrl = "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Sony%20-%20PlayStation%20Vita.dat" }); + Insert.IntoTable("NoIntroCatalogSources").Row(new { Name = "No-Intro Sony PlayStation Vita PSN", SourceUrl = "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Sony%20-%20PlayStation%20Vita%20%28PSN%29.dat" }); + Insert.IntoTable("NoIntroCatalogSources").Row(new { Name = "No-Intro Sony PlayStation 3 PSN", SourceUrl = "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Sony%20-%20PlayStation%203%20%28PSN%29.dat" }); + } + } +} diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroCatalogDefaults.cs b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogDefaults.cs new file mode 100644 index 00000000..c6e087e8 --- /dev/null +++ b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogDefaults.cs @@ -0,0 +1,115 @@ +using System.Collections.Generic; +using NzbDrone.Core.Games; + +namespace NzbDrone.Core.RomCatalog +{ + public static class NoIntroCatalogDefaults + { + public static readonly IReadOnlyList Sources = new List + { + new NoIntroCatalogSourceSeed("No-Intro Nintendo Game Boy", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Game%20Boy.dat"), + new NoIntroCatalogSourceSeed("No-Intro Nintendo Game Boy Color", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Game%20Boy%20Color.dat"), + new NoIntroCatalogSourceSeed("No-Intro Nintendo Game Boy Advance", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Game%20Boy%20Advance.dat"), + new NoIntroCatalogSourceSeed("No-Intro Nintendo DS", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Nintendo%20DS.dat"), + new NoIntroCatalogSourceSeed("No-Intro Nintendo 3DS", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Nintendo%203DS.dat"), + new NoIntroCatalogSourceSeed("No-Intro Nintendo 3DS Digital", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Nintendo%203DS%20%28Digital%29.dat"), + new NoIntroCatalogSourceSeed("No-Intro New Nintendo 3DS", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20New%20Nintendo%203DS.dat"), + new NoIntroCatalogSourceSeed("No-Intro New Nintendo 3DS Digital", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20New%20Nintendo%203DS%20%28Digital%29.dat"), + new NoIntroCatalogSourceSeed("No-Intro Nintendo DS Download Play", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Nintendo%20DS%20%28Download%20Play%29.dat"), + new NoIntroCatalogSourceSeed("No-Intro Nintendo DS DSvision SD Cards", "datomatic://system/319"), + new NoIntroCatalogSourceSeed("No-Intro Nintendo Game Boy Advance Multiboot", "datomatic://system/137"), + new NoIntroCatalogSourceSeed("No-Intro Nintendo Game Boy Advance e-Reader", "datomatic://system/41"), + new NoIntroCatalogSourceSeed("No-Intro Nintendo Game Boy Advance Play-Yan", "datomatic://system/148"), + new NoIntroCatalogSourceSeed("No-Intro Nintendo Game Boy Advance Video", "datomatic://system/297"), + new NoIntroCatalogSourceSeed("No-Intro Nintendo Entertainment System", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Nintendo%20Entertainment%20System.dat"), + new NoIntroCatalogSourceSeed("No-Intro Super Nintendo Entertainment System", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Super%20Nintendo%20Entertainment%20System.dat"), + new NoIntroCatalogSourceSeed("No-Intro Nintendo 64", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Nintendo%2064.dat"), + new NoIntroCatalogSourceSeed("No-Intro Nintendo 64DD", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Nintendo%2064DD.dat"), + new NoIntroCatalogSourceSeed("No-Intro Family Computer Disk System", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Family%20Computer%20Disk%20System.dat"), + new NoIntroCatalogSourceSeed("No-Intro Virtual Boy", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Virtual%20Boy.dat"), + new NoIntroCatalogSourceSeed("No-Intro Pokemon Mini", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Pokemon%20Mini.dat"), + new NoIntroCatalogSourceSeed("No-Intro Satellaview", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Satellaview.dat"), + new NoIntroCatalogSourceSeed("No-Intro Sufami Turbo", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Sufami%20Turbo.dat"), + new NoIntroCatalogSourceSeed("No-Intro Nintendo DSi", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Nintendo%20DSi.dat"), + new NoIntroCatalogSourceSeed("No-Intro Wii Digital", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Wii%20%28Digital%29.dat"), + new NoIntroCatalogSourceSeed("No-Intro Wii U Digital", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Wii%20U%20%28Digital%29.dat"), + new NoIntroCatalogSourceSeed("No-Intro Sony PlayStation Portable", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Sony%20-%20PlayStation%20Portable.dat"), + new NoIntroCatalogSourceSeed("No-Intro Sony PlayStation Portable PSN", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Sony%20-%20PlayStation%20Portable%20%28PSN%29.dat"), + new NoIntroCatalogSourceSeed("No-Intro Sony PlayStation Portable PSX2PSP", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Sony%20-%20PlayStation%20Portable%20%28PSX2PSP%29.dat"), + new NoIntroCatalogSourceSeed("No-Intro Sony PlayStation Vita", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Sony%20-%20PlayStation%20Vita.dat"), + new NoIntroCatalogSourceSeed("No-Intro Sony PlayStation Vita PSN", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Sony%20-%20PlayStation%20Vita%20%28PSN%29.dat"), + new NoIntroCatalogSourceSeed("No-Intro Sony PlayStation 3 PSN", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Sony%20-%20PlayStation%203%20%28PSN%29.dat") + }; + + public static PlatformFamily MapPlatformFamily(string systemKey) + { + return systemKey switch + { + "nintendo---game-boy" => PlatformFamily.NintendoGB, + "nintendo---game-boy-color" => PlatformFamily.NintendoGBC, + "nintendo---game-boy-advance" => PlatformFamily.NintendoGBA, + "nintendo---game-boy-advance-multiboot" => PlatformFamily.NintendoGBA, + "nintendo---game-boy-advance--multiboot" => PlatformFamily.NintendoGBA, + "nintendo---game-boy-advance-e-reader" => PlatformFamily.NintendoGBA, + "nintendo---game-boy-advance--e-reader" => PlatformFamily.NintendoGBA, + "nintendo---game-boy-advance-play-yan" => PlatformFamily.NintendoGBA, + "nintendo---game-boy-advance--play-yan" => PlatformFamily.NintendoGBA, + "nintendo---game-boy-advance-video" => PlatformFamily.NintendoGBA, + "nintendo---game-boy-advance--video" => PlatformFamily.NintendoGBA, + "nintendo---nintendo-ds" => PlatformFamily.NintendoDS, + "nintendo---nintendo-dsi" => PlatformFamily.NintendoDSi, + "nintendo---nintendo-ds-download-play" => PlatformFamily.NintendoDS, + "nintendo---nintendo-ds--download-play" => PlatformFamily.NintendoDS, + "nintendo---nintendo-ds-dsvision-sd-cards" => PlatformFamily.NintendoDS, + "nintendo---nintendo-ds--dsvision-sd-cards" => PlatformFamily.NintendoDS, + "nintendo---nintendo-3ds" => PlatformFamily.Nintendo3DS, + "nintendo---nintendo-3ds-digital" => PlatformFamily.Nintendo3DS, + "nintendo---nintendo-3ds--digital" => PlatformFamily.Nintendo3DS, + "nintendo---new-nintendo-3ds" => PlatformFamily.Nintendo3DS, + "nintendo---new-nintendo-3ds-digital" => PlatformFamily.Nintendo3DS, + "nintendo---new-nintendo-3ds--digital" => PlatformFamily.Nintendo3DS, + "nintendo---nintendo-entertainment-system" => PlatformFamily.NintendoNES, + "nintendo---super-nintendo-entertainment-system" => PlatformFamily.NintendoSNES, + "nintendo---nintendo-64" => PlatformFamily.NintendoN64, + "nintendo---nintendo-64dd" => PlatformFamily.NintendoN64, + "nintendo---family-computer-disk-system" => PlatformFamily.NintendoFDS, + "nintendo---virtual-boy" => PlatformFamily.NintendoVirtualBoy, + "nintendo---pokemon-mini" => PlatformFamily.NintendoPokemonMini, + "nintendo---satellaview" => PlatformFamily.NintendoSNES, + "nintendo---sufami-turbo" => PlatformFamily.NintendoSNES, + "nintendo---wii--digital" => PlatformFamily.NintendoWii, + "nintendo---wii-u--digital" => PlatformFamily.NintendoWiiU, + "sony---playstation-3--psn" => PlatformFamily.SonyPS3, + "sony---playstation-portable" => PlatformFamily.SonyPSP, + "sony---playstation-portable--psn" => PlatformFamily.SonyPSP, + "sony---playstation-portable--psx2psp" => PlatformFamily.SonyPSP, + "sony---playstation-vita" => PlatformFamily.SonyPSVita, + "sony---playstation-vita--psn" => PlatformFamily.SonyPSVita, + _ => PlatformFamily.Unknown + }; + } + + public static bool MatchesGamePlatform(PlatformFamily gamePlatform, PlatformFamily catalogPlatform) + { + if (gamePlatform == catalogPlatform) + { + return true; + } + + return (gamePlatform == PlatformFamily.Nintendo && GamePlatform.IsNintendoFamily(catalogPlatform)) || + (gamePlatform == PlatformFamily.PlayStation && GamePlatform.IsPlayStationFamily(catalogPlatform)); + } + } + + public class NoIntroCatalogSourceSeed + { + public NoIntroCatalogSourceSeed(string name, string sourceUrl) + { + Name = name; + SourceUrl = sourceUrl; + } + + public string Name { get; } + public string SourceUrl { get; } + } +} diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSyncService.cs b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSyncService.cs index 29587b3c..b7299eea 100644 --- a/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSyncService.cs +++ b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSyncService.cs @@ -3,7 +3,6 @@ using System.Linq; using System.Xml.Linq; using NLog; -using NzbDrone.Core.Games; using NzbDrone.Core.Messaging.Commands; namespace NzbDrone.Core.RomCatalog @@ -112,7 +111,7 @@ private void ReplaceCatalog(NoIntroCatalogSource source, NoIntroCatalogSnapshot SystemKey = snapshot.SystemKey, CanonicalName = entry.CanonicalName, ParentCanonicalName = entry.ParentCanonicalName, - PlatformFamily = MapPlatformFamily(snapshot.SystemKey), + PlatformFamily = NoIntroCatalogDefaults.MapPlatformFamily(snapshot.SystemKey), CanonicalFileName = entry.CanonicalFileName, ReleaseNumber = entry.ReleaseNumber, NumberedCanonicalFileName = entry.NumberedCanonicalFileName @@ -142,20 +141,10 @@ private void ReplaceCatalog(NoIntroCatalogSource source, NoIntroCatalogSnapshot private void EnsureDefaultSources(List sources) { - AddDefaultSource(sources, "No-Intro Nintendo Game Boy", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Game%20Boy.dat"); - AddDefaultSource(sources, "No-Intro Nintendo Game Boy Color", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Game%20Boy%20Color.dat"); - AddDefaultSource(sources, "No-Intro Nintendo Game Boy Advance", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Game%20Boy%20Advance.dat"); - AddDefaultSource(sources, "No-Intro Nintendo DS", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Nintendo%20DS.dat"); - AddDefaultSource(sources, "No-Intro Nintendo 3DS", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Nintendo%203DS.dat"); - AddDefaultSource(sources, "No-Intro Nintendo 3DS Digital", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Nintendo%203DS%20%28Digital%29.dat"); - AddDefaultSource(sources, "No-Intro New Nintendo 3DS", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20New%20Nintendo%203DS.dat"); - AddDefaultSource(sources, "No-Intro New Nintendo 3DS Digital", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20New%20Nintendo%203DS%20%28Digital%29.dat"); - AddDefaultSource(sources, "No-Intro Nintendo DS Download Play", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Nintendo%20DS%20%28Download%20Play%29.dat"); - AddDefaultSource(sources, "No-Intro Nintendo DS DSvision SD Cards", "datomatic://system/319"); - AddDefaultSource(sources, "No-Intro Nintendo Game Boy Advance Multiboot", "datomatic://system/137"); - AddDefaultSource(sources, "No-Intro Nintendo Game Boy Advance e-Reader", "datomatic://system/41"); - AddDefaultSource(sources, "No-Intro Nintendo Game Boy Advance Play-Yan", "datomatic://system/148"); - AddDefaultSource(sources, "No-Intro Nintendo Game Boy Advance Video", "datomatic://system/297"); + foreach (var source in NoIntroCatalogDefaults.Sources) + { + AddDefaultSource(sources, source.Name, source.SourceUrl); + } } private void AddDefaultSource(List sources, string name, string sourceUrl) @@ -302,35 +291,5 @@ private static string GetAdvansceneSourceUrl(string systemKey) _ => null }; } - - private static PlatformFamily MapPlatformFamily(string systemKey) - { - return systemKey switch - { - "nintendo---game-boy" => PlatformFamily.NintendoGB, - "nintendo---game-boy-color" => PlatformFamily.NintendoGBC, - "nintendo---game-boy-advance" => PlatformFamily.NintendoGBA, - "nintendo---game-boy-advance-multiboot" => PlatformFamily.NintendoGBA, - "nintendo---game-boy-advance--multiboot" => PlatformFamily.NintendoGBA, - "nintendo---game-boy-advance-e-reader" => PlatformFamily.NintendoGBA, - "nintendo---game-boy-advance--e-reader" => PlatformFamily.NintendoGBA, - "nintendo---game-boy-advance-play-yan" => PlatformFamily.NintendoGBA, - "nintendo---game-boy-advance--play-yan" => PlatformFamily.NintendoGBA, - "nintendo---game-boy-advance-video" => PlatformFamily.NintendoGBA, - "nintendo---game-boy-advance--video" => PlatformFamily.NintendoGBA, - "nintendo---nintendo-ds" => PlatformFamily.NintendoDS, - "nintendo---nintendo-3ds" => PlatformFamily.Nintendo3DS, - "nintendo---nintendo-3ds-digital" => PlatformFamily.Nintendo3DS, - "nintendo---nintendo-3ds--digital" => PlatformFamily.Nintendo3DS, - "nintendo---new-nintendo-3ds" => PlatformFamily.Nintendo3DS, - "nintendo---new-nintendo-3ds-digital" => PlatformFamily.Nintendo3DS, - "nintendo---new-nintendo-3ds--digital" => PlatformFamily.Nintendo3DS, - "nintendo---nintendo-ds-download-play" => PlatformFamily.NintendoDS, - "nintendo---nintendo-ds--download-play" => PlatformFamily.NintendoDS, - "nintendo---nintendo-ds-dsvision-sd-cards" => PlatformFamily.NintendoDS, - "nintendo---nintendo-ds--dsvision-sd-cards" => PlatformFamily.NintendoDS, - _ => PlatformFamily.Nintendo - }; - } } } From cbc2c8df42d5bbba65b7edc830bb4ba37caf3bc4 Mon Sep 17 00:00:00 2001 From: pfuenzle Date: Sun, 19 Jul 2026 17:04:37 +0000 Subject: [PATCH 29/42] Restrict No-Intro game component platform matching --- .../GameTests/GameComponentServiceFixture.cs | 3 +++ .../Components/NoIntroGameComponentPlanner.cs | 15 ++++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/NzbDrone.Core.Test/GameTests/GameComponentServiceFixture.cs b/src/NzbDrone.Core.Test/GameTests/GameComponentServiceFixture.cs index 6f272c89..2580a13a 100644 --- a/src/NzbDrone.Core.Test/GameTests/GameComponentServiceFixture.cs +++ b/src/NzbDrone.Core.Test/GameTests/GameComponentServiceFixture.cs @@ -63,6 +63,7 @@ private static NoIntroCatalogEntry Entry(string canonicalName, PlatformFamily pl { SystemKey = platform switch { + PlatformFamily.SonyPSP => "sony---playstation-portable", PlatformFamily.Nintendo3DS => "nintendo---nintendo-3ds", PlatformFamily.NintendoDS => "nintendo---nintendo-ds", _ => "nintendo---game-boy-advance" @@ -119,6 +120,8 @@ public void should_create_nointro_catalog_components_for_matching_game_title_and Entry("Mario Kart DS (Europe) (En,Fr,De,Es,It)", PlatformFamily.NintendoDS), Entry("Mario Kart DS (USA, Australia) (En,Fr,De,Es,It)", PlatformFamily.NintendoDS), Entry("Mario Kart DS (Japan)", PlatformFamily.NintendoDS), + Entry("Mario Kart DS (USA)", PlatformFamily.Nintendo3DS, extension: "3ds"), + Entry("Mario Kart DS (USA)", PlatformFamily.SonyPSP, extension: "iso"), Entry("Pokemon Dash (USA)", PlatformFamily.NintendoDS), Entry("Mario Kart DS (USA)", PlatformFamily.NintendoGBA) }); diff --git a/src/NzbDrone.Core/Games/Components/NoIntroGameComponentPlanner.cs b/src/NzbDrone.Core/Games/Components/NoIntroGameComponentPlanner.cs index abcb2f60..5c977a6b 100644 --- a/src/NzbDrone.Core/Games/Components/NoIntroGameComponentPlanner.cs +++ b/src/NzbDrone.Core/Games/Components/NoIntroGameComponentPlanner.cs @@ -20,7 +20,7 @@ public NoIntroGameComponentPlanner(INoIntroComponentClassifier componentClassifi public List GetSlots(Game game, List entries) { var platformEntries = entries - .Where(entry => entry.PlatformFamily == game.Platform) + .Where(entry => NoIntroCatalogDefaults.MatchesGamePlatform(game.Platform, entry.PlatformFamily)) .ToList(); var plan = _componentClassifier.BuildCatalogPlan(platformEntries); var titleKeys = BuildTitleKeys(game); @@ -131,6 +131,19 @@ private static HashSet BuildFileNames(string canonicalName, List Date: Sun, 19 Jul 2026 17:04:43 +0000 Subject: [PATCH 30/42] Recognize Sony No-Intro ROM paths --- .../RomCatalog/NoIntroComponentClassifierFixture.cs | 1 + .../RomCatalog/NoIntroComponentClassifier.cs | 12 +++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/NzbDrone.Core.Test/RomCatalog/NoIntroComponentClassifierFixture.cs b/src/NzbDrone.Core.Test/RomCatalog/NoIntroComponentClassifierFixture.cs index e9b333c3..629ada81 100644 --- a/src/NzbDrone.Core.Test/RomCatalog/NoIntroComponentClassifierFixture.cs +++ b/src/NzbDrone.Core.Test/RomCatalog/NoIntroComponentClassifierFixture.cs @@ -18,6 +18,7 @@ public void Setup() [TestCase("GBA (by-id)/0001 - F-Zero for Game Boy Advance (Japan).zip", "0001 - F-Zero for Game Boy Advance (Japan).zip", NoIntroRomComponentType.RetailRom, false)] [TestCase("Nintendo 3DS/3ds/Mario Kart 7 (USA) (En,Fr,Es).3ds", "Mario Kart 7 (USA) (En,Fr,Es).3ds", NoIntroRomComponentType.RetailRom, false)] + [TestCase("Sony PlayStation Portable/iso/Grand Theft Auto - Liberty City Stories (USA).iso", "Grand Theft Auto - Liberty City Stories (USA).iso", NoIntroRomComponentType.RetailRom, false)] [TestCase("GBA (e-Reader)/Animal Crossing-e - Series 1 - A-001 - K.K. Slider (USA).zip", "Animal Crossing-e - Series 1 - A-001 - K.K. Slider (USA).zip", NoIntroRomComponentType.EReaderCards, false)] [TestCase("GBA (Multiboot)/Animal Crossing - Balloon Fight (USA, Europe).gba", "Animal Crossing - Balloon Fight (USA, Europe).gba", NoIntroRomComponentType.Multiboot, false)] [TestCase("GBA (Play-Yan)/Nintendo - Game Boy Advance (Play-Yan).zip", "Nintendo - Game Boy Advance (Play-Yan).zip", NoIntroRomComponentType.Video, false)] diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroComponentClassifier.cs b/src/NzbDrone.Core/RomCatalog/NoIntroComponentClassifier.cs index bd484618..f785b586 100644 --- a/src/NzbDrone.Core/RomCatalog/NoIntroComponentClassifier.cs +++ b/src/NzbDrone.Core/RomCatalog/NoIntroComponentClassifier.cs @@ -46,14 +46,24 @@ public NoIntroComponentClassification Classify(string relativePath, string fileN if (Contains(path, "GBA") || Contains(path, "GBA (by-id)") || Contains(path, "3DS") || + Contains(path, "PSP") || + Contains(path, "PlayStation Portable") || + Contains(path, "PlayStation Vita") || + Contains(path, "PlayStation 3") || Contains(path, "/zip/") || Contains(path, "/nds/") || Contains(path, "/3ds/") || Contains(path, "/cia/") || + Contains(path, "/iso/") || + Contains(path, "/pkg/") || + Contains(path, "/vpk/") || EndsWithFolder(path, "zip") || EndsWithFolder(path, "nds") || EndsWithFolder(path, "3ds") || - EndsWithFolder(path, "cia")) + EndsWithFolder(path, "cia") || + EndsWithFolder(path, "iso") || + EndsWithFolder(path, "pkg") || + EndsWithFolder(path, "vpk")) { return Exact(NoIntroRomComponentType.RetailRom); } From 227823c6b083f5139febb54b3f2caa080f1dc58a Mon Sep 17 00:00:00 2001 From: pfuenzle Date: Sun, 19 Jul 2026 17:04:54 +0000 Subject: [PATCH 31/42] Display No-Intro variant labels robustly --- .../Components/GameComponentsTable.tsx | 34 ++++++++++++------- .../src/GameFile/Editor/GameFileEditorRow.tsx | 12 ++++++- 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/frontend/src/Game/Details/Components/GameComponentsTable.tsx b/frontend/src/Game/Details/Components/GameComponentsTable.tsx index acf90851..8f25d13e 100644 --- a/frontend/src/Game/Details/Components/GameComponentsTable.tsx +++ b/frontend/src/Game/Details/Components/GameComponentsTable.tsx @@ -24,15 +24,7 @@ import GameInteractiveSearchModal from '../../Search/GameInteractiveSearchModal' interface GameComponent { id: number; gameId: number; - componentType: - | 'base' - | 'update' - | 'dlc' - | 'noIntroRetailRom' - | 'noIntroMultiboot' - | 'noIntroVideo' - | 'noIntroBios' - | 'noIntroRomhackOrUnverified'; + componentType: string; key: string; title: string; monitored: boolean; @@ -72,7 +64,7 @@ const columns = [ ]; const typeKinds: Record< - GameComponent['componentType'], + string, 'info' | 'success' | 'primary' | 'warning' | 'danger' > = { base: 'info', @@ -85,7 +77,7 @@ const typeKinds: Record< noIntroRomhackOrUnverified: 'danger', }; -const typeLabels: Record = { +const typeLabels: Record = { base: 'BASE', update: 'UPDATE', dlc: 'DLC', @@ -96,6 +88,22 @@ const typeLabels: Record = { noIntroRomhackOrUnverified: 'UNVERIFIED', }; +const fallbackTypeLabels: Record = { + nointroretailrom: 'REGIONAL VARIANT', + nointromultiboot: 'RELEASE VARIANT', + nointrovideo: 'VIDEO', + nointrobios: 'BIOS', + nointroromhackorunverified: 'UNVERIFIED', +}; + +function getTypeLabel(componentType: string) { + return ( + typeLabels[componentType] ?? + fallbackTypeLabels[componentType.toLowerCase()] ?? + componentType + ); +} + const noIntroSystemNames: Record = { 'nintendo---game-boy': 'Nintendo Game Boy', 'nintendo---game-boy-color': 'Nintendo Game Boy Color', @@ -254,8 +262,8 @@ function GameComponentRow({ return ( - diff --git a/frontend/src/GameFile/Editor/GameFileEditorRow.tsx b/frontend/src/GameFile/Editor/GameFileEditorRow.tsx index ece5bd00..0d082600 100644 --- a/frontend/src/GameFile/Editor/GameFileEditorRow.tsx +++ b/frontend/src/GameFile/Editor/GameFileEditorRow.tsx @@ -122,6 +122,14 @@ function GameFileEditorRow(props: GameFileEditorRowProps) { file: 'FILE', }; + const fallbackComponentLabels: Record = { + nointroretailrom: 'REGIONAL VARIANT', + nointromultiboot: 'RELEASE VARIANT', + nointrovideo: 'VIDEO', + nointrobios: 'BIOS', + nointroromhackorunverified: 'UNVERIFIED', + }; + return ( {columns.map((column) => { @@ -172,7 +180,9 @@ function GameFileEditorRow(props: GameFileEditorRowProps) {
{componentTitle} From ddbbdbab20f452192f2d21aa8820923b97f75687 Mon Sep 17 00:00:00 2001 From: pfuenzle Date: Sun, 19 Jul 2026 17:05:03 +0000 Subject: [PATCH 32/42] Surface added platform choices in forms --- .../src/AddGame/AddNewGame/AddNewGameModalContent.tsx | 10 ++++++++++ frontend/src/Game/Edit/EditGameModalContent.tsx | 10 ++++++++++ .../Quality/EditQualityProfileModalContent.tsx | 10 ++++++++++ 3 files changed, 30 insertions(+) diff --git a/frontend/src/AddGame/AddNewGame/AddNewGameModalContent.tsx b/frontend/src/AddGame/AddNewGame/AddNewGameModalContent.tsx index 505a75ad..37a83d08 100644 --- a/frontend/src/AddGame/AddNewGame/AddNewGameModalContent.tsx +++ b/frontend/src/AddGame/AddNewGame/AddNewGameModalContent.tsx @@ -55,16 +55,26 @@ const platformOptions = [ { key: 'linux', value: 'Linux' }, { key: 'mac', value: 'macOS' }, { key: 'playStation', value: 'PlayStation' }, + { key: 'sonyPS3', value: 'Sony PlayStation 3' }, + { key: 'sonyPSP', value: 'Sony PSP' }, + { key: 'sonyPSVita', value: 'Sony PlayStation Vita' }, { key: 'xbox', value: 'Xbox' }, { key: 'nintendo', value: 'Nintendo' }, { key: 'nintendoSwitch', value: 'Nintendo Switch' }, { key: 'nintendoWiiU', value: 'Nintendo Wii U' }, { key: 'nintendoWii', value: 'Nintendo Wii' }, { key: 'nintendo3DS', value: 'Nintendo 3DS' }, + { key: 'nintendoDSi', value: 'Nintendo DSi' }, { key: 'nintendoDS', value: 'Nintendo DS' }, { key: 'nintendoGBA', value: 'Nintendo Game Boy Advance' }, { key: 'nintendoGBC', value: 'Nintendo Game Boy Color' }, { key: 'nintendoGB', value: 'Nintendo Game Boy' }, + { key: 'nintendoNES', value: 'Nintendo Entertainment System' }, + { key: 'nintendoSNES', value: 'Super Nintendo Entertainment System' }, + { key: 'nintendoN64', value: 'Nintendo 64' }, + { key: 'nintendoFDS', value: 'Family Computer Disk System' }, + { key: 'nintendoVirtualBoy', value: 'Virtual Boy' }, + { key: 'nintendoPokemonMini', value: 'Pokemon Mini' }, ]; function AddNewGameModalContent(props: AddNewGameModalContentProps) { diff --git a/frontend/src/Game/Edit/EditGameModalContent.tsx b/frontend/src/Game/Edit/EditGameModalContent.tsx index d822d656..6a7dfc6f 100644 --- a/frontend/src/Game/Edit/EditGameModalContent.tsx +++ b/frontend/src/Game/Edit/EditGameModalContent.tsx @@ -45,16 +45,26 @@ const platformOptions = [ { key: 'linux', value: 'Linux' }, { key: 'mac', value: 'macOS' }, { key: 'playStation', value: 'PlayStation' }, + { key: 'sonyPS3', value: 'Sony PlayStation 3' }, + { key: 'sonyPSP', value: 'Sony PSP' }, + { key: 'sonyPSVita', value: 'Sony PlayStation Vita' }, { key: 'xbox', value: 'Xbox' }, { key: 'nintendo', value: 'Nintendo' }, { key: 'nintendoSwitch', value: 'Nintendo Switch' }, { key: 'nintendoWiiU', value: 'Nintendo Wii U' }, { key: 'nintendoWii', value: 'Nintendo Wii' }, { key: 'nintendo3DS', value: 'Nintendo 3DS' }, + { key: 'nintendoDSi', value: 'Nintendo DSi' }, { key: 'nintendoDS', value: 'Nintendo DS' }, { key: 'nintendoGBA', value: 'Nintendo Game Boy Advance' }, { key: 'nintendoGBC', value: 'Nintendo Game Boy Color' }, { key: 'nintendoGB', value: 'Nintendo Game Boy' }, + { key: 'nintendoNES', value: 'Nintendo Entertainment System' }, + { key: 'nintendoSNES', value: 'Super Nintendo Entertainment System' }, + { key: 'nintendoN64', value: 'Nintendo 64' }, + { key: 'nintendoFDS', value: 'Family Computer Disk System' }, + { key: 'nintendoVirtualBoy', value: 'Virtual Boy' }, + { key: 'nintendoPokemonMini', value: 'Pokemon Mini' }, ]; function EditGameModalContent({ diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.tsx b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.tsx index a1cc87e7..43e80304 100644 --- a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.tsx +++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.tsx @@ -29,16 +29,26 @@ const platformOptions = [ { key: 8, value: 'Linux', order: 2 }, { key: 9, value: 'Mac', order: 3 }, { key: 2, value: 'PlayStation', order: 4 }, + { key: 25, value: 'Sony PlayStation 3', order: 4.1 }, + { key: 26, value: 'Sony PSP', order: 4.2 }, + { key: 27, value: 'Sony PlayStation Vita', order: 4.3 }, { key: 3, value: 'Xbox', order: 5 }, { key: 4, value: 'Nintendo', order: 6 }, { key: 10, value: 'Nintendo Switch', order: 7 }, { key: 11, value: 'Nintendo Wii U', order: 8 }, { key: 12, value: 'Nintendo Wii', order: 9 }, { key: 13, value: 'Nintendo 3DS', order: 10 }, + { key: 24, value: 'Nintendo DSi', order: 10.5 }, { key: 14, value: 'Nintendo DS', order: 11 }, { key: 15, value: 'Nintendo Game Boy Advance', order: 12 }, { key: 17, value: 'Nintendo Game Boy Color', order: 13 }, { key: 16, value: 'Nintendo Game Boy', order: 14 }, + { key: 18, value: 'Nintendo Entertainment System', order: 15 }, + { key: 19, value: 'Super Nintendo Entertainment System', order: 16 }, + { key: 20, value: 'Nintendo 64', order: 17 }, + { key: 21, value: 'Family Computer Disk System', order: 18 }, + { key: 22, value: 'Virtual Boy', order: 19 }, + { key: 23, value: 'Pokemon Mini', order: 20 }, ]; interface PendingValue { From 883fb174af57a20d37cc3b8ef92f728525725db9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 17:33:09 -0600 Subject: [PATCH 33/42] Fix stylelint property order in GameFileEditorRow.css order/properties-order wanted display -> align-items -> flex-direction. Last frontend blocker for PR #153's CI. Co-Authored-By: Claude Opus 4.8 --- frontend/src/GameFile/Editor/GameFileEditorRow.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/GameFile/Editor/GameFileEditorRow.css b/frontend/src/GameFile/Editor/GameFileEditorRow.css index 7e619227..f8c97406 100644 --- a/frontend/src/GameFile/Editor/GameFileEditorRow.css +++ b/frontend/src/GameFile/Editor/GameFileEditorRow.css @@ -11,8 +11,8 @@ } .componentContent { - align-items: flex-start; display: flex; + align-items: flex-start; flex-direction: column; gap: 4px; } From c515cd488a660ca0e299848aa2f7217e781c148b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 21:31:40 -0600 Subject: [PATCH 34/42] Don't seed the broken DAT-o-MATIC catalog sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live smoke test of the greened branch: the 27 libretro GitHub .dat sources sync cleanly (52,639 entries / 157,908 hashes), but all 5 datomatic:// sources fail with "DAT-o-MATIC did not return a numbered DAT download token" — the scrape flow (hardcoded dated form field + HTML-button regex) no longer matches the current site. Shipping them means 5 permanently-erroring sources. Drop the 5 niche datomatic:// seeds (GBA Multiboot/e-Reader/Play-Yan/Video, DSvision — DAT-o-MATIC-only peripheral systems) from both the seed migration and NoIntroCatalogDefaults so a fresh install syncs 27/27 clean with zero errored sources. The datomatic support code stays; re-seed once FetchDatOMaticNumbered is hardened. Test updated to assert they are not seeded. Verified live: fresh install seeds 27 sources, full sync completes with 0 errored sources and no datomatic re-added by EnsureDefaultSources. Co-Authored-By: Claude Opus 4.8 --- .../NoIntroCatalogSyncServiceFixture.cs | 10 +++--- ...seed_additional_nointro_catalog_sources.cs | 34 +++---------------- .../RomCatalog/NoIntroCatalogDefaults.cs | 10 +++--- 3 files changed, 15 insertions(+), 39 deletions(-) diff --git a/src/NzbDrone.Core.Test/RomCatalog/NoIntroCatalogSyncServiceFixture.cs b/src/NzbDrone.Core.Test/RomCatalog/NoIntroCatalogSyncServiceFixture.cs index 297557db..f3850152 100644 --- a/src/NzbDrone.Core.Test/RomCatalog/NoIntroCatalogSyncServiceFixture.cs +++ b/src/NzbDrone.Core.Test/RomCatalog/NoIntroCatalogSyncServiceFixture.cs @@ -171,11 +171,11 @@ public void sync_should_seed_missing_default_game_boy_sources() sources.Should().Contain(x => x.Name == "No-Intro Sony PlayStation Portable"); sources.Should().Contain(x => x.Name == "No-Intro Sony PlayStation Vita"); sources.Should().Contain(x => x.Name == "No-Intro Sony PlayStation 3 PSN"); - sources.Should().Contain(x => x.Name == "No-Intro Nintendo DS DSvision SD Cards" && x.SourceUrl == "datomatic://system/319"); - sources.Should().Contain(x => x.Name == "No-Intro Nintendo Game Boy Advance Multiboot" && x.SourceUrl == "datomatic://system/137"); - sources.Should().Contain(x => x.Name == "No-Intro Nintendo Game Boy Advance e-Reader" && x.SourceUrl == "datomatic://system/41"); - sources.Should().Contain(x => x.Name == "No-Intro Nintendo Game Boy Advance Play-Yan" && x.SourceUrl == "datomatic://system/148"); - sources.Should().Contain(x => x.Name == "No-Intro Nintendo Game Boy Advance Video" && x.SourceUrl == "datomatic://system/297"); + + // The DAT-o-MATIC-only niche systems are intentionally NOT seeded while + // the datomatic:// scrape path is non-functional (would surface + // permanently-failing sources). Restore once the scrape is hardened. + sources.Should().NotContain(x => x.SourceUrl.StartsWith("datomatic://")); sources.Should().ContainSingle(x => x.SourceUrl == existingSource.SourceUrl); } diff --git a/src/NzbDrone.Core/Datastore/Migration/013_seed_additional_nointro_catalog_sources.cs b/src/NzbDrone.Core/Datastore/Migration/013_seed_additional_nointro_catalog_sources.cs index ade39145..3698035b 100644 --- a/src/NzbDrone.Core/Datastore/Migration/013_seed_additional_nointro_catalog_sources.cs +++ b/src/NzbDrone.Core/Datastore/Migration/013_seed_additional_nointro_catalog_sources.cs @@ -14,35 +14,11 @@ protected override void MainDbUpgrade() SourceUrl = "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Nintendo%20DS%20%28Download%20Play%29.dat" }); - Insert.IntoTable("NoIntroCatalogSources").Row(new - { - Name = "No-Intro Nintendo DS DSvision SD Cards", - SourceUrl = "datomatic://system/319" - }); - - Insert.IntoTable("NoIntroCatalogSources").Row(new - { - Name = "No-Intro Nintendo Game Boy Advance Multiboot", - SourceUrl = "datomatic://system/137" - }); - - Insert.IntoTable("NoIntroCatalogSources").Row(new - { - Name = "No-Intro Nintendo Game Boy Advance e-Reader", - SourceUrl = "datomatic://system/41" - }); - - Insert.IntoTable("NoIntroCatalogSources").Row(new - { - Name = "No-Intro Nintendo Game Boy Advance Play-Yan", - SourceUrl = "datomatic://system/148" - }); - - Insert.IntoTable("NoIntroCatalogSources").Row(new - { - Name = "No-Intro Nintendo Game Boy Advance Video", - SourceUrl = "datomatic://system/297" - }); + // Note: the DAT-o-MATIC-only niche systems (GBA Multiboot/e-Reader/ + // Play-Yan/Video, DSvision) are intentionally NOT seeded — the + // datomatic:// scrape path is currently non-functional, so seeding + // them would surface permanently-failing sources. The support code + // remains; re-seed once FetchDatOMaticNumbered is hardened. } } } diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroCatalogDefaults.cs b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogDefaults.cs index c6e087e8..4d501460 100644 --- a/src/NzbDrone.Core/RomCatalog/NoIntroCatalogDefaults.cs +++ b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogDefaults.cs @@ -16,11 +16,11 @@ public static class NoIntroCatalogDefaults new NoIntroCatalogSourceSeed("No-Intro New Nintendo 3DS", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20New%20Nintendo%203DS.dat"), new NoIntroCatalogSourceSeed("No-Intro New Nintendo 3DS Digital", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20New%20Nintendo%203DS%20%28Digital%29.dat"), new NoIntroCatalogSourceSeed("No-Intro Nintendo DS Download Play", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Nintendo%20DS%20%28Download%20Play%29.dat"), - new NoIntroCatalogSourceSeed("No-Intro Nintendo DS DSvision SD Cards", "datomatic://system/319"), - new NoIntroCatalogSourceSeed("No-Intro Nintendo Game Boy Advance Multiboot", "datomatic://system/137"), - new NoIntroCatalogSourceSeed("No-Intro Nintendo Game Boy Advance e-Reader", "datomatic://system/41"), - new NoIntroCatalogSourceSeed("No-Intro Nintendo Game Boy Advance Play-Yan", "datomatic://system/148"), - new NoIntroCatalogSourceSeed("No-Intro Nintendo Game Boy Advance Video", "datomatic://system/297"), + + // DAT-o-MATIC-only niche systems (GBA Multiboot/e-Reader/Play-Yan/Video, + // DSvision) are intentionally not seeded while the datomatic:// scrape + // path is broken — see FetchDatOMaticNumbered. Restore once it works. + new NoIntroCatalogSourceSeed("No-Intro Nintendo Entertainment System", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Nintendo%20Entertainment%20System.dat"), new NoIntroCatalogSourceSeed("No-Intro Super Nintendo Entertainment System", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Super%20Nintendo%20Entertainment%20System.dat"), new NoIntroCatalogSourceSeed("No-Intro Nintendo 64", "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/no-intro/Nintendo%20-%20Nintendo%2064.dat"), From 42e0ada1f28f83c6ea2bd9fa2b8ee3b277535092 Mon Sep 17 00:00:00 2001 From: pfuenzle Date: Mon, 27 Jul 2026 07:39:59 +0000 Subject: [PATCH 35/42] Scale No-Intro component reconciliation --- .../GameTests/GameComponentServiceFixture.cs | 57 ++++++++++++++++--- .../Games/Components/GameComponentService.cs | 26 +++++---- .../Components/NoIntroGameComponentPlanner.cs | 28 ++++++--- .../RomCatalog/NoIntroCatalogDefaults.cs | 39 +++++++++++++ .../NoIntroCatalogEntryRepository.cs | 9 +++ 5 files changed, 133 insertions(+), 26 deletions(-) diff --git a/src/NzbDrone.Core.Test/GameTests/GameComponentServiceFixture.cs b/src/NzbDrone.Core.Test/GameTests/GameComponentServiceFixture.cs index 0b830fd0..a3337098 100644 --- a/src/NzbDrone.Core.Test/GameTests/GameComponentServiceFixture.cs +++ b/src/NzbDrone.Core.Test/GameTests/GameComponentServiceFixture.cs @@ -42,6 +42,10 @@ public void Setup() Mocker.GetMock() .Setup(m => m.GetFilesByGame(_game.Id)) .Returns(new List()); + + Mocker.GetMock() + .Setup(r => r.GetByPlatformFamily(It.IsAny())) + .Returns(new List()); } private List CapturedInserts() @@ -114,7 +118,7 @@ public void should_create_nointro_catalog_components_for_matching_game_title_and _game.GameMetadata.Value.DlcReferences = new List(); Mocker.GetMock() - .Setup(r => r.All()) + .Setup(r => r.GetByPlatformFamily(PlatformFamily.NintendoDS)) .Returns(new List { Entry("Mario Kart DS (Europe) (En,Fr,De,Es,It)", PlatformFamily.NintendoDS), @@ -133,7 +137,7 @@ public void should_create_nointro_catalog_components_for_matching_game_title_and "Europe (En,Fr,De,Es,It)", "USA, Australia (En,Fr,De,Es,It)", "Japan"); - inserted.Where(c => c.ComponentType == GameComponentType.NoIntroRetailRom).Should().OnlyContain(c => c.Monitored && c.Key.StartsWith("nointro:retail:")); + inserted.Where(c => c.ComponentType == GameComponentType.NoIntroRetailRom).Should().OnlyContain(c => !c.Monitored && c.Key.StartsWith("nointro:retail:")); } [Test] @@ -144,7 +148,7 @@ public void should_create_download_play_components_for_matching_game_title_and_p _game.GameMetadata.Value.DlcReferences = new List(); Mocker.GetMock() - .Setup(r => r.All()) + .Setup(r => r.GetByPlatformFamily(PlatformFamily.NintendoDS)) .Returns(new List { Entry("Mario Kart DS (Europe) (En,Fr,De,Es,It)", PlatformFamily.NintendoDS), @@ -177,7 +181,7 @@ public void should_create_nointro_components_from_alternative_titles() }; Mocker.GetMock() - .Setup(r => r.All()) + .Setup(r => r.GetByPlatformFamily(PlatformFamily.NintendoDS)) .Returns(new List { Entry("Pokemon - White Version (USA, Europe) (NDSi Enhanced)", PlatformFamily.NintendoDS), @@ -198,7 +202,7 @@ public void should_create_3ds_nointro_components_for_matching_game_title_and_pla _game.GameMetadata.Value.DlcReferences = new List(); Mocker.GetMock() - .Setup(r => r.All()) + .Setup(r => r.GetByPlatformFamily(PlatformFamily.Nintendo3DS)) .Returns(new List { Entry("Mario Kart 7 (Europe) (En,Fr,De,Es,It,Nl,Pt,Ru)", PlatformFamily.Nintendo3DS, extension: "3ds"), @@ -233,7 +237,7 @@ public void should_link_nointro_file_to_matching_catalog_component() .Returns(new List { file }); Mocker.GetMock() - .Setup(r => r.All()) + .Setup(r => r.GetByPlatformFamily(PlatformFamily.NintendoDS)) .Returns(new List { Entry("Mario Kart DS (Europe) (En,Fr,De,Es,It)", PlatformFamily.NintendoDS, "0201 - Mario Kart DS (Europe) (En,Fr,De,Es,It).nds") @@ -246,6 +250,45 @@ public void should_link_nointro_file_to_matching_catalog_component() .Verify(m => m.Update(It.Is>(l => l.Count == 1 && l[0].ComponentId == 11)), Times.Once()); } + [Test] + public void should_link_renamed_nointro_file_using_original_file_path() + { + _game.Platform = PlatformFamily.NintendoGB; + _game.GameMetadata.Value.Title = "Mega Man IV"; + _game.GameMetadata.Value.DlcReferences = new List(); + + var baseSlot = new GameComponent { Id = 10, GameId = _game.Id, ComponentType = GameComponentType.Base, Key = "base" }; + var usaSlot = new GameComponent { Id = 11, GameId = _game.Id, ComponentType = GameComponentType.NoIntroRetailRom, Key = "nointro:retail:mega-man-iv-usa" }; + var file = new GameFile + { + Id = 1, + GameId = _game.Id, + RelativePath = "Mega Man IV (1993) Retail - Gamarr.zip", + OriginalFilePath = "Nintendo - Game Boy/Mega Man IV (USA).zip" + }; + + Mocker.GetMock() + .Setup(r => r.GetByGame(_game.Id)) + .Returns(new List { baseSlot, usaSlot }); + + Mocker.GetMock() + .Setup(m => m.GetFilesByGame(_game.Id)) + .Returns(new List { file }); + + Mocker.GetMock() + .Setup(r => r.GetByPlatformFamily(PlatformFamily.NintendoGB)) + .Returns(new List + { + Entry("Mega Man IV (USA)", PlatformFamily.NintendoGB, extension: "zip") + }); + + Subject.EnsureComponents(_game); + + file.ComponentId.Should().Be(11); + Mocker.GetMock() + .Verify(m => m.Update(It.Is>(l => l.Count == 1 && l[0].ComponentId == 11)), Times.Once()); + } + [Test] public void should_link_folder_backed_nointro_file_to_matching_catalog_component() { @@ -275,7 +318,7 @@ public void should_link_folder_backed_nointro_file_to_matching_catalog_component .Returns(new List { "/games/Mario Kart DS/0201 - Mario Kart DS (Europe) (En,Fr,De,Es,It).nds" }); Mocker.GetMock() - .Setup(r => r.All()) + .Setup(r => r.GetByPlatformFamily(PlatformFamily.NintendoDS)) .Returns(new List { Entry("Mario Kart DS (Europe) (En,Fr,De,Es,It)", PlatformFamily.NintendoDS, "0201 - Mario Kart DS (Europe) (En,Fr,De,Es,It).nds") diff --git a/src/NzbDrone.Core/Games/Components/GameComponentService.cs b/src/NzbDrone.Core/Games/Components/GameComponentService.cs index ae8e0788..b84397b3 100644 --- a/src/NzbDrone.Core/Games/Components/GameComponentService.cs +++ b/src/NzbDrone.Core/Games/Components/GameComponentService.cs @@ -101,7 +101,8 @@ public void EnsureComponents(Game game) { var existing = _componentRepository.GetByGame(game.Id); var files = _mediaFileService.GetFilesByGame(game.Id); - var noIntroEntries = (_noIntroCatalogEntryRepository.All() ?? Enumerable.Empty()).ToList(); + var noIntroEntries = (_noIntroCatalogEntryRepository.GetByPlatformFamily(game.Platform) ?? Enumerable.Empty()).ToList(); + var noIntroSlots = _noIntroComponentPlanner.GetSlots(game, noIntroEntries); MergeDuplicateDlcSlots(existing, files); @@ -139,14 +140,16 @@ public void EnsureComponents(Game game) } } - foreach (var slot in _noIntroComponentPlanner.GetSlots(game, noIntroEntries)) + foreach (var slot in noIntroSlots) { - FindOrStage(existing, toInsert, game, slot.ComponentType, slot.Key, slot.Title, monitored: true); + FindOrStage(existing, toInsert, game, slot.ComponentType, slot.Key, slot.Title, monitored: false); } + var folderBackedFileSlot = ResolveFolderBackedFileSlot(game, files, noIntroSlots); + foreach (var file in files) { - var component = GetComponentForFile(existing, toInsert, game, file, baseComponent, noIntroEntries); + var component = GetComponentForFile(existing, toInsert, game, file, baseComponent, noIntroSlots, folderBackedFileSlot); if (component != null && file.ComponentId != component.Id) { @@ -170,7 +173,7 @@ public void EnsureComponents(Game game) foreach (var file in files) { - var component = GetComponentForFile(all, new List(), game, file, all.FirstOrDefault(c => c.ComponentType == GameComponentType.Base), noIntroEntries); + var component = GetComponentForFile(all, new List(), game, file, all.FirstOrDefault(c => c.ComponentType == GameComponentType.Base), noIntroSlots, folderBackedFileSlot); if (component is { Id: > 0 } && file.ComponentId != component.Id) { @@ -229,14 +232,13 @@ private void MergeDuplicateDlcSlots(List existing, List } } - private GameComponent GetComponentForFile(List existing, List toInsert, Game game, GameFile file, GameComponent baseComponent, List noIntroEntries) + private GameComponent GetComponentForFile(List existing, List toInsert, Game game, GameFile file, GameComponent baseComponent, List noIntroSlots, NoIntroGameComponentSlot folderBackedFileSlot) { - var noIntroSlot = _noIntroComponentPlanner.FindSlotForFile(game, noIntroEntries, file) ?? - FindSlotForFolderBackedFile(game, noIntroEntries, file); + var noIntroSlot = _noIntroComponentPlanner.FindSlotForFile(noIntroSlots, file) ?? folderBackedFileSlot; if (noIntroSlot != null) { - return FindOrStage(existing, toInsert, game, noIntroSlot.ComponentType, noIntroSlot.Key, noIntroSlot.Title, monitored: true); + return FindOrStage(existing, toInsert, game, noIntroSlot.ComponentType, noIntroSlot.Key, noIntroSlot.Title, monitored: false); } if (file.RelativePath.IsNullOrWhiteSpace()) @@ -293,9 +295,9 @@ private static bool IsMetadataDlcKey(string key) return key.StartsWith("igdb:") || key.StartsWith("steam:"); } - private NoIntroGameComponentSlot FindSlotForFolderBackedFile(Game game, List noIntroEntries, GameFile file) + private NoIntroGameComponentSlot ResolveFolderBackedFileSlot(Game game, List files, List noIntroSlots) { - if (!file.RelativePath.IsNullOrWhiteSpace() || !_diskProvider.FolderExists(game.Path)) + if (!_diskProvider.FolderExists(game.Path) || !files.Any(file => file.RelativePath.IsNullOrWhiteSpace())) { return null; } @@ -303,7 +305,7 @@ private NoIntroGameComponentSlot FindSlotForFolderBackedFile(Game game, List name.IsNotNullOrWhiteSpace()) - .Select(name => _noIntroComponentPlanner.FindSlotForFileName(game, noIntroEntries, name)) + .Select(name => _noIntroComponentPlanner.FindSlotForFileName(noIntroSlots, name)) .Where(slot => slot != null) .GroupBy(slot => slot.Key) .Select(group => group.First()) diff --git a/src/NzbDrone.Core/Games/Components/NoIntroGameComponentPlanner.cs b/src/NzbDrone.Core/Games/Components/NoIntroGameComponentPlanner.cs index 5c977a6b..52254469 100644 --- a/src/NzbDrone.Core/Games/Components/NoIntroGameComponentPlanner.cs +++ b/src/NzbDrone.Core/Games/Components/NoIntroGameComponentPlanner.cs @@ -85,24 +85,38 @@ private static string CleanTitleKey(string title) public NoIntroGameComponentSlot FindSlotForFile(Game game, List entries, GameFile file) { - if (string.IsNullOrWhiteSpace(file.RelativePath)) + return FindSlotForFile(GetSlots(game, entries), file); + } + + public NoIntroGameComponentSlot FindSlotForFileName(Game game, List entries, string fileName) + { + return FindSlotForFileName(GetSlots(game, entries), fileName); + } + + public NoIntroGameComponentSlot FindSlotForFile(List slots, GameFile file) + { + return FindSlotForFileName(slots, GetFileName(file.RelativePath)) ?? + FindSlotForFileName(slots, GetFileName(file.OriginalFilePath)); + } + + public NoIntroGameComponentSlot FindSlotForFileName(List slots, string fileName) + { + if (string.IsNullOrWhiteSpace(fileName)) { return null; } - var fileName = Path.GetFileName(file.RelativePath.Replace('\\', '/')); - - return FindSlotForFileName(game, entries, fileName); + return slots.FirstOrDefault(slot => slot.FileNames.Contains(fileName)); } - public NoIntroGameComponentSlot FindSlotForFileName(Game game, List entries, string fileName) + private static string GetFileName(string path) { - if (string.IsNullOrWhiteSpace(fileName)) + if (string.IsNullOrWhiteSpace(path)) { return null; } - return GetSlots(game, entries).FirstOrDefault(slot => slot.FileNames.Contains(fileName)); + return Path.GetFileName(path.Replace('\\', '/')); } private static NoIntroGameComponentSlot ToSlot(NoIntroCatalogComponentSlot slot, Dictionary> entriesByCanonicalName) diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroCatalogDefaults.cs b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogDefaults.cs index 4d501460..7ae11d5f 100644 --- a/src/NzbDrone.Core/RomCatalog/NoIntroCatalogDefaults.cs +++ b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogDefaults.cs @@ -99,6 +99,45 @@ public static bool MatchesGamePlatform(PlatformFamily gamePlatform, PlatformFami return (gamePlatform == PlatformFamily.Nintendo && GamePlatform.IsNintendoFamily(catalogPlatform)) || (gamePlatform == PlatformFamily.PlayStation && GamePlatform.IsPlayStationFamily(catalogPlatform)); } + + public static IReadOnlyList GetRelevantPlatformFamilies(PlatformFamily gamePlatform) + { + if (gamePlatform == PlatformFamily.Nintendo) + { + return new[] + { + PlatformFamily.Nintendo, + PlatformFamily.NintendoSwitch, + PlatformFamily.NintendoWiiU, + PlatformFamily.NintendoWii, + PlatformFamily.Nintendo3DS, + PlatformFamily.NintendoDSi, + PlatformFamily.NintendoDS, + PlatformFamily.NintendoGBA, + PlatformFamily.NintendoGB, + PlatformFamily.NintendoGBC, + PlatformFamily.NintendoNES, + PlatformFamily.NintendoSNES, + PlatformFamily.NintendoN64, + PlatformFamily.NintendoFDS, + PlatformFamily.NintendoVirtualBoy, + PlatformFamily.NintendoPokemonMini + }; + } + + if (gamePlatform == PlatformFamily.PlayStation) + { + return new[] + { + PlatformFamily.PlayStation, + PlatformFamily.SonyPS3, + PlatformFamily.SonyPSP, + PlatformFamily.SonyPSVita + }; + } + + return new[] { gamePlatform }; + } } public class NoIntroCatalogSourceSeed diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroCatalogEntryRepository.cs b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogEntryRepository.cs index 1ca510b3..a4a5051b 100644 --- a/src/NzbDrone.Core/RomCatalog/NoIntroCatalogEntryRepository.cs +++ b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogEntryRepository.cs @@ -1,5 +1,7 @@ using System.Collections.Generic; +using System.Linq; using NzbDrone.Core.Datastore; +using NzbDrone.Core.Games; using NzbDrone.Core.Messaging.Events; namespace NzbDrone.Core.RomCatalog @@ -7,6 +9,7 @@ namespace NzbDrone.Core.RomCatalog public interface INoIntroCatalogEntryRepository : IBasicRepository { List GetBySourceId(int catalogSourceId); + List GetByPlatformFamily(PlatformFamily platformFamily); void DeleteBySourceId(int catalogSourceId); } @@ -22,6 +25,12 @@ public List GetBySourceId(int catalogSourceId) return Query(x => x.CatalogSourceId == catalogSourceId); } + public List GetByPlatformFamily(PlatformFamily platformFamily) + { + var relevantFamilies = NoIntroCatalogDefaults.GetRelevantPlatformFamilies(platformFamily).ToHashSet(); + return Query(x => relevantFamilies.Contains(x.PlatformFamily)); + } + public void DeleteBySourceId(int catalogSourceId) { Delete(x => x.CatalogSourceId == catalogSourceId); From 43256b6bb650905e0bc93d7627ee5f3108f2be7d Mon Sep 17 00:00:00 2001 From: pfuenzle Date: Mon, 27 Jul 2026 07:40:08 +0000 Subject: [PATCH 36/42] Tolerate unreadable No-Intro files during verification --- .../GameComponents/GameComponentController.cs | 21 ++++++- .../NoIntroVerificationServiceFixture.cs | 53 +++++++++++++++++ .../RomCatalog/NoIntroRomHasher.cs | 3 +- .../RomCatalog/NoIntroVerificationService.cs | 57 ++++++++++++------- 4 files changed, 110 insertions(+), 24 deletions(-) diff --git a/src/Gamarr.Api.V3/GameComponents/GameComponentController.cs b/src/Gamarr.Api.V3/GameComponents/GameComponentController.cs index 5a7c91eb..42369b08 100644 --- a/src/Gamarr.Api.V3/GameComponents/GameComponentController.cs +++ b/src/Gamarr.Api.V3/GameComponents/GameComponentController.cs @@ -2,6 +2,7 @@ using System.Linq; using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Disk; +using NLog; using NzbDrone.Core.Games; using NzbDrone.Core.Games.Components; using NzbDrone.Core.MediaFiles; @@ -23,6 +24,7 @@ public class GameComponentController : Controller private readonly INoIntroCatalogSourceRepository _noIntroCatalogSourceRepository; private readonly INoIntroCatalogHashRepository _noIntroCatalogHashRepository; private readonly IDiskProvider _diskProvider; + private readonly Logger _logger; public GameComponentController(IGameComponentService componentService, IMediaFileService mediaFileService, @@ -31,7 +33,8 @@ public GameComponentController(IGameComponentService componentService, INoIntroCatalogEntryRepository noIntroCatalogEntryRepository, INoIntroCatalogSourceRepository noIntroCatalogSourceRepository, INoIntroCatalogHashRepository noIntroCatalogHashRepository, - IDiskProvider diskProvider) + IDiskProvider diskProvider, + Logger logger) { _componentService = componentService; _mediaFileService = mediaFileService; @@ -41,6 +44,7 @@ public GameComponentController(IGameComponentService componentService, _noIntroCatalogSourceRepository = noIntroCatalogSourceRepository; _noIntroCatalogHashRepository = noIntroCatalogHashRepository; _diskProvider = diskProvider; + _logger = logger; } [HttpGet] @@ -104,8 +108,19 @@ private List GetFileHashMatches(List file continue; } - using var stream = _diskProvider.OpenReadStream(path); - var hashes = NoIntroRomHasher.Compute(stream); + NoIntroHashTriplet hashes; + + try + { + using var stream = _diskProvider.OpenReadStream(path); + hashes = NoIntroRomHasher.Compute(stream); + } + catch (global::System.Exception ex) + { + _logger.Warn(ex, "Failed hashing game file {0} for component inspection", path); + continue; + } + var matchedHash = FindMatch(hashes, hashByKey); if (matchedHash == null) diff --git a/src/NzbDrone.Core.Test/RomCatalog/NoIntroVerificationServiceFixture.cs b/src/NzbDrone.Core.Test/RomCatalog/NoIntroVerificationServiceFixture.cs index ec38f376..10b8829b 100644 --- a/src/NzbDrone.Core.Test/RomCatalog/NoIntroVerificationServiceFixture.cs +++ b/src/NzbDrone.Core.Test/RomCatalog/NoIntroVerificationServiceFixture.cs @@ -110,6 +110,59 @@ public void should_verify_raw_and_zip_roms_and_mark_duplicates_missing_and_bad_d results.Should().Contain(x => x.CatalogEntryId == missingEntry.Id && x.IsMissing); } + [Test] + public void should_continue_when_one_file_cannot_be_read() + { + var source = _sourceRepository.Insert(new NoIntroCatalogSource + { + Name = "No-Intro", + SourceUrl = "https://example.invalid/gba.dat" + }); + + var goodBytes = new byte[] { 1, 2, 3, 4 }; + + var verifiedEntry = _entryRepository.Insert(new NoIntroCatalogEntry + { + CatalogSourceId = source.Id, + SystemKey = "nintendo-gba", + CanonicalName = "Verified Game", + CanonicalFileName = "Verified Game.gba", + PlatformFamily = PlatformFamily.Nintendo + }); + + InsertSha1Hash(verifiedEntry.Id, goodBytes, false); + + var missingEntry = _entryRepository.Insert(new NoIntroCatalogEntry + { + CatalogSourceId = source.Id, + SystemKey = "nintendo-gba", + CanonicalName = "Missing Game", + CanonicalFileName = "Missing Game.gba", + PlatformFamily = PlatformFamily.Nintendo + }); + + InsertSha1Hash(missingEntry.Id, new byte[] { 7, 7, 7, 7 }, false); + + var verificationSet = _verificationSetRepository.Insert(new NoIntroVerificationSet + { + CatalogSourceId = source.Id, + SystemKey = "nintendo-gba", + RootPath = _tempRoot, + Enabled = true + }); + + var rawPath = WriteBytes("Verified Game.gba", goodBytes); + var unreadablePath = Path.Combine(_tempRoot, "Unreadable Game.gba"); + + _subject.Verify(verificationSet.Id, new[] { rawPath, unreadablePath }); + + var results = _resultRepository.All().ToList(); + + results.Should().Contain(x => x.ActualFileName == "Verified Game.gba" && x.VerificationStatus == NoIntroVerificationStatus.Verified); + results.Should().Contain(x => x.ActualFileName == "Unreadable Game.gba" && x.VerificationStatus == NoIntroVerificationStatus.Unknown && !x.IsMissing); + results.Should().Contain(x => x.CatalogEntryId == missingEntry.Id && x.IsMissing); + } + private void InsertSha1Hash(int entryId, byte[] content, bool isBadDump) { using var sha1 = System.Security.Cryptography.SHA1.Create(); diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroRomHasher.cs b/src/NzbDrone.Core/RomCatalog/NoIntroRomHasher.cs index 7ab02299..83f6d423 100644 --- a/src/NzbDrone.Core/RomCatalog/NoIntroRomHasher.cs +++ b/src/NzbDrone.Core/RomCatalog/NoIntroRomHasher.cs @@ -1,6 +1,5 @@ using System; using System.IO; -using System.Linq; using System.Security.Cryptography; using ICSharpCode.SharpZipLib.Checksum; @@ -21,7 +20,7 @@ public static NoIntroHashTriplet Compute(Stream stream) { md5.TransformBlock(buffer, 0, bytesRead, null, 0); sha1.TransformBlock(buffer, 0, bytesRead, null, 0); - crc.Update(buffer.Take(bytesRead).ToArray()); + crc.Update(new ArraySegment(buffer, 0, bytesRead)); } md5.TransformFinalBlock(Array.Empty(), 0, 0); diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroVerificationService.cs b/src/NzbDrone.Core/RomCatalog/NoIntroVerificationService.cs index c260fa9c..8fca9606 100644 --- a/src/NzbDrone.Core/RomCatalog/NoIntroVerificationService.cs +++ b/src/NzbDrone.Core/RomCatalog/NoIntroVerificationService.cs @@ -74,9 +74,16 @@ private static NoIntroVerificationResult VerifyPath( Dictionary entryMap, RenameProfile renameProfile) { - return Path.GetExtension(path).Equals(".zip", StringComparison.OrdinalIgnoreCase) - ? VerifyArchive(snapshot, verificationSet, path, hashMap, entryMap, renameProfile) - : VerifyFile(snapshot, verificationSet, path, hashMap, entryMap, renameProfile); + try + { + return Path.GetExtension(path).Equals(".zip", StringComparison.OrdinalIgnoreCase) + ? VerifyArchive(snapshot, verificationSet, path, hashMap, entryMap, renameProfile) + : VerifyFile(snapshot, verificationSet, path, hashMap, entryMap, renameProfile); + } + catch (Exception) + { + return BuildUnknownResult(snapshot, verificationSet, path, Path.GetFileName(path), null, null, null); + } } private static NoIntroVerificationResult VerifyFile( @@ -148,22 +155,7 @@ private static NoIntroVerificationResult BuildMatchedResult( if (matchedHash == null) { - return new NoIntroVerificationResult - { - SnapshotId = snapshot.Id, - VerificationSetId = verificationSet.Id, - RelativePath = GetRelativePath(verificationSet.RootPath, fullPath), - ArchivePath = archivePath, - MemberPath = memberPath, - ActualFileName = actualFileName, - ExpectedFileName = null, - HashType = hashes.PreferredHashType, - HashValue = hashes.PreferredHashValue, - VerificationStatus = NoIntroVerificationStatus.Unknown, - IsDuplicate = false, - IsMissing = false, - VerifiedAt = DateTime.UtcNow - }; + return BuildUnknownResult(snapshot, verificationSet, fullPath, actualFileName, archivePath, memberPath, hashes); } var catalogEntry = entryMap[matchedHash.CatalogEntryId]; @@ -258,6 +250,33 @@ private static string GetRelativePath(string rootPath, string fullPath) : fullPath; } + private static NoIntroVerificationResult BuildUnknownResult( + NoIntroVerificationSnapshot snapshot, + NoIntroVerificationSet verificationSet, + string fullPath, + string actualFileName, + string archivePath, + string memberPath, + NoIntroHashTriplet hashes) + { + return new NoIntroVerificationResult + { + SnapshotId = snapshot.Id, + VerificationSetId = verificationSet.Id, + RelativePath = GetRelativePath(verificationSet.RootPath, fullPath), + ArchivePath = archivePath, + MemberPath = memberPath, + ActualFileName = actualFileName, + ExpectedFileName = null, + HashType = hashes?.PreferredHashType, + HashValue = hashes?.PreferredHashValue, + VerificationStatus = NoIntroVerificationStatus.Unknown, + IsDuplicate = false, + IsMissing = false, + VerifiedAt = DateTime.UtcNow + }; + } + private static NoIntroHashTriplet ComputeHashes(Stream stream) { return NoIntroRomHasher.Compute(stream); From 9fe634eb943786bda3023e655540e041e498b88a Mon Sep 17 00:00:00 2001 From: pfuenzle Date: Mon, 27 Jul 2026 07:40:17 +0000 Subject: [PATCH 37/42] Harden DAT-o-MATIC numbered catalog fetches --- .../NoIntroCatalogDocumentClient.cs | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroCatalogDocumentClient.cs b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogDocumentClient.cs index 5ef0f583..dfac969c 100644 --- a/src/NzbDrone.Core/RomCatalog/NoIntroCatalogDocumentClient.cs +++ b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogDocumentClient.cs @@ -17,6 +17,7 @@ public interface INoIntroCatalogDocumentClient public class NoIntroCatalogDocumentClient : INoIntroCatalogDocumentClient { private const string DatOMaticSourceUrlPrefix = "datomatic://system/"; + private static readonly Regex PrepareFieldRegex = new Regex(@"name=""(?dat_dl_[^""]+)""\s+value=""Prepare""", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex DownloadTokenRegex = new Regex("[0-9a-f]{32})\" value=\"Download!!\"", RegexOptions.Compiled | RegexOptions.IgnoreCase); private readonly IHttpClient _httpClient; @@ -29,7 +30,11 @@ public string Fetch(string sourceUrl) { if (sourceUrl.StartsWith(DatOMaticSourceUrlPrefix, StringComparison.OrdinalIgnoreCase)) { - var systemId = int.Parse(sourceUrl.Substring(DatOMaticSourceUrlPrefix.Length)); + if (!int.TryParse(sourceUrl.AsSpan(DatOMaticSourceUrlPrefix.Length), out var systemId)) + { + throw new InvalidOperationException($"Invalid DAT-o-MATIC source url: {sourceUrl}"); + } + return FetchDatOMaticNumbered(systemId); } @@ -39,7 +44,13 @@ public string Fetch(string sourceUrl) public string FetchDatOMaticNumbered(int systemId) { var prepareUrl = $"https://datomatic.no-intro.org/index.php?page=download&op=dat&s={systemId}"; - _httpClient.Get(new HttpRequest(prepareUrl)); + var preparePage = _httpClient.Get(new HttpRequest(prepareUrl)); + var prepareField = PrepareFieldRegex.Match(preparePage.Content).Groups["field"].Value; + + if (string.IsNullOrWhiteSpace(prepareField)) + { + throw new InvalidOperationException("DAT-o-MATIC did not expose a numbered DAT prepare field"); + } var prepareRequest = new HttpRequestBuilder(prepareUrl) .Post() @@ -61,7 +72,7 @@ public string FetchDatOMaticNumbered(int systemId) .AddFormParameter("storage_2", 1) .AddFormParameter("inc_nodump", 0) .AddFormParameter("inc_mia", 1) - .AddFormParameter("dat_dl_2026-05-30", "Prepare") + .AddFormParameter(prepareField, "Prepare") .Build(); var prepareResponse = _httpClient.Post(prepareRequest); @@ -95,7 +106,13 @@ private static string ExtractDat(byte[] data) { using var stream = new MemoryStream(data); using var archive = new ZipArchive(stream, ZipArchiveMode.Read); - var entry = archive.Entries.FirstOrDefault(x => x.Name.EndsWith(".dat", StringComparison.OrdinalIgnoreCase)) ?? archive.Entries.First(); + var entry = archive.Entries.FirstOrDefault(x => x.Name.EndsWith(".dat", StringComparison.OrdinalIgnoreCase)) ?? archive.Entries.FirstOrDefault(); + + if (entry == null) + { + throw new InvalidOperationException("Catalog archive was empty"); + } + using var entryStream = entry.Open(); using var reader = new StreamReader(entryStream); return reader.ReadToEnd(); From c2c961dc3decff26c3b2f019194e45c5e1ca1b5f Mon Sep 17 00:00:00 2001 From: pfuenzle Date: Mon, 27 Jul 2026 07:40:27 +0000 Subject: [PATCH 38/42] Preserve No-Intro variant filenames under Gamarr profile --- .../RenameProfileNamingBehaviorFixture.cs | 50 ++++++++++ .../Organizer/FileNameBuilder.cs | 91 +++++++++++++++++-- 2 files changed, 131 insertions(+), 10 deletions(-) diff --git a/src/NzbDrone.Core.Test/Organizer/RenameProfileNamingBehaviorFixture.cs b/src/NzbDrone.Core.Test/Organizer/RenameProfileNamingBehaviorFixture.cs index 983ef85f..d3609f55 100644 --- a/src/NzbDrone.Core.Test/Organizer/RenameProfileNamingBehaviorFixture.cs +++ b/src/NzbDrone.Core.Test/Organizer/RenameProfileNamingBehaviorFixture.cs @@ -4,10 +4,12 @@ using NUnit.Framework; using NzbDrone.Core.CustomFormats; using NzbDrone.Core.Games; +using NzbDrone.Core.Games.Components; using NzbDrone.Core.Games.Translations; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Organizer; using NzbDrone.Core.Qualities; +using NzbDrone.Core.RomCatalog; using NzbDrone.Core.Test.Framework; namespace NzbDrone.Core.Test.Organizer @@ -58,5 +60,53 @@ public void RenameProfile_should_preserve_existing_default_file_name_builder_out Subject.BuildFileName(game, gameFile) .Should().Be("South Park (1998) Uplay"); } + + [Test] + public void RenameProfile_should_preserve_original_nointro_variant_filename_for_gamarr_profile() + { + var game = new Game + { + Id = 5, + Title = "Mega Man IV", + Year = 1993 + }; + + var gameFile = new GameFile + { + GameId = 5, + Quality = new QualityModel(Quality.Retail), + OriginalFilePath = "Nintendo - Game Boy/Mega Man IV (USA).zip", + RelativePath = "Mega Man IV (1993) Retail - Gamarr.zip" + }; + + Mocker.GetMock() + .Setup(x => x.GetByGame(5)) + .Returns(new System.Collections.Generic.List + { + new GameComponent + { + Id = 11, + GameId = 5, + ComponentType = GameComponentType.NoIntroRetailRom, + Key = "nointro:retail:mega-man-iv-usa", + Title = "USA" + } + }); + + Mocker.GetMock() + .Setup(x => x.All()) + .Returns(new[] + { + new NoIntroCatalogEntry + { + SystemKey = "nintendo---game-boy", + CanonicalName = "Mega Man IV (USA)", + CanonicalFileName = "Mega Man IV (USA).zip" + } + }); + + Subject.BuildFileName(game, gameFile) + .Should().Be("Mega Man IV (USA)"); + } } } diff --git a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs index 9840dd7e..f609c27a 100644 --- a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs +++ b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs @@ -165,32 +165,88 @@ public string BuildFileName(Game game, GameFile gameFile, NamingConfig namingCon private string GetNoIntroFileName(GameFile gameFile, RenameProfile renameProfile) { - if (renameProfile == RenameProfile.Gamarr || gameFile?.ComponentId <= 0) + if (gameFile == null) { return null; } - var component = _componentRepository.Get(gameFile.ComponentId); + var actualFileName = GetActualFileName(gameFile); + var catalogEntry = ResolveNoIntroCatalogEntry(gameFile, actualFileName); + + if (catalogEntry == null) + { + return null; + } + + if (renameProfile == RenameProfile.Gamarr) + { + return Path.GetFileNameWithoutExtension(actualFileName); + } + + var expectedFileName = NoIntroRenameProfileEvaluator.GetExpectedFileName(catalogEntry, actualFileName, renameProfile); + return Path.GetFileNameWithoutExtension(expectedFileName); + } + + private NoIntroCatalogEntry ResolveNoIntroCatalogEntry(GameFile gameFile, string actualFileName) + { + if (actualFileName.IsNullOrWhiteSpace()) + { + return null; + } + + if (gameFile.ComponentId > 0) + { + var component = _componentRepository.Get(gameFile.ComponentId); + + if (component != null) + { + return FindMatchingCatalogEntry(component, actualFileName); + } + } + + if (gameFile.GameId <= 0) + { + return null; + } + + return _componentRepository.GetByGame(gameFile.GameId) + .Where(IsNoIntroComponent) + .Select(component => FindMatchingCatalogEntry(component, actualFileName)) + .FirstOrDefault(entry => entry != null); + } - if (component == null || component.Title.IsNullOrWhiteSpace()) + private NoIntroCatalogEntry FindMatchingCatalogEntry(GameComponent component, string actualFileName) + { + if (component == null) { return null; } - var catalogEntry = _noIntroEntryRepository.All() - .Where(entry => IsComponentCatalogMatch(component.Title, entry)) + return _noIntroEntryRepository.All() + .Where(entry => IsComponentCatalogMatch(component, entry) && EntryMatchesActualFileName(entry, actualFileName)) .OrderBy(entry => entry.SystemKey) .ThenBy(entry => entry.CanonicalName) .FirstOrDefault(); + } - if (catalogEntry == null || catalogEntry.CanonicalFileName.IsNullOrWhiteSpace()) + private static bool EntryMatchesActualFileName(NoIntroCatalogEntry entry, string actualFileName) + { + if (entry == null || actualFileName.IsNullOrWhiteSpace()) { - return null; + return false; } - var actualFileName = GetActualFileName(gameFile); - var expectedFileName = NoIntroRenameProfileEvaluator.GetExpectedFileName(catalogEntry, actualFileName, renameProfile); - return Path.GetFileNameWithoutExtension(expectedFileName); + return string.Equals(entry.CanonicalFileName, actualFileName, StringComparison.Ordinal) || + string.Equals(entry.NumberedCanonicalFileName, actualFileName, StringComparison.Ordinal); + } + + private static bool IsNoIntroComponent(GameComponent component) + { + return component.ComponentType is GameComponentType.NoIntroRetailRom or + GameComponentType.NoIntroMultiboot or + GameComponentType.NoIntroVideo or + GameComponentType.NoIntroBios or + GameComponentType.NoIntroRomhackOrUnverified; } private static bool IsComponentCatalogMatch(string componentTitle, NoIntroCatalogEntry entry) @@ -208,6 +264,21 @@ private static bool IsComponentCatalogMatch(string componentTitle, NoIntroCatalo return entry.CanonicalName == componentTitle || entry.CanonicalName.StartsWith($"{componentTitle} (", StringComparison.Ordinal); } + private static bool IsComponentCatalogMatch(GameComponent component, NoIntroCatalogEntry entry) + { + if (component == null || entry == null || entry.CanonicalName.IsNullOrWhiteSpace()) + { + return false; + } + + if (IsNoIntroComponent(component)) + { + return component.Key?.EndsWith($":{Parser.Parser.ToUrlSlug(entry.CanonicalName, true)}", StringComparison.Ordinal) == true; + } + + return IsComponentCatalogMatch(component.Title, entry); + } + private static string GetActualFileName(GameFile gameFile) { if (gameFile.RelativePath.IsNotNullOrWhiteSpace()) From 36d7972575da7bacbf67ef8a7f629b9a55937196 Mon Sep 17 00:00:00 2001 From: pfuenzle Date: Mon, 27 Jul 2026 07:40:34 +0000 Subject: [PATCH 39/42] Show richer metadata in component rows --- .../Components/GameComponentsTable.tsx | 23 ++++++++++++++++--- .../GameComponents/GameComponentResource.cs | 14 +++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/frontend/src/Game/Details/Components/GameComponentsTable.tsx b/frontend/src/Game/Details/Components/GameComponentsTable.tsx index 8f25d13e..1202330d 100644 --- a/frontend/src/Game/Details/Components/GameComponentsTable.tsx +++ b/frontend/src/Game/Details/Components/GameComponentsTable.tsx @@ -32,6 +32,8 @@ interface GameComponent { qualityProfileId: number; hasFile: boolean; sizeOnDisk: number; + releaseGroup?: string; + version?: string; noIntroCatalogMatches: NoIntroCatalogMatch[]; } @@ -53,7 +55,7 @@ const columns = [ { name: 'title', label: () => translate('Title'), isVisible: true }, { name: 'size', label: () => translate('Size'), isVisible: true }, { name: 'status', label: () => translate('Status'), isVisible: true }, - { name: 'noIntro', label: () => 'No-Intro', isVisible: true }, + { name: 'noIntro', label: () => 'Catalog', isVisible: true }, { name: 'qualityProfileId', label: () => translate('QualityProfile'), @@ -104,6 +106,14 @@ function getTypeLabel(componentType: string) { ); } +function getComponentMeta(component: GameComponent) { + const parts = [component.version, component.releaseGroup].filter( + (value): value is string => value != null && value.trim().length > 0 + ); + + return parts.join(' · '); +} + const noIntroSystemNames: Record = { 'nintendo---game-boy': 'Nintendo Game Boy', 'nintendo---game-boy-color': 'Nintendo Game Boy Color', @@ -174,7 +184,7 @@ function getNoIntroStatus(component: GameComponent) { const version = match.catalogVersion ? ` (${match.catalogVersion})` : ''; const systemName = noIntroSystemNames[match.systemKey] ?? match.systemKey; const title = [ - `${match.sourceName ?? 'No-Intro'}${version}`, + `${match.sourceName ?? 'Catalog'}${version}`, ...matches.flatMap((catalogMatch) => { const lines = [catalogMatch.canonicalFileName]; @@ -267,7 +277,14 @@ function GameComponentRow({ - {component.title} + +
{component.title}
+ {getComponentMeta(component) ? ( +
+ {getComponentMeta(component)} +
+ ) : null} +
{component.hasFile ? formatBytes(component.sizeOnDisk) : '-'} diff --git a/src/Gamarr.Api.V3/GameComponents/GameComponentResource.cs b/src/Gamarr.Api.V3/GameComponents/GameComponentResource.cs index df2a6b15..487c5d31 100644 --- a/src/Gamarr.Api.V3/GameComponents/GameComponentResource.cs +++ b/src/Gamarr.Api.V3/GameComponents/GameComponentResource.cs @@ -23,6 +23,8 @@ public class GameComponentResource : RestResource // component without a file is "missing". public bool HasFile { get; set; } public long SizeOnDisk { get; set; } + public string ReleaseGroup { get; set; } + public string Version { get; set; } public List NoIntroCatalogMatches { get; set; } = new List(); } @@ -74,10 +76,22 @@ public static GameComponentResource ToResource(this GameComponent model, GameCom QualityProfileId = model.QualityProfileId, HasFile = files.Any(), SizeOnDisk = files.Sum(f => f.Size), + ReleaseGroup = JoinDistinct(files.Select(f => f.ReleaseGroup)), + Version = JoinDistinct(files.Select(f => f.GameVersion?.ToString())), NoIntroCatalogMatches = GetNoIntroCatalogMatches(model, files, context) }; } + private static string JoinDistinct(IEnumerable values) + { + var distinct = values + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Distinct() + .ToList(); + + return distinct.Count == 0 ? null : string.Join(", ", distinct); + } + private static List GetNoIntroCatalogMatches(GameComponent component, List files, GameComponentNoIntroCatalogContext context) { if (context.Entries.Count == 0 || string.IsNullOrWhiteSpace(component.Title)) From f3608ce4d75708e03ee05d9392fe2aeb95ca4d0d Mon Sep 17 00:00:00 2001 From: pfuenzle Date: Mon, 27 Jul 2026 08:23:59 +0000 Subject: [PATCH 40/42] Prefer original No-Intro filenames for variant naming --- src/NzbDrone.Core/Organizer/FileNameBuilder.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs index f609c27a..769aec50 100644 --- a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs +++ b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs @@ -281,14 +281,14 @@ private static bool IsComponentCatalogMatch(GameComponent component, NoIntroCata private static string GetActualFileName(GameFile gameFile) { - if (gameFile.RelativePath.IsNotNullOrWhiteSpace()) + if (gameFile.OriginalFilePath.IsNotNullOrWhiteSpace()) { - return Path.GetFileName(gameFile.RelativePath); + return Path.GetFileName(gameFile.OriginalFilePath); } - if (gameFile.OriginalFilePath.IsNotNullOrWhiteSpace()) + if (gameFile.RelativePath.IsNotNullOrWhiteSpace()) { - return Path.GetFileName(gameFile.OriginalFilePath); + return Path.GetFileName(gameFile.RelativePath); } return Path.GetFileName(gameFile.Path); From 67d909b02f4e2ab1d87ac415672495b399f0e8d0 Mon Sep 17 00:00:00 2001 From: pfuenzle Date: Mon, 27 Jul 2026 09:07:30 +0000 Subject: [PATCH 41/42] Fix No-Intro unit test regressions --- .../FileNameBuilderFixture.cs | 2 ++ .../NoIntroCatalogSyncServiceFixture.cs | 2 ++ .../Organizer/FileNameBuilder.cs | 5 +++- .../RomCatalog/NoIntroCatalogSyncService.cs | 24 +++++++++++++++++-- 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs index 217fa70f..278313b2 100644 --- a/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs +++ b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs @@ -181,12 +181,14 @@ public void should_use_nointro_catalog_filename_when_nointro_profile_is_selected _namingConfig.RenameProfile = RenameProfile.NoIntroPreserveById; _gameFile.ComponentId = 50; _gameFile.RelativePath = "Mario Kart DS (2005) Unknown - Gamarr.nds"; + _gameFile.OriginalFilePath = "Nintendo - Nintendo DS/Mario Kart DS (Europe) (En,Fr,De,Es,It).nds"; Mocker.GetMock() .Setup(x => x.Get(50)) .Returns(new GameComponent { Id = 50, + Key = "nointro:retail:mario-kart-ds-europe-en-fr-de-es-it", Title = "Mario Kart DS (Europe) (En,Fr,De,Es,It)" }); diff --git a/src/NzbDrone.Core.Test/RomCatalog/NoIntroCatalogSyncServiceFixture.cs b/src/NzbDrone.Core.Test/RomCatalog/NoIntroCatalogSyncServiceFixture.cs index f3850152..3854f016 100644 --- a/src/NzbDrone.Core.Test/RomCatalog/NoIntroCatalogSyncServiceFixture.cs +++ b/src/NzbDrone.Core.Test/RomCatalog/NoIntroCatalogSyncServiceFixture.cs @@ -6,6 +6,7 @@ using NzbDrone.Core.Games; using NzbDrone.Core.RomCatalog; using NzbDrone.Core.Test.Framework; +using NzbDrone.Test.Common; namespace NzbDrone.Core.Test.RomCatalog { @@ -254,6 +255,7 @@ public void sync_failure_should_preserve_existing_catalog_and_record_failure() Action act = () => _subject.Sync(source.Id); act.Should().Throw(); + ExceptionVerification.ExpectedWarns(1); _entryRepository.All().Should().ContainSingle(x => x.Id == existingEntry.Id); _hashRepository.All().Should().ContainSingle(x => x.HashValue == "oldhash"); diff --git a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs index 769aec50..27d4dff6 100644 --- a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs +++ b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs @@ -273,7 +273,10 @@ private static bool IsComponentCatalogMatch(GameComponent component, NoIntroCata if (IsNoIntroComponent(component)) { - return component.Key?.EndsWith($":{Parser.Parser.ToUrlSlug(entry.CanonicalName, true)}", StringComparison.Ordinal) == true; + if (component.Key?.EndsWith($":{Parser.Parser.ToUrlSlug(entry.CanonicalName, true)}", StringComparison.Ordinal) == true) + { + return true; + } } return IsComponentCatalogMatch(component.Title, entry); diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSyncService.cs b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSyncService.cs index b7299eea..f1f2ea23 100644 --- a/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSyncService.cs +++ b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSyncService.cs @@ -172,7 +172,15 @@ private void EnrichWithNumberedCatalog(NoIntroCatalogSnapshot snapshot) try { - var numberedSnapshot = _snapshotParser.Parse(_documentClient.FetchDatOMaticNumbered(systemId.Value)); + var numberedContent = _documentClient.FetchDatOMaticNumbered(systemId.Value); + + if (string.IsNullOrWhiteSpace(numberedContent)) + { + EnrichWithAdvansceneCatalog(snapshot); + return; + } + + var numberedSnapshot = _snapshotParser.Parse(numberedContent); var numberedByHash = numberedSnapshot.Entries .SelectMany(entry => entry.Hashes.Select(hash => new { Key = HashKey(hash), Entry = entry })) .GroupBy(x => x.Key) @@ -193,6 +201,11 @@ private void EnrichWithNumberedCatalog(NoIntroCatalogSnapshot snapshot) entry.NumberedCanonicalFileName = numberedEntry.NumberedCanonicalFileName; } } + catch (InvalidOperationException) + { + EnrichWithAdvansceneCatalog(snapshot); + return; + } catch (Exception ex) { _logger.Warn(ex, "Failed enriching No-Intro catalog {0} with DAT-o-MATIC numbered filenames", snapshot.SystemKey); @@ -212,7 +225,14 @@ private void EnrichWithAdvansceneCatalog(NoIntroCatalogSnapshot snapshot) try { - var releaseNumbersByCrc = ParseAdvansceneReleaseNumbers(_documentClient.FetchAdvanscene(sourceUrl)); + var advansceneContent = _documentClient.FetchAdvanscene(sourceUrl); + + if (string.IsNullOrWhiteSpace(advansceneContent)) + { + return; + } + + var releaseNumbersByCrc = ParseAdvansceneReleaseNumbers(advansceneContent); foreach (var entry in snapshot.Entries.Where(x => x.NumberedCanonicalFileName == null)) { From 9dbf40505325d975951efb785cd903dae8507252 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 03:27:06 -0600 Subject: [PATCH 42/42] Skip needless ADVANsCEne download; log the DAT-o-MATIC fallback Reinstates the early-out when every entry is already numbered (the daily sync would otherwise fetch the zip per system for nothing) and gives the silent token-failure fallback a debug line. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01F92WBVAQREJSZ86yokPjp4 --- .../RomCatalog/NoIntroCatalogSyncService.cs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSyncService.cs b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSyncService.cs index f1f2ea23..8f777c44 100644 --- a/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSyncService.cs +++ b/src/NzbDrone.Core/RomCatalog/NoIntroCatalogSyncService.cs @@ -201,8 +201,9 @@ private void EnrichWithNumberedCatalog(NoIntroCatalogSnapshot snapshot) entry.NumberedCanonicalFileName = numberedEntry.NumberedCanonicalFileName; } } - catch (InvalidOperationException) + catch (InvalidOperationException ex) { + _logger.Debug(ex, "DAT-o-MATIC numbered DAT unavailable for {0}; falling back to ADVANsCEne", snapshot.SystemKey); EnrichWithAdvansceneCatalog(snapshot); return; } @@ -223,6 +224,15 @@ private void EnrichWithAdvansceneCatalog(NoIntroCatalogSnapshot snapshot) return; } + var unnumbered = snapshot.Entries.Where(x => x.NumberedCanonicalFileName == null).ToList(); + + // Nothing left to number (DAT-o-MATIC covered everything) — skip + // the download entirely. + if (unnumbered.Count == 0) + { + return; + } + try { var advansceneContent = _documentClient.FetchAdvanscene(sourceUrl); @@ -234,7 +244,7 @@ private void EnrichWithAdvansceneCatalog(NoIntroCatalogSnapshot snapshot) var releaseNumbersByCrc = ParseAdvansceneReleaseNumbers(advansceneContent); - foreach (var entry in snapshot.Entries.Where(x => x.NumberedCanonicalFileName == null)) + foreach (var entry in unnumbered) { var crc = entry.Hashes.FirstOrDefault(x => x.HashType.Equals("crc32", StringComparison.OrdinalIgnoreCase));