diff --git a/.azure/pipelines/ci.yml b/.azure/pipelines/ci.yml index 02c4fd20..d0e5ac37 100644 --- a/.azure/pipelines/ci.yml +++ b/.azure/pipelines/ci.yml @@ -59,66 +59,66 @@ stages: DOTNET_CLI_TELEMETRY_OPTOUT: 1 DOTNET_NOLOGO: 1 - - stage: "integration_testing" - displayName: "Integration testing" - dependsOn: [] - condition: eq(variables['RunIntegrationTestingStage'], true) - jobs: - - job: "windows_integrationtests" - displayName: "Windows CI" - pool: - vmImage: "windows-latest" - steps: - - task: UseDotNet@2 - displayName: "Use .NET Core SDK" - inputs: - packageType: "sdk" - version: $(dotnet.sdk.version) - - task: PowerShell@2 - displayName: "Start local MongoDB" - inputs: - targetType: "inline" - script: | - Write-Host "Starting MongoDB" - md log - md data - Start-Process -FilePath "mongod" -ArgumentList "--logpath log/mongod.log", "--dbpath data", "--port 27017" -WindowStyle Hidden - - task: DotNetCoreCLI@2 - displayName: "Restore .NET packages" - inputs: - command: "restore" - projects: "$(SolutionFile)" - feedsToUse: "select" - noCache: $(dotnet.cache.disable) - env: - DOTNET_CLI_TELEMETRY_OPTOUT: 1 - DOTNET_NOLOGO: 1 - - task: DotNetCoreCLI@2 - displayName: "Build .NET solution" - inputs: - command: "build" - projects: "$(SolutionFile)" - arguments: "-c $(BuildConfiguration)" - env: - DOTNET_CLI_TELEMETRY_OPTOUT: 1 - DOTNET_NOLOGO: 1 - - task: DotNetCoreCLI@2 - displayName: "Run .NET test projects" - inputs: - command: "test" - projects: "$(TestProjects)" - arguments: '--configuration $(BuildConfiguration) --report-xunit-trx --coverage --coverage-output-format cobertura -- --filter-query "/[(Category=UnitTests)|(Mode=Readonly)]"' - env: - AllowedOrigins__0: "5207" - Features__IsScalarEnabled: true - Features__IsHttpsRedirectionEnabled: false - ASPNETCORE_ENVIRONMENT: Development - DOTNET_CLI_TELEMETRY_OPTOUT: 1 - DOTNET_NOLOGO: 1 - - task: PublishBuildArtifacts@1 - displayName: "Publish artifacts" - condition: failed() - inputs: - PathtoPublish: "$(Build.ArtifactStagingDirectory)" - ArtifactName: "drop" - publishLocation: "Container" +# - stage: "integration_testing" +# displayName: "Integration testing" +# dependsOn: [] +# condition: eq(variables['RunIntegrationTestingStage'], true) +# jobs: +# - job: "windows_integrationtests" +# displayName: "Windows CI" +# pool: +# vmImage: "windows-latest" +# steps: +# - task: UseDotNet@2 +# displayName: "Use .NET Core SDK" +# inputs: +# packageType: "sdk" +# version: $(dotnet.sdk.version) +# - task: PowerShell@2 +# displayName: "Start local MongoDB" +# inputs: +# targetType: "inline" +# script: | +# Write-Host "Starting MongoDB" +# md log +# md data +# Start-Process -FilePath "mongod" -ArgumentList "--logpath log/mongod.log", "--dbpath data", "--port 27017" -WindowStyle Hidden +# - task: DotNetCoreCLI@2 +# displayName: "Restore .NET packages" +# inputs: +# command: "restore" +# projects: "$(SolutionFile)" +# feedsToUse: "select" +# noCache: $(dotnet.cache.disable) +# env: +# DOTNET_CLI_TELEMETRY_OPTOUT: 1 +# DOTNET_NOLOGO: 1 +# - task: DotNetCoreCLI@2 +# displayName: "Build .NET solution" +# inputs: +# command: "build" +# projects: "$(SolutionFile)" +# arguments: "-c $(BuildConfiguration)" +# env: +# DOTNET_CLI_TELEMETRY_OPTOUT: 1 +# DOTNET_NOLOGO: 1 +# - task: DotNetCoreCLI@2 +# displayName: "Run .NET test projects" +# inputs: +# command: "test" +# projects: "$(TestProjects)" +# arguments: '--configuration $(BuildConfiguration) --report-xunit-trx --coverage --coverage-output-format cobertura -- --filter-query "/[(Category=UnitTests)|(Mode=Readonly)]"' +# env: +# AllowedOrigins__0: "5207" +# Features__IsScalarEnabled: true +# Features__IsHttpsRedirectionEnabled: false +# ASPNETCORE_ENVIRONMENT: Development +# DOTNET_CLI_TELEMETRY_OPTOUT: 1 +# DOTNET_NOLOGO: 1 +# - task: PublishBuildArtifacts@1 +# displayName: "Publish artifacts" +# condition: failed() +# inputs: +# PathtoPublish: "$(Build.ArtifactStagingDirectory)" +# ArtifactName: "drop" +# publishLocation: "Container" diff --git a/.editorconfig b/.editorconfig index 0a60a2c0..742fee64 100644 --- a/.editorconfig +++ b/.editorconfig @@ -6,7 +6,7 @@ root = true end_of_line = lf insert_final_newline = true indent_style = space -max_line_length = 150 +max_line_length = 180 [*.{cs,razor,sln}] indent_size = 4 @@ -19,6 +19,7 @@ trim_trailing_whitespace = true [*.md] indent_size = 2 trim_trailing_whitespace = false +max_line_length = 240 [*.cs] dotnet_sort_system_directives_first = true @@ -152,3 +153,7 @@ csharp_space_between_square_brackets = false csharp_prefer_braces = true:silent csharp_preserve_single_line_blocks = true csharp_preserve_single_line_statements = true +# Mapperly configuration drift (an unmapped source or target member on a [Mapper] class) must fail the build, the way AutoMapperConfigurationTest's AssertConfigurationIsValid() used to fail the test run +# before it was deleted - see docs/automapper-removal-plan.md. +dotnet_diagnostic.RMG012.severity = error +dotnet_diagnostic.RMG020.severity = error diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b827e193..cbe55619 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -4,9 +4,11 @@ on: push: - branches: [ main ] + branches: + - main pull_request: - branches: [ main ] + branches: + - main workflow_dispatch: inputs: run-code-quality: @@ -37,13 +39,13 @@ jobs: markup-lint: name: Markup - uses: devpro/github-workflow-parts/.github/workflows/reusable-markup-lint.yml@c90ba46a93ca90123f57744b47e49c1a73962cea + uses: devpro/github-workflow-parts/.github/workflows/reusable-markup-lint.yml@f9d8c00db1ed723e39cfff6c4c7addc77a23c54d code-quality: name: Code needs: git-check if: needs.git-check.outputs.app_changed == 'true' || (github.event_name == 'workflow_dispatch' && inputs.run-code-quality) - uses: devpro/github-workflow-parts/.github/workflows/reusable-dotnet-quality.yml@c90ba46a93ca90123f57744b47e49c1a73962cea + uses: devpro/github-workflow-parts/.github/workflows/reusable-dotnet-quality.yml@f9d8c00db1ed723e39cfff6c4c7addc77a23c54d with: custom-commands: | ./scripts/mongodb-install.sh @@ -58,11 +60,11 @@ jobs: Infrastructure__MongoDB__DatabaseName=keeptrack_ci fossa-enabled: true sonar-enabled: true - sonar-exclusions: "samples/**" + sonar-exclusions: "samples/**,**/*.g.cs,**/*.generated.cs" sonar-organization: ${{ vars.SONAR_ORG }} sonar-project-key: ${{ vars.SONAR_PROJECT_KEY }} sonar-project-name: Keeptrack - workflow-parts-version: c90ba46a93ca90123f57744b47e49c1a73962cea + workflow-parts-version: f9d8c00db1ed723e39cfff6c4c7addc77a23c54d secrets: fossa-api-key: ${{ secrets.FOSSA_API_KEY }} sonar-token: ${{ secrets.SONAR_TOKEN }} @@ -70,6 +72,9 @@ jobs: Authentication__JwtBearer__Authority=https://securetoken.google.com/${{ secrets.FIREBASE_PROJECTID }} Authentication__JwtBearer__TokenValidation__Issuer=https://securetoken.google.com/${{ secrets.FIREBASE_PROJECTID }} Authentication__JwtBearer__TokenValidation__Audience=${{ secrets.FIREBASE_PROJECTID }} + Tmdb__ApiKey=${{ secrets.TMDB_APIKEY }} + Rawg__ApiKey=${{ secrets.RAWG_APIKEY }} + Discogs__Token=${{ secrets.DISCOGS_TOKEN }} FIREBASE_APIKEY=${{ secrets.FIREBASE_APIKEY }} FIREBASE_USERNAME=${{ secrets.FIREBASE_TESTUSERNAME }} FIREBASE_PASSWORD=${{ secrets.FIREBASE_TESTPASSWORD }} @@ -90,7 +95,7 @@ jobs: - name: "Web Api" image-name: "keeptrack-webapi" image-definition: "src/WebApi/Dockerfile" - uses: devpro/github-workflow-parts/.github/workflows/reusable-container-scan.yml@c90ba46a93ca90123f57744b47e49c1a73962cea + uses: devpro/github-workflow-parts/.github/workflows/reusable-container-scan.yml@f9d8c00db1ed723e39cfff6c4c7addc77a23c54d with: image-definition: ${{ matrix.image-definition }} image-name: ${{ matrix.image-name }} diff --git a/.github/workflows/pkg.yaml b/.github/workflows/pkg.yaml index 82d0668c..caed832a 100644 --- a/.github/workflows/pkg.yaml +++ b/.github/workflows/pkg.yaml @@ -28,7 +28,7 @@ jobs: permissions: id-token: write contents: read - uses: devpro/github-workflow-parts/.github/workflows/reusable-container-publication.yml@c90ba46a93ca90123f57744b47e49c1a73962cea + uses: devpro/github-workflow-parts/.github/workflows/reusable-container-publication.yml@f9d8c00db1ed723e39cfff6c4c7addc77a23c54d with: create-latest: ${{ github.ref_name == 'main' }} image-definition: ${{ matrix.image-definition }} diff --git a/.gitignore b/.gitignore index b10602ac..11e8acdc 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ Local.runsettings # NPM firebase.config.js node_modules/ + +# Logs +*.log diff --git a/.markdownlint-cli2.yaml b/.markdownlint-cli2.yaml index bc94ac6b..5e56cf49 100644 --- a/.markdownlint-cli2.yaml +++ b/.markdownlint-cli2.yaml @@ -1,4 +1,4 @@ -gitignore: true +gitignore: true ignores: - "**/node_modules/**" config: diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..2d95db74 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,724 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project overview + +Keeptrack is a source-available application (PolyForm Strict 1.0.0, see `LICENSE` - not open source). +It lets users save and review everything they read, watch, listen to or play (books, movies, TV shows, music albums, video games, cars/car history). + +It is a three-tier .NET 10 / C# solution: + +- Frontend: `BlazorApp`, a Blazor Server application. +- Backend: `WebApi`, an ASP.NET Web API (REST). +- Database: MongoDB. + +## Commands + +```bash +# restore and build the whole solution +dotnet restore +dotnet build + +# run the Web API (https://localhost:5011/) +dotnet run --project src/WebApi + +# run the Blazor Server app (https://localhost:5021/) +dotnet run --project src/BlazorApp + +# run all tests (uses the Microsoft.Testing.Platform runner, xunit v3) +dotnet test + +# run a single test project +dotnet test test/WebApi.UnitTests/WebApi.UnitTests.csproj +dotnet test test/WebApi.IntegrationTests/WebApi.IntegrationTests.csproj + +# run a single test by fully qualified name +dotnet test --filter-method "Keeptrack.WebApi.UnitTests.Services.WatchNextServiceTest.ComputeInProgressShows_IncludesShowWithAConfirmedAiredUnwatchedNextEpisode" + +# build container images +docker build . -t devprofr/keeptrack-blazorapp:local -f src/BlazorApp/Dockerfile +docker build . -t devprofr/keeptrack-webapi:local -f src/WebApi/Dockerfile +``` + +A local MongoDB instance is required to run the Web API or the integration tests: + +```bash +docker run --name mongodb -d -p 27017:27017 mongo:8.2 +``` + +Integration tests also need Firebase test-user credentials and MongoDB connection settings. +Provide them as environment variables, or in a `Local.runsettings` file at the repository root (see `CONTRIBUTING.md` for the template). +Never commit this file. + +## Architecture + +The solution follows a layered / clean-architecture style split across small, single-purpose projects (`src/*`), with `Domain` at the center and no project referencing "outward": + +Project | Depends on | Responsibility +-------------------------|--------------------------------------------------------|--------------- +`Common.System` | — | Cross-cutting primitives shared by every layer: `IHasId`, `IHasIdAndOwnerId`, `PagedRequest`, `PagedResult`. +`Domain` | `Common.System` | Business models (`*Model` in `Models/`) and repository interfaces (`I*Repository` in `Repositories/`). No persistence or web concerns. +`Infrastructure.MongoDb` | `Domain` | MongoDB implementation: BSON `Entities/` and `Repositories/` implementing the `Domain` interfaces. +`WebApi.Contracts` | `Common.System` | Public REST DTOs (`Dto/`), shared between `WebApi` and `BlazorApp` so the Blazor client can deserialize API responses without duplicating classes. +`WebApi` | `Infrastructure.MongoDb`, `Domain`, `WebApi.Contracts` | ASP.NET Web API: controllers, DTO mappers, DI wiring, JWT authentication, OpenAPI/Scalar docs. +`BlazorApp` | `Common.System`, `WebApi.Contracts` | Blazor Server UI. Talks to `WebApi` over HTTP using the shared DTOs; it never references `Domain` or `Infrastructure.MongoDb` directly. + +### Data model conventions + +Every entity that belongs to a user implements `IHasIdAndOwnerId` (`Id` + `OwnerId`) at all three layers (`Domain` model, MongoDB entity, contract DTO), each with its own class. +Mapping between them is generated at compile time by [Riok.Mapperly](https://github.com/riok/mapperly) (a source generator, no runtime reflection) rather than AutoMapper - see `docs/automapper-removal-plan.md` for the removal history: + +- `Infrastructure.MongoDb/Mappers/`: one `[Mapper]` partial class per entity pair (e.g. `BookStorageMapper`), implementing `IStorageMapper` (`Infrastructure.MongoDb/Mappers/IStorageMapper.cs`) - + MongoDB entity <-> Domain model. + Injected into `MongoDbRepositoryBase` and the six owner-less reference repositories. +- `WebApi/Mappers/`: one `[Mapper]` partial class per CRUD pair (e.g. `BookDtoMapper`), implementing `IDtoMapper` (`WebApi/Mappers/IDtoMapper.cs`) - DTO <-> Domain model. + `OwnerId` is always set via `[MapValue(nameof(Model.OwnerId), "")]` on the DTO -> model direction (not `[MapperIgnoreTarget]` - + `OwnerId` is `required` on the model, so a plain ignore would fail to compile the generated object initializer). + The placeholder value is immediately overwritten server-side from the authenticated user's claims in `DataCrudControllerBase`, never trusted from client input. + Read-only feature controllers (`WatchNextController`, `WishlistController`, `CarController`/`HouseController`'s metrics, `ReferenceDataController`) use a small one-directional Model-to-DTO mapper class instead. + It's injected as its concrete type rather than through `IDtoMapper`, since it has no DTO -> Model direction to speak of. + +Unmapped members are build errors, not a runtime assertion: `RMG012`/`RMG020` (Mapperly's unmapped-target/unmapped-source diagnostics) are escalated to `error` severity in `.editorconfig`. +So a new property added to a model/entity/DTO without a matching mapping fails the build immediately. +This replaced the old `AutoMapperConfigurationTest`'s runtime `AssertConfigurationIsValid()` check, now enforced at compile time instead. +A source-only member each direction doesn't need (like `OwnerId` on the Model -> Dto direction, or a filter-only DTO/model field with no entity counterpart) needs an explicit `[MapperIgnoreSource]`/`[MapperIgnoreTarget]` attribute. +This satisfies the diagnostic. +Don't leave a member unmapped and unignored - the build won't compile. + +An enum used in a `Domain` model (e.g. `TvShowModel.State: TvShowStatus?`) needs its own **separate** definition in `WebApi.Contracts` (`TvShowDto.State` uses `Keeptrack.WebApi.Contracts.Dto.TvShowStatus`, not the Domain one). +`WebApi.Contracts` doesn't depend on `Domain` (see the project table above), so the DTO can't reference the Domain enum directly. +Keep the member names identical between the two; the DTO mapper sets `EnumMappingStrategy = EnumMappingStrategy.ByName` on its `[Mapper]` attribute so Mapperly maps enum-to-enum by name. +This generates a build diagnostic (rather than a silent runtime mismatch) if a member name ever drifts between the two definitions. +The Mongo entity, on the other hand, *can* reuse the Domain enum directly (`Infrastructure.MongoDb` depends on `Domain`). +Only the Contracts layer needs the duplicate, and storage mappers don't need the `ByName` strategy since there's only one enum type involved. + +**Gotcha:** a DTO member that's nullable while its model counterpart is `required` non-nullable (the `InventoryPageBase` `new()`-constraint gotcha two sections down - e.g. `BookDto.Title` vs. `BookModel.Title`). +This needs an explicit fallback on the DTO -> model direction. +Mapperly refuses to compile a silent substitution the way AutoMapper used to. +`CommonDtoMappings.ToRequiredString` (`WebApi/Mappers/`, a `[UserMapping]` static method attached to the affected DTO mappers via `[UseStaticMapper(typeof(CommonDtoMappings))]`) reproduces AutoMapper's old `?? string.Empty` behavior. +It applies for every such string member across every mapper that needs it. +This is for exact behavior parity. +Tightening this to a real 400 validation error for a missing title is a deliberate follow-up, not part of the mechanical migration. +`CarDto.EnergyType` (nullable) -> `CarModel.EnergyType` (required, non-nullable, and a *different* enum type than the Dto's) needs the same treatment but can't reuse `ToRequiredString` (wrong types). +See `CarDtoMapper`'s own hand-written `[UserMapping]` for the pattern: a partial method gets the generated, drift-checked `ByName` enum conversion, wrapped by one small hand-written method that only handles the null -> `default` fallback. + +### Adding a new trackable item type + +Follow the existing types (`Book`, `Movie`, `Album`, `TvShow`, `VideoGame`, `Car`/`CarHistory`) as the template. +A new type touches every layer: + +1. `Domain/Models/Model.cs` and `Domain/Repositories/IRepository.cs` (extends `IDataRepository`). +2. `Infrastructure.MongoDb/Entities/.cs` (BSON attributes, `snake_case` element names via `[BsonElement]`). + `Infrastructure.MongoDb/Repositories/Repository.cs` (extends `MongoDbRepositoryBase`, overrides `CollectionName` and, if searchable, `GetFilter`). +3. `WebApi.Contracts/Dto/Dto.cs` (XML doc comments drive the generated OpenAPI spec). +4. `WebApi/Controllers/Controller.cs`: a one-line class extending `DataCrudControllerBase` — CRUD logic is never duplicated per controller. +5. Register the repository in `WebApi/DependencyInjection/InfrastructureServiceCollectionExtensions.cs`. +6. Add a storage mapper (`Infrastructure.MongoDb/Mappers/StorageMapper.cs`, implementing `IStorageMapper`, registered in `InfrastructureServiceCollectionExtensions.cs`). + Also add a DTO mapper (`WebApi/Mappers/DtoMapper.cs`, implementing `IDtoMapper`, registered in `Program.cs`). +7. `BlazorApp/Components/Inventory/Clients/ApiClient.cs` extending `InventoryApiClientBase`, plus a `Pages/.razor` / `.razor.cs` pair extending `InventoryPageBase`. + +**Gotcha:** `Dto` can never have a `required` member if `Pages/.razor.cs` extends `InventoryPageBase` — that base class is constrained `where TDto : IHasId, new()`. +A type with *any* `required` member can't satisfy a bare `new()` constraint in C# (`CS9040`). +This is why `BookDto.Title`, `CarDto.Name`, etc. are all nullable even though their Domain model counterpart (`BookModel.Title`, `CarModel.Name`) is `required`. +It's a hard language limitation forced by the generic list-page base class, not a design inconsistency to "fix" by making the Dto match the model. +A Dto with no `InventoryPageBase` usage (e.g. `CarHistoryDto`, owned-by-a-parent types managed from their parent's detail page rather than their own list page) has no such constraint. +It can mirror the Domain model's `required` members in full. + +### Child entities (1-to-many owned by another entity) + +`CarHistory` (owned by `Car`) and `Episode` (owned by `TvShow`) are separate top-level collections referencing their parent by id (`car_id`, `tv_show_id`), not embedded arrays. +This is deliberate MongoDB schema design, not an accident: these child collections can grow unbounded per parent over years of use. +Features that need to query the child across *all* of a user's parents at once (e.g. Watch Next, below) need a plain indexed query rather than an `$unwind` aggregation. +Embedding is the right call for genuinely small, always-together, never-queried-alone data; neither condition holds here. +`EpisodeRepository.GetFilter`/`CarHistoryRepository.GetFilter` both filter their parent-id field (`TvShowId`/`CarId`) with `Eq`, not `Text`. +An exact-id filter used `Text` in `CarHistory`'s first draft, throwing whenever a free-text `search` was also supplied (MongoDB allows only one `$text` expression per query). +This was fixed and is covered by `docs/code-quality-findings.md`. +New multi-word BSON fields get an explicit `[BsonElement("snake_case_name")]`; don't rely on the `CamelCaseElementNameConvention` registered in `AddMongoDbInfrastructure`. +Every existing multi-word field already overrides it explicitly, so it's effectively dead configuration. +Indexes for new collections are declared in `scripts/mongodb-create-index.js` (natural-key uniqueness, query-shape support, and partial indexes for sparse boolean flags like `is_favorite`/`want_to_watch`). + +`CarHistoryModel.EventType` (`CarHistoryType`: `Refuel`/`Maintenance`/`Other`) is a real discriminated enum, not free text. +This follows the same "never name a property/field bare `Type`" rule as any other discriminator in this codebase (see `TvShowModel.State`'s own rename history above). +A bare `Type` reads ambiguously and collides in spirit with `object.GetType()`/`System.Type`. +`Car.EnergyType` (`CarEnergyType`: `Combustion`/`Hybrid`/`Electric`) and `CarHistoryModel.EventType`/`CarId`/`HistoryDate` are all `required` on both the Domain model and the Mongo entity. +`CarDto` itself can't mirror `Name`/`EnergyType` as `required`, because `Cars.razor` extends `InventoryPageBase`, which needs a bare `new()` constraint — a type with *any* `required` member can't satisfy `new()` in C#. +So the Dto layer stays nullable there by hard language necessity, not by choice (`CarHistoryDto` has no such constraint, so it mirrors `required` in full). +`CarHistoryModel.DeltaMileage` is real user-entered data (typically read off the car's own trip computer at refuel time), not a derived/computed field. +It's kept specifically so `Domain/Services/CarMetricsService.cs` can cross-check it against consecutive `Mileage` readings and flag a likely typo or a skipped entry. +This is the automated version of a manual spreadsheet cross-check, and the reason this field was kept rather than dropped as "unused" during review. +`CarMetricsService` (consumption — only ever computed across a full refill/recharge, never a partial one — cost-of-ownership history, mileage warnings, next-maintenance-due) follows `WatchNextService`'s shape. +It's a plain, `AddSingleton`-registered, unit-tested pure computation class with no persistence of its own. +It's exposed via `CarController.GetMetrics` the same way `VideoGameController.RefreshReference` adds an extra per-item action to an entity's own CRUD controller instead of a separate cross-entity controller. + +`House`/`HouseHistory` (owned-by-a-parent, same shape as `Car`/`CarHistory` above) is a deliberately smaller sibling, not a second copy of Car's design. +The priority (confirmed with the owner) is an exhaustive, browsable action log for insurance purposes plus a yearly cost review, not fuel/mileage tracking or reminders. +The owner tracks recurring bills/maintenance schedules elsewhere and explicitly asked for reminders to stay out of scope ("keep it simple, not important, I manage it elsewhere"). +Concretely: `HouseHistoryModel` has no `Mileage`/`DeltaMileage`/fuel fields, no location sub-fields (a house doesn't move, unlike a car's refuel stops). +`Domain/Services/HouseMetricsService.cs` has no `ComputeNextMaintenanceDue`-style due-date engine - it only computes `ComputeAnnualCostHistory` (cost per year, broken down by `HouseEventType`). +`HouseHistoryModel.HistoryDate` stays `DateOnly` (not `DateTime` like `CarHistoryModel.HistoryDate`) - House has no Car-style same-day-multiple-entries ordering need (a road trip's several refuel stops). +So it reuses `CommonStorageMappings` (`Infrastructure.MongoDb/Mappers/`), the shared `DateOnly<->DateTime` UTC-stamping conversion every other date field in the app attaches via `[UseStaticMapper(typeof(CommonStorageMappings))]`, for free. +This avoids Car's bespoke hand-written `DateTime.SpecifyKind`/`ModalTimeText` "HH:mm" proxy machinery. +`HouseHistoryModel.Provider` is a single field (contractor/technician/utility company/store name) covering every category, unlike Car's Refuel-only `StationBrandName` vs. Maintenance-only `Garage` split. +House has no event type where "who was involved" doesn't apply, so one field suffices. + +`HouseDetail.razor`'s yearly cost chart is a single-series bar chart (total cost per year) plus a plain HTML breakdown table underneath (rows = years, columns = the 6 categories + total), not a 6-color stacked bar chart. +A stacked chart with that many categories would be visually noisy and add real code, and the table already carries the actual per-category precision an insurance review needs at a glance. +The chart's axis-drawing code (`ChartGeometry`, `RenderAxes`, `EvenlySpacedIndices`) was extracted from `CarDetail.razor` into `src/BlazorApp/Components/Shared/SvgChartHelpers.cs` specifically so House's chart wouldn't duplicate it. +The CLAUDE.md "no duplicated algorithms" quality bar applies to Razor-hosted chart code the same as any other logic. +Only the axis/geometry math moved; each page's own series-drawing loop (Car's stacked 2-series cost bars, Car's single-series consumption line, House's single-series year bars) stays local. +That part differs enough per chart that forcing one shared renderer would have been the over-generalization the quality bar warns against. +The shared CSS these charts need (`.kt-callout*`, `.kt-chart-axis-text`/`-title`, `.kt-chart-svg`, `.kt-sheet-table`, `.kt-chart-legend`/`.kt-legend-swatch`) moved from `CarDetail.razor.css` into `app.css`. +It's now plain global rules, for the same reason. +As a side benefit, they no longer need `::deep` to reach into child `*Row` components' markup, since global (non-isolated) CSS was never scoped to begin with. + +### Reference data (shared, owner-less) + +`tvshow_reference` and `movie_reference` (`MovieReferenceModel.cs`, `MovieReference.cs`) hold metadata (synopsis, episode titles) fetched from TMDB. +They are the one deliberate exception to "every collection has `owner_id`": this data is public facts about a real show/movie, not user content. +So storing it once and pointing every tenant's `TvShowModel.ReferenceId`/`MovieModel.ReferenceId` at it avoids duplicating the same show across every user who tracks it. +Matching key is normalized title + year (`TitleNormalizer.Normalize`, `Common.System/TitleNormalizer.cs` — shared with `TvTimeImportService` so the two never drift on what counts as "the same title"). +The rest of this section walks through the mechanics using TV shows/movies/TMDB as the concrete example. +`book_reference`/`videogame_reference`/`album_reference` (Open Library/RAWG/Discogs) follow the identical shape - see "Reference data now covers five domains" further down for what's shared versus what's provider-specific. + +These repositories (`ITvShowReferenceRepository`/`IMovieReferenceRepository`) do **not** extend `IDataRepository` or `MongoDbRepositoryBase`. +Both are hard-constrained to `IHasIdAndOwnerId` and owner-scoped paged CRUD, which doesn't fit a shared lookup table with a different method set (`FindByTitleYearAsync`, `UpsertAsync`). +Don't force a new owner-less collection through that base; write a small purpose-built repository instead, like these two. + +`TvShowReferenceModel.Episodes` is **embedded**, the opposite choice from the per-tenant `Episode` collection. +This isn't inconsistent - the access pattern is opposite too: a reference show's episode list is bounded to its real runtime, always fetched as a whole (rendering a season picker needs all of it at once), and never queried across shows. +That's exactly the case CLAUDE.md already says favors embedding. +The per-tenant `Episode` collection fails all three conditions (unbounded per-user growth, queried across shows for Watch Next, upserted independently), which is why that one is referenced instead. + +`WebApi/ReferenceData/ReferenceEnrichmentService.cs` is the single place that resolves a title+year to a TMDB id. +It propagates the result to every tenant's matching document via `ITvShowRepository.SetReferenceLinkAsync`/`IMovieRepository`'s equivalent. +The automatic best-effort match is fired from `TvShowController`/`MovieController`'s `OnCreatedAsync` hook on `DataCrudControllerBase`, and from `TvTimeImportService` after each newly-imported show/movie. +Both it and the admin's manual pick (`ReferenceDataAdminController`) call through this one method. +This avoids duplicating the "upsert reference doc, then propagate" logic. +The automatic path only ever acts on a *single, confident* TMDB search result - zero or multiple candidates leaves the item unresolved for the admin queue rather than guessing. +This is the same "don't guess when you don't have the info" principle as Watch Next, below. +`SetReferenceLinkAsync` sets `ReferenceId`, `Title` (the TMDB canonical name) and, when the reference has one, `Year` (the TMDB canonical year) in the same update. +Linking is meant to correct whatever title/year the tenant originally typed/imported, not just attach an id, and pre-populating `Year` from a trustworthy source beats leaving it at whatever the tenant originally guessed. +`Year` stays freely editable afterward on `MovieDetail.razor`/`TvShowDetail.razor` (same as `Title` - see the paragraph on `TryLinkExistingTvShowReferenceAsync` below for why), so this is a starting value, not a lock. + +`TvShowReferenceModel`/`MovieReferenceModel.MatchedAliases` is a `List` of every (title, year) combination ever confirmed (via a TMDB resolution, automatic or admin-picked) to mean this exact show/movie. +This is not just the document's own canonical `TitleNormalized`/`Year`. +`ResolveTvShowAsync`/`ResolveMovieAsync` populate it with **both** the (TMDB canonical title, TMDB canonical year) and whatever (title, year) the tenant actually searched with. +This uses `MergeMatchedAliases`, merged with any aliases a previous resolution already contributed - never overwritten. +`TvShowReferenceRepository`/`MovieReferenceRepository.UpsertAsync` additionally guarantees the document's own (`TitleNormalized`, `Year`) is always in the list even if a caller forgot. +Year travels with its specific title variant rather than living as a single top-level scalar, because a title-only alias list isn't enough on its own. +Two tenants (or a tenant and TMDB's own canonical data) can legitimately record different years for the same real show. +A French-language tenant's "Le Fil" (2002) and an English tenant's "The Wire" (2002) are one case this handles. +So is a tenant who recorded 2003 by mistake and TMDB's canonical year of 2002 - both should still resolve to the same document once *either* variant has been confirmed. +`FindByTitleYearAsync`/`FindByTitleAsync` (on both reference repositories) query `MatchedAliases` with `Builders.Filter.ElemMatch` instead of a plain `Eq` against `TitleNormalized`/`Year`. +Both the title and year condition must hold on the *same* embedded array element - a plain `AnyEq`-per-field approach would let a title match on one alias and a year match on a completely different one. +`scripts/mongodb-create-index.js`'s `tvshow_reference_title_year`/`movie_reference_title_year` indexes were changed to a compound multikey index over `matched_aliases.title`/`matched_aliases.year` accordingly. +A single compound index over two sub-fields of the *same* array is fine, unlike trying to combine two *different* array fields in one compound index, which MongoDB disallows. + +`ReferenceEnrichmentService.TryLinkExistingTvShowReferenceAsync`/`TryLinkExistingMovieReferenceAsync` is a second, cheaper resolution path that never calls TMDB. +It only checks whether a matching reference document *already exists* (title+year, falling back to title-only, against `MatchedAliases`). +This backs `POST /api/tv-shows/{id}/refresh-reference`/`/api/movies/{id}/refresh-reference`. +That's a "check for reference match" control shown **unconditionally** on both detail pages (not just when unresolved) to **any** authenticated user, not just admins. +It can never do anything beyond reusing a fact some other tenant (or an admin) already established. +It's unconditional deliberately: `Title` and `Year` are both freely editable at any time. +A tenant who thinks their current link is wrong (two different real movies sharing an identical title is common - year and cast/poster are often the only way to tell them apart) needs to be able to edit either. +They need to be able to re-check *even though something is already linked*. +This is to replace a bad match. +The method does **not** short-circuit on an existing `ReferenceId` for exactly this reason. +When a match is found it updates only this tenant's own document directly (`ITvShowRepository.UpdateAsync`/`IMovieRepository`'s equivalent). +It sets `Year` to the reference's own canonical year alongside `Title` and `ReferenceId` (same rationale as `SetReferenceLinkAsync` above). +This is rather than the broad cross-tenant `SetReferenceLinkAsync` (which refuses to touch already-linked documents by design, so it wouldn't fix a wrong link on this tenant's own item). +But it still also calls `SetReferenceLinkAsync` with the pre-edit title/year afterward, so any other still-unresolved tenant sharing that text benefits too. +When **no** match is found for the current title/year and the document *was* linked, the link is cleared (`ReferenceId` set to `""`) rather than left pointing at something the tenant just told us, by editing the title, is wrong. +Clearing it is also exactly what puts the item back into the admin's unresolved queue (`FindDistinctUnresolvedTitleYearsAsync`) for a manual TMDB search. +This is deliberately explicit/user-triggered rather than automatic on every page view (no `GetById` side effect), so the behavior stays visible and predictable. +It's also why `Title`/`Year` are unconditionally editable on both `MovieDetail.razor`/`TvShowDetail.razor` regardless of link status - +free editing is what makes "fix a typo or a wrong year (or pick a different match), then hit refresh" work. +`Year` matters here specifically because TMDB search results embedding a year into the title text (e.g. "Dune 2021") is unreliable - the tip that used to suggest doing that was wrong and has been removed. +The actual matching (both this local lookup and the live TMDB search behind `InlineReferenceLinker`) always takes year as its own separate field/parameter, never parsed out of title text. +The list edit modal (`Movies.razor`/`TvShows.razor`) still locks `Title` for non-admins once linked - that's a separate, intentionally stricter surface for bulk-editing, not an inconsistency to reconcile. + +**Gotcha:** the title-only fallback in `TryLinkExistingTvShowReferenceAsync`/`TryLinkExistingMovieReferenceAsync` must run unconditionally, *including* when the tenant's `Year` is `null`. +An earlier version only attempted it when `Year is not null`, which is backwards. +A tenant with no year recorded at all is exactly the case that needs the year-agnostic fallback most, since `FindByTitleYearAsync(title, null)` can only ever match a reference whose own `Year` is also `null`. +Skipping the fallback there meant any linked item with an unset year would unlink itself the instant "check for reference match" was clicked, since neither query could succeed. +Confirmed by a real user hitting this on a genuinely valid, already-linked title. + +`ResolveTvShowAsync`/`ResolveMovieAsync`'s "does a reference document already exist for this" check now looks up by TMDB id first. +This uses `ITvShowReferenceRepository`/`IMovieReferenceRepository.FindByExternalIdAsync`, same shape as `IPersonReferenceRepository`'s cast-dedup lookup. +It falls back to title+year/title-only only when no document has that id yet. +Title-text matching alone isn't reliable enough to prevent duplicates. +Two tenants (or an admin resolving the same unresolved queue twice under different title text - a translation, or a typo the admin fixed) can easily resolve the *same* TMDB entry through completely different search strings. +If the existence check only compares title text, the second resolution creates a second reference document for what's genuinely the same movie/show instead of updating the first. +The TMDB id is the one signal that's invariant under any title text difference, so it's checked first and is authoritative. + +`scripts/mongodb-create-index.js`'s `tvshow_reference_tmdb_id`/`movie_reference_tmdb_id`/`person_reference_tmdb_id` indexes are `unique: true` with a `partialFilterExpression: { "external_ids.tmdb": { $exists: true } }`. +The application-level id-first dedup check above is what's *supposed* to prevent two documents sharing a TMDB id, but a database constraint is what actually guarantees it can never happen even if a future code path forgets to check. +The partial filter (rather than a plain unique index, or the older `sparse: true` option) is required because it's only `external_ids.tmdb` that must be unique *when present*. +A document from before this field existed (or any owner-less collection document that's never linked to that provider) has no such key at all. +A plain unique index would treat every one of those "missing the field" documents as colliding on the same null key. + +Reference data is meant to be portable across environments, not re-earned from TMDB one search at a time per deployment. +`ReferenceDataAdminController`'s `GET/POST /api/reference-data/export`/`import` round-trip the entire `tvshow_reference`/`movie_reference`/`person_reference` collections as a zip of JSON arrays. +Idempotency here is free, not new logic - every repository's existing `UpsertAsync` already replaces-by-id when the model carries one, so re-importing the same export twice is a no-op the second time by construction. +`FindAllAsync()` on each of the three reference repositories exists solely to back this export; it's an unpaged full-collection read, acceptable because this data is small and shared (not per-tenant). + +`TvTimeImportService`'s per-show/per-movie reference match (`TryEnrichShowAsync`/`TryEnrichMovieAsync`) fires on its own DI scope via `IServiceScopeFactory`, same shape as `TvShowController`/`MovieController.OnCreatedAsync`. +It's not awaited inline. +A bulk import creating dozens of new shows/movies must not block on a sequential chain of TMDB HTTP calls (search + details + credits, per item) before the import job itself can report "Completed". +If TV Time import feels slow again, check this path before assuming it's a missing Mongo index (already fully audited once - see `docs/code-quality-findings.md`). + +**Gotcha (historical - fixed by the AutoMapper -> Mapperly migration, but old data still needs this):** "does this tenant document have no reference link yet" cannot be a plain `Eq(x => x.ReferenceId, null)` filter. +Under AutoMapper, `AllowNullDestinationValues = false` made mapping a model whose string property is null store an **empty string**, never an actual BSON null; documents written that way still exist. +`TvShowRepository`/`MovieRepository`'s `UnresolvedFilter()` matches null *or* empty string for exactly this reason. +Copy that helper's shape (not a bare null check) for any new "is this string field unset" query, even now that new writes store a real null (Mapperly preserves nulls by default). +This one is easy to get wrong silently: it doesn't throw, it just quietly matches zero documents. +A real-MongoDB integration test (`TvShowReferenceLinkingTest`) is what caught it originally, not the unit tests (which mock the repository and never see the actual serialization behavior). + +**Second instance of the same gotcha (also historical - see `docs/automapper-removal-plan.md`):** AutoMapper's `AllowNullDestinationValues = false` used to also change `mapper.Map(source)`. +This happened even when `source` itself (not just one of its properties) was `null`. +Instead of returning `null`, it returned a new, all-default instance of `TDestination`. +Mapperly (the current mapper) takes the opposite, honest approach: it *throws* on a null source rather than fabricating a default instance, which is exactly why the `entity is null` guard below still matters, +just for a different reason now. +Every `Find*Async` method on `TvShowReferenceRepository`/`MovieReferenceRepository`/`PersonReferenceRepository` does `var entity = await Collection.Find(...).FirstOrDefaultAsync();`. +`entity` can legitimately be null (nothing matched). +Mapping it directly would either fabricate a fake found-but-empty model (old AutoMapper behavior) or throw (current Mapperly behavior). +Neither of those is the `null` that `ReferenceDataController`'s `model is null` 404 check (and anything else checking `is null` on a Find result) needs. +Each of those methods checks `entity is null` before calling the mapper, returning `null` directly instead. +**This isn't limited to the owner-less reference repositories**: `MongoDbRepositoryBase.FindOneAsync` (the shared base every ordinary owner-scoped repository extends) has the exact same shape and needs the exact same guard. +Every entity's `GetById` 404 check depends on it. +Copy this guard for any new Find method that can legitimately return "nothing matched," anywhere in the codebase, not just the owner-less collections. +A mocked-repository unit test cannot catch a regression here, only a real MongoDB integration test can (a mock never exercises the real mapper/serialization behavior). + +The reference layer also now has `Genres`, `Cast` (embedded `CastMemberModel` list, pointing at a third owner-less collection, `person_reference`), and `PosterUrl`/`CastMemberDto.ProfileImageUrl`. +Actors are deduplicated across every show/movie that credits them, keyed by TMDB person id via `IPersonReferenceRepository.FindByExternalIdAsync`, not by name. +Images are **hotlinked directly from TMDB's CDN** (`https://image.tmdb.org/t/p/{size}{path}`, built once in `TmdbClient` and stored as a plain URL) rather than downloaded and re-hosted. +This is confirmed to be TMDB's own sanctioned, standard usage pattern (a separate, unauthenticated static-asset host, not the rate-limited API), so there's no local storage/volume/static-file-serving subsystem to operate. +`CastMemberDto` is fully hydrated server-side (`ReferenceDataController` joins the embedded cast list against `person_reference`) specifically because that join needs repository access a generated mapper doesn't have. +`TvShowReferenceDtoMapper`/`MovieReferenceDtoMapper` (`WebApi/Mappers/`) ignore `Cast` via `[MapperIgnoreTarget]` for exactly this reason; don't try to make it a plain mapped member. + +`TvShowDetail.razor`'s episode list is a full watch-through checklist (every reference episode, checkbox = watched) once the show has a `ReferenceId`, +falling back to the original recorded-episodes-only view with a manual add form when it doesn't. +There's no way to know the full episode count without reference data, so that fallback is a deliberate scope boundary, not a bug. +Checking a box creates an `Episode` with `WatchedAt = today`; unchecking deletes it - there's no way to set an arbitrary watched date from this view once a show is resolved. + +Admin-only endpoints (`ReferenceDataAdminController`) use policy-based authorization (`[Authorize(Policy = "AdminOnly")]`, registered in both `WebApi/Program.cs` and `BlazorApp/Program.cs`). +The policy is `RequireClaim("role", "admin")`, backed by a Firebase custom claim. +This is not ASP.NET's built-in `Roles=` support. +Firebase's claim arrives as a plain `"role"` claim rather than the `ClaimTypes.Role` URI that `[Authorize(Roles=...)]`/`` expect by default. +`BlazorApp/Components/Account/Controllers/AuthenticationController.cs` copies the `role` claim from the verified Firebase token into the cookie principal at sign-in. +WebApi validates the bearer token's claims directly and needs no equivalent step. +There's no in-app way to grant the first admin; it's a one-off `setCustomUserClaims` call via the Firebase Admin SDK (see `CONTRIBUTING.md`). + +**Gotcha:** `WebApi/Program.cs`'s `AddJwtBearer` sets `options.MapInboundClaims = false` deliberately. +Without it, the token handler silently renames certain short JWT claim names to legacy `ClaimTypes.*` URIs before `HttpContext.User` ever sees them. +`"role"` is one of the remapped names (to `ClaimTypes.Role`), so `RequireClaim("role", "admin")` would never match even though the raw token genuinely has a `role` claim. +This is exactly why the Blazor side (whose cookie principal is built by hand in `AuthenticationController`, using the literal claim name) could show the admin nav link while the same request's bearer-token call to WebApi still 403'd. +The two sides were checking different claim types for what was, on the wire, the same claim. +`user_id` was never affected by this because it isn't one of the handful of short names the legacy map remaps; don't assume a new custom claim is equally safe without checking, or just leave `MapInboundClaims = false` alone. + +### Web API request flow + +`DataCrudControllerBase` (`WebApi/Controllers/DataCrudControllerBase.cs`) implements the full CRUD surface (`GET`, `GET/{id}`, `POST`, `PUT/{id}`, `DELETE/{id}`) once, generically. +It calls the shared `ControllerBaseExtensions.GetUserId()` extension (`WebApi/Controllers/ControllerBaseExtensions.cs`) to read the caller's `user_id` claim, scope every query, and stamp `OwnerId` on writes. +Per-type controllers only need routing and generic type arguments — any new controller (CRUD or not) should call the same extension rather than re-reading the claim. +Unhandled exceptions are converted to JSON error responses by `ApiExceptionFilterAttribute` (`ArgumentException`/`ArgumentNullException` -> 400, everything else -> 500). +It also logs every caught exception via `ILogger` before converting it - a 500 leaves a server-side trail to diagnose, not just an opaque error in the browser. + +**Resilience against failing external providers.** TMDB/RAWG/Open Library/Discogs (`WebApi/ReferenceData/`'s `TmdbClient`/`RawgClient`/`OpenLibraryClient`/`DiscogsClient`) are third-party APIs outside Keeptrack's control. +Slow, rate-limited, or briefly down is a "when," not an "if." +Each of their four `AddHttpClient<...>()` registrations in `Program.cs` chains `.AddStandardResilienceHandler()` (`Microsoft.Extensions.Http.Resilience`). +This gives every call automatic retry, a per-attempt timeout, a total-request timeout, and a circuit breaker. +A transient failure is retried and recovered without the caller ever seeing it. +A provider that's genuinely down fails with a normal exception (mapped to a clean JSON 500 by `ApiExceptionFilterAttribute` above) instead of hanging a request thread indefinitely or degrading the whole app under concurrent load. +Give any future outbound HTTP client to a third-party service the same treatment. +`.AddStandardResilienceHandler()` is a one-line addition to the `AddHttpClient<...>()` chain, not something to hand-roll per client. +Covered by `ExternalProviderResilienceTest` (`test/WebApi.UnitTests/ReferenceData/`), which builds the exact same wiring against a stub `HttpMessageHandler` (no real network). +It asserts both halves: a transient failure is retried and recovered, and a provider that stays down fails cleanly rather than hanging. +This was tested once against `RawgClient` as a representative example, since the wiring is identical for all four and duplicating the same test four times would add nothing. + +`builder.Services.Configure(opts => opts.BackgroundServiceExceptionBehavior = BackgroundServiceExceptionBehavior.Ignore)` in `Program.cs` is a separate, systemic safety net. +By default, an unhandled exception escaping any hosted `BackgroundService.ExecuteAsync` (like `ReferenceSyncBackgroundService`) stops the *entire host*, taking every other endpoint down with it, not just the background job. +`ReferenceSyncBackgroundService`/`ReferenceSyncService` already catch and log everything they can anticipate (see "Keeping reference data fresh" below). +This setting is the backstop for whatever a future background service's own error handling misses. +`Ignore` logs the failure and keeps the rest of the app serving requests instead of crashing the process. +Apply the same reasoning before adding a new `AddHostedService<...>()`: its own `ExecuteAsync` should still catch what it can anticipate (this global setting isn't a substitute for that). +A bug in it should never be able to take down unrelated endpoints. + +Not every endpoint is per-item CRUD. +`WatchNextController`/`WishlistController` (read-only cross-entity aggregations) live in `WebApi/Controllers/` like every other controller, with a plain `ControllerBase` rather than being force-fit into `DataCrudControllerBase`. +Their computation lives in `Domain/Services/` (`WatchNextService`, `WishlistService`) since it's pure logic over Domain models with no persistence or web dependency - Domain's stated charter from the project-overview table above. +`WebApi/Import/` (the TV Time GDPR-export upsert, using `CsvHelper` for parsing) and `WebApi/ReferenceData/` still follow the older feature-folder shape (a `ControllerBase` and service class colocated under `WebApi//`). +This is now recognized as the same misplacement `WatchNext` had, but migrating them is deliberately deferred to a separate change to limit regression surface and manual-testing burden. +Don't extend the older feature-folder shape to new code; follow `WatchNextController`/`WishlistController`'s split instead (controller in `Controllers/`, pure computation in `Domain/Services/` when there is any). + +`seen_episode_source.csv` alone is a drastically incomplete picture of a user's episode history (it's only written when an episode is marked watched via TV Time's episode-detail screen) - confirmed against real export data, not assumed. +`TvTimeImportService` also reads `tracking-prod-records.csv` and `tracking-prod-records-v2.csv`, TV Time's generic event logs, which capture episodes marked watched any other way (bulk/season actions). +All three are merged and de-duplicated per (show, season, episode), earliest date wins, before upserting `Episode`s. +If a future TV Time export field looks suspiciously sparse, check the raw export files directly (`grep` the show name across every `*.csv`) before assuming the current parser set is complete - +this is exactly how the tracking files were found. + +`followed_tv_show.csv` is not a complete list of shows either - confirmed with a real show ("The Pitt") that has genuine watch history in the tracking files but no row in `followed_tv_show.csv` at all. +`ImportEpisodesAsync` creates a show on the fly from watch-event data (title, plus the show's TV Time id when the source file has one) rather than skipping shows that aren't already known. +This applies the same rating/favorite/notes enrichment a normally-followed show gets. +Don't reintroduce a "skip if not already followed" check here. + +The import is idempotent by matching on a stable, enrichment-immutable id, **not** by title. +Re-running an import used to duplicate every show/movie (and, via the new show id, all their episodes). +Shows/movies were matched by normalized `Title`, but reference enrichment rewrites `Title` to the provider's canonical name after the first import (`SetReferenceLinkAsync`). +So on the second import the export's original title no longer matched and a duplicate was created - confirmed by a real re-import. +The fix stamps every imported show/movie with `TvTimeId` (`IHasTvTimeId`, a real Domain field on `TvShowModel`/`MovieModel` carried through entity/DTO like `ReferenceId`, +and round-tripped on edits since `UpdateAsync` is a full `ReplaceOneAsync`). +This is TV Time's own show id for shows (`followed_tv_show.csv`/the tracking logs), and the per-movie tracking `uuid` for movies (every follow/watch/towatch row for one movie shares it). +Movies do have a stable id after all, contrary to the older "movies have no stable id" note, which was only ever true of the per-vote uuid in the rating files. +When the export carries no id for a title (a movie known only from the vote files; a show seen only in `seen_episode_source.csv`), `ResolveTvTimeId` synthesizes a fallback. +It's a deterministic `tvtime_title:` fallback from the **export** title. +This fallback title is one that enrichment never touches. +`BuildIdByTitle` first maps every title to its id across the id-bearing files, so the title-only files resolve to the *same* id as their id-bearing counterpart. +This is why an id-less `seen_episode_source.csv` episode still attaches to the followed show it belongs to, even after that show's stored title was enrichment-renamed. +`UpsertIndex` matches an incoming item by `TvTimeId` first. +A title fallback fires only for a pre-existing record that has **no** `TvTimeId` yet (created by an import predating this feature), which is then adopted and back-filled exactly once (`BackfillTvTimeIdAsync`) rather than duplicated. +A record already carrying a *different* id is left alone, so two genuinely different items sharing a title are never collapsed. +On a match the record is **left untouched** - not merely honoring the "if it already exists, do nothing" rule. +This is because a re-import must never clobber edits the user made *in the app* after the initial import (rating, notes, favorite, a corrected title/year). +Re-applying the export's values on every re-import would silently erase those. +So don't reintroduce an update-on-existing path here. +Only genuinely new items are created, and the result counts `*Created`/`*Skipped` (deduped by reference identity so a show touched in both the followed-shows and episodes phases counts once). +Covered by `TvTimeImportServiceIdempotencyTest` (unit, in-memory repos, re-imports after mutating the stored titles to prove no duplication) and the re-import half of `TvTimeImportResourceTest` (integration). +Note this fixes *future* imports; pre-existing duplicates from earlier buggy runs need a one-off cleanup (a `scripts/*.js` dedupe in the same run-once style as `migrate-poster-url-to-image-url.js`), not yet written. + +When a `SeenEpisodeRecord`-shaped property is only present in *some* of the three source files' headers (like `TvShowId`, absent from `seen_episode_source.csv`), it needs a special attribute. +It needs `[Optional]` from `CsvHelper.Configuration.Attributes` in addition to not being C#'s `required`. +CsvHelper's header validation throws on a missing column for any `[Name]`-decorated property regardless of C# nullability unless that attribute is present. +This only surfaces at runtime against a real file missing the column, not at compile time - a unit test with a realistic fixture (matching `seen_episode_source.csv`'s actual header) is what caught it. + +Movies were originally believed to have no watch date anywhere in the export ("import movies without a watch date" was the confirmed scope decision). +That turned out to be wrong the same way the episode-history gaps were: `tracking-prod-records.csv`'s generic event log carries `entity_type == "movie"` rows too. +These have `type` "watch" (an individually dated watch event - `Movie.FirstSeenAt`), "towatch" (`Movie.WantToWatch`, only applied when there's no watched event, so a since-watched movie doesn't stay flagged as still-to-watch). +They also have "follow" (existence only, same role as `followed_tv_show.csv` for shows). +`MovieTrackingEventsCsvParser` reads these; `tracking-prod-records-v2.csv` (the newer-generation log) carries no movie data at all - confirmed against a real export, not assumed. +If a future TV Time field looks suspiciously absent, re-check the real export before documenting it as a limitation - this is the second time an "unrecoverable" gap turned out to just be unparsed. + +The import runs as a background job rather than a single request/response, so the UI can show real progress. +`POST /api/import/tv-time` buffers the upload, kicks off the work via `IServiceScopeFactory.CreateScope()` (the request's own DI scope is gone by the time the background work runs), and returns a job id immediately. +`GET /api/import/tv-time/{jobId}` reports the current `ImportStage`. +`ImportJobStore` is an in-memory singleton keyed by job id and checks the caller's owner id on every read, so a job is only ever visible to the user who started it. +Follow this shape (buffer input, background `Task` via a fresh scope, pollable status keyed by owner) for any other future long-running action. +Don't block a request on multi-second server work just because the current single-endpoint pattern is simpler to write. + +Watch Next originally reported only the *last watched* episode per in-progress show, deliberately never guessing a "next" one, because Keeptrack had no episode-guide data to confirm a further episode actually existed. +That constraint is gone now that `TvShowReferenceModel.Episodes` carries a real TMDB episode list. +`WatchNextService.ComputeInProgressShows` now takes a third parameter, `referencesByShowId`. +It only reports a show if (a) its `Status` is `TvShowStatus.Current` (not merely "not Finished/Stopped" as before) and (b) its linked reference's episode list has an entry after the last one watched. +That entry is compared by `(SeasonNumber, EpisodeNumber)`, not by title or air-date order, and its `AirDate` must have already passed (or be unset). +A show with no `ReferenceId`, or no matching reference document, is excluded rather than guessed at - the same "don't guess when you don't have the info" principle as before, just enforced by data instead of by omission. +`WatchNextController` only fetches the (small, bounded) reference document for shows that are `Current` and linked, since those are the only ones that can possibly appear in the result. +The DTO now also reports the confirmed next episode's season/episode/title (`InProgressShowDto.Next*`). +This was withheld before purely for lack of data, not by design preference, so surfacing it once the data is trustworthy is not a reintroduction of the old "+1" heuristic that shipped a confirmed-wrong result. +That heuristic guessed without confirming an episode existed; this checks a real episode-guide. + +`WatchNextService.FilterMoviesToWatch` excludes a movie from the "movies to watch" list once it's marked seen (`FirstSeenAt` set), even though it's still flagged `WantToWatch`. +Toggling that flag on a movie's own detail page doesn't clear it on watch (unlike the TV Time import's "towatch" event handling, which never flags an already-watched movie in the first place). +So the exclusion has to happen at read time here instead of relying on the flag never going stale. + +`TvShowDetail.razor`'s episode checklist filters `_reference.Episodes` to `AirDate is null || AirDate <= today` before grouping into seasons. +This is the same air-date filter `WatchNextService` already applies for its "next episode" calc. +An episode TMDB lists with a future air date (a confirmed-but-unaired next season, e.g. a renewal announced months ahead) hasn't happened yet from the viewer's perspective - it shouldn't appear as a checkbox to mark watched. +An entirely future season simply doesn't appear in the season picker at all once every one of its episodes is filtered out. + +### Keeping reference data fresh: periodic + on-demand TMDB sync + +TMDB's own data (episode air dates as seasons progress, genres, posters, cast) drifts out of date after the initial resolution - a show resolved months ago needs re-checking, not just a one-time fetch. +`ReferenceSyncBackgroundService` (`WebApi/ReferenceData/`) is a plain in-process `BackgroundService` running a `PeriodicTimer` (24h interval, does an initial pass immediately on startup too). +It's deliberately **not** a Kubernetes CronJob or separate worker process. +A second scheduled workload is real operational overhead (another manifest, another thing that can silently stop running) for a job that's cheap enough to run inside the existing API process. +`ReferenceSyncService.SyncStaleReferencesAsync(staleAfter, ...)` is shared by both the periodic loop and the admin's on-demand trigger, so there's exactly one sync algorithm. +It skips any reference document whose `LastEnrichedAt` is more recent than `staleAfter` (3 days for the periodic pass; `TimeSpan.Zero` for the admin's forced "sync now", which therefore re-checks everything regardless of recency). +It never lets one failing document (a TMDB id that's since been removed, a transient network error) abort the rest of the run - each is caught and logged individually. + +`ReferenceEnrichmentService.RefreshTvShowReferenceAsync`/`RefreshMovieReferenceAsync` do the actual per-document work, and lead with a cheap pre-check before the expensive full re-fetch. +`ITmdbClient.HasTvShowChangedSinceAsync`/`HasMovieChangedSinceAsync` call TMDB's per-id `/{tv,movie}/{id}/changes?start_date=...` endpoint (one call, no season fan-out) to ask "has anything changed since this was last enriched". +This is the "if TMDB provides the last updated date... check with `last_enriched_at`" idea, implemented via TMDB's changes endpoint since TMDB doesn't expose a plain last-modified field on the show/movie details response itself. +If nothing changed, only `LastEnrichedAt` is bumped (so the next periodic pass doesn't re-check an already-current document) and the costly `GetTvShowDetailsAsync` + per-season `GetTvShowCastAsync` calls are skipped entirely. +A reference with no `LastEnrichedAt` yet always does the full fetch (nothing to compare against, and the changes-check would be meaningless anyway). + +An admin can force an immediate full re-check via `POST /api/reference-data/sync-now` (`ReferenceDataAdminController`, admin-only) instead of waiting for the next scheduled pass. +The same "Reference data" admin page that already has export/import gets a "Sync now" button reporting checked/updated counts for both collections. + +**Gotcha:** the periodic background service is registered unconditionally in `Program.cs`, but only actually does work when `Features:IsReferenceSyncEnabled` (`AppConfiguration.IsReferenceSyncEnabled`, default `true`) is true. +This is checked fresh on every tick, not once at startup. +The integration test host (`KestrelWebAppFactory`) overrides this to `false` via `ConfigureAppConfiguration` (an in-memory source added last, so it wins over `appsettings.json`). +`UseSetting` was tried first and silently didn't work for a top-level-statement minimal-hosting `Program.cs` like this one - it's loaded too early relative to the rest of configuration and gets overridden back by `appsettings.json`. +Without this, every integration test run spun up its own in-process Kestrel host (one per `IClassFixture>` test class). +Each one immediately fired real TMDB HTTP calls against whatever reference data the shared test MongoDB happened to hold - noisy and non-deterministic, not a real regression to chase. +Any integration test using a bare `WebApplicationFactory` instead of `KestrelWebAppFactory` bypasses this override (this already bit `AuxiliaryResourceTest` once). +Use `KestrelWebAppFactory` for any new integration test fixture, even one that doesn't need real Kestrel networking, specifically so it inherits this override for free instead of re-solving it. + +### Reference data now covers five domains (Book, VideoGame, Album) + +The TMDB-backed pattern above (shared `*_reference` collection, admin unresolved queue, user-triggered "check for reference match", periodic + on-demand sync, export/import) is no longer TV-show/movie-only. +`Book`/`VideoGame`/`Album` (the last renamed from `MusicAlbum` for consistency with `Episode`/`CarHistory`-style short names) each get their own external source and reference collection: + +- **Books** → Open Library (`IBookReferenceClient`/`OpenLibraryClient`, `WebApi/ReferenceData/`), no API key, `book_reference` collection with an extra `AuthorReferenceId` field. +- **Video Games** → RAWG (`IRawgClient`/`RawgClient`), a single API key appended as a query param (same convention as TMDB's `api_key`), `videogame_reference` with an extra `Platforms` list. + `VideoGameModel.Platform`/`State` are deliberately never overwritten by a reference link - they describe this tenant's own copy/progress, not the canonical release. + This is unlike `Title`/`Year`, which do get set on link the same way Movie/TvShow already do. +- **Albums** → Discogs (`IDiscogsClient`/`DiscogsClient`), a personal access token appended as a query param plus a required descriptive `User-Agent` header (Discogs API policy), `album_reference` with an extra `ArtistReferenceId` field. + Discogs search results title a release as `"Artist - Album Title"` in one string; `DiscogsClient` splits on the first `" - "` to populate `Artist`/`Title` separately. + +**Divergence from TMDB:** none of these three APIs expose a TMDB-style per-id `/changes` endpoint, so `RefreshReferenceAsync` for these three domains has no cheap "has this changed" pre-check. +It always does a full re-fetch once a document is past its staleness cutoff (the cutoff check itself, in `ReferenceSyncService`, is unchanged). +`ReferenceSyncResultDto`'s `Books/VideoGames/AlbumsUpdated` counts are therefore always equal to their matching `*Checked` counts. + +**Author/artist matter for search precision, not just display.** A common book/album title alone (e.g. "Killing Floor") returns many unrelated candidates from Open Library/Discogs. +`IBookReferenceClient.SearchBooksAsync`/`IDiscogsClient.SearchAlbumsAsync` both take an optional `author`/`artist` parameter (the provider's own query field for it) precisely to narrow this. +This is threaded through `TryAutoResolveBookAsync`/`TryAutoResolveAlbumAsync` (captured from `BookModel.Author`/`AlbumModel.Artist` in `BookController`/`AlbumController.OnCreatedAsync`) and through the admin's manual search. +The admin's manual search is `ReferenceDataAdminController.Search`'s `creator` query param, surfaced as a free-text box in `ReferenceDataAdminPage.razor`. +It's also `InlineReferenceLinker`'s `Creator` parameter, populated from the tenant's own Author/Artist field on `BookDetail.razor`/`AlbumDetail.razor`. +`ReferenceSearchResultDto.Creator` (book author or album artist; null for TV/movie/game) surfaces this on every search candidate the admin sees. +Without it, an admin looking at otherwise-identical title+year+cover candidates has no way to tell them apart. + +**Gotcha:** `OpenLibraryClient.SearchBooksAsync` deliberately never sends `year` as a server-side query filter (unlike TMDB/RAWG/Discogs, which do). +Open Library's `first_publish_year` is a book's ORIGINAL publication year, not whatever edition/printing year a tenant happened to record. +Searching "Killing Floor" (Lee Child, first published 1997) with `first_publish_year=2016` (a tenant's 2016 reprint) returns zero relevant results. +This is because Open Library filters the *work* by that exact year server-side and no edition of that work was first published in 2016. +`year` is still returned per candidate for display/tie-breaking, just never used to narrow the query itself. +This was confirmed against the real API (searching `title=Killing+Floor&first_publish_year=2016` returns an unrelated 2016 book; dropping the year filter and adding `author=Lee+Child` finds the real one). +This is a book-specific problem (one canonical release date doesn't generalize to "the book was first published once, but every reader owns some edition"). +RAWG/Discogs keep their year filters since no equivalent bug has been found for games/albums. + +Book authors and album artists are deduplicated the same way TV/movie cast is: `PersonReferenceModel`/`person_reference` was kept as-is (no rename) and reused, rather than adding two more near-duplicate lookup tables. +"Person" already meant "a named individual or group identified by an external provider id," not "actor" specifically. +So extending it to a book's author (Open Library author id) or an album's artist (Discogs artist id, which can be a group/band) needed no rename, just reuse. +`BookReferenceModel.AuthorReferenceId`/`AlbumReferenceModel.ArtistReferenceId` point at a `PersonReferenceModel` document instead of embedding a plain name string. +`ReferenceEnrichmentService.ResolvePersonReferenceIdAsync` (shared helper in the core partial-class file) is the single dedup-by-external-id path used by cast resolution (`ResolveCastAsync`, refactored to call it per member) +and by `ResolveBookAsync`/`ResolveAlbumAsync`. +`BookReferenceDto.AuthorName`/`AlbumReferenceDto.ArtistName` are hydrated by `ReferenceDataController` joining `person_reference` by id (same `[MapperIgnoreTarget]` + manual join pattern as `Cast`). +The model only carries the id, never a denormalized name, so there's nothing to keep in sync. +When linking (`TryLinkExistingBookReferenceAsync`/`TryLinkExistingAlbumReferenceAsync`), the tenant's own plain-string `BookModel.Author`/`AlbumModel.Artist` is still populated. +This works by resolving the reference's `*ReferenceId` to a name at link time (`ResolvePersonNameAsync`). +The tenant-facing field stays a free-text string editable like `Title`, only the shared reference document uses the dedup'd id. + +`ReferenceEnrichmentService` is a single `partial class` split across `.TvShowsAndMovies.cs`/`.Books.cs`/`.VideoGames.cs`/`.Albums.cs` files (one class, same constructor, same five-method-per-domain template: +`TryLinkExistingReferenceAsync`/`TryAutoResolveAsync`/`ResolveAsync`/`RefreshReferenceAsync`). +Splitting by file rather than by class keeps the shared helpers (`MergeMatchedAliases`, `TitleNormalizer.Normalize`) in one place without one file growing to cover six domains' worth of logic. +`ReferenceMatchModel`/`MatchedAliases` needed no changes at all to support the new domains - it was already generic, not TMDB-specific, per its original naming decision. + +`PosterUrl` was renamed to `ImageUrl` on `TvShowReferenceModel`/`MovieReferenceModel` (and their entities/DTOs/Razor bindings) as part of this. +"Poster" only made sense for movies/TV, and the field is now shared across all five reference types. +Similarly, the admin search/link DTOs' `TmdbId` field was renamed to `ExternalId` (`ReferenceSearchResultDto`, `LinkReferenceRequestDto`). +This is because `ReferenceDataAdminController`'s `Search`/`Link`/`GetUnresolved` endpoints now switch over a 5-way `ReferenceItemType` enum instead of a TV-show/movie ternary. +Hardcoding a TMDB-specific name in a request DTO shared by Open Library/RAWG/Discogs would have been misleading. + +`Book`/`VideoGame`/`Album` detail pages (`BookDetail.razor`, `VideoGameDetail.razor`, `AlbumDetail.razor`) follow `MovieDetail.razor`'s exact shape (own route, own load/save methods, no `InventoryPageBase` inheritance). +Cover art, synopsis and genres render when linked, plus the same refresh-reference icon button and admin `InlineReferenceLinker`. +None of the three have a cast/credits concept, so there's no `CastGrid` equivalent for them. + +**Gotcha:** `OpenLibraryClient.GetBookDetailsAsync` fetches a work's year from `first_publish_date` on `/works/{id}.json`, but that field is routinely absent from the work document itself. +This was confirmed against the real API for a book as well-known as Lee Child's "Killing Floor" (`/works/OL24477958W.json` has no `first_publish_date` at all), +while the search index's computed `first_publish_year` for the same work is reliable. +`GetBookDetailsAsync` falls back to `FindPublishYearViaSearchAsync` (a single-document `q=key:{workKey}` search re-query) whenever the work-level parse comes back null. +This is rather than silently leaving `BookReferenceModel.Year`/the tenant's own `BookModel.Year` unset after a link. + +**No reliable "series" field exists in Open Library.** Checked directly against the real API (work JSON, edition JSON, and a `fields=*` search query for "Killing Floor") - none expose a `series` field. +The closest available signal is the search index's `person`/`subject_people` facet (a recurring fictional character, e.g. `"person": ["Jack Reacher"]` for "Killing Floor"), but that's a character-name facet, not a series title. +It doesn't generalize (many series aren't named after - or don't have - a recurring eponymous character, and a standalone book can still feature a named character). +It's not wired up to `BookModel.Series`/`BookReferenceModel` for that reason - auto-filling `Series` from it would be right by coincidence for character-titled franchises and wrong or absent otherwise. + +`Album`/`Book` gained `IsFavorite` (`AlbumModel`/`Album`/`AlbumDto`, `BookModel`/`Book`/`BookDto`), same shape as `Movie`/`TvShow`'s existing flag: +a partial Mongo index (`album_favorite`/`book_favorite`, `scripts/mongodb-create-index.js`), a `Favorites` filter button in each list page's `Filters` slot, +and a toggle button on `AlbumDetail.razor`/`BookDetail.razor`'s page header (same `kt-toggle-btn`/`active` pattern as `MovieDetail.razor`). + +`VideoGameModel.State` stays a plain free-text string (not an enum like `TvShowModel.State`) deliberately. +Real tenant data already has values like `"To resume"`/`"On-hold"` that aren't valid C# enum member names, and MongoDB's `EnumRepresentationConvention(BsonType.String)` deserializes by matching the enum member name exactly. +So converting would require a data migration with real breakage risk for existing documents. +Filtering by state (`VideoGameRepository.GetFilter` already supported exact-match filtering by `input.State`) is instead exposed as list-page filter buttons (`VideoGames.razor`'s `Filters` slot, +same visual pattern as `TvShows.razor`'s `TvShowStatus` filter buttons) over the existing string values. +The Add/Edit forms' State ` + + @if (_refreshMessage is not null) + { + @_refreshMessage + } + +
+ +
+ + @if (string.IsNullOrEmpty(_album.ReferenceId)) + { + + + + + + } + +
+
+ @if (!string.IsNullOrEmpty(_reference?.ImageUrl)) + { + @_album.Title cover + } +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ @if (!string.IsNullOrEmpty(_reference?.Synopsis)) + { +

@_reference.Synopsis

+ } +
+
+
+ + @if (!string.IsNullOrEmpty(_reference?.ArtistImageUrl)) + { +

Artist

+ + } + + @if (_reference?.Tracks.Count > 0) + { +

Tracklist

+
+ @foreach (var track in _reference.Tracks) + { +
+ @track.Position @track.Title +
+ @track.Duration + +
+ @if (_openTrackMenu == track.Position) + { +
+

Add to playlist

+ @if (_playlists.Count == 0) + { +

Create a playlist first

+ } + else + { + @foreach (var playlist in _playlists) + { + + } + } +
+ } +
+ } +
+ } +} + +@code { + [Parameter] public required string Id { get; set; } + + [Inject] private AlbumApiClient AlbumApi { get; set; } = null!; + + [Inject] private ReferenceDataApiClient ReferenceDataApi { get; set; } = null!; + + [Inject] private PlaylistApiClient PlaylistApi { get; set; } = null!; + + [Inject] private SongApiClient SongApi { get; set; } = null!; + + private static readonly TimeSpan RefreshMessageDuration = TimeSpan.FromSeconds(4); + + private bool _loading = true; + private AlbumDto? _album; + private AlbumReferenceDto? _reference; + private List _playlists = []; + private string? _openTrackMenu; + private bool _refreshingReference; + private string? _refreshMessage; + private string _refreshMessageStyle = "neutral"; + private object? _refreshMessageToken; + + /// Reuses CastGrid's "photo + name" card for the single credited artist, instead of + /// introducing a one-off component for what's visually the same layout as a TV/movie cast member. + private List _artistCard => _reference is null + ? [] + : [new CastMemberDto { Name = _reference.ArtistName ?? _album?.Artist ?? "", CharacterName = "", ProfileImageUrl = _reference.ArtistImageUrl }]; + + protected override async Task OnParametersSetAsync() => await LoadAsync(); + + private async Task LoadAsync() + { + _loading = true; + _album = await AlbumApi.GetOneAsync(Id); + _reference = string.IsNullOrEmpty(_album?.ReferenceId) ? null : await ReferenceDataApi.GetAlbumAsync(_album.ReferenceId); + var playlists = await PlaylistApi.GetAsync("", 1, 5000); + _playlists = playlists.Items; + _loading = false; + } + + private void ToggleTrackMenu(string position) + { + _openTrackMenu = _openTrackMenu == position ? null : position; + } + + /// + /// The shared "add a real track" operation () - + /// reuses the same if this exact track was already added to any playlist, + /// instead of creating a duplicate. + /// + private async Task AddTrackToPlaylistAsync(ReferenceTrackDto track, PlaylistDto playlist) + { + _openTrackMenu = null; + if (_album?.Id is null) return; + + var song = await SongApi.GetOrCreateForTrackAsync(_album.Id, track.Position, track.Title, track.Duration, _album.Artist); + if (song.Id is not null && !playlist.SongIds.Contains(song.Id)) + { + playlist.SongIds.Add(song.Id); + await PlaylistApi.UpdateAsync(playlist); + } + } + + private async Task SetRatingAsync(float rating) + { + if (_album is null) return; + _album.Rating = rating; + await AlbumApi.UpdateAsync(_album); + } + + private async Task ToggleFavoriteAsync() + { + if (_album is null) return; + _album.IsFavorite = !_album.IsFavorite; + await AlbumApi.UpdateAsync(_album); + } + + private async Task SaveTitleAsync(ChangeEventArgs e) + { + if (_album is null) return; + var title = e.Value?.ToString(); + if (string.IsNullOrWhiteSpace(title)) return; + _album.Title = title; + await AlbumApi.UpdateAsync(_album); + } + + private async Task SaveArtistAsync(ChangeEventArgs e) + { + if (_album is null) return; + var artist = e.Value?.ToString(); + if (string.IsNullOrWhiteSpace(artist)) return; + _album.Artist = artist; + await AlbumApi.UpdateAsync(_album); + } + + private async Task SaveGenreAsync(ChangeEventArgs e) + { + if (_album is null) return; + _album.Genre = e.Value?.ToString(); + await AlbumApi.UpdateAsync(_album); + } + + private async Task RefreshReferenceAsync() + { + if (_album?.Id is null) return; + + var previousReferenceId = _album.ReferenceId; + _refreshingReference = true; + _refreshMessage = null; + try + { + var refreshed = await AlbumApi.RefreshReferenceAsync(_album.Id); + await LoadAsync(); + + (_refreshMessage, _refreshMessageStyle) = string.IsNullOrEmpty(refreshed.ReferenceId) + ? string.IsNullOrEmpty(previousReferenceId) ? ("No match found", "neutral") : ("Unlinked - no match", "danger") + : refreshed.ReferenceId == previousReferenceId ? ("Already linked", "neutral") : ("Linked!", "success"); + } + catch (Exception ex) + { + _refreshMessage = $"Error: {ex.Message}"; + _refreshMessageStyle = "danger"; + } + finally + { + _refreshingReference = false; + } + + ScheduleRefreshMessageClear(); + } + + /// + /// The toast is a small, auto-dismissing pill next to the refresh icon, not a permanent line of text - + /// a token guards against an in-flight delay from a previous click clearing a newer message. + /// + private void ScheduleRefreshMessageClear() + { + var token = new object(); + _refreshMessageToken = token; + _ = ClearRefreshMessageAfterDelayAsync(token); + } + + private async Task ClearRefreshMessageAfterDelayAsync(object token) + { + await Task.Delay(RefreshMessageDuration); + if (!ReferenceEquals(_refreshMessageToken, token)) return; + _refreshMessage = null; + await InvokeAsync(StateHasChanged); + } + + private async Task SaveYearAsync(ChangeEventArgs e) + { + if (_album is null) return; + _album.Year = int.TryParse(e.Value?.ToString(), out var year) ? year : null; + await AlbumApi.UpdateAsync(_album); + } +} diff --git a/src/BlazorApp/Components/Inventory/Pages/MusicAlbums.razor b/src/BlazorApp/Components/Inventory/Pages/Albums.razor similarity index 51% rename from src/BlazorApp/Components/Inventory/Pages/MusicAlbums.razor rename to src/BlazorApp/Components/Inventory/Pages/Albums.razor index 026a0278..58009a70 100644 --- a/src/BlazorApp/Components/Inventory/Pages/MusicAlbums.razor +++ b/src/BlazorApp/Components/Inventory/Pages/Albums.razor @@ -1,10 +1,11 @@ -@page "/music-albums" -@inherits InventoryPageBase +@page "/albums" +@inherits InventoryPageBase @rendermode InteractiveServer @attribute [Authorize] - + + + Title Artist Rating + - @album.Title + + @album.Title + @album.Artist + + @if (album.IsFavorite) + { + + } + - - - +
+ + +
+
+ + +
+
+ + +
+
+ + +
- + + + +
@@ -53,7 +82,7 @@
- +
@@ -61,7 +90,14 @@
- + +
+
+ +
+ + +
diff --git a/src/BlazorApp/Components/Inventory/Pages/Albums.razor.cs b/src/BlazorApp/Components/Inventory/Pages/Albums.razor.cs new file mode 100644 index 00000000..a0a15c17 --- /dev/null +++ b/src/BlazorApp/Components/Inventory/Pages/Albums.razor.cs @@ -0,0 +1,35 @@ +using Keeptrack.WebApi.Contracts.Dto; +using Microsoft.AspNetCore.Components; + +namespace Keeptrack.BlazorApp.Components.Inventory.Pages; + +public partial class Albums : InventoryPageBase +{ + [Inject] private AlbumApiClient AlbumApi { get; set; } = null!; + + protected override InventoryApiClientBase Api => AlbumApi; + + private bool _favoriteFilter; + + protected override IReadOnlyDictionary? ExtraQuery => + _favoriteFilter ? new Dictionary { ["IsFavorite"] = "true" } : null; + + private async Task ToggleFavoriteFilterAsync() + { + _favoriteFilter = !_favoriteFilter; + _page = 1; + await LoadAsync(); + } + + protected override AlbumDto CloneItem(AlbumDto item) => new() + { + Id = item.Id, + Title = item.Title, + Artist = item.Artist, + Genre = item.Genre, + Year = item.Year, + Rating = item.Rating, + ReferenceId = item.ReferenceId, + IsFavorite = item.IsFavorite + }; +} diff --git a/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor b/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor new file mode 100644 index 00000000..2352995b --- /dev/null +++ b/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor @@ -0,0 +1,267 @@ +@page "/books/{Id}" +@rendermode InteractiveServer +@attribute [Authorize] +@using Keeptrack.BlazorApp.Components.ReferenceData +@using Keeptrack.BlazorApp.Components.ReferenceDataAdmin + +@if (_loading) +{ +
+
+ Loading… +
+} +else if (_book is null) +{ +
+
+

Book not found.

+
+} +else +{ +
+
+ + + @if (_refreshMessage is not null) + { + @_refreshMessage + } +
+
+ + + +
+
+ @if (string.IsNullOrEmpty(_book.ReferenceId)) + { + + + + + + } + +
+ + @(_book.FirstReadAt is not null ? "✓ Read" : "Mark as read") + +
+ @if (!string.IsNullOrEmpty(_reference?.ImageUrl)) + { +
+ @_book.Title cover +
+ } +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ @if (_book.FirstReadAt is not null) + { +
+ + +
+ } +
+ + +
+
+
+ + +
+
+ @if (!string.IsNullOrEmpty(_reference?.Synopsis)) + { +
+

@_reference.Synopsis

+ } +
+} + +@code { + [Parameter] public required string Id { get; set; } + + [Inject] private BookApiClient BookApi { get; set; } = null!; + + [Inject] private ReferenceDataApiClient ReferenceDataApi { get; set; } = null!; + + private static readonly TimeSpan RefreshMessageDuration = TimeSpan.FromSeconds(4); + + private bool _loading = true; + private BookDto? _book; + private BookReferenceDto? _reference; + private bool _refreshingReference; + private string? _refreshMessage; + private string _refreshMessageStyle = "neutral"; + private object? _refreshMessageToken; + + protected override async Task OnParametersSetAsync() => await LoadAsync(); + + private async Task LoadAsync() + { + _loading = true; + _book = await BookApi.GetOneAsync(Id); + _reference = string.IsNullOrEmpty(_book?.ReferenceId) ? null : await ReferenceDataApi.GetBookAsync(_book.ReferenceId); + _loading = false; + } + + private async Task SetRatingAsync(float rating) + { + if (_book is null) return; + _book.Rating = rating; + await BookApi.UpdateAsync(_book); + } + + private async Task ToggleFavoriteAsync() + { + if (_book is null) return; + _book.IsFavorite = !_book.IsFavorite; + await BookApi.UpdateAsync(_book); + } + + private async Task ToggleOwnedAsync() + { + if (_book is null) return; + _book.IsOwned = !_book.IsOwned; + await BookApi.UpdateAsync(_book); + } + + private async Task ToggleWishlistedAsync() + { + if (_book is null) return; + _book.IsWishlisted = !_book.IsWishlisted; + await BookApi.UpdateAsync(_book); + } + + private async Task ToggleReadAsync() + { + if (_book is null) return; + _book.FirstReadAt = _book.FirstReadAt is null ? DateOnly.FromDateTime(DateTime.Today) : null; + await BookApi.UpdateAsync(_book); + } + + private async Task SetReadDateAsync(ChangeEventArgs e) + { + if (_book is null) return; + _book.FirstReadAt = DateOnly.TryParse(e.Value?.ToString(), out var date) ? date : null; + await BookApi.UpdateAsync(_book); + } + + private async Task SaveTitleAsync(ChangeEventArgs e) + { + if (_book is null) return; + var title = e.Value?.ToString(); + if (string.IsNullOrWhiteSpace(title)) return; + _book.Title = title; + await BookApi.UpdateAsync(_book); + } + + private async Task SaveAuthorAsync(ChangeEventArgs e) + { + if (_book is null) return; + var author = e.Value?.ToString(); + if (string.IsNullOrWhiteSpace(author)) return; + _book.Author = author; + await BookApi.UpdateAsync(_book); + } + + private async Task SaveSeriesAsync(ChangeEventArgs e) + { + if (_book is null) return; + _book.Series = e.Value?.ToString(); + await BookApi.UpdateAsync(_book); + } + + private async Task SaveGenreAsync(ChangeEventArgs e) + { + if (_book is null) return; + _book.Genre = e.Value?.ToString(); + await BookApi.UpdateAsync(_book); + } + + private async Task RefreshReferenceAsync() + { + if (_book?.Id is null) return; + + var previousReferenceId = _book.ReferenceId; + _refreshingReference = true; + _refreshMessage = null; + try + { + var refreshed = await BookApi.RefreshReferenceAsync(_book.Id); + await LoadAsync(); + + (_refreshMessage, _refreshMessageStyle) = string.IsNullOrEmpty(refreshed.ReferenceId) + ? string.IsNullOrEmpty(previousReferenceId) ? ("No match found", "neutral") : ("Unlinked - no match", "danger") + : refreshed.ReferenceId == previousReferenceId ? ("Already linked", "neutral") : ("Linked!", "success"); + } + catch (Exception ex) + { + _refreshMessage = $"Error: {ex.Message}"; + _refreshMessageStyle = "danger"; + } + finally + { + _refreshingReference = false; + } + + ScheduleRefreshMessageClear(); + } + + /// + /// The toast is a small, auto-dismissing pill next to the refresh icon, not a permanent line of text - + /// a token guards against an in-flight delay from a previous click clearing a newer message. + /// + private void ScheduleRefreshMessageClear() + { + var token = new object(); + _refreshMessageToken = token; + _ = ClearRefreshMessageAfterDelayAsync(token); + } + + private async Task ClearRefreshMessageAfterDelayAsync(object token) + { + await Task.Delay(RefreshMessageDuration); + if (!ReferenceEquals(_refreshMessageToken, token)) return; + _refreshMessage = null; + await InvokeAsync(StateHasChanged); + } + + private async Task SaveYearAsync(ChangeEventArgs e) + { + if (_book is null) return; + _book.Year = int.TryParse(e.Value?.ToString(), out var year) ? year : null; + await BookApi.UpdateAsync(_book); + } + + private async Task SaveNotesAsync(ChangeEventArgs e) + { + if (_book is null) return; + _book.Notes = e.Value?.ToString(); + await BookApi.UpdateAsync(_book); + } +} diff --git a/src/BlazorApp/Components/Inventory/Pages/Books.razor b/src/BlazorApp/Components/Inventory/Pages/Books.razor index c22b772c..35e39f5f 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Books.razor +++ b/src/BlazorApp/Components/Inventory/Pages/Books.razor @@ -5,6 +5,7 @@ + + + + + Title Author Series Rating Finished at + - @book.Title + + @book.Title + @book.Author @book.Series @book.FirstReadAt?.ToString("yyyy-MM-dd") + + @if (book.IsFavorite) + { + + } + @if (book.IsOwned) + { + + } + @if (book.IsWishlisted) + { + + } + - - - - - +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
- + + + +
@@ -61,6 +102,10 @@
+
+ + +
@@ -77,5 +122,20 @@
+
+ +
+ + +
+
+ + +
+
+ + +
+
diff --git a/src/BlazorApp/Components/Inventory/Pages/Books.razor.cs b/src/BlazorApp/Components/Inventory/Pages/Books.razor.cs index 18779d50..70efd008 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Books.razor.cs +++ b/src/BlazorApp/Components/Inventory/Pages/Books.razor.cs @@ -1,4 +1,4 @@ -using Keeptrack.WebApi.Contracts.Dto; +using Keeptrack.WebApi.Contracts.Dto; using Microsoft.AspNetCore.Components; namespace Keeptrack.BlazorApp.Components.Inventory.Pages; @@ -9,15 +9,59 @@ public partial class Books : InventoryPageBase protected override InventoryApiClientBase Api => BookApi; + private bool _favoriteFilter; + + private bool _ownedFilter; + + private bool _wishlistedFilter; + + protected override IReadOnlyDictionary? ExtraQuery + { + get + { + var query = new Dictionary(); + if (_favoriteFilter) query["IsFavorite"] = "true"; + if (_ownedFilter) query["IsOwned"] = "true"; + if (_wishlistedFilter) query["IsWishlisted"] = "true"; + return query.Count > 0 ? query : null; + } + } + + private async Task ToggleFavoriteFilterAsync() + { + _favoriteFilter = !_favoriteFilter; + _page = 1; + await LoadAsync(); + } + + private async Task ToggleOwnedFilterAsync() + { + _ownedFilter = !_ownedFilter; + _page = 1; + await LoadAsync(); + } + + private async Task ToggleWishlistedFilterAsync() + { + _wishlistedFilter = !_wishlistedFilter; + _page = 1; + await LoadAsync(); + } + protected override BookDto CloneItem(BookDto item) => new() { Id = item.Id, Title = item.Title, Author = item.Author, Series = item.Series, + Year = item.Year, Genre = item.Genre, Rating = item.Rating, Notes = item.Notes, - FirstReadAt = item.FirstReadAt + FirstReadAt = item.FirstReadAt, + ReferenceId = item.ReferenceId, + IsFavorite = item.IsFavorite, + IsOwned = item.IsOwned, + IsWishlisted = item.IsWishlisted }; } diff --git a/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor b/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor new file mode 100644 index 00000000..d896520c --- /dev/null +++ b/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor @@ -0,0 +1,599 @@ +@page "/cars/{Id}" +@rendermode InteractiveServer +@attribute [Authorize] +@using System.Globalization + +@if (_loading) +{ +
+
+ Loading… +
+} +else if (_car is null) +{ +
+
+

Car not found.

+
+} +else +{ +
+
+ +
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ @foreach (var energyType in Enum.GetValues()) + { + + } +
+
+
+ + @if (HasMetricsToShow) + { + var metrics = _metrics!; +

Metrics

+
+ @if (metrics.NextMaintenance is { } next) + { +

+ @(next.MonthsRemaining < 0 + ? $"Maintenance overdue by {-next.MonthsRemaining} month(s) - last done {next.LastMaintenanceDate:yyyy-MM-dd}." + : $"Next maintenance due in {next.MonthsRemaining} month(s) ({next.DueDate:yyyy-MM-dd}).") +

+ } + + @if (metrics.MileageWarnings.Count > 0) + { +

+ ⚠ @metrics.MileageWarnings.Count mileage inconsistenc@(metrics.MileageWarnings.Count == 1 ? "y" : "ies") found - check the highlighted row(s) below. +

+ } + + @{ + var showFuelChart = ShowFuel && metrics.FuelConsumption.Count > 0; + var showElectricChart = ShowElectric && metrics.ElectricConsumption.Count > 0; + // Side-by-side (half width each) only when both charts actually render, e.g. a hybrid - + // a combustion-only or electric-only car has just one chart, which should take the full row + // instead of leaving the other half empty. + var bothConsumptionCharts = showFuelChart && showElectricChart; + var consumptionColClass = bothConsumptionCharts ? "col-md-6" : "col-12"; + var consumptionGeometry = bothConsumptionCharts ? SvgChartHelpers.HalfWidthGeometry : SvgChartHelpers.FullWidthGeometry; + } +
+ @if (showFuelChart) + { +
+

+ Fuel consumption (L/100km) - avg @metrics.AverageFuelConsumptionPer100Km?.ToString("F1") +

+ @RenderConsumptionChart(metrics.FuelConsumption, "var(--kt-accent)", "L/100km", "fuel", consumptionGeometry) +
+ } + @if (showElectricChart) + { +
+

+ Electric consumption (kWh/100km) - avg @metrics.AverageElectricConsumptionPer100Km?.ToString("F1") +

+ @RenderConsumptionChart(metrics.ElectricConsumption, "var(--kt-chart-secondary)", "kWh/100km", "electric", consumptionGeometry) +
+ } +
+ + @if (metrics.CostHistory.Count > 0) + { +

Cost history - total @metrics.TotalCost.ToString("F2")

+
+ Fuel + Maintenance +
+ @RenderCostHistoryChart(metrics.CostHistory) + } +
+ } + +
+

History

+ +
+
+
+ + + + + + + + + + + + + + + @if (_history.Count == 0) + { + + } + @foreach (var entry in _history) + { + + } + +
DateMileageEventCostCityDescription
No history yet - add the first entry above.
+
+
+ + @if (_showModal) + { +
+
+
+
@(IsEditMode ? "Edit" : "Add") history entry
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ @foreach (var eventType in Enum.GetValues()) + { + + } +
+
+ @if (_modalEntry.EventType == CarHistoryType.Refuel) + { + @if (ShowFuel) + { +
+ + +
+
+ + +
+ } + @if (ShowElectric) + { +
+ + +
+
+ + +
+ } +
+ + +
+
+
+ + +
+
+ } + else + { +
+ + +
+ } + +
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+
+ } +} + +@code { + [Parameter] public required string Id { get; set; } + + [Inject] private CarApiClient CarApi { get; set; } = null!; + + [Inject] private CarHistoryApiClient CarHistoryApi { get; set; } = null!; + + private bool _loading = true; + private CarDto? _car; + private List _history = []; + private CarMetricsDto? _metrics; + private CarHistoryDto _modalEntry = NewEntry(""); + private bool _showModal; + private Dictionary _warningsByEntryId = []; + + private bool IsEditMode => !string.IsNullOrEmpty(_modalEntry.Id); + + /// Separate Date/Time proxies over the single _modalEntry.HistoryDate, so editing one doesn't + /// reset the other back to midnight - a plain @bind straight to HistoryDate would lose the time + /// whenever the date input fires (its built-in converter always parses a bare yyyy-MM-dd). + private DateOnly ModalDate + { + get => DateOnly.FromDateTime(_modalEntry.HistoryDate); + set => _modalEntry.HistoryDate = value.ToDateTime(TimeOnly.FromDateTime(_modalEntry.HistoryDate)); + } + + /// + /// A plain text "HH:mm" field rather than <input type="time"> - the native time picker's 12h/24h + /// display is decided by the browser from the OS region format, not anything this page controls (Chrome + /// and Firefox both ignore the page's own language/culture for it), so it can't be forced to 24h that + /// way. A free-text field sidesteps the native widget entirely and always reads/writes 24h. + /// + private string ModalTimeText + { + get => TimeOnly.FromDateTime(_modalEntry.HistoryDate).ToString("HH:mm"); + set + { + if (TimeOnly.TryParseExact(value, "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var time)) + { + _modalEntry.HistoryDate = DateOnly.FromDateTime(_modalEntry.HistoryDate).ToDateTime(time); + } + } + } + + private bool ShowFuel => _car?.EnergyType != CarEnergyType.Electric; + + private bool ShowElectric => _car?.EnergyType is CarEnergyType.Electric or CarEnergyType.Hybrid; + + /// `ComputeMetrics` always returns a non-null result, even for a brand-new car with no history + /// at all (empty lists, null averages/next-maintenance) - the metrics panel should only render once + /// there's actually something in it to show, not just because the object itself isn't null. + private bool HasMetricsToShow => _metrics is not null && ( + _metrics.FuelConsumption.Count > 0 || + _metrics.ElectricConsumption.Count > 0 || + _metrics.CostHistory.Count > 0 || + _metrics.MileageWarnings.Count > 0 || + _metrics.NextMaintenance is not null); + + protected override async Task OnParametersSetAsync() => await LoadAsync(); + + private static CarHistoryDto NewEntry(string carId) => new() + { + CarId = carId, + HistoryDate = DateTime.Now, + EventType = CarHistoryType.Refuel + }; + + private async Task LoadAsync() + { + _loading = true; + _car = await CarApi.GetOneAsync(Id); + if (_car is not null) + { + var result = await CarHistoryApi.GetAsync("", 1, int.MaxValue, new Dictionary { ["CarId"] = Id }); + _history = result.Items.OrderBy(h => h.HistoryDate).ToList(); + _metrics = await CarApi.GetMetricsAsync(Id); + _warningsByEntryId = _metrics.MileageWarnings + .GroupBy(w => w.CarHistoryId) + .ToDictionary(g => g.Key, g => string.Join(" ", g.Select(w => w.Message))); + } + _loading = false; + } + + private async Task SaveCarAsync() + { + if (_car is null) return; + await CarApi.UpdateAsync(_car); + } + + private async Task SaveNameAsync(ChangeEventArgs e) + { + if (_car is null) return; + var name = e.Value?.ToString(); + if (string.IsNullOrWhiteSpace(name)) return; + _car.Name = name; + await SaveCarAsync(); + } + + private async Task SaveManufacturerAsync(ChangeEventArgs e) + { + if (_car is null) return; + _car.Manufacturer = e.Value?.ToString(); + await SaveCarAsync(); + } + + private async Task SaveModelAsync(ChangeEventArgs e) + { + if (_car is null) return; + _car.Model = e.Value?.ToString(); + await SaveCarAsync(); + } + + private async Task SaveYearAsync(ChangeEventArgs e) + { + if (_car is null) return; + _car.Year = int.TryParse(e.Value?.ToString(), out var year) ? year : null; + await SaveCarAsync(); + } + + private async Task SaveLicensePlateAsync(ChangeEventArgs e) + { + if (_car is null) return; + _car.LicensePlate = e.Value?.ToString(); + await SaveCarAsync(); + } + + private async Task SetEnergyTypeAsync(CarEnergyType energyType) + { + if (_car is null) return; + _car.EnergyType = energyType; + await SaveCarAsync(); + } + + private void ShowAddModal() + { + _modalEntry = NewEntry(Id); + _showModal = true; + } + + /// Edits a copy, not the row's own object, so Cancel discards changes instead of leaving the + /// table showing edits that were never saved. + private void ShowEditModal(CarHistoryDto entry) + { + _modalEntry = CloneEntry(entry); + _showModal = true; + } + + private void CancelModal() => _showModal = false; + + /// + /// Every history mutation reloads the whole car (history + metrics) rather than patching local state - + /// an edit can change mileage-warning results and chronological order, so recomputing from the server is + /// simpler and safer than trying to keep local derived state in sync by hand. + /// + private async Task SaveModalEntryAsync() + { + if (IsEditMode) await CarHistoryApi.UpdateAsync(_modalEntry); + else await CarHistoryApi.AddAsync(_modalEntry); + + _showModal = false; + await LoadAsync(); + } + + private static CarHistoryDto CloneEntry(CarHistoryDto entry) => new() + { + Id = entry.Id, + CarId = entry.CarId, + HistoryDate = entry.HistoryDate, + Mileage = entry.Mileage, + EventType = entry.EventType, + Description = entry.Description, + Cost = entry.Cost, + City = entry.City, + PostalCode = entry.PostalCode, + Country = entry.Country, + Longitude = entry.Longitude, + Latitude = entry.Latitude, + FuelCategory = entry.FuelCategory, + FuelVolume = entry.FuelVolume, + FuelUnitPrice = entry.FuelUnitPrice, + ElectricVolume = entry.ElectricVolume, + ElectricUnitPrice = entry.ElectricUnitPrice, + IsFullRefill = entry.IsFullRefill, + DeltaMileage = entry.DeltaMileage, + StationBrandName = entry.StationBrandName, + Garage = entry.Garage + }; + + private async Task DeleteEntryAsync(string id) + { + await CarHistoryApi.DeleteAsync(id); + await LoadAsync(); + } + + /// Thin hand-rolled SVG line chart - this app has no charting dependency, and one isn't worth + /// adding for a couple of small, single-series trend lines. A single series needs no legend (the label + /// above the chart already names it); each point carries a native <title> tooltip. Axis drawing + /// (arrowheads, ticks, titles) lives in , shared with HouseDetail.razor's + /// own chart. + private static RenderFragment RenderConsumptionChart(List points, string color, string yLabel, string chartId, SvgChartHelpers.ChartGeometry geometry) => builder => + { + var (viewWidth, viewHeight, plotLeft, plotRight, plotTop, plotBottom) = geometry; + + var values = points.Select(p => p.ValuePer100Km).ToList(); + var min = values.Min(); + var max = values.Max(); + var range = max - min == 0 ? 1 : max - min; + var stepX = points.Count > 1 ? (plotRight - plotLeft) / (points.Count - 1) : 0; + + double X(int i) => plotLeft + i * stepX; + double Y(double v) => plotBottom - (v - min) / range * (plotBottom - plotTop); + + var linePoints = string.Join(" ", points.Select((p, i) => $"{X(i):F1},{Y(p.ValuePer100Km):F1}")); + + var yTicks = Enumerable.Range(0, 4) + .Select(i => min + range * i / 3.0) + .Select(v => (Y(v), v.ToString("F1"))) + .ToList(); + var xTicks = SvgChartHelpers.EvenlySpacedIndices(points.Count, 4) + .Select(i => (X(i), points[i].Date.ToString("MMM yy"))) + .ToList(); + + var seq = 0; + builder.OpenElement(seq++, "svg"); + builder.AddAttribute(seq++, "viewBox", $"0 0 {viewWidth} {viewHeight}"); + builder.AddAttribute(seq++, "class", "kt-chart-svg"); + + SvgChartHelpers.RenderAxes(builder, ref seq, geometry, $"kt-chart-arrow-{chartId}", "Date", yLabel, yTicks, xTicks); + + builder.OpenElement(seq++, "polyline"); + builder.AddAttribute(seq++, "points", linePoints); + builder.AddAttribute(seq++, "fill", "none"); + builder.AddAttribute(seq++, "stroke", color); + builder.AddAttribute(seq++, "stroke-width", "2"); + builder.AddAttribute(seq++, "stroke-linecap", "round"); + builder.AddAttribute(seq++, "stroke-linejoin", "round"); + builder.AddAttribute(seq++, "vector-effect", "non-scaling-stroke"); + builder.CloseElement(); + + for (var i = 0; i < points.Count; i++) + { + builder.OpenElement(seq++, "circle"); + builder.AddAttribute(seq++, "cx", X(i).ToString("F1")); + builder.AddAttribute(seq++, "cy", Y(points[i].ValuePer100Km).ToString("F1")); + builder.AddAttribute(seq++, "r", "1.6"); + builder.AddAttribute(seq++, "fill", color); + builder.OpenElement(seq++, "title"); + builder.AddContent(seq++, $"{points[i].Date:yyyy-MM-dd}: {points[i].ValuePer100Km:F1}"); + builder.CloseElement(); + builder.CloseElement(); + } + + builder.CloseElement(); + }; + + /// Hand-rolled SVG stacked bar chart - fuel vs. maintenance cost per month, each bar segment + /// carrying a native <title> tooltip with the exact figure, and a 2px surface gap between the two + /// stacked segments so they read as distinct marks rather than one solid bar. + private static RenderFragment RenderCostHistoryChart(List points) => builder => + { + var geometry = SvgChartHelpers.FullWidthGeometry; + var (viewWidth, viewHeight, plotLeft, plotRight, plotTop, plotBottom) = geometry; + + var maxTotal = points.Max(p => p.TotalCost); + if (maxTotal <= 0) maxTotal = 1; + + var plotWidth = plotRight - plotLeft; + var barWidth = plotWidth / points.Count * 0.7; + var gap = plotWidth / points.Count * 0.3; + + double ScaleHeight(double value) => value / maxTotal * (plotBottom - plotTop); + + var yTicks = Enumerable.Range(0, 4) + .Select(i => maxTotal * i / 3.0) + .Select(v => (plotBottom - ScaleHeight(v), v.ToString("F0"))) + .ToList(); + var xTicks = SvgChartHelpers.EvenlySpacedIndices(points.Count, 5) + .Select(i => (plotLeft + i * (barWidth + gap) + barWidth / 2, points[i].Period.ToString("MMM yy"))) + .ToList(); + + var seq = 0; + builder.OpenElement(seq++, "svg"); + builder.AddAttribute(seq++, "viewBox", $"0 0 {viewWidth} {viewHeight}"); + builder.AddAttribute(seq++, "class", "kt-chart-svg"); + + SvgChartHelpers.RenderAxes(builder, ref seq, geometry, "kt-chart-arrow-cost", "Month", "€", yTicks, xTicks); + + for (var i = 0; i < points.Count; i++) + { + var point = points[i]; + var x = plotLeft + i * (barWidth + gap); + var fuelHeight = ScaleHeight(point.FuelCost); + var maintenanceHeight = ScaleHeight(point.MaintenanceCost); + var stackGap = point.FuelCost > 0 && point.MaintenanceCost > 0 ? 0.6 : 0; + + var maintenanceY = plotBottom - maintenanceHeight; + builder.OpenElement(seq++, "rect"); + builder.AddAttribute(seq++, "x", x.ToString("F1")); + builder.AddAttribute(seq++, "y", maintenanceY.ToString("F1")); + builder.AddAttribute(seq++, "width", barWidth.ToString("F1")); + builder.AddAttribute(seq++, "height", maintenanceHeight.ToString("F1")); + builder.AddAttribute(seq++, "fill", "var(--kt-chart-secondary)"); + builder.OpenElement(seq++, "title"); + builder.AddContent(seq++, $"{point.Period:yyyy-MM}: maintenance {point.MaintenanceCost:F2}"); + builder.CloseElement(); + builder.CloseElement(); + + var fuelY = maintenanceY - stackGap - fuelHeight; + builder.OpenElement(seq++, "rect"); + builder.AddAttribute(seq++, "x", x.ToString("F1")); + builder.AddAttribute(seq++, "y", fuelY.ToString("F1")); + builder.AddAttribute(seq++, "width", barWidth.ToString("F1")); + builder.AddAttribute(seq++, "height", fuelHeight.ToString("F1")); + builder.AddAttribute(seq++, "fill", "var(--kt-accent)"); + builder.OpenElement(seq++, "title"); + builder.AddContent(seq++, $"{point.Period:yyyy-MM}: fuel {point.FuelCost:F2}"); + builder.CloseElement(); + builder.CloseElement(); + } + + builder.CloseElement(); + }; +} diff --git a/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor.css b/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor.css new file mode 100644 index 00000000..9307b294 --- /dev/null +++ b/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor.css @@ -0,0 +1,12 @@ +/* Mileage-inconsistency row highlighting - Car-specific (CarMetricsService.ComputeMileageWarnings has no + House equivalent), so this stays here rather than in the shared rules in app.css. Rows/cells are rendered + by the child CarHistoryRow component, not this file's own markup - CSS isolation scopes stop at a + component boundary, so this needs ::deep to reach in. */ +.kt-car-sheet ::deep .kt-history-row-warning td { + background: var(--kt-warning-bg); +} + +.kt-car-sheet ::deep .kt-history-warning-icon { + color: var(--kt-warning); + cursor: help; +} diff --git a/src/BlazorApp/Components/Inventory/Pages/CarHistoryRow.razor b/src/BlazorApp/Components/Inventory/Pages/CarHistoryRow.razor new file mode 100644 index 00000000..112c9c8a --- /dev/null +++ b/src/BlazorApp/Components/Inventory/Pages/CarHistoryRow.razor @@ -0,0 +1,35 @@ +@* One row of the car history sheet - a compact summary of the most important fields. Editing happens + through the same modal used to add a new entry (CarDetail.razor), opened via the Edit button below. *@ + + + @Entry.HistoryDate.ToString(Entry.HistoryDate.TimeOfDay == TimeSpan.Zero ? "yyyy-MM-dd" : "yyyy-MM-dd HH:mm") + @Entry.Mileage + @Entry.EventType + @Entry.Cost?.ToString("F2") + @Entry.City + @Entry.Description + + @if (HasWarning) + { + + } + + +
+ + +
+ + + +@code { + [Parameter] public required CarHistoryDto Entry { get; set; } + + [Parameter] public bool HasWarning { get; set; } + + [Parameter] public string? WarningMessage { get; set; } + + [Parameter] public EventCallback OnEdit { get; set; } + + [Parameter] public EventCallback OnDelete { get; set; } +} diff --git a/src/BlazorApp/Components/Inventory/Pages/Cars.razor b/src/BlazorApp/Components/Inventory/Pages/Cars.razor new file mode 100644 index 00000000..91d17f7d --- /dev/null +++ b/src/BlazorApp/Components/Inventory/Pages/Cars.razor @@ -0,0 +1,96 @@ +@page "/cars" +@inherits InventoryPageBase +@rendermode InteractiveServer +@attribute [Authorize] + + + + Name + Manufacturer + Model + Year + License plate + Energy + + + + + @car.Name + + @car.Manufacturer + @car.Model + @car.Year + @car.LicensePlate + @car.EnergyType + + + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+

Manage energy type and history from the car's detail page.

+
+
diff --git a/src/BlazorApp/Components/Inventory/Pages/Cars.razor.cs b/src/BlazorApp/Components/Inventory/Pages/Cars.razor.cs new file mode 100644 index 00000000..72895cdc --- /dev/null +++ b/src/BlazorApp/Components/Inventory/Pages/Cars.razor.cs @@ -0,0 +1,52 @@ +using Keeptrack.WebApi.Contracts.Dto; +using Microsoft.AspNetCore.Components; + +namespace Keeptrack.BlazorApp.Components.Inventory.Pages; + +public partial class Cars : InventoryPageBase +{ + [Inject] private CarApiClient CarApi { get; set; } = null!; + + [Inject] private NavigationManager Nav { get; set; } = null!; + + protected override InventoryApiClientBase Api => CarApi; + + /// + /// Creating a car only ever captures identity fields here (see FormTemplate) - energy type and + /// history are added on the detail page, so a successful create navigates straight there instead of + /// closing the form and staying on the list (same as VideoGames.razor.cs's SaveAsync). + /// + protected override async Task SaveAsync() + { + try + { + if (_form.Id is null) + { + var created = await CarApi.AddAsync(_form); + _showForm = false; + Nav.NavigateTo($"/cars/{created.Id}"); + } + else + { + await CarApi.UpdateAsync(_form); + _showForm = false; + await LoadAsync(); + } + } + catch (Exception ex) + { + _error = ex.Message; + } + } + + protected override CarDto CloneItem(CarDto item) => new() + { + Id = item.Id, + Name = item.Name, + Manufacturer = item.Manufacturer, + Model = item.Model, + Year = item.Year, + LicensePlate = item.LicensePlate, + EnergyType = item.EnergyType + }; +} diff --git a/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor b/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor new file mode 100644 index 00000000..311f338f --- /dev/null +++ b/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor @@ -0,0 +1,361 @@ +@page "/houses/{Id}" +@rendermode InteractiveServer +@attribute [Authorize] + +@if (_loading) +{ +
+
+ Loading… +
+} +else if (_house is null) +{ +
+
+

House not found.

+
+} +else +{ +
+
+ +
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + @if (HasMetricsToShow) + { + var metrics = _metrics!; +

Yearly cost review

+
+

Total cost by year - @metrics.CostHistory.Sum(p => p.TotalCost).ToString("F2") across the whole history

+ @RenderYearlyCostChart(metrics.CostHistory) + +
+ + + + + @foreach (var eventType in Enum.GetValues()) + { + + } + + + + + @foreach (var point in metrics.CostHistory) + { + + + @foreach (var eventType in Enum.GetValues()) + { + + } + + + } + +
Year@eventTypeTotal
@point.Year@(point.CostByCategory.FirstOrDefault(c => c.EventType == eventType)?.Cost.ToString("F2") ?? "-")@point.TotalCost.ToString("F2")
+
+
+ } + +
+

History

+ +
+
+
+ + + + + + + + + + + + + @if (_history.Count == 0) + { + + } + @foreach (var entry in _history) + { + + } + +
DateEventCostProviderDescription
No history yet - add the first entry above.
+
+
+ + @if (_showModal) + { +
+
+
+
@(IsEditMode ? "Edit" : "Add") history entry
+ +
+
+
+
+ + +
+
+ + +
+
+ +
+ @foreach (var eventType in Enum.GetValues()) + { + + } +
+
+
+ + +
+
+ + +
+
+
+ +
+
+ } +} + +@code { + [Parameter] public required string Id { get; set; } + + [Inject] private HouseApiClient HouseApi { get; set; } = null!; + + [Inject] private HouseHistoryApiClient HouseHistoryApi { get; set; } = null!; + + private bool _loading = true; + private HouseDto? _house; + private List _history = []; + private HouseMetricsDto? _metrics; + private HouseHistoryDto _modalEntry = NewEntry(""); + private bool _showModal; + + private bool IsEditMode => !string.IsNullOrEmpty(_modalEntry.Id); + + /// `ComputeMetrics` always returns a non-null result, even for a brand-new house with no history + /// at all (an empty list) - the metrics panel should only render once there's actually something in it + /// to show, not just because the object itself isn't null. + private bool HasMetricsToShow => _metrics is { CostHistory.Count: > 0 }; + + protected override async Task OnParametersSetAsync() => await LoadAsync(); + + private static HouseHistoryDto NewEntry(string houseId) => new() + { + HouseId = houseId, + HistoryDate = DateOnly.FromDateTime(DateTime.Now), + EventType = HouseEventType.Maintenance + }; + + private async Task LoadAsync() + { + _loading = true; + _house = await HouseApi.GetOneAsync(Id); + if (_house is not null) + { + var result = await HouseHistoryApi.GetAsync("", 1, int.MaxValue, new Dictionary { ["HouseId"] = Id }); + // Most recent first - unlike CarDetail's chronological (ascending) order, House has no mileage-style + // ordering dependency, and a browsable insurance/maintenance log reads better with the newest entry on top. + _history = result.Items.OrderByDescending(h => h.HistoryDate).ToList(); + _metrics = await HouseApi.GetMetricsAsync(Id); + } + _loading = false; + } + + private async Task SaveHouseAsync() + { + if (_house is null) return; + await HouseApi.UpdateAsync(_house); + } + + private async Task SaveNameAsync(ChangeEventArgs e) + { + if (_house is null) return; + var name = e.Value?.ToString(); + if (string.IsNullOrWhiteSpace(name)) return; + _house.Name = name; + await SaveHouseAsync(); + } + + private async Task SaveAddressAsync(ChangeEventArgs e) + { + if (_house is null) return; + _house.Address = e.Value?.ToString(); + await SaveHouseAsync(); + } + + private async Task SaveCityAsync(ChangeEventArgs e) + { + if (_house is null) return; + _house.City = e.Value?.ToString(); + await SaveHouseAsync(); + } + + private async Task SavePostalCodeAsync(ChangeEventArgs e) + { + if (_house is null) return; + _house.PostalCode = e.Value?.ToString(); + await SaveHouseAsync(); + } + + private async Task SaveCountryAsync(ChangeEventArgs e) + { + if (_house is null) return; + _house.Country = e.Value?.ToString(); + await SaveHouseAsync(); + } + + private async Task SaveNotesAsync(ChangeEventArgs e) + { + if (_house is null) return; + _house.Notes = e.Value?.ToString(); + await SaveHouseAsync(); + } + + private void ShowAddModal() + { + _modalEntry = NewEntry(Id); + _showModal = true; + } + + /// Edits a copy, not the row's own object, so Cancel discards changes instead of leaving the + /// table showing edits that were never saved. + private void ShowEditModal(HouseHistoryDto entry) + { + _modalEntry = CloneEntry(entry); + _showModal = true; + } + + private void CancelModal() => _showModal = false; + + /// Every history mutation reloads the whole house (history + metrics) rather than patching local + /// state - an edit can change the yearly cost breakdown, so recomputing from the server is simpler and + /// safer than trying to keep local derived state in sync by hand. + private async Task SaveModalEntryAsync() + { + if (IsEditMode) await HouseHistoryApi.UpdateAsync(_modalEntry); + else await HouseHistoryApi.AddAsync(_modalEntry); + + _showModal = false; + await LoadAsync(); + } + + private static HouseHistoryDto CloneEntry(HouseHistoryDto entry) => new() + { + Id = entry.Id, + HouseId = entry.HouseId, + HistoryDate = entry.HistoryDate, + EventType = entry.EventType, + Description = entry.Description, + Cost = entry.Cost, + Provider = entry.Provider + }; + + private async Task DeleteEntryAsync(string id) + { + await HouseHistoryApi.DeleteAsync(id); + await LoadAsync(); + } + + /// Hand-rolled SVG bar chart - total cost per year, one bar each. Simpler than a stacked chart + /// (which would need 6 colors for House's event categories): the breakdown table right below already + /// carries the per-category precision an insurance review actually needs, so the chart only needs to + /// answer "what did this cost, year by year" at a glance. Axis drawing lives in , + /// shared with CarDetail.razor's own charts. + private static RenderFragment RenderYearlyCostChart(List points) => builder => + { + var geometry = SvgChartHelpers.FullWidthGeometry; + var (viewWidth, viewHeight, plotLeft, plotRight, plotTop, plotBottom) = geometry; + + var maxTotal = points.Max(p => p.TotalCost); + if (maxTotal <= 0) maxTotal = 1; + + var plotWidth = plotRight - plotLeft; + var barWidth = plotWidth / points.Count * 0.6; + var gap = plotWidth / points.Count * 0.4; + + double ScaleHeight(double value) => value / maxTotal * (plotBottom - plotTop); + + var yTicks = Enumerable.Range(0, 4) + .Select(i => maxTotal * i / 3.0) + .Select(v => (plotBottom - ScaleHeight(v), v.ToString("F0"))) + .ToList(); + var xTicks = points.Select((p, i) => (plotLeft + i * (barWidth + gap) + barWidth / 2, p.Year.ToString())).ToList(); + + var seq = 0; + builder.OpenElement(seq++, "svg"); + builder.AddAttribute(seq++, "viewBox", $"0 0 {viewWidth} {viewHeight}"); + builder.AddAttribute(seq++, "class", "kt-chart-svg"); + + SvgChartHelpers.RenderAxes(builder, ref seq, geometry, "kt-chart-arrow-house-cost", "Year", "€", yTicks, xTicks); + + for (var i = 0; i < points.Count; i++) + { + var point = points[i]; + var x = plotLeft + i * (barWidth + gap); + var height = ScaleHeight(point.TotalCost); + var y = plotBottom - height; + + builder.OpenElement(seq++, "rect"); + builder.AddAttribute(seq++, "x", x.ToString("F1")); + builder.AddAttribute(seq++, "y", y.ToString("F1")); + builder.AddAttribute(seq++, "width", barWidth.ToString("F1")); + builder.AddAttribute(seq++, "height", height.ToString("F1")); + builder.AddAttribute(seq++, "fill", "var(--kt-accent)"); + builder.OpenElement(seq++, "title"); + builder.AddContent(seq++, $"{point.Year}: {point.TotalCost:F2}"); + builder.CloseElement(); + builder.CloseElement(); + } + + builder.CloseElement(); + }; +} diff --git a/src/BlazorApp/Components/Inventory/Pages/HouseHistoryRow.razor b/src/BlazorApp/Components/Inventory/Pages/HouseHistoryRow.razor new file mode 100644 index 00000000..7e2809cd --- /dev/null +++ b/src/BlazorApp/Components/Inventory/Pages/HouseHistoryRow.razor @@ -0,0 +1,24 @@ +@* One row of the house history sheet - a compact summary of the most important fields. Editing happens + through the same modal used to add a new entry (HouseDetail.razor), opened via the Edit button below. *@ + + + @Entry.HistoryDate.ToString("yyyy-MM-dd") + @Entry.EventType + @Entry.Cost?.ToString("F2") + @Entry.Provider + @Entry.Description + +
+ + +
+ + + +@code { + [Parameter] public required HouseHistoryDto Entry { get; set; } + + [Parameter] public EventCallback OnEdit { get; set; } + + [Parameter] public EventCallback OnDelete { get; set; } +} diff --git a/src/BlazorApp/Components/Inventory/Pages/Houses.razor b/src/BlazorApp/Components/Inventory/Pages/Houses.razor new file mode 100644 index 00000000..bd44e318 --- /dev/null +++ b/src/BlazorApp/Components/Inventory/Pages/Houses.razor @@ -0,0 +1,92 @@ +@page "/houses" +@inherits InventoryPageBase +@rendermode InteractiveServer +@attribute [Authorize] + + + + Name + Address + City + Country + + + + + @house.Name + + @house.Address + @house.City + @house.Country + + + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+

Manage notes and history from the house's detail page.

+
+
diff --git a/src/BlazorApp/Components/Inventory/Pages/Houses.razor.cs b/src/BlazorApp/Components/Inventory/Pages/Houses.razor.cs new file mode 100644 index 00000000..14e33098 --- /dev/null +++ b/src/BlazorApp/Components/Inventory/Pages/Houses.razor.cs @@ -0,0 +1,52 @@ +using Keeptrack.WebApi.Contracts.Dto; +using Microsoft.AspNetCore.Components; + +namespace Keeptrack.BlazorApp.Components.Inventory.Pages; + +public partial class Houses : InventoryPageBase +{ + [Inject] private HouseApiClient HouseApi { get; set; } = null!; + + [Inject] private NavigationManager Nav { get; set; } = null!; + + protected override InventoryApiClientBase Api => HouseApi; + + /// + /// Creating a house only ever captures identity fields here (see FormTemplate) - notes and history + /// are added on the detail page, so a successful create navigates straight there instead of closing the + /// form and staying on the list (same as Cars.razor.cs's SaveAsync). + /// + protected override async Task SaveAsync() + { + try + { + if (_form.Id is null) + { + var created = await HouseApi.AddAsync(_form); + _showForm = false; + Nav.NavigateTo($"/houses/{created.Id}"); + } + else + { + await HouseApi.UpdateAsync(_form); + _showForm = false; + await LoadAsync(); + } + } + catch (Exception ex) + { + _error = ex.Message; + } + } + + protected override HouseDto CloneItem(HouseDto item) => new() + { + Id = item.Id, + Name = item.Name, + Address = item.Address, + City = item.City, + PostalCode = item.PostalCode, + Country = item.Country, + Notes = item.Notes + }; +} diff --git a/src/BlazorApp/Components/Inventory/Pages/MovieDetail.razor b/src/BlazorApp/Components/Inventory/Pages/MovieDetail.razor new file mode 100644 index 00000000..8a03a895 --- /dev/null +++ b/src/BlazorApp/Components/Inventory/Pages/MovieDetail.razor @@ -0,0 +1,250 @@ +@page "/movies/{Id}" +@rendermode InteractiveServer +@attribute [Authorize] +@using Keeptrack.BlazorApp.Components.ReferenceData +@using Keeptrack.BlazorApp.Components.ReferenceDataAdmin + +@if (_loading) +{ +
+
+ Loading… +
+} +else if (_movie is null) +{ +
+
+

Movie not found.

+
+} +else +{ +
+
+ + + @if (_refreshMessage is not null) + { + @_refreshMessage + } +
+
+ + + + +
+
+ @if (string.IsNullOrEmpty(_movie.ReferenceId)) + { + + + + + + } + +
+ + @(_movie.FirstSeenAt is not null ? "✓ Watched" : "Mark as watched") + +
+ @if (!string.IsNullOrEmpty(_reference?.ImageUrl)) + { +
+ @_movie.Title poster +
+ } +
+
+ + +
+
+ + +
+ @if (_movie.FirstSeenAt is not null) + { +
+ + +
+ } + @if (_reference?.Genres.Count > 0) + { +

@string.Join(", ", _reference.Genres)

+ } +
+
+ + +
+
+ @if (!string.IsNullOrEmpty(_reference?.Synopsis)) + { +
+

@_reference.Synopsis

+ } +
+ + @if (_reference?.Cast.Count > 0) + { +

Cast

+ + } +} + +@code { + [Parameter] public required string Id { get; set; } + + [Inject] private MovieApiClient MovieApi { get; set; } = null!; + + [Inject] private ReferenceDataApiClient ReferenceDataApi { get; set; } = null!; + + private static readonly TimeSpan RefreshMessageDuration = TimeSpan.FromSeconds(4); + + private bool _loading = true; + private MovieDto? _movie; + private MovieReferenceDto? _reference; + private bool _refreshingReference; + private string? _refreshMessage; + private string _refreshMessageStyle = "neutral"; + private object? _refreshMessageToken; + + protected override async Task OnParametersSetAsync() => await LoadAsync(); + + private async Task LoadAsync() + { + _loading = true; + _movie = await MovieApi.GetOneAsync(Id); + _reference = string.IsNullOrEmpty(_movie?.ReferenceId) ? null : await ReferenceDataApi.GetMovieAsync(_movie.ReferenceId); + _loading = false; + } + + private async Task ToggleFavoriteAsync() + { + if (_movie is null) return; + _movie.IsFavorite = !_movie.IsFavorite; + await MovieApi.UpdateAsync(_movie); + } + + private async Task ToggleWantToWatchAsync() + { + if (_movie is null) return; + _movie.WantToWatch = !_movie.WantToWatch; + await MovieApi.UpdateAsync(_movie); + } + + private async Task ToggleOwnedAsync() + { + if (_movie is null) return; + _movie.IsOwned = !_movie.IsOwned; + await MovieApi.UpdateAsync(_movie); + } + + private async Task ToggleWishlistedAsync() + { + if (_movie is null) return; + _movie.IsWishlisted = !_movie.IsWishlisted; + await MovieApi.UpdateAsync(_movie); + } + + private async Task SetRatingAsync(float rating) + { + if (_movie is null) return; + _movie.Rating = rating; + await MovieApi.UpdateAsync(_movie); + } + + private async Task ToggleWatchedAsync() + { + if (_movie is null) return; + _movie.FirstSeenAt = _movie.FirstSeenAt is null ? DateOnly.FromDateTime(DateTime.Today) : null; + await MovieApi.UpdateAsync(_movie); + } + + private async Task SetWatchedDateAsync(ChangeEventArgs e) + { + if (_movie is null) return; + _movie.FirstSeenAt = DateOnly.TryParse(e.Value?.ToString(), out var date) ? date : null; + await MovieApi.UpdateAsync(_movie); + } + + private async Task SaveTitleAsync(ChangeEventArgs e) + { + if (_movie is null) return; + var title = e.Value?.ToString(); + if (string.IsNullOrWhiteSpace(title)) return; + _movie.Title = title; + await MovieApi.UpdateAsync(_movie); + } + + private async Task RefreshReferenceAsync() + { + if (_movie?.Id is null) return; + + var previousReferenceId = _movie.ReferenceId; + _refreshingReference = true; + _refreshMessage = null; + try + { + var refreshed = await MovieApi.RefreshReferenceAsync(_movie.Id); + await LoadAsync(); + + (_refreshMessage, _refreshMessageStyle) = string.IsNullOrEmpty(refreshed.ReferenceId) + ? string.IsNullOrEmpty(previousReferenceId) ? ("No match found", "neutral") : ("Unlinked - no match", "danger") + : refreshed.ReferenceId == previousReferenceId ? ("Already linked", "neutral") : ("Linked!", "success"); + } + catch (Exception ex) + { + _refreshMessage = $"Error: {ex.Message}"; + _refreshMessageStyle = "danger"; + } + finally + { + _refreshingReference = false; + } + + ScheduleRefreshMessageClear(); + } + + /// + /// The toast is a small, auto-dismissing pill next to the refresh icon, not a permanent line of text - + /// a token guards against an in-flight delay from a previous click clearing a newer message. + /// + private void ScheduleRefreshMessageClear() + { + var token = new object(); + _refreshMessageToken = token; + _ = ClearRefreshMessageAfterDelayAsync(token); + } + + private async Task ClearRefreshMessageAfterDelayAsync(object token) + { + await Task.Delay(RefreshMessageDuration); + if (!ReferenceEquals(_refreshMessageToken, token)) return; + _refreshMessage = null; + await InvokeAsync(StateHasChanged); + } + + private async Task SaveYearAsync(ChangeEventArgs e) + { + if (_movie is null) return; + _movie.Year = int.TryParse(e.Value?.ToString(), out var year) ? year : null; + await MovieApi.UpdateAsync(_movie); + } + + private async Task SaveNotesAsync(ChangeEventArgs e) + { + if (_movie is null) return; + _movie.Notes = e.Value?.ToString(); + await MovieApi.UpdateAsync(_movie); + } +} diff --git a/src/BlazorApp/Components/Inventory/Pages/Movies.razor b/src/BlazorApp/Components/Inventory/Pages/Movies.razor index 5363faed..6a6cd592 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Movies.razor +++ b/src/BlazorApp/Components/Inventory/Pages/Movies.razor @@ -5,6 +5,7 @@ + + + + + Title Year Rating First seen + - @movie.Title + + @movie.Title + @(movie.Year > 0 ? movie.Year : "") @movie.FirstSeenAt?.ToString("yyyy-MM-dd") + + @if (movie.IsFavorite) + { + + } + @if (movie.WantToWatch) + { + + } + @if (movie.IsOwned) + { + + } + @if (movie.IsWishlisted) + { + + } + - - - - - - -
- +
- +
- - + + +
+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + + + +
+
+ + +
+ @if (!string.IsNullOrEmpty(movie.ReferenceId)) + { +
+

Year is matched to a reference source and can't be edited here.

+
+ }
- +
@@ -71,5 +129,24 @@
+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
diff --git a/src/BlazorApp/Components/Inventory/Pages/Movies.razor.cs b/src/BlazorApp/Components/Inventory/Pages/Movies.razor.cs index e60a55aa..554bdf9f 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Movies.razor.cs +++ b/src/BlazorApp/Components/Inventory/Pages/Movies.razor.cs @@ -9,16 +9,57 @@ public partial class Movies : InventoryPageBase protected override InventoryApiClientBase Api => MovieApi; + private bool _favoriteFilter; + + private bool _ownedFilter; + + private bool _wishlistedFilter; + + protected override IReadOnlyDictionary? ExtraQuery + { + get + { + var query = new Dictionary(); + if (_favoriteFilter) query["IsFavorite"] = "true"; + if (_ownedFilter) query["IsOwned"] = "true"; + if (_wishlistedFilter) query["IsWishlisted"] = "true"; + return query.Count > 0 ? query : null; + } + } + + private async Task ToggleFavoriteFilterAsync() + { + _favoriteFilter = !_favoriteFilter; + _page = 1; + await LoadAsync(); + } + + private async Task ToggleOwnedFilterAsync() + { + _ownedFilter = !_ownedFilter; + _page = 1; + await LoadAsync(); + } + + private async Task ToggleWishlistedFilterAsync() + { + _wishlistedFilter = !_wishlistedFilter; + _page = 1; + await LoadAsync(); + } + protected override MovieDto CloneItem(MovieDto item) => new() { Id = item.Id, Title = item.Title, Year = item.Year, - Genre = item.Genre, Rating = item.Rating, Notes = item.Notes, FirstSeenAt = item.FirstSeenAt, - AllocineId = item.AllocineId, - ImdbPageId = item.ImdbPageId + ReferenceId = item.ReferenceId, + IsFavorite = item.IsFavorite, + WantToWatch = item.WantToWatch, + IsOwned = item.IsOwned, + IsWishlisted = item.IsWishlisted }; } diff --git a/src/BlazorApp/Components/Inventory/Pages/MusicAlbums.razor.cs b/src/BlazorApp/Components/Inventory/Pages/MusicAlbums.razor.cs deleted file mode 100644 index 8ff0ed0b..00000000 --- a/src/BlazorApp/Components/Inventory/Pages/MusicAlbums.razor.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Keeptrack.WebApi.Contracts.Dto; -using Microsoft.AspNetCore.Components; - -namespace Keeptrack.BlazorApp.Components.Inventory.Pages; - -public partial class MusicAlbums : InventoryPageBase -{ - [Inject] private MusicAlbumApiClient MusicAlbumApi { get; set; } = null!; - - protected override InventoryApiClientBase Api => MusicAlbumApi; - - protected override MusicAlbumDto CloneItem(MusicAlbumDto item) => new() - { - Id = item.Id, - Title = item.Title, - Artist = item.Artist, - Genre = item.Genre, - Year = item.Year, - Rating = item.Rating - }; -} diff --git a/src/BlazorApp/Components/Inventory/Pages/PlaylistDetail.razor b/src/BlazorApp/Components/Inventory/Pages/PlaylistDetail.razor new file mode 100644 index 00000000..db62e007 --- /dev/null +++ b/src/BlazorApp/Components/Inventory/Pages/PlaylistDetail.razor @@ -0,0 +1,217 @@ +@page "/playlists/{Id}" +@rendermode InteractiveServer +@attribute [Authorize] +@using Keeptrack.BlazorApp.Components.ReferenceData + +@if (_loading) +{ +
+
+ Loading… +
+} +else if (_playlist is null) +{ +
+
+

Playlist not found.

+
+} +else +{ +
+
+ +
+
+ +

Songs

+ + @if (PlaylistSongs.Count == 0) + { +

No songs yet - add one from an album below.

+ } + else + { +
+ @foreach (var (song, index) in PlaylistSongs) + { +
+ @song.Title + @song.Artist + + @if (!string.IsNullOrEmpty(song.AlbumId) && _albumTitlesById.TryGetValue(song.AlbumId, out var albumTitle)) + { + @albumTitle + } + +
+ + + +
+
+ } +
+ } + +
+ + + + @if (!string.IsNullOrEmpty(_selectedAlbumId)) + { + @if (_selectedAlbumTracks.Count == 0) + { +

+ No tracklist available - link this album to Discogs first. +

+ } + else + { +
+ @foreach (var track in _selectedAlbumTracks) + { +
+ @track.Position @track.Title +
+ @track.Duration + +
+
+ } +
+ } + } +
+} + +@code { + [Parameter] public required string Id { get; set; } + + [Inject] private PlaylistApiClient PlaylistApi { get; set; } = null!; + + [Inject] private SongApiClient SongApi { get; set; } = null!; + + [Inject] private AlbumApiClient AlbumApi { get; set; } = null!; + + [Inject] private ReferenceDataApiClient ReferenceDataApi { get; set; } = null!; + + private bool _loading = true; + private PlaylistDto? _playlist; + private List _albums = []; + private Dictionary _songsById = []; + private Dictionary _albumTitlesById = []; + private string _selectedAlbumId = ""; + private List _selectedAlbumTracks = []; + + /// + /// Every song in the playlist paired with its position - a dangling SongId (the song was + /// deleted separately) is silently skipped rather than shown as a broken row, the same soft-reference + /// tolerance already used for a missing reference-link elsewhere in the app. + /// + private List<(SongDto Song, int Index)> PlaylistSongs => + _playlist is null + ? [] + : _playlist.SongIds + .Select((id, index) => (Song: _songsById.GetValueOrDefault(id), Index: index)) + .Where(x => x.Song is not null) + .Select(x => (x.Song!, x.Index)) + .ToList(); + + protected override async Task OnParametersSetAsync() => await LoadAsync(); + + private async Task LoadAsync() + { + _loading = true; + _playlist = await PlaylistApi.GetOneAsync(Id); + var songs = await SongApi.GetAsync("", 1, 5000); + _songsById = songs.Items.Where(s => s.Id is not null).ToDictionary(s => s.Id!, s => s); + var albums = await AlbumApi.GetAsync("", 1, 5000); + _albums = albums.Items; + _albumTitlesById = _albums.Where(a => a.Id is not null).ToDictionary(a => a.Id!, a => a.Title ?? ""); + _loading = false; + } + + private async Task SavePlaylistAsync() + { + if (_playlist is null) return; + await PlaylistApi.UpdateAsync(_playlist); + } + + private async Task SaveTitleAsync(ChangeEventArgs e) + { + if (_playlist is null) return; + var title = e.Value?.ToString(); + if (string.IsNullOrWhiteSpace(title)) return; + _playlist.Title = title; + await SavePlaylistAsync(); + } + + private async Task MoveUpAsync(int index) + { + if (_playlist is null || index <= 0) return; + (_playlist.SongIds[index - 1], _playlist.SongIds[index]) = (_playlist.SongIds[index], _playlist.SongIds[index - 1]); + await SavePlaylistAsync(); + } + + private async Task MoveDownAsync(int index) + { + if (_playlist is null || index >= _playlist.SongIds.Count - 1) return; + (_playlist.SongIds[index], _playlist.SongIds[index + 1]) = (_playlist.SongIds[index + 1], _playlist.SongIds[index]); + await SavePlaylistAsync(); + } + + private async Task RemoveSongAsync(int index) + { + if (_playlist is null) return; + _playlist.SongIds.RemoveAt(index); + await SavePlaylistAsync(); + } + + private async Task SelectAlbumAsync(ChangeEventArgs e) + { + _selectedAlbumId = e.Value?.ToString() ?? ""; + await LoadSelectedAlbumTracksAsync(); + } + + private async Task LoadSelectedAlbumTracksAsync() + { + _selectedAlbumTracks = []; + if (string.IsNullOrEmpty(_selectedAlbumId)) return; + + var album = _albums.FirstOrDefault(a => a.Id == _selectedAlbumId); + if (string.IsNullOrEmpty(album?.ReferenceId)) return; + + var reference = await ReferenceDataApi.GetAlbumAsync(album.ReferenceId); + _selectedAlbumTracks = reference?.Tracks ?? []; + } + + /// + /// The shared "add a real track" operation () - + /// reuses the same if this exact track was already added elsewhere, instead of + /// creating a duplicate. + /// + private async Task AddTrackAsync(ReferenceTrackDto track) + { + if (_playlist is null) return; + var album = _albums.FirstOrDefault(a => a.Id == _selectedAlbumId); + if (album?.Id is null) return; + + var song = await SongApi.GetOrCreateForTrackAsync(album.Id, track.Position, track.Title, track.Duration, album.Artist); + if (song.Id is null) return; + + _songsById[song.Id] = song; + if (!_playlist.SongIds.Contains(song.Id)) + { + _playlist.SongIds.Add(song.Id); + await SavePlaylistAsync(); + } + } +} diff --git a/src/BlazorApp/Components/Inventory/Pages/Playlists.razor b/src/BlazorApp/Components/Inventory/Pages/Playlists.razor new file mode 100644 index 00000000..790cb5fc --- /dev/null +++ b/src/BlazorApp/Components/Inventory/Pages/Playlists.razor @@ -0,0 +1,51 @@ +@page "/playlists" +@inherits InventoryPageBase +@rendermode InteractiveServer +@attribute [Authorize] + + + + Title + Songs + + + + @playlist.Title + + @playlist.SongIds.Count + + + + + +
+ + +
+

Manage songs from the playlist's detail page.

+
+
diff --git a/src/BlazorApp/Components/Inventory/Pages/Playlists.razor.cs b/src/BlazorApp/Components/Inventory/Pages/Playlists.razor.cs new file mode 100644 index 00000000..893e98c0 --- /dev/null +++ b/src/BlazorApp/Components/Inventory/Pages/Playlists.razor.cs @@ -0,0 +1,48 @@ +using Keeptrack.WebApi.Contracts.Dto; +using Microsoft.AspNetCore.Components; + +namespace Keeptrack.BlazorApp.Components.Inventory.Pages; + +public partial class Playlists : InventoryPageBase +{ + [Inject] private PlaylistApiClient PlaylistApi { get; set; } = null!; + + [Inject] private NavigationManager Nav { get; set; } = null!; + + protected override InventoryApiClientBase Api => PlaylistApi; + + /// + /// Creating a playlist only ever captures Title here (see FormTemplate) - songs are added on + /// the detail page, so a successful create navigates straight there instead of closing the form and + /// staying on the list. Same rationale as VideoGames.razor.cs's override. + /// + protected override async Task SaveAsync() + { + try + { + if (_form.Id is null) + { + var created = await PlaylistApi.AddAsync(_form); + _showForm = false; + Nav.NavigateTo($"/playlists/{created.Id}"); + } + else + { + await PlaylistApi.UpdateAsync(_form); + _showForm = false; + await LoadAsync(); + } + } + catch (Exception ex) + { + _error = ex.Message; + } + } + + protected override PlaylistDto CloneItem(PlaylistDto item) => new() + { + Id = item.Id, + Title = item.Title, + SongIds = [.. item.SongIds] + }; +} diff --git a/src/BlazorApp/Components/Inventory/Pages/TvShowDetail.razor b/src/BlazorApp/Components/Inventory/Pages/TvShowDetail.razor new file mode 100644 index 00000000..787a033f --- /dev/null +++ b/src/BlazorApp/Components/Inventory/Pages/TvShowDetail.razor @@ -0,0 +1,470 @@ +@page "/tv-shows/{Id}" +@rendermode InteractiveServer +@attribute [Authorize] +@using Keeptrack.BlazorApp.Components.ReferenceData +@using Keeptrack.BlazorApp.Components.ReferenceDataAdmin + +@if (_loading) +{ +
+
+ Loading… +
+} +else if (_show is null) +{ +
+
+

Show not found.

+
+} +else +{ +
+
+ + + @if (_refreshMessage is not null) + { + @_refreshMessage + } +
+
+ + + + +
+
+ +
+ + + +
+ + @if (string.IsNullOrEmpty(_show.ReferenceId)) + { + + + + + + } + +
+
+ @if (!string.IsNullOrEmpty(_reference?.ImageUrl)) + { +
+ @_show.Title poster +
+ } +
+
+ + +
+
+ + +
+ @if (_reference?.Genres.Count > 0) + { +

@string.Join(", ", _reference.Genres)

+ } +
+
+ + +
+
+ @if (!string.IsNullOrEmpty(_reference?.Synopsis)) + { +
+

@_reference.Synopsis

+ } +
+ + @if (_reference?.Cast.Count > 0) + { +

Cast

+ + } + + @if (_reference is not null) + { + @if (_seasons.Count == 0) + { +
+
+

No episode data available for this show yet.

+
+ } + else + { +

Episodes

+
+ @foreach (var season in _seasons) + { + + } +
+ +
+
+ Season @_selectedSeason + +
+ @foreach (var episode in _referenceEpisodesBySeason[_selectedSeason]) + { + var watched = _watchedByKey.GetValueOrDefault((episode.SeasonNumber, episode.EpisodeNumber)); +
+ + E@(episode.EpisodeNumber.ToString("00")) + @episode.Title + @watched?.WatchedAt?.ToString("yyyy-MM-dd") +
+ } +
+ } + @if (_error is not null) + { +
@_error
+ } + } + else + { +

Add episode watched

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ @if (_error is not null) + { +
@_error
+ } +
+ + @if (_unresolvedSeasons.Count == 0) + { +
+
+

No episodes tracked yet. This show hasn't been matched to a reference source yet, so the full episode list isn't available - add episodes manually above.

+
+ } + else + { +
+ @foreach (var season in _unresolvedSeasons) + { + + } +
+ +
+
Season @_selectedSeason
+ @foreach (var episode in _unresolvedEpisodesBySeason[_selectedSeason]) + { +
+ E@(episode.EpisodeNumber.ToString("00")) + @episode.Notes + @episode.WatchedAt?.ToString("yyyy-MM-dd") +
+ } +
+ } + } +} + +@code { + [Parameter] public required string Id { get; set; } + + [Inject] private TvShowApiClient TvShowApi { get; set; } = null!; + + [Inject] private EpisodeApiClient EpisodeApi { get; set; } = null!; + + [Inject] private ReferenceDataApiClient ReferenceDataApi { get; set; } = null!; + + private bool _loading = true; + private TvShowDto? _show; + private TvShowReferenceDto? _reference; + private Dictionary<(int Season, int Episode), EpisodeDto> _watchedByKey = new(); + private Dictionary> _referenceEpisodesBySeason = new(); + private List _seasons = []; + private Dictionary> _unresolvedEpisodesBySeason = new(); + private List _unresolvedSeasons = []; + private int _selectedSeason; + private string? _error; + private static readonly TimeSpan RefreshMessageDuration = TimeSpan.FromSeconds(4); + private bool _refreshingReference; + private string? _refreshMessage; + private string _refreshMessageStyle = "neutral"; + private object? _refreshMessageToken; + + private int _newSeason = 1; + private int _newEpisode = 1; + private DateOnly? _newWatchedAt = DateOnly.FromDateTime(DateTime.Today); + + protected override async Task OnParametersSetAsync() => await LoadAsync(); + + private async Task LoadAsync() + { + _loading = true; + _show = await TvShowApi.GetOneAsync(Id); + var episodes = await EpisodeApi.GetAsync(string.Empty, 1, 5000, new Dictionary { ["TvShowId"] = Id }); + + // reference data (synopsis, real episode titles, cast, poster) is a progressive enhancement: it + // may not exist yet (background match still pending, or genuinely unresolved), so the page never + // blocks on it - see the two very different rendering branches below. + _reference = string.IsNullOrEmpty(_show?.ReferenceId) ? null : await ReferenceDataApi.GetTvShowAsync(_show.ReferenceId); + _watchedByKey = episodes.Items.ToDictionary(e => (e.SeasonNumber, e.EpisodeNumber)); + + if (_reference is not null) + { + // full checklist, in natural watch-through order - an episode TMDB lists with a future air date + // hasn't aired yet, so it isn't "unseen and ready to watch", it doesn't exist yet from the + // viewer's perspective (same air-date filter WatchNextService applies for the "next episode" calc) + var today = DateOnly.FromDateTime(DateTime.Today); + _referenceEpisodesBySeason = _reference.Episodes + .Where(e => e.AirDate is null || e.AirDate <= today) + .GroupBy(e => e.SeasonNumber) + .ToDictionary(g => g.Key, g => g.OrderBy(e => e.EpisodeNumber).ToList()); + _seasons = _referenceEpisodesBySeason.Keys.OrderByDescending(s => s).ToList(); + if (!_seasons.Contains(_selectedSeason)) _selectedSeason = _seasons.Count > 0 ? _seasons[0] : 0; + } + else + { + // unresolved: only what's actually been recorded, most recently aired first + _unresolvedEpisodesBySeason = episodes.Items + .GroupBy(e => e.SeasonNumber) + .ToDictionary(g => g.Key, g => g.OrderByDescending(e => e.EpisodeNumber).ToList()); + _unresolvedSeasons = _unresolvedEpisodesBySeason.Keys.OrderByDescending(s => s).ToList(); + if (!_unresolvedSeasons.Contains(_selectedSeason)) _selectedSeason = _unresolvedSeasons.Count > 0 ? _unresolvedSeasons[0] : 0; + } + + _loading = false; + } + + private void SelectSeason(int season) => _selectedSeason = season; + + private async Task ToggleFavoriteAsync() + { + if (_show is null) return; + _show.IsFavorite = !_show.IsFavorite; + await TvShowApi.UpdateAsync(_show); + } + + private async Task ToggleWantToWatchAsync() + { + if (_show is null) return; + _show.WantToWatch = !_show.WantToWatch; + await TvShowApi.UpdateAsync(_show); + } + + private async Task ToggleOwnedAsync() + { + if (_show is null) return; + _show.IsOwned = !_show.IsOwned; + await TvShowApi.UpdateAsync(_show); + } + + private async Task ToggleWishlistedAsync() + { + if (_show is null) return; + _show.IsWishlisted = !_show.IsWishlisted; + await TvShowApi.UpdateAsync(_show); + } + + private async Task SetRatingAsync(float rating) + { + if (_show is null) return; + _show.Rating = rating; + await TvShowApi.UpdateAsync(_show); + } + + /// + /// Clicking the already-active state button clears it back to unset - otherwise sets it. Explicit + /// buttons instead of a click-to-cycle flag: all three options are visible and directly selectable, + /// nothing to guess about what a click will do. + /// + private async Task SetStateAsync(TvShowStatus state) + { + if (_show is null) return; + _show.State = _show.State == state ? null : state; + await TvShowApi.UpdateAsync(_show); + } + + private async Task SaveTitleAsync(ChangeEventArgs e) + { + if (_show is null) return; + var title = e.Value?.ToString(); + if (string.IsNullOrWhiteSpace(title)) return; + _show.Title = title; + await TvShowApi.UpdateAsync(_show); + } + + private async Task RefreshReferenceAsync() + { + if (_show?.Id is null) return; + + var previousReferenceId = _show.ReferenceId; + _refreshingReference = true; + _refreshMessage = null; + try + { + var refreshed = await TvShowApi.RefreshReferenceAsync(_show.Id); + await LoadAsync(); + + (_refreshMessage, _refreshMessageStyle) = string.IsNullOrEmpty(refreshed.ReferenceId) + ? string.IsNullOrEmpty(previousReferenceId) ? ("No match found", "neutral") : ("Unlinked - no match", "danger") + : refreshed.ReferenceId == previousReferenceId ? ("Already linked", "neutral") : ("Linked!", "success"); + } + catch (Exception ex) + { + _refreshMessage = $"Error: {ex.Message}"; + _refreshMessageStyle = "danger"; + } + finally + { + _refreshingReference = false; + } + + ScheduleRefreshMessageClear(); + } + + /// + /// The toast is a small, auto-dismissing pill next to the refresh icon, not a permanent line of text - + /// a token guards against an in-flight delay from a previous click clearing a newer message. + /// + private void ScheduleRefreshMessageClear() + { + var token = new object(); + _refreshMessageToken = token; + _ = ClearRefreshMessageAfterDelayAsync(token); + } + + private async Task ClearRefreshMessageAfterDelayAsync(object token) + { + await Task.Delay(RefreshMessageDuration); + if (!ReferenceEquals(_refreshMessageToken, token)) return; + _refreshMessage = null; + await InvokeAsync(StateHasChanged); + } + + private async Task SaveYearAsync(ChangeEventArgs e) + { + if (_show is null) return; + _show.Year = int.TryParse(e.Value?.ToString(), out var year) ? year : null; + await TvShowApi.UpdateAsync(_show); + } + + private async Task SaveNotesAsync(ChangeEventArgs e) + { + if (_show is null) return; + _show.Notes = e.Value?.ToString(); + await TvShowApi.UpdateAsync(_show); + } + + private bool IsSelectedSeasonFullyWatched() => + _referenceEpisodesBySeason.TryGetValue(_selectedSeason, out var episodes) && episodes.Count > 0 + && episodes.All(e => _watchedByKey.ContainsKey((e.SeasonNumber, e.EpisodeNumber))); + + private async Task ToggleEpisodeAsync(ReferenceEpisodeDto episode, bool isWatched) + { + try + { + await SetEpisodeWatchedAsync(episode, isWatched); + _error = null; + await LoadAsync(); + } + catch (Exception ex) + { + _error = ex.Message; + } + } + + private async Task ToggleSeasonAsync(bool markWatched) + { + if (!_referenceEpisodesBySeason.TryGetValue(_selectedSeason, out var episodes)) return; + + try + { + foreach (var episode in episodes) + { + await SetEpisodeWatchedAsync(episode, markWatched); + } + + _error = null; + await LoadAsync(); + } + catch (Exception ex) + { + _error = ex.Message; + } + } + + private async Task SetEpisodeWatchedAsync(ReferenceEpisodeDto episode, bool isWatched) + { + var key = (episode.SeasonNumber, episode.EpisodeNumber); + var existing = _watchedByKey.GetValueOrDefault(key); + + if (isWatched && existing is null) + { + await EpisodeApi.AddAsync(new EpisodeDto + { + TvShowId = Id, + SeasonNumber = episode.SeasonNumber, + EpisodeNumber = episode.EpisodeNumber, + WatchedAt = DateOnly.FromDateTime(DateTime.Today) + }); + } + else if (!isWatched && existing?.Id is not null) + { + await EpisodeApi.DeleteAsync(existing.Id); + } + } + + private async Task AddEpisodeAsync() + { + try + { + await EpisodeApi.AddAsync(new EpisodeDto + { + TvShowId = Id, + SeasonNumber = _newSeason, + EpisodeNumber = _newEpisode, + WatchedAt = _newWatchedAt + }); + _error = null; + await LoadAsync(); + } + catch (Exception ex) + { + _error = ex.Message; + } + } +} diff --git a/src/BlazorApp/Components/Inventory/Pages/TvShows.razor b/src/BlazorApp/Components/Inventory/Pages/TvShows.razor index c963dbab..cb68eabe 100644 --- a/src/BlazorApp/Components/Inventory/Pages/TvShows.razor +++ b/src/BlazorApp/Components/Inventory/Pages/TvShows.razor @@ -1,10 +1,11 @@ -@page "/tv-shows" +@page "/tv-shows" @inherits InventoryPageBase @rendermode InteractiveServer @attribute [Authorize] + + + + + + + + + Title - Last episode seen + State Rating + - @show.Title - @show.LastEpisodeSeen + + @show.Title + + + @if (show.State is not null) + { + @show.State + } + + + @if (show.IsFavorite) + { + + } + @if (show.WantToWatch) + { + + } + @if (show.IsOwned) + { + + } + @if (show.IsWishlisted) + { + + } + - - - +
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
- + + + +
- +
+ @if (!string.IsNullOrEmpty(show.ReferenceId)) + { +
+

Year is matched to a reference source and can't be edited here.

+
+ }
- +
- - + +
- - + +
+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
diff --git a/src/BlazorApp/Components/Inventory/Pages/TvShows.razor.cs b/src/BlazorApp/Components/Inventory/Pages/TvShows.razor.cs index 471c5e09..4e4f9788 100644 --- a/src/BlazorApp/Components/Inventory/Pages/TvShows.razor.cs +++ b/src/BlazorApp/Components/Inventory/Pages/TvShows.razor.cs @@ -1,4 +1,4 @@ -using Keeptrack.WebApi.Contracts.Dto; +using Keeptrack.WebApi.Contracts.Dto; using Microsoft.AspNetCore.Components; namespace Keeptrack.BlazorApp.Components.Inventory.Pages; @@ -9,16 +9,68 @@ public partial class TvShows : InventoryPageBase protected override InventoryApiClientBase Api => TvShowApi; + private TvShowStatus? _stateFilter; + + private bool _favoriteFilter; + + private bool _ownedFilter; + + private bool _wishlistedFilter; + + protected override IReadOnlyDictionary? ExtraQuery + { + get + { + var query = new Dictionary(); + if (_stateFilter is not null) query["State"] = _stateFilter.ToString()!; + if (_favoriteFilter) query["IsFavorite"] = "true"; + if (_ownedFilter) query["IsOwned"] = "true"; + if (_wishlistedFilter) query["IsWishlisted"] = "true"; + return query.Count > 0 ? query : null; + } + } + + private async Task SetStateFilterAsync(TvShowStatus? state) + { + _stateFilter = state; + _page = 1; + await LoadAsync(); + } + + private async Task ToggleFavoriteFilterAsync() + { + _favoriteFilter = !_favoriteFilter; + _page = 1; + await LoadAsync(); + } + + private async Task ToggleOwnedFilterAsync() + { + _ownedFilter = !_ownedFilter; + _page = 1; + await LoadAsync(); + } + + private async Task ToggleWishlistedFilterAsync() + { + _wishlistedFilter = !_wishlistedFilter; + _page = 1; + await LoadAsync(); + } + protected override TvShowDto CloneItem(TvShowDto item) => new() { Id = item.Id, Title = item.Title, Rating = item.Rating, - AllocineId = item.AllocineId, - ImdbPageId = item.ImdbPageId, + ReferenceId = item.ReferenceId, Notes = item.Notes, - FinishedAt = item.FinishedAt, + State = item.State, LastEpisodeSeen = item.LastEpisodeSeen, - Year = item.Year + Year = item.Year, + IsFavorite = item.IsFavorite, + WantToWatch = item.WantToWatch, + IsOwned = item.IsOwned, + IsWishlisted = item.IsWishlisted }; } diff --git a/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor b/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor new file mode 100644 index 00000000..4af24c67 --- /dev/null +++ b/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor @@ -0,0 +1,360 @@ +@page "/video-games/{Id}" +@rendermode InteractiveServer +@attribute [Authorize] +@using Keeptrack.BlazorApp.Components.ReferenceData +@using Keeptrack.BlazorApp.Components.ReferenceDataAdmin + +@if (_loading) +{ +
+
+ Loading… +
+} +else if (_game is null) +{ +
+
+

Video game not found.

+
+} +else +{ +
+
+ + + @if (_refreshMessage is not null) + { + @_refreshMessage + } +
+
+ + +
+
+ + @if (string.IsNullOrEmpty(_game.ReferenceId)) + { + + + + + + } + + @if (!string.IsNullOrEmpty(_reference?.ImageUrl)) + { +
+ @_game.Title cover +
+ } + +
+
+
+
+ + +
+
+ + +
+ @if (_reference?.Platforms.Count > 0) + { +

Available on: @string.Join(", ", _reference.Platforms)

+ } + @if (_reference?.Genres.Count > 0) + { +

@string.Join(", ", _reference.Genres)

+ } +
+
+ + +
+
+ @if (!string.IsNullOrEmpty(_reference?.Synopsis)) + { +
+

@_reference.Synopsis

+ } +
+ +

Platforms

+ + @if (_game.Platforms.Count == 0) + { +

No platforms tracked yet - add one below.

+ } + + @foreach (var entry in _game.Platforms) + { +
+
+
+

@entry.Platform

+
+ + +
+
+ +
+ +
+ @foreach (var state in VideoGames.VideoGameStates) + { + + } +
+ +
+ + @if (entry.IsFullyCompleted) + { + + } +
+ +
+ + @foreach (var playthrough in entry.Playthroughs) + { +
+ + + +
+ } + +
+
+ } + +
+ +
+ + + + +
+
+} + +@code { + [Parameter] public required string Id { get; set; } + + [Inject] private VideoGameApiClient VideoGameApi { get; set; } = null!; + + [Inject] private ReferenceDataApiClient ReferenceDataApi { get; set; } = null!; + + private static readonly TimeSpan RefreshMessageDuration = TimeSpan.FromSeconds(4); + + private bool _loading = true; + private VideoGameDto? _game; + private VideoGameReferenceDto? _reference; + private bool _refreshingReference; + private string? _refreshMessage; + private string _refreshMessageStyle = "neutral"; + private object? _refreshMessageToken; + private string _newPlatform = ""; + private VideoGameCopyType _newCopyType; + + protected override async Task OnParametersSetAsync() => await LoadAsync(); + + private async Task LoadAsync() + { + _loading = true; + _game = await VideoGameApi.GetOneAsync(Id); + _reference = string.IsNullOrEmpty(_game?.ReferenceId) ? null : await ReferenceDataApi.GetVideoGameAsync(_game.ReferenceId); + _loading = false; + } + + /// Every mutation on this page follows the same shape: mutate _game locally, then PUT + /// the whole DTO - shared here since the per-platform/per-playthrough editors below call it often. + private async Task SaveGameAsync() + { + if (_game is null) return; + await VideoGameApi.UpdateAsync(_game); + } + + private async Task SetRatingAsync(float rating) + { + if (_game is null) return; + _game.Rating = rating; + await SaveGameAsync(); + } + + private async Task ToggleOwnedAsync() + { + if (_game is null) return; + _game.IsOwned = !_game.IsOwned; + await SaveGameAsync(); + } + + private async Task ToggleWishlistedAsync() + { + if (_game is null) return; + _game.IsWishlisted = !_game.IsWishlisted; + await SaveGameAsync(); + } + + private async Task SaveTitleAsync(ChangeEventArgs e) + { + if (_game is null) return; + var title = e.Value?.ToString(); + if (string.IsNullOrWhiteSpace(title)) return; + _game.Title = title; + await SaveGameAsync(); + } + + private async Task SaveYearAsync(ChangeEventArgs e) + { + if (_game is null) return; + _game.Year = int.TryParse(e.Value?.ToString(), out var year) ? year : null; + await SaveGameAsync(); + } + + private async Task SaveNotesAsync(ChangeEventArgs e) + { + if (_game is null) return; + _game.Notes = e.Value?.ToString(); + await SaveGameAsync(); + } + + private async Task AddPlatformAsync() + { + if (_game is null || string.IsNullOrEmpty(_newPlatform)) return; + _game.Platforms.Add(new VideoGamePlatformDto { Platform = _newPlatform, CopyType = _newCopyType }); + _newPlatform = ""; + _newCopyType = VideoGameCopyType.Physical; + await SaveGameAsync(); + } + + private async Task RemovePlatformAsync(VideoGamePlatformDto entry) + { + if (_game is null) return; + _game.Platforms.Remove(entry); + await SaveGameAsync(); + } + + private async Task SetCopyTypeAsync(VideoGamePlatformDto entry, VideoGameCopyType copyType) + { + entry.CopyType = copyType; + await SaveGameAsync(); + } + + /// + /// Clicking the already-active state button clears it back to unset - same toggle-off-on-reclick + /// pattern as TvShowDetail.razor's SetStatusAsync, now applied per platform entry. + /// + private async Task SetPlatformStateAsync(VideoGamePlatformDto entry, string state) + { + entry.State = entry.State == state ? "" : state; + await SaveGameAsync(); + } + + private async Task ToggleFullyCompletedAsync(VideoGamePlatformDto entry) + { + entry.IsFullyCompleted = !entry.IsFullyCompleted; + entry.FullyCompletedAt = entry.IsFullyCompleted ? entry.FullyCompletedAt ?? DateOnly.FromDateTime(DateTime.Today) : null; + await SaveGameAsync(); + } + + private async Task SetFullyCompletedDateAsync(VideoGamePlatformDto entry, ChangeEventArgs e) + { + entry.FullyCompletedAt = DateOnly.TryParse(e.Value?.ToString(), out var date) ? date : null; + await SaveGameAsync(); + } + + private async Task AddPlaythroughAsync(VideoGamePlatformDto entry) + { + entry.Playthroughs.Add(new PlaythroughDto { Label = "" }); + await SaveGameAsync(); + } + + private async Task RemovePlaythroughAsync(VideoGamePlatformDto entry, PlaythroughDto playthrough) + { + entry.Playthroughs.Remove(playthrough); + await SaveGameAsync(); + } + + private async Task UpdatePlaythroughLabelAsync(PlaythroughDto playthrough, ChangeEventArgs e) + { + playthrough.Label = e.Value?.ToString() ?? ""; + await SaveGameAsync(); + } + + private async Task UpdatePlaythroughDateAsync(PlaythroughDto playthrough, ChangeEventArgs e) + { + playthrough.CompletedAt = DateOnly.TryParse(e.Value?.ToString(), out var date) ? date : null; + await SaveGameAsync(); + } + + private async Task RefreshReferenceAsync() + { + if (_game?.Id is null) return; + + var previousReferenceId = _game.ReferenceId; + _refreshingReference = true; + _refreshMessage = null; + try + { + var refreshed = await VideoGameApi.RefreshReferenceAsync(_game.Id); + await LoadAsync(); + + (_refreshMessage, _refreshMessageStyle) = string.IsNullOrEmpty(refreshed.ReferenceId) + ? string.IsNullOrEmpty(previousReferenceId) ? ("No match found", "neutral") : ("Unlinked - no match", "danger") + : refreshed.ReferenceId == previousReferenceId ? ("Already linked", "neutral") : ("Linked!", "success"); + } + catch (Exception ex) + { + _refreshMessage = $"Error: {ex.Message}"; + _refreshMessageStyle = "danger"; + } + finally + { + _refreshingReference = false; + } + + ScheduleRefreshMessageClear(); + } + + /// + /// The toast is a small, auto-dismissing pill next to the refresh icon, not a permanent line of text - + /// a token guards against an in-flight delay from a previous click clearing a newer message. + /// + private void ScheduleRefreshMessageClear() + { + var token = new object(); + _refreshMessageToken = token; + _ = ClearRefreshMessageAfterDelayAsync(token); + } + + private async Task ClearRefreshMessageAfterDelayAsync(object token) + { + await Task.Delay(RefreshMessageDuration); + if (!ReferenceEquals(_refreshMessageToken, token)) return; + _refreshMessage = null; + await InvokeAsync(StateHasChanged); + } +} diff --git a/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor b/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor index 721abfd2..321ddfef 100644 --- a/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor +++ b/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor @@ -5,6 +5,7 @@ + + + @foreach (var state in VideoGameStates) + { + + } + + + Title - Platform - State + Platforms Rating - Finished + - @game.Title - @game.Platform - @game.State + + @game.Title + + + @foreach (var entry in game.Platforms) + { + + @entry.Platform@(string.IsNullOrEmpty(entry.State) ? "" : $" ({entry.State})") + + } + - @game.FinishedAt?.ToString("yyyy-MM-dd") + + @if (game.IsOwned) + { + + } + @if (game.IsWishlisted) + { + + } + - - - - +
- -
-
- - -
-
- - + + + +
@@ -105,13 +87,21 @@
-
- - -
-
+
+
+ +
+ + +
+
+ + +
+
+

Manage platforms, state and playthroughs from the game's detail page.

diff --git a/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor.cs b/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor.cs index c5484b3f..fbdd569f 100644 --- a/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor.cs +++ b/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor.cs @@ -1,23 +1,117 @@ -using Keeptrack.WebApi.Contracts.Dto; +using Keeptrack.WebApi.Contracts.Dto; using Microsoft.AspNetCore.Components; namespace Keeptrack.BlazorApp.Components.Inventory.Pages; public partial class VideoGames : InventoryPageBase { + /// Shared by the list's Filters buttons, the detail page's per-platform State buttons, and + /// the (now-removed from the list page) Add/Edit forms' old State button group. + internal static readonly string[] VideoGameStates = ["Available", "Current", "Completed", "To resume", "On-hold"]; + + /// Shared by the detail page's platform picker - previously duplicated as a literal + /// <select> in three places (list add form, list edit modal, detail page). + internal static readonly string[] VideoGamePlatforms = + ["Xbox Series X", "PS5", "PC", "Xbox One X", "PS4", "WII", "Xbox 360", "PS2", "PS1"]; + + /// + /// The kt-status-badge modifier class for a state value (see app.css) - same badge/color + /// pattern as TvShows.razor's status column, sharing its "current" modifier for the identical + /// in-progress meaning and adding the three states with no TV show equivalent. + /// + internal static string StateBadgeClass(string state) => state.ToLowerInvariant().Replace(" ", "-"); + [Inject] private VideoGameApiClient VideoGameApi { get; set; } = null!; + [Inject] private NavigationManager Nav { get; set; } = null!; + protected override InventoryApiClientBase Api => VideoGameApi; + private string? _stateFilter; + + private bool _ownedFilter; + + private bool _wishlistedFilter; + + protected override IReadOnlyDictionary? ExtraQuery + { + get + { + var query = new Dictionary(); + if (!string.IsNullOrEmpty(_stateFilter)) query["State"] = _stateFilter; + if (_ownedFilter) query["IsOwned"] = "true"; + if (_wishlistedFilter) query["IsWishlisted"] = "true"; + return query.Count > 0 ? query : null; + } + } + + private async Task SetStateFilterAsync(string? state) + { + _stateFilter = state; + _page = 1; + await LoadAsync(); + } + + private async Task ToggleOwnedFilterAsync() + { + _ownedFilter = !_ownedFilter; + _page = 1; + await LoadAsync(); + } + + private async Task ToggleWishlistedFilterAsync() + { + _wishlistedFilter = !_wishlistedFilter; + _page = 1; + await LoadAsync(); + } + + /// + /// Creating a game only ever captures Title+Year here (see FormTemplate) - platforms, state + /// and playthroughs are added on the detail page, so a successful create navigates straight there + /// instead of closing the form and staying on the list. + /// + protected override async Task SaveAsync() + { + try + { + if (_form.Id is null) + { + var created = await VideoGameApi.AddAsync(_form); + _showForm = false; + Nav.NavigateTo($"/video-games/{created.Id}"); + } + else + { + await VideoGameApi.UpdateAsync(_form); + _showForm = false; + await LoadAsync(); + } + } + catch (Exception ex) + { + _error = ex.Message; + } + } + protected override VideoGameDto CloneItem(VideoGameDto item) => new() { Id = item.Id, Title = item.Title, - Platform = item.Platform, - State = item.State, - FinishedAt = item.FinishedAt, + Platforms = item.Platforms.Select(p => new VideoGamePlatformDto + { + Platform = p.Platform, + CopyType = p.CopyType, + State = p.State, + Playthroughs = p.Playthroughs.Select(pt => new PlaythroughDto { Label = pt.Label, CompletedAt = pt.CompletedAt }).ToList(), + IsFullyCompleted = p.IsFullyCompleted, + FullyCompletedAt = p.FullyCompletedAt + }).ToList(), Notes = item.Notes, Rating = item.Rating, - Year = item.Year + Year = item.Year, + ReferenceId = item.ReferenceId, + IsOwned = item.IsOwned, + IsWishlisted = item.IsWishlisted }; } diff --git a/src/BlazorApp/Components/Inventory/Shared/InventoryList.razor b/src/BlazorApp/Components/Inventory/Shared/InventoryList.razor index 7824411d..ef216aeb 100644 --- a/src/BlazorApp/Components/Inventory/Shared/InventoryList.razor +++ b/src/BlazorApp/Components/Inventory/Shared/InventoryList.razor @@ -26,10 +26,15 @@
} + + @if (ShowForm) {
-
New item
+
New @(ItemName ?? "item")
@FormTemplate(Form)
@@ -68,6 +73,10 @@ else { } + @if (Filters is not null) + { +
@Filters
+ }
@if (Items.Count > 0) { @@ -86,7 +95,7 @@ else
- +
@@ -154,6 +163,7 @@ else @code { private bool _initialized; + private string? _pendingDeleteId; private string _localSearch = ""; private bool _showModal; private bool _mouseDownOnBackdrop; @@ -178,6 +188,17 @@ else await OnClearSearch.InvokeAsync(); } + private void RequestDelete(TDto item) => _pendingDeleteId = item.Id; + + private void CancelDelete() => _pendingDeleteId = null; + + private async Task ConfirmDeleteAsync() + { + var id = _pendingDeleteId; + _pendingDeleteId = null; + if (id is not null) await OnDelete.InvokeAsync(id); + } + private async Task OpenEditModal(TDto item) { await OnStartInlineEdit.InvokeAsync(item); @@ -212,6 +233,7 @@ else } [Parameter] public required string Title { get; set; } + [Parameter] public string? ItemName { get; set; } [Parameter] public required List Items { get; set; } [Parameter] public required TDto Form { get; set; } [Parameter] public required TDto? EditingInline { get; set; } @@ -227,6 +249,7 @@ else [Parameter] public required RenderFragment RowTemplate { get; set; } [Parameter] public required RenderFragment FormTemplate { get; set; } [Parameter] public required RenderFragment EditModalTemplate { get; set; } + [Parameter] public RenderFragment? Filters { get; set; } [Parameter] public required EventCallback OnSave { get; set; } [Parameter] public required EventCallback OnCancelForm { get; set; } [Parameter] public required EventCallback OnShowAddForm { get; set; } diff --git a/src/BlazorApp/Components/Inventory/Shared/StarRating.razor b/src/BlazorApp/Components/Inventory/Shared/StarRating.razor index af9979db..4b6a5963 100644 --- a/src/BlazorApp/Components/Inventory/Shared/StarRating.razor +++ b/src/BlazorApp/Components/Inventory/Shared/StarRating.razor @@ -1,15 +1,25 @@ -@for (var i = 1; i <= 5; i++) +@for (var i = 1; i <= 5; i++) { - + var fillPercent = GetFillPercent(i); + + ★ + @if (fillPercent > 0) + { + + } + } @code { [Parameter] public float? Rating { get; set; } - private string GetStarClass(int position) + /// + /// How much of this star is filled, as a percentage - not just full/half/empty, so a precise + /// rating like 4.8 shows visually as the 5th star ~80% filled, not rounded down to a half-star look. + /// + private int GetFillPercent(int position) { - var rating = Rating ?? 0; - if (rating >= position) return "full"; - return rating >= position - 0.5f ? "half" : "empty"; + var fill = (Rating ?? 0) - (position - 1); + return (int)(Math.Clamp(fill, 0, 1) * 100); } } diff --git a/src/BlazorApp/Components/Inventory/Shared/StarRatingInput.razor b/src/BlazorApp/Components/Inventory/Shared/StarRatingInput.razor new file mode 100644 index 00000000..2491505d --- /dev/null +++ b/src/BlazorApp/Components/Inventory/Shared/StarRatingInput.razor @@ -0,0 +1,24 @@ +
+
+ +
+ +@code { + /// + /// Stars are an accurate live preview (see StarRating's percentage fill) - clicking a star can only + /// ever express a handful of fixed positions, nowhere near enough precision for an exact rating like + /// 4.8, so the number field next to it is the actual editing surface. + /// + [Parameter] public float? Rating { get; set; } + + [Parameter] public EventCallback RatingChanged { get; set; } + + private async Task HandleChangeAsync(ChangeEventArgs e) + { + if (float.TryParse(e.Value?.ToString(), out var value)) + { + await RatingChanged.InvokeAsync(MathF.Round(Math.Clamp(value, 0, 5), 1)); + } + } +} diff --git a/src/BlazorApp/Components/Layout/MainLayout.razor b/src/BlazorApp/Components/Layout/MainLayout.razor index e159d4bd..e35c2850 100644 --- a/src/BlazorApp/Components/Layout/MainLayout.razor +++ b/src/BlazorApp/Components/Layout/MainLayout.razor @@ -14,5 +14,5 @@
An unhandled error has occurred. Reload - 🗙 +
diff --git a/src/BlazorApp/Components/Layout/NavMenu.razor b/src/BlazorApp/Components/Layout/NavMenu.razor index 66a22a47..81bf3e5b 100644 --- a/src/BlazorApp/Components/Layout/NavMenu.razor +++ b/src/BlazorApp/Components/Layout/NavMenu.razor @@ -9,56 +9,107 @@ + +@code { + private static string GetInitial(string? name) => + string.IsNullOrEmpty(name) ? "" : name[..1].ToUpperInvariant(); +} diff --git a/src/BlazorApp/Components/Layout/ReconnectModal.razor.css b/src/BlazorApp/Components/Layout/ReconnectModal.razor.css index 3ad3773f..2e8e5936 100644 --- a/src/BlazorApp/Components/Layout/ReconnectModal.razor.css +++ b/src/BlazorApp/Components/Layout/ReconnectModal.razor.css @@ -21,13 +21,15 @@ #components-reconnect-modal { - background-color: white; + background-color: var(--kt-surface); + color: var(--kt-text); width: 20rem; margin: 20vh auto; padding: 2rem; - border: 0; - border-radius: 0.5rem; - box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3); + border: 1px solid var(--kt-border); + border-top: 2px solid var(--kt-accent); + border-radius: var(--kt-radius-lg); + box-shadow: var(--kt-shadow-lg); opacity: 0; transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete; animation: components-reconnect-modal-fadeOutOpacity 0.5s both; @@ -41,7 +43,7 @@ } #components-reconnect-modal::backdrop { - background-color: rgba(0, 0, 0, 0.4); + background-color: rgba(0, 0, 0, 0.5); animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out; opacity: 1; } @@ -86,22 +88,30 @@ #components-reconnect-modal p { margin: 0; text-align: center; + color: var(--kt-text-muted); + font-family: var(--kt-font); + font-size: 0.85rem; } #components-reconnect-modal button { - border: 0; - background-color: #6b9ed2; - color: white; - padding: 4px 24px; - border-radius: 4px; + border: 1px solid var(--kt-accent); + background-color: var(--kt-accent); + color: #fff; + font-family: var(--kt-font); + font-weight: 500; + padding: 0.4rem 1.2rem; + border-radius: 8px; + cursor: pointer; } #components-reconnect-modal button:hover { - background-color: #3b6ea2; + background-color: var(--kt-accent-dim); + border-color: var(--kt-accent-dim); } #components-reconnect-modal button:active { - background-color: #6b9ed2; + background-color: var(--kt-accent); + border-color: var(--kt-accent); } .components-rejoining-animation { @@ -112,7 +122,7 @@ .components-rejoining-animation div { position: absolute; - border: 3px solid #0087ff; + border: 3px solid var(--kt-accent); opacity: 1; border-radius: 50%; animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite; diff --git a/src/BlazorApp/Components/Pages/Home.razor b/src/BlazorApp/Components/Pages/Home.razor index e3faaff5..c6448923 100644 --- a/src/BlazorApp/Components/Pages/Home.razor +++ b/src/BlazorApp/Components/Pages/Home.razor @@ -12,10 +12,10 @@

- 🎬 Movies - 📺 TV Shows - 📚 Books - ⛽ Refueling + ◼ Movies + ▭ TV Shows + ▬ Books + ▲ Refueling + more
diff --git a/src/BlazorApp/Components/ReferenceData/CastGrid.razor b/src/BlazorApp/Components/ReferenceData/CastGrid.razor new file mode 100644 index 00000000..c4b2d763 --- /dev/null +++ b/src/BlazorApp/Components/ReferenceData/CastGrid.razor @@ -0,0 +1,21 @@ +
+ @foreach (var member in Cast) + { +
+ @if (!string.IsNullOrEmpty(member.ProfileImageUrl)) + { + @member.Name + } + else + { +
+ } +

@member.Name

+

@member.CharacterName

+
+ } +
+ +@code { + [Parameter, EditorRequired] public required List Cast { get; set; } +} diff --git a/src/BlazorApp/Components/ReferenceData/ReferenceDataApiClient.cs b/src/BlazorApp/Components/ReferenceData/ReferenceDataApiClient.cs new file mode 100644 index 00000000..a926d32f --- /dev/null +++ b/src/BlazorApp/Components/ReferenceData/ReferenceDataApiClient.cs @@ -0,0 +1,40 @@ +using Keeptrack.WebApi.Contracts.Dto; + +namespace Keeptrack.BlazorApp.Components.ReferenceData; + +/// +/// Read-only access to the shared reference collection - not per-tenant CRUD, so this doesn't extend +/// . +/// +public sealed class ReferenceDataApiClient(HttpClient http) +{ + public async Task GetTvShowAsync(string referenceId) + { + var response = await http.GetAsync($"/api/reference-data/tv-shows/{referenceId}"); + return response.IsSuccessStatusCode ? await response.Content.ReadFromJsonAsync() : null; + } + + public async Task GetMovieAsync(string referenceId) + { + var response = await http.GetAsync($"/api/reference-data/movies/{referenceId}"); + return response.IsSuccessStatusCode ? await response.Content.ReadFromJsonAsync() : null; + } + + public async Task GetBookAsync(string referenceId) + { + var response = await http.GetAsync($"/api/reference-data/books/{referenceId}"); + return response.IsSuccessStatusCode ? await response.Content.ReadFromJsonAsync() : null; + } + + public async Task GetVideoGameAsync(string referenceId) + { + var response = await http.GetAsync($"/api/reference-data/video-games/{referenceId}"); + return response.IsSuccessStatusCode ? await response.Content.ReadFromJsonAsync() : null; + } + + public async Task GetAlbumAsync(string referenceId) + { + var response = await http.GetAsync($"/api/reference-data/albums/{referenceId}"); + return response.IsSuccessStatusCode ? await response.Content.ReadFromJsonAsync() : null; + } +} diff --git a/src/BlazorApp/Components/ReferenceDataAdmin/InlineReferenceLinker.razor b/src/BlazorApp/Components/ReferenceDataAdmin/InlineReferenceLinker.razor new file mode 100644 index 00000000..aa9d3c7e --- /dev/null +++ b/src/BlazorApp/Components/ReferenceDataAdmin/InlineReferenceLinker.razor @@ -0,0 +1,110 @@ +@inject ReferenceDataAdminApiClient Api + + + +
+

Not yet matched to a reference source (admin only).

+ + @if (!_searched) + { + + } + else if (_searching) + { +
Searching…
+ } + else if (_results.Count == 0) + { +
No @ProviderName results for this title/year.
+ } + else + { + @foreach (var candidate in _results) + { +
+ @if (!string.IsNullOrEmpty(candidate.ImageUrl)) + { + @candidate.Title poster + } +
+ @candidate.Title @(candidate.Year is not null ? $"({candidate.Year})" : "") + @if (!string.IsNullOrEmpty(candidate.Creator)) + { +
@candidate.Creator
+ } + @if (candidate.TopCastNames.Count > 0) + { +
@string.Join(", ", candidate.TopCastNames)
+ } +
+ +
+ } + } + + @if (_error is not null) + { +
@_error
+ } +
+
+
+ +@code { + [Parameter, EditorRequired] public required ReferenceItemType Type { get; set; } + + [Parameter, EditorRequired] public required string Title { get; set; } + + [Parameter] public int? Year { get; set; } + + /// + /// The book's author or the album's artist, when known - narrows the search considerably (a common + /// title alone often returns many unrelated candidates). Ignored for TV shows/movies/video games. + /// + [Parameter] public string? Creator { get; set; } + + [Parameter] public EventCallback OnLinked { get; set; } + + private bool _searched; + private bool _searching; + private bool _linking; + private List _results = []; + private string? _error; + + private string ProviderName => Type switch + { + ReferenceItemType.TvShow or ReferenceItemType.Movie => "TMDB", + ReferenceItemType.Book => "Open Library", + ReferenceItemType.VideoGame => "RAWG", + ReferenceItemType.Album => "Discogs", + _ => "reference" + }; + + private async Task SearchAsync() + { + _searched = true; + _searching = true; + _results = await Api.SearchAsync(Type, Title, Year, Creator); + _searching = false; + } + + private async Task LinkAsync(ReferenceSearchResultDto candidate) + { + _linking = true; + _error = null; + try + { + await Api.LinkAsync(new LinkReferenceRequestDto { Type = Type, Title = Title, Year = Year, ExternalId = candidate.ExternalId }); + await OnLinked.InvokeAsync(); + } + catch (Exception ex) + { + _error = ex.Message; + } + finally + { + _linking = false; + } + } +} diff --git a/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminApiClient.cs b/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminApiClient.cs new file mode 100644 index 00000000..54fe88b4 --- /dev/null +++ b/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminApiClient.cs @@ -0,0 +1,75 @@ +using System.Net.Http.Headers; +using Keeptrack.WebApi.Contracts.Dto; + +namespace Keeptrack.BlazorApp.Components.ReferenceDataAdmin; + +public sealed class ReferenceDataAdminApiClient(HttpClient http) +{ + public async Task> GetUnresolvedAsync(ReferenceItemType type) + { + var results = await http.GetFromJsonAsync>($"/api/reference-data/unresolved?type={type}"); + return results ?? []; + } + + public async Task> SearchAsync(ReferenceItemType type, string title, int? year, string? creator = null) + { + var query = $"/api/reference-data/search?type={type}&title={Uri.EscapeDataString(title)}"; + if (year is not null) query += $"&year={year}"; + if (!string.IsNullOrEmpty(creator)) query += $"&creator={Uri.EscapeDataString(creator)}"; + + var results = await http.GetFromJsonAsync>(query); + return results ?? []; + } + + public async Task LinkAsync(LinkReferenceRequestDto request) + { + var response = await http.PostAsJsonAsync("/api/reference-data/link", request); + response.EnsureSuccessStatusCode(); + } + + /// + /// The full reference dataset (TV shows, movies, cast) as a zip, for seeding a fresh environment. + /// + public async Task ExportAsync() + { + var response = await http.GetAsync("/api/reference-data/export"); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsByteArrayAsync(); + } + + /// + /// Idempotent (upsert-by-id) re-import of a previously exported zip. + /// + public async Task ImportAsync(Stream zipStream, string fileName) + { + using var content = new MultipartFormDataContent(); + using var streamContent = new StreamContent(zipStream); + streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/zip"); + content.Add(streamContent, "file", fileName); + + var response = await http.PostAsync("/api/reference-data/import", content); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync() + ?? new ReferenceDataImportResultDto { TvShowCount = 0, MovieCount = 0, PersonCount = 0, BookCount = 0, VideoGameCount = 0, AlbumCount = 0 }; + } + + /// + /// Starts an immediate re-check of every reference document against its provider (see + /// ReferenceDataAdminController.SyncNow), instead of waiting for the periodic background sync. + /// Runs in the background; poll with the returned job id for progress. + /// + public async Task StartSyncAsync() + { + var response = await http.PostAsync("/api/reference-data/sync-now", null); + response.EnsureSuccessStatusCode(); + + var job = await response.Content.ReadFromJsonAsync(); + return job!.JobId; + } + + public async Task GetSyncStatusAsync(Guid jobId) + { + var status = await http.GetFromJsonAsync($"/api/reference-data/sync-now/{jobId}"); + return status ?? new ReferenceSyncJobStatusDto { Stage = ReferenceSyncStage.Failed, ErrorMessage = "Lost track of the sync job." }; + } +} diff --git a/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminPage.razor b/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminPage.razor new file mode 100644 index 00000000..27acb507 --- /dev/null +++ b/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminPage.razor @@ -0,0 +1,377 @@ +@page "/admin/reference-data" +@rendermode InteractiveServer +@attribute [Authorize(Policy = "AdminOnly")] + +
+

Reference data

+
+ +
+

+ Export the whole reference dataset (TV shows, movies, cast) as a zip, or re-import a previously + exported one - re-importing is safe to run more than once, since every document is upserted by id. +

+
+ + + @if (_importing) + { +
+ } +
+ @if (_importResult is not null) + { +
+ Imported @_importResult.TvShowCount TV show(s), @_importResult.MovieCount movie(s), @_importResult.PersonCount person(s), + @_importResult.BookCount book(s), @_importResult.VideoGameCount video game(s), @_importResult.AlbumCount album(s). +
+ } + @if (_importExportError is not null) + { +
@_importExportError
+ } +
+ +
+

+ Reference data (episode guides, genres, posters, cast) drifts out of date after TMDB updates a show + or movie - a background sync re-checks documents older than 3 days every 24h. Use this to force an + immediate re-check of everything instead of waiting. +

+
+ +
+ @if (_syncing) + { +
+
+
+

@SyncStageLabel(_syncStage)

+ } + @if (_syncResult is not null) + { +
+ TV shows: @_syncResult.TvShowsChecked checked, @_syncResult.TvShowsUpdated updated. + Movies: @_syncResult.MoviesChecked checked, @_syncResult.MoviesUpdated updated. + Books: @_syncResult.BooksChecked checked, @_syncResult.BooksUpdated updated. + Video games: @_syncResult.VideoGamesChecked checked, @_syncResult.VideoGamesUpdated updated. + Albums: @_syncResult.AlbumsChecked checked, @_syncResult.AlbumsUpdated updated. +
+ } + @if (_syncError is not null) + { +
@_syncError
+ } +
+ +
+

+ Titles below couldn't be automatically matched to a reference entry (no result, or more than one + candidate). Pick the right one to link every account's matching item at once. +

+
+ + + + + +
+
+ +
+
+
+
+ Unresolved (@_unresolved.Count) +
+ @if (_unresolved.Count == 0) + { +
+
+

Nothing waiting on a manual match.

+
+ } + else + { + + + @foreach (var item in _unresolved) + { + + + + + } + +
@item.Title@item.Year
+ } +
+
+ +
+
+ @if (_selected is null) + { +

Pick a title on the left to search @ProviderName for a match.

+ } + else + { +

@_selected.Title @(_selected.Year is not null ? $"({_selected.Year})" : "")

+ + @if (_type is ReferenceItemType.Book or ReferenceItemType.Album) + { +
+ + +
+ } + + @if (_searching) + { +
Searching…
+ } + else if (_searchResults.Count == 0) + { +
No @ProviderName results for this title/year.
+ } + else + { + @foreach (var candidate in _searchResults) + { +
+
+ @if (!string.IsNullOrEmpty(candidate.ImageUrl)) + { + @candidate.Title poster + } +
+

@candidate.Title @(candidate.Year is not null ? $"({candidate.Year})" : "")

+ @if (!string.IsNullOrEmpty(candidate.Creator)) + { +

@candidate.Creator

+ } + @if (candidate.TopCastNames.Count > 0) + { +

@string.Join(", ", candidate.TopCastNames)

+ } + @if (!string.IsNullOrEmpty(candidate.Synopsis)) + { +

@candidate.Synopsis

+ } +
+
+ +
+ } + } + + @if (_error is not null) + { +
@_error
+ } + } +
+
+
+ +@code { + private const long MaxImportFileSize = 50_000_000; + + [Inject] private ReferenceDataAdminApiClient Api { get; set; } = null!; + + [Inject] private IJSRuntime JsRuntime { get; set; } = null!; + + private ReferenceItemType _type = ReferenceItemType.TvShow; + private List _unresolved = []; + private UnresolvedReferenceDto? _selected; + private List _searchResults = []; + private bool _searching; + private bool _linking; + private string? _error; + private string? _creator; + + private string ProviderName => _type switch + { + ReferenceItemType.TvShow or ReferenceItemType.Movie => "TMDB", + ReferenceItemType.Book => "Open Library", + ReferenceItemType.VideoGame => "RAWG", + ReferenceItemType.Album => "Discogs", + _ => "reference" + }; + + private bool _exporting; + private bool _importing; + private ReferenceDataImportResultDto? _importResult; + private string? _importExportError; + + private static readonly TimeSpan SyncPollInterval = TimeSpan.FromMilliseconds(600); + + private bool _syncing; + private ReferenceSyncResultDto? _syncResult; + private string? _syncError; + private ReferenceSyncStage _syncStage; + + private async Task SyncNowAsync() + { + _syncing = true; + _syncError = null; + _syncResult = null; + _syncStage = ReferenceSyncStage.SyncingTvShows; + + try + { + var jobId = await Api.StartSyncAsync(); + + while (true) + { + var status = await Api.GetSyncStatusAsync(jobId); + _syncStage = status.Stage; + + if (status.Stage == ReferenceSyncStage.Completed) + { + _syncResult = status.Result; + break; + } + + if (status.Stage == ReferenceSyncStage.Failed) + { + _syncError = status.ErrorMessage ?? "The sync failed."; + break; + } + + StateHasChanged(); + await Task.Delay(SyncPollInterval); + } + } + catch (Exception ex) + { + _syncError = ex.Message; + } + finally + { + _syncing = false; + } + } + + private static int SyncProgressPercent(ReferenceSyncStage stage) => stage switch + { + ReferenceSyncStage.SyncingTvShows => 15, + ReferenceSyncStage.SyncingMovies => 35, + ReferenceSyncStage.SyncingBooks => 55, + ReferenceSyncStage.SyncingVideoGames => 75, + ReferenceSyncStage.SyncingAlbums => 90, + ReferenceSyncStage.Completed => 100, + _ => 0 + }; + + private static string SyncStageLabel(ReferenceSyncStage stage) => stage switch + { + ReferenceSyncStage.SyncingTvShows => "Checking TV shows…", + ReferenceSyncStage.SyncingMovies => "Checking movies…", + ReferenceSyncStage.SyncingBooks => "Checking books…", + ReferenceSyncStage.SyncingVideoGames => "Checking video games…", + ReferenceSyncStage.SyncingAlbums => "Checking albums…", + ReferenceSyncStage.Completed => "Done", + _ => "" + }; + + private async Task ExportAsync() + { + _exporting = true; + _importExportError = null; + try + { + var bytes = await Api.ExportAsync(); + await JsRuntime.InvokeVoidAsync("ktDownloadFile", "keeptrack-reference-data.zip", "application/zip", Convert.ToBase64String(bytes)); + } + catch (Exception ex) + { + _importExportError = ex.Message; + } + finally + { + _exporting = false; + } + } + + private async Task OnImportFileSelectedAsync(InputFileChangeEventArgs e) + { + _importing = true; + _importExportError = null; + _importResult = null; + try + { + await using var stream = e.File.OpenReadStream(MaxImportFileSize); + _importResult = await Api.ImportAsync(stream, e.File.Name); + } + catch (Exception ex) + { + _importExportError = ex.Message; + } + finally + { + _importing = false; + } + } + + protected override async Task OnInitializedAsync() => await LoadUnresolvedAsync(); + + private async Task LoadUnresolvedAsync() + { + _unresolved = await Api.GetUnresolvedAsync(_type); + _selected = null; + _searchResults = []; + _creator = null; + } + + private async Task SelectTypeAsync(ReferenceItemType type) + { + _type = type; + await LoadUnresolvedAsync(); + } + + private async Task SelectItemAsync(UnresolvedReferenceDto item) + { + _selected = item; + _creator = null; + await SearchAsync(); + } + + /// + /// Re-run manually (via the "Search again" button) once the admin types an author/artist - the + /// unresolved queue itself has no creator field (it's a distinct (title, year) pair across every + /// tenant, not any one tenant's own record), so this is the only way to supply one for Book/Album. + /// + private async Task SearchAsync() + { + if (_selected is null) return; + + _searching = true; + _error = null; + _searchResults = await Api.SearchAsync(_type, _selected.Title, _selected.Year, _creator); + _searching = false; + } + + private async Task LinkAsync(ReferenceSearchResultDto candidate) + { + if (_selected is null) return; + + _linking = true; + _error = null; + try + { + await Api.LinkAsync(new LinkReferenceRequestDto { Type = _type, Title = _selected.Title, Year = _selected.Year, ExternalId = candidate.ExternalId }); + await LoadUnresolvedAsync(); + } + catch (Exception ex) + { + _error = ex.Message; + } + finally + { + _linking = false; + } + } +} diff --git a/src/BlazorApp/Components/Shared/ConfirmModal.razor b/src/BlazorApp/Components/Shared/ConfirmModal.razor new file mode 100644 index 00000000..fbcdfb72 --- /dev/null +++ b/src/BlazorApp/Components/Shared/ConfirmModal.razor @@ -0,0 +1,41 @@ +@if (Show) +{ +
+
+
+
@Title
+ +
+
+

@Message

+
+ +
+
+} + +@code { + private bool _mouseDownOnBackdrop; + + [Parameter] public required bool Show { get; set; } + [Parameter] public required string Title { get; set; } + [Parameter] public required string Message { get; set; } + [Parameter] public string ConfirmLabel { get; set; } = "Delete"; + [Parameter] public required EventCallback OnConfirm { get; set; } + [Parameter] public required EventCallback OnCancel { get; set; } + + private void HandleBackdropMouseDown() => _mouseDownOnBackdrop = true; + + private async Task HandleBackdropClick() + { + if (_mouseDownOnBackdrop) + { + _mouseDownOnBackdrop = false; + await OnCancel.InvokeAsync(); + } + _mouseDownOnBackdrop = false; + } +} diff --git a/src/BlazorApp/Components/Shared/SvgChartHelpers.cs b/src/BlazorApp/Components/Shared/SvgChartHelpers.cs new file mode 100644 index 00000000..27d2a5b2 --- /dev/null +++ b/src/BlazorApp/Components/Shared/SvgChartHelpers.cs @@ -0,0 +1,161 @@ +using Microsoft.AspNetCore.Components.Rendering; + +namespace Keeptrack.BlazorApp.Components.Shared; + +/// +/// Shared SVG axis-drawing primitives for the app's hand-rolled charts (no charting library dependency for a handful of small trend/bar charts). +/// Extracted from CarDetail.razor so HouseDetail.razor's own yearly cost chart doesn't duplicate the same axis geometry/arrow-marker/tick-label algorithm. +/// Deliberately limited to just axis/geometry, not the per-chart series-drawing code (line vs. bar, single vs. stacked series) - +/// that part differs enough between consumers that it stays with each page rather than being forced into one over-generalized shared renderer. +/// +public static class SvgChartHelpers +{ + /// + /// Plot geometry for one chart. + /// Not every chart shares a single fixed viewBox: a chart rendered at full row width needs a proportionally wider viewBox than a half-width one - + /// matching ViewWidth to actual on-screen width keeps the rendered scale (and therefore axis text/arrow/tick size) the same across every chart instead of the wider ones blowing up. + /// + public readonly record struct ChartGeometry( + double ViewWidth, + double ViewHeight, + double PlotLeft, + double PlotRight, + double PlotTop, + double PlotBottom); + + public static readonly ChartGeometry HalfWidthGeometry = + new(ViewWidth: 300, ViewHeight: 170, PlotLeft: 40, PlotRight: 288, PlotTop: 14, PlotBottom: 132); + + public static readonly ChartGeometry FullWidthGeometry = + new(ViewWidth: 600, ViewHeight: 170, PlotLeft: 40, PlotRight: 588, PlotTop: 14, PlotBottom: 132); + + /// + /// Draws a graduated X/Y axis pair (arrowhead, tick marks, tick labels, axis title). + /// Ticks are computed by the caller, since what counts as an evenly-spaced value differs between a continuous line chart and a per-bar categorical one. + /// The Y-axis title is a plain horizontal caption above the axis rather than rotated sideways along it - + /// fine for a multi-word label like "L/100km", but a rotated single glyph like "€" reads as a completely different, garbled character, not a sideways euro sign. + /// +#pragma warning disable ASP0006 + public static void RenderAxes( + RenderTreeBuilder builder, ref int seq, ChartGeometry geometry, string markerId, string xAxisLabel, string yAxisLabel, + IReadOnlyList<(double Y, string Label)> yTicks, IReadOnlyList<(double X, string Label)> xTicks) + { + const string AxisColor = "var(--kt-text-muted)"; + var (_, viewHeight, plotLeft, plotRight, plotTop, plotBottom) = geometry; + + builder.OpenElement(seq++, "defs"); + builder.OpenElement(seq++, "marker"); + builder.AddAttribute(seq++, "id", markerId); + builder.AddAttribute(seq++, "viewBox", "0 0 8 8"); + builder.AddAttribute(seq++, "refX", "6"); + builder.AddAttribute(seq++, "refY", "4"); + builder.AddAttribute(seq++, "markerWidth", "6"); + builder.AddAttribute(seq++, "markerHeight", "6"); + builder.AddAttribute(seq++, "orient", "auto-start-reverse"); + builder.OpenElement(seq++, "path"); + builder.AddAttribute(seq++, "d", "M0,0 L8,4 L0,8 Z"); + builder.AddAttribute(seq++, "fill", AxisColor); + builder.CloseElement(); + builder.CloseElement(); + builder.CloseElement(); + + // Y-axis: drawn bottom-to-top so the arrowhead (marker-end) points up. + builder.OpenElement(seq++, "line"); + builder.AddAttribute(seq++, "x1", plotLeft.ToString("F1")); + builder.AddAttribute(seq++, "y1", plotBottom.ToString("F1")); + builder.AddAttribute(seq++, "x2", plotLeft.ToString("F1")); + builder.AddAttribute(seq++, "y2", plotTop.ToString("F1")); + builder.AddAttribute(seq++, "stroke", AxisColor); + builder.AddAttribute(seq++, "stroke-width", "1"); + builder.AddAttribute(seq++, "vector-effect", "non-scaling-stroke"); + builder.AddAttribute(seq++, "marker-end", $"url(#{markerId})"); + builder.CloseElement(); + + // X-axis: drawn left-to-right so the arrowhead points right. + builder.OpenElement(seq++, "line"); + builder.AddAttribute(seq++, "x1", plotLeft.ToString("F1")); + builder.AddAttribute(seq++, "y1", plotBottom.ToString("F1")); + builder.AddAttribute(seq++, "x2", plotRight.ToString("F1")); + builder.AddAttribute(seq++, "y2", plotBottom.ToString("F1")); + builder.AddAttribute(seq++, "stroke", AxisColor); + builder.AddAttribute(seq++, "stroke-width", "1"); + builder.AddAttribute(seq++, "vector-effect", "non-scaling-stroke"); + builder.AddAttribute(seq++, "marker-end", $"url(#{markerId})"); + builder.CloseElement(); + + foreach (var (y, label) in yTicks) + { + builder.OpenElement(seq++, "line"); + builder.AddAttribute(seq++, "x1", (plotLeft - 3).ToString("F1")); + builder.AddAttribute(seq++, "y1", y.ToString("F1")); + builder.AddAttribute(seq++, "x2", plotLeft.ToString("F1")); + builder.AddAttribute(seq++, "y2", y.ToString("F1")); + builder.AddAttribute(seq++, "stroke", AxisColor); + builder.AddAttribute(seq++, "stroke-width", "1"); + builder.AddAttribute(seq++, "vector-effect", "non-scaling-stroke"); + builder.CloseElement(); + + builder.OpenElement(seq++, "text"); + builder.AddAttribute(seq++, "x", (plotLeft - 5).ToString("F1")); + builder.AddAttribute(seq++, "y", (y + 2.5).ToString("F1")); + builder.AddAttribute(seq++, "text-anchor", "end"); + builder.AddAttribute(seq++, "class", "kt-chart-axis-text"); + builder.AddContent(seq++, label); + builder.CloseElement(); + } + + foreach (var (x, label) in xTicks) + { + builder.OpenElement(seq++, "line"); + builder.AddAttribute(seq++, "x1", x.ToString("F1")); + builder.AddAttribute(seq++, "y1", plotBottom.ToString("F1")); + builder.AddAttribute(seq++, "x2", x.ToString("F1")); + builder.AddAttribute(seq++, "y2", (plotBottom + 3).ToString("F1")); + builder.AddAttribute(seq++, "stroke", AxisColor); + builder.AddAttribute(seq++, "stroke-width", "1"); + builder.AddAttribute(seq++, "vector-effect", "non-scaling-stroke"); + builder.CloseElement(); + + builder.OpenElement(seq++, "text"); + builder.AddAttribute(seq++, "x", x.ToString("F1")); + builder.AddAttribute(seq++, "y", (plotBottom + 12).ToString("F1")); + builder.AddAttribute(seq++, "text-anchor", "middle"); + builder.AddAttribute(seq++, "class", "kt-chart-axis-text"); + builder.AddContent(seq++, label); + builder.CloseElement(); + } + + // Y-axis title: a plain horizontal caption in the top-left corner, naming the axis unit. + builder.OpenElement(seq++, "text"); + builder.AddAttribute(seq++, "x", "2"); + builder.AddAttribute(seq++, "y", (plotTop - 4).ToString("F1")); + builder.AddAttribute(seq++, "text-anchor", "start"); + builder.AddAttribute(seq++, "class", "kt-chart-axis-title"); + builder.AddContent(seq++, yAxisLabel); + builder.CloseElement(); + + var xTitleCenter = (plotLeft + plotRight) / 2; + builder.OpenElement(seq++, "text"); + builder.AddAttribute(seq++, "x", xTitleCenter.ToString("F1")); + builder.AddAttribute(seq++, "y", (viewHeight - 4).ToString("F1")); + builder.AddAttribute(seq++, "text-anchor", "middle"); + builder.AddAttribute(seq++, "class", "kt-chart-axis-title"); + builder.AddContent(seq++, xAxisLabel); + builder.CloseElement(); + } +#pragma warning restore ASP0006 + + /// + /// Picks up to evenly-spaced indices from a 0-based range, always including the first and last - + /// shared by every chart's X-axis tick placement. + /// + public static List EvenlySpacedIndices(int total, int count) + { + if (total <= 1 || count <= 1) return [0]; + count = Math.Min(count, total); + return Enumerable.Range(0, count) + .Select(i => i * (total - 1) / (count - 1)) + .Distinct() + .ToList(); + } +} diff --git a/src/BlazorApp/Components/WatchNext/WatchNextApiClient.cs b/src/BlazorApp/Components/WatchNext/WatchNextApiClient.cs new file mode 100644 index 00000000..795a530a --- /dev/null +++ b/src/BlazorApp/Components/WatchNext/WatchNextApiClient.cs @@ -0,0 +1,12 @@ +using Keeptrack.WebApi.Contracts.Dto; + +namespace Keeptrack.BlazorApp.Components.WatchNext; + +public sealed class WatchNextApiClient(HttpClient http) +{ + public async Task GetAsync() + { + var result = await http.GetFromJsonAsync("/api/watch-next"); + return result ?? new WatchNextDto(); + } +} diff --git a/src/BlazorApp/Components/WatchNext/WatchNextPage.razor b/src/BlazorApp/Components/WatchNext/WatchNextPage.razor new file mode 100644 index 00000000..65ded692 --- /dev/null +++ b/src/BlazorApp/Components/WatchNext/WatchNextPage.razor @@ -0,0 +1,97 @@ +@page "/watch-next" +@rendermode InteractiveServer +@attribute [Authorize] + +
+

Watch next

+
+ +@if (_loading) +{ +
+
+ Loading… +
+} +else +{ +
+ + +
+ + @if (_tab == Tab.TvShows) + { +

+ Current shows with a confirmed new episode ready to watch. +

+ @if (_data.InProgressShows.Count == 0) + { +
+
+

Nothing new to watch. Shows need a "Current" status and a linked reference to appear here.

+
+ } + else + { + + } + } + else + { + @if (_data.MoviesToWatch.Count == 0) + { +
+
+

Your movie watchlist is empty. Add a movie to your watchlist to see it here.

+
+ } + else + { +
+ @foreach (var movie in _data.MoviesToWatch) + { + +

@movie.Title

+ @if (movie.Year > 0) + { +

@movie.Year

+ } +
+ } +
+ } + } +} + +@code { + private enum Tab { TvShows, Movies } + + [Inject] private WatchNextApiClient WatchNextApi { get; set; } = null!; + + private bool _loading = true; + private WatchNextDto _data = new(); + private Tab _tab = Tab.TvShows; + + protected override async Task OnInitializedAsync() + { + _data = await WatchNextApi.GetAsync(); + _loading = false; + } + + private void SelectTab(Tab tab) => _tab = tab; +} diff --git a/src/BlazorApp/Components/Wishlist/WishlistApiClient.cs b/src/BlazorApp/Components/Wishlist/WishlistApiClient.cs new file mode 100644 index 00000000..467e142b --- /dev/null +++ b/src/BlazorApp/Components/Wishlist/WishlistApiClient.cs @@ -0,0 +1,12 @@ +using Keeptrack.WebApi.Contracts.Dto; + +namespace Keeptrack.BlazorApp.Components.Wishlist; + +public sealed class WishlistApiClient(HttpClient http) +{ + public async Task GetAsync() + { + var result = await http.GetFromJsonAsync("/api/wishlist"); + return result ?? new WishlistDto(); + } +} diff --git a/src/BlazorApp/Components/Wishlist/WishlistPage.razor b/src/BlazorApp/Components/Wishlist/WishlistPage.razor new file mode 100644 index 00000000..ff9783d1 --- /dev/null +++ b/src/BlazorApp/Components/Wishlist/WishlistPage.razor @@ -0,0 +1,148 @@ +@page "/wishlist" +@rendermode InteractiveServer +@attribute [Authorize] + +
+

Wishlist

+
+ +@if (_loading) +{ +
+
+ Loading… +
+} +else +{ +
+ + + + +
+ + @if (_tab == Tab.Movies) + { + @if (_data.Movies.Count == 0) + { +
+
+

No movies on your wishlist yet.

+
+ } + else + { +
+ @foreach (var movie in _data.Movies) + { + +

@movie.Title

+ @if (movie.Year > 0) + { +

@movie.Year

+ } +
+ } +
+ } + } + else if (_tab == Tab.TvShows) + { + @if (_data.TvShows.Count == 0) + { +
+
+

No TV shows on your wishlist yet.

+
+ } + else + { +
+ @foreach (var show in _data.TvShows) + { + +

@show.Title

+ @if (show.Year > 0) + { +

@show.Year

+ } +
+ } +
+ } + } + else if (_tab == Tab.Books) + { + @if (_data.Books.Count == 0) + { +
+
+

No books on your wishlist yet.

+
+ } + else + { +
+ @foreach (var book in _data.Books) + { + +

@book.Title

+

@book.Author

+
+ } +
+ } + } + else + { + @if (_data.VideoGames.Count == 0) + { +
+
+

No video games on your wishlist yet.

+
+ } + else + { +
+ @foreach (var game in _data.VideoGames) + { + +

@game.Title

+ @if (game.Year > 0) + { +

@game.Year

+ } +
+ } +
+ } + } +} + +@code { + private enum Tab { Movies, TvShows, Books, VideoGames } + + [Inject] private WishlistApiClient WishlistApi { get; set; } = null!; + + private bool _loading = true; + private WishlistDto _data = new(); + private Tab _tab = Tab.Movies; + + protected override async Task OnInitializedAsync() + { + _data = await WishlistApi.GetAsync(); + _loading = false; + } + + private void SelectTab(Tab tab) => _tab = tab; +} diff --git a/src/BlazorApp/Components/_Imports.razor b/src/BlazorApp/Components/_Imports.razor index 7c58a760..03a2303c 100644 --- a/src/BlazorApp/Components/_Imports.razor +++ b/src/BlazorApp/Components/_Imports.razor @@ -7,6 +7,7 @@ @using Keeptrack.BlazorApp.Components.Inventory.Clients @using Keeptrack.BlazorApp.Components.Inventory.Shared @using Keeptrack.BlazorApp.Components.Layout +@using Keeptrack.BlazorApp.Components.Shared @using Keeptrack.Common.System @using Keeptrack.WebApi.Contracts.Dto @using Microsoft.AspNetCore.Authorization diff --git a/src/BlazorApp/DependencyInjection/InfrastructureServiceCollectionExtensions.cs b/src/BlazorApp/DependencyInjection/InfrastructureServiceCollectionExtensions.cs index dbae8441..524da38a 100644 --- a/src/BlazorApp/DependencyInjection/InfrastructureServiceCollectionExtensions.cs +++ b/src/BlazorApp/DependencyInjection/InfrastructureServiceCollectionExtensions.cs @@ -5,15 +5,41 @@ internal static class InfrastructureServiceCollectionExtensions internal static void AddWebApiHttpClient(this IServiceCollection services, string webApiBaseUrl) { var webApiUri = new Uri(webApiBaseUrl); + services.AddHttpClient(client => client.BaseAddress = webApiUri) + .AddHttpMessageHandler(); services.AddHttpClient(client => client.BaseAddress = webApiUri) .AddHttpMessageHandler(); - services.AddHttpClient(client => client.BaseAddress = webApiUri) + services.AddHttpClient(client => client.BaseAddress = webApiUri) + .AddHttpMessageHandler(); + services.AddHttpClient(client => client.BaseAddress = webApiUri) + .AddHttpMessageHandler(); + services.AddHttpClient(client => client.BaseAddress = webApiUri) .AddHttpMessageHandler(); - services.AddHttpClient(client => client.BaseAddress = webApiUri) + services.AddHttpClient(client => client.BaseAddress = webApiUri) + .AddHttpMessageHandler(); + services.AddHttpClient(client => client.BaseAddress = webApiUri) + .AddHttpMessageHandler(); + services.AddHttpClient(client => client.BaseAddress = webApiUri) .AddHttpMessageHandler(); services.AddHttpClient(client => client.BaseAddress = webApiUri) .AddHttpMessageHandler(); services.AddHttpClient(client => client.BaseAddress = webApiUri) .AddHttpMessageHandler(); + services.AddHttpClient(client => client.BaseAddress = webApiUri) + .AddHttpMessageHandler(); + services.AddHttpClient(client => client.BaseAddress = webApiUri) + .AddHttpMessageHandler(); + services.AddHttpClient(client => client.BaseAddress = webApiUri) + .AddHttpMessageHandler(); + services.AddHttpClient(client => client.BaseAddress = webApiUri) + .AddHttpMessageHandler(); + services.AddHttpClient(client => client.BaseAddress = webApiUri) + .AddHttpMessageHandler(); + services.AddHttpClient(client => client.BaseAddress = webApiUri) + .AddHttpMessageHandler(); + services.AddHttpClient(client => client.BaseAddress = webApiUri) + .AddHttpMessageHandler(); + services.AddHttpClient(client => client.BaseAddress = webApiUri) + .AddHttpMessageHandler(); } } diff --git a/src/BlazorApp/Program.cs b/src/BlazorApp/Program.cs index 296b76e6..c387b332 100644 --- a/src/BlazorApp/Program.cs +++ b/src/BlazorApp/Program.cs @@ -12,12 +12,16 @@ options.ExpireTimeSpan = TimeSpan.FromHours(8); options.SlidingExpiration = true; }); +builder.Services.AddAuthorization(options => +{ + options.AddPolicy("AdminOnly", policy => policy.RequireClaim("role", "admin")); +}); builder.Services.AddControllers(); builder.Services.AddSingleton(builder.Configuration.TryGetSection("Firebase:WebAppConfiguration")); if (FirebaseApp.DefaultInstance is null) { var firebaseJson = builder.Configuration.TryGetSection("Firebase:ServiceAccount"); - var googleCredential = GoogleCredential.FromJson(firebaseJson); + var googleCredential = CredentialFactory.FromJson(firebaseJson).ToGoogleCredential(); FirebaseApp.Create(new AppOptions { Credential = googleCredential }); } builder.Services.AddHttpContextAccessor(); diff --git a/src/BlazorApp/wwwroot/Keeptrack.BlazorApp.lib.module.js b/src/BlazorApp/wwwroot/Keeptrack.BlazorApp.lib.module.js new file mode 100644 index 00000000..2022a490 --- /dev/null +++ b/src/BlazorApp/wwwroot/Keeptrack.BlazorApp.lib.module.js @@ -0,0 +1,7 @@ +// JS initializer, auto-detected and loaded by Blazor (file name must match the assembly name). +// Re-applies the theme after enhanced navigation, since data-bs-theme is set by client-side JS +// (theme.js) rather than server-rendered markup, so Blazor's enhanced-navigation DOM diff would +// otherwise drop it whenever it merges in a freshly server-rendered page. +export function afterWebStarted(blazor) { + blazor.addEventListener('enhancedload', ktApplyTheme); +} diff --git a/src/BlazorApp/wwwroot/app.css b/src/BlazorApp/wwwroot/app.css index ba460824..2bbca4b0 100644 --- a/src/BlazorApp/wwwroot/app.css +++ b/src/BlazorApp/wwwroot/app.css @@ -1,58 +1,92 @@ /* ═══════════════════════════════════════════════════════════════════ - KEEPTRACK — Cinema-grade dark theme v3 - Bootstrap 5.3.3 override layer + KEEPTRACK — theme layer (light + dark) + Bootstrap 5.3 override layer, driven by [data-bs-theme] on ═══════════════════════════════════════════════════════════════════ */ -@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,600;0,700;1,400;1,600&family=DM+Sans:wght@300;400;500;600&display=swap'); - -/* ── Variables ─────────────────────────────────────────────────────── */ +/* ── Tokens: light (default) ──────────────────────────────────────── */ :root { - --kt-bg: #0d0f14; - --kt-surface: #13161e; - --kt-surface-2: #1a1e28; - --kt-surface-3: #222636; - --kt-border: rgba(255,255,255,0.08); - --kt-border-hover: rgba(255,255,255,0.16); - --kt-amber: #e8a045; - --kt-amber-dim: #c4843a; - --kt-amber-glow: rgba(232,160,69,0.12); - --kt-text: #e8e6e1; - --kt-text-muted: #7a7d8a; - --kt-text-subtle: #3e414e; - --kt-danger: #c0485a; - --kt-success: #3d9970; + color-scheme: light; + + --kt-bg: #f6f7f9; + --kt-surface: #ffffff; + --kt-surface-2: #f1f2f5; + --kt-surface-3: #eceef1; + --kt-border: rgba(15, 18, 25, 0.10); + --kt-border-hover: rgba(15, 18, 25, 0.18); + --kt-accent: #2f6fed; + --kt-accent-dim: #1f56c4; + --kt-accent-glow: rgba(47, 111, 237, 0.10); + --kt-text: #1a1d23; + --kt-text-muted: #5f6570; + --kt-text-subtle: #b1b6bd; + --kt-danger: #c73a52; + --kt-danger-bg: #f5dde1; + --kt-success: #2b8a5e; + --kt-success-bg: #dcefe4; + --kt-warning: #9a6b00; + --kt-warning-bg: #f7ecd2; + --kt-chart-secondary: #8a5fd1; --kt-radius: 10px; --kt-radius-lg: 14px; - --kt-shadow: 0 4px 24px rgba(0,0,0,0.5); - --kt-shadow-lg: 0 12px 48px rgba(0,0,0,0.7); - --kt-font-display: 'Playfair Display', Georgia, serif; - --kt-font-ui: 'DM Sans', system-ui, sans-serif; - --kt-transition: 0.18s cubic-bezier(0.4, 0, 0.2, 1); - - /* Bootstrap overrides */ - --bs-body-bg: #0d0f14; - --bs-body-color: #e8e6e1; - --bs-border-color: rgba(255,255,255,0.08); + --kt-shadow: 0 2px 10px rgba(15, 18, 25, 0.06); + --kt-shadow-lg: 0 12px 36px rgba(15, 18, 25, 0.14); + --kt-font: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + --kt-transition: 0.15s ease; + + --bs-body-bg: var(--kt-bg); + --bs-body-color: var(--kt-text); + --bs-border-color: var(--kt-border); --bs-table-bg: transparent; - --bs-table-color: #e8e6e1; - --bs-table-border-color: rgba(255,255,255,0.08); + --bs-table-color: var(--kt-text); + --bs-table-border-color: var(--kt-border); --bs-table-striped-bg: transparent; - --bs-table-hover-bg: #1a1e28; - --bs-table-active-bg: #1a1e28; + --bs-table-hover-bg: var(--kt-surface-2); + --bs-table-active-bg: var(--kt-surface-2); +} + +/* ── Tokens: dark ──────────────────────────────────────────────────── */ +:root[data-bs-theme="dark"] { + color-scheme: dark; + + --kt-bg: #14161a; + --kt-surface: #1b1e24; + --kt-surface-2: #23262d; + --kt-surface-3: #2b2f37; + --kt-border: rgba(255, 255, 255, 0.08); + --kt-border-hover: rgba(255, 255, 255, 0.16); + --kt-accent: #5b9dff; + --kt-accent-dim: #3f7fe0; + --kt-accent-glow: rgba(91, 157, 255, 0.14); + --kt-text: #e6e8eb; + --kt-text-muted: #9aa0aa; + --kt-text-subtle: #565c66; + --kt-danger: #e0687a; + --kt-danger-bg: rgba(224, 104, 122, 0.16); + --kt-success: #4cb489; + --kt-success-bg: rgba(76, 180, 137, 0.16); + --kt-warning: #e0be5b; + --kt-warning-bg: rgba(224, 190, 91, 0.16); + --kt-chart-secondary: #b39ce8; + --kt-shadow: 0 4px 20px rgba(0, 0, 0, 0.35); + --kt-shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.5); + + --bs-body-bg: var(--kt-bg); + --bs-body-color: var(--kt-text); + --bs-border-color: var(--kt-border); + --bs-table-hover-bg: var(--kt-surface-2); + --bs-table-active-bg: var(--kt-surface-2); } /* ── Base ──────────────────────────────────────────────────────────── */ *, *::before, *::after { box-sizing: border-box; } -html { color-scheme: dark; } - html, body { height: 100%; margin: 0; padding: 0; - background-color: var(--kt-bg) !important; - color: var(--kt-text) !important; - font-family: var(--kt-font-ui); + background-color: var(--kt-bg); + color: var(--kt-text); + font-family: var(--kt-font); font-size: 15px; font-weight: 400; line-height: 1.6; @@ -60,14 +94,18 @@ html, body { } h1, h2, h3, h4 { - font-family: var(--kt-font-display); + font-family: var(--kt-font); font-weight: 600; - letter-spacing: -0.02em; + letter-spacing: -0.01em; color: var(--kt-text); } -a { color: var(--kt-amber); text-decoration: none; transition: color var(--kt-transition); } -a:hover { color: var(--kt-text); } +a { color: var(--kt-accent); text-decoration: none; transition: color var(--kt-transition); } +a:hover { color: var(--kt-accent-dim); } + +/* Routes.razor's programmatically focuses the page's h1 after + navigation for screen readers; suppress the resulting focus ring since h1 isn't interactive. */ +h1:focus { outline: none; } /* ── Layout ────────────────────────────────────────────────────────── */ .page { @@ -76,7 +114,6 @@ a:hover { color: var(--kt-text); } background: var(--kt-bg); } -/* The main column fills all remaining space */ .main { flex: 1 1 0%; min-width: 0; @@ -85,8 +122,6 @@ a:hover { color: var(--kt-text); } overflow-x: hidden; } -/* Content area — covers both article.content (our layout) and - div.content (Blazor default template). Centered with max-width. */ article.content, div.content, .content { @@ -119,21 +154,19 @@ div.content, border-bottom: 1px solid var(--kt-border); } -/* Hide the default Blazor top-row inside .main (About link etc) */ .main > .top-row { display: none; } .navbar-brand { - font-family: var(--kt-font-display); - font-size: 1.25rem; + font-size: 1.2rem; font-weight: 700; color: var(--kt-text) !important; - letter-spacing: -0.02em; + letter-spacing: -0.01em; display: flex; align-items: center; gap: 0.5rem; text-decoration: none !important; } -.navbar-brand::before { content: '◈'; color: var(--kt-amber); font-size: 0.95rem; } +.navbar-brand::before { content: '◈'; color: var(--kt-accent); font-size: 0.95rem; } .navbar-toggler { display: none; @@ -147,12 +180,10 @@ div.content, line-height: 1; } -/* Hide the checkbox input, show only the label */ #nav-toggle { display: none !important; } -/* Hamburger label — visible on mobile */ .navbar-toggler-label { display: none; background: none; @@ -192,24 +223,37 @@ div.content, line-height: 1.4; } .nav-link:hover { color: var(--kt-text) !important; background: var(--kt-surface-2) !important; } -.nav-link.active { color: var(--kt-amber) !important; background: var(--kt-amber-glow) !important; } -button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0.08) !important; } +.nav-link.active { color: var(--kt-accent) !important; background: var(--kt-accent-glow) !important; } +button.nav-link:hover { color: var(--kt-danger) !important; background: rgba(199, 58, 82, 0.08) !important; } -/* nav-icon span used instead of pseudo-elements to avoid encoding issues */ .nav-icon { width: 16px; text-align: center; flex-shrink: 0; font-style: normal; - opacity: 0.7; + opacity: 0.8; } -.nav-link-user { +/* the signed-in user's nav row has no plain-text glyph that reads as "profile" (a person silhouette is a + color-emoji-only codepoint - see the Theme section in CLAUDE.md), so it's a small initial-letter avatar + instead of an icon - the same pattern Gmail/Slack use, and it doubles as a personalization touch */ +.kt-nav-avatar { + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--kt-accent-glow); + color: var(--kt-accent); + font-size: 0.7rem; + font-weight: 700; + display: inline-flex; + align-items: center; + justify-content: center; + opacity: 1; } .nav-link-logout:hover { - color: #e07070 !important; - background: rgba(192,72,90,0.08) !important; + color: var(--kt-danger) !important; + background: rgba(199, 58, 82, 0.08) !important; } .nav-username { @@ -222,27 +266,49 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0. .nav-divider { border-top: 1px solid var(--kt-border); margin: 0.75rem 0; } +/* ── Theme toggle ──────────────────────────────────────────────────── */ +.kt-theme-toggle { + display: flex !important; + align-items: center; + gap: 0.6rem; + width: 100%; + padding: 0.5rem 0.75rem; + border-radius: 7px; + border: none; + background: none; + color: var(--kt-text-muted); + font-size: 0.875rem; + font-weight: 500; + font-family: var(--kt-font); + cursor: pointer; + text-align: left; +} +.kt-theme-toggle:hover { color: var(--kt-text); background: var(--kt-surface-2); } +.kt-theme-toggle .nav-icon { opacity: 1; } +[data-bs-theme="dark"] .kt-theme-toggle .kt-theme-icon-dark { display: none; } +[data-bs-theme="light"] .kt-theme-toggle .kt-theme-icon-light { display: none; } +:root:not([data-bs-theme]) .kt-theme-toggle .kt-theme-icon-light { display: none; } + /* ── Buttons ───────────────────────────────────────────────────────── */ .btn { - font-family: var(--kt-font-ui) !important; + font-family: var(--kt-font) !important; font-weight: 500 !important; font-size: 0.85rem !important; border-radius: 8px !important; padding: 0.5rem 1.1rem !important; transition: all var(--kt-transition) !important; - letter-spacing: 0.01em; line-height: 1.4; } -.btn-primary { background: var(--kt-amber) !important; color: #0d0f14 !important; border-color: var(--kt-amber) !important; } -.btn-primary:hover { background: #f0b060 !important; border-color: #f0b060 !important; color: #0d0f14 !important; box-shadow: 0 0 0 3px rgba(232,160,69,0.22) !important; } +.btn-primary { background: var(--kt-accent) !important; color: #fff !important; border-color: var(--kt-accent) !important; } +.btn-primary:hover { background: var(--kt-accent-dim) !important; border-color: var(--kt-accent-dim) !important; color: #fff !important; box-shadow: 0 0 0 3px var(--kt-accent-glow) !important; } .btn-secondary { background: var(--kt-surface-3) !important; color: var(--kt-text-muted) !important; border-color: var(--kt-border) !important; } -.btn-secondary:hover { background: #2a2f40 !important; color: var(--kt-text) !important; border-color: var(--kt-border-hover) !important; box-shadow: none !important; } +.btn-secondary:hover { background: var(--kt-surface-2) !important; color: var(--kt-text) !important; border-color: var(--kt-border-hover) !important; box-shadow: none !important; } .btn-success { background: var(--kt-success) !important; color: #fff !important; border-color: var(--kt-success) !important; } -.btn-success:hover { background: #4aad82 !important; border-color: #4aad82 !important; } -.btn-outline-danger { background: transparent !important; color: var(--kt-danger) !important; border-color: rgba(192,72,90,0.35) !important; } -.btn-outline-danger:hover { background: rgba(192,72,90,0.12) !important; color: #d45a6c !important; border-color: var(--kt-danger) !important; box-shadow: none !important; } -.btn-outline-primary { background: transparent !important; color: var(--kt-amber) !important; border-color: rgba(232,160,69,0.35) !important; } -.btn-outline-primary:hover { background: var(--kt-amber-glow) !important; color: var(--kt-amber) !important; border-color: rgba(232,160,69,0.6) !important; box-shadow: none !important; } +.btn-success:hover { filter: brightness(1.08); border-color: var(--kt-success) !important; } +.btn-outline-danger { background: transparent !important; color: var(--kt-danger) !important; border-color: rgba(199, 58, 82, 0.35) !important; } +.btn-outline-danger:hover { background: rgba(199, 58, 82, 0.10) !important; color: var(--kt-danger) !important; border-color: var(--kt-danger) !important; box-shadow: none !important; } +.btn-outline-primary { background: transparent !important; color: var(--kt-accent) !important; border-color: rgba(47, 111, 237, 0.35) !important; } +.btn-outline-primary:hover { background: var(--kt-accent-glow) !important; color: var(--kt-accent) !important; border-color: var(--kt-accent) !important; box-shadow: none !important; } .btn-sm { padding: 0.28rem 0.65rem !important; font-size: 0.78rem !important; border-radius: 6px !important; } /* ── Form controls ─────────────────────────────────────────────────── */ @@ -254,20 +320,21 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0. border-radius: 8px !important; padding: 0.5rem 0.85rem !important; font-size: 0.875rem !important; - font-family: var(--kt-font-ui) !important; + font-family: var(--kt-font) !important; transition: border-color var(--kt-transition), box-shadow var(--kt-transition) !important; } .form-control:focus, .form-select:focus { background-color: var(--kt-surface-3) !important; - border-color: var(--kt-amber) !important; + border-color: var(--kt-accent) !important; color: var(--kt-text) !important; - box-shadow: 0 0 0 3px rgba(232,160,69,0.12) !important; + box-shadow: 0 0 0 3px var(--kt-accent-glow) !important; outline: none !important; } .form-control::placeholder { color: var(--kt-text-subtle) !important; } .form-select option { background: var(--kt-surface-3); color: var(--kt-text); } .form-control-sm, .form-select-sm { padding: 0.3rem 0.65rem !important; font-size: 0.82rem !important; border-radius: 6px !important; } -.form-label { font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.07em; color: var(--kt-text-muted); margin-bottom: 0.35rem; } +.form-label { font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--kt-text-muted); margin-bottom: 0.35rem; } +.form-check-input:checked { background-color: var(--kt-accent) !important; border-color: var(--kt-accent) !important; } /* ── TABLE ─────────────────────────────────────────────────────────── */ .table { @@ -283,7 +350,6 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0. border-spacing: 0 !important; } -/* Override Bootstrap's per-cell bg injection */ .table > :not(caption) > * > * { background-color: transparent !important; color: var(--kt-text) !important; @@ -297,7 +363,7 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0. font-size: 0.7rem !important; font-weight: 600 !important; text-transform: uppercase !important; - letter-spacing: 0.1em !important; + letter-spacing: 0.08em !important; color: var(--kt-text-muted) !important; border-bottom: 1px solid var(--kt-border) !important; white-space: nowrap; @@ -307,9 +373,9 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0. .table tbody tr:hover td { background-color: var(--kt-surface-2) !important; } .table tbody tr.kt-editing td { - background-color: rgba(232,160,69,0.04) !important; - border-top: 1px solid rgba(232,160,69,0.2) !important; - border-bottom: 1px solid rgba(232,160,69,0.2) !important; + background-color: var(--kt-accent-glow) !important; + border-top: 1px solid var(--kt-accent) !important; + border-bottom: 1px solid var(--kt-accent) !important; } .kt-table-wrap { @@ -321,7 +387,6 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0. width: 100%; } -/* Header bar inside the table card — title left, button right */ .kt-table-header { display: flex; align-items: center; @@ -331,13 +396,13 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0. } .kt-table-title { - font-size: 1.75rem; + font-size: 1.6rem; margin: 0; line-height: 1; padding-bottom: 1.25rem; } -.kt-table-title span { color: var(--kt-amber); } +.kt-table-title span { color: var(--kt-accent); } /* ── Page header ───────────────────────────────────────────────────── */ .kt-page-header { @@ -348,46 +413,244 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0. gap: 1rem; flex-wrap: wrap; } -.kt-page-header h1 { font-size: 2.2rem; color: var(--kt-text); margin: 0; line-height: 1; } -.kt-page-header h1 span { color: var(--kt-amber); } +.kt-page-header h1 { font-size: 2rem; color: var(--kt-text); margin: 0; line-height: 1; } +.kt-page-header h1 span { color: var(--kt-accent); } + +/* editable "title" input on a detail page, styled to read as a heading until you interact with it. + flex/min-width let it fill its row (rather than a fixed browser-default input width), so a button placed + right after it (e.g. the reference-refresh icon) sits at a predictable spot instead of trailing right + after wherever the visible text happens to end. */ +.kt-title-input { + font-size: 2rem; + font-weight: 700; + color: var(--kt-text); + background: transparent; + border: 1px solid transparent; + border-radius: var(--kt-radius); + padding: 0.1rem 0.5rem; + margin: 0 0 0 -0.5rem; + max-width: 100%; + flex: 1 1 auto; + min-width: 0; + font-family: var(--kt-font); +} +.kt-title-input:hover:not(:disabled) { border-color: var(--kt-border); } +.kt-title-input:focus { border-color: var(--kt-accent); outline: none; background: var(--kt-surface); } +.kt-title-input:disabled { cursor: not-allowed; } + +/* small icon-only button next to the title (e.g. "check for reference match") - borderless/transparent + until hover, matching the title input's own minimal look, instead of standing out as a heavy bordered button */ +.kt-icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.25rem; + height: 2.25rem; + flex-shrink: 0; + border: none; + background: transparent; + border-radius: var(--kt-radius); + color: var(--kt-text-muted); + font-size: 1.2rem; + cursor: pointer; +} +.kt-icon-btn:hover:not(:disabled) { background: var(--kt-surface-3); color: var(--kt-text); } +.kt-icon-btn:disabled { cursor: not-allowed; opacity: 0.5; } + +/* small transient pill next to an inline action button (e.g. the refresh-reference result), instead of a + plain unstyled paragraph that reads like leftover debug output. Same pill shape as .kt-status-badge, with + generic semantic colors instead of TvShowStatus-specific ones. Auto-dismissed from code-behind after a + few seconds - see RefreshReferenceAsync in MovieDetail.razor/TvShowDetail.razor. */ +.kt-inline-toast { + display: inline-flex; + align-items: center; + padding: 0.2rem 0.6rem; + font-size: 0.75rem; + font-weight: 600; + border-radius: 20px; + white-space: nowrap; + animation: kt-toast-in 0.15s ease-out; +} +.kt-inline-toast.success { background: var(--kt-success-bg); color: var(--kt-success); } +.kt-inline-toast.neutral { background: var(--kt-surface-3); color: var(--kt-text-muted); } +.kt-inline-toast.danger { background: var(--kt-danger-bg); color: var(--kt-danger); } +@keyframes kt-toast-in { from { opacity: 0; transform: translateY(-2px); } to { opacity: 1; transform: translateY(0); } } + +/* small "more options" popup anchored under a kt-icon-btn (e.g. a track row's "⋮" menu) - the row it's + anchored to needs position:relative so this absolute box lands directly under the button, not the page. */ +.kt-menu { + position: absolute; + right: 0; + top: 100%; + z-index: 10; + background: var(--kt-surface); + border: 1px solid var(--kt-border); + border-radius: var(--kt-radius-lg); + box-shadow: var(--kt-shadow-lg); + padding: 0.4rem; + min-width: 180px; +} +.kt-menu-item { + display: block; + width: 100%; + text-align: left; + background: transparent; + border: none; + padding: 0.4rem 0.6rem; + border-radius: var(--kt-radius); + color: var(--kt-text); + font-size: 0.85rem; + cursor: pointer; +} +.kt-menu-item:hover { background: var(--kt-surface-3); } + +/* Album/Book/VideoGame detail hero: cover + fields side by side. A fixed grid (not flex-wrap) so the + layout never depends on how much horizontal space the field values happen to need - relying on + flex-wrap's intrinsic-sizing heuristics caused the cover to intermittently land alone on its own row + for some albums (long genre text, etc.) while others stayed side-by-side, with no clear pattern from + the markup alone. */ +.kt-album-hero { + display: grid; + grid-template-columns: 300px minmax(0, 480px); + gap: 1.5rem; + align-items: start; +} +.kt-album-hero.kt-album-hero-no-cover { grid-template-columns: minmax(0, 480px); } +.kt-album-cover { + width: 300px; + height: 300px; + object-fit: cover; + border-radius: var(--kt-radius-lg); +} +@media (max-width: 767px) { + .kt-album-hero, .kt-album-hero.kt-album-hero-no-cover { grid-template-columns: 1fr; } + .kt-album-cover { width: 200px; height: 200px; } +} + +/* compact aligned row for a list of tracks/songs inside a .kt-form-card (e.g. a playlist's song list) - + a CSS grid so title/artist/album line up in columns across rows without resorting to a full + .kt-table-wrap/, which reads as a top-level list page rather than a detail-page sub-section. */ +.kt-track-row { + display: grid; + grid-template-columns: 1fr 180px 180px auto; + align-items: center; + gap: 0.75rem; + padding: 0.55rem 0; + border-top: 1px solid var(--kt-border); +} +.kt-track-row:first-of-type { border-top: none; } /* ── Form card ─────────────────────────────────────────────────────── */ .kt-form-card { + position: relative; background: var(--kt-surface); border: 1px solid var(--kt-border); - border-top: 1px solid rgba(232,160,69,0.3); + border-top: 2px solid var(--kt-accent); border-radius: var(--kt-radius-lg); padding: 1.5rem 1.75rem; margin-bottom: 1.75rem; box-shadow: var(--kt-shadow); } .kt-form-card h5 { - font-family: var(--kt-font-display); - font-style: italic; - color: var(--kt-amber); - font-size: 0.9rem; + color: var(--kt-accent); + font-size: 0.85rem; font-weight: 600; margin-bottom: 1.25rem; text-transform: uppercase; - letter-spacing: 0.08em; + letter-spacing: 0.06em; +} + +/* corner flag: e.g. "watched" status on a movie/show presentation card */ +.kt-corner-flag { + position: absolute; + top: 0; + right: 0; + padding: 0.35rem 0.9rem; + font-size: 0.75rem; + font-weight: 600; + border-radius: 0 var(--kt-radius-lg) 0 var(--kt-radius); + cursor: pointer; + background: var(--kt-surface-3); + color: var(--kt-text-muted); + transition: background var(--kt-transition), color var(--kt-transition); + user-select: none; +} +.kt-corner-flag:hover { background: var(--kt-border-hover); } +.kt-corner-flag.active { + background: var(--kt-success-bg); + color: var(--kt-success); +} + +/* favorite/watchlist toggle buttons on a detail page: a soft tinted "selected" state instead of the + solid btn-primary blue/white block, which read as unnecessarily aggressive, especially in dark mode - + same accent-glow treatment already used for .kt-page-btn.active. */ +.kt-toggle-btn { + background: var(--kt-surface-3) !important; + color: var(--kt-text-muted) !important; + border: 1px solid var(--kt-border) !important; +} +.kt-toggle-btn:hover { background: var(--kt-surface-2) !important; color: var(--kt-text) !important; } +.kt-toggle-btn.active { + background: var(--kt-accent-glow) !important; + color: var(--kt-accent) !important; + border-color: rgba(47, 111, 237, 0.35) !important; } +.kt-toggle-btn.active:hover { background: var(--kt-accent-glow) !important; } + +/* Status/state pill for a list column (TV show status, video game state) - setting the value itself + happens via the explicit buttons on the detail page, not by clicking this) */ +.kt-status-badge { + display: inline-flex; + align-items: center; + padding: 0.2rem 0.6rem; + font-size: 0.75rem; + font-weight: 600; + border-radius: 20px; +} +.kt-status-badge.current { background: var(--kt-warning-bg); color: var(--kt-warning); } +.kt-status-badge.finished { background: var(--kt-success-bg); color: var(--kt-success); } +.kt-status-badge.stopped { background: var(--kt-danger-bg); color: var(--kt-danger); } +/* video game states share "current" above (same in-progress meaning); the remaining three have no + TV show equivalent, so get their own modifiers here. */ +.kt-status-badge.completed { background: var(--kt-success-bg); color: var(--kt-success); } +.kt-status-badge.to-resume { background: var(--kt-warning-bg); color: var(--kt-warning); } +.kt-status-badge.on-hold { background: var(--kt-danger-bg); color: var(--kt-danger); } +.kt-status-badge.available { background: var(--kt-surface-2); color: var(--kt-text-muted); } + +/* ── Progress bar ──────────────────────────────────────────────────── */ +.progress { background-color: var(--kt-surface-3) !important; border-radius: 999px !important; } +.progress-bar { background-color: var(--kt-accent) !important; transition: width 0.3s ease !important; } /* ── Alerts ────────────────────────────────────────────────────────── */ .alert { border-radius: var(--kt-radius) !important; font-size: 0.875rem; padding: 0.8rem 1.1rem !important; } -.alert-danger { background: rgba(192,72,90,0.1) !important; border-color: rgba(192,72,90,0.3) !important; color: #e07080 !important; } -.alert-info { background: rgba(232,160,69,0.06) !important; border-color: rgba(232,160,69,0.18) !important; color: var(--kt-text-muted) !important; } +.alert-danger { background: rgba(199, 58, 82, 0.10) !important; border-color: rgba(199, 58, 82, 0.3) !important; color: var(--kt-danger) !important; } +.alert-info { background: var(--kt-accent-glow) !important; border-color: rgba(47, 111, 237, 0.2) !important; color: var(--kt-text-muted) !important; } /* ── Loading / empty ───────────────────────────────────────────────── */ .kt-loading { display: flex; align-items: center; gap: 0.75rem; color: var(--kt-text-muted); font-size: 0.9rem; padding: 4rem 0; justify-content: center; } -.kt-spinner { width: 18px; height: 18px; border: 2px solid var(--kt-surface-3); border-top-color: var(--kt-amber); border-radius: 50%; animation: spin 0.75s linear infinite; flex-shrink: 0; } +.kt-spinner { width: 18px; height: 18px; border: 2px solid var(--kt-surface-3); border-top-color: var(--kt-accent); border-radius: 50%; animation: spin 0.75s linear infinite; flex-shrink: 0; } @keyframes spin { to { transform: rotate(360deg); } } + +/* plain monochrome glyph (not a color emoji) for a small inline action button, e.g. "check for reference + match" - spins in place using the same animation as .kt-spinner while the action is running */ +.kt-icon-spin { display: inline-block; animation: spin 0.75s linear infinite; } .kt-empty { text-align: center; padding: 5rem 2rem; } .kt-empty-icon { font-size: 2.5rem; color: var(--kt-text-subtle); display: block; margin-bottom: 1rem; } .kt-empty p { font-size: 0.9rem; color: var(--kt-text-muted); margin: 0; line-height: 1.8; } /* ── Stars ─────────────────────────────────────────────────────────── */ -.kt-stars { color: var(--kt-amber); letter-spacing: 0; font-size: 0.85rem; } -.kt-stars .empty { color: var(--kt-text-subtle); } +.kt-stars { color: var(--kt-accent); letter-spacing: 0; font-size: 0.85rem; } + +/* each star is the empty glyph with a precisely-widthed filled glyph overlaid on top, so any decimal + rating (e.g. 4.8) renders as an accurate partial fill, not rounded to the nearest half star */ +.star { position: relative; display: inline-block; font-size: 1.2rem; color: var(--kt-text-subtle); } +.star-fill { + position: absolute; + left: 0; + top: 0; + overflow: hidden; + color: var(--kt-accent); +} /* ── Home ──────────────────────────────────────────────────────────── */ .kt-home { @@ -400,9 +663,9 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0. margin-right: auto; padding-top: 5vh; } -.kt-home-eyebrow { font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.2em; color: var(--kt-amber); margin-bottom: 1.1rem; } -.kt-home h1 { font-size: clamp(2.4rem, 4.5vw, 3.8rem); line-height: 1.12; margin-bottom: 1.25rem; } -.kt-home h1 em { font-style: italic; color: var(--kt-amber); } +.kt-home-eyebrow { font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.16em; color: var(--kt-accent); margin-bottom: 1.1rem; } +.kt-home h1 { font-size: clamp(2.2rem, 4.2vw, 3.4rem); line-height: 1.15; margin-bottom: 1.25rem; } +.kt-home h1 em { font-style: normal; color: var(--kt-accent); } .kt-home-sub { font-size: 1rem; color: var(--kt-text-muted); line-height: 1.75; margin-bottom: 2.5rem; } .kt-home-cta { display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center; } @@ -422,7 +685,7 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0. border: 1px solid var(--kt-border); border-radius: 20px; padding: 0.3rem 0.85rem; - letter-spacing: 0.02em; + letter-spacing: 0.01em; } /* ── Login ─────────────────────────────────────────────────────────── */ @@ -437,7 +700,7 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0. .kt-login-card { background: var(--kt-surface); border: 1px solid var(--kt-border); - border-top: 2px solid var(--kt-amber); + border-top: 2px solid var(--kt-accent); border-radius: var(--kt-radius-lg); padding: 2.25rem 2rem 2rem; width: 100%; @@ -445,8 +708,8 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0. box-shadow: var(--kt-shadow-lg); } -.kt-login-logo { font-family: var(--kt-font-display); font-size: 1.5rem; font-weight: 700; color: var(--kt-text); margin-bottom: 0.35rem; display: flex; align-items: center; gap: 0.45rem; } -.kt-login-logo::before { content: '◈'; color: var(--kt-amber); font-size: 1rem; } +.kt-login-logo { font-size: 1.4rem; font-weight: 700; color: var(--kt-text); margin-bottom: 0.35rem; display: flex; align-items: center; gap: 0.45rem; } +.kt-login-logo::before { content: '◈'; color: var(--kt-accent); font-size: 1rem; } .kt-login-tagline { font-size: 0.82rem; color: var(--kt-text-muted); margin-bottom: 1.75rem; } /* OAuth buttons — flex, icon left, text centered in remaining space */ @@ -461,7 +724,7 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0. margin-bottom: 0.65rem; transition: all var(--kt-transition); cursor: pointer; - font-family: var(--kt-font-ui); + font-family: var(--kt-font); border: 1px solid var(--kt-border); background: var(--kt-surface-2); color: var(--kt-text); @@ -470,7 +733,6 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0. position: relative; } -/* Icon sits at fixed left position */ .btn-oauth svg, .btn-oauth img { position: absolute; @@ -478,7 +740,6 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0. flex-shrink: 0; } -/* Label fills the button and centers itself */ .btn-oauth span.btn-label { flex: 1; text-align: center; @@ -493,9 +754,10 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0. ::-webkit-scrollbar-thumb { background: var(--kt-surface-3); border-radius: 10px; } /* ── Blazor internals ──────────────────────────────────────────────── */ -#blazor-error-ui { background: var(--kt-surface); border-top: 1px solid var(--kt-border); color: var(--kt-text-muted); font-family: var(--kt-font-ui); font-size: 0.82rem; padding: 0.75rem 1.25rem; position: fixed; bottom: 0; width: 100%; z-index: 1000; display: none; } +#blazor-error-ui { background: var(--kt-surface); border-top: 1px solid var(--kt-border); color: var(--kt-text-muted); font-family: var(--kt-font); font-size: 0.82rem; padding: 0.75rem 1.25rem; position: fixed; bottom: 0; width: 100%; z-index: 1000; display: none; } #blazor-error-ui .dismiss { cursor: pointer; float: right; font-weight: 600; } -#components-reconnect-modal { background: var(--kt-surface); border: 1px solid var(--kt-border); border-radius: var(--kt-radius); padding: 0.85rem 1.25rem; position: fixed; bottom: 1.5rem; right: 1.5rem; color: var(--kt-text-muted); font-size: 0.82rem; z-index: 1001; box-shadow: var(--kt-shadow-lg); } +/* Reconnect modal theming lives in Components/Layout/ReconnectModal.razor.css - it's Blazor CSS + isolation, so a scoped selector there always wins the cascade over anything declared here. */ /* ── Responsive ────────────────────────────────────────────────────── */ @media (max-width: 767px) { @@ -506,7 +768,7 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0. .nav-scrollable { display: none; } #nav-toggle:checked ~ .nav-scrollable { display: block; } article.content { padding: 1.5rem 1rem; } - .kt-page-header h1 { font-size: 1.75rem; } + .kt-page-header h1 { font-size: 1.6rem; } .kt-table-wrap { border-radius: var(--kt-radius); } .table thead { display: none; } .table, .table tbody, .table tr, .table td { display: block; width: 100%; } @@ -549,6 +811,17 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0. pointer-events: none; } +/* pushed to the far right of the search bar row via margin-left:auto - the search input/Clear button stay + left-aligned, filter toggles (status, favorites, ...) sit on the opposite end of the same line instead + of a separate row above the table */ +.kt-search-filters { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + margin-left: auto; +} + /* ── Pagination ────────────────────────────────────────────────────── */ .kt-pagination { display: flex; @@ -576,7 +849,7 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0. border: 1px solid var(--kt-border); background: transparent; color: var(--kt-text-muted); - font-family: var(--kt-font-ui); + font-family: var(--kt-font); font-size: 0.82rem; font-weight: 500; cursor: pointer; @@ -593,9 +866,9 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0. } .kt-page-btn.active { - background: var(--kt-amber-glow); - border-color: rgba(232,160,69,0.4); - color: var(--kt-amber); + background: var(--kt-accent-glow); + border-color: rgba(47, 111, 237, 0.4); + color: var(--kt-accent); font-weight: 600; } @@ -610,29 +883,11 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0. color: var(--kt-text-subtle); } -/* ── Stars ────────────────────────────────────────────────────── */ -.star { font-size: 1.2rem; } -.star.full { color: gold; } -.star.empty { color: lightgray; } -.star.half { - position: relative; - display: inline-block; - color: lightgray; -} -.star.half::before { - content: '★'; - position: absolute; - left: 0; - width: 50%; - overflow: hidden; - color: gold; -} - /* ── Modal ─────────────────────────────────────────────────────────── */ .kt-modal-backdrop { position: fixed; inset: 0; - background: rgba(0, 0, 0, 0.65); + background: rgba(10, 12, 16, 0.5); backdrop-filter: blur(3px); z-index: 500; display: flex; @@ -645,7 +900,7 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0. .kt-modal { background: var(--kt-surface); border: 1px solid var(--kt-border); - border-top: 2px solid var(--kt-amber); + border-top: 2px solid var(--kt-accent); border-radius: var(--kt-radius-lg); box-shadow: var(--kt-shadow-lg); width: 100%; @@ -666,14 +921,12 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0. } .kt-modal-header h5 { - font-family: var(--kt-font-display); - font-style: italic; - color: var(--kt-amber); - font-size: 0.9rem; + color: var(--kt-accent); + font-size: 0.85rem; font-weight: 600; margin: 0; text-transform: uppercase; - letter-spacing: 0.08em; + letter-spacing: 0.06em; } .kt-modal-close { @@ -707,13 +960,228 @@ button.nav-link:hover { color: #e07070 !important; background: rgba(192,72,90,0. @keyframes kt-fade-in { from { opacity: 0; } to { opacity: 1; } } @keyframes kt-slide-up { from { transform: translateY(12px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } -/* Mobile: slide up from bottom as a sheet */ @media (max-width: 767px) { .kt-modal-backdrop { align-items: flex-end; padding: 0; } .kt-modal { max-width: 100%; max-height: 95vh; border-radius: var(--kt-radius-lg) var(--kt-radius-lg) 0 0; - border-top: 2px solid var(--kt-amber); + border-top: 2px solid var(--kt-accent); } } + +/* ── Dashboard cards (Watch Next) ──────────────────────────────────── */ +.kt-card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 1rem; +} + +.kt-card { + background: var(--kt-surface); + border: 1px solid var(--kt-border); + border-radius: var(--kt-radius-lg); + padding: 1.25rem; + box-shadow: var(--kt-shadow); + transition: border-color var(--kt-transition), box-shadow var(--kt-transition); +} +.kt-card:hover { border-color: var(--kt-border-hover); } + +.kt-card-title { + font-size: 1rem; + font-weight: 600; + color: var(--kt-text); + margin: 0 0 0.6rem; +} + +.kt-card-badge { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-size: 0.78rem; + font-weight: 600; + color: var(--kt-accent); + background: var(--kt-accent-glow); + border-radius: 20px; + padding: 0.25rem 0.75rem; + margin-bottom: 0.75rem; +} + +.kt-card-meta { + font-size: 0.78rem; + color: var(--kt-text-muted); +} + +.kt-section-title { + font-size: 0.78rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--kt-text-muted); + margin: 2rem 0 1rem; +} +.kt-section-title:first-child { margin-top: 0; } + +/* ── Callouts, sheet tables and hand-rolled SVG charts (shared by CarDetail.razor/HouseDetail.razor) ── + Global (not component-scoped) on purpose: a per-item history table/chart is built from a RenderFragment via + RenderTreeBuilder or a child *Row component, so its markup never receives the page's own CSS-isolation + scope attribute - putting these rules here avoids every consumer having to redeclare them with ::deep. */ +.kt-callout { + background: var(--kt-surface-2); + border-radius: var(--kt-radius); + padding: 0.6rem 0.9rem; + margin: 0 0 0.75rem 0; + font-size: 0.9rem; +} + +.kt-callout-warning { + background: var(--kt-warning-bg); + color: var(--kt-warning); +} + +.kt-callout-danger { + background: var(--kt-danger-bg); + color: var(--kt-danger); +} + +/* Compact history table: a handful of summary columns per row, with the full field set only shown in the + add/edit modal - too many always-visible editable columns read as a spreadsheet dump rather than a + browsable history. */ +.kt-sheet-table { + font-size: 0.85rem; +} + +.kt-sheet-table th { + white-space: nowrap; + color: var(--kt-text-muted); + font-weight: 600; +} + +.kt-sheet-table td { + padding: 0.4rem 0.6rem; + vertical-align: middle; +} + +/* width: 100%, height: auto (not a fixed px height) lets each SVG's own viewBox aspect ratio drive the + rendered height - required since the charts have real axis text, and preserveAspectRatio="none" would + stretch that text unreadably. */ +.kt-chart-svg { + width: 100%; + height: auto; + display: block; +} + +.kt-chart-axis-text { + font-size: 8px; + fill: var(--kt-text-muted); +} + +.kt-chart-axis-title { + font-size: 9px; + font-weight: 600; + fill: var(--kt-text-muted); +} + +.kt-chart-legend { + display: flex; + gap: 1rem; + font-size: 0.8rem; + color: var(--kt-text-muted); + margin-bottom: 0.4rem; +} + +.kt-legend-swatch { + display: inline-block; + width: 0.7rem; + height: 0.7rem; + border-radius: 2px; + margin-right: 0.3rem; + vertical-align: middle; +} + +/* ── Tabs (page-level section switcher, e.g. Watch Next / Wishlist by type) ── */ +.kt-tabs { + display: flex; + gap: 1.5rem; + border-bottom: 1px solid var(--kt-border); + margin-bottom: 1.5rem; + flex-wrap: wrap; +} + +.kt-tab { + padding: 0.65rem 0.1rem; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + color: var(--kt-text-muted); + font-family: var(--kt-font); + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: color var(--kt-transition), border-color var(--kt-transition); + margin-bottom: -1px; +} + +.kt-tab:hover { + color: var(--kt-text); +} + +.kt-tab.active { + color: var(--kt-accent); + border-bottom-color: var(--kt-accent); + font-weight: 600; +} + +.kt-tab-count { + color: var(--kt-text-subtle); + font-weight: 400; + margin-left: 0.3rem; +} + +/* ── Season timeline (TV show detail) ──────────────────────────────── */ +.kt-season-picker { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin-bottom: 1rem; +} + +.kt-season { + background: var(--kt-surface); + border: 1px solid var(--kt-border); + border-radius: var(--kt-radius-lg); + margin-bottom: 1rem; + overflow: hidden; +} + +.kt-season-header { + padding: 0.9rem 1.25rem; + font-weight: 600; + font-size: 0.9rem; + border-bottom: 1px solid var(--kt-border); +} + +.kt-episode-row { + display: flex; + align-items: center; + gap: 0.85rem; + padding: 0.65rem 1.25rem; + border-top: 1px solid var(--kt-border); + font-size: 0.85rem; +} +.kt-episode-row:first-of-type { border-top: none; } +.kt-episode-row.watched { color: var(--kt-text); } +.kt-episode-row.unwatched { color: var(--kt-text-muted); } +.kt-episode-number { font-variant-numeric: tabular-nums; color: var(--kt-text-muted); min-width: 2.5rem; } +.kt-episode-date { margin-left: auto; font-size: 0.78rem; color: var(--kt-text-subtle); } + +/* ── Upload dropzone (Import) ──────────────────────────────────────── */ +.kt-dropzone { + border: 2px dashed var(--kt-border); + border-radius: var(--kt-radius-lg); + padding: 3rem 2rem; + text-align: center; + color: var(--kt-text-muted); + background: var(--kt-surface); +} +.kt-dropzone-icon { font-size: 2.25rem; display: block; margin-bottom: 1rem; color: var(--kt-text-subtle); } diff --git a/src/BlazorApp/wwwroot/download.js b/src/BlazorApp/wwwroot/download.js new file mode 100644 index 00000000..8dc6bb6d --- /dev/null +++ b/src/BlazorApp/wwwroot/download.js @@ -0,0 +1,11 @@ +// Triggers a browser save-as for bytes already fetched by C# (e.g. a zip export) - there's no other way +// to hand raw bytes from a Blazor Server component to the browser's download mechanism, since the file +// only exists in the server-side response, not at a public URL the browser could navigate to directly. +window.ktDownloadFile = (fileName, contentType, base64Data) => { + const link = document.createElement('a'); + link.href = `data:${contentType};base64,${base64Data}`; + link.download = fileName; + document.body.appendChild(link); + link.click(); + link.remove(); +}; diff --git a/src/BlazorApp/wwwroot/theme.js b/src/BlazorApp/wwwroot/theme.js new file mode 100644 index 00000000..7adc2442 --- /dev/null +++ b/src/BlazorApp/wwwroot/theme.js @@ -0,0 +1,15 @@ +function ktApplyTheme() { + var stored = localStorage.getItem('kt-theme'); + var theme = stored || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); + document.documentElement.setAttribute('data-bs-theme', theme); +} + +// runs immediately, before CSS paints, so the very first page load never flashes the wrong theme +ktApplyTheme(); + +function ktToggleTheme() { + var current = document.documentElement.getAttribute('data-bs-theme'); + var next = current === 'dark' ? 'light' : 'dark'; + document.documentElement.setAttribute('data-bs-theme', next); + localStorage.setItem('kt-theme', next); +} diff --git a/src/Common.System/IHasTvTimeId.cs b/src/Common.System/IHasTvTimeId.cs new file mode 100644 index 00000000..707059a3 --- /dev/null +++ b/src/Common.System/IHasTvTimeId.cs @@ -0,0 +1,11 @@ +namespace Keeptrack.Common.System; + +/// +/// A record that can carry the stable id of the TV Time item it was imported from. Lets the import +/// pipeline match and de-duplicate shows and movies by this immutable id generically, independent of +/// their (reference-enrichment-mutable) title. +/// +public interface IHasTvTimeId +{ + public string? TvTimeId { get; set; } +} diff --git a/src/Common.System/TitleNormalizer.cs b/src/Common.System/TitleNormalizer.cs new file mode 100644 index 00000000..dae4a5c1 --- /dev/null +++ b/src/Common.System/TitleNormalizer.cs @@ -0,0 +1,10 @@ +namespace Keeptrack.Common.System; + +/// +/// Shared title normalization for matching by name across the import pipeline and the reference-data +/// layer - one implementation so both never drift apart on what counts as "the same title". +/// +public static class TitleNormalizer +{ + public static string Normalize(string title) => title.Trim().ToLowerInvariant(); +} diff --git a/src/Domain/Models/MusicAlbumModel.cs b/src/Domain/Models/AlbumModel.cs similarity index 67% rename from src/Domain/Models/MusicAlbumModel.cs rename to src/Domain/Models/AlbumModel.cs index 66d15c5b..2132af52 100644 --- a/src/Domain/Models/MusicAlbumModel.cs +++ b/src/Domain/Models/AlbumModel.cs @@ -1,8 +1,8 @@ -using Keeptrack.Common.System; +using Keeptrack.Common.System; namespace Keeptrack.Domain.Models; -public class MusicAlbumModel : IHasIdAndOwnerId +public class AlbumModel : IHasIdAndOwnerId { public string? Id { get; set; } @@ -17,4 +17,8 @@ public class MusicAlbumModel : IHasIdAndOwnerId public string? Genre { get; set; } public float? Rating { get; set; } + + public string? ReferenceId { get; set; } + + public bool IsFavorite { get; set; } } diff --git a/src/Domain/Models/AlbumReferenceModel.cs b/src/Domain/Models/AlbumReferenceModel.cs new file mode 100644 index 00000000..f4e74ab3 --- /dev/null +++ b/src/Domain/Models/AlbumReferenceModel.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using Keeptrack.Common.System; + +namespace Keeptrack.Domain.Models; + +/// +/// Shared, tenant-agnostic album metadata sourced from an external provider (Discogs). +/// See for why this deliberately has no OwnerId. +/// +public class AlbumReferenceModel : IHasId +{ + public string? Id { get; set; } + + public required string Title { get; set; } + + public required string TitleNormalized { get; set; } + + public int? Year { get; set; } + + public string? Synopsis { get; set; } + + /// + /// Points at the shared, deduplicated for this album's artist + /// (keyed by the artist's Discogs id) - the same dedup mechanism as TV/movie cast, just for a single + /// credited individual/group instead of a list. Null when Discogs' response didn't carry an artist id + /// to dedupe by. + /// + public string? ArtistReferenceId { get; set; } + + public required Dictionary ExternalIds { get; set; } + + /// + /// Every (title, year) combination that has ever been confirmed to mean this album - see + /// for the full rationale. + /// + public List MatchedAliases { get; set; } = []; + + public List Genres { get; set; } = []; + + public List Tracks { get; set; } = []; + + public string? ImageUrl { get; set; } + + public DateTime? LastEnrichedAt { get; set; } +} diff --git a/src/Domain/Models/BookModel.cs b/src/Domain/Models/BookModel.cs index f710772d..4715e8aa 100644 --- a/src/Domain/Models/BookModel.cs +++ b/src/Domain/Models/BookModel.cs @@ -15,6 +15,8 @@ public class BookModel : IHasIdAndOwnerId public string? Series { get; set; } + public int? Year { get; set; } + public float? Rating { get; set; } public string? Genre { get; set; } @@ -22,4 +24,12 @@ public class BookModel : IHasIdAndOwnerId public string? Notes { get; set; } public DateOnly? FirstReadAt { get; set; } + + public string? ReferenceId { get; set; } + + public bool IsFavorite { get; set; } + + public bool IsOwned { get; set; } + + public bool IsWishlisted { get; set; } } diff --git a/src/Domain/Models/BookReferenceModel.cs b/src/Domain/Models/BookReferenceModel.cs new file mode 100644 index 00000000..053b8b3d --- /dev/null +++ b/src/Domain/Models/BookReferenceModel.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using Keeptrack.Common.System; + +namespace Keeptrack.Domain.Models; + +/// +/// Shared, tenant-agnostic book metadata sourced from an external provider (Open Library). +/// See for why this deliberately has no OwnerId. +/// +public class BookReferenceModel : IHasId +{ + public string? Id { get; set; } + + public required string Title { get; set; } + + public required string TitleNormalized { get; set; } + + public int? Year { get; set; } + + public string? Synopsis { get; set; } + + /// + /// Points at the shared, deduplicated for this book's author + /// (keyed by the author's Open Library id) - the same dedup mechanism as TV/movie cast, just for a + /// single credited individual instead of a list. Null when Open Library's response didn't carry an + /// author id to dedupe by. + /// + public string? AuthorReferenceId { get; set; } + + public required Dictionary ExternalIds { get; set; } + + /// + /// Every (title, year) combination that has ever been confirmed to mean this book - see + /// for the full rationale. + /// + public List MatchedAliases { get; set; } = []; + + public List Genres { get; set; } = []; + + public string? ImageUrl { get; set; } + + public DateTime? LastEnrichedAt { get; set; } +} diff --git a/src/Domain/Models/CarCostHistoryPointModel.cs b/src/Domain/Models/CarCostHistoryPointModel.cs new file mode 100644 index 00000000..6052c791 --- /dev/null +++ b/src/Domain/Models/CarCostHistoryPointModel.cs @@ -0,0 +1,18 @@ +using System; + +namespace Keeptrack.Domain.Models; + +/// +/// Total cost of ownership for one calendar month - fuel/electric spend and maintenance/other spend tracked +/// separately as well as combined, so the UI can show a breakdown, not just a total. +/// +public class CarCostHistoryPointModel +{ + public required DateOnly Period { get; set; } + + public required double FuelCost { get; set; } + + public required double MaintenanceCost { get; set; } + + public double TotalCost => FuelCost + MaintenanceCost; +} diff --git a/src/Domain/Models/CarEnergyType.cs b/src/Domain/Models/CarEnergyType.cs new file mode 100644 index 00000000..83150a50 --- /dev/null +++ b/src/Domain/Models/CarEnergyType.cs @@ -0,0 +1,8 @@ +namespace Keeptrack.Domain.Models; + +public enum CarEnergyType +{ + Combustion, + Hybrid, + Electric +} diff --git a/src/Domain/Models/CarHistoryModel.cs b/src/Domain/Models/CarHistoryModel.cs index 0af144f1..4c303a11 100644 --- a/src/Domain/Models/CarHistoryModel.cs +++ b/src/Domain/Models/CarHistoryModel.cs @@ -1,4 +1,4 @@ -using System; +using System; using Keeptrack.Common.System; namespace Keeptrack.Domain.Models; @@ -11,14 +11,27 @@ public class CarHistoryModel : IHasIdAndOwnerId public required string CarId { get; set; } - public DateOnly HistoryDate { get; set; } + /// + /// When this event happened. Carries a real time-of-day, not just a calendar day - several entries + /// (e.g. multiple refuels on a long road trip) can share the same date, and only the time lets them + /// sort in the order they actually happened. Defaults to midnight when the time isn't known. + /// + public required DateTime HistoryDate { get; set; } public int? Mileage { get; set; } - public required string Action { get; set; } + public required CarHistoryType EventType { get; set; } + + public string? Description { get; set; } + + public double? Cost { get; set; } public string? City { get; set; } + public string? PostalCode { get; set; } + + public string? Country { get; set; } + public double? Longitude { get; set; } public double? Latitude { get; set; } @@ -29,13 +42,26 @@ public class CarHistoryModel : IHasIdAndOwnerId public double? FuelUnitPrice { get; set; } - public double? Amount { get; set; } + public double? ElectricVolume { get; set; } - public bool? IsFullTank { get; set; } + public double? ElectricUnitPrice { get; set; } - public double? DeltaMileage { get; set; } + public bool? IsFullRefill { get; set; } - public string? LastRefuelHistoryId { get; set; } + /// + /// Distance driven since the previous history entry, as recorded by the user directly (typically read off + /// the car's own trip computer at refuel time) - independent of the odometer reading in . + /// Kept deliberately as real input, not derived: cross-checks + /// it against consecutive readings to catch a mistyped entry or a refuel that was never + /// logged at all - the same manual check the user used to do by hand in a spreadsheet. + /// + public double? DeltaMileage { get; set; } public string? StationBrandName { get; set; } + + /// + /// Garage/auto shop name - the Maintenance/Other-event counterpart of + /// (which only applies to Refuel events). + /// + public string? Garage { get; set; } } diff --git a/src/Domain/Models/CarHistoryType.cs b/src/Domain/Models/CarHistoryType.cs new file mode 100644 index 00000000..f2d57b98 --- /dev/null +++ b/src/Domain/Models/CarHistoryType.cs @@ -0,0 +1,8 @@ +namespace Keeptrack.Domain.Models; + +public enum CarHistoryType +{ + Refuel, + Maintenance, + Other +} diff --git a/src/Domain/Models/CarMetricsModel.cs b/src/Domain/Models/CarMetricsModel.cs new file mode 100644 index 00000000..8d60705d --- /dev/null +++ b/src/Domain/Models/CarMetricsModel.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; + +namespace Keeptrack.Domain.Models; + +public class CarMetricsModel +{ + public required List FuelConsumption { get; set; } + + public double? AverageFuelConsumptionPer100Km { get; set; } + + public required List ElectricConsumption { get; set; } + + public double? AverageElectricConsumptionPer100Km { get; set; } + + public required List CostHistory { get; set; } + + public required double TotalCost { get; set; } + + public required List MileageWarnings { get; set; } + + public NextMaintenanceModel? NextMaintenance { get; set; } +} diff --git a/src/Domain/Models/CarMileageWarningModel.cs b/src/Domain/Models/CarMileageWarningModel.cs new file mode 100644 index 00000000..2f482c47 --- /dev/null +++ b/src/Domain/Models/CarMileageWarningModel.cs @@ -0,0 +1,13 @@ +namespace Keeptrack.Domain.Models; + +/// +/// Flags a CarHistory entry whose recorded mileage doesn't reconcile with its neighbors, so the user can fix +/// the underlying entry - see CarMetricsService. Advisory only: the flagged entry is never excluded from +/// metrics or corrected automatically. +/// +public class CarMileageWarningModel +{ + public required string CarHistoryId { get; set; } + + public required string Message { get; set; } +} diff --git a/src/Domain/Models/CarModel.cs b/src/Domain/Models/CarModel.cs index 71c133aa..c4451401 100644 --- a/src/Domain/Models/CarModel.cs +++ b/src/Domain/Models/CarModel.cs @@ -1,4 +1,4 @@ -using Keeptrack.Common.System; +using Keeptrack.Common.System; namespace Keeptrack.Domain.Models; @@ -9,4 +9,14 @@ public class CarModel : IHasIdAndOwnerId public required string OwnerId { get; set; } public required string Name { get; set; } + + public string? Manufacturer { get; set; } + + public string? Model { get; set; } + + public int? Year { get; set; } + + public string? LicensePlate { get; set; } + + public required CarEnergyType EnergyType { get; set; } } diff --git a/src/Domain/Models/CastMemberModel.cs b/src/Domain/Models/CastMemberModel.cs new file mode 100644 index 00000000..06a4d7f6 --- /dev/null +++ b/src/Domain/Models/CastMemberModel.cs @@ -0,0 +1,15 @@ +namespace Keeptrack.Domain.Models; + +/// +/// Embedded within / - a bounded, +/// always-fetched-with-the-show top-billed cast list. See for the +/// same embedding reasoning. +/// +public class CastMemberModel +{ + public required string PersonReferenceId { get; set; } + + public required string CharacterName { get; set; } + + public int Order { get; set; } +} diff --git a/src/Domain/Models/ConsumptionPointModel.cs b/src/Domain/Models/ConsumptionPointModel.cs new file mode 100644 index 00000000..e3e9ada1 --- /dev/null +++ b/src/Domain/Models/ConsumptionPointModel.cs @@ -0,0 +1,14 @@ +using System; + +namespace Keeptrack.Domain.Models; + +/// +/// One consumption data point, always computed across a full refill/recharge (never a partial one) - see +/// CarMetricsService. +/// +public class ConsumptionPointModel +{ + public required DateOnly Date { get; set; } + + public required double ValuePer100Km { get; set; } +} diff --git a/src/Domain/Models/EpisodeModel.cs b/src/Domain/Models/EpisodeModel.cs new file mode 100644 index 00000000..044825ca --- /dev/null +++ b/src/Domain/Models/EpisodeModel.cs @@ -0,0 +1,21 @@ +using System; +using Keeptrack.Common.System; + +namespace Keeptrack.Domain.Models; + +public class EpisodeModel : IHasIdAndOwnerId +{ + public string? Id { get; set; } + + public required string OwnerId { get; set; } + + public required string TvShowId { get; set; } + + public required int SeasonNumber { get; set; } + + public required int EpisodeNumber { get; set; } + + public DateOnly? WatchedAt { get; set; } + + public string? Notes { get; set; } +} diff --git a/src/Domain/Models/HouseCategoryCostModel.cs b/src/Domain/Models/HouseCategoryCostModel.cs new file mode 100644 index 00000000..7d53343c --- /dev/null +++ b/src/Domain/Models/HouseCategoryCostModel.cs @@ -0,0 +1,8 @@ +namespace Keeptrack.Domain.Models; + +public class HouseCategoryCostModel +{ + public required HouseEventType EventType { get; set; } + + public required double Cost { get; set; } +} diff --git a/src/Domain/Models/HouseCostHistoryPointModel.cs b/src/Domain/Models/HouseCostHistoryPointModel.cs new file mode 100644 index 00000000..c3ac202d --- /dev/null +++ b/src/Domain/Models/HouseCostHistoryPointModel.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; + +namespace Keeptrack.Domain.Models; + +/// +/// Total cost of ownership for one calendar year, both as a single total and broken down by event category - +/// see . +/// +public class HouseCostHistoryPointModel +{ + public required int Year { get; set; } + + public required double TotalCost { get; set; } + + public required List CostByCategory { get; set; } +} diff --git a/src/Domain/Models/HouseEventType.cs b/src/Domain/Models/HouseEventType.cs new file mode 100644 index 00000000..0b32cb2a --- /dev/null +++ b/src/Domain/Models/HouseEventType.cs @@ -0,0 +1,11 @@ +namespace Keeptrack.Domain.Models; + +public enum HouseEventType +{ + Maintenance, + Installation, + Rework, + Purchase, + Bill, + Other +} diff --git a/src/Domain/Models/HouseHistoryModel.cs b/src/Domain/Models/HouseHistoryModel.cs new file mode 100644 index 00000000..dece2988 --- /dev/null +++ b/src/Domain/Models/HouseHistoryModel.cs @@ -0,0 +1,28 @@ +using System; +using Keeptrack.Common.System; + +namespace Keeptrack.Domain.Models; + +public class HouseHistoryModel : IHasIdAndOwnerId +{ + public string? Id { get; set; } + + public required string OwnerId { get; set; } + + public required string HouseId { get; set; } + + public required DateOnly HistoryDate { get; set; } + + public required HouseEventType EventType { get; set; } + + public string? Description { get; set; } + + public double? Cost { get; set; } + + /// + /// Contractor/technician/utility company/store name - a single field covering every category, unlike + /// Car's Refuel-only StationBrandName vs Maintenance-only Garage split, since House has no event type + /// where "who was involved" doesn't apply. + /// + public string? Provider { get; set; } +} diff --git a/src/Domain/Models/HouseMetricsModel.cs b/src/Domain/Models/HouseMetricsModel.cs new file mode 100644 index 00000000..e9e7f3b5 --- /dev/null +++ b/src/Domain/Models/HouseMetricsModel.cs @@ -0,0 +1,8 @@ +using System.Collections.Generic; + +namespace Keeptrack.Domain.Models; + +public class HouseMetricsModel +{ + public required List CostHistory { get; set; } +} diff --git a/src/Domain/Models/HouseModel.cs b/src/Domain/Models/HouseModel.cs new file mode 100644 index 00000000..3b20f0fc --- /dev/null +++ b/src/Domain/Models/HouseModel.cs @@ -0,0 +1,22 @@ +using Keeptrack.Common.System; + +namespace Keeptrack.Domain.Models; + +public class HouseModel : IHasIdAndOwnerId +{ + public string? Id { get; set; } + + public required string OwnerId { get; set; } + + public required string Name { get; set; } + + public string? Address { get; set; } + + public string? City { get; set; } + + public string? PostalCode { get; set; } + + public string? Country { get; set; } + + public string? Notes { get; set; } +} diff --git a/src/Domain/Models/InProgressShowModel.cs b/src/Domain/Models/InProgressShowModel.cs new file mode 100644 index 00000000..5a391be8 --- /dev/null +++ b/src/Domain/Models/InProgressShowModel.cs @@ -0,0 +1,27 @@ +using System; + +namespace Keeptrack.Domain.Models; + +/// +/// A TV show in progress, with the last episode Keeptrack knows was watched and the next confirmed-unseen +/// one from its TMDB episode guide. Only shows with a resolved reference and a confirmed aired-but-unwatched +/// episode are reported at all - see WatchNextService. +/// +public class InProgressShowModel +{ + public required string TvShowId { get; set; } + + public required string TvShowTitle { get; set; } + + public required int LastSeasonNumber { get; set; } + + public required int LastEpisodeNumber { get; set; } + + public DateOnly? LastWatchedAt { get; set; } + + public required int NextSeasonNumber { get; set; } + + public required int NextEpisodeNumber { get; set; } + + public required string NextEpisodeTitle { get; set; } +} diff --git a/src/Domain/Models/MovieModel.cs b/src/Domain/Models/MovieModel.cs index aa84358c..32ef735a 100644 --- a/src/Domain/Models/MovieModel.cs +++ b/src/Domain/Models/MovieModel.cs @@ -3,7 +3,7 @@ namespace Keeptrack.Domain.Models; -public class MovieModel : IHasIdAndOwnerId +public class MovieModel : IHasIdAndOwnerId, IHasTvTimeId { public string? Id { get; set; } @@ -11,17 +11,29 @@ public class MovieModel : IHasIdAndOwnerId public required string Title { get; set; } + /// + /// Stable identifier for the item this record was imported from (TV Time's own per-movie tracking + /// uuid, or a title-derived fallback when the export carries none). Set once at import time and never + /// rewritten by reference enrichment, so a re-import matches by this id instead of the + /// enrichment-mutable Title and can't create a duplicate. Null for movies created outside the import. + /// + public string? TvTimeId { get; set; } + public int? Year { get; set; } public float? Rating { get; set; } - public string? Genre { get; set; } - public string? Notes { get; set; } - public string? ImdbPageId { get; set; } - - public string? AllocineId { get; set; } + public string? ReferenceId { get; set; } public DateOnly? FirstSeenAt { get; set; } + + public bool IsFavorite { get; set; } + + public bool WantToWatch { get; set; } + + public bool IsOwned { get; set; } + + public bool IsWishlisted { get; set; } } diff --git a/src/Domain/Models/MovieReferenceModel.cs b/src/Domain/Models/MovieReferenceModel.cs new file mode 100644 index 00000000..169f7106 --- /dev/null +++ b/src/Domain/Models/MovieReferenceModel.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using Keeptrack.Common.System; + +namespace Keeptrack.Domain.Models; + +/// +/// Shared, tenant-agnostic movie metadata sourced from an external provider such as TMDB. +/// See for why this deliberately has no OwnerId. +/// +public class MovieReferenceModel : IHasId +{ + public string? Id { get; set; } + + public required string Title { get; set; } + + public required string TitleNormalized { get; set; } + + public int? Year { get; set; } + + public string? Synopsis { get; set; } + + public required Dictionary ExternalIds { get; set; } + + /// + /// Every (title, year) combination that has ever been confirmed (via TMDB resolution, automatic or + /// admin-picked) to mean this movie - not just /. See + /// for the full rationale. + /// + public List MatchedAliases { get; set; } = []; + + public List Genres { get; set; } = []; + + public List Cast { get; set; } = []; + + public string? ImageUrl { get; set; } + + public DateTime? LastEnrichedAt { get; set; } +} diff --git a/src/Domain/Models/NextMaintenanceModel.cs b/src/Domain/Models/NextMaintenanceModel.cs new file mode 100644 index 00000000..774c9ebf --- /dev/null +++ b/src/Domain/Models/NextMaintenanceModel.cs @@ -0,0 +1,16 @@ +using System; + +namespace Keeptrack.Domain.Models; + +/// +/// Estimated next maintenance due date, assuming a fixed 1-year cadence from the last recorded maintenance +/// event. Only ever produced when a maintenance history actually exists - see CarMetricsService. +/// +public class NextMaintenanceModel +{ + public required DateOnly LastMaintenanceDate { get; set; } + + public required DateOnly DueDate { get; set; } + + public required int MonthsRemaining { get; set; } +} diff --git a/src/Domain/Models/PersonReferenceModel.cs b/src/Domain/Models/PersonReferenceModel.cs new file mode 100644 index 00000000..d7cadb06 --- /dev/null +++ b/src/Domain/Models/PersonReferenceModel.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using Keeptrack.Common.System; + +namespace Keeptrack.Domain.Models; + +/// +/// Shared, owner-less actor/person metadata sourced from an external provider such as TMDB. Deduplicated +/// across every show/movie that credits them - matched by external provider id, not by name (unlike +/// /, credits already give an exact id). +/// +public class PersonReferenceModel : IHasId +{ + public string? Id { get; set; } + + public required string Name { get; set; } + + public string? ProfileImageUrl { get; set; } + + public required Dictionary ExternalIds { get; set; } +} diff --git a/src/Domain/Models/PlaylistModel.cs b/src/Domain/Models/PlaylistModel.cs new file mode 100644 index 00000000..bdef6e8c --- /dev/null +++ b/src/Domain/Models/PlaylistModel.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using Keeptrack.Common.System; + +namespace Keeptrack.Domain.Models; + +public class PlaylistModel : IHasIdAndOwnerId +{ + public string? Id { get; set; } + + public required string OwnerId { get; set; } + + public required string Title { get; set; } + + /// + /// Ids of documents, in playback order - the list's own order is the + /// playlist order, there's no separate position field. + /// + public List SongIds { get; set; } = []; +} diff --git a/src/Domain/Models/PlaythroughModel.cs b/src/Domain/Models/PlaythroughModel.cs new file mode 100644 index 00000000..e0cb18d3 --- /dev/null +++ b/src/Domain/Models/PlaythroughModel.cs @@ -0,0 +1,16 @@ +using System; + +namespace Keeptrack.Domain.Models; + +/// +/// One recorded run through a game - the base playthrough, an NG+ replay, a speedrun, or any other +/// attempt the tenant wants to log. is free text rather than a fixed enum because +/// replays don't fit a closed set (NG+1, NG+2, a side-run "just for fun", ...). +/// is optional so an in-progress or abandoned run can still be logged. +/// +public class PlaythroughModel +{ + public required string Label { get; set; } + + public DateOnly? CompletedAt { get; set; } +} diff --git a/src/Domain/Models/ReferenceEpisodeModel.cs b/src/Domain/Models/ReferenceEpisodeModel.cs new file mode 100644 index 00000000..d075736e --- /dev/null +++ b/src/Domain/Models/ReferenceEpisodeModel.cs @@ -0,0 +1,20 @@ +using System; + +namespace Keeptrack.Domain.Models; + +/// +/// One episode's reference metadata (title, air date), embedded within a . +/// Unlike the per-tenant (a separate collection, since it grows unbounded per +/// user over years and is queried across shows for Watch Next), this list is bounded to one show's real +/// runtime and always fetched as a whole, so embedding is the right call here. +/// +public class ReferenceEpisodeModel +{ + public required int SeasonNumber { get; set; } + + public required int EpisodeNumber { get; set; } + + public required string Title { get; set; } + + public DateOnly? AirDate { get; set; } +} diff --git a/src/Domain/Models/ReferenceMatchModel.cs b/src/Domain/Models/ReferenceMatchModel.cs new file mode 100644 index 00000000..4fc58aa8 --- /dev/null +++ b/src/Domain/Models/ReferenceMatchModel.cs @@ -0,0 +1,27 @@ +namespace Keeptrack.Domain.Models; + +/// +/// One (title, year) combination ever confirmed - via TMDB resolution, automatic or admin-picked - to mean +/// a specific show/movie, embedded within a /. +/// See for the full rationale: a title-only match isn't +/// enough because two tenants (or a tenant and TMDB's own canonical data) can legitimately record different +/// years for the exact same show/movie, so year has to travel with the specific title variant it was +/// confirmed under, not live as a single scalar on the reference document. +/// +public class ReferenceMatchModel +{ + public required string Title { get; set; } + + public int? Year { get; set; } + + /// + /// Normalized book author / album artist this (title, year) combination was confirmed under - null + /// for domains with no creator concept (TV show, movie, video game). Required for Book/Album matching + /// specifically: two different tenants' different books/albums can easily share a common (title, year) + /// (e.g. two different novels both titled "Echoes" published the same year) - without creator in the + /// match key, the second tenant's item would incorrectly latch onto the first tenant's reference + /// document. Deliberately generic name, not "Author"/"Artist" - see 's + /// own naming rationale, which this follows. + /// + public string? Creator { get; set; } +} diff --git a/src/Domain/Models/ReferenceTrackModel.cs b/src/Domain/Models/ReferenceTrackModel.cs new file mode 100644 index 00000000..6c1995be --- /dev/null +++ b/src/Domain/Models/ReferenceTrackModel.cs @@ -0,0 +1,21 @@ +namespace Keeptrack.Domain.Models; + +/// +/// One track's reference metadata (position, title, duration), embedded within an +/// - same embedding rationale as +/// : bounded to one album's real tracklist and always fetched as a whole. +/// +public class ReferenceTrackModel +{ + /// + /// Discogs' own position label - not always numeric (vinyl releases use side+track like "A1"/"B2"). + /// + public required string Position { get; set; } + + public required string Title { get; set; } + + /// + /// Discogs' own "M:SS" text, often absent - carried through as display text, not parsed. + /// + public string? Duration { get; set; } +} diff --git a/src/Domain/Models/SongModel.cs b/src/Domain/Models/SongModel.cs new file mode 100644 index 00000000..9625e7ca --- /dev/null +++ b/src/Domain/Models/SongModel.cs @@ -0,0 +1,35 @@ +using Keeptrack.Common.System; + +namespace Keeptrack.Domain.Models; + +public class SongModel : IHasIdAndOwnerId +{ + public string? Id { get; set; } + + public required string OwnerId { get; set; } + + public required string Title { get; set; } + + public string? Artist { get; set; } + + /// + /// Optional link to the tenant's own - a soft reference, like + /// ReferenceId elsewhere in the codebase: no cascade-delete or enforcement if the album is + /// later removed. + /// + public string? AlbumId { get; set; } + + /// + /// Discogs' own "M:SS" text, usually filled in by picking a track from the linked album's reference + /// tracklist (see ) rather than typed by hand. + /// + public string? Duration { get; set; } + + /// + /// The this song was created from - combined with + /// , this is a track-based song's real identity: picking the same track twice + /// must reuse the same , not create a duplicate (see + /// SongApiClient.GetOrCreateForTrackAsync). Null for a song not tied to a specific tracklist entry. + /// + public string? TrackPosition { get; set; } +} diff --git a/src/Domain/Models/TvShowModel.cs b/src/Domain/Models/TvShowModel.cs index 0a50131d..3dac6ae4 100644 --- a/src/Domain/Models/TvShowModel.cs +++ b/src/Domain/Models/TvShowModel.cs @@ -1,9 +1,8 @@ -using System; -using Keeptrack.Common.System; +using Keeptrack.Common.System; namespace Keeptrack.Domain.Models; -public class TvShowModel : IHasIdAndOwnerId +public class TvShowModel : IHasIdAndOwnerId, IHasTvTimeId { public string? Id { get; set; } @@ -11,6 +10,14 @@ public class TvShowModel : IHasIdAndOwnerId public required string Title { get; set; } + /// + /// Stable identifier for the item this record was imported from (TV Time's own show id, or a + /// title-derived fallback when the export carries none). Set once at import time and never rewritten + /// by reference enrichment, so a re-import matches by this id instead of the enrichment-mutable Title + /// and can't create a duplicate. Null for shows created outside the import. + /// + public string? TvTimeId { get; set; } + public int? Year { get; set; } public float? Rating { get; set; } @@ -19,9 +26,15 @@ public class TvShowModel : IHasIdAndOwnerId public string? LastEpisodeSeen { get; set; } - public string? ImdbPageId { get; set; } + public string? ReferenceId { get; set; } + + public TvShowStatus? State { get; set; } + + public bool IsFavorite { get; set; } + + public bool WantToWatch { get; set; } - public string? AllocineId { get; set; } + public bool IsOwned { get; set; } - public DateOnly? FinishedAt { get; set; } + public bool IsWishlisted { get; set; } } diff --git a/src/Domain/Models/TvShowReferenceModel.cs b/src/Domain/Models/TvShowReferenceModel.cs new file mode 100644 index 00000000..80b886af --- /dev/null +++ b/src/Domain/Models/TvShowReferenceModel.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using Keeptrack.Common.System; + +namespace Keeptrack.Domain.Models; + +/// +/// Shared, tenant-agnostic show metadata (title, synopsis, episode list) sourced from an external +/// provider such as TMDB. Deliberately has no OwnerId - unlike every other collection in +/// Keeptrack, this one carries no user content, only public facts about a real show, so storing it +/// once and pointing every tenant's own at it avoids duplicating +/// the same show's data per user. +/// +public class TvShowReferenceModel : IHasId +{ + public string? Id { get; set; } + + public required string Title { get; set; } + + public required string TitleNormalized { get; set; } + + public int? Year { get; set; } + + public string? Synopsis { get; set; } + + /// + /// Provider name (e.g. "tmdb") to that provider's id for this show. Keeps the document itself + /// source-agnostic: adding a second provider later is just another dictionary entry, not a schema change. + /// + public required Dictionary ExternalIds { get; set; } + + /// + /// Every (title, year) combination that has ever been confirmed (via TMDB resolution, automatic or + /// admin-picked) to mean this show - not just /. A tenant + /// typing a different-language title, a typo, or a regional variant that an admin resolved to this same + /// TMDB entry gets remembered here, so the next tenant with that exact same (title, year) matches + /// instantly without a fresh TMDB search. Year travels with its specific title variant rather than living + /// as a single scalar on this document, because two tenants (or a tenant and TMDB's own canonical value) + /// can legitimately disagree on the year for the same real show - a single top-level year would reject a + /// tenant whose recorded year genuinely differs from whichever one happens to be canonical. Always + /// includes paired with . + /// + public List MatchedAliases { get; set; } = []; + + public List Episodes { get; set; } = []; + + public List Genres { get; set; } = []; + + public List Cast { get; set; } = []; + + public string? ImageUrl { get; set; } + + public DateTime? LastEnrichedAt { get; set; } +} diff --git a/src/Domain/Models/TvShowStatus.cs b/src/Domain/Models/TvShowStatus.cs new file mode 100644 index 00000000..2fafe297 --- /dev/null +++ b/src/Domain/Models/TvShowStatus.cs @@ -0,0 +1,15 @@ +namespace Keeptrack.Domain.Models; + +/// +/// A user's own general watch state for a show - distinct from per-episode watched dates. Nullable on +/// : unset means "no state chosen yet", not a default of any one value. +/// The enum type itself keeps the "Status" name (only 's property name was +/// renamed for parity with - VideoGame's State is a plain string with no +/// equivalent enum to rename). +/// +public enum TvShowStatus +{ + Current, + Finished, + Stopped +} diff --git a/src/Domain/Models/VideoGameCopyType.cs b/src/Domain/Models/VideoGameCopyType.cs new file mode 100644 index 00000000..47510d9a --- /dev/null +++ b/src/Domain/Models/VideoGameCopyType.cs @@ -0,0 +1,7 @@ +namespace Keeptrack.Domain.Models; + +public enum VideoGameCopyType +{ + Physical, + Digital +} diff --git a/src/Domain/Models/VideoGameModel.cs b/src/Domain/Models/VideoGameModel.cs index c23d5445..4e9c45ed 100644 --- a/src/Domain/Models/VideoGameModel.cs +++ b/src/Domain/Models/VideoGameModel.cs @@ -1,4 +1,4 @@ -using System; +using System.Collections.Generic; using Keeptrack.Common.System; namespace Keeptrack.Domain.Models; @@ -11,9 +11,7 @@ public class VideoGameModel : IHasIdAndOwnerId public required string Title { get; set; } - public required string Platform { get; set; } - - public required string State { get; set; } + public List Platforms { get; set; } = []; public int? Year { get; set; } @@ -21,5 +19,23 @@ public class VideoGameModel : IHasIdAndOwnerId public string? Notes { get; set; } - public DateOnly? FinishedAt { get; set; } + public string? ReferenceId { get; set; } + + public bool IsOwned { get; set; } + + public bool IsWishlisted { get; set; } + + /// + /// Filter-only: matches if any entry in has this platform. Never persisted - + /// this property exists solely so can keep doubling as the filter-probe + /// object passed to IDataRepository{TModel}.FindAllAsync, the same convention every other + /// repository already relies on. + /// + public string? Platform { get; set; } + + /// + /// Filter-only: matches if any entry in has this state. Never persisted - see + /// . + /// + public string? State { get; set; } } diff --git a/src/Domain/Models/VideoGamePlatformModel.cs b/src/Domain/Models/VideoGamePlatformModel.cs new file mode 100644 index 00000000..6d4d3614 --- /dev/null +++ b/src/Domain/Models/VideoGamePlatformModel.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; + +namespace Keeptrack.Domain.Models; + +/// +/// One entry per platform a game is tracked on for a given tenant - not one entry per physical copy. +/// Owning two PS5 discs of the same game is still a single entry with "PS5". +/// / ("platinum"/100%) live here rather than +/// on or on a single entry, because full +/// completion is a per-platform fact - a PS5 platinum says nothing about Xbox/PC progress on the same game. +/// +public class VideoGamePlatformModel +{ + public required string Platform { get; set; } + + public VideoGameCopyType CopyType { get; set; } + + public string State { get; set; } = ""; + + public List Playthroughs { get; set; } = []; + + public bool IsFullyCompleted { get; set; } + + public DateOnly? FullyCompletedAt { get; set; } +} diff --git a/src/Domain/Models/VideoGameReferenceModel.cs b/src/Domain/Models/VideoGameReferenceModel.cs new file mode 100644 index 00000000..e7f474af --- /dev/null +++ b/src/Domain/Models/VideoGameReferenceModel.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using Keeptrack.Common.System; + +namespace Keeptrack.Domain.Models; + +/// +/// Shared, tenant-agnostic video game metadata sourced from an external provider (RAWG). +/// See for why this deliberately has no OwnerId. +/// +public class VideoGameReferenceModel : IHasId +{ + public string? Id { get; set; } + + public required string Title { get; set; } + + public required string TitleNormalized { get; set; } + + public int? Year { get; set; } + + public string? Synopsis { get; set; } + + /// + /// Every platform RAWG lists as a release target for this game - read-only reference info, distinct + /// from , which is the tenant's own "played on" free-text field + /// and is never overwritten by reference data. + /// + public List Platforms { get; set; } = []; + + public required Dictionary ExternalIds { get; set; } + + /// + /// Every (title, year) combination that has ever been confirmed to mean this game - see + /// for the full rationale. + /// + public List MatchedAliases { get; set; } = []; + + public List Genres { get; set; } = []; + + public string? ImageUrl { get; set; } + + public DateTime? LastEnrichedAt { get; set; } +} diff --git a/src/Domain/Repositories/IAlbumReferenceRepository.cs b/src/Domain/Repositories/IAlbumReferenceRepository.cs new file mode 100644 index 00000000..ed16bbb4 --- /dev/null +++ b/src/Domain/Repositories/IAlbumReferenceRepository.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Keeptrack.Domain.Models; + +namespace Keeptrack.Domain.Repositories; + +/// +/// Repository for the shared, owner-less album reference collection. See +/// for why this doesn't extend . +/// +public interface IAlbumReferenceRepository +{ + Task FindByIdAsync(string id); + + /// + /// is required (not optional) as part of the match key, not just a search + /// hint: two different tenants' different albums can easily share the same (title, year) - a generic + /// title re-released the same year is common - so title+year alone risks silently linking a tenant + /// to another tenant's unrelated album. See . + /// + Task FindByTitleYearAsync(string title, int? year, string artist); + + /// + /// Title-only fallback match (normalized, ignores year) for "or just title" matching when a + /// title+year lookup finds nothing - still requires to match, for the same + /// reason does. + /// + Task FindByTitleAsync(string title, string artist); + + /// + /// Looks up a reference document by external provider id (e.g. its Discogs master id) - the strongest + /// possible "is this genuinely the same album" signal, unaffected by title text ever diverging. + /// + Task FindByExternalIdAsync(string provider, string externalId); + + Task UpsertAsync(AlbumReferenceModel model); + + /// + /// Every reference document, for admin export. Bounded, shared metadata (not per-tenant), so a + /// full unpaged read is fine. + /// + Task> FindAllAsync(); +} diff --git a/src/Domain/Repositories/IAlbumRepository.cs b/src/Domain/Repositories/IAlbumRepository.cs new file mode 100644 index 00000000..5df8c4f8 --- /dev/null +++ b/src/Domain/Repositories/IAlbumRepository.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Keeptrack.Domain.Models; + +namespace Keeptrack.Domain.Repositories; + +public interface IAlbumRepository : IDataRepository +{ + /// + /// Sets , , , + /// and (to the reference's canonical values) + /// on every tenant's album matching this title/year that doesn't already have a reference link - see + /// . + /// + Task SetReferenceLinkAsync(string title, int? year, string referenceId, string canonicalTitle, int? canonicalYear = null, string? canonicalArtist = null, string? canonicalGenre = null); + + /// + /// Distinct (title, year) pairs across every tenant's albums that have no + /// yet - feeds the admin curation queue. + /// + Task> FindDistinctUnresolvedTitleYearsAsync(); +} diff --git a/src/Domain/Repositories/IBookReferenceRepository.cs b/src/Domain/Repositories/IBookReferenceRepository.cs new file mode 100644 index 00000000..d97da182 --- /dev/null +++ b/src/Domain/Repositories/IBookReferenceRepository.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Keeptrack.Domain.Models; + +namespace Keeptrack.Domain.Repositories; + +/// +/// Repository for the shared, owner-less book reference collection. See +/// for why this doesn't extend . +/// +public interface IBookReferenceRepository +{ + Task FindByIdAsync(string id); + + /// + /// is required (not optional) as part of the match key, not just a search + /// hint: two different tenants' different books can easily share the same (title, year) - a generic + /// title re-published the same year is common - so title+year alone risks silently linking a tenant + /// to another tenant's unrelated book. See . + /// + Task FindByTitleYearAsync(string title, int? year, string author); + + /// + /// Title-only fallback match (normalized, ignores year) for "or just title" matching when a + /// title+year lookup finds nothing - still requires to match, for the same + /// reason does. + /// + Task FindByTitleAsync(string title, string author); + + /// + /// Looks up a reference document by external provider id (e.g. its Open Library work id) - the + /// strongest possible "is this genuinely the same book" signal, unaffected by title text ever diverging. + /// + Task FindByExternalIdAsync(string provider, string externalId); + + Task UpsertAsync(BookReferenceModel model); + + /// + /// Every reference document, for admin export. Bounded, shared metadata (not per-tenant), so a + /// full unpaged read is fine. + /// + Task> FindAllAsync(); +} diff --git a/src/Domain/Repositories/IBookRepository.cs b/src/Domain/Repositories/IBookRepository.cs index ea11127e..bb911cc4 100644 --- a/src/Domain/Repositories/IBookRepository.cs +++ b/src/Domain/Repositories/IBookRepository.cs @@ -1,7 +1,22 @@ -using Keeptrack.Domain.Models; +using System.Collections.Generic; +using System.Threading.Tasks; +using Keeptrack.Domain.Models; namespace Keeptrack.Domain.Repositories; public interface IBookRepository : IDataRepository { + /// + /// Sets , , , + /// and (to the reference's canonical values) + /// on every tenant's book matching this title/year that doesn't already have a reference link - see + /// . + /// + Task SetReferenceLinkAsync(string title, int? year, string referenceId, string canonicalTitle, int? canonicalYear = null, string? canonicalAuthor = null, string? canonicalGenre = null); + + /// + /// Distinct (title, year) pairs across every tenant's books that have no + /// yet - feeds the admin curation queue. + /// + Task> FindDistinctUnresolvedTitleYearsAsync(); } diff --git a/src/Domain/Repositories/ICarHistoryRepository.cs b/src/Domain/Repositories/ICarHistoryRepository.cs index beb7921f..24861512 100644 --- a/src/Domain/Repositories/ICarHistoryRepository.cs +++ b/src/Domain/Repositories/ICarHistoryRepository.cs @@ -1,7 +1,14 @@ -using Keeptrack.Domain.Models; +using System.Threading.Tasks; +using Keeptrack.Domain.Models; namespace Keeptrack.Domain.Repositories; public interface ICarHistoryRepository : IDataRepository { + /// + /// Deletes every history entry owned by for the given car - used to cascade + /// a car deletion, since CarHistory is a separate top-level collection referencing its parent by id + /// rather than an embedded array (see CLAUDE.md's "Child entities" section). + /// + Task DeleteAllForCarAsync(string carId, string ownerId); } diff --git a/src/Domain/Repositories/IEpisodeRepository.cs b/src/Domain/Repositories/IEpisodeRepository.cs new file mode 100644 index 00000000..9c642d57 --- /dev/null +++ b/src/Domain/Repositories/IEpisodeRepository.cs @@ -0,0 +1,7 @@ +using Keeptrack.Domain.Models; + +namespace Keeptrack.Domain.Repositories; + +public interface IEpisodeRepository : IDataRepository +{ +} diff --git a/src/Domain/Repositories/IHouseHistoryRepository.cs b/src/Domain/Repositories/IHouseHistoryRepository.cs new file mode 100644 index 00000000..529c335b --- /dev/null +++ b/src/Domain/Repositories/IHouseHistoryRepository.cs @@ -0,0 +1,14 @@ +using System.Threading.Tasks; +using Keeptrack.Domain.Models; + +namespace Keeptrack.Domain.Repositories; + +public interface IHouseHistoryRepository : IDataRepository +{ + /// + /// Deletes every history entry owned by for the given house - used to cascade + /// a house deletion, since HouseHistory is a separate top-level collection referencing its parent by id + /// rather than an embedded array (see CLAUDE.md's "Child entities" section). + /// + Task DeleteAllForHouseAsync(string houseId, string ownerId); +} diff --git a/src/Domain/Repositories/IHouseRepository.cs b/src/Domain/Repositories/IHouseRepository.cs new file mode 100644 index 00000000..0c9c57ee --- /dev/null +++ b/src/Domain/Repositories/IHouseRepository.cs @@ -0,0 +1,7 @@ +using Keeptrack.Domain.Models; + +namespace Keeptrack.Domain.Repositories; + +public interface IHouseRepository : IDataRepository +{ +} diff --git a/src/Domain/Repositories/IMovieReferenceRepository.cs b/src/Domain/Repositories/IMovieReferenceRepository.cs new file mode 100644 index 00000000..65f8ff41 --- /dev/null +++ b/src/Domain/Repositories/IMovieReferenceRepository.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Keeptrack.Domain.Models; + +namespace Keeptrack.Domain.Repositories; + +/// +/// Repository for the shared, owner-less movie reference collection. See +/// for why this doesn't extend . +/// +public interface IMovieReferenceRepository +{ + Task FindByIdAsync(string id); + + Task FindByTitleYearAsync(string title, int? year); + + /// + /// Title-only fallback match (normalized, ignores year) for "or just title" matching when a + /// title+year lookup finds nothing - e.g. the tenant's recorded year is wrong or missing. + /// + Task FindByTitleAsync(string title); + + /// + /// Looks up a reference document by external provider id (e.g. its TMDB id) - the strongest possible + /// "is this genuinely the same movie" signal, unaffected by title text ever diverging. See + /// for the equivalent on cast members. + /// + Task FindByExternalIdAsync(string provider, string externalId); + + Task UpsertAsync(MovieReferenceModel model); + + /// + /// Every reference document, for admin export. Bounded, shared metadata (not per-tenant), so a + /// full unpaged read is fine. + /// + Task> FindAllAsync(); +} diff --git a/src/Domain/Repositories/IMovieRepository.cs b/src/Domain/Repositories/IMovieRepository.cs index 3cd62121..153eeb79 100644 --- a/src/Domain/Repositories/IMovieRepository.cs +++ b/src/Domain/Repositories/IMovieRepository.cs @@ -1,7 +1,21 @@ -using Keeptrack.Domain.Models; +using System.Collections.Generic; +using System.Threading.Tasks; +using Keeptrack.Domain.Models; namespace Keeptrack.Domain.Repositories; public interface IMovieRepository : IDataRepository { + /// + /// Sets , and + /// (to the reference's canonical values) on every tenant's movie matching this title/year that doesn't + /// already have a reference link - see . + /// + Task SetReferenceLinkAsync(string title, int? year, string referenceId, string canonicalTitle, int? canonicalYear = null); + + /// + /// Distinct (title, year) pairs across every tenant's movies that have no + /// yet - feeds the admin curation queue. + /// + Task> FindDistinctUnresolvedTitleYearsAsync(); } diff --git a/src/Domain/Repositories/IMusicAlbumRepository.cs b/src/Domain/Repositories/IMusicAlbumRepository.cs deleted file mode 100644 index d88f5072..00000000 --- a/src/Domain/Repositories/IMusicAlbumRepository.cs +++ /dev/null @@ -1,5 +0,0 @@ -using Keeptrack.Domain.Models; - -namespace Keeptrack.Domain.Repositories; - -public interface IMusicAlbumRepository : IDataRepository; diff --git a/src/Domain/Repositories/IPersonReferenceRepository.cs b/src/Domain/Repositories/IPersonReferenceRepository.cs new file mode 100644 index 00000000..aed8d881 --- /dev/null +++ b/src/Domain/Repositories/IPersonReferenceRepository.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Keeptrack.Domain.Models; + +namespace Keeptrack.Domain.Repositories; + +/// +/// Repository for the shared, owner-less person/actor reference collection. See +/// for why this doesn't extend . +/// +public interface IPersonReferenceRepository +{ + Task FindByIdAsync(string id); + + Task FindByExternalIdAsync(string provider, string externalId); + + Task UpsertAsync(PersonReferenceModel model); + + /// + /// Every reference document, for admin export. Bounded, shared metadata (not per-tenant), so a + /// full unpaged read is fine. + /// + Task> FindAllAsync(); +} diff --git a/src/Domain/Repositories/IPlaylistRepository.cs b/src/Domain/Repositories/IPlaylistRepository.cs new file mode 100644 index 00000000..bc8b96e9 --- /dev/null +++ b/src/Domain/Repositories/IPlaylistRepository.cs @@ -0,0 +1,7 @@ +using Keeptrack.Domain.Models; + +namespace Keeptrack.Domain.Repositories; + +public interface IPlaylistRepository : IDataRepository +{ +} diff --git a/src/Domain/Repositories/ISongRepository.cs b/src/Domain/Repositories/ISongRepository.cs new file mode 100644 index 00000000..addb72ef --- /dev/null +++ b/src/Domain/Repositories/ISongRepository.cs @@ -0,0 +1,7 @@ +using Keeptrack.Domain.Models; + +namespace Keeptrack.Domain.Repositories; + +public interface ISongRepository : IDataRepository +{ +} diff --git a/src/Domain/Repositories/ITvShowReferenceRepository.cs b/src/Domain/Repositories/ITvShowReferenceRepository.cs new file mode 100644 index 00000000..cb03aca0 --- /dev/null +++ b/src/Domain/Repositories/ITvShowReferenceRepository.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Keeptrack.Domain.Models; + +namespace Keeptrack.Domain.Repositories; + +/// +/// Repository for the shared, owner-less TV show reference collection. Deliberately not +/// - that interface (and its Mongo base class) is hard-constrained +/// to owner-scoped paged CRUD, which doesn't fit a shared lookup table. +/// +public interface ITvShowReferenceRepository +{ + Task FindByIdAsync(string id); + + Task FindByTitleYearAsync(string title, int? year); + + /// + /// Title-only fallback match (normalized, ignores year) for "or just title" matching when a + /// title+year lookup finds nothing - e.g. the tenant's recorded year is wrong or missing. + /// + Task FindByTitleAsync(string title); + + /// + /// Looks up a reference document by external provider id (e.g. its TMDB id) - the strongest possible + /// "is this genuinely the same show" signal, unaffected by title text ever diverging. See + /// for the equivalent on cast members. + /// + Task FindByExternalIdAsync(string provider, string externalId); + + Task UpsertAsync(TvShowReferenceModel model); + + /// + /// Every reference document, for admin export. Bounded, shared metadata (not per-tenant), so a + /// full unpaged read is fine. + /// + Task> FindAllAsync(); +} diff --git a/src/Domain/Repositories/ITvShowRepository.cs b/src/Domain/Repositories/ITvShowRepository.cs index fce4caa8..1af6d952 100644 --- a/src/Domain/Repositories/ITvShowRepository.cs +++ b/src/Domain/Repositories/ITvShowRepository.cs @@ -1,7 +1,24 @@ -using Keeptrack.Domain.Models; +using System.Collections.Generic; +using System.Threading.Tasks; +using Keeptrack.Domain.Models; namespace Keeptrack.Domain.Repositories; public interface ITvShowRepository : IDataRepository { + /// + /// Sets and (to the reference's + /// canonical name) on every tenant's show matching this title/year that doesn't already have a + /// reference link - the confirmed cross-tenant propagation for reference-data linking. Also sets + /// to when the reference has one, so a + /// newly-linked show starts with a trustworthy year instead of whatever the tenant originally guessed + /// (still freely editable afterward). Otherwise never touches any tenant's own rating/notes/episodes. + /// + Task SetReferenceLinkAsync(string title, int? year, string referenceId, string canonicalTitle, int? canonicalYear = null); + + /// + /// Distinct (title, year) pairs across every tenant's shows that have no + /// yet - feeds the admin curation queue. + /// + Task> FindDistinctUnresolvedTitleYearsAsync(); } diff --git a/src/Domain/Repositories/IVideoGameReferenceRepository.cs b/src/Domain/Repositories/IVideoGameReferenceRepository.cs new file mode 100644 index 00000000..981d250b --- /dev/null +++ b/src/Domain/Repositories/IVideoGameReferenceRepository.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Keeptrack.Domain.Models; + +namespace Keeptrack.Domain.Repositories; + +/// +/// Repository for the shared, owner-less video game reference collection. See +/// for why this doesn't extend . +/// +public interface IVideoGameReferenceRepository +{ + Task FindByIdAsync(string id); + + Task FindByTitleYearAsync(string title, int? year); + + /// + /// Title-only fallback match (normalized, ignores year) for "or just title" matching when a + /// title+year lookup finds nothing. + /// + Task FindByTitleAsync(string title); + + /// + /// Looks up a reference document by external provider id (e.g. its RAWG game id) - the strongest + /// possible "is this genuinely the same game" signal, unaffected by title text ever diverging. + /// + Task FindByExternalIdAsync(string provider, string externalId); + + Task UpsertAsync(VideoGameReferenceModel model); + + /// + /// Every reference document, for admin export. Bounded, shared metadata (not per-tenant), so a + /// full unpaged read is fine. + /// + Task> FindAllAsync(); +} diff --git a/src/Domain/Repositories/IVideoGameRepository.cs b/src/Domain/Repositories/IVideoGameRepository.cs index a8709581..7aa13c1d 100644 --- a/src/Domain/Repositories/IVideoGameRepository.cs +++ b/src/Domain/Repositories/IVideoGameRepository.cs @@ -1,7 +1,21 @@ -using Keeptrack.Domain.Models; +using System.Collections.Generic; +using System.Threading.Tasks; +using Keeptrack.Domain.Models; namespace Keeptrack.Domain.Repositories; public interface IVideoGameRepository : IDataRepository { + /// + /// Sets , and + /// (to the reference's canonical values) on every tenant's game matching + /// this title/year that doesn't already have a reference link - see . + /// + Task SetReferenceLinkAsync(string title, int? year, string referenceId, string canonicalTitle, int? canonicalYear = null); + + /// + /// Distinct (title, year) pairs across every tenant's games that have no + /// yet - feeds the admin curation queue. + /// + Task> FindDistinctUnresolvedTitleYearsAsync(); } diff --git a/src/Domain/Services/CarMetricsService.cs b/src/Domain/Services/CarMetricsService.cs new file mode 100644 index 00000000..b8a27c32 --- /dev/null +++ b/src/Domain/Services/CarMetricsService.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Keeptrack.Domain.Models; + +namespace Keeptrack.Domain.Services; + +/// +/// Computes fuel/electric consumption, cost history, mileage-consistency warnings and a next-maintenance-due +/// estimate from a car's full intervention history. Pure computation over an in-memory list, same shape as +/// WatchNextService - nothing here is persisted, so a car's metrics are always derived fresh from its history. +/// +public class CarMetricsService +{ + private const double MileageToleranceKm = 1; + + public CarMetricsModel ComputeMetrics(IEnumerable history) + { + var historyList = history.ToList(); + + return new CarMetricsModel + { + FuelConsumption = ComputeConsumption(historyList, h => h.FuelVolume, out var averageFuel), + AverageFuelConsumptionPer100Km = averageFuel, + ElectricConsumption = ComputeConsumption(historyList, h => h.ElectricVolume, out var averageElectric), + AverageElectricConsumptionPer100Km = averageElectric, + CostHistory = ComputeCostHistory(historyList), + TotalCost = historyList.Sum(h => h.Cost ?? 0), + MileageWarnings = ComputeMileageWarnings(historyList), + NextMaintenance = ComputeNextMaintenanceDue(historyList) + }; + } + + /// + /// Shared trip-computer algorithm for both fuel (liters) and electric (kWh) consumption - a data point is + /// only ever emitted across a full refill/recharge (): the + /// quantity added by every refill since the last full one is accumulated, and once a full refill closes + /// that window, the accumulated quantity is divided by the mileage covered since the previous full refill. + /// A partial refill on its own never produces a data point. + /// + private static List ComputeConsumption( + IReadOnlyList history, Func quantitySelector, out double? average) + { + var refuels = history + .Where(h => h.EventType == CarHistoryType.Refuel && h.Mileage is not null && quantitySelector(h) is not null) + .OrderBy(h => h.Mileage) + .ToList(); + + var points = new List(); + var quantitySinceLastFullRefill = 0.0; + CarHistoryModel? lastFullRefill = null; + + foreach (var refuel in refuels) + { + quantitySinceLastFullRefill += quantitySelector(refuel)!.Value; + + if (refuel.IsFullRefill != true) continue; + + if (lastFullRefill is not null) + { + var distance = refuel.Mileage!.Value - lastFullRefill.Mileage!.Value; + if (distance > 0) + { + points.Add(new ConsumptionPointModel + { + Date = DateOnly.FromDateTime(refuel.HistoryDate), + ValuePer100Km = quantitySinceLastFullRefill / distance * 100 + }); + } + } + + lastFullRefill = refuel; + quantitySinceLastFullRefill = 0; + } + + average = points.Count > 0 ? points.Average(p => p.ValuePer100Km) : null; + return points; + } + + private static List ComputeCostHistory(IReadOnlyList history) => + history + .Where(h => h.Cost is not null) + .GroupBy(h => new DateOnly(h.HistoryDate.Year, h.HistoryDate.Month, 1)) + .OrderBy(g => g.Key) + .Select(g => new CarCostHistoryPointModel + { + Period = g.Key, + FuelCost = g.Where(h => h.EventType == CarHistoryType.Refuel).Sum(h => h.Cost!.Value), + MaintenanceCost = g.Where(h => h.EventType != CarHistoryType.Refuel).Sum(h => h.Cost!.Value) + }) + .ToList(); + + /// + /// The automated version of the manual Excel cross-check the user used to do by hand: a regression check + /// (the odometer can never go backwards) plus a delta cross-check between the user-entered + /// (typically read off the car's trip computer at refuel time) + /// and the computed mileage difference between consecutive entries. A mismatch means either number was + /// mistyped, or - if the computed delta is larger than the entered one - an entry was never logged at all. + /// Advisory only: nothing here excludes an entry from the metrics above or corrects it automatically. + /// + private static List ComputeMileageWarnings(IReadOnlyList history) + { + var ordered = history + .Where(h => h.Mileage is not null) + .OrderBy(h => h.HistoryDate) + .ToList(); + + var warnings = new List(); + + for (var i = 1; i < ordered.Count; i++) + { + var previous = ordered[i - 1]; + var current = ordered[i]; + + if (current.Mileage < previous.Mileage) + { + warnings.Add(new CarMileageWarningModel + { + CarHistoryId = current.Id!, + Message = $"Mileage {current.Mileage} on {current.HistoryDate:yyyy-MM-dd} is lower than {previous.Mileage} " + + $"recorded on {previous.HistoryDate:yyyy-MM-dd}." + }); + continue; + } + + if (current.DeltaMileage is null) continue; + + var computedDelta = current.Mileage!.Value - previous.Mileage!.Value; + if (Math.Abs(computedDelta - current.DeltaMileage.Value) <= MileageToleranceKm) continue; + + var suggestsMissingEntry = computedDelta > current.DeltaMileage.Value; + warnings.Add(new CarMileageWarningModel + { + CarHistoryId = current.Id!, + Message = $"Entered delta mileage ({current.DeltaMileage}) doesn't match the {computedDelta} " + + $"difference between this entry's mileage ({current.Mileage}) and the previous entry's " + + $"({previous.Mileage} on {previous.HistoryDate:yyyy-MM-dd})." + + (suggestsMissingEntry ? " An entry may be missing in between." : " Check both entries for a typo.") + }); + } + + return warnings; + } + + /// + /// Assumes a fixed 1-year maintenance cadence from the last recorded maintenance event. Returns null - never + /// a guess - when no maintenance history exists yet, same "don't guess without data" principle as + /// WatchNextService. + /// + private static NextMaintenanceModel? ComputeNextMaintenanceDue(IReadOnlyList history) + { + var lastMaintenance = history + .Where(h => h.EventType == CarHistoryType.Maintenance) + .OrderByDescending(h => h.HistoryDate) + .FirstOrDefault(); + + if (lastMaintenance is null) return null; + + var lastMaintenanceDate = DateOnly.FromDateTime(lastMaintenance.HistoryDate); + var dueDate = lastMaintenanceDate.AddYears(1); + var today = DateOnly.FromDateTime(DateTime.Today); + var monthsRemaining = ((dueDate.Year - today.Year) * 12) + (dueDate.Month - today.Month); + + return new NextMaintenanceModel + { + LastMaintenanceDate = lastMaintenanceDate, + DueDate = dueDate, + MonthsRemaining = monthsRemaining + }; + } +} diff --git a/src/Domain/Services/HouseMetricsService.cs b/src/Domain/Services/HouseMetricsService.cs new file mode 100644 index 00000000..70f87d8c --- /dev/null +++ b/src/Domain/Services/HouseMetricsService.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using System.Linq; +using Keeptrack.Domain.Models; + +namespace Keeptrack.Domain.Services; + +/// +/// Pure, stateless computation over a house's history - no persistence of its own, same shape as +/// /. Deliberately limited to a yearly cost +/// breakdown: House has no reminders/due-date engine (unlike Car's ComputeNextMaintenanceDue) by design - +/// the owner tracks recurring bills/maintenance elsewhere and only wants an exhaustive record plus a yearly +/// cost review here. +/// +public class HouseMetricsService +{ + public HouseMetricsModel ComputeMetrics(IEnumerable history) => + new() { CostHistory = ComputeAnnualCostHistory(history) }; + + private static List ComputeAnnualCostHistory(IEnumerable history) => + history + .Where(h => h.Cost is not null) + .GroupBy(h => h.HistoryDate.Year) + .OrderBy(g => g.Key) + .Select(g => new HouseCostHistoryPointModel + { + Year = g.Key, + TotalCost = g.Sum(h => h.Cost!.Value), + CostByCategory = g.GroupBy(h => h.EventType) + .Select(cg => new HouseCategoryCostModel { EventType = cg.Key, Cost = cg.Sum(h => h.Cost!.Value) }) + .OrderBy(c => c.EventType) + .ToList() + }) + .ToList(); +} diff --git a/src/Domain/Services/WatchNextService.cs b/src/Domain/Services/WatchNextService.cs new file mode 100644 index 00000000..c35ff80c --- /dev/null +++ b/src/Domain/Services/WatchNextService.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Keeptrack.Domain.Models; + +namespace Keeptrack.Domain.Services; + +/// +/// Computes, for each in-progress TV show, whether there is a confirmed unseen episode to watch next. +/// +public static class WatchNextService +{ + /// + /// A show only appears here if it's marked + /// AND has a resolved TMDB episode guide () confirming an aired episode exists after the last one watched. + /// Without a reference, Keeptrack has no episode-guide data and can't tell whether a further episode actually exists (the show might already be fully caught up) - + /// so unlinked shows are excluded rather than guessed at, the same "don't guess" principle as before, now enforced by data. + /// + public static List ComputeInProgressShows( + IEnumerable shows, + IEnumerable episodes, + IReadOnlyDictionary referencesByShowId) + { + var currentShows = shows.Where(s => s.State == Domain.Models.TvShowStatus.Current).ToDictionary(s => s.Id!); + var today = DateOnly.FromDateTime(DateTime.Today); + + return episodes + .Where(e => currentShows.ContainsKey(e.TvShowId)) + .GroupBy(e => e.TvShowId) + .Select(group => + { + var show = currentShows[group.Key]; + var lastWatched = group + .OrderByDescending(e => e.SeasonNumber) + .ThenByDescending(e => e.EpisodeNumber) + .First(); + + if (!referencesByShowId.TryGetValue(group.Key, out var reference)) return null; + + var nextEpisode = reference.Episodes + .Where(e => e.SeasonNumber > lastWatched.SeasonNumber + || (e.SeasonNumber == lastWatched.SeasonNumber && e.EpisodeNumber > lastWatched.EpisodeNumber)) + .Where(e => e.AirDate is null || e.AirDate <= today) + .OrderBy(e => e.SeasonNumber) + .ThenBy(e => e.EpisodeNumber) + .FirstOrDefault(); + if (nextEpisode is null) return null; + + return new InProgressShowModel + { + TvShowId = show.Id!, + TvShowTitle = show.Title, + LastSeasonNumber = lastWatched.SeasonNumber, + LastEpisodeNumber = lastWatched.EpisodeNumber, + LastWatchedAt = lastWatched.WatchedAt, + NextSeasonNumber = nextEpisode.SeasonNumber, + NextEpisodeNumber = nextEpisode.EpisodeNumber, + NextEpisodeTitle = nextEpisode.Title + }; + }) + .Where(model => model is not null) + .Select(model => model!) + .OrderByDescending(n => n.LastWatchedAt) + .ToList(); + } + + /// + /// A movie flagged "want to watch" that has since been marked seen shouldn't linger in the watchlist - + /// mirrors MovieTrackingEventsCsvParser's "towatch" handling, which never flags an already-watched movie in the first place. + /// Toggling "want to watch" directly on a movie's own detail page doesn't clear the flag on watch, so this is filtered here rather than relying on the flag never going stale. + /// + public static List FilterMoviesToWatch(IEnumerable movies) => + movies.Where(m => m.FirstSeenAt is null).ToList(); +} diff --git a/src/Domain/Services/WishlistService.cs b/src/Domain/Services/WishlistService.cs new file mode 100644 index 00000000..a4f226ae --- /dev/null +++ b/src/Domain/Services/WishlistService.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.Linq; +using Keeptrack.Domain.Models; + +namespace Keeptrack.Domain.Services; + +/// +/// Assembles the cross-type wishlist view: each list is expected to already be filtered to IsWishlisted == true by the repository query (see WishlistController), +/// so this only sorts each type's results alphabetically by title for a stable, scannable display order. +/// +public static class WishlistService +{ + public static List SortMovies(IEnumerable movies) => + movies.OrderBy(m => m.Title).ToList(); + + public static List SortTvShows(IEnumerable tvShows) => + tvShows.OrderBy(s => s.Title).ToList(); + + public static List SortBooks(IEnumerable books) => + books.OrderBy(b => b.Title).ToList(); + + public static List SortVideoGames(IEnumerable videoGames) => + videoGames.OrderBy(g => g.Title).ToList(); +} diff --git a/src/Infrastructure.MongoDb/Entities/MusicAlbum.cs b/src/Infrastructure.MongoDb/Entities/Album.cs similarity index 69% rename from src/Infrastructure.MongoDb/Entities/MusicAlbum.cs rename to src/Infrastructure.MongoDb/Entities/Album.cs index 301a99aa..a1220577 100644 --- a/src/Infrastructure.MongoDb/Entities/MusicAlbum.cs +++ b/src/Infrastructure.MongoDb/Entities/Album.cs @@ -1,10 +1,10 @@ -using Keeptrack.Common.System; +using Keeptrack.Common.System; using MongoDB.Bson; using MongoDB.Bson.Serialization.Attributes; namespace Keeptrack.Infrastructure.MongoDb.Entities; -public class MusicAlbum : IHasIdAndOwnerId +public class Album : IHasIdAndOwnerId { [BsonId] [BsonRepresentation(BsonType.ObjectId)] @@ -22,4 +22,10 @@ public class MusicAlbum : IHasIdAndOwnerId public string? Genre { get; set; } public float? Rating { get; set; } + + [BsonElement("reference_id")] + public string? ReferenceId { get; set; } + + [BsonElement("is_favorite")] + public bool IsFavorite { get; set; } } diff --git a/src/Infrastructure.MongoDb/Entities/AlbumReference.cs b/src/Infrastructure.MongoDb/Entities/AlbumReference.cs new file mode 100644 index 00000000..40e8b693 --- /dev/null +++ b/src/Infrastructure.MongoDb/Entities/AlbumReference.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace Keeptrack.Infrastructure.MongoDb.Entities; + +/// +/// Shared, owner-less album metadata collection (album_reference). See +/// for why this has no owner_id. +/// +public class AlbumReference +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string? Id { get; set; } + + public required string Title { get; set; } + + [BsonElement("title_normalized")] + public required string TitleNormalized { get; set; } + + public int? Year { get; set; } + + public string? Synopsis { get; set; } + + [BsonElement("artist_reference_id")] + public string? ArtistReferenceId { get; set; } + + [BsonElement("external_ids")] + public required Dictionary ExternalIds { get; set; } + + [BsonElement("matched_aliases")] + public List MatchedAliases { get; set; } = []; + + public List Genres { get; set; } = []; + + public List Tracks { get; set; } = []; + + [BsonElement("image_url")] + public string? ImageUrl { get; set; } + + [BsonElement("last_enriched_at")] + public DateTime? LastEnrichedAt { get; set; } +} diff --git a/src/Infrastructure.MongoDb/Entities/Allocine.cs b/src/Infrastructure.MongoDb/Entities/Allocine.cs deleted file mode 100644 index 9a6801a3..00000000 --- a/src/Infrastructure.MongoDb/Entities/Allocine.cs +++ /dev/null @@ -1,11 +0,0 @@ -using MongoDB.Bson; -using MongoDB.Bson.Serialization.Attributes; - -namespace Keeptrack.Infrastructure.MongoDb.Entities; - -public class Allocine -{ - [BsonId] - [BsonRepresentation(BsonType.ObjectId)] - public string Id { get; set; } = null!; -} diff --git a/src/Infrastructure.MongoDb/Entities/Book.cs b/src/Infrastructure.MongoDb/Entities/Book.cs index d9e05067..5d24f79c 100644 --- a/src/Infrastructure.MongoDb/Entities/Book.cs +++ b/src/Infrastructure.MongoDb/Entities/Book.cs @@ -20,6 +20,8 @@ public class Book : IHasIdAndOwnerId public string? Series { get; set; } + public int? Year { get; set; } + public float? Rating { get; set; } public string? Genre { get; set; } @@ -28,4 +30,16 @@ public class Book : IHasIdAndOwnerId [BsonElement("first_read_at")] public DateTime? FirstReadAt { get; set; } + + [BsonElement("reference_id")] + public string? ReferenceId { get; set; } + + [BsonElement("is_favorite")] + public bool IsFavorite { get; set; } + + [BsonElement("is_owned")] + public bool IsOwned { get; set; } + + [BsonElement("is_wishlisted")] + public bool IsWishlisted { get; set; } } diff --git a/src/Infrastructure.MongoDb/Entities/BookReference.cs b/src/Infrastructure.MongoDb/Entities/BookReference.cs new file mode 100644 index 00000000..5ff70d90 --- /dev/null +++ b/src/Infrastructure.MongoDb/Entities/BookReference.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace Keeptrack.Infrastructure.MongoDb.Entities; + +/// +/// Shared, owner-less book metadata collection (book_reference). See +/// for why this has no owner_id. +/// +public class BookReference +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string? Id { get; set; } + + public required string Title { get; set; } + + [BsonElement("title_normalized")] + public required string TitleNormalized { get; set; } + + public int? Year { get; set; } + + public string? Synopsis { get; set; } + + [BsonElement("author_reference_id")] + public string? AuthorReferenceId { get; set; } + + [BsonElement("external_ids")] + public required Dictionary ExternalIds { get; set; } + + [BsonElement("matched_aliases")] + public List MatchedAliases { get; set; } = []; + + public List Genres { get; set; } = []; + + [BsonElement("image_url")] + public string? ImageUrl { get; set; } + + [BsonElement("last_enriched_at")] + public DateTime? LastEnrichedAt { get; set; } +} diff --git a/src/Infrastructure.MongoDb/Entities/Car.cs b/src/Infrastructure.MongoDb/Entities/Car.cs index fac6fe07..246bc6de 100644 --- a/src/Infrastructure.MongoDb/Entities/Car.cs +++ b/src/Infrastructure.MongoDb/Entities/Car.cs @@ -1,4 +1,5 @@ -using Keeptrack.Common.System; +using Keeptrack.Common.System; +using Keeptrack.Domain.Models; using MongoDB.Bson; using MongoDB.Bson.Serialization.Attributes; @@ -15,4 +16,16 @@ public class Car : IHasIdAndOwnerId [BsonElement("commercial_name")] public required string Name { get; set; } + + public string? Manufacturer { get; set; } + + public string? Model { get; set; } + + public int? Year { get; set; } + + [BsonElement("license_plate")] + public string? LicensePlate { get; set; } + + [BsonElement("energy_type")] + public required CarEnergyType EnergyType { get; set; } } diff --git a/src/Infrastructure.MongoDb/Entities/CarHistory.cs b/src/Infrastructure.MongoDb/Entities/CarHistory.cs index 43fd9191..f0808d67 100644 --- a/src/Infrastructure.MongoDb/Entities/CarHistory.cs +++ b/src/Infrastructure.MongoDb/Entities/CarHistory.cs @@ -1,6 +1,6 @@ -using System; -using System.Collections.Generic; +using System; using Keeptrack.Common.System; +using Keeptrack.Domain.Models; using MongoDB.Bson; using MongoDB.Bson.Serialization.Attributes; @@ -19,17 +19,22 @@ public class CarHistory : IHasIdAndOwnerId public required string CarId { get; set; } [BsonElement("history_date")] - public DateTime HistoryDate { get; set; } + public required DateTime HistoryDate { get; set; } public double? Mileage { get; set; } - public required string Action { get; set; } + [BsonElement("event_type")] + public required CarHistoryType EventType { get; set; } - public CarHistoryLocation? Location { get; set; } + public string? Description { get; set; } + + public double? Cost { get; set; } - public List? Coordinates { get; set; } + public CarHistoryLocation? Location { get; set; } public CarHistoryFuel? Fuel { get; set; } public CarHistoryStation? Station { get; set; } + + public string? Garage { get; set; } } diff --git a/src/Infrastructure.MongoDb/Entities/CarHistoryFuel.cs b/src/Infrastructure.MongoDb/Entities/CarHistoryFuel.cs index 9fb11507..635032cc 100644 --- a/src/Infrastructure.MongoDb/Entities/CarHistoryFuel.cs +++ b/src/Infrastructure.MongoDb/Entities/CarHistoryFuel.cs @@ -1,25 +1,25 @@ -using MongoDB.Bson; using MongoDB.Bson.Serialization.Attributes; namespace Keeptrack.Infrastructure.MongoDb.Entities; public class CarHistoryFuel { - public required string Category { get; set; } + public string? Category { get; set; } public double? Volume { get; set; } [BsonElement("unit_price")] public double? UnitPrice { get; set; } - public double? Amount { get; set; } + [BsonElement("electric_volume")] + public double? ElectricVolume { get; set; } - [BsonElement("is_full_tank")] - public bool? IsFullTank { get; set; } + [BsonElement("electric_unit_price")] + public double? ElectricUnitPrice { get; set; } + + [BsonElement("is_full_refill")] + public bool? IsFullRefill { get; set; } [BsonElement("delta_mileage")] public double? DeltaMileage { get; set; } - - [BsonElement("last_refuel_history_id")] - public ObjectId? LastRefuelHistoryId { get; set; } } diff --git a/src/Infrastructure.MongoDb/Entities/CarHistoryLocation.cs b/src/Infrastructure.MongoDb/Entities/CarHistoryLocation.cs index 3f2f76b1..ac195665 100644 --- a/src/Infrastructure.MongoDb/Entities/CarHistoryLocation.cs +++ b/src/Infrastructure.MongoDb/Entities/CarHistoryLocation.cs @@ -1,6 +1,16 @@ -namespace Keeptrack.Infrastructure.MongoDb.Entities; +using System.Collections.Generic; +using MongoDB.Bson.Serialization.Attributes; + +namespace Keeptrack.Infrastructure.MongoDb.Entities; public class CarHistoryLocation { public required string City { get; set; } + + [BsonElement("postal_code")] + public string? PostalCode { get; set; } + + public string? Country { get; set; } + + public List? Coordinates { get; set; } } diff --git a/src/Infrastructure.MongoDb/Entities/CastMember.cs b/src/Infrastructure.MongoDb/Entities/CastMember.cs new file mode 100644 index 00000000..db68d9e0 --- /dev/null +++ b/src/Infrastructure.MongoDb/Entities/CastMember.cs @@ -0,0 +1,18 @@ +using MongoDB.Bson.Serialization.Attributes; + +namespace Keeptrack.Infrastructure.MongoDb.Entities; + +/// +/// Embedded within / - see +/// for the embedding rationale. +/// +public class CastMember +{ + [BsonElement("person_reference_id")] + public required string PersonReferenceId { get; set; } + + [BsonElement("character_name")] + public required string CharacterName { get; set; } + + public int Order { get; set; } +} diff --git a/src/Infrastructure.MongoDb/Entities/Episode.cs b/src/Infrastructure.MongoDb/Entities/Episode.cs new file mode 100644 index 00000000..e5eac283 --- /dev/null +++ b/src/Infrastructure.MongoDb/Entities/Episode.cs @@ -0,0 +1,30 @@ +using System; +using Keeptrack.Common.System; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace Keeptrack.Infrastructure.MongoDb.Entities; + +public class Episode : IHasIdAndOwnerId +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string? Id { get; set; } + + [BsonElement("owner_id")] + public required string OwnerId { get; set; } + + [BsonElement("tv_show_id")] + public required string TvShowId { get; set; } + + [BsonElement("season_number")] + public required int SeasonNumber { get; set; } + + [BsonElement("episode_number")] + public required int EpisodeNumber { get; set; } + + [BsonElement("watched_at")] + public DateTime? WatchedAt { get; set; } + + public string? Notes { get; set; } +} diff --git a/src/Infrastructure.MongoDb/Entities/House.cs b/src/Infrastructure.MongoDb/Entities/House.cs new file mode 100644 index 00000000..0bc6e1d2 --- /dev/null +++ b/src/Infrastructure.MongoDb/Entities/House.cs @@ -0,0 +1,28 @@ +using Keeptrack.Common.System; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace Keeptrack.Infrastructure.MongoDb.Entities; + +public class House : IHasIdAndOwnerId +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string? Id { get; set; } + + [BsonElement("owner_id")] + public required string OwnerId { get; set; } + + public required string Name { get; set; } + + public string? Address { get; set; } + + public string? City { get; set; } + + [BsonElement("postal_code")] + public string? PostalCode { get; set; } + + public string? Country { get; set; } + + public string? Notes { get; set; } +} diff --git a/src/Infrastructure.MongoDb/Entities/HouseHistory.cs b/src/Infrastructure.MongoDb/Entities/HouseHistory.cs new file mode 100644 index 00000000..44c0286c --- /dev/null +++ b/src/Infrastructure.MongoDb/Entities/HouseHistory.cs @@ -0,0 +1,32 @@ +using System; +using Keeptrack.Common.System; +using Keeptrack.Domain.Models; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace Keeptrack.Infrastructure.MongoDb.Entities; + +public class HouseHistory : IHasIdAndOwnerId +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string? Id { get; set; } + + [BsonElement("owner_id")] + public required string OwnerId { get; set; } + + [BsonElement("house_id")] + public required string HouseId { get; set; } + + [BsonElement("history_date")] + public required DateTime HistoryDate { get; set; } + + [BsonElement("event_type")] + public required HouseEventType EventType { get; set; } + + public string? Description { get; set; } + + public double? Cost { get; set; } + + public string? Provider { get; set; } +} diff --git a/src/Infrastructure.MongoDb/Entities/Imdb.cs b/src/Infrastructure.MongoDb/Entities/Imdb.cs deleted file mode 100644 index 3d85cf12..00000000 --- a/src/Infrastructure.MongoDb/Entities/Imdb.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Keeptrack.Infrastructure.MongoDb.Entities; - -public class Imdb -{ - public required string PageId { get; set; } -} diff --git a/src/Infrastructure.MongoDb/Entities/Movie.cs b/src/Infrastructure.MongoDb/Entities/Movie.cs index 628ef7ac..3a81d40d 100644 --- a/src/Infrastructure.MongoDb/Entities/Movie.cs +++ b/src/Infrastructure.MongoDb/Entities/Movie.cs @@ -16,18 +16,30 @@ public class Movie : IHasIdAndOwnerId public required string Title { get; set; } + [BsonElement("tv_time_id")] + public string? TvTimeId { get; set; } + public int? Year { get; set; } public float? Rating { get; set; } - public string? Genre { get; set; } - public string? Notes { get; set; } - public Imdb? Imdb { get; set; } - - public Allocine? Allocine { get; set; } + [BsonElement("reference_id")] + public string? ReferenceId { get; set; } [BsonElement("first_seen_at")] public DateTime? FirstSeenAt { get; set; } + + [BsonElement("is_favorite")] + public bool IsFavorite { get; set; } + + [BsonElement("want_to_watch")] + public bool WantToWatch { get; set; } + + [BsonElement("is_owned")] + public bool IsOwned { get; set; } + + [BsonElement("is_wishlisted")] + public bool IsWishlisted { get; set; } } diff --git a/src/Infrastructure.MongoDb/Entities/MovieReference.cs b/src/Infrastructure.MongoDb/Entities/MovieReference.cs new file mode 100644 index 00000000..6b4cbd22 --- /dev/null +++ b/src/Infrastructure.MongoDb/Entities/MovieReference.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace Keeptrack.Infrastructure.MongoDb.Entities; + +/// +/// Shared, owner-less movie metadata collection (movie_reference). See +/// for why this has no owner_id. +/// +public class MovieReference +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string? Id { get; set; } + + public required string Title { get; set; } + + [BsonElement("title_normalized")] + public required string TitleNormalized { get; set; } + + public int? Year { get; set; } + + public string? Synopsis { get; set; } + + [BsonElement("external_ids")] + public required Dictionary ExternalIds { get; set; } + + [BsonElement("matched_aliases")] + public List MatchedAliases { get; set; } = []; + + public List Genres { get; set; } = []; + + public List Cast { get; set; } = []; + + [BsonElement("image_url")] + public string? ImageUrl { get; set; } + + [BsonElement("last_enriched_at")] + public DateTime? LastEnrichedAt { get; set; } +} diff --git a/src/Infrastructure.MongoDb/Entities/PersonReference.cs b/src/Infrastructure.MongoDb/Entities/PersonReference.cs new file mode 100644 index 00000000..3e857005 --- /dev/null +++ b/src/Infrastructure.MongoDb/Entities/PersonReference.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace Keeptrack.Infrastructure.MongoDb.Entities; + +/// +/// Shared, owner-less person/actor metadata collection (person_reference). See +/// for why this has no owner_id. +/// +public class PersonReference +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string? Id { get; set; } + + public required string Name { get; set; } + + [BsonElement("profile_image_url")] + public string? ProfileImageUrl { get; set; } + + [BsonElement("external_ids")] + public required Dictionary ExternalIds { get; set; } +} diff --git a/src/Infrastructure.MongoDb/Entities/Playlist.cs b/src/Infrastructure.MongoDb/Entities/Playlist.cs new file mode 100644 index 00000000..d395c586 --- /dev/null +++ b/src/Infrastructure.MongoDb/Entities/Playlist.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using Keeptrack.Common.System; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace Keeptrack.Infrastructure.MongoDb.Entities; + +public class Playlist : IHasIdAndOwnerId +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string? Id { get; set; } + + [BsonElement("owner_id")] + public required string OwnerId { get; set; } + + public required string Title { get; set; } + + [BsonElement("song_ids")] + public List SongIds { get; set; } = []; +} diff --git a/src/Infrastructure.MongoDb/Entities/Playthrough.cs b/src/Infrastructure.MongoDb/Entities/Playthrough.cs new file mode 100644 index 00000000..fda048a0 --- /dev/null +++ b/src/Infrastructure.MongoDb/Entities/Playthrough.cs @@ -0,0 +1,12 @@ +using System; +using MongoDB.Bson.Serialization.Attributes; + +namespace Keeptrack.Infrastructure.MongoDb.Entities; + +public class Playthrough +{ + public required string Label { get; set; } + + [BsonElement("completed_at")] + public DateTime? CompletedAt { get; set; } +} diff --git a/src/Infrastructure.MongoDb/Entities/ReferenceEpisode.cs b/src/Infrastructure.MongoDb/Entities/ReferenceEpisode.cs new file mode 100644 index 00000000..533f41f6 --- /dev/null +++ b/src/Infrastructure.MongoDb/Entities/ReferenceEpisode.cs @@ -0,0 +1,21 @@ +using System; +using MongoDB.Bson.Serialization.Attributes; + +namespace Keeptrack.Infrastructure.MongoDb.Entities; + +/// +/// Embedded within - see that class for why embedding is the right call here. +/// +public class ReferenceEpisode +{ + [BsonElement("season_number")] + public required int SeasonNumber { get; set; } + + [BsonElement("episode_number")] + public required int EpisodeNumber { get; set; } + + public required string Title { get; set; } + + [BsonElement("air_date")] + public DateTime? AirDate { get; set; } +} diff --git a/src/Infrastructure.MongoDb/Entities/ReferenceMatch.cs b/src/Infrastructure.MongoDb/Entities/ReferenceMatch.cs new file mode 100644 index 00000000..76aa9200 --- /dev/null +++ b/src/Infrastructure.MongoDb/Entities/ReferenceMatch.cs @@ -0,0 +1,26 @@ +namespace Keeptrack.Infrastructure.MongoDb.Entities; + +/// +/// Embedded within / - see +/// for why embedding is the right call here. +/// +public class ReferenceMatch +{ + public required string Title { get; set; } + + public int? Year { get; set; } + + /// + /// Null for TV show/movie/video game (no creator dimension in their match key). The global + /// IgnoreIfNullConvention (InfrastructureServiceCollectionExtensions.AddMongoDbInfrastructure) + /// already omits any null property from the document - no per-property [BsonIgnoreIfNull] needed + /// here or anywhere else in the codebase. This used to require a per-member AutoMapper opt-out + /// (.ForMember(x => x.Creator, opt => opt.AllowNull())) because AutoMapper's profile-wide + /// AllowNullDestinationValues = false substituted an empty string for the null before BSON + /// serialization ever saw it; Mapperly (the current mapper, see ReferenceMatchModel -> + /// ReferenceMatch mapping in TvShowReferenceStorageMapper etc.) preserves nulls by + /// default, so no such opt-out exists or is needed anymore. Historical documents written under the + /// old behavior still store "" instead of an absent field - see MergeMatchedAliases. + /// + public string? Creator { get; set; } +} diff --git a/src/Infrastructure.MongoDb/Entities/ReferenceTrack.cs b/src/Infrastructure.MongoDb/Entities/ReferenceTrack.cs new file mode 100644 index 00000000..3dc27055 --- /dev/null +++ b/src/Infrastructure.MongoDb/Entities/ReferenceTrack.cs @@ -0,0 +1,13 @@ +namespace Keeptrack.Infrastructure.MongoDb.Entities; + +/// +/// Embedded within - see that class for why embedding is the right call here. +/// +public class ReferenceTrack +{ + public required string Position { get; set; } + + public required string Title { get; set; } + + public string? Duration { get; set; } +} diff --git a/src/Infrastructure.MongoDb/Entities/Song.cs b/src/Infrastructure.MongoDb/Entities/Song.cs new file mode 100644 index 00000000..cf8c7fe1 --- /dev/null +++ b/src/Infrastructure.MongoDb/Entities/Song.cs @@ -0,0 +1,27 @@ +using Keeptrack.Common.System; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace Keeptrack.Infrastructure.MongoDb.Entities; + +public class Song : IHasIdAndOwnerId +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string? Id { get; set; } + + [BsonElement("owner_id")] + public required string OwnerId { get; set; } + + public required string Title { get; set; } + + public string? Artist { get; set; } + + [BsonElement("album_id")] + public string? AlbumId { get; set; } + + public string? Duration { get; set; } + + [BsonElement("track_position")] + public string? TrackPosition { get; set; } +} diff --git a/src/Infrastructure.MongoDb/Entities/TvShow.cs b/src/Infrastructure.MongoDb/Entities/TvShow.cs index 6d69f612..4de88cee 100644 --- a/src/Infrastructure.MongoDb/Entities/TvShow.cs +++ b/src/Infrastructure.MongoDb/Entities/TvShow.cs @@ -1,5 +1,5 @@ -using System; -using Keeptrack.Common.System; +using Keeptrack.Common.System; +using Keeptrack.Domain.Models; using MongoDB.Bson; using MongoDB.Bson.Serialization.Attributes; @@ -16,6 +16,9 @@ public class TvShow : IHasIdAndOwnerId public required string Title { get; set; } + [BsonElement("tv_time_id")] + public string? TvTimeId { get; set; } + public int? Year { get; set; } public float? Rating { get; set; } @@ -25,10 +28,23 @@ public class TvShow : IHasIdAndOwnerId [BsonElement("last_episode_seen")] public string? LastEpisodeSeen { get; set; } - public Imdb? Imdb { get; set; } + [BsonElement("reference_id")] + public string? ReferenceId { get; set; } + + // storage name kept as "status" deliberately - only the C# property was renamed to State (for parity + // with VideoGame.State), so existing documents need no migration. + [BsonElement("status")] + public TvShowStatus? State { get; set; } + + [BsonElement("is_favorite")] + public bool IsFavorite { get; set; } + + [BsonElement("want_to_watch")] + public bool WantToWatch { get; set; } - public Allocine? Allocine { get; set; } + [BsonElement("is_owned")] + public bool IsOwned { get; set; } - [BsonElement("finished_at")] - public DateTime? FinishedAt { get; set; } + [BsonElement("is_wishlisted")] + public bool IsWishlisted { get; set; } } diff --git a/src/Infrastructure.MongoDb/Entities/TvShowReference.cs b/src/Infrastructure.MongoDb/Entities/TvShowReference.cs new file mode 100644 index 00000000..fab4cc8b --- /dev/null +++ b/src/Infrastructure.MongoDb/Entities/TvShowReference.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace Keeptrack.Infrastructure.MongoDb.Entities; + +/// +/// Shared, owner-less show metadata collection (tvshow_reference). Deliberately has no +/// owner_id - see for why. +/// +public class TvShowReference +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string? Id { get; set; } + + public required string Title { get; set; } + + [BsonElement("title_normalized")] + public required string TitleNormalized { get; set; } + + public int? Year { get; set; } + + public string? Synopsis { get; set; } + + [BsonElement("external_ids")] + public required Dictionary ExternalIds { get; set; } + + [BsonElement("matched_aliases")] + public List MatchedAliases { get; set; } = []; + + public List Episodes { get; set; } = []; + + public List Genres { get; set; } = []; + + public List Cast { get; set; } = []; + + [BsonElement("image_url")] + public string? ImageUrl { get; set; } + + [BsonElement("last_enriched_at")] + public DateTime? LastEnrichedAt { get; set; } +} diff --git a/src/Infrastructure.MongoDb/Entities/VideoGame.cs b/src/Infrastructure.MongoDb/Entities/VideoGame.cs index 5f3e136e..77148e5a 100644 --- a/src/Infrastructure.MongoDb/Entities/VideoGame.cs +++ b/src/Infrastructure.MongoDb/Entities/VideoGame.cs @@ -1,4 +1,4 @@ -using System; +using System.Collections.Generic; using Keeptrack.Common.System; using MongoDB.Bson; using MongoDB.Bson.Serialization.Attributes; @@ -16,9 +16,7 @@ public class VideoGame : IHasIdAndOwnerId public required string Title { get; set; } - public required string Platform { get; set; } - - public required string State { get; set; } + public List Platforms { get; set; } = []; public int? Year { get; set; } @@ -26,6 +24,12 @@ public class VideoGame : IHasIdAndOwnerId public string? Notes { get; set; } - [BsonElement("finished_at")] - public DateTime? FinishedAt { get; set; } + [BsonElement("reference_id")] + public string? ReferenceId { get; set; } + + [BsonElement("is_owned")] + public bool IsOwned { get; set; } + + [BsonElement("is_wishlisted")] + public bool IsWishlisted { get; set; } } diff --git a/src/Infrastructure.MongoDb/Entities/VideoGamePlatform.cs b/src/Infrastructure.MongoDb/Entities/VideoGamePlatform.cs new file mode 100644 index 00000000..b2b36ad0 --- /dev/null +++ b/src/Infrastructure.MongoDb/Entities/VideoGamePlatform.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using Keeptrack.Domain.Models; +using MongoDB.Bson.Serialization.Attributes; + +namespace Keeptrack.Infrastructure.MongoDb.Entities; + +public class VideoGamePlatform +{ + public required string Platform { get; set; } + + [BsonElement("copy_type")] + public VideoGameCopyType CopyType { get; set; } + + public string State { get; set; } = ""; + + public List Playthroughs { get; set; } = []; + + [BsonElement("is_fully_completed")] + public bool IsFullyCompleted { get; set; } + + [BsonElement("fully_completed_at")] + public DateTime? FullyCompletedAt { get; set; } +} diff --git a/src/Infrastructure.MongoDb/Entities/VideoGameReference.cs b/src/Infrastructure.MongoDb/Entities/VideoGameReference.cs new file mode 100644 index 00000000..45bab635 --- /dev/null +++ b/src/Infrastructure.MongoDb/Entities/VideoGameReference.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace Keeptrack.Infrastructure.MongoDb.Entities; + +/// +/// Shared, owner-less video game metadata collection (videogame_reference). See +/// for why this has no owner_id. +/// +public class VideoGameReference +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string? Id { get; set; } + + public required string Title { get; set; } + + [BsonElement("title_normalized")] + public required string TitleNormalized { get; set; } + + public int? Year { get; set; } + + public string? Synopsis { get; set; } + + public List Platforms { get; set; } = []; + + [BsonElement("external_ids")] + public required Dictionary ExternalIds { get; set; } + + [BsonElement("matched_aliases")] + public List MatchedAliases { get; set; } = []; + + public List Genres { get; set; } = []; + + [BsonElement("image_url")] + public string? ImageUrl { get; set; } + + [BsonElement("last_enriched_at")] + public DateTime? LastEnrichedAt { get; set; } +} diff --git a/src/Infrastructure.MongoDb/Infrastructure.MongoDb.csproj b/src/Infrastructure.MongoDb/Infrastructure.MongoDb.csproj index 494be141..7d728808 100644 --- a/src/Infrastructure.MongoDb/Infrastructure.MongoDb.csproj +++ b/src/Infrastructure.MongoDb/Infrastructure.MongoDb.csproj @@ -8,9 +8,9 @@ - + diff --git a/src/Infrastructure.MongoDb/Mappers/AlbumReferenceStorageMapper.cs b/src/Infrastructure.MongoDb/Mappers/AlbumReferenceStorageMapper.cs new file mode 100644 index 00000000..86253260 --- /dev/null +++ b/src/Infrastructure.MongoDb/Mappers/AlbumReferenceStorageMapper.cs @@ -0,0 +1,17 @@ +using Keeptrack.Domain.Models; +using Keeptrack.Infrastructure.MongoDb.Entities; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.Infrastructure.MongoDb.Mappers; + +/// +/// Injected directly by - see +/// for why this has no shared interface. +/// +[Mapper] +public partial class AlbumReferenceStorageMapper +{ + public partial AlbumReference ToEntity(AlbumReferenceModel model); + + public partial AlbumReferenceModel ToModel(AlbumReference entity); +} diff --git a/src/Infrastructure.MongoDb/Mappers/AlbumStorageMapper.cs b/src/Infrastructure.MongoDb/Mappers/AlbumStorageMapper.cs new file mode 100644 index 00000000..6d0deb1b --- /dev/null +++ b/src/Infrastructure.MongoDb/Mappers/AlbumStorageMapper.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using Keeptrack.Domain.Models; +using Keeptrack.Infrastructure.MongoDb.Entities; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.Infrastructure.MongoDb.Mappers; + +[Mapper] +public partial class AlbumStorageMapper : IStorageMapper +{ + public partial Album ToEntity(AlbumModel model); + + public partial AlbumModel ToModel(Album entity); + + public partial List ToModels(List entities); +} diff --git a/src/Infrastructure.MongoDb/Mappers/BookReferenceStorageMapper.cs b/src/Infrastructure.MongoDb/Mappers/BookReferenceStorageMapper.cs new file mode 100644 index 00000000..269756d9 --- /dev/null +++ b/src/Infrastructure.MongoDb/Mappers/BookReferenceStorageMapper.cs @@ -0,0 +1,17 @@ +using Keeptrack.Domain.Models; +using Keeptrack.Infrastructure.MongoDb.Entities; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.Infrastructure.MongoDb.Mappers; + +/// +/// Injected directly by - see +/// for why this has no shared interface. +/// +[Mapper] +public partial class BookReferenceStorageMapper +{ + public partial BookReference ToEntity(BookReferenceModel model); + + public partial BookReferenceModel ToModel(BookReference entity); +} diff --git a/src/Infrastructure.MongoDb/Mappers/BookStorageMapper.cs b/src/Infrastructure.MongoDb/Mappers/BookStorageMapper.cs new file mode 100644 index 00000000..79ad8554 --- /dev/null +++ b/src/Infrastructure.MongoDb/Mappers/BookStorageMapper.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using Keeptrack.Domain.Models; +using Keeptrack.Infrastructure.MongoDb.Entities; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.Infrastructure.MongoDb.Mappers; + +[Mapper] +[UseStaticMapper(typeof(CommonStorageMappings))] +public partial class BookStorageMapper : IStorageMapper +{ + public partial Book ToEntity(BookModel model); + + public partial BookModel ToModel(Book entity); + + public partial List ToModels(List entities); +} diff --git a/src/Infrastructure.MongoDb/Mappers/CarHistoryStorageMapper.cs b/src/Infrastructure.MongoDb/Mappers/CarHistoryStorageMapper.cs new file mode 100644 index 00000000..8ea259f5 --- /dev/null +++ b/src/Infrastructure.MongoDb/Mappers/CarHistoryStorageMapper.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using Keeptrack.Domain.Models; +using Keeptrack.Infrastructure.MongoDb.Entities; + +namespace Keeptrack.Infrastructure.MongoDb.Mappers; + +/// +/// Hand-written rather than a Mapperly [Mapper] class: 's flat +/// City/PostalCode/Country/Longitude/Latitude/Fuel*/StationBrandName fields fan out into +/// 's Location/Fuel/Station sub-documents on write and back on read, which is +/// bespoke enough that explicit C# is more readable than attribute configuration. +/// +public class CarHistoryStorageMapper : IStorageMapper +{ + public CarHistory ToEntity(CarHistoryModel model) + { + return new CarHistory + { + Id = model.Id, + OwnerId = model.OwnerId, + CarId = model.CarId, + // HistoryDate is a plain DateTime on both sides (the only date field in the codebase that + // isn't DateOnly on the model), so nothing upstream stamps DateTimeKind.Utc - the Mongo + // driver's default DateTimeSerializer requires it. + HistoryDate = DateTime.SpecifyKind(model.HistoryDate, DateTimeKind.Utc), + Mileage = model.Mileage, + EventType = model.EventType, + Description = model.Description, + Cost = model.Cost, + Location = BuildLocation(model), + Fuel = BuildFuel(model), + Station = BuildStation(model), + Garage = model.Garage, + }; + } + + public CarHistoryModel ToModel(CarHistory entity) + { + return new CarHistoryModel + { + Id = entity.Id, + OwnerId = entity.OwnerId, + CarId = entity.CarId, + HistoryDate = entity.HistoryDate, + Mileage = (int?)entity.Mileage, + EventType = entity.EventType, + Description = entity.Description, + Cost = entity.Cost, + City = entity.Location?.City, + PostalCode = entity.Location?.PostalCode, + Country = entity.Location?.Country, + Longitude = entity.Location?.Coordinates != null ? entity.Location.Coordinates[0] : null, + Latitude = entity.Location?.Coordinates != null ? entity.Location.Coordinates[1] : null, + FuelCategory = entity.Fuel?.Category, + FuelVolume = entity.Fuel?.Volume, + FuelUnitPrice = entity.Fuel?.UnitPrice, + ElectricVolume = entity.Fuel?.ElectricVolume, + ElectricUnitPrice = entity.Fuel?.ElectricUnitPrice, + IsFullRefill = entity.Fuel?.IsFullRefill, + DeltaMileage = entity.Fuel?.DeltaMileage, + StationBrandName = entity.Station?.BrandName, + Garage = entity.Garage, + }; + } + + public List ToModels(List entities) + { + var models = new List(entities.Count); + foreach (var entity in entities) models.Add(ToModel(entity)); + return models; + } + + private static CarHistoryLocation BuildLocation(CarHistoryModel model) + { + return new CarHistoryLocation + { + // CarHistoryLocation.City is `required` at the entity/schema level, but a non-Refuel event + // can legitimately carry no location at all - the null-forgiving operator here preserves that + // existing schema/model mismatch rather than introducing a new one. + City = model.City!, + PostalCode = model.PostalCode, + Country = model.Country, + // Coordinates: never persist an empty list, only a real 2-element [lon, lat] pair or nothing - + // the read side (ToModel) indexes Coordinates[0]/[1] guarded by "!= null", which an empty + // (not null) list would defeat. Covered by CarHistoryResourceTest's create/read round-trip. + Coordinates = model.Longitude.HasValue && model.Latitude.HasValue + ? [model.Longitude.Value, model.Latitude.Value] + : null, + }; + } + + private static CarHistoryFuel BuildFuel(CarHistoryModel model) + { + return new CarHistoryFuel + { + Category = model.FuelCategory, + Volume = model.FuelVolume, + UnitPrice = model.FuelUnitPrice, + ElectricVolume = model.ElectricVolume, + ElectricUnitPrice = model.ElectricUnitPrice, + IsFullRefill = model.IsFullRefill, + DeltaMileage = model.DeltaMileage, + }; + } + + private static CarHistoryStation BuildStation(CarHistoryModel model) + { + return new CarHistoryStation + { + // BrandName is `required` on the entity for the same reason City is above - preserved as-is. + BrandName = model.StationBrandName!, + }; + } +} diff --git a/src/Infrastructure.MongoDb/Mappers/CarStorageMapper.cs b/src/Infrastructure.MongoDb/Mappers/CarStorageMapper.cs new file mode 100644 index 00000000..dc334ed8 --- /dev/null +++ b/src/Infrastructure.MongoDb/Mappers/CarStorageMapper.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using Keeptrack.Domain.Models; +using Keeptrack.Infrastructure.MongoDb.Entities; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.Infrastructure.MongoDb.Mappers; + +[Mapper] +public partial class CarStorageMapper : IStorageMapper +{ + public partial Car ToEntity(CarModel model); + + public partial CarModel ToModel(Car entity); + + public partial List ToModels(List entities); +} diff --git a/src/Infrastructure.MongoDb/Mappers/CommonStorageMappings.cs b/src/Infrastructure.MongoDb/Mappers/CommonStorageMappings.cs new file mode 100644 index 00000000..648c4c2a --- /dev/null +++ b/src/Infrastructure.MongoDb/Mappers/CommonStorageMappings.cs @@ -0,0 +1,26 @@ +using System; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.Infrastructure.MongoDb.Mappers; + +/// +/// Shared DateOnly <-> DateTime conversion, attached to any storage mapper that needs +/// it via [UseStaticMapper(typeof(CommonStorageMappings))]. Every Domain model date field is +/// while its Mongo entity counterpart is - the driver's +/// DateTimeSerializer requires , so stamps +/// it explicitly rather than leaving it . +/// +public static class CommonStorageMappings +{ + [UserMapping] + public static DateOnly ToDateOnly(DateTime dateTime) + { + return DateOnly.FromDateTime(dateTime); + } + + [UserMapping] + public static DateTime ToDateTime(DateOnly dateOnly) + { + return dateOnly.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); + } +} diff --git a/src/Infrastructure.MongoDb/Mappers/EpisodeStorageMapper.cs b/src/Infrastructure.MongoDb/Mappers/EpisodeStorageMapper.cs new file mode 100644 index 00000000..b2782613 --- /dev/null +++ b/src/Infrastructure.MongoDb/Mappers/EpisodeStorageMapper.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using Keeptrack.Domain.Models; +using Keeptrack.Infrastructure.MongoDb.Entities; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.Infrastructure.MongoDb.Mappers; + +[Mapper] +[UseStaticMapper(typeof(CommonStorageMappings))] +public partial class EpisodeStorageMapper : IStorageMapper +{ + public partial Episode ToEntity(EpisodeModel model); + + public partial EpisodeModel ToModel(Episode entity); + + public partial List ToModels(List entities); +} diff --git a/src/Infrastructure.MongoDb/Mappers/HouseHistoryStorageMapper.cs b/src/Infrastructure.MongoDb/Mappers/HouseHistoryStorageMapper.cs new file mode 100644 index 00000000..dbce03d2 --- /dev/null +++ b/src/Infrastructure.MongoDb/Mappers/HouseHistoryStorageMapper.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using Keeptrack.Domain.Models; +using Keeptrack.Infrastructure.MongoDb.Entities; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.Infrastructure.MongoDb.Mappers; + +[Mapper] +[UseStaticMapper(typeof(CommonStorageMappings))] +public partial class HouseHistoryStorageMapper : IStorageMapper +{ + public partial HouseHistory ToEntity(HouseHistoryModel model); + + public partial HouseHistoryModel ToModel(HouseHistory entity); + + public partial List ToModels(List entities); +} diff --git a/src/Infrastructure.MongoDb/Mappers/HouseStorageMapper.cs b/src/Infrastructure.MongoDb/Mappers/HouseStorageMapper.cs new file mode 100644 index 00000000..9ed7544e --- /dev/null +++ b/src/Infrastructure.MongoDb/Mappers/HouseStorageMapper.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using Keeptrack.Domain.Models; +using Keeptrack.Infrastructure.MongoDb.Entities; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.Infrastructure.MongoDb.Mappers; + +[Mapper] +public partial class HouseStorageMapper : IStorageMapper +{ + public partial House ToEntity(HouseModel model); + + public partial HouseModel ToModel(House entity); + + public partial List ToModels(List entities); +} diff --git a/src/Infrastructure.MongoDb/Mappers/IStorageMapper.cs b/src/Infrastructure.MongoDb/Mappers/IStorageMapper.cs new file mode 100644 index 00000000..0e4b3762 --- /dev/null +++ b/src/Infrastructure.MongoDb/Mappers/IStorageMapper.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; + +namespace Keeptrack.Infrastructure.MongoDb.Mappers; + +/// +/// Maps between a Domain model and its MongoDB entity counterpart. Implemented per entity pair by a +/// Mapperly [Mapper] partial class (e.g. BookStorageMapper) and injected into +/// instead of AutoMapper's untyped +/// IMapper. +/// +public interface IStorageMapper +{ + TEntity ToEntity(TModel model); + + TModel ToModel(TEntity entity); + + List ToModels(List entities); +} diff --git a/src/Infrastructure.MongoDb/Mappers/MovieReferenceStorageMapper.cs b/src/Infrastructure.MongoDb/Mappers/MovieReferenceStorageMapper.cs new file mode 100644 index 00000000..2e3a0ab6 --- /dev/null +++ b/src/Infrastructure.MongoDb/Mappers/MovieReferenceStorageMapper.cs @@ -0,0 +1,17 @@ +using Keeptrack.Domain.Models; +using Keeptrack.Infrastructure.MongoDb.Entities; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.Infrastructure.MongoDb.Mappers; + +/// +/// Injected directly by - see +/// for why this has no shared interface. +/// +[Mapper] +public partial class MovieReferenceStorageMapper +{ + public partial MovieReference ToEntity(MovieReferenceModel model); + + public partial MovieReferenceModel ToModel(MovieReference entity); +} diff --git a/src/Infrastructure.MongoDb/Mappers/MovieStorageMapper.cs b/src/Infrastructure.MongoDb/Mappers/MovieStorageMapper.cs new file mode 100644 index 00000000..fda8ba66 --- /dev/null +++ b/src/Infrastructure.MongoDb/Mappers/MovieStorageMapper.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using Keeptrack.Domain.Models; +using Keeptrack.Infrastructure.MongoDb.Entities; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.Infrastructure.MongoDb.Mappers; + +[Mapper] +[UseStaticMapper(typeof(CommonStorageMappings))] +public partial class MovieStorageMapper : IStorageMapper +{ + public partial Movie ToEntity(MovieModel model); + + public partial MovieModel ToModel(Movie entity); + + public partial List ToModels(List entities); +} diff --git a/src/Infrastructure.MongoDb/Mappers/PersonReferenceStorageMapper.cs b/src/Infrastructure.MongoDb/Mappers/PersonReferenceStorageMapper.cs new file mode 100644 index 00000000..bc187d9a --- /dev/null +++ b/src/Infrastructure.MongoDb/Mappers/PersonReferenceStorageMapper.cs @@ -0,0 +1,17 @@ +using Keeptrack.Domain.Models; +using Keeptrack.Infrastructure.MongoDb.Entities; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.Infrastructure.MongoDb.Mappers; + +/// +/// Injected directly by - see +/// for why this has no shared interface. +/// +[Mapper] +public partial class PersonReferenceStorageMapper +{ + public partial PersonReference ToEntity(PersonReferenceModel model); + + public partial PersonReferenceModel ToModel(PersonReference entity); +} diff --git a/src/Infrastructure.MongoDb/Mappers/PlaylistStorageMapper.cs b/src/Infrastructure.MongoDb/Mappers/PlaylistStorageMapper.cs new file mode 100644 index 00000000..fdf3bae5 --- /dev/null +++ b/src/Infrastructure.MongoDb/Mappers/PlaylistStorageMapper.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using Keeptrack.Domain.Models; +using Keeptrack.Infrastructure.MongoDb.Entities; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.Infrastructure.MongoDb.Mappers; + +[Mapper] +public partial class PlaylistStorageMapper : IStorageMapper +{ + public partial Playlist ToEntity(PlaylistModel model); + + public partial PlaylistModel ToModel(Playlist entity); + + public partial List ToModels(List entities); +} diff --git a/src/Infrastructure.MongoDb/Mappers/SongStorageMapper.cs b/src/Infrastructure.MongoDb/Mappers/SongStorageMapper.cs new file mode 100644 index 00000000..13fc6a92 --- /dev/null +++ b/src/Infrastructure.MongoDb/Mappers/SongStorageMapper.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using Keeptrack.Domain.Models; +using Keeptrack.Infrastructure.MongoDb.Entities; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.Infrastructure.MongoDb.Mappers; + +[Mapper] +public partial class SongStorageMapper : IStorageMapper +{ + public partial Song ToEntity(SongModel model); + + public partial SongModel ToModel(Song entity); + + public partial List ToModels(List entities); +} diff --git a/src/Infrastructure.MongoDb/Mappers/TvShowReferenceStorageMapper.cs b/src/Infrastructure.MongoDb/Mappers/TvShowReferenceStorageMapper.cs new file mode 100644 index 00000000..892f7e06 --- /dev/null +++ b/src/Infrastructure.MongoDb/Mappers/TvShowReferenceStorageMapper.cs @@ -0,0 +1,19 @@ +using Keeptrack.Domain.Models; +using Keeptrack.Infrastructure.MongoDb.Entities; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.Infrastructure.MongoDb.Mappers; + +/// +/// Injected directly by - the owner-less reference +/// repositories are purpose-built already and need no shared +/// abstraction, per docs/automapper-removal-plan.md. +/// +[Mapper] +[UseStaticMapper(typeof(CommonStorageMappings))] +public partial class TvShowReferenceStorageMapper +{ + public partial TvShowReference ToEntity(TvShowReferenceModel model); + + public partial TvShowReferenceModel ToModel(TvShowReference entity); +} diff --git a/src/Infrastructure.MongoDb/Mappers/TvShowStorageMapper.cs b/src/Infrastructure.MongoDb/Mappers/TvShowStorageMapper.cs new file mode 100644 index 00000000..aa70c7dd --- /dev/null +++ b/src/Infrastructure.MongoDb/Mappers/TvShowStorageMapper.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using Keeptrack.Domain.Models; +using Keeptrack.Infrastructure.MongoDb.Entities; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.Infrastructure.MongoDb.Mappers; + +[Mapper] +public partial class TvShowStorageMapper : IStorageMapper +{ + public partial TvShow ToEntity(TvShowModel model); + + public partial TvShowModel ToModel(TvShow entity); + + public partial List ToModels(List entities); +} diff --git a/src/Infrastructure.MongoDb/Mappers/VideoGameReferenceStorageMapper.cs b/src/Infrastructure.MongoDb/Mappers/VideoGameReferenceStorageMapper.cs new file mode 100644 index 00000000..eea9732c --- /dev/null +++ b/src/Infrastructure.MongoDb/Mappers/VideoGameReferenceStorageMapper.cs @@ -0,0 +1,17 @@ +using Keeptrack.Domain.Models; +using Keeptrack.Infrastructure.MongoDb.Entities; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.Infrastructure.MongoDb.Mappers; + +/// +/// Injected directly by - see +/// for why this has no shared interface. +/// +[Mapper] +public partial class VideoGameReferenceStorageMapper +{ + public partial VideoGameReference ToEntity(VideoGameReferenceModel model); + + public partial VideoGameReferenceModel ToModel(VideoGameReference entity); +} diff --git a/src/Infrastructure.MongoDb/Mappers/VideoGameStorageMapper.cs b/src/Infrastructure.MongoDb/Mappers/VideoGameStorageMapper.cs new file mode 100644 index 00000000..24364c08 --- /dev/null +++ b/src/Infrastructure.MongoDb/Mappers/VideoGameStorageMapper.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using Keeptrack.Domain.Models; +using Keeptrack.Infrastructure.MongoDb.Entities; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.Infrastructure.MongoDb.Mappers; + +[Mapper] +[UseStaticMapper(typeof(CommonStorageMappings))] +public partial class VideoGameStorageMapper : IStorageMapper +{ + // Platform/State are filter-only members on VideoGameModel with no matching entity field - see + // VideoGameModel.Platform/State's own doc comments. Ignored on both directions: as an unmapped + // target on the read side, as an unmapped source (nothing to write) on the write side. + [MapperIgnoreSource(nameof(VideoGameModel.Platform))] + [MapperIgnoreSource(nameof(VideoGameModel.State))] + public partial VideoGame ToEntity(VideoGameModel model); + + [MapperIgnoreTarget(nameof(VideoGameModel.Platform))] + [MapperIgnoreTarget(nameof(VideoGameModel.State))] + public partial VideoGameModel ToModel(VideoGame entity); + + public partial List ToModels(List entities); +} diff --git a/src/Infrastructure.MongoDb/Repositories/AlbumReferenceRepository.cs b/src/Infrastructure.MongoDb/Repositories/AlbumReferenceRepository.cs new file mode 100644 index 00000000..e7d0f2b1 --- /dev/null +++ b/src/Infrastructure.MongoDb/Repositories/AlbumReferenceRepository.cs @@ -0,0 +1,84 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Keeptrack.Common.System; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.Infrastructure.MongoDb.Entities; +using Keeptrack.Infrastructure.MongoDb.Mappers; +using MongoDB.Driver; + +namespace Keeptrack.Infrastructure.MongoDb.Repositories; + +public class AlbumReferenceRepository(IMongoDatabase mongoDatabase, AlbumReferenceStorageMapper mapper) : IAlbumReferenceRepository +{ + private const string CollectionName = "album_reference"; + + private IMongoCollection Collection => mongoDatabase.GetCollection(CollectionName); + + public async Task FindByIdAsync(string id) + { + var entity = await Collection.Find(x => x.Id == id).FirstOrDefaultAsync(); + return entity is null ? null : mapper.ToModel(entity); + } + + public async Task FindByTitleYearAsync(string title, int? year, string artist) + { + var normalized = TitleNormalizer.Normalize(title); + var normalizedArtist = TitleNormalizer.Normalize(artist); + var filter = Builders.Filter.ElemMatch(x => x.MatchedAliases, + Builders.Filter.Eq(m => m.Title, normalized) + & Builders.Filter.Eq(m => m.Year, year) + & Builders.Filter.Eq(m => m.Creator, normalizedArtist)); + var entity = await Collection.Find(filter).FirstOrDefaultAsync(); + return entity is null ? null : mapper.ToModel(entity); + } + + public async Task FindByTitleAsync(string title, string artist) + { + var normalized = TitleNormalizer.Normalize(title); + var normalizedArtist = TitleNormalizer.Normalize(artist); + var filter = Builders.Filter.ElemMatch(x => x.MatchedAliases, + Builders.Filter.Eq(m => m.Title, normalized) + & Builders.Filter.Eq(m => m.Creator, normalizedArtist)); + var entity = await Collection.Find(filter).FirstOrDefaultAsync(); + return entity is null ? null : mapper.ToModel(entity); + } + + public async Task FindByExternalIdAsync(string provider, string externalId) + { + var filter = Builders.Filter.Eq($"external_ids.{provider}", externalId); + var entity = await Collection.Find(filter).FirstOrDefaultAsync(); + return entity is null ? null : mapper.ToModel(entity); + } + + public async Task> FindAllAsync() + { + var entities = await Collection.Find(FilterDefinition.Empty).ToListAsync(); + return entities.Select(mapper.ToModel).ToList(); + } + + public async Task UpsertAsync(AlbumReferenceModel model) + { + model.TitleNormalized = TitleNormalizer.Normalize(model.Title); + // see BookReferenceRepository.UpsertAsync's equivalent comment - this safety net can't know the + // canonical artist text (the model only carries ArtistReferenceId), so it's harmless dead weight + // when hit, not a false-positive risk. + if (!model.MatchedAliases.Any(m => m.Title == model.TitleNormalized && m.Year == model.Year)) + { + model.MatchedAliases.Add(new ReferenceMatchModel { Title = model.TitleNormalized, Year = model.Year }); + } + var entity = mapper.ToEntity(model); + + if (string.IsNullOrEmpty(entity.Id)) + { + await Collection.InsertOneAsync(entity); + } + else + { + await Collection.ReplaceOneAsync(x => x.Id == entity.Id, entity, new ReplaceOptions { IsUpsert = true }); + } + + return mapper.ToModel(entity); + } +} diff --git a/src/Infrastructure.MongoDb/Repositories/AlbumRepository.cs b/src/Infrastructure.MongoDb/Repositories/AlbumRepository.cs new file mode 100644 index 00000000..5fafb2af --- /dev/null +++ b/src/Infrastructure.MongoDb/Repositories/AlbumRepository.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.Infrastructure.MongoDb.Entities; +using Keeptrack.Infrastructure.MongoDb.Mappers; +using Microsoft.Extensions.Logging; +using MongoDB.Bson; +using MongoDB.Driver; + +namespace Keeptrack.Infrastructure.MongoDb.Repositories; + +public class AlbumRepository(IMongoDatabase mongoDatabase, ILogger logger, IStorageMapper mapper) + : MongoDbRepositoryBase(mongoDatabase, logger, mapper), IAlbumRepository +{ + protected override string CollectionName => "album"; + + protected override FilterDefinition GetFilter(string ownerId, string? search, AlbumModel input) + { + var builder = Builders.Filter; + var filter = builder.Eq(f => f.OwnerId, ownerId); + if (!string.IsNullOrEmpty(search)) filter &= builder.Where(f => f.Title.Contains(search, System.StringComparison.CurrentCultureIgnoreCase) + || f.Artist.Contains(search, System.StringComparison.CurrentCultureIgnoreCase)); + if (input.IsFavorite) filter &= builder.Eq(f => f.IsFavorite, true); + return filter; + } + + public async Task SetReferenceLinkAsync(string title, int? year, string referenceId, string canonicalTitle, int? canonicalYear = null, string? canonicalArtist = null, string? canonicalGenre = null) + { + var builder = Builders.Filter; + var filter = builder.Regex(f => f.Title, new BsonRegularExpression($"^{Regex.Escape(title)}$", "i")) + & builder.Eq(f => f.Year, year) + & UnresolvedFilter(); + + var update = Builders.Update.Set(f => f.ReferenceId, referenceId).Set(f => f.Title, canonicalTitle); + if (canonicalYear is not null) update = update.Set(f => f.Year, canonicalYear); + if (canonicalArtist is not null) update = update.Set(f => f.Artist, canonicalArtist); + if (canonicalGenre is not null) update = update.Set(f => f.Genre, canonicalGenre); + var result = await GetCollection().UpdateManyAsync(filter, update); + return result.ModifiedCount; + } + + public async Task> FindDistinctUnresolvedTitleYearsAsync() + { + var groups = await GetCollection().Aggregate() + .Match(UnresolvedFilter()) + .Group(f => new { f.Title, f.Year }, g => g.Key) + .ToListAsync(); + return groups.Select(g => (g.Title, g.Year)).ToList(); + } + + /// + /// "Has no reference link yet" means is null OR empty string, not + /// just null: old documents (written before the AutoMapper -> Mapperly migration) can still store "" + /// for an unset field; new writes store a real null instead (Mapperly preserves nulls, and the Mongo + /// driver's IgnoreIfNullConvention then omits it entirely). Both generations must match. + /// + private static FilterDefinition UnresolvedFilter() + { + var builder = Builders.Filter; + return builder.Eq(f => f.ReferenceId, null) | builder.Eq(f => f.ReferenceId, string.Empty); + } +} diff --git a/src/Infrastructure.MongoDb/Repositories/BookReferenceRepository.cs b/src/Infrastructure.MongoDb/Repositories/BookReferenceRepository.cs new file mode 100644 index 00000000..3fa12837 --- /dev/null +++ b/src/Infrastructure.MongoDb/Repositories/BookReferenceRepository.cs @@ -0,0 +1,86 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Keeptrack.Common.System; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.Infrastructure.MongoDb.Entities; +using Keeptrack.Infrastructure.MongoDb.Mappers; +using MongoDB.Driver; + +namespace Keeptrack.Infrastructure.MongoDb.Repositories; + +public class BookReferenceRepository(IMongoDatabase mongoDatabase, BookReferenceStorageMapper mapper) : IBookReferenceRepository +{ + private const string CollectionName = "book_reference"; + + private IMongoCollection Collection => mongoDatabase.GetCollection(CollectionName); + + public async Task FindByIdAsync(string id) + { + var entity = await Collection.Find(x => x.Id == id).FirstOrDefaultAsync(); + return entity is null ? null : mapper.ToModel(entity); + } + + public async Task FindByTitleYearAsync(string title, int? year, string author) + { + var normalized = TitleNormalizer.Normalize(title); + var normalizedAuthor = TitleNormalizer.Normalize(author); + var filter = Builders.Filter.ElemMatch(x => x.MatchedAliases, + Builders.Filter.Eq(m => m.Title, normalized) + & Builders.Filter.Eq(m => m.Year, year) + & Builders.Filter.Eq(m => m.Creator, normalizedAuthor)); + var entity = await Collection.Find(filter).FirstOrDefaultAsync(); + return entity is null ? null : mapper.ToModel(entity); + } + + public async Task FindByTitleAsync(string title, string author) + { + var normalized = TitleNormalizer.Normalize(title); + var normalizedAuthor = TitleNormalizer.Normalize(author); + var filter = Builders.Filter.ElemMatch(x => x.MatchedAliases, + Builders.Filter.Eq(m => m.Title, normalized) + & Builders.Filter.Eq(m => m.Creator, normalizedAuthor)); + var entity = await Collection.Find(filter).FirstOrDefaultAsync(); + return entity is null ? null : mapper.ToModel(entity); + } + + public async Task FindByExternalIdAsync(string provider, string externalId) + { + var filter = Builders.Filter.Eq($"external_ids.{provider}", externalId); + var entity = await Collection.Find(filter).FirstOrDefaultAsync(); + return entity is null ? null : mapper.ToModel(entity); + } + + public async Task> FindAllAsync() + { + var entities = await Collection.Find(FilterDefinition.Empty).ToListAsync(); + return entities.Select(mapper.ToModel).ToList(); + } + + public async Task UpsertAsync(BookReferenceModel model) + { + model.TitleNormalized = TitleNormalizer.Normalize(model.Title); + // this safety net can't know the canonical author text (the model only carries AuthorReferenceId, + // a dedup'd link, not denormalized text), so an alias added here has no Creator and simply won't + // match via FindByTitleYearAsync/FindByTitleAsync's required-creator filter - harmless, since the + // normal Resolve/Refresh path always adds a proper creator-bearing alias via MergeMatchedAliases + // before calling UpsertAsync; this only guards a caller that skipped that step entirely. + if (!model.MatchedAliases.Any(m => m.Title == model.TitleNormalized && m.Year == model.Year)) + { + model.MatchedAliases.Add(new ReferenceMatchModel { Title = model.TitleNormalized, Year = model.Year }); + } + var entity = mapper.ToEntity(model); + + if (string.IsNullOrEmpty(entity.Id)) + { + await Collection.InsertOneAsync(entity); + } + else + { + await Collection.ReplaceOneAsync(x => x.Id == entity.Id, entity, new ReplaceOptions { IsUpsert = true }); + } + + return mapper.ToModel(entity); + } +} diff --git a/src/Infrastructure.MongoDb/Repositories/BookRepository.cs b/src/Infrastructure.MongoDb/Repositories/BookRepository.cs index bfad62a2..78a62086 100644 --- a/src/Infrastructure.MongoDb/Repositories/BookRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/BookRepository.cs @@ -1,13 +1,18 @@ -using AutoMapper; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; using Keeptrack.Domain.Models; using Keeptrack.Domain.Repositories; using Keeptrack.Infrastructure.MongoDb.Entities; +using Keeptrack.Infrastructure.MongoDb.Mappers; using Microsoft.Extensions.Logging; +using MongoDB.Bson; using MongoDB.Driver; namespace Keeptrack.Infrastructure.MongoDb.Repositories; -public class BookRepository(IMongoDatabase mongoDatabase, ILogger> logger, IMapper mapper) +public class BookRepository(IMongoDatabase mongoDatabase, ILogger logger, IStorageMapper mapper) : MongoDbRepositoryBase(mongoDatabase, logger, mapper), IBookRepository { protected override string CollectionName => "book"; @@ -15,10 +20,49 @@ public class BookRepository(IMongoDatabase mongoDatabase, ILogger GetFilter(string ownerId, string? search, BookModel input) { var builder = Builders.Filter; - if (string.IsNullOrEmpty(search)) return builder.Eq(f => f.OwnerId, ownerId); - return builder.Eq(f => f.OwnerId, ownerId) - & builder.Where(f => f.Title.Contains(search, System.StringComparison.CurrentCultureIgnoreCase) - || (f.Series != null && f.Series.Contains(search, System.StringComparison.CurrentCultureIgnoreCase)) - || f.Author.Contains(search, System.StringComparison.CurrentCultureIgnoreCase)); + var filter = builder.Eq(f => f.OwnerId, ownerId); + if (!string.IsNullOrEmpty(search)) filter &= builder.Where(f => f.Title.Contains(search, System.StringComparison.CurrentCultureIgnoreCase) + || (f.Series != null && f.Series.Contains(search, System.StringComparison.CurrentCultureIgnoreCase)) + || f.Author.Contains(search, System.StringComparison.CurrentCultureIgnoreCase)); + if (input.IsFavorite) filter &= builder.Eq(f => f.IsFavorite, true); + if (input.IsOwned) filter &= builder.Eq(f => f.IsOwned, true); + if (input.IsWishlisted) filter &= builder.Eq(f => f.IsWishlisted, true); + return filter; + } + + public async Task SetReferenceLinkAsync(string title, int? year, string referenceId, string canonicalTitle, int? canonicalYear = null, string? canonicalAuthor = null, string? canonicalGenre = null) + { + var builder = Builders.Filter; + var filter = builder.Regex(f => f.Title, new BsonRegularExpression($"^{Regex.Escape(title)}$", "i")) + & builder.Eq(f => f.Year, year) + & UnresolvedFilter(); + + var update = Builders.Update.Set(f => f.ReferenceId, referenceId).Set(f => f.Title, canonicalTitle); + if (canonicalYear is not null) update = update.Set(f => f.Year, canonicalYear); + if (canonicalAuthor is not null) update = update.Set(f => f.Author, canonicalAuthor); + if (canonicalGenre is not null) update = update.Set(f => f.Genre, canonicalGenre); + var result = await GetCollection().UpdateManyAsync(filter, update); + return result.ModifiedCount; + } + + public async Task> FindDistinctUnresolvedTitleYearsAsync() + { + var groups = await GetCollection().Aggregate() + .Match(UnresolvedFilter()) + .Group(f => new { f.Title, f.Year }, g => g.Key) + .ToListAsync(); + return groups.Select(g => (g.Title, g.Year)).ToList(); + } + + /// + /// "Has no reference link yet" means is null OR empty string, not + /// just null: old documents (written before the AutoMapper -> Mapperly migration) can still store "" + /// for an unset field; new writes store a real null instead (Mapperly preserves nulls, and the Mongo + /// driver's IgnoreIfNullConvention then omits it entirely). Both generations must match. + /// + private static FilterDefinition UnresolvedFilter() + { + var builder = Builders.Filter; + return builder.Eq(f => f.ReferenceId, null) | builder.Eq(f => f.ReferenceId, string.Empty); } } diff --git a/src/Infrastructure.MongoDb/Repositories/CarHistoryRepository.cs b/src/Infrastructure.MongoDb/Repositories/CarHistoryRepository.cs index f69adacc..da4a32b1 100644 --- a/src/Infrastructure.MongoDb/Repositories/CarHistoryRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/CarHistoryRepository.cs @@ -1,23 +1,35 @@ -using AutoMapper; +using System.Threading.Tasks; using Keeptrack.Domain.Models; using Keeptrack.Domain.Repositories; using Keeptrack.Infrastructure.MongoDb.Entities; +using Keeptrack.Infrastructure.MongoDb.Mappers; using Microsoft.Extensions.Logging; using MongoDB.Driver; namespace Keeptrack.Infrastructure.MongoDb.Repositories; -public class CarHistoryRepository(IMongoDatabase mongoDatabase, ILogger> logger, IMapper mapper) +public class CarHistoryRepository(IMongoDatabase mongoDatabase, ILogger logger, IStorageMapper mapper) : MongoDbRepositoryBase(mongoDatabase, logger, mapper), ICarHistoryRepository { protected override string CollectionName => "car_history"; + public async Task DeleteAllForCarAsync(string carId, string ownerId) + { + var filter = Builders.Filter.Eq(f => f.OwnerId, ownerId) & Builders.Filter.Eq(f => f.CarId, carId); + var result = await GetCollection().DeleteManyAsync(filter); + return result.DeletedCount; + } + protected override FilterDefinition GetFilter(string ownerId, string? search, CarHistoryModel input) { var builder = Builders.Filter; var filter = builder.Eq(f => f.OwnerId, ownerId); - if (!string.IsNullOrEmpty(input.CarId)) filter &= builder.Text(input.CarId); - if (!string.IsNullOrEmpty(search)) filter &= builder.Text(search); + if (!string.IsNullOrEmpty(input.CarId)) filter &= builder.Eq(f => f.CarId, input.CarId); + if (!string.IsNullOrEmpty(search)) + { + filter &= builder.Where(f => f.Description != null + && f.Description.Contains(search, System.StringComparison.CurrentCultureIgnoreCase)); + } return filter; } } diff --git a/src/Infrastructure.MongoDb/Repositories/CarRepository.cs b/src/Infrastructure.MongoDb/Repositories/CarRepository.cs index 8cf4982b..037431ba 100644 --- a/src/Infrastructure.MongoDb/Repositories/CarRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/CarRepository.cs @@ -1,14 +1,22 @@ -using AutoMapper; -using Keeptrack.Domain.Models; +using Keeptrack.Domain.Models; using Keeptrack.Domain.Repositories; using Keeptrack.Infrastructure.MongoDb.Entities; +using Keeptrack.Infrastructure.MongoDb.Mappers; using Microsoft.Extensions.Logging; using MongoDB.Driver; namespace Keeptrack.Infrastructure.MongoDb.Repositories; -public class CarRepository(IMongoDatabase mongoDatabase, ILogger> logger, IMapper mapper) +public class CarRepository(IMongoDatabase mongoDatabase, ILogger logger, IStorageMapper mapper) : MongoDbRepositoryBase(mongoDatabase, logger, mapper), ICarRepository { protected override string CollectionName => "car"; + + protected override FilterDefinition GetFilter(string ownerId, string? search, CarModel input) + { + var builder = Builders.Filter; + var filter = builder.Eq(f => f.OwnerId, ownerId); + if (!string.IsNullOrEmpty(search)) filter &= builder.Where(f => f.Name.Contains(search, System.StringComparison.CurrentCultureIgnoreCase)); + return filter; + } } diff --git a/src/Infrastructure.MongoDb/Repositories/EpisodeRepository.cs b/src/Infrastructure.MongoDb/Repositories/EpisodeRepository.cs new file mode 100644 index 00000000..3653356e --- /dev/null +++ b/src/Infrastructure.MongoDb/Repositories/EpisodeRepository.cs @@ -0,0 +1,22 @@ +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.Infrastructure.MongoDb.Entities; +using Keeptrack.Infrastructure.MongoDb.Mappers; +using Microsoft.Extensions.Logging; +using MongoDB.Driver; + +namespace Keeptrack.Infrastructure.MongoDb.Repositories; + +public class EpisodeRepository(IMongoDatabase mongoDatabase, ILogger logger, IStorageMapper mapper) + : MongoDbRepositoryBase(mongoDatabase, logger, mapper), IEpisodeRepository +{ + protected override string CollectionName => "episode"; + + protected override FilterDefinition GetFilter(string ownerId, string? search, EpisodeModel input) + { + var builder = Builders.Filter; + var filter = builder.Eq(f => f.OwnerId, ownerId); + if (!string.IsNullOrEmpty(input.TvShowId)) filter &= builder.Eq(f => f.TvShowId, input.TvShowId); + return filter; + } +} diff --git a/src/Infrastructure.MongoDb/Repositories/HouseHistoryRepository.cs b/src/Infrastructure.MongoDb/Repositories/HouseHistoryRepository.cs new file mode 100644 index 00000000..4fb6f826 --- /dev/null +++ b/src/Infrastructure.MongoDb/Repositories/HouseHistoryRepository.cs @@ -0,0 +1,35 @@ +using System.Threading.Tasks; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.Infrastructure.MongoDb.Entities; +using Keeptrack.Infrastructure.MongoDb.Mappers; +using Microsoft.Extensions.Logging; +using MongoDB.Driver; + +namespace Keeptrack.Infrastructure.MongoDb.Repositories; + +public class HouseHistoryRepository(IMongoDatabase mongoDatabase, ILogger logger, IStorageMapper mapper) + : MongoDbRepositoryBase(mongoDatabase, logger, mapper), IHouseHistoryRepository +{ + protected override string CollectionName => "house_history"; + + public async Task DeleteAllForHouseAsync(string houseId, string ownerId) + { + var filter = Builders.Filter.Eq(f => f.OwnerId, ownerId) & Builders.Filter.Eq(f => f.HouseId, houseId); + var result = await GetCollection().DeleteManyAsync(filter); + return result.DeletedCount; + } + + protected override FilterDefinition GetFilter(string ownerId, string? search, HouseHistoryModel input) + { + var builder = Builders.Filter; + var filter = builder.Eq(f => f.OwnerId, ownerId); + if (!string.IsNullOrEmpty(input.HouseId)) filter &= builder.Eq(f => f.HouseId, input.HouseId); + if (!string.IsNullOrEmpty(search)) + { + filter &= builder.Where(f => f.Description != null + && f.Description.Contains(search, System.StringComparison.CurrentCultureIgnoreCase)); + } + return filter; + } +} diff --git a/src/Infrastructure.MongoDb/Repositories/HouseRepository.cs b/src/Infrastructure.MongoDb/Repositories/HouseRepository.cs new file mode 100644 index 00000000..0e3e614b --- /dev/null +++ b/src/Infrastructure.MongoDb/Repositories/HouseRepository.cs @@ -0,0 +1,22 @@ +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.Infrastructure.MongoDb.Entities; +using Keeptrack.Infrastructure.MongoDb.Mappers; +using Microsoft.Extensions.Logging; +using MongoDB.Driver; + +namespace Keeptrack.Infrastructure.MongoDb.Repositories; + +public class HouseRepository(IMongoDatabase mongoDatabase, ILogger logger, IStorageMapper mapper) + : MongoDbRepositoryBase(mongoDatabase, logger, mapper), IHouseRepository +{ + protected override string CollectionName => "house"; + + protected override FilterDefinition GetFilter(string ownerId, string? search, HouseModel input) + { + var builder = Builders.Filter; + var filter = builder.Eq(f => f.OwnerId, ownerId); + if (!string.IsNullOrEmpty(search)) filter &= builder.Where(f => f.Name.Contains(search, System.StringComparison.CurrentCultureIgnoreCase)); + return filter; + } +} diff --git a/src/Infrastructure.MongoDb/Repositories/MongoDbRepositoryBase.cs b/src/Infrastructure.MongoDb/Repositories/MongoDbRepositoryBase.cs index eb0c6fb8..0bbb169a 100644 --- a/src/Infrastructure.MongoDb/Repositories/MongoDbRepositoryBase.cs +++ b/src/Infrastructure.MongoDb/Repositories/MongoDbRepositoryBase.cs @@ -1,8 +1,6 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using AutoMapper; +using System.Threading.Tasks; using Keeptrack.Common.System; -using Keeptrack.Infrastructure.MongoDb.Entities; +using Keeptrack.Infrastructure.MongoDb.Mappers; using Microsoft.Extensions.Logging; using MongoDB.Driver; @@ -16,19 +14,25 @@ namespace Keeptrack.Infrastructure.MongoDb.Repositories; public abstract class MongoDbRepositoryBase( IMongoDatabase mongoDatabase, ILogger> logger, - IMapper mapper) + IStorageMapper mapper) where TEntity : IHasIdAndOwnerId { protected abstract string CollectionName { get; } protected ILogger> Logger { get; } = logger; - private IMapper Mapper { get; } = mapper; + private IStorageMapper Mapper { get; } = mapper; + /// + /// "Not found" must stay a real null, not a mapped default instance: Mapperly throws on a null + /// source rather than substituting a default instance, so the missing-document check has to happen + /// before mapping regardless - this guard is what makes that "not found" case behave as null instead + /// of propagating an exception. + /// public async Task FindOneAsync(string id, string ownerId) { - var entities = await GetCollection().FindAsync(x => x.Id == id && x.OwnerId == ownerId); - return Mapper.Map(await entities.FirstOrDefaultAsync()); + var entity = await GetCollection().Find(x => x.Id == id && x.OwnerId == ownerId).FirstOrDefaultAsync(); + return entity is null ? default : Mapper.ToModel(entity); } public async Task> FindAllAsync(string ownerId, int page, int pageSize, string? search, TModel input) @@ -45,7 +49,7 @@ public async Task> FindAllAsync(string ownerId, int page, in .ToListAsync(); return new PagedResult( - Mapper.Map>(entities), + Mapper.ToModels(entities), totalCount, page, pageSize @@ -54,14 +58,14 @@ public async Task> FindAllAsync(string ownerId, int page, in public async Task CreateAsync(TModel model) { - var entity = Mapper.Map(model); + var entity = Mapper.ToEntity(model); await GetCollection().InsertOneAsync(entity); - return Mapper.Map(entity); + return Mapper.ToModel(entity); } public async Task UpdateAsync(string id, TModel model, string ownerId) { - var entity = Mapper.Map(model); + var entity = Mapper.ToEntity(model); var result = await GetCollection().ReplaceOneAsync(x => x.Id == id && x.OwnerId == ownerId, entity); return result.ModifiedCount; } @@ -80,7 +84,7 @@ protected virtual FilterDefinition GetFilter(string ownerId, string? se return filter; } - private IMongoCollection GetCollection() + protected IMongoCollection GetCollection() { return mongoDatabase.GetCollection(CollectionName); } diff --git a/src/Infrastructure.MongoDb/Repositories/MovieReferenceRepository.cs b/src/Infrastructure.MongoDb/Repositories/MovieReferenceRepository.cs new file mode 100644 index 00000000..990e5c3d --- /dev/null +++ b/src/Infrastructure.MongoDb/Repositories/MovieReferenceRepository.cs @@ -0,0 +1,87 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Keeptrack.Common.System; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.Infrastructure.MongoDb.Entities; +using Keeptrack.Infrastructure.MongoDb.Mappers; +using MongoDB.Driver; + +namespace Keeptrack.Infrastructure.MongoDb.Repositories; + +public class MovieReferenceRepository(IMongoDatabase mongoDatabase, MovieReferenceStorageMapper mapper) : IMovieReferenceRepository +{ + private const string CollectionName = "movie_reference"; + + private IMongoCollection Collection => mongoDatabase.GetCollection(CollectionName); + + public async Task FindByIdAsync(string id) + { + var entity = await Collection.Find(x => x.Id == id).FirstOrDefaultAsync(); + // Mapperly throws on a null source rather than substituting a default instance - checking for a + // missing document must happen before mapping regardless. + return entity is null ? null : mapper.ToModel(entity); + } + + public async Task FindByTitleYearAsync(string title, int? year) + { + // matches against every known-good (title, year) combination for this reference (see + // MatchedAliases), not just its canonical TitleNormalized/Year - ElemMatch requires both conditions + // to hold on the SAME array element, so a tenant whose recorded year genuinely differs from the + // document's own canonical Year still matches, as long as that exact (title, year) pair was + // confirmed at some point (automatic resolution or admin pick). + var normalized = TitleNormalizer.Normalize(title); + var filter = Builders.Filter.ElemMatch(x => x.MatchedAliases, + Builders.Filter.Eq(m => m.Title, normalized) & Builders.Filter.Eq(m => m.Year, year)); + var entity = await Collection.Find(filter).FirstOrDefaultAsync(); + return entity is null ? null : mapper.ToModel(entity); + } + + public async Task FindByTitleAsync(string title) + { + var normalized = TitleNormalizer.Normalize(title); + var filter = Builders.Filter.ElemMatch(x => x.MatchedAliases, + Builders.Filter.Eq(m => m.Title, normalized)); + var entity = await Collection.Find(filter).FirstOrDefaultAsync(); + return entity is null ? null : mapper.ToModel(entity); + } + + public async Task FindByExternalIdAsync(string provider, string externalId) + { + // a string field-path filter, not an expression indexer - the driver's expression-to-filter + // translation doesn't support indexing a Dictionary by a runtime key. + var filter = Builders.Filter.Eq($"external_ids.{provider}", externalId); + var entity = await Collection.Find(filter).FirstOrDefaultAsync(); + return entity is null ? null : mapper.ToModel(entity); + } + + public async Task> FindAllAsync() + { + var entities = await Collection.Find(FilterDefinition.Empty).ToListAsync(); + return entities.Select(mapper.ToModel).ToList(); + } + + public async Task UpsertAsync(MovieReferenceModel model) + { + model.TitleNormalized = TitleNormalizer.Normalize(model.Title); + // the canonical (title, year) combination is always itself a valid match, whether or not the caller + // remembered to include it + if (!model.MatchedAliases.Any(m => m.Title == model.TitleNormalized && m.Year == model.Year)) + { + model.MatchedAliases.Add(new ReferenceMatchModel { Title = model.TitleNormalized, Year = model.Year }); + } + var entity = mapper.ToEntity(model); + + if (string.IsNullOrEmpty(entity.Id)) + { + await Collection.InsertOneAsync(entity); + } + else + { + await Collection.ReplaceOneAsync(x => x.Id == entity.Id, entity, new ReplaceOptions { IsUpsert = true }); + } + + return mapper.ToModel(entity); + } +} diff --git a/src/Infrastructure.MongoDb/Repositories/MovieRepository.cs b/src/Infrastructure.MongoDb/Repositories/MovieRepository.cs index bc2ff4cb..23fdc975 100644 --- a/src/Infrastructure.MongoDb/Repositories/MovieRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/MovieRepository.cs @@ -1,23 +1,65 @@ -using AutoMapper; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; using Keeptrack.Domain.Models; using Keeptrack.Domain.Repositories; using Keeptrack.Infrastructure.MongoDb.Entities; +using Keeptrack.Infrastructure.MongoDb.Mappers; using Microsoft.Extensions.Logging; +using MongoDB.Bson; using MongoDB.Driver; -namespace Keeptrack.Infrastructure.MongoDb.Repositories +namespace Keeptrack.Infrastructure.MongoDb.Repositories; + +public class MovieRepository(IMongoDatabase mongoDatabase, ILogger logger, IStorageMapper mapper) + : MongoDbRepositoryBase(mongoDatabase, logger, mapper), IMovieRepository { - public class MovieRepository(IMongoDatabase mongoDatabase, ILogger> logger, IMapper mapper) - : MongoDbRepositoryBase(mongoDatabase, logger, mapper), IMovieRepository + protected override string CollectionName => "movie"; + + protected override FilterDefinition GetFilter(string ownerId, string? search, MovieModel input) + { + var builder = Builders.Filter; + var filter = builder.Eq(f => f.OwnerId, ownerId); + if (!string.IsNullOrEmpty(search)) filter &= builder.Where(f => f.Title.Contains(search, System.StringComparison.CurrentCultureIgnoreCase)); + if (input.IsFavorite) filter &= builder.Eq(f => f.IsFavorite, true); + if (input.WantToWatch) filter &= builder.Eq(f => f.WantToWatch, true); + if (input.IsOwned) filter &= builder.Eq(f => f.IsOwned, true); + if (input.IsWishlisted) filter &= builder.Eq(f => f.IsWishlisted, true); + return filter; + } + + public async Task SetReferenceLinkAsync(string title, int? year, string referenceId, string canonicalTitle, int? canonicalYear = null) { - protected override string CollectionName => "movie"; + var builder = Builders.Filter; + var filter = builder.Regex(f => f.Title, new BsonRegularExpression($"^{Regex.Escape(title)}$", "i")) + & builder.Eq(f => f.Year, year) + & UnresolvedFilter(); - protected override FilterDefinition GetFilter(string ownerId, string? search, MovieModel input) - { - var builder = Builders.Filter; - var filter = builder.Eq(f => f.OwnerId, ownerId); - if (!string.IsNullOrEmpty(search)) builder.Where(f => f.Title.Contains(search, System.StringComparison.CurrentCultureIgnoreCase)); - return filter; - } + var update = Builders.Update.Set(f => f.ReferenceId, referenceId).Set(f => f.Title, canonicalTitle); + if (canonicalYear is not null) update = update.Set(f => f.Year, canonicalYear); + var result = await GetCollection().UpdateManyAsync(filter, update); + return result.ModifiedCount; + } + + public async Task> FindDistinctUnresolvedTitleYearsAsync() + { + var groups = await GetCollection().Aggregate() + .Match(UnresolvedFilter()) + .Group(f => new { f.Title, f.Year }, g => g.Key) + .ToListAsync(); + return groups.Select(g => (g.Title, g.Year)).ToList(); + } + + /// + /// "Has no reference link yet" means is null OR empty string, not + /// just null: old documents (written before the AutoMapper -> Mapperly migration) can still store "" + /// for an unset field; new writes store a real null instead (Mapperly preserves nulls, and the Mongo + /// driver's IgnoreIfNullConvention then omits it entirely). Both generations must match. + /// + private static FilterDefinition UnresolvedFilter() + { + var builder = Builders.Filter; + return builder.Eq(f => f.ReferenceId, null) | builder.Eq(f => f.ReferenceId, string.Empty); } } diff --git a/src/Infrastructure.MongoDb/Repositories/MusicAlbumRepository.cs b/src/Infrastructure.MongoDb/Repositories/MusicAlbumRepository.cs deleted file mode 100644 index a03e6bec..00000000 --- a/src/Infrastructure.MongoDb/Repositories/MusicAlbumRepository.cs +++ /dev/null @@ -1,24 +0,0 @@ -using AutoMapper; -using Keeptrack.Domain.Models; -using Keeptrack.Domain.Repositories; -using Keeptrack.Infrastructure.MongoDb.Entities; -using Microsoft.Extensions.Logging; -using MongoDB.Driver; - -namespace Keeptrack.Infrastructure.MongoDb.Repositories; - -public class MusicAlbumRepository(IMongoDatabase mongoDatabase, ILogger> logger, IMapper mapper) - : MongoDbRepositoryBase(mongoDatabase, logger, mapper), IMusicAlbumRepository -{ - protected override string CollectionName => "music-album"; - - protected override FilterDefinition GetFilter(string ownerId, string? search, MusicAlbumModel input) - { - var builder = Builders.Filter; - var filter = builder.Eq(f => f.OwnerId, ownerId); - if (!string.IsNullOrEmpty(search)) builder.Where(f => f.Title.Contains(search, System.StringComparison.CurrentCultureIgnoreCase) - || f.Artist.Contains(search, System.StringComparison.CurrentCultureIgnoreCase)); - return filter; - } -} - diff --git a/src/Infrastructure.MongoDb/Repositories/PersonReferenceRepository.cs b/src/Infrastructure.MongoDb/Repositories/PersonReferenceRepository.cs new file mode 100644 index 00000000..166b7672 --- /dev/null +++ b/src/Infrastructure.MongoDb/Repositories/PersonReferenceRepository.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.Infrastructure.MongoDb.Entities; +using Keeptrack.Infrastructure.MongoDb.Mappers; +using MongoDB.Driver; + +namespace Keeptrack.Infrastructure.MongoDb.Repositories; + +public class PersonReferenceRepository(IMongoDatabase mongoDatabase, PersonReferenceStorageMapper mapper) : IPersonReferenceRepository +{ + private const string CollectionName = "person_reference"; + + private IMongoCollection Collection => mongoDatabase.GetCollection(CollectionName); + + public async Task FindByIdAsync(string id) + { + var entity = await Collection.Find(x => x.Id == id).FirstOrDefaultAsync(); + // Mapperly throws on a null source rather than substituting a default instance - checking for a + // missing document must happen before mapping regardless. + return entity is null ? null : mapper.ToModel(entity); + } + + public async Task FindByExternalIdAsync(string provider, string externalId) + { + // a string field-path filter, not an expression indexer - the driver's expression-to-filter + // translation doesn't support indexing a Dictionary by a runtime key. + var filter = Builders.Filter.Eq($"external_ids.{provider}", externalId); + var entity = await Collection.Find(filter).FirstOrDefaultAsync(); + return entity is null ? null : mapper.ToModel(entity); + } + + public async Task> FindAllAsync() + { + var entities = await Collection.Find(FilterDefinition.Empty).ToListAsync(); + return entities.Select(mapper.ToModel).ToList(); + } + + public async Task UpsertAsync(PersonReferenceModel model) + { + var entity = mapper.ToEntity(model); + + if (string.IsNullOrEmpty(entity.Id)) + { + await Collection.InsertOneAsync(entity); + } + else + { + await Collection.ReplaceOneAsync(x => x.Id == entity.Id, entity, new ReplaceOptions { IsUpsert = true }); + } + + return mapper.ToModel(entity); + } +} diff --git a/src/Infrastructure.MongoDb/Repositories/PlaylistRepository.cs b/src/Infrastructure.MongoDb/Repositories/PlaylistRepository.cs new file mode 100644 index 00000000..e0852a66 --- /dev/null +++ b/src/Infrastructure.MongoDb/Repositories/PlaylistRepository.cs @@ -0,0 +1,22 @@ +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.Infrastructure.MongoDb.Entities; +using Keeptrack.Infrastructure.MongoDb.Mappers; +using Microsoft.Extensions.Logging; +using MongoDB.Driver; + +namespace Keeptrack.Infrastructure.MongoDb.Repositories; + +public class PlaylistRepository(IMongoDatabase mongoDatabase, ILogger logger, IStorageMapper mapper) + : MongoDbRepositoryBase(mongoDatabase, logger, mapper), IPlaylistRepository +{ + protected override string CollectionName => "playlist"; + + protected override FilterDefinition GetFilter(string ownerId, string? search, PlaylistModel input) + { + var builder = Builders.Filter; + var filter = builder.Eq(f => f.OwnerId, ownerId); + if (!string.IsNullOrEmpty(search)) filter &= builder.Where(f => f.Title.Contains(search, System.StringComparison.CurrentCultureIgnoreCase)); + return filter; + } +} diff --git a/src/Infrastructure.MongoDb/Repositories/SongRepository.cs b/src/Infrastructure.MongoDb/Repositories/SongRepository.cs new file mode 100644 index 00000000..aacf6979 --- /dev/null +++ b/src/Infrastructure.MongoDb/Repositories/SongRepository.cs @@ -0,0 +1,30 @@ +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.Infrastructure.MongoDb.Entities; +using Keeptrack.Infrastructure.MongoDb.Mappers; +using Microsoft.Extensions.Logging; +using MongoDB.Driver; + +namespace Keeptrack.Infrastructure.MongoDb.Repositories; + +public class SongRepository(IMongoDatabase mongoDatabase, ILogger logger, IStorageMapper mapper) + : MongoDbRepositoryBase(mongoDatabase, logger, mapper), ISongRepository +{ + protected override string CollectionName => "song"; + + protected override FilterDefinition GetFilter(string ownerId, string? search, SongModel input) + { + var builder = Builders.Filter; + var filter = builder.Eq(f => f.OwnerId, ownerId); + if (!string.IsNullOrEmpty(search)) + { + filter &= builder.Where(f => f.Title.Contains(search, System.StringComparison.CurrentCultureIgnoreCase) + || (f.Artist != null && f.Artist.Contains(search, System.StringComparison.CurrentCultureIgnoreCase))); + } + + if (!string.IsNullOrEmpty(input.AlbumId)) filter &= builder.Eq(f => f.AlbumId, input.AlbumId); + if (!string.IsNullOrEmpty(input.TrackPosition)) filter &= builder.Eq(f => f.TrackPosition, input.TrackPosition); + + return filter; + } +} diff --git a/src/Infrastructure.MongoDb/Repositories/TvShowReferenceRepository.cs b/src/Infrastructure.MongoDb/Repositories/TvShowReferenceRepository.cs new file mode 100644 index 00000000..7d4e3d87 --- /dev/null +++ b/src/Infrastructure.MongoDb/Repositories/TvShowReferenceRepository.cs @@ -0,0 +1,82 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Keeptrack.Common.System; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.Infrastructure.MongoDb.Entities; +using Keeptrack.Infrastructure.MongoDb.Mappers; +using MongoDB.Driver; + +namespace Keeptrack.Infrastructure.MongoDb.Repositories; + +public class TvShowReferenceRepository(IMongoDatabase mongoDatabase, TvShowReferenceStorageMapper mapper) : ITvShowReferenceRepository +{ + private const string CollectionName = "tvshow_reference"; + + private IMongoCollection Collection => mongoDatabase.GetCollection(CollectionName); + + public async Task FindByIdAsync(string id) + { + var entity = await Collection.Find(x => x.Id == id).FirstOrDefaultAsync(); + // Mapperly throws on a null source rather than substituting a default instance - checking for a missing document must happen before mapping regardless. + return entity is null ? null : mapper.ToModel(entity); + } + + public async Task FindByTitleYearAsync(string title, int? year) + { + // matches against every known-good (title, year) combination for this reference (see MatchedAliases), not just its canonical TitleNormalized/Year - + // ElemMatch requires both conditions to hold on the SAME array element, so a tenant whose recorded year genuinely differs from the document's own canonical Year still matches, + // as long as that exact (title, year) pair was confirmed at some point (automatic resolution or admin pick). + var normalized = TitleNormalizer.Normalize(title); + var filter = Builders.Filter.ElemMatch(x => x.MatchedAliases, + Builders.Filter.Eq(m => m.Title, normalized) & Builders.Filter.Eq(m => m.Year, year)); + var entity = await Collection.Find(filter).FirstOrDefaultAsync(); + return entity is null ? null : mapper.ToModel(entity); + } + + public async Task FindByTitleAsync(string title) + { + var normalized = TitleNormalizer.Normalize(title); + var filter = Builders.Filter.ElemMatch(x => x.MatchedAliases, + Builders.Filter.Eq(m => m.Title, normalized)); + var entity = await Collection.Find(filter).FirstOrDefaultAsync(); + return entity is null ? null : mapper.ToModel(entity); + } + + public async Task FindByExternalIdAsync(string provider, string externalId) + { + // a string field-path filter, not an expression indexer - the driver's expression-to-filter translation doesn't support indexing a Dictionary by a runtime key. + var filter = Builders.Filter.Eq($"external_ids.{provider}", externalId); + var entity = await Collection.Find(filter).FirstOrDefaultAsync(); + return entity is null ? null : mapper.ToModel(entity); + } + + public async Task> FindAllAsync() + { + var entities = await Collection.Find(FilterDefinition.Empty).ToListAsync(); + return entities.Select(mapper.ToModel).ToList(); + } + + public async Task UpsertAsync(TvShowReferenceModel model) + { + model.TitleNormalized = TitleNormalizer.Normalize(model.Title); + // the canonical (title, year) combination is always itself a valid match, whether the caller remembered to include it + if (!model.MatchedAliases.Any(m => m.Title == model.TitleNormalized && m.Year == model.Year)) + { + model.MatchedAliases.Add(new ReferenceMatchModel { Title = model.TitleNormalized, Year = model.Year }); + } + var entity = mapper.ToEntity(model); + + if (string.IsNullOrEmpty(entity.Id)) + { + await Collection.InsertOneAsync(entity); + } + else + { + await Collection.ReplaceOneAsync(x => x.Id == entity.Id, entity, new ReplaceOptions { IsUpsert = true }); + } + + return mapper.ToModel(entity); + } +} diff --git a/src/Infrastructure.MongoDb/Repositories/TvShowRepository.cs b/src/Infrastructure.MongoDb/Repositories/TvShowRepository.cs index 6b4983df..1eb7b25a 100644 --- a/src/Infrastructure.MongoDb/Repositories/TvShowRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/TvShowRepository.cs @@ -1,13 +1,18 @@ -using AutoMapper; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; using Keeptrack.Domain.Models; using Keeptrack.Domain.Repositories; using Keeptrack.Infrastructure.MongoDb.Entities; +using Keeptrack.Infrastructure.MongoDb.Mappers; using Microsoft.Extensions.Logging; +using MongoDB.Bson; using MongoDB.Driver; namespace Keeptrack.Infrastructure.MongoDb.Repositories; -public class TvShowRepository(IMongoDatabase mongoDatabase, ILogger> logger, IMapper mapper) +public class TvShowRepository(IMongoDatabase mongoDatabase, ILogger logger, IStorageMapper mapper) : MongoDbRepositoryBase(mongoDatabase, logger, mapper), ITvShowRepository { protected override string CollectionName => "tvshow"; @@ -17,6 +22,45 @@ protected override FilterDefinition GetFilter(string ownerId, string? se var builder = Builders.Filter; var filter = builder.Eq(f => f.OwnerId, ownerId); if (!string.IsNullOrEmpty(search)) filter &= builder.Where(f => f.Title.Contains(search, System.StringComparison.CurrentCultureIgnoreCase)); + if (input.IsFavorite) filter &= builder.Eq(f => f.IsFavorite, true); + if (input.WantToWatch) filter &= builder.Eq(f => f.WantToWatch, true); + if (input.State is not null) filter &= builder.Eq(f => f.State, input.State); + if (input.IsOwned) filter &= builder.Eq(f => f.IsOwned, true); + if (input.IsWishlisted) filter &= builder.Eq(f => f.IsWishlisted, true); return filter; } + + public async Task SetReferenceLinkAsync(string title, int? year, string referenceId, string canonicalTitle, int? canonicalYear = null) + { + var builder = Builders.Filter; + var filter = builder.Regex(f => f.Title, new BsonRegularExpression($"^{Regex.Escape(title)}$", "i")) + & builder.Eq(f => f.Year, year) + & UnresolvedFilter(); + + var update = Builders.Update.Set(f => f.ReferenceId, referenceId).Set(f => f.Title, canonicalTitle); + if (canonicalYear is not null) update = update.Set(f => f.Year, canonicalYear); + var result = await GetCollection().UpdateManyAsync(filter, update); + return result.ModifiedCount; + } + + public async Task> FindDistinctUnresolvedTitleYearsAsync() + { + var groups = await GetCollection().Aggregate() + .Match(UnresolvedFilter()) + .Group(f => new { f.Title, f.Year }, g => g.Key) + .ToListAsync(); + return groups.Select(g => (g.Title, g.Year)).ToList(); + } + + /// + /// "Has no reference link yet" means is null OR empty string, not + /// just null: old documents (written before the AutoMapper -> Mapperly migration) can still store "" + /// for an unset field; new writes store a real null instead (Mapperly preserves nulls, and the Mongo + /// driver's IgnoreIfNullConvention then omits it entirely). Both generations must match. + /// + private static FilterDefinition UnresolvedFilter() + { + var builder = Builders.Filter; + return builder.Eq(f => f.ReferenceId, null) | builder.Eq(f => f.ReferenceId, string.Empty); + } } diff --git a/src/Infrastructure.MongoDb/Repositories/VideoGameReferenceRepository.cs b/src/Infrastructure.MongoDb/Repositories/VideoGameReferenceRepository.cs new file mode 100644 index 00000000..57310845 --- /dev/null +++ b/src/Infrastructure.MongoDb/Repositories/VideoGameReferenceRepository.cs @@ -0,0 +1,76 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Keeptrack.Common.System; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.Infrastructure.MongoDb.Entities; +using Keeptrack.Infrastructure.MongoDb.Mappers; +using MongoDB.Driver; + +namespace Keeptrack.Infrastructure.MongoDb.Repositories; + +public class VideoGameReferenceRepository(IMongoDatabase mongoDatabase, VideoGameReferenceStorageMapper mapper) : IVideoGameReferenceRepository +{ + private const string CollectionName = "videogame_reference"; + + private IMongoCollection Collection => mongoDatabase.GetCollection(CollectionName); + + public async Task FindByIdAsync(string id) + { + var entity = await Collection.Find(x => x.Id == id).FirstOrDefaultAsync(); + return entity is null ? null : mapper.ToModel(entity); + } + + public async Task FindByTitleYearAsync(string title, int? year) + { + var normalized = TitleNormalizer.Normalize(title); + var filter = Builders.Filter.ElemMatch(x => x.MatchedAliases, + Builders.Filter.Eq(m => m.Title, normalized) & Builders.Filter.Eq(m => m.Year, year)); + var entity = await Collection.Find(filter).FirstOrDefaultAsync(); + return entity is null ? null : mapper.ToModel(entity); + } + + public async Task FindByTitleAsync(string title) + { + var normalized = TitleNormalizer.Normalize(title); + var filter = Builders.Filter.ElemMatch(x => x.MatchedAliases, + Builders.Filter.Eq(m => m.Title, normalized)); + var entity = await Collection.Find(filter).FirstOrDefaultAsync(); + return entity is null ? null : mapper.ToModel(entity); + } + + public async Task FindByExternalIdAsync(string provider, string externalId) + { + var filter = Builders.Filter.Eq($"external_ids.{provider}", externalId); + var entity = await Collection.Find(filter).FirstOrDefaultAsync(); + return entity is null ? null : mapper.ToModel(entity); + } + + public async Task> FindAllAsync() + { + var entities = await Collection.Find(FilterDefinition.Empty).ToListAsync(); + return entities.Select(mapper.ToModel).ToList(); + } + + public async Task UpsertAsync(VideoGameReferenceModel model) + { + model.TitleNormalized = TitleNormalizer.Normalize(model.Title); + if (!model.MatchedAliases.Any(m => m.Title == model.TitleNormalized && m.Year == model.Year)) + { + model.MatchedAliases.Add(new ReferenceMatchModel { Title = model.TitleNormalized, Year = model.Year }); + } + var entity = mapper.ToEntity(model); + + if (string.IsNullOrEmpty(entity.Id)) + { + await Collection.InsertOneAsync(entity); + } + else + { + await Collection.ReplaceOneAsync(x => x.Id == entity.Id, entity, new ReplaceOptions { IsUpsert = true }); + } + + return mapper.ToModel(entity); + } +} diff --git a/src/Infrastructure.MongoDb/Repositories/VideoGameRepository.cs b/src/Infrastructure.MongoDb/Repositories/VideoGameRepository.cs index 6837d4b3..37111de4 100644 --- a/src/Infrastructure.MongoDb/Repositories/VideoGameRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/VideoGameRepository.cs @@ -1,13 +1,18 @@ -using AutoMapper; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; using Keeptrack.Domain.Models; using Keeptrack.Domain.Repositories; using Keeptrack.Infrastructure.MongoDb.Entities; +using Keeptrack.Infrastructure.MongoDb.Mappers; using Microsoft.Extensions.Logging; +using MongoDB.Bson; using MongoDB.Driver; namespace Keeptrack.Infrastructure.MongoDb.Repositories; -public class VideoGameRepository(IMongoDatabase mongoDatabase, ILogger> logger, IMapper mapper) +public class VideoGameRepository(IMongoDatabase mongoDatabase, ILogger logger, IStorageMapper mapper) : MongoDbRepositoryBase(mongoDatabase, logger, mapper), IVideoGameRepository { protected override string CollectionName => "videogame"; @@ -17,8 +22,44 @@ protected override FilterDefinition GetFilter(string ownerId, string? var builder = Builders.Filter; var filter = builder.Eq(f => f.OwnerId, ownerId); if (!string.IsNullOrEmpty(search)) filter &= builder.Where(f => f.Title.Contains(search, System.StringComparison.CurrentCultureIgnoreCase)); - if (!string.IsNullOrEmpty(input.State)) filter &= builder.Where(f => f.State == input.State); - if (!string.IsNullOrEmpty(input.Platform)) filter &= builder.Where(f => f.Platform == input.Platform); + if (!string.IsNullOrEmpty(input.State)) filter &= builder.AnyEq(f => f.Platforms.Select(p => p.State), input.State); + if (!string.IsNullOrEmpty(input.Platform)) filter &= builder.AnyEq(f => f.Platforms.Select(p => p.Platform), input.Platform); + if (input.IsOwned) filter &= builder.Eq(f => f.IsOwned, true); + if (input.IsWishlisted) filter &= builder.Eq(f => f.IsWishlisted, true); return filter; } + + public async Task SetReferenceLinkAsync(string title, int? year, string referenceId, string canonicalTitle, int? canonicalYear = null) + { + var builder = Builders.Filter; + var filter = builder.Regex(f => f.Title, new BsonRegularExpression($"^{Regex.Escape(title)}$", "i")) + & builder.Eq(f => f.Year, year) + & UnresolvedFilter(); + + var update = Builders.Update.Set(f => f.ReferenceId, referenceId).Set(f => f.Title, canonicalTitle); + if (canonicalYear is not null) update = update.Set(f => f.Year, canonicalYear); + var result = await GetCollection().UpdateManyAsync(filter, update); + return result.ModifiedCount; + } + + public async Task> FindDistinctUnresolvedTitleYearsAsync() + { + var groups = await GetCollection().Aggregate() + .Match(UnresolvedFilter()) + .Group(f => new { f.Title, f.Year }, g => g.Key) + .ToListAsync(); + return groups.Select(g => (g.Title, g.Year)).ToList(); + } + + /// + /// "Has no reference link yet" means is null OR empty string, not + /// just null: old documents (written before the AutoMapper -> Mapperly migration) can still store "" + /// for an unset field; new writes store a real null instead (Mapperly preserves nulls, and the Mongo + /// driver's IgnoreIfNullConvention then omits it entirely). Both generations must match. + /// + private static FilterDefinition UnresolvedFilter() + { + var builder = Builders.Filter; + return builder.Eq(f => f.ReferenceId, null) | builder.Eq(f => f.ReferenceId, string.Empty); + } } diff --git a/src/WebApi.Contracts/Dto/AlbumDto.cs b/src/WebApi.Contracts/Dto/AlbumDto.cs new file mode 100644 index 00000000..1fd4dd29 --- /dev/null +++ b/src/WebApi.Contracts/Dto/AlbumDto.cs @@ -0,0 +1,48 @@ +using Keeptrack.Common.System; + +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// A music album the user has listened to or wants to listen to. +/// +public class AlbumDto : IHasId +{ + /// + /// Unique identifier. + /// + public string? Id { get; set; } + + /// + /// Album title. + /// + /// The Dark Side of the Moon + public string? Title { get; set; } + + /// + /// Artist or band name. + /// + /// Pink Floyd + public string? Artist { get; set; } + + /// + /// Release year. + /// + public int? Year { get; set; } + + /// + /// Musical genre. + /// + public string? Genre { get; set; } + + /// + /// User rating, from 0 to 5. + /// + public float? Rating { get; set; } + + /// + /// Id of the linked album_reference document, when a match has been found. + /// + public string? ReferenceId { get; set; } + + public bool IsFavorite { get; set; } +} diff --git a/src/WebApi.Contracts/Dto/AlbumReferenceDto.cs b/src/WebApi.Contracts/Dto/AlbumReferenceDto.cs new file mode 100644 index 00000000..71ab3137 --- /dev/null +++ b/src/WebApi.Contracts/Dto/AlbumReferenceDto.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using Keeptrack.Common.System; + +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// Shared album metadata (synopsis, cover) - read-only, fetched separately from since +/// it isn't the tenant's own data. +/// +public class AlbumReferenceDto : IHasId +{ + public string? Id { get; set; } + + public string? Title { get; set; } + + public int? Year { get; set; } + + public string? Synopsis { get; set; } + + public string? ArtistName { get; set; } + + public string? ArtistImageUrl { get; set; } + + public List Genres { get; set; } = []; + + public List Tracks { get; set; } = []; + + public string? ImageUrl { get; set; } +} diff --git a/src/WebApi.Contracts/Dto/BookDto.cs b/src/WebApi.Contracts/Dto/BookDto.cs index eaff23ee..21b92a86 100644 --- a/src/WebApi.Contracts/Dto/BookDto.cs +++ b/src/WebApi.Contracts/Dto/BookDto.cs @@ -31,6 +31,11 @@ public class BookDto : IHasId /// Middle-earth Universe public string? Series { get; set; } + /// + /// Publication year. + /// + public int? Year { get; set; } + public float? Rating { get; set; } public string? Genre { get; set; } @@ -41,4 +46,15 @@ public class BookDto : IHasId /// Book finished reading date. /// public DateOnly? FirstReadAt { get; set; } + + /// + /// Id of the linked book_reference document, when a match has been found. + /// + public string? ReferenceId { get; set; } + + public bool IsFavorite { get; set; } + + public bool IsOwned { get; set; } + + public bool IsWishlisted { get; set; } } diff --git a/src/WebApi.Contracts/Dto/BookReferenceDto.cs b/src/WebApi.Contracts/Dto/BookReferenceDto.cs new file mode 100644 index 00000000..0b0d51b3 --- /dev/null +++ b/src/WebApi.Contracts/Dto/BookReferenceDto.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using Keeptrack.Common.System; + +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// Shared book metadata (synopsis, cover) - read-only, fetched separately from since +/// it isn't the tenant's own data. +/// +public class BookReferenceDto : IHasId +{ + public string? Id { get; set; } + + public string? Title { get; set; } + + public int? Year { get; set; } + + public string? Synopsis { get; set; } + + public string? AuthorName { get; set; } + + public string? AuthorImageUrl { get; set; } + + public List Genres { get; set; } = []; + + public string? ImageUrl { get; set; } +} diff --git a/src/WebApi.Contracts/Dto/CarCostHistoryPointDto.cs b/src/WebApi.Contracts/Dto/CarCostHistoryPointDto.cs new file mode 100644 index 00000000..f02d2a68 --- /dev/null +++ b/src/WebApi.Contracts/Dto/CarCostHistoryPointDto.cs @@ -0,0 +1,29 @@ +using System; + +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// Total cost of ownership for one calendar month. +/// +public class CarCostHistoryPointDto +{ + /// + /// First day of the month this point covers. + /// + public required DateOnly Period { get; set; } + + /// + /// Fuel/electric spend for the month. + /// + public required double FuelCost { get; set; } + + /// + /// Maintenance/other spend for the month. + /// + public required double MaintenanceCost { get; set; } + + /// + /// Combined spend for the month. + /// + public required double TotalCost { get; set; } +} diff --git a/src/WebApi.Contracts/Dto/CarDto.cs b/src/WebApi.Contracts/Dto/CarDto.cs index a46a0477..1d9fb71a 100644 --- a/src/WebApi.Contracts/Dto/CarDto.cs +++ b/src/WebApi.Contracts/Dto/CarDto.cs @@ -1,4 +1,4 @@ -using Keeptrack.Common.System; +using Keeptrack.Common.System; namespace Keeptrack.WebApi.Contracts.Dto; @@ -16,4 +16,29 @@ public class CarDto : IHasId /// Car name. /// public string? Name { get; set; } + + /// + /// Manufacturer. + /// + public string? Manufacturer { get; set; } + + /// + /// Model. + /// + public string? Model { get; set; } + + /// + /// Year. + /// + public int? Year { get; set; } + + /// + /// License plate. + /// + public string? LicensePlate { get; set; } + + /// + /// Energy type (Combustion, Hybrid, Electric). + /// + public CarEnergyType? EnergyType { get; set; } } diff --git a/src/WebApi.Contracts/Dto/CarEnergyType.cs b/src/WebApi.Contracts/Dto/CarEnergyType.cs new file mode 100644 index 00000000..3389edfc --- /dev/null +++ b/src/WebApi.Contracts/Dto/CarEnergyType.cs @@ -0,0 +1,16 @@ +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// Car energy type. +/// +public enum CarEnergyType +{ + /// Combustion (petrol/diesel) engine. + Combustion, + + /// Hybrid (combustion + electric). + Hybrid, + + /// Fully electric. + Electric +} diff --git a/src/WebApi.Contracts/Dto/CarHistoryDto.cs b/src/WebApi.Contracts/Dto/CarHistoryDto.cs index b2c2685a..6964082e 100644 --- a/src/WebApi.Contracts/Dto/CarHistoryDto.cs +++ b/src/WebApi.Contracts/Dto/CarHistoryDto.cs @@ -1,4 +1,4 @@ -using System; +using System; using Keeptrack.Common.System; namespace Keeptrack.WebApi.Contracts.Dto; @@ -19,9 +19,10 @@ public class CarHistoryDto : IHasId public required string CarId { get; set; } /// - /// History date. + /// History date and time. Several entries can share the same date (e.g. multiple refuels on a road + /// trip); the time is what lets them sort correctly. Defaults to midnight when the time isn't known. /// - public DateOnly HistoryDate { get; set; } + public required DateTime HistoryDate { get; set; } /// /// Mileage indicated on the car. @@ -29,15 +30,35 @@ public class CarHistoryDto : IHasId public int? Mileage { get; set; } /// - /// Action made on the car. + /// Event type (Refuel, Maintenance, Other). /// - public required string Action { get; set; } + public required CarHistoryType EventType { get; set; } + + /// + /// Free-text description of what was done - the main field for Maintenance/Other events. + /// + public string? Description { get; set; } + + /// + /// Total cost of this event (fuel/electric fill-up, invoice, ...). + /// + public double? Cost { get; set; } /// /// City. /// public string? City { get; set; } + /// + /// Postal code. + /// + public string? PostalCode { get; set; } + + /// + /// Country. + /// + public string? Country { get; set; } + /// /// Longitude. /// @@ -49,42 +70,48 @@ public class CarHistoryDto : IHasId public double? Latitude { get; set; } /// - /// Fuel category. + /// Fuel category (e.g. Diesel, SP95). /// public string? FuelCategory { get; set; } /// - /// Fuel volme (L). + /// Fuel volume (L). /// public double? FuelVolume { get; set; } /// - /// Fuel unit price. + /// Fuel unit price (per L). /// public double? FuelUnitPrice { get; set; } /// - /// Amount. + /// Electric energy added (kWh). /// - public double? Amount { get; set; } + public double? ElectricVolume { get; set; } /// - /// Is full tank? + /// Electric unit price (per kWh). /// - public bool? IsFullTank { get; set; } + public double? ElectricUnitPrice { get; set; } /// - /// Delta mileage since last refuel. + /// Is this a full tank / full charge refill? /// - public double? DeltaMileage { get; set; } + public bool? IsFullRefill { get; set; } /// - /// Last refuel history id. + /// Distance driven since the previous entry, as recorded by the user (e.g. from the car's trip computer) - + /// independent of the odometer reading in , used to cross-check it. /// - public string? LastRefuelHistoryId { get; set; } + public double? DeltaMileage { get; set; } /// /// Station brand name. /// public string? StationBrandName { get; set; } + + /// + /// Garage/auto shop name - the Maintenance/Other-event counterpart of . + /// + public string? Garage { get; set; } } diff --git a/src/WebApi.Contracts/Dto/CarHistoryImportResultDto.cs b/src/WebApi.Contracts/Dto/CarHistoryImportResultDto.cs new file mode 100644 index 00000000..f6e1a026 --- /dev/null +++ b/src/WebApi.Contracts/Dto/CarHistoryImportResultDto.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; + +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// Result of importing the personal "Voitures.xlsx" spreadsheet (fuel/maintenance history per car). +/// +public class CarHistoryImportResultDto +{ + public int CarsCreated { get; set; } + + public int CarsSkipped { get; set; } + + public int HistoryEntriesCreated { get; set; } + + /// + /// Rows that couldn't be parsed (e.g. an unreadable date) and were skipped rather than guessed at. + /// + public List Warnings { get; set; } = []; +} diff --git a/src/WebApi.Contracts/Dto/CarHistoryType.cs b/src/WebApi.Contracts/Dto/CarHistoryType.cs new file mode 100644 index 00000000..0ec5f540 --- /dev/null +++ b/src/WebApi.Contracts/Dto/CarHistoryType.cs @@ -0,0 +1,16 @@ +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// Car history event type. +/// +public enum CarHistoryType +{ + /// Fuel refill or electric recharge. + Refuel, + + /// Maintenance/service. + Maintenance, + + /// Anything else (insurance, inspection, tires, ...). + Other +} diff --git a/src/WebApi.Contracts/Dto/CarMetricsDto.cs b/src/WebApi.Contracts/Dto/CarMetricsDto.cs new file mode 100644 index 00000000..0509319a --- /dev/null +++ b/src/WebApi.Contracts/Dto/CarMetricsDto.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; + +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// Computed metrics for one car: consumption, cost history, mileage warnings and next-maintenance estimate. +/// +public class CarMetricsDto +{ + /// + /// Fuel consumption over time (L/100km), computed only across full refills. + /// + public required List FuelConsumption { get; set; } + + /// + /// Average fuel consumption (L/100km), or null if there isn't enough data yet. + /// + public double? AverageFuelConsumptionPer100Km { get; set; } + + /// + /// Electric consumption over time (kWh/100km), computed only across full recharges. + /// + public required List ElectricConsumption { get; set; } + + /// + /// Average electric consumption (kWh/100km), or null if there isn't enough data yet. + /// + public double? AverageElectricConsumptionPer100Km { get; set; } + + /// + /// Total cost of ownership, by month. + /// + public required List CostHistory { get; set; } + + /// + /// Total cost across the whole history. + /// + public required double TotalCost { get; set; } + + /// + /// Entries whose recorded mileage doesn't reconcile with their neighbors. + /// + public required List MileageWarnings { get; set; } + + /// + /// Estimated next maintenance due, or null if no maintenance has been recorded yet. + /// + public NextMaintenanceDto? NextMaintenance { get; set; } +} diff --git a/src/WebApi.Contracts/Dto/CarMileageWarningDto.cs b/src/WebApi.Contracts/Dto/CarMileageWarningDto.cs new file mode 100644 index 00000000..ff68f925 --- /dev/null +++ b/src/WebApi.Contracts/Dto/CarMileageWarningDto.cs @@ -0,0 +1,18 @@ +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// Flags a car history entry whose recorded mileage doesn't reconcile with its neighbors. Advisory only - the +/// entry is never excluded from metrics or corrected automatically; the user reviews and fixes it themselves. +/// +public class CarMileageWarningDto +{ + /// + /// ID of the flagged car history entry. + /// + public required string CarHistoryId { get; set; } + + /// + /// Human-readable explanation of the mismatch. + /// + public required string Message { get; set; } +} diff --git a/src/WebApi.Contracts/Dto/CastMemberDto.cs b/src/WebApi.Contracts/Dto/CastMemberDto.cs new file mode 100644 index 00000000..73251f7b --- /dev/null +++ b/src/WebApi.Contracts/Dto/CastMemberDto.cs @@ -0,0 +1,14 @@ +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// One cast credit, fully hydrated server-side (joined against the person reference collection) so +/// the client never needs a follow-up lookup per cast member. +/// +public class CastMemberDto +{ + public required string Name { get; set; } + + public required string CharacterName { get; set; } + + public string? ProfileImageUrl { get; set; } +} diff --git a/src/WebApi.Contracts/Dto/ConsumptionPointDto.cs b/src/WebApi.Contracts/Dto/ConsumptionPointDto.cs new file mode 100644 index 00000000..8d4cc906 --- /dev/null +++ b/src/WebApi.Contracts/Dto/ConsumptionPointDto.cs @@ -0,0 +1,19 @@ +using System; + +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// One consumption data point, always computed across a full refill/recharge. +/// +public class ConsumptionPointDto +{ + /// + /// Date of the full refill/recharge that closed this data point. + /// + public required DateOnly Date { get; set; } + + /// + /// Consumption per 100 km (liters for fuel, kWh for electric). + /// + public required double ValuePer100Km { get; set; } +} diff --git a/src/WebApi.Contracts/Dto/EpisodeDto.cs b/src/WebApi.Contracts/Dto/EpisodeDto.cs new file mode 100644 index 00000000..1b5b2f4e --- /dev/null +++ b/src/WebApi.Contracts/Dto/EpisodeDto.cs @@ -0,0 +1,37 @@ +using System; +using Keeptrack.Common.System; + +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// Episode data transfer object. +/// +public class EpisodeDto : IHasId +{ + /// + /// Episode ID. + /// + public string? Id { get; set; } + + /// + /// TV Show ID. + /// + public required string TvShowId { get; set; } + + /// + /// Season number. + /// + public required int SeasonNumber { get; set; } + + /// + /// Episode number within the season. + /// + public required int EpisodeNumber { get; set; } + + /// + /// Date the episode was watched. + /// + public DateOnly? WatchedAt { get; set; } + + public string? Notes { get; set; } +} diff --git a/src/WebApi.Contracts/Dto/HouseCategoryCostDto.cs b/src/WebApi.Contracts/Dto/HouseCategoryCostDto.cs new file mode 100644 index 00000000..dc124b37 --- /dev/null +++ b/src/WebApi.Contracts/Dto/HouseCategoryCostDto.cs @@ -0,0 +1,17 @@ +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// Total cost for one house history event category within a . +/// +public class HouseCategoryCostDto +{ + /// + /// Event category. + /// + public required HouseEventType EventType { get; set; } + + /// + /// Total cost for this category. + /// + public required double Cost { get; set; } +} diff --git a/src/WebApi.Contracts/Dto/HouseCostHistoryPointDto.cs b/src/WebApi.Contracts/Dto/HouseCostHistoryPointDto.cs new file mode 100644 index 00000000..eb601af2 --- /dev/null +++ b/src/WebApi.Contracts/Dto/HouseCostHistoryPointDto.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; + +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// Total cost of ownership for one calendar year, both as a single total and broken down by event category. +/// +public class HouseCostHistoryPointDto +{ + /// + /// Calendar year this point covers. + /// + public required int Year { get; set; } + + /// + /// Combined spend for the year. + /// + public required double TotalCost { get; set; } + + /// + /// Spend broken down by event category. + /// + public required List CostByCategory { get; set; } +} diff --git a/src/WebApi.Contracts/Dto/HouseDto.cs b/src/WebApi.Contracts/Dto/HouseDto.cs new file mode 100644 index 00000000..4da05df6 --- /dev/null +++ b/src/WebApi.Contracts/Dto/HouseDto.cs @@ -0,0 +1,44 @@ +using Keeptrack.Common.System; + +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// House data transfer object. +/// +public class HouseDto : IHasId +{ + /// + /// House ID. + /// + public string? Id { get; set; } + + /// + /// House name (e.g. "Main house", "Beach apartment"). + /// + public string? Name { get; set; } + + /// + /// Street address. + /// + public string? Address { get; set; } + + /// + /// City. + /// + public string? City { get; set; } + + /// + /// Postal code. + /// + public string? PostalCode { get; set; } + + /// + /// Country. + /// + public string? Country { get; set; } + + /// + /// Free-text notes. + /// + public string? Notes { get; set; } +} diff --git a/src/WebApi.Contracts/Dto/HouseEventType.cs b/src/WebApi.Contracts/Dto/HouseEventType.cs new file mode 100644 index 00000000..727f77c2 --- /dev/null +++ b/src/WebApi.Contracts/Dto/HouseEventType.cs @@ -0,0 +1,16 @@ +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// House history event type. Kept as a separate definition from Keeptrack.Domain.Models.HouseEventType +/// since WebApi.Contracts doesn't depend on Domain - member names must stay identical so the generated +/// Mapperly mapper (EnumMappingStrategy.ByName) can map enum-to-enum by name. +/// +public enum HouseEventType +{ + Maintenance, + Installation, + Rework, + Purchase, + Bill, + Other +} diff --git a/src/WebApi.Contracts/Dto/HouseHistoryDto.cs b/src/WebApi.Contracts/Dto/HouseHistoryDto.cs new file mode 100644 index 00000000..128dadbe --- /dev/null +++ b/src/WebApi.Contracts/Dto/HouseHistoryDto.cs @@ -0,0 +1,45 @@ +using System; +using Keeptrack.Common.System; + +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// House history data transfer object. +/// +public class HouseHistoryDto : IHasId +{ + /// + /// History ID. + /// + public string? Id { get; set; } + + /// + /// House ID. + /// + public required string HouseId { get; set; } + + /// + /// History date. + /// + public required DateOnly HistoryDate { get; set; } + + /// + /// Event type (Maintenance, Installation, Rework, Purchase, Bill, Other). + /// + public required HouseEventType EventType { get; set; } + + /// + /// Free-text description of what was done. + /// + public string? Description { get; set; } + + /// + /// Total cost of this event. + /// + public double? Cost { get; set; } + + /// + /// Contractor/technician/utility company/store name. + /// + public string? Provider { get; set; } +} diff --git a/src/WebApi.Contracts/Dto/HouseMetricsDto.cs b/src/WebApi.Contracts/Dto/HouseMetricsDto.cs new file mode 100644 index 00000000..3ccd2f24 --- /dev/null +++ b/src/WebApi.Contracts/Dto/HouseMetricsDto.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; + +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// Computed metrics for one house: yearly cost history. +/// +public class HouseMetricsDto +{ + /// + /// Total cost of ownership, by year. + /// + public required List CostHistory { get; set; } +} diff --git a/src/WebApi.Contracts/Dto/ImportJobDto.cs b/src/WebApi.Contracts/Dto/ImportJobDto.cs new file mode 100644 index 00000000..2871ab32 --- /dev/null +++ b/src/WebApi.Contracts/Dto/ImportJobDto.cs @@ -0,0 +1,38 @@ +using System; + +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// Stage of an in-progress TV Time import, reported back to the client so it can show real progress +/// instead of a single opaque "importing..." spinner. +/// +public enum ImportStage +{ + Parsing, + ImportingShows, + ImportingEpisodes, + ImportingMovies, + Completed, + Failed +} + +/// +/// Returned immediately when an import is started; poll at +/// GET /api/import/tv-time/{JobId} for progress. +/// +public class ImportJobDto +{ + public required Guid JobId { get; set; } +} + +/// +/// Current status of an import job. +/// +public class ImportJobStatusDto +{ + public required ImportStage Stage { get; set; } + + public ImportResultDto? Result { get; set; } + + public string? ErrorMessage { get; set; } +} diff --git a/src/WebApi.Contracts/Dto/ImportResultDto.cs b/src/WebApi.Contracts/Dto/ImportResultDto.cs new file mode 100644 index 00000000..391144f6 --- /dev/null +++ b/src/WebApi.Contracts/Dto/ImportResultDto.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; + +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// Result of a TV Time GDPR export import. +/// +public class ImportResultDto +{ + public int ShowsCreated { get; set; } + + /// + /// Items already present (matched by their stable TV Time id) and therefore left untouched - + /// re-importing the same export never overwrites or duplicates what a previous import created. + /// + public int ShowsSkipped { get; set; } + + public int EpisodesCreated { get; set; } + + public int EpisodesSkipped { get; set; } + + public int MoviesCreated { get; set; } + + public int MoviesSkipped { get; set; } + + /// + /// Non-fatal issues encountered during the import (e.g. a movie referenced only by an unresolvable id). + /// + public List Warnings { get; set; } = []; +} diff --git a/src/WebApi.Contracts/Dto/LinkReferenceRequestDto.cs b/src/WebApi.Contracts/Dto/LinkReferenceRequestDto.cs new file mode 100644 index 00000000..0effea4e --- /dev/null +++ b/src/WebApi.Contracts/Dto/LinkReferenceRequestDto.cs @@ -0,0 +1,16 @@ +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// An admin's manual choice: link every tenant's (Title, Year) match to this external provider id +/// (TMDB, Open Library, RAWG or Discogs, depending on ). +/// +public class LinkReferenceRequestDto +{ + public required ReferenceItemType Type { get; set; } + + public required string Title { get; set; } + + public int? Year { get; set; } + + public required string ExternalId { get; set; } +} diff --git a/src/WebApi.Contracts/Dto/MovieDto.cs b/src/WebApi.Contracts/Dto/MovieDto.cs index dd82b6f7..21455092 100644 --- a/src/WebApi.Contracts/Dto/MovieDto.cs +++ b/src/WebApi.Contracts/Dto/MovieDto.cs @@ -9,17 +9,30 @@ public class MovieDto : IHasId public string? Title { get; set; } + /// + /// Stable id of the TV Time item this movie was imported from, if any. Managed server-side by the + /// import; round-tripped on edits so it is never lost, but not meant to be set by clients. + /// + public string? TvTimeId { get; set; } + public int? Year { get; set; } public float? Rating { get; set; } - public string? Genre { get; set; } - public string? Notes { get; set; } - public string? ImdbPageId { get; set; } - - public string? AllocineId { get; set; } + /// + /// Id of the shared reference-data document (synopsis) for this movie, once resolved. + /// + public string? ReferenceId { get; set; } public DateOnly? FirstSeenAt { get; set; } + + public bool IsFavorite { get; set; } + + public bool WantToWatch { get; set; } + + public bool IsOwned { get; set; } + + public bool IsWishlisted { get; set; } } diff --git a/src/WebApi.Contracts/Dto/MovieReferenceDto.cs b/src/WebApi.Contracts/Dto/MovieReferenceDto.cs new file mode 100644 index 00000000..c5b640ea --- /dev/null +++ b/src/WebApi.Contracts/Dto/MovieReferenceDto.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using Keeptrack.Common.System; + +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// Shared movie metadata (synopsis) - read-only, fetched separately from since +/// it isn't the tenant's own data. +/// +public class MovieReferenceDto : IHasId +{ + public string? Id { get; set; } + + public string? Title { get; set; } + + public int? Year { get; set; } + + public string? Synopsis { get; set; } + + public List Genres { get; set; } = []; + + public List Cast { get; set; } = []; + + public string? ImageUrl { get; set; } +} diff --git a/src/WebApi.Contracts/Dto/MusicAlbumDto.cs b/src/WebApi.Contracts/Dto/MusicAlbumDto.cs deleted file mode 100644 index bf33e892..00000000 --- a/src/WebApi.Contracts/Dto/MusicAlbumDto.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Keeptrack.Common.System; - -namespace Keeptrack.WebApi.Contracts.Dto; - -public class MusicAlbumDto : IHasId -{ - public string? Id { get; set; } - - public string? Title { get; set; } - - public string? Artist { get; set; } - - public int? Year { get; set; } - - public string? Genre { get; set; } - - public float? Rating { get; set; } -} diff --git a/src/WebApi.Contracts/Dto/NextMaintenanceDto.cs b/src/WebApi.Contracts/Dto/NextMaintenanceDto.cs new file mode 100644 index 00000000..a5e7b13a --- /dev/null +++ b/src/WebApi.Contracts/Dto/NextMaintenanceDto.cs @@ -0,0 +1,24 @@ +using System; + +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// Estimated next maintenance due date, assuming a fixed 1-year cadence from the last recorded maintenance event. +/// +public class NextMaintenanceDto +{ + /// + /// Date of the last recorded maintenance event. + /// + public required DateOnly LastMaintenanceDate { get; set; } + + /// + /// Estimated due date for the next maintenance. + /// + public required DateOnly DueDate { get; set; } + + /// + /// Months remaining until the due date (negative if overdue). + /// + public required int MonthsRemaining { get; set; } +} diff --git a/src/WebApi.Contracts/Dto/PlaylistDto.cs b/src/WebApi.Contracts/Dto/PlaylistDto.cs new file mode 100644 index 00000000..8e49b863 --- /dev/null +++ b/src/WebApi.Contracts/Dto/PlaylistDto.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using Keeptrack.Common.System; + +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// Playlist data transfer object. +/// +public class PlaylistDto : IHasId +{ + /// + /// Playlist ID. + /// + public string? Id { get; set; } + + /// + /// Playlist title. + /// + public string? Title { get; set; } + + /// + /// Ids of the songs in this playlist, in playback order. + /// + public List SongIds { get; set; } = []; +} diff --git a/src/WebApi.Contracts/Dto/PlaythroughDto.cs b/src/WebApi.Contracts/Dto/PlaythroughDto.cs new file mode 100644 index 00000000..59c04c8b --- /dev/null +++ b/src/WebApi.Contracts/Dto/PlaythroughDto.cs @@ -0,0 +1,20 @@ +using System; + +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// One recorded run through a game - the base playthrough, an NG+ replay, a speedrun, or any other +/// attempt worth logging. +/// +public class PlaythroughDto +{ + /// + /// Free-text label for this run (e.g. "First run", "NG+1", "Speedrun for fun"). + /// + public string? Label { get; set; } + + /// + /// When this run was completed. Unset if still in progress or abandoned. + /// + public DateOnly? CompletedAt { get; set; } +} diff --git a/src/WebApi.Contracts/Dto/ReferenceDataImportResultDto.cs b/src/WebApi.Contracts/Dto/ReferenceDataImportResultDto.cs new file mode 100644 index 00000000..ca74ec69 --- /dev/null +++ b/src/WebApi.Contracts/Dto/ReferenceDataImportResultDto.cs @@ -0,0 +1,19 @@ +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// Counts of documents upserted by a reference-data zip import (POST /api/reference-data/import). +/// +public class ReferenceDataImportResultDto +{ + public required int TvShowCount { get; set; } + + public required int MovieCount { get; set; } + + public required int PersonCount { get; set; } + + public required int BookCount { get; set; } + + public required int VideoGameCount { get; set; } + + public required int AlbumCount { get; set; } +} diff --git a/src/WebApi.Contracts/Dto/ReferenceEpisodeDto.cs b/src/WebApi.Contracts/Dto/ReferenceEpisodeDto.cs new file mode 100644 index 00000000..7a728695 --- /dev/null +++ b/src/WebApi.Contracts/Dto/ReferenceEpisodeDto.cs @@ -0,0 +1,17 @@ +using System; + +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// One episode's reference metadata (title, air date) from the shared reference collection. +/// +public class ReferenceEpisodeDto +{ + public required int SeasonNumber { get; set; } + + public required int EpisodeNumber { get; set; } + + public required string Title { get; set; } + + public DateOnly? AirDate { get; set; } +} diff --git a/src/WebApi.Contracts/Dto/ReferenceItemType.cs b/src/WebApi.Contracts/Dto/ReferenceItemType.cs new file mode 100644 index 00000000..860d8df3 --- /dev/null +++ b/src/WebApi.Contracts/Dto/ReferenceItemType.cs @@ -0,0 +1,13 @@ +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// Which tenant collection an admin reference-data action applies to. +/// +public enum ReferenceItemType +{ + TvShow, + Movie, + Book, + VideoGame, + Album +} diff --git a/src/WebApi.Contracts/Dto/ReferenceSearchResultDto.cs b/src/WebApi.Contracts/Dto/ReferenceSearchResultDto.cs new file mode 100644 index 00000000..2af0a153 --- /dev/null +++ b/src/WebApi.Contracts/Dto/ReferenceSearchResultDto.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; + +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// One external-provider search candidate (TMDB, Open Library, RAWG or Discogs, depending on the request's +/// ), shown to an admin picking the right match for an unresolved title. +/// Cover art and top cast names (TV/movie only) help distinguish near-identical candidates (e.g. remakes, +/// regional variants). +/// +public class ReferenceSearchResultDto +{ + public required string ExternalId { get; set; } + + public required string Title { get; set; } + + public int? Year { get; set; } + + public string? Synopsis { get; set; } + + public string? ImageUrl { get; set; } + + /// + /// Book author or album artist - null for TV shows/movies/video games, which have no single-name + /// creator concept exposed here (TV/movie use instead). + /// + public string? Creator { get; set; } + + public List TopCastNames { get; set; } = []; +} diff --git a/src/WebApi.Contracts/Dto/ReferenceSyncJobDto.cs b/src/WebApi.Contracts/Dto/ReferenceSyncJobDto.cs new file mode 100644 index 00000000..b8b837de --- /dev/null +++ b/src/WebApi.Contracts/Dto/ReferenceSyncJobDto.cs @@ -0,0 +1,39 @@ +using System; + +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// Stage of an in-progress reference-data sync, reported back to the client so it can show real progress +/// instead of a single opaque "syncing..." spinner. +/// +public enum ReferenceSyncStage +{ + SyncingTvShows, + SyncingMovies, + SyncingBooks, + SyncingVideoGames, + SyncingAlbums, + Completed, + Failed +} + +/// +/// Returned immediately when a sync is started; poll at +/// GET /api/reference-data/sync-now/{JobId} for progress. +/// +public class ReferenceSyncJobDto +{ + public required Guid JobId { get; set; } +} + +/// +/// Current status of a reference-data sync job. +/// +public class ReferenceSyncJobStatusDto +{ + public required ReferenceSyncStage Stage { get; set; } + + public ReferenceSyncResultDto? Result { get; set; } + + public string? ErrorMessage { get; set; } +} diff --git a/src/WebApi.Contracts/Dto/ReferenceSyncResultDto.cs b/src/WebApi.Contracts/Dto/ReferenceSyncResultDto.cs new file mode 100644 index 00000000..acf90529 --- /dev/null +++ b/src/WebApi.Contracts/Dto/ReferenceSyncResultDto.cs @@ -0,0 +1,51 @@ +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// Summary of a reference-data sync pass (periodic or admin-triggered "sync now"). +/// +public class ReferenceSyncResultDto +{ + /// + /// How many TV show reference documents were examined. + /// + public int TvShowsChecked { get; set; } + + /// + /// How many of the examined TV show references had actual TMDB changes and were re-fetched. + /// + public int TvShowsUpdated { get; set; } + + /// + /// How many movie reference documents were examined. + /// + public int MoviesChecked { get; set; } + + /// + /// How many of the examined movie references had actual TMDB changes and were re-fetched. + /// + public int MoviesUpdated { get; set; } + + public int BooksChecked { get; set; } + + /// + /// Open Library exposes no per-id "changed since" endpoint (unlike TMDB), so every examined book + /// reference is always fully re-fetched - this count is always equal to . + /// + public int BooksUpdated { get; set; } + + public int VideoGamesChecked { get; set; } + + /// + /// RAWG exposes no per-id "changed since" endpoint (unlike TMDB), so every examined game reference is + /// always fully re-fetched - this count is always equal to . + /// + public int VideoGamesUpdated { get; set; } + + public int AlbumsChecked { get; set; } + + /// + /// Discogs exposes no per-id "changed since" endpoint (unlike TMDB), so every examined album reference + /// is always fully re-fetched - this count is always equal to . + /// + public int AlbumsUpdated { get; set; } +} diff --git a/src/WebApi.Contracts/Dto/ReferenceTrackDto.cs b/src/WebApi.Contracts/Dto/ReferenceTrackDto.cs new file mode 100644 index 00000000..9dd5cd8a --- /dev/null +++ b/src/WebApi.Contracts/Dto/ReferenceTrackDto.cs @@ -0,0 +1,13 @@ +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// One track's reference metadata (position, title, duration) from the shared reference collection. +/// +public class ReferenceTrackDto +{ + public required string Position { get; set; } + + public required string Title { get; set; } + + public string? Duration { get; set; } +} diff --git a/src/WebApi.Contracts/Dto/SongDto.cs b/src/WebApi.Contracts/Dto/SongDto.cs new file mode 100644 index 00000000..c4f5fd67 --- /dev/null +++ b/src/WebApi.Contracts/Dto/SongDto.cs @@ -0,0 +1,36 @@ +using Keeptrack.Common.System; + +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// Song data transfer object. +/// +public class SongDto : IHasId +{ + /// + /// Song ID. + /// + public string? Id { get; set; } + + /// + /// Song title. + /// + public string? Title { get; set; } + + public string? Artist { get; set; } + + /// + /// Id of the linked Album this song is from, if any - the tenant's own tracked album, not + /// external reference-data. + /// + public string? AlbumId { get; set; } + + public string? Duration { get; set; } + + /// + /// The reference tracklist position this song was created from - combined with , + /// lets the client look up (and reuse) the song already created for a given album track instead of + /// creating a duplicate. + /// + public string? TrackPosition { get; set; } +} diff --git a/src/WebApi.Contracts/Dto/TvShowDto.cs b/src/WebApi.Contracts/Dto/TvShowDto.cs index 8191fcdc..7b972422 100644 --- a/src/WebApi.Contracts/Dto/TvShowDto.cs +++ b/src/WebApi.Contracts/Dto/TvShowDto.cs @@ -1,5 +1,4 @@ -using System; -using Keeptrack.Common.System; +using Keeptrack.Common.System; namespace Keeptrack.WebApi.Contracts.Dto; @@ -18,6 +17,12 @@ public class TvShowDto : IHasId /// public string? Title { get; set; } + /// + /// Stable id of the TV Time item this show was imported from, if any. Managed server-side by the + /// import; round-tripped on edits so it is never lost, but not meant to be set by clients. + /// + public string? TvTimeId { get; set; } + public int? Year { get; set; } public float? Rating { get; set; } @@ -26,9 +31,18 @@ public class TvShowDto : IHasId public string? LastEpisodeSeen { get; set; } - public string? ImdbPageId { get; set; } + /// + /// Id of the shared reference-data document (episode titles, synopsis) for this show, once resolved. + /// + public string? ReferenceId { get; set; } + + public TvShowStatus? State { get; set; } + + public bool IsFavorite { get; set; } + + public bool WantToWatch { get; set; } - public string? AllocineId { get; set; } + public bool IsOwned { get; set; } - public DateOnly? FinishedAt { get; set; } + public bool IsWishlisted { get; set; } } diff --git a/src/WebApi.Contracts/Dto/TvShowReferenceDto.cs b/src/WebApi.Contracts/Dto/TvShowReferenceDto.cs new file mode 100644 index 00000000..cff2afc0 --- /dev/null +++ b/src/WebApi.Contracts/Dto/TvShowReferenceDto.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using Keeptrack.Common.System; + +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// Shared show metadata (synopsis, episode titles) - read-only, fetched separately from +/// since it isn't the tenant's own data. +/// +public class TvShowReferenceDto : IHasId +{ + public string? Id { get; set; } + + public string? Title { get; set; } + + public int? Year { get; set; } + + public string? Synopsis { get; set; } + + public List Episodes { get; set; } = []; + + public List Genres { get; set; } = []; + + public List Cast { get; set; } = []; + + public string? ImageUrl { get; set; } +} diff --git a/src/WebApi.Contracts/Dto/TvShowStatus.cs b/src/WebApi.Contracts/Dto/TvShowStatus.cs new file mode 100644 index 00000000..80558d87 --- /dev/null +++ b/src/WebApi.Contracts/Dto/TvShowStatus.cs @@ -0,0 +1,13 @@ +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// Mirrors Keeptrack.Domain.Models.TvShowStatus - WebApi.Contracts can't depend on Domain, so this +/// is a separate enum with matching member names; the generated Mapperly mapper (EnumMappingStrategy.ByName) +/// maps enum-to-enum by name. +/// +public enum TvShowStatus +{ + Current, + Finished, + Stopped +} diff --git a/src/WebApi.Contracts/Dto/UnresolvedReferenceDto.cs b/src/WebApi.Contracts/Dto/UnresolvedReferenceDto.cs new file mode 100644 index 00000000..376810b0 --- /dev/null +++ b/src/WebApi.Contracts/Dto/UnresolvedReferenceDto.cs @@ -0,0 +1,14 @@ +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// A distinct (title, year) pair, across all tenants, that has no reference-data link yet - one entry +/// in the admin curation queue at GET /api/reference-data/unresolved. +/// +public class UnresolvedReferenceDto +{ + public required ReferenceItemType Type { get; set; } + + public required string Title { get; set; } + + public int? Year { get; set; } +} diff --git a/src/WebApi.Contracts/Dto/VideoGameCopyType.cs b/src/WebApi.Contracts/Dto/VideoGameCopyType.cs new file mode 100644 index 00000000..c3b835b1 --- /dev/null +++ b/src/WebApi.Contracts/Dto/VideoGameCopyType.cs @@ -0,0 +1,10 @@ +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// Whether a tracked video game platform entry is a physical or digital copy. +/// +public enum VideoGameCopyType +{ + Physical, + Digital +} diff --git a/src/WebApi.Contracts/Dto/VideoGameDto.cs b/src/WebApi.Contracts/Dto/VideoGameDto.cs index 3b466922..7f10aeea 100644 --- a/src/WebApi.Contracts/Dto/VideoGameDto.cs +++ b/src/WebApi.Contracts/Dto/VideoGameDto.cs @@ -1,4 +1,4 @@ -using System; +using System.Collections.Generic; using Keeptrack.Common.System; namespace Keeptrack.WebApi.Contracts.Dto; @@ -19,14 +19,9 @@ public class VideoGameDto : IHasId public string? Title { get; set; } /// - /// Latest plaform the game has been played on. + /// Every platform this game is tracked on - one entry per platform, not per physical copy. /// - public string? Platform { get; set; } - - /// - /// Current payling state. - /// - public string? State { get; set; } + public List Platforms { get; set; } = []; public int? Year { get; set; } @@ -35,7 +30,24 @@ public class VideoGameDto : IHasId public string? Notes { get; set; } /// - /// Finished date. + /// Id of the linked videogame_reference document, when a match has been found. /// - public DateOnly? FinishedAt { get; set; } + public string? ReferenceId { get; set; } + + public bool IsOwned { get; set; } + + public bool IsWishlisted { get; set; } + + /// + /// Filter-only query parameter: matches if any entry in has this platform. + /// Never populated on a returned game - the list endpoint binds query-string filters onto this DTO + /// before mapping to the Domain model, so this property exists purely to receive ?platform=. + /// + public string? Platform { get; set; } + + /// + /// Filter-only query parameter: matches if any entry in has this state. Never + /// populated on a returned game - see . + /// + public string? State { get; set; } } diff --git a/src/WebApi.Contracts/Dto/VideoGamePlatformDto.cs b/src/WebApi.Contracts/Dto/VideoGamePlatformDto.cs new file mode 100644 index 00000000..f7cc6764 --- /dev/null +++ b/src/WebApi.Contracts/Dto/VideoGamePlatformDto.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; + +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// One entry per platform a game is tracked on - not one entry per physical copy. +/// +public class VideoGamePlatformDto +{ + /// + /// Platform name (e.g. "PS5", "Xbox Series X", "PC"). + /// + public string? Platform { get; set; } + + /// + /// Whether this is a physical or digital copy. + /// + public VideoGameCopyType CopyType { get; set; } + + /// + /// Current playing state for this platform entry. + /// + public string? State { get; set; } + + /// + /// Every recorded run through the game on this platform. + /// + public List Playthroughs { get; set; } = []; + + /// + /// Whether the game has been fully completed (e.g. platinum trophy, 1000/1000 achievements) on this platform. + /// + public bool IsFullyCompleted { get; set; } + + /// + /// When the game was fully completed on this platform. + /// + public DateOnly? FullyCompletedAt { get; set; } +} diff --git a/src/WebApi.Contracts/Dto/VideoGameReferenceDto.cs b/src/WebApi.Contracts/Dto/VideoGameReferenceDto.cs new file mode 100644 index 00000000..f8cb4880 --- /dev/null +++ b/src/WebApi.Contracts/Dto/VideoGameReferenceDto.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using Keeptrack.Common.System; + +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// Shared video game metadata (synopsis, cover, platforms) - read-only, fetched separately from +/// since it isn't the tenant's own data. +/// +public class VideoGameReferenceDto : IHasId +{ + public string? Id { get; set; } + + public string? Title { get; set; } + + public int? Year { get; set; } + + public string? Synopsis { get; set; } + + public List Platforms { get; set; } = []; + + public List Genres { get; set; } = []; + + public string? ImageUrl { get; set; } +} diff --git a/src/WebApi.Contracts/Dto/WatchNextDto.cs b/src/WebApi.Contracts/Dto/WatchNextDto.cs new file mode 100644 index 00000000..f8aad273 --- /dev/null +++ b/src/WebApi.Contracts/Dto/WatchNextDto.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; + +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// A TV show in progress, with the last episode Keeptrack knows was watched and the next confirmed-unseen +/// one from its TMDB episode guide. Only shows with a resolved reference and a confirmed aired-but-unwatched +/// episode are reported at all - see WatchNextService in WebApi. +/// +public class InProgressShowDto +{ + public required string TvShowId { get; set; } + + public required string TvShowTitle { get; set; } + + public required int LastSeasonNumber { get; set; } + + public required int LastEpisodeNumber { get; set; } + + public DateOnly? LastWatchedAt { get; set; } + + public required int NextSeasonNumber { get; set; } + + public required int NextEpisodeNumber { get; set; } + + public required string NextEpisodeTitle { get; set; } +} + +/// +/// What to watch next: in-progress shows and movies on the watch list. +/// +public class WatchNextDto +{ + public List InProgressShows { get; set; } = []; + + public List MoviesToWatch { get; set; } = []; +} diff --git a/src/WebApi.Contracts/Dto/WishlistDto.cs b/src/WebApi.Contracts/Dto/WishlistDto.cs new file mode 100644 index 00000000..0726db15 --- /dev/null +++ b/src/WebApi.Contracts/Dto/WishlistDto.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; + +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// Everything wishlisted across every trackable type, in one place. +/// +public class WishlistDto +{ + public List Movies { get; set; } = []; + + public List TvShows { get; set; } = []; + + public List Books { get; set; } = []; + + public List VideoGames { get; set; } = []; +} diff --git a/src/WebApi/AppConfiguration.cs b/src/WebApi/AppConfiguration.cs index ff481c1f..3e6ec012 100644 --- a/src/WebApi/AppConfiguration.cs +++ b/src/WebApi/AppConfiguration.cs @@ -1,4 +1,5 @@ using Keeptrack.WebApi.Authentication; +using Keeptrack.WebApi.ReferenceData; using Withywoods.Configuration; namespace Keeptrack.WebApi; @@ -13,10 +14,30 @@ public class AppConfiguration(IConfiguration configuration) public bool IsScalarEnabled => configuration.TryGetSection("Features:IsScalarEnabled"); + /// + /// Gates - on by default (production wants this running), + /// but off in the integration test host (see KestrelWebAppFactory), which otherwise fires real + /// TMDB calls against shared test data on every host start-up. + /// + public bool IsReferenceSyncEnabled => configuration.TryGetSection("Features:IsReferenceSyncEnabled"); + public OpenApiInfo OpenApiInfo { get; } = configuration.TryGetSection("OpenApi"); public JwtBearerSettings JwtBearerSettings { get; } = configuration.TryGetSection("Authentication:JwtBearer"); + public TmdbSettings TmdbSettings { get; } = configuration.TryGetSection("Tmdb"); + + public RawgSettings RawgSettings { get; } = configuration.TryGetSection("Rawg"); + + public DiscogsSettings DiscogsSettings { get; } = configuration.TryGetSection("Discogs"); + + /// + /// Selects which implementation Program.cs + /// registers - see the switch there for supported values. Overridable via the + /// ReferenceData__BookProvider environment variable, same convention as every other setting. + /// + public string BookReferenceProvider => configuration.TryGetSection("ReferenceData:BookProvider"); + public string ConnectionString => configuration.TryGetSection("Infrastructure:MongoDB:ConnectionString"); public string DatabaseName => configuration.TryGetSection("Infrastructure:MongoDB:DatabaseName"); diff --git a/src/WebApi/Controllers/AlbumController.cs b/src/WebApi/Controllers/AlbumController.cs new file mode 100644 index 00000000..7ec730f6 --- /dev/null +++ b/src/WebApi/Controllers/AlbumController.cs @@ -0,0 +1,60 @@ +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.WebApi.Mappers; +using Keeptrack.WebApi.ReferenceData; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Keeptrack.WebApi.Controllers; + +[ApiController] +[Authorize] +[Route("api/albums")] +public class AlbumController( + IDtoMapper mapper, + IAlbumRepository dataRepository, + ReferenceEnrichmentService enrichmentService, + IServiceScopeFactory scopeFactory, + ILogger logger) + : DataCrudControllerBase(mapper, dataRepository) +{ + /// + /// Fires a best-effort background Discogs match for the new album - see . + /// + protected override Task OnCreatedAsync(AlbumModel model) + { + var title = model.Title; + var year = model.Year; + var artist = model.Artist; + _ = Task.Run(async () => + { + try + { + using var scope = scopeFactory.CreateScope(); + var scopedEnrichmentService = scope.ServiceProvider.GetRequiredService(); + await scopedEnrichmentService.TryAutoResolveAlbumAsync(title, year, artist); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Background reference-data match failed for album '{Title}'.", title); + } + }); + return Task.CompletedTask; + } + + /// + /// User-triggered, exact-match-only re-check against the local reference collection - see + /// . + /// + [HttpPost("{id}/refresh-reference")] + [ProducesResponseType(200)] + [ProducesResponseType(404)] + public async Task> RefreshReference(string id) + { + var model = await dataRepository.FindOneAsync(id, this.GetUserId()); + if (model is null) return NotFound(); + + model = await enrichmentService.TryLinkExistingAlbumReferenceAsync(model); + return Ok(Mapper.ToDto(model)); + } +} diff --git a/src/WebApi/Controllers/BookController.cs b/src/WebApi/Controllers/BookController.cs index 5e52836e..c430daa9 100644 --- a/src/WebApi/Controllers/BookController.cs +++ b/src/WebApi/Controllers/BookController.cs @@ -1,5 +1,7 @@ -using Keeptrack.Domain.Models; +using Keeptrack.Domain.Models; using Keeptrack.Domain.Repositories; +using Keeptrack.WebApi.Mappers; +using Keeptrack.WebApi.ReferenceData; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -8,5 +10,51 @@ namespace Keeptrack.WebApi.Controllers; [ApiController] [Authorize] [Route("api/books")] -public class BookController(IMapper mapper, IBookRepository dataRepository) - : DataCrudControllerBase(mapper, dataRepository); +public class BookController( + IDtoMapper mapper, + IBookRepository dataRepository, + ReferenceEnrichmentService enrichmentService, + IServiceScopeFactory scopeFactory, + ILogger logger) + : DataCrudControllerBase(mapper, dataRepository) +{ + /// + /// Fires a best-effort background Open Library match for the new book - see . + /// + protected override Task OnCreatedAsync(BookModel model) + { + var title = model.Title; + var year = model.Year; + var author = model.Author; + _ = Task.Run(async () => + { + try + { + using var scope = scopeFactory.CreateScope(); + var scopedEnrichmentService = scope.ServiceProvider.GetRequiredService(); + await scopedEnrichmentService.TryAutoResolveBookAsync(title, year, author); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Background reference-data match failed for book '{Title}'.", title); + } + }); + return Task.CompletedTask; + } + + /// + /// User-triggered, exact-match-only re-check against the local reference collection - see + /// . + /// + [HttpPost("{id}/refresh-reference")] + [ProducesResponseType(200)] + [ProducesResponseType(404)] + public async Task> RefreshReference(string id) + { + var model = await dataRepository.FindOneAsync(id, this.GetUserId()); + if (model is null) return NotFound(); + + model = await enrichmentService.TryLinkExistingBookReferenceAsync(model); + return Ok(Mapper.ToDto(model)); + } +} diff --git a/src/WebApi/Controllers/CarController.cs b/src/WebApi/Controllers/CarController.cs new file mode 100644 index 00000000..a146e048 --- /dev/null +++ b/src/WebApi/Controllers/CarController.cs @@ -0,0 +1,47 @@ +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.Domain.Services; +using Keeptrack.WebApi.Mappers; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Keeptrack.WebApi.Controllers; + +[ApiController] +[Authorize] +[Route("api/cars")] +public class CarController( + IDtoMapper mapper, + ICarRepository dataRepository, + ICarHistoryRepository carHistoryRepository, + CarMetricsService metricsService, + CarMetricsDtoMapper metricsMapper) + : DataCrudControllerBase(mapper, dataRepository) +{ + /// + /// Computed fuel/electric consumption, cost history, mileage warnings and next-maintenance estimate for + /// this car - see . + /// + [HttpGet("{id}/metrics")] + [ProducesResponseType(200)] + [ProducesResponseType(404)] + public async Task> GetMetrics(string id) + { + var ownerId = this.GetUserId(); + + var car = await dataRepository.FindOneAsync(id, ownerId); + if (car is null) return NotFound(); + + var history = await carHistoryRepository.FindAllAsync(ownerId, 1, int.MaxValue, null, + new CarHistoryModel { OwnerId = ownerId, CarId = id, EventType = default, HistoryDate = default }); + + return Ok(metricsMapper.ToDto(metricsService.ComputeMetrics(history.Items))); + } + + /// + /// CarHistory is a separate top-level collection referencing its car by id, not an embedded array + /// (see CLAUDE.md's "Child entities" section) - without this, deleting a car would leave its fuel/ + /// maintenance history orphaned in MongoDB forever, since it's only ever reachable via the car's own id. + /// + protected override async Task OnDeletedAsync(string id, string ownerId) => await carHistoryRepository.DeleteAllForCarAsync(id, ownerId); +} diff --git a/src/WebApi/Controllers/CarHistoryController.cs b/src/WebApi/Controllers/CarHistoryController.cs index c49f6576..83287d58 100644 --- a/src/WebApi/Controllers/CarHistoryController.cs +++ b/src/WebApi/Controllers/CarHistoryController.cs @@ -1,5 +1,6 @@ using Keeptrack.Domain.Models; using Keeptrack.Domain.Repositories; +using Keeptrack.WebApi.Mappers; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -8,5 +9,5 @@ namespace Keeptrack.WebApi.Controllers; [ApiController] [Authorize] [Route("api/car-history")] -public class CarHistoryController(IMapper mapper, ICarHistoryRepository dataRepository) +public class CarHistoryController(IDtoMapper mapper, ICarHistoryRepository dataRepository) : DataCrudControllerBase(mapper, dataRepository); diff --git a/src/WebApi/Controllers/ControllerBaseExtensions.cs b/src/WebApi/Controllers/ControllerBaseExtensions.cs new file mode 100644 index 00000000..ad0e8387 --- /dev/null +++ b/src/WebApi/Controllers/ControllerBaseExtensions.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Keeptrack.WebApi.Controllers; + +internal static class ControllerBaseExtensions +{ + /// + /// Get authenticated user id. + /// + internal static string GetUserId(this ControllerBase controller) + { + var userId = controller.User.Claims.FirstOrDefault(x => x.Type == "user_id")?.Value; + return string.IsNullOrEmpty(userId) ? throw new UnauthorizedAccessException() : userId; + } +} diff --git a/src/WebApi/Controllers/DataCrudControllerBase.cs b/src/WebApi/Controllers/DataCrudControllerBase.cs index affab3ca..3a8bc83f 100644 --- a/src/WebApi/Controllers/DataCrudControllerBase.cs +++ b/src/WebApi/Controllers/DataCrudControllerBase.cs @@ -1,5 +1,6 @@ using Keeptrack.Common.System; using Keeptrack.Domain.Repositories; +using Keeptrack.WebApi.Mappers; using Microsoft.AspNetCore.Mvc; namespace Keeptrack.WebApi.Controllers; @@ -10,22 +11,29 @@ namespace Keeptrack.WebApi.Controllers; /// Data Transfer Object /// Domain Model [ApiController] -public abstract class DataCrudControllerBase(IMapper mapper, IDataRepository dataRepository) +public abstract class DataCrudControllerBase(IDtoMapper mapper, IDataRepository dataRepository) : ControllerBase where TModel : class, IHasIdAndOwnerId { + /// + /// Exposes the mapper to subclasses that add their own actions (e.g. a refresh-reference endpoint) - + /// lets them reuse this instance instead of capturing their own IDtoMapper primary-constructor + /// parameter as a second field holding the same reference. + /// + protected IDtoMapper Mapper => mapper; + [HttpGet] [ProducesResponseType(200)] [ProducesResponseType(400)] [ProducesResponseType(500)] public async Task>> Get([FromQuery] PagedRequest pagedRequest, [FromQuery] TDto input) { - var models = await dataRepository.FindAllAsync(GetUserId(), + var models = await dataRepository.FindAllAsync(this.GetUserId(), pagedRequest.Page, pagedRequest.PageSize, pagedRequest.Search, - mapper.Map(input)); - return Ok(models.Map(mapper.Map)); + mapper.ToModel(input)); + return Ok(models.Map(mapper.ToDto)); } [HttpGet("{id}")] @@ -40,13 +48,13 @@ public async Task> GetById(string id) return BadRequest(); } - var model = await dataRepository.FindOneAsync(id, GetUserId()); + var model = await dataRepository.FindOneAsync(id, this.GetUserId()); if (model == null) { return NotFound(); } - return Ok(mapper.Map(model)); + return Ok(mapper.ToDto(model)); } [HttpPost] @@ -55,12 +63,19 @@ public async Task> GetById(string id) [ProducesResponseType(201)] public async Task Post([FromBody] TDto dto) { - var input = mapper.Map(dto); - input.OwnerId = GetUserId(); + var input = mapper.ToModel(dto); + input.OwnerId = this.GetUserId(); var model = await dataRepository.CreateAsync(input); - return CreatedAtAction(nameof(GetById), new { id = model.Id }, mapper.Map(model)); + await OnCreatedAsync(model); + return CreatedAtAction(nameof(GetById), new { id = model.Id }, mapper.ToDto(model)); } + /// + /// Hook for subclasses that need to react to a new item being created (e.g. triggering background + /// reference-data enrichment). No-op by default. + /// + protected virtual Task OnCreatedAsync(TModel model) => Task.CompletedTask; + [HttpPut("{id}")] [ProducesResponseType(204)] [ProducesResponseType(400)] @@ -72,9 +87,9 @@ public async Task Put(string id, [FromBody] TDto dto) return BadRequest(); } - var input = mapper.Map(dto); - input.OwnerId = GetUserId(); - await dataRepository.UpdateAsync(id, input, GetUserId()); + var input = mapper.ToModel(dto); + input.OwnerId = this.GetUserId(); + await dataRepository.UpdateAsync(id, input, this.GetUserId()); return NoContent(); } @@ -89,17 +104,15 @@ public async Task Delete(string id) return BadRequest(); } - await dataRepository.DeleteAsync(id, GetUserId()); + var ownerId = this.GetUserId(); + await dataRepository.DeleteAsync(id, ownerId); + await OnDeletedAsync(id, ownerId); return NoContent(); } /// - /// Get authenticated user id. + /// Hook for subclasses that need to react to an item being deleted (e.g. cascading the delete to a + /// child collection such as CarHistory). No-op by default, same shape as . /// - /// - private string GetUserId() - { - var userId = User.Claims.FirstOrDefault(x => x.Type == "user_id")?.Value; - return string.IsNullOrEmpty(userId) ? throw new UnauthorizedAccessException() : userId; - } + protected virtual Task OnDeletedAsync(string id, string ownerId) => Task.CompletedTask; } diff --git a/src/WebApi/Controllers/EpisodeController.cs b/src/WebApi/Controllers/EpisodeController.cs new file mode 100644 index 00000000..51aca957 --- /dev/null +++ b/src/WebApi/Controllers/EpisodeController.cs @@ -0,0 +1,13 @@ +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.WebApi.Mappers; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Keeptrack.WebApi.Controllers; + +[ApiController] +[Authorize] +[Route("api/episodes")] +public class EpisodeController(IDtoMapper mapper, IEpisodeRepository dataRepository) + : DataCrudControllerBase(mapper, dataRepository); diff --git a/src/WebApi/Controllers/HouseController.cs b/src/WebApi/Controllers/HouseController.cs new file mode 100644 index 00000000..309e92bc --- /dev/null +++ b/src/WebApi/Controllers/HouseController.cs @@ -0,0 +1,46 @@ +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.Domain.Services; +using Keeptrack.WebApi.Mappers; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Keeptrack.WebApi.Controllers; + +[ApiController] +[Authorize] +[Route("api/houses")] +public class HouseController( + IDtoMapper mapper, + IHouseRepository dataRepository, + IHouseHistoryRepository houseHistoryRepository, + HouseMetricsService metricsService, + HouseMetricsDtoMapper metricsMapper) + : DataCrudControllerBase(mapper, dataRepository) +{ + /// + /// Computed yearly cost history for this house - see . + /// + [HttpGet("{id}/metrics")] + [ProducesResponseType(200)] + [ProducesResponseType(404)] + public async Task> GetMetrics(string id) + { + var ownerId = this.GetUserId(); + + var house = await dataRepository.FindOneAsync(id, ownerId); + if (house is null) return NotFound(); + + var history = await houseHistoryRepository.FindAllAsync(ownerId, 1, int.MaxValue, null, + new HouseHistoryModel { OwnerId = ownerId, HouseId = id, EventType = default, HistoryDate = default }); + + return Ok(metricsMapper.ToDto(metricsService.ComputeMetrics(history.Items))); + } + + /// + /// HouseHistory is a separate top-level collection referencing its house by id, not an embedded array + /// (see CLAUDE.md's "Child entities" section) - without this, deleting a house would leave its history + /// orphaned in MongoDB forever, since it's only ever reachable via the house's own id. + /// + protected override async Task OnDeletedAsync(string id, string ownerId) => await houseHistoryRepository.DeleteAllForHouseAsync(id, ownerId); +} diff --git a/src/WebApi/Controllers/HouseHistoryController.cs b/src/WebApi/Controllers/HouseHistoryController.cs new file mode 100644 index 00000000..97fda8ad --- /dev/null +++ b/src/WebApi/Controllers/HouseHistoryController.cs @@ -0,0 +1,13 @@ +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.WebApi.Mappers; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Keeptrack.WebApi.Controllers; + +[ApiController] +[Authorize] +[Route("api/house-history")] +public class HouseHistoryController(IDtoMapper mapper, IHouseHistoryRepository dataRepository) + : DataCrudControllerBase(mapper, dataRepository); diff --git a/src/WebApi/Controllers/MovieController.cs b/src/WebApi/Controllers/MovieController.cs index be1a7508..8ffcb781 100644 --- a/src/WebApi/Controllers/MovieController.cs +++ b/src/WebApi/Controllers/MovieController.cs @@ -1,5 +1,7 @@ -using Keeptrack.Domain.Models; +using Keeptrack.Domain.Models; using Keeptrack.Domain.Repositories; +using Keeptrack.WebApi.Mappers; +using Keeptrack.WebApi.ReferenceData; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -8,5 +10,50 @@ namespace Keeptrack.WebApi.Controllers; [ApiController] [Authorize] [Route("api/movies")] -public class MovieController(IMapper mapper, IMovieRepository dataRepository) - : DataCrudControllerBase(mapper, dataRepository); +public class MovieController( + IDtoMapper mapper, + IMovieRepository dataRepository, + ReferenceEnrichmentService enrichmentService, + IServiceScopeFactory scopeFactory, + ILogger logger) + : DataCrudControllerBase(mapper, dataRepository) +{ + /// + /// Fires a best-effort background TMDB match for the new movie - see . + /// + protected override Task OnCreatedAsync(MovieModel model) + { + var title = model.Title; + var year = model.Year; + _ = Task.Run(async () => + { + try + { + using var scope = scopeFactory.CreateScope(); + var scopedEnrichmentService = scope.ServiceProvider.GetRequiredService(); + await scopedEnrichmentService.TryAutoResolveMovieAsync(title, year); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Background reference-data match failed for movie '{Title}'.", title); + } + }); + return Task.CompletedTask; + } + + /// + /// User-triggered, exact-match-only re-check against the local reference collection - see + /// . + /// + [HttpPost("{id}/refresh-reference")] + [ProducesResponseType(200)] + [ProducesResponseType(404)] + public async Task> RefreshReference(string id) + { + var model = await dataRepository.FindOneAsync(id, this.GetUserId()); + if (model is null) return NotFound(); + + model = await enrichmentService.TryLinkExistingMovieReferenceAsync(model); + return Ok(Mapper.ToDto(model)); + } +} diff --git a/src/WebApi/Controllers/MusicAlbumController.cs b/src/WebApi/Controllers/MusicAlbumController.cs deleted file mode 100644 index dd476f78..00000000 --- a/src/WebApi/Controllers/MusicAlbumController.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Keeptrack.Domain.Models; -using Keeptrack.Domain.Repositories; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; - -namespace Keeptrack.WebApi.Controllers; - -[ApiController] -[Authorize] -[Route("api/music-albums")] -public class MusicAlbumController(IMapper mapper, IMusicAlbumRepository dataRepository) - : DataCrudControllerBase(mapper, dataRepository); diff --git a/src/WebApi/Controllers/PlaylistController.cs b/src/WebApi/Controllers/PlaylistController.cs new file mode 100644 index 00000000..228d34b7 --- /dev/null +++ b/src/WebApi/Controllers/PlaylistController.cs @@ -0,0 +1,13 @@ +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.WebApi.Mappers; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Keeptrack.WebApi.Controllers; + +[ApiController] +[Authorize] +[Route("api/playlists")] +public class PlaylistController(IDtoMapper mapper, IPlaylistRepository dataRepository) + : DataCrudControllerBase(mapper, dataRepository); diff --git a/src/WebApi/Controllers/SongController.cs b/src/WebApi/Controllers/SongController.cs new file mode 100644 index 00000000..9088b3ab --- /dev/null +++ b/src/WebApi/Controllers/SongController.cs @@ -0,0 +1,13 @@ +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.WebApi.Mappers; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Keeptrack.WebApi.Controllers; + +[ApiController] +[Authorize] +[Route("api/songs")] +public class SongController(IDtoMapper mapper, ISongRepository dataRepository) + : DataCrudControllerBase(mapper, dataRepository); diff --git a/src/WebApi/Controllers/TvShowController.cs b/src/WebApi/Controllers/TvShowController.cs index 1d4ed890..e2f72d89 100644 --- a/src/WebApi/Controllers/TvShowController.cs +++ b/src/WebApi/Controllers/TvShowController.cs @@ -1,5 +1,7 @@ -using Keeptrack.Domain.Models; +using Keeptrack.Domain.Models; using Keeptrack.Domain.Repositories; +using Keeptrack.WebApi.Mappers; +using Keeptrack.WebApi.ReferenceData; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -8,5 +10,52 @@ namespace Keeptrack.WebApi.Controllers; [ApiController] [Authorize] [Route("api/tv-shows")] -public class TvShowController(IMapper mapper, ITvShowRepository dataRepository) - : DataCrudControllerBase(mapper, dataRepository); +public class TvShowController( + IDtoMapper mapper, + ITvShowRepository dataRepository, + ReferenceEnrichmentService enrichmentService, + IServiceScopeFactory scopeFactory, + ILogger logger) + : DataCrudControllerBase(mapper, dataRepository) +{ + /// + /// Fires a best-effort background TMDB match for the new show. Runs on its own DI scope since the + /// request will have completed by the time it finishes - same shape as the TV Time import job. + /// + protected override Task OnCreatedAsync(TvShowModel model) + { + var title = model.Title; + var year = model.Year; + _ = Task.Run(async () => + { + try + { + using var scope = scopeFactory.CreateScope(); + var scopedEnrichmentService = scope.ServiceProvider.GetRequiredService(); + await scopedEnrichmentService.TryAutoResolveTvShowAsync(title, year); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Background reference-data match failed for TV show '{Title}'.", title); + } + }); + return Task.CompletedTask; + } + + /// + /// User-triggered, exact-match-only re-check against the local reference collection - no TMDB call. + /// Distinct from the admin-only live TMDB search/linker: this only ever picks up a match that already + /// exists locally (e.g. after the tenant fixes a typo'd title), so any user can trigger it. + /// + [HttpPost("{id}/refresh-reference")] + [ProducesResponseType(200)] + [ProducesResponseType(404)] + public async Task> RefreshReference(string id) + { + var model = await dataRepository.FindOneAsync(id, this.GetUserId()); + if (model is null) return NotFound(); + + model = await enrichmentService.TryLinkExistingTvShowReferenceAsync(model); + return Ok(Mapper.ToDto(model)); + } +} diff --git a/src/WebApi/Controllers/VideoGameController.cs b/src/WebApi/Controllers/VideoGameController.cs index d581c54b..1bd4d2cf 100644 --- a/src/WebApi/Controllers/VideoGameController.cs +++ b/src/WebApi/Controllers/VideoGameController.cs @@ -1,5 +1,7 @@ -using Keeptrack.Domain.Models; +using Keeptrack.Domain.Models; using Keeptrack.Domain.Repositories; +using Keeptrack.WebApi.Mappers; +using Keeptrack.WebApi.ReferenceData; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -8,5 +10,50 @@ namespace Keeptrack.WebApi.Controllers; [ApiController] [Authorize] [Route("api/video-games")] -public class VideoGameController(IMapper mapper, IVideoGameRepository dataRepository) - : DataCrudControllerBase(mapper, dataRepository); +public class VideoGameController( + IDtoMapper mapper, + IVideoGameRepository dataRepository, + ReferenceEnrichmentService enrichmentService, + IServiceScopeFactory scopeFactory, + ILogger logger) + : DataCrudControllerBase(mapper, dataRepository) +{ + /// + /// Fires a best-effort background RAWG match for the new game - see . + /// + protected override Task OnCreatedAsync(VideoGameModel model) + { + var title = model.Title; + var year = model.Year; + _ = Task.Run(async () => + { + try + { + using var scope = scopeFactory.CreateScope(); + var scopedEnrichmentService = scope.ServiceProvider.GetRequiredService(); + await scopedEnrichmentService.TryAutoResolveVideoGameAsync(title, year); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Background reference-data match failed for video game '{Title}'.", title); + } + }); + return Task.CompletedTask; + } + + /// + /// User-triggered, exact-match-only re-check against the local reference collection - see + /// . + /// + [HttpPost("{id}/refresh-reference")] + [ProducesResponseType(200)] + [ProducesResponseType(404)] + public async Task> RefreshReference(string id) + { + var model = await dataRepository.FindOneAsync(id, this.GetUserId()); + if (model is null) return NotFound(); + + model = await enrichmentService.TryLinkExistingVideoGameReferenceAsync(model); + return Ok(Mapper.ToDto(model)); + } +} diff --git a/src/WebApi/Controllers/WatchNextController.cs b/src/WebApi/Controllers/WatchNextController.cs new file mode 100644 index 00000000..d2e88723 --- /dev/null +++ b/src/WebApi/Controllers/WatchNextController.cs @@ -0,0 +1,51 @@ +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.Domain.Services; +using Keeptrack.WebApi.Mappers; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Keeptrack.WebApi.Controllers; + +[ApiController] +[Authorize] +[Route("api/watch-next")] +public class WatchNextController( + ITvShowRepository tvShowRepository, + IEpisodeRepository episodeRepository, + IMovieRepository movieRepository, + ITvShowReferenceRepository tvShowReferenceRepository, + InProgressShowDtoMapper inProgressShowMapper, + IDtoMapper movieMapper) : ControllerBase +{ + [HttpGet] + [ProducesResponseType(200)] + [ProducesResponseType(500)] + public async Task> Get() + { + var ownerId = this.GetUserId(); + + var shows = await tvShowRepository.FindAllAsync(ownerId, 1, int.MaxValue, null, + new TvShowModel { OwnerId = ownerId, Title = string.Empty }); + var episodes = await episodeRepository.FindAllAsync(ownerId, 1, int.MaxValue, null, + new EpisodeModel { OwnerId = ownerId, TvShowId = string.Empty, SeasonNumber = 0, EpisodeNumber = 0 }); + var moviesToWatch = await movieRepository.FindAllAsync(ownerId, 1, int.MaxValue, null, + new MovieModel { OwnerId = ownerId, Title = string.Empty, WantToWatch = true }); + + // only current shows with a reference link can possibly appear in the result (see WatchNextService), + // so only those need their (small, bounded) episode guide fetched + var referencesByShowId = new Dictionary(); + foreach (var show in shows.Items.Where(s => s.State == Domain.Models.TvShowStatus.Current && !string.IsNullOrEmpty(s.ReferenceId))) + { + var reference = await tvShowReferenceRepository.FindByIdAsync(show.ReferenceId!); + if (reference is not null) referencesByShowId[show.Id!] = reference; + } + + return Ok(new WatchNextDto + { + InProgressShows = WatchNextService.ComputeInProgressShows(shows.Items, episodes.Items, referencesByShowId) + .Select(inProgressShowMapper.ToDto).ToList(), + MoviesToWatch = WatchNextService.FilterMoviesToWatch(moviesToWatch.Items).Select(movieMapper.ToDto).ToList() + }); + } +} diff --git a/src/WebApi/Controllers/WishlistController.cs b/src/WebApi/Controllers/WishlistController.cs new file mode 100644 index 00000000..d68c6838 --- /dev/null +++ b/src/WebApi/Controllers/WishlistController.cs @@ -0,0 +1,47 @@ +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.Domain.Services; +using Keeptrack.WebApi.Mappers; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Keeptrack.WebApi.Controllers; + +[ApiController] +[Authorize] +[Route("api/wishlist")] +public class WishlistController( + IMovieRepository movieRepository, + ITvShowRepository tvShowRepository, + IBookRepository bookRepository, + IVideoGameRepository videoGameRepository, + IDtoMapper movieMapper, + IDtoMapper tvShowMapper, + IDtoMapper bookMapper, + IDtoMapper videoGameMapper) : ControllerBase +{ + [HttpGet] + [ProducesResponseType(200)] + [ProducesResponseType(500)] + public async Task> Get() + { + var ownerId = this.GetUserId(); + + var movies = await movieRepository.FindAllAsync(ownerId, 1, int.MaxValue, null, + new MovieModel { OwnerId = ownerId, Title = string.Empty, IsWishlisted = true }); + var tvShows = await tvShowRepository.FindAllAsync(ownerId, 1, int.MaxValue, null, + new TvShowModel { OwnerId = ownerId, Title = string.Empty, IsWishlisted = true }); + var books = await bookRepository.FindAllAsync(ownerId, 1, int.MaxValue, null, + new BookModel { OwnerId = ownerId, Title = string.Empty, Author = string.Empty, IsWishlisted = true }); + var videoGames = await videoGameRepository.FindAllAsync(ownerId, 1, int.MaxValue, null, + new VideoGameModel { OwnerId = ownerId, Title = string.Empty, IsWishlisted = true }); + + return Ok(new WishlistDto + { + Movies = WishlistService.SortMovies(movies.Items).Select(movieMapper.ToDto).ToList(), + TvShows = WishlistService.SortTvShows(tvShows.Items).Select(tvShowMapper.ToDto).ToList(), + Books = WishlistService.SortBooks(books.Items).Select(bookMapper.ToDto).ToList(), + VideoGames = WishlistService.SortVideoGames(videoGames.Items).Select(videoGameMapper.ToDto).ToList() + }); + } +} diff --git a/src/WebApi/DependencyInjection/InfrastructureServiceCollectionExtensions.cs b/src/WebApi/DependencyInjection/InfrastructureServiceCollectionExtensions.cs index f81be7a7..d895de15 100644 --- a/src/WebApi/DependencyInjection/InfrastructureServiceCollectionExtensions.cs +++ b/src/WebApi/DependencyInjection/InfrastructureServiceCollectionExtensions.cs @@ -1,4 +1,6 @@ -using Keeptrack.Infrastructure.MongoDb.Repositories; +using Keeptrack.Infrastructure.MongoDb.Entities; +using Keeptrack.Infrastructure.MongoDb.Mappers; +using Keeptrack.Infrastructure.MongoDb.Repositories; using Microsoft.Extensions.DependencyInjection.Extensions; using MongoDB.Bson.Serialization.Conventions; using MongoDB.Driver; @@ -25,12 +27,43 @@ internal static void AddMongoDbInfrastructure(this IServiceCollection services, services.AddSingleton(sp => sp.GetRequiredService().GetDatabase(configuration.DatabaseName)); + services.AddSingleton, BookStorageMapper>(); + services.AddSingleton, MovieStorageMapper>(); + services.AddSingleton, TvShowStorageMapper>(); + services.AddSingleton, AlbumStorageMapper>(); + services.AddSingleton, EpisodeStorageMapper>(); + services.AddSingleton, SongStorageMapper>(); + services.AddSingleton, PlaylistStorageMapper>(); + services.AddSingleton, VideoGameStorageMapper>(); + services.AddSingleton, CarStorageMapper>(); + services.AddSingleton, CarHistoryStorageMapper>(); + services.AddSingleton, HouseStorageMapper>(); + services.AddSingleton, HouseHistoryStorageMapper>(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); - services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); } } diff --git a/src/WebApi/Filters/ApiExceptionFilterAttribute.cs b/src/WebApi/Filters/ApiExceptionFilterAttribute.cs index c6b6b113..34f6dbd8 100644 --- a/src/WebApi/Filters/ApiExceptionFilterAttribute.cs +++ b/src/WebApi/Filters/ApiExceptionFilterAttribute.cs @@ -12,7 +12,7 @@ namespace Keeptrack.WebApi.Filters; /// /// [AttributeUsage(AttributeTargets.Class)] -public sealed class ApiExceptionFilterAttribute : ExceptionFilterAttribute +public sealed class ApiExceptionFilterAttribute(ILogger logger) : ExceptionFilterAttribute { /// public override void OnException(ExceptionContext context) @@ -24,6 +24,11 @@ public override void OnException(ExceptionContext context) _ => (context.Exception.Message, StatusCodes.Status500InternalServerError) }; + // logged here (not just left to be visible client-side) so a 500 - especially one caused by a + // failing external provider call (TMDB/RAWG/Open Library/Discogs) - leaves a server-side trail to + // diagnose after the fact, instead of only ever being visible as an opaque error in the browser. + logger.LogError(context.Exception, "Unhandled exception in {Path}: {Message}", context.HttpContext.Request.Path, message); + context.Result = new JsonResult(new { error = message }); context.HttpContext.Response.StatusCode = statusCode; diff --git a/src/WebApi/GlobalUsings.cs b/src/WebApi/GlobalUsings.cs index 9f22a2ec..657a2363 100644 --- a/src/WebApi/GlobalUsings.cs +++ b/src/WebApi/GlobalUsings.cs @@ -1,5 +1,4 @@ global using System; -global using AutoMapper; global using Keeptrack.WebApi; global using Keeptrack.WebApi.Contracts.Dto; global using Keeptrack.WebApi.DependencyInjection; diff --git a/src/WebApi/Import/CarHistoryImportController.cs b/src/WebApi/Import/CarHistoryImportController.cs new file mode 100644 index 00000000..e573f0fe --- /dev/null +++ b/src/WebApi/Import/CarHistoryImportController.cs @@ -0,0 +1,33 @@ +using Keeptrack.WebApi.Controllers; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Keeptrack.WebApi.Import; + +[ApiController] +[Authorize] +[Route("api/import")] +public class CarHistoryImportController(CarHistoryImportService importService) : ControllerBase +{ + /// + /// Imports the personal "Voitures.xlsx" spreadsheet (fuel/maintenance history per car). Runs + /// synchronously - unlike the TV Time import, there's no external API call in the loop, so a few + /// hundred rows complete well within a normal request. + /// + [HttpPost("car-history")] + [RequestSizeLimit(10_000_000)] + [Consumes("multipart/form-data")] + [ProducesResponseType(200)] + [ProducesResponseType(400)] + public async Task> ImportCarHistory(IFormFile file) + { + if (file.Length == 0) + { + return BadRequest(); + } + + await using var stream = file.OpenReadStream(); + var result = await importService.ImportAsync(stream, this.GetUserId()); + return Ok(result); + } +} diff --git a/src/WebApi/Import/CarHistoryImportService.cs b/src/WebApi/Import/CarHistoryImportService.cs new file mode 100644 index 00000000..d85cad2c --- /dev/null +++ b/src/WebApi/Import/CarHistoryImportService.cs @@ -0,0 +1,249 @@ +using System.Globalization; +using ClosedXML.Excel; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using CarEnergyType = Keeptrack.Domain.Models.CarEnergyType; +using CarHistoryType = Keeptrack.Domain.Models.CarHistoryType; + +namespace Keeptrack.WebApi.Import; + +/// +/// One-off personal import of the "Voitures.xlsx" spreadsheet: one fuel-log sheet and (optionally) one +/// maintenance-log sheet per car, hand-tracked in French. This is a single-owner spreadsheet with a fixed, +/// known shape (not a generic user-facing file format like the TV Time GDPR export), so the sheet names and +/// column headers below are hardcoded rather than auto-detected. Not idempotent by design - matches an +/// existing car by name so re-running doesn't duplicate the cars themselves, but re-uploading the same +/// file will duplicate history entries. This is meant to be run once per car; unlike , +/// there is no per-entry natural key in the source data to de-duplicate against. +/// +public class CarHistoryImportService(ICarRepository carRepository, ICarHistoryRepository carHistoryRepository) +{ + private static readonly CultureInfo French = CultureInfo.GetCultureInfo("fr-FR"); + + private sealed record CarSheetGroup(string CarName, string Manufacturer, string Model, string? FuelSheet, string? MaintenanceSheet); + + private static readonly CarSheetGroup[] CarGroups = + [ + new("Renault Scenic", "Renault", "Scenic", "Carburant S", null), + new("Renault Modus", "Renault", "Modus", "Carburant M", "Interventions M"), + new("Peugeot 306", "Peugeot", "306", "Carburant 306", "Intervention 306") + ]; + + public async Task ImportAsync(Stream xlsxStream, string ownerId) + { + using var workbook = new XLWorkbook(xlsxStream); + var result = new CarHistoryImportResultDto(); + + var existingCars = (await carRepository.FindAllAsync(ownerId, 1, int.MaxValue, null, NewCar(ownerId, string.Empty))).Items; + + foreach (var group in CarGroups) + { + var car = existingCars.FirstOrDefault(c => string.Equals(c.Name, group.CarName, StringComparison.OrdinalIgnoreCase)); + if (car is null) + { + car = await carRepository.CreateAsync(new CarModel + { + OwnerId = ownerId, + Name = group.CarName, + Manufacturer = group.Manufacturer, + Model = group.Model, + EnergyType = CarEnergyType.Combustion + }); + result.CarsCreated++; + } + else + { + result.CarsSkipped++; + } + + if (group.FuelSheet is not null && workbook.Worksheets.TryGetWorksheet(group.FuelSheet, out var fuelSheet)) + { + result.HistoryEntriesCreated += await ImportFuelSheetAsync(fuelSheet, car.Id!, ownerId, group.CarName, result.Warnings); + } + + if (group.MaintenanceSheet is not null && workbook.Worksheets.TryGetWorksheet(group.MaintenanceSheet, out var maintenanceSheet)) + { + result.HistoryEntriesCreated += await ImportMaintenanceSheetAsync(maintenanceSheet, car.Id!, ownerId, group.CarName, result.Warnings); + } + } + + return result; + } + + private async Task ImportFuelSheetAsync(IXLWorksheet sheet, string carId, string ownerId, string carName, List warnings) + { + var headers = ReadHeaders(sheet); + var created = 0; + + foreach (var row in DataRows(sheet)) + { + var dateCell = Cell(row, headers, "Date"); + if (dateCell is null || dateCell.IsEmpty()) continue; + + var date = ParseDate(dateCell); + if (date is null) + { + warnings.Add($"{carName}: skipped a fuel entry with an unreadable date (\"{dateCell.GetString()}\")."); + continue; + } + + var time = ParseTime(Cell(row, headers, "Heure")) ?? TimeOnly.MinValue; + + var entry = new CarHistoryModel + { + OwnerId = ownerId, + CarId = carId, + HistoryDate = date.Value.ToDateTime(time), + EventType = CarHistoryType.Refuel, + City = StringOrNull(Cell(row, headers, "Ville")), + PostalCode = StringOrNull(Cell(row, headers, "CP")), + FuelCategory = StringOrNull(Cell(row, headers, "Type carburant")), + FuelVolume = DoubleOrNull(Cell(row, headers, "Volume (L)")) ?? DoubleOrNull(Cell(row, headers, "Quantité (L)")), + FuelUnitPrice = PriceOrNull(Cell(row, headers, "Prix (€ / L)")), + Cost = PriceOrNull(Cell(row, headers, "Prix (€)")) ?? PriceOrNull(Cell(row, headers, "Montant (€)")), + Mileage = IntOrNull(Cell(row, headers, "Km")), + DeltaMileage = DoubleOrNull(Cell(row, headers, "Distance (km)")), + IsFullRefill = IsFlagSet(Cell(row, headers, "Plein")), + StationBrandName = StringOrNull(Cell(row, headers, "Distributeur")), + Description = JoinNonEmpty("; ", + StringOrNull(Cell(row, headers, "Voyage")) is { } voyage ? $"Voyage : {voyage}" : null, + StringOrNull(Cell(row, headers, "Détails")), + IsFlagSet(Cell(row, headers, "Autoroute Eloigné")) == true ? "Autoroute/éloigné" : null, + IsFlagSet(Cell(row, headers, "Autoroute")) == true ? "Autoroute" : null, + IsFlagSet(Cell(row, headers, "Gonflage")) == true ? "Gonflage pneus" : null) + }; + + await carHistoryRepository.CreateAsync(entry); + created++; + } + + return created; + } + + private async Task ImportMaintenanceSheetAsync(IXLWorksheet sheet, string carId, string ownerId, string carName, List warnings) + { + var headers = ReadHeaders(sheet); + var created = 0; + + foreach (var row in DataRows(sheet)) + { + var dateCell = Cell(row, headers, "Date"); + if (dateCell is null || dateCell.IsEmpty()) continue; + + var date = ParseDate(dateCell); + if (date is null) + { + warnings.Add($"{carName}: skipped a maintenance entry with an unreadable date (\"{dateCell.GetString()}\")."); + continue; + } + + var nature = StringOrNull(Cell(row, headers, "Nature")); + var invoiceNumber = StringOrNull(Cell(row, headers, "N° facture")); + + var entry = new CarHistoryModel + { + OwnerId = ownerId, + CarId = carId, + // Neither maintenance sheet has a time-of-day column, so this always defaults to midnight. + HistoryDate = date.Value.ToDateTime(TimeOnly.MinValue), + // "Achat" (purchase) is not a maintenance action - everything else in these two sheets is. + EventType = string.Equals(nature, "Achat", StringComparison.OrdinalIgnoreCase) ? CarHistoryType.Other : CarHistoryType.Maintenance, + City = StringOrNull(Cell(row, headers, "Ville")), + PostalCode = StringOrNull(Cell(row, headers, "CP")), + Cost = PriceOrNull(Cell(row, headers, "Prix (TTC)")), + Mileage = IntOrNull(Cell(row, headers, "Km")), + Garage = StringOrNull(Cell(row, headers, "Garage")), + Description = JoinNonEmpty("; ", + nature, + StringOrNull(Cell(row, headers, "Détail")), + StringOrNull(Cell(row, headers, "Complément")), + invoiceNumber is not null ? $"Facture {invoiceNumber}" : null) + }; + + await carHistoryRepository.CreateAsync(entry); + created++; + } + + return created; + } + + private static CarModel NewCar(string ownerId, string name) => new() { OwnerId = ownerId, Name = name, EnergyType = CarEnergyType.Combustion }; + + private static Dictionary ReadHeaders(IXLWorksheet sheet) + { + var headerRow = sheet.Row(1); + var headers = new Dictionary(); + foreach (var cell in headerRow.CellsUsed()) + { + // Some headers (e.g. "Autoroute\nEloigné") span two lines within a single cell; normalize + // whitespace so a lookup doesn't have to guess the exact line-break character used in the file. + var text = string.Join(' ', cell.GetString().Split([' ', '\r', '\n', '\t'], StringSplitOptions.RemoveEmptyEntries)); + if (text.Length > 0) headers.TryAdd(text, cell.Address.ColumnNumber); + } + + return headers; + } + + private static IEnumerable DataRows(IXLWorksheet sheet) => sheet.RowsUsed().Skip(1); + + private static IXLCell? Cell(IXLRow row, Dictionary headers, string header) => + headers.TryGetValue(header, out var column) ? row.Cell(column) : null; + + /// + /// Reads a date, falling back to a manual French (d/M/yyyy) text parse for the one row that wasn't + /// stored as a real Excel date. One entry in "Carburant S" has the corrupted literal text "11/06/0207", + /// confirmed (from the surrounding, chronologically-sorted rows: 2017-08-13 above, 2017-05-26 below) to + /// be a mistyped 11/06/2017 - fixed here rather than skipped, per a manual review of the source file. + /// + private static DateOnly? ParseDate(IXLCell cell) + { + if (cell.TryGetValue(out DateTime dateTime)) return DateOnly.FromDateTime(dateTime); + + var raw = cell.GetString().Trim(); + if (raw == "11/06/0207") return new DateOnly(2017, 6, 11); + + return DateOnly.TryParseExact(raw, "d/M/yyyy", French, DateTimeStyles.None, out var parsed) ? parsed : null; + } + + /// + /// The "Heure" column is inconsistent in this file - some cells are real Excel time values (a fraction + /// of a day), others are plain text like "11:57" - but GetFormattedString() renders both the + /// same way, so a single text parse handles both. + /// + private static TimeOnly? ParseTime(IXLCell? cell) + { + if (cell is null || cell.IsEmpty()) return null; + var text = cell.GetFormattedString().Trim(); + return TimeOnly.TryParse(text, CultureInfo.InvariantCulture, out var parsed) ? parsed : null; + } + + private static bool? IsFlagSet(IXLCell? cell) + { + if (cell is null || cell.IsEmpty()) return null; + return string.Equals(cell.GetString().Trim(), "O", StringComparison.OrdinalIgnoreCase); + } + + private static string? StringOrNull(IXLCell? cell) + { + if (cell is null || cell.IsEmpty()) return null; + var text = cell.GetString().Trim(); + return text.Length > 0 ? text : null; + } + + private static double? DoubleOrNull(IXLCell? cell) => cell is not null && !cell.IsEmpty() && cell.TryGetValue(out double value) ? value : null; + + /// + /// Several euro-amount columns are Excel formulas (e.g. volume × unit price), which routinely produce + /// floating-point noise like 180.77539999999996 instead of a clean 180.78 - rounded to the cent here so + /// every imported price is a real, displayable amount. + /// + private static double? PriceOrNull(IXLCell? cell) => DoubleOrNull(cell) is { } value ? Math.Round(value, 2) : null; + + private static int? IntOrNull(IXLCell? cell) => DoubleOrNull(cell) is { } value ? (int)Math.Round(value) : null; + + private static string? JoinNonEmpty(string separator, params string?[] parts) + { + var joined = string.Join(separator, parts.Where(p => !string.IsNullOrWhiteSpace(p))); + return joined.Length > 0 ? joined : null; + } +} diff --git a/src/WebApi/Import/Parsers/EpisodeCommentsCsvParser.cs b/src/WebApi/Import/Parsers/EpisodeCommentsCsvParser.cs new file mode 100644 index 00000000..a766d5c9 --- /dev/null +++ b/src/WebApi/Import/Parsers/EpisodeCommentsCsvParser.cs @@ -0,0 +1,36 @@ +using CsvHelper; +using CsvHelper.Configuration.Attributes; + +namespace Keeptrack.WebApi.Import.Parsers; + +public class EpisodeCommentRecord +{ + [Name("tv_show_name")] + public required string ShowTitle { get; set; } + + [Name("episode_season_number")] + public required int SeasonNumber { get; set; } + + [Name("episode_number")] + public required int EpisodeNumber { get; set; } + + [Name("comment")] + public required string Comment { get; set; } + + [Name("created_at")] + public required DateTime CreatedAt { get; set; } +} + +/// +/// Parses TV Time's episode_comment.csv: the exporting user's own free-text comments on episodes. +/// Episodes here are only identified by show name (no show id in this file), same as seen_episode_source.csv. +/// +public static class EpisodeCommentsCsvParser +{ + public static List Parse(Stream csvStream) + { + using var reader = new StreamReader(csvStream); + using var csv = new CsvReader(reader, TvTimeCsvConfiguration.Instance); + return csv.GetRecords().ToList(); + } +} diff --git a/src/WebApi/Import/Parsers/EpisodeWatchCsvParser.cs b/src/WebApi/Import/Parsers/EpisodeWatchCsvParser.cs new file mode 100644 index 00000000..dac98c12 --- /dev/null +++ b/src/WebApi/Import/Parsers/EpisodeWatchCsvParser.cs @@ -0,0 +1,15 @@ +namespace Keeptrack.WebApi.Import.Parsers; + +/// +/// Parses TV Time's tracking-prod-records-v2.csv: the newer-generation replacement for +/// tracking-prod-records.csv, same idea - a generic event log with an individually dated row per +/// episode watch, identified by its "gsi" column starting with "watch-episode-". Other row types in +/// this file (one per-show summary row with an aggregate "most_recent_ep_watched" field) are skipped. +/// +public static class EpisodeWatchCsvParser +{ + private const string EpisodeWatchPrefix = "watch-episode-"; + + public static List Parse(Stream csvStream) => + RawEpisodeWatchRowParser.Parse(csvStream, "s_id", csv => (csv.GetField("gsi") ?? string.Empty).StartsWith(EpisodeWatchPrefix, StringComparison.Ordinal)); +} diff --git a/src/WebApi/Import/Parsers/FavoriteMoviesListParser.cs b/src/WebApi/Import/Parsers/FavoriteMoviesListParser.cs new file mode 100644 index 00000000..a77472f9 --- /dev/null +++ b/src/WebApi/Import/Parsers/FavoriteMoviesListParser.cs @@ -0,0 +1,60 @@ +using System.Text.RegularExpressions; +using CsvHelper; +using CsvHelper.Configuration.Attributes; + +namespace Keeptrack.WebApi.Import.Parsers; + +internal class ListRowRecord +{ + [Name("s_key")] + public string? SKey { get; set; } + + [Name("objects")] + public string? Objects { get; set; } +} + +/// +/// Parses TV Time's lists-prod-lists.csv to find the movie ids in the built-in "Favorite Movies" list. +/// That one row's `objects` column is not CSV or JSON: it's a Go fmt-style dump of a slice of maps, +/// e.g. "[map[created_at:... type:movie uuid:1be8d227-...] map[... type:series id:71663] ...]". +/// +public static partial class FavoriteMoviesListParser +{ + private const string FavoriteMoviesSKey = "favorite-movies"; + + public static HashSet Parse(Stream csvStream) + { + using var reader = new StreamReader(csvStream); + using var csv = new CsvReader(reader, TvTimeCsvConfiguration.Instance); + var objectsField = csv.GetRecords() + .FirstOrDefault(row => row.SKey == FavoriteMoviesSKey) + ?.Objects; + return ParseObjectsField(objectsField); + } + + /// + /// Pure parsing of the raw `objects` field content, split out so it can be unit-tested without a CSV file. + /// + public static HashSet ParseObjectsField(string? objectsField) + { + var uuids = new HashSet(); + if (string.IsNullOrEmpty(objectsField)) return uuids; + + foreach (Match entry in EntryRegex().Matches(objectsField)) + { + var content = entry.Groups[1].Value; + if (!content.Contains("type:movie")) continue; + + var uuidMatch = UuidRegex().Match(content); + if (uuidMatch.Success) uuids.Add(uuidMatch.Groups[1].Value); + } + + return uuids; + } + + [GeneratedRegex(@"map\[([^\[\]]*)\]")] + private static partial Regex EntryRegex(); + + [GeneratedRegex(@"uuid:([0-9a-fA-F-]{36})")] + private static partial Regex UuidRegex(); +} diff --git a/src/WebApi/Import/Parsers/FollowedShowsCsvParser.cs b/src/WebApi/Import/Parsers/FollowedShowsCsvParser.cs new file mode 100644 index 00000000..9d860eb7 --- /dev/null +++ b/src/WebApi/Import/Parsers/FollowedShowsCsvParser.cs @@ -0,0 +1,26 @@ +using CsvHelper; +using CsvHelper.Configuration.Attributes; + +namespace Keeptrack.WebApi.Import.Parsers; + +public class FollowedShowRecord +{ + [Name("tv_show_id")] + public required string TvShowId { get; set; } + + [Name("tv_show_name")] + public required string Title { get; set; } +} + +/// +/// Parses TV Time's followed_tv_show.csv, the master list of a user's TV shows. +/// +public static class FollowedShowsCsvParser +{ + public static List Parse(Stream csvStream) + { + using var reader = new StreamReader(csvStream); + using var csv = new CsvReader(reader, TvTimeCsvConfiguration.Instance); + return csv.GetRecords().ToList(); + } +} diff --git a/src/WebApi/Import/Parsers/LegacyEpisodeWatchCsvParser.cs b/src/WebApi/Import/Parsers/LegacyEpisodeWatchCsvParser.cs new file mode 100644 index 00000000..6fd00049 --- /dev/null +++ b/src/WebApi/Import/Parsers/LegacyEpisodeWatchCsvParser.cs @@ -0,0 +1,14 @@ +namespace Keeptrack.WebApi.Import.Parsers; + +/// +/// Parses TV Time's tracking-prod-records.csv: a generic, older-generation event log that includes +/// (among many other event types) an individually dated row per episode watch. Far more complete +/// than seen_episode_source.csv, which only covers episodes marked watched via the episode-detail +/// screen. A genuine episode watch row has type "watch" and entity_type "episode"; every other row +/// type (show-level counters, "last episode watched" summaries, ...) is skipped. +/// +public static class LegacyEpisodeWatchCsvParser +{ + public static List Parse(Stream csvStream) => + RawEpisodeWatchRowParser.Parse(csvStream, "series_id", csv => csv.GetField("type") == "watch" && csv.GetField("entity_type") == "episode"); +} diff --git a/src/WebApi/Import/Parsers/MovieTrackingEventsCsvParser.cs b/src/WebApi/Import/Parsers/MovieTrackingEventsCsvParser.cs new file mode 100644 index 00000000..e3dd27dd --- /dev/null +++ b/src/WebApi/Import/Parsers/MovieTrackingEventsCsvParser.cs @@ -0,0 +1,59 @@ +using CsvHelper; + +namespace Keeptrack.WebApi.Import.Parsers; + +public enum MovieTrackingEventType +{ + Watched, + WantToWatch, + Followed +} + +/// +/// A single movie event. is TV Time's own per-movie tracking id: every event +/// type (follow/watch/towatch) for the same movie shares one uuid, and two different movies that happen +/// to share a title get distinct uuids - so it is a stable, title-independent key for the movie. +/// +public sealed record MovieTrackingEventRecord(string MovieName, MovieTrackingEventType EventType, DateTime CreatedAt, string? Uuid); + +/// +/// Parses the movie-related rows of tracking-prod-records.csv (tracking-prod-records-v2.csv, TV Time's +/// newer-generation log, carries no movie data at all - confirmed against a real export). Movies have +/// no dedicated "watched"/"want to watch" file the way shows do (followed_tv_show.csv, +/// user_show_special_status.csv); this generic event log turns out to be the only source for both, via +/// entity_type "movie" rows of type "watch"/"towatch"/"follow" - each individually dated. Previously +/// assumed unrecoverable ("import movies without a watch date"); found by grepping the real export for +/// a movie-watch signal instead of accepting that as final, the same way the episode-history gaps were found. +/// +public static class MovieTrackingEventsCsvParser +{ + public static List Parse(Stream csvStream) + { + using var reader = new StreamReader(csvStream); + using var csv = new CsvReader(reader, TvTimeCsvConfiguration.Instance); + csv.Read(); + csv.ReadHeader(); + + var records = new List(); + while (csv.Read()) + { + if (csv.GetField("entity_type") != "movie") continue; + + var eventType = csv.GetField("type") switch + { + "watch" => MovieTrackingEventType.Watched, + "towatch" => MovieTrackingEventType.WantToWatch, + "follow" => MovieTrackingEventType.Followed, + _ => (MovieTrackingEventType?)null + }; + if (eventType is null) continue; + + var movieName = csv.GetField("movie_name"); + if (string.IsNullOrEmpty(movieName)) continue; + + records.Add(new MovieTrackingEventRecord(movieName, eventType.Value, csv.GetField("created_at"), csv.GetField("uuid"))); + } + + return records; + } +} diff --git a/src/WebApi/Import/Parsers/MovieVotesCsvParser.cs b/src/WebApi/Import/Parsers/MovieVotesCsvParser.cs new file mode 100644 index 00000000..500fd95a --- /dev/null +++ b/src/WebApi/Import/Parsers/MovieVotesCsvParser.cs @@ -0,0 +1,35 @@ +using CsvHelper; +using CsvHelper.Configuration.Attributes; + +namespace Keeptrack.WebApi.Import.Parsers; + +public class MovieVoteRecord +{ + [Name("uuid")] + public required string Uuid { get; set; } + + [Name("movie_name")] + public required string MovieName { get; set; } +} + +/// +/// Parses TV Time's rating/emotion vote files (ratings-v2-prod-votes.csv, ratings-live-votes.csv, +/// emotions-v2-prod-votes.csv, emotions-live-votes.csv). These files mix movie and episode votes; +/// only rows carrying a movie_name are kept. The uuid here is the vote's own id (used to match +/// the favorite-movies list), NOT a stable movie id - a movie's stable id is its per-movie tracking +/// uuid in tracking-prod-records.csv (see ). A movie known only +/// from these vote files therefore falls back to a title-derived id at import time. +/// The vote's own emotion/rating code is intentionally not read here: it's an undocumented internal +/// enum, not a linear rating scale. +/// +public static class MovieVotesCsvParser +{ + public static List Parse(Stream csvStream) + { + using var reader = new StreamReader(csvStream); + using var csv = new CsvReader(reader, TvTimeCsvConfiguration.Instance); + return csv.GetRecords() + .Where(record => !string.IsNullOrWhiteSpace(record.MovieName)) + .ToList(); + } +} diff --git a/src/WebApi/Import/Parsers/RawEpisodeWatchRowParser.cs b/src/WebApi/Import/Parsers/RawEpisodeWatchRowParser.cs new file mode 100644 index 00000000..17dc5814 --- /dev/null +++ b/src/WebApi/Import/Parsers/RawEpisodeWatchRowParser.cs @@ -0,0 +1,42 @@ +using System.Globalization; +using CsvHelper; + +namespace Keeptrack.WebApi.Import.Parsers; + +/// +/// Shared row-by-row reader for TV Time files that mix genuine per-episode watch events together +/// with other row types (show-level summaries, counters, ...) in the same CSV. Each source file has +/// its own way of telling those apart, so the discriminator is supplied by the caller; everything +/// else (opening the stream, skipping rows without a season/episode, building the result) is common. +/// +internal static class RawEpisodeWatchRowParser +{ + public static List Parse(Stream csvStream, string showIdColumn, Func isEpisodeWatchRow) + { + using var reader = new StreamReader(csvStream); + using var csv = new CsvReader(reader, TvTimeCsvConfiguration.Instance); + csv.Read(); + csv.ReadHeader(); + + var records = new List(); + while (csv.Read()) + { + if (!isEpisodeWatchRow(csv)) continue; + + var seasonNumber = csv.GetField("season_number"); + var episodeNumber = csv.GetField("episode_number"); + if (string.IsNullOrEmpty(seasonNumber) || string.IsNullOrEmpty(episodeNumber)) continue; + + records.Add(new SeenEpisodeRecord + { + ShowTitle = csv.GetField("series_name") ?? string.Empty, + TvShowId = csv.GetField(showIdColumn), + SeasonNumber = int.Parse(seasonNumber, CultureInfo.InvariantCulture), + EpisodeNumber = int.Parse(episodeNumber, CultureInfo.InvariantCulture), + WatchedAt = csv.GetField("created_at") + }); + } + + return records; + } +} diff --git a/src/WebApi/Import/Parsers/SeenEpisodesCsvParser.cs b/src/WebApi/Import/Parsers/SeenEpisodesCsvParser.cs new file mode 100644 index 00000000..4fd1a7c5 --- /dev/null +++ b/src/WebApi/Import/Parsers/SeenEpisodesCsvParser.cs @@ -0,0 +1,53 @@ +using CsvHelper; +using CsvHelper.Configuration.Attributes; + +namespace Keeptrack.WebApi.Import.Parsers; + +/// +/// One episode watch event. The column names differ slightly across the several files that carry +/// this same information (see , , +/// ), so both naming variants are accepted here. +/// +public class SeenEpisodeRecord +{ + [Name("tv_show_name", "series_name")] + public required string ShowTitle { get; set; } + + /// + /// TV Time's show id, when the source file has one (the tracking-prod-records files do; + /// seen_episode_source.csv doesn't, so this is null for records coming from that file). + /// Lets a show be identified - and enriched with its rating/status/comments - even when it never + /// appeared in followed_tv_show.csv. [Optional] because none of the three aliases exist at all in + /// seen_episode_source.csv's header, and CsvHelper's header validation otherwise throws on that, + /// even though the property itself isn't `required`. + /// + [Name("tv_show_id", "series_id", "s_id")] + [Optional] + public string? TvShowId { get; set; } + + [Name("episode_season_number", "season_number")] + public required int SeasonNumber { get; set; } + + [Name("episode_number")] + public required int EpisodeNumber { get; set; } + + [Name("created_at")] + public required DateTime WatchedAt { get; set; } +} + +/// +/// Parses TV Time's seen_episode_source.csv: one row per episode watch event, with a date. +/// Episodes here are only identified by show name (no show id in this file). This file alone is +/// usually far from complete - it's only written when an episode is marked watched via the +/// episode-detail screen. and +/// cover the much larger volume of episodes marked watched other ways. +/// +public static class SeenEpisodesCsvParser +{ + public static List Parse(Stream csvStream) + { + using var reader = new StreamReader(csvStream); + using var csv = new CsvReader(reader, TvTimeCsvConfiguration.Instance); + return csv.GetRecords().ToList(); + } +} diff --git a/src/WebApi/Import/Parsers/ShowActivityCsvParser.cs b/src/WebApi/Import/Parsers/ShowActivityCsvParser.cs new file mode 100644 index 00000000..10764a5c --- /dev/null +++ b/src/WebApi/Import/Parsers/ShowActivityCsvParser.cs @@ -0,0 +1,31 @@ +using CsvHelper; +using CsvHelper.Configuration.Attributes; + +namespace Keeptrack.WebApi.Import.Parsers; + +public class ShowActivityRecord +{ + [Name("tv_show_id")] + public required string TvShowId { get; set; } + + /// + /// TV Time's own count of episodes seen for this show, including ones marked via bulk/season + /// actions that never get an individual row in seen_episode_source.csv. + /// + [Name("nb_episodes_seen")] + public required int EpisodesSeenCount { get; set; } +} + +/// +/// Parses TV Time's user_tv_show_data.csv: per-show aggregate activity, notably a total episode +/// count that can be higher than what seen_episode_source.csv captures in detail. +/// +public static class ShowActivityCsvParser +{ + public static List Parse(Stream csvStream) + { + using var reader = new StreamReader(csvStream); + using var csv = new CsvReader(reader, TvTimeCsvConfiguration.Instance); + return csv.GetRecords().ToList(); + } +} diff --git a/src/WebApi/Import/Parsers/ShowCommentsCsvParser.cs b/src/WebApi/Import/Parsers/ShowCommentsCsvParser.cs new file mode 100644 index 00000000..1db638a0 --- /dev/null +++ b/src/WebApi/Import/Parsers/ShowCommentsCsvParser.cs @@ -0,0 +1,30 @@ +using CsvHelper; +using CsvHelper.Configuration.Attributes; + +namespace Keeptrack.WebApi.Import.Parsers; + +public class ShowCommentRecord +{ + [Name("tv_show_id")] + public required string TvShowId { get; set; } + + [Name("comment")] + public required string Comment { get; set; } + + [Name("created_at")] + public required DateTime CreatedAt { get; set; } +} + +/// +/// Parses TV Time's show_comment.csv: the exporting user's own free-text comments on shows. +/// The GDPR export only ever contains the requesting user's own rows, so no user-id filtering is needed. +/// +public static class ShowCommentsCsvParser +{ + public static List Parse(Stream csvStream) + { + using var reader = new StreamReader(csvStream); + using var csv = new CsvReader(reader, TvTimeCsvConfiguration.Instance); + return csv.GetRecords().ToList(); + } +} diff --git a/src/WebApi/Import/Parsers/ShowRatingsCsvParser.cs b/src/WebApi/Import/Parsers/ShowRatingsCsvParser.cs new file mode 100644 index 00000000..fc3e1c28 --- /dev/null +++ b/src/WebApi/Import/Parsers/ShowRatingsCsvParser.cs @@ -0,0 +1,26 @@ +using CsvHelper; +using CsvHelper.Configuration.Attributes; + +namespace Keeptrack.WebApi.Import.Parsers; + +public class ShowRatingRecord +{ + [Name("tv_show_id")] + public required string TvShowId { get; set; } + + [Name("rating")] + public required float Rating { get; set; } +} + +/// +/// Parses TV Time's tv_show_rate.csv: a clean 0-5 rating per show. +/// +public static class ShowRatingsCsvParser +{ + public static List Parse(Stream csvStream) + { + using var reader = new StreamReader(csvStream); + using var csv = new CsvReader(reader, TvTimeCsvConfiguration.Instance); + return csv.GetRecords().ToList(); + } +} diff --git a/src/WebApi/Import/Parsers/ShowStatusCsvParser.cs b/src/WebApi/Import/Parsers/ShowStatusCsvParser.cs new file mode 100644 index 00000000..8becacde --- /dev/null +++ b/src/WebApi/Import/Parsers/ShowStatusCsvParser.cs @@ -0,0 +1,33 @@ +using CsvHelper; +using CsvHelper.Configuration.Attributes; + +namespace Keeptrack.WebApi.Import.Parsers; + +public class ShowStatusRecord +{ + [Name("tv_show_id")] + public required string TvShowId { get; set; } + + /// + /// TV Time status value, e.g. "favorite" or "for_later". + /// + [Name("status")] + public required string Status { get; set; } +} + +/// +/// Parses TV Time's user_show_special_status.csv: favorite/for_later status per show. +/// +public static class ShowStatusCsvParser +{ + public const string FavoriteStatus = "favorite"; + + public const string ForLaterStatus = "for_later"; + + public static List Parse(Stream csvStream) + { + using var reader = new StreamReader(csvStream); + using var csv = new CsvReader(reader, TvTimeCsvConfiguration.Instance); + return csv.GetRecords().ToList(); + } +} diff --git a/src/WebApi/Import/Parsers/TvTimeCsvConfiguration.cs b/src/WebApi/Import/Parsers/TvTimeCsvConfiguration.cs new file mode 100644 index 00000000..f4ff8f55 --- /dev/null +++ b/src/WebApi/Import/Parsers/TvTimeCsvConfiguration.cs @@ -0,0 +1,16 @@ +using System.Globalization; +using CsvHelper.Configuration; + +namespace Keeptrack.WebApi.Import.Parsers; + +/// +/// Shared CsvHelper configuration for every TV Time export file: tolerant of the differing +/// column sets between the several export-generation files this importer reads. +/// +internal static class TvTimeCsvConfiguration +{ + public static CsvConfiguration Instance { get; } = new(CultureInfo.InvariantCulture) + { + PrepareHeaderForMatch = args => args.Header.Trim().ToLowerInvariant() + }; +} diff --git a/src/WebApi/Import/TvTimeImportController.cs b/src/WebApi/Import/TvTimeImportController.cs new file mode 100644 index 00000000..f27da050 --- /dev/null +++ b/src/WebApi/Import/TvTimeImportController.cs @@ -0,0 +1,82 @@ +using Keeptrack.WebApi.Controllers; +using Keeptrack.WebApi.Jobs; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Keeptrack.WebApi.Import; + +[ApiController] +[Authorize] +[Route("api/import")] +public class TvTimeImportController(JobStore jobStore, IServiceScopeFactory scopeFactory) : ControllerBase +{ + /// + /// Starts importing a TV Time GDPR export (the zip you get from TV Time's "Download my data" request) + /// as an upsert. Runs in the background; poll with the returned job id for progress. + /// + [HttpPost("tv-time")] + [RequestSizeLimit(50_000_000)] + [Consumes("multipart/form-data")] + [ProducesResponseType(202)] + [ProducesResponseType(400)] + public async Task> ImportTvTime(IFormFile file) + { + if (file.Length == 0) + { + return BadRequest(); + } + + // buffered up front: IFormFile's stream isn't valid once this request finishes, but the import + // itself runs in the background after we respond + var buffer = new MemoryStream(); + await using (var uploadStream = file.OpenReadStream()) + { + await uploadStream.CopyToAsync(buffer); + } + + buffer.Position = 0; + var ownerId = this.GetUserId(); + var jobId = jobStore.Create(ownerId, ImportStage.Parsing); + + _ = RunImportJobAsync(jobId, buffer, ownerId); + + return Accepted(new ImportJobDto { JobId = jobId }); + } + + /// + /// Current status of a previously started import job. + /// + [HttpGet("tv-time/{jobId:guid}")] + [ProducesResponseType(200)] + [ProducesResponseType(404)] + public ActionResult GetStatus(Guid jobId) + { + var status = jobStore.GetStatus(jobId, this.GetUserId()); + if (status is null) return NotFound(); + + return Ok(new ImportJobStatusDto { Stage = status.Value.Stage, Result = status.Value.Result, ErrorMessage = status.Value.ErrorMessage }); + } + + /// + /// Runs the import on a background task using its own DI scope (the request that started it has + /// already completed by the time this runs, so it can't reuse the request's scoped services). + /// + private async Task RunImportJobAsync(Guid jobId, MemoryStream buffer, string ownerId) + { + await using (buffer) + { + using var scope = scopeFactory.CreateScope(); + var importService = scope.ServiceProvider.GetRequiredService(); + + try + { + var result = await importService.ImportAsync(buffer, ownerId, stage => jobStore.UpdateStage(jobId, stage)); + jobStore.Complete(jobId, ImportStage.Completed, result); + } + catch (Exception ex) + { + jobStore.Fail(jobId, ImportStage.Failed, ex.Message); + } + } + } +} diff --git a/src/WebApi/Import/TvTimeImportService.cs b/src/WebApi/Import/TvTimeImportService.cs new file mode 100644 index 00000000..354f6b0c --- /dev/null +++ b/src/WebApi/Import/TvTimeImportService.cs @@ -0,0 +1,513 @@ +using System.IO.Compression; +using Keeptrack.Common.System; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.WebApi.Import.Parsers; +using Keeptrack.WebApi.ReferenceData; + +namespace Keeptrack.WebApi.Import; + +/// +/// Imports a TV Time GDPR export (a zip of CSV files) as an upsert: followed shows, per-episode +/// watch history, ratings, favorite/want-to-watch status, comments, and movies discovered through +/// rating/emotion votes and tracking-prod-records.csv's movie watch/want-to-watch/follow events. +/// Every record created or updated is stamped with the authenticated caller's owner id only - nothing +/// in the uploaded file (including TV Time's own internal user id) is ever used to determine ownership. +/// +public class TvTimeImportService( + ITvShowRepository tvShowRepository, + IEpisodeRepository episodeRepository, + IMovieRepository movieRepository, + IServiceScopeFactory scopeFactory, + ILogger logger) +{ + private static readonly string[] s_movieVoteFileNames = + [ + "ratings-v2-prod-votes.csv", + "ratings-live-votes.csv", + "emotions-v2-prod-votes.csv", + "emotions-live-votes.csv" + ]; + + public async Task ImportAsync(Stream zipStream, string ownerId, Action? onStageChanged = null) + { + onStageChanged?.Invoke(ImportStage.Parsing); + + await using var archive = new ZipArchive(zipStream, ZipArchiveMode.Read); + + // followed_tv_show.csv is NOT a complete list of shows the user has a relationship with - it's + // just one signal among several (confirmed against real export data: shows with real watch + // history in the tracking files are sometimes entirely absent from this file). Never treat it + // as the sole source of truth for "which shows exist". + var followedShows = ReadCsvEntry(archive, "followed_tv_show.csv", FollowedShowsCsvParser.Parse) ?? []; + + // Three overlapping sources of "episode watched on this date", oldest/least complete to newest/most complete. + // seen_episode_source.csv alone is usually drastically incomplete (only episode-detail-screen taps); the two + // tracking-prod-records files are TV Time's generic event logs and capture episodes marked watched any other + // way too. All three are merged and de-duplicated per (show, season, episode) in ImportEpisodesAsync. + var seenEpisodes = (ReadCsvEntry(archive, "seen_episode_source.csv", SeenEpisodesCsvParser.Parse) ?? []) + .Concat(ReadCsvEntry(archive, "tracking-prod-records.csv", LegacyEpisodeWatchCsvParser.Parse) ?? []) + .Concat(ReadCsvEntry(archive, "tracking-prod-records-v2.csv", EpisodeWatchCsvParser.Parse) ?? []) + .ToList(); + + var showRatings = ReadCsvEntry(archive, "tv_show_rate.csv", ShowRatingsCsvParser.Parse) ?? []; + var showStatuses = ReadCsvEntry(archive, "user_show_special_status.csv", ShowStatusCsvParser.Parse) ?? []; + var showComments = ReadCsvEntry(archive, "show_comment.csv", ShowCommentsCsvParser.Parse) ?? []; + var episodeComments = ReadCsvEntry(archive, "episode_comment.csv", EpisodeCommentsCsvParser.Parse) ?? []; + var showActivity = ReadCsvEntry(archive, "user_tv_show_data.csv", ShowActivityCsvParser.Parse) ?? []; + var favoriteMovieUuids = ReadCsvEntry(archive, "lists-prod-lists.csv", FavoriteMoviesListParser.Parse) ?? []; + + var movieVotes = s_movieVoteFileNames + .Select(fileName => ReadCsvEntry(archive, fileName, MovieVotesCsvParser.Parse)) + .Where(votes => votes is not null) + .SelectMany(votes => votes!) + .ToList(); + + // The only source of a movie's watched-date/want-to-watch status: tracking-prod-records.csv's + // generic event log also carries movie rows (entity_type "movie"), not just episode ones. + var movieTrackingEvents = ReadCsvEntry(archive, "tracking-prod-records.csv", MovieTrackingEventsCsvParser.Parse) ?? []; + + var enrichment = ShowEnrichment.Build(showRatings, showStatuses, showComments); + var result = new ImportResultDto(); + + // Map every TV Time title to its stable id, so the title-only source files (seen_episode_source.csv + // for shows; the vote files for movies) resolve to the *same* id as the id-bearing files + // (followed_tv_show.csv / the tracking logs) instead of being treated as a different item. + var showIdByTitle = BuildIdByTitle( + followedShows.Select(s => (s.Title, (string?)s.TvShowId)) + .Concat(seenEpisodes.Select(e => (e.ShowTitle, e.TvShowId)))); + var movieIdByTitle = BuildIdByTitle(movieTrackingEvents.Select(e => (e.MovieName, e.Uuid))); + + onStageChanged?.Invoke(ImportStage.ImportingShows); + var showIndex = await ImportShowsAsync(ownerId, followedShows, enrichment, showIdByTitle); + + onStageChanged?.Invoke(ImportStage.ImportingEpisodes); + var detailedEpisodeCountByShowTitle = await ImportEpisodesAsync(ownerId, showIndex, seenEpisodes, episodeComments, enrichment, showIdByTitle, result); + result.ShowsCreated = showIndex.CreatedCount; + result.ShowsSkipped = showIndex.SkippedCount; + AnnotateWatchCompleteness(followedShows, showActivity, detailedEpisodeCountByShowTitle, result); + + onStageChanged?.Invoke(ImportStage.ImportingMovies); + await ImportMoviesAsync(ownerId, movieVotes, movieTrackingEvents, favoriteMovieUuids, movieIdByTitle, result); + + return result; + } + + private const string TitleTvTimeIdPrefix = "tvtime_title:"; + + /// + /// Builds a normalized-title -> stable-id lookup from every (title, id) pair that carries an id, + /// first one wins. Used so a title appearing in a source file that has no id (seen_episode_source.csv, + /// the movie vote files) still resolves to the id its id-bearing counterpart established. + /// + private static Dictionary BuildIdByTitle(IEnumerable<(string Title, string? Id)> entries) + { + var map = new Dictionary(); + foreach (var (title, id) in entries) + { + if (!string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(title)) + { + map.TryAdd(TitleNormalizer.Normalize(title), id); + } + } + + return map; + } + + /// + /// The stable TV Time id to stamp on (and match) a record: the export's own id for that title when + /// there is one, otherwise a deterministic title-derived fallback (tvtime_title:<normalized>). + /// The fallback is stable across re-imports because it is derived from the export title, which + /// reference enrichment never touches - unlike the record's own Title. + /// + private static string ResolveTvTimeId(string title, Dictionary idByTitle) + { + var key = TitleNormalizer.Normalize(title); + return idByTitle.TryGetValue(key, out var id) ? id : TitleTvTimeIdPrefix + key; + } + + private async Task> ImportShowsAsync( + string ownerId, + List followedShows, + ShowEnrichment enrichment, + Dictionary showIdByTitle) + { + var existing = (await tvShowRepository.FindAllAsync(ownerId, 1, int.MaxValue, null, NewShow(ownerId, string.Empty))).Items; + var index = new UpsertIndex(existing, show => show.Title); + + foreach (var show in followedShows) + { + var tvTimeId = ResolveTvTimeId(show.Title, showIdByTitle); + + if (index.TryMatch(tvTimeId, show.Title, out var existingShow)) + { + await BackfillTvTimeIdAsync(tvShowRepository, index, existingShow, tvTimeId, ownerId); + index.MarkMatched(existingShow); + continue; + } + + var model = NewShow(ownerId, show.Title); + model.TvTimeId = tvTimeId; + enrichment.ApplyTo(model, show.TvShowId); + + var created = await tvShowRepository.CreateAsync(model); + model.Id = created.Id; + index.MarkCreated(model); + index.Index(model); + await TryEnrichShowAsync(model); + } + + return index; + } + + /// + /// Upserts episodes from the merged watch-event sources (see ), taking the + /// earliest recorded date per (show, season, episode) when more than one source reports it. A show + /// with watch history that was never in followed_tv_show.csv is created here rather than skipped - + /// confirmed against real export data that a show can have genuine watch history without ever + /// appearing in that file. Returns, per normalized show title, how many distinct episodes got a + /// watch date - used by to flag shows where TV Time's own + /// reported total is still higher than what these files captured in detail. + /// + private async Task> ImportEpisodesAsync( + string ownerId, + UpsertIndex showIndex, + List seenEpisodes, + List episodeComments, + ShowEnrichment enrichment, + Dictionary showIdByTitle, + ImportResultDto result) + { + var notesByEpisodeKey = episodeComments + .GroupBy(c => (Title: TitleNormalizer.Normalize(c.ShowTitle), c.SeasonNumber, c.EpisodeNumber)) + .ToDictionary(g => g.Key, g => FormatComments(g.Select(c => (c.CreatedAt, c.Comment)))); + + var detailedEpisodeCountByShowTitle = new Dictionary(); + + foreach (var showGroup in seenEpisodes.GroupBy(e => TitleNormalizer.Normalize(e.ShowTitle))) + { + var showTitle = showGroup.First().ShowTitle; + var tvTimeId = ResolveTvTimeId(showTitle, showIdByTitle); + + if (showIndex.TryMatch(tvTimeId, showTitle, out var show)) + { + await BackfillTvTimeIdAsync(tvShowRepository, showIndex, show, tvTimeId, ownerId); + showIndex.MarkMatched(show); + } + else + { + // A show with genuine watch history but no followed_tv_show.csv row is created here rather + // than skipped (see ImportEpisodesAsync summary). enrichment.ApplyTo takes the raw TV Time + // show id (null when the export has none), not the possibly-synthesized tvTimeId. + var rawShowId = showGroup.Select(e => e.TvShowId).FirstOrDefault(id => !string.IsNullOrEmpty(id)); + show = NewShow(ownerId, showTitle); + show.TvTimeId = tvTimeId; + enrichment.ApplyTo(show, rawShowId); + + var created = await tvShowRepository.CreateAsync(show); + show.Id = created.Id; + showIndex.MarkCreated(show); + showIndex.Index(show); + await TryEnrichShowAsync(show); + } + + // de-duplicate across the merged sources: one row per (season, episode), earliest date wins + var episodesWatched = showGroup + .GroupBy(e => (e.SeasonNumber, e.EpisodeNumber)) + .Select(g => (g.Key.SeasonNumber, g.Key.EpisodeNumber, WatchedAt: g.Min(e => e.WatchedAt))) + .ToList(); + + detailedEpisodeCountByShowTitle[showGroup.Key] = episodesWatched.Count; + + // one bulk fetch per show, so matching each watched episode doesn't need its own database round trip + var existingEpisodes = (await episodeRepository.FindAllAsync(ownerId, 1, int.MaxValue, null, + NewEpisode(ownerId, show.Id!, 0, 0))) + .Items + .Select(e => (e.SeasonNumber, e.EpisodeNumber)) + .ToHashSet(); + + foreach (var seen in episodesWatched) + { + // Already imported: leave it as-is (keeps the earliest date a previous import recorded). + if (existingEpisodes.Contains((seen.SeasonNumber, seen.EpisodeNumber))) + { + result.EpisodesSkipped++; + continue; + } + + var episode = NewEpisode(ownerId, show.Id!, seen.SeasonNumber, seen.EpisodeNumber); + episode.WatchedAt = DateOnly.FromDateTime(seen.WatchedAt); + if (notesByEpisodeKey.TryGetValue((showGroup.Key, seen.SeasonNumber, seen.EpisodeNumber), out var notes)) episode.Notes = notes; + + await episodeRepository.CreateAsync(episode); + result.EpisodesCreated++; + } + } + + return detailedEpisodeCountByShowTitle; + } + + /// + /// TV Time's own reported episode count (user_tv_show_data.csv) can still be higher than what the + /// merged watch-event sources captured in detail, for shows with activity old or unusual enough + /// that even those logs missed it. Surface that gap in plain language instead of silently importing + /// a suspiciously short watch history. + /// + private static void AnnotateWatchCompleteness( + List followedShows, + List showActivity, + Dictionary detailedEpisodeCountByShowTitle, + ImportResultDto result) + { + var reportedCountByTvShowId = showActivity.ToDictionary(a => a.TvShowId, a => a.EpisodesSeenCount); + + foreach (var show in followedShows) + { + if (!reportedCountByTvShowId.TryGetValue(show.TvShowId, out var reportedCount)) continue; + + var detailedCount = detailedEpisodeCountByShowTitle.GetValueOrDefault(TitleNormalizer.Normalize(show.Title)); + if (reportedCount <= detailedCount) continue; + + result.Warnings.Add($"{show.Title}: {detailedCount} of {reportedCount} episodes imported (bulk actions are not exported by TV Time)."); + } + } + + /// + /// A movie exists in Keeptrack if it appears in *either* the rating/emotion vote files or the + /// tracking-prod-records.csv movie events - not just the vote files, the same "don't limit + /// existence to one file" lesson already applied to shows. + /// events set (the earliest one, if there's more than one); + /// a event only sets + /// when there's no watched event too, so a since-watched movie doesn't stay flagged as still-to-watch. + /// + private async Task ImportMoviesAsync( + string ownerId, + List movieVotes, + List movieTrackingEvents, + HashSet favoriteMovieUuids, + Dictionary movieIdByTitle, + ImportResultDto result) + { + var existing = (await movieRepository.FindAllAsync(ownerId, 1, int.MaxValue, null, NewMovie(ownerId, string.Empty))).Items; + var index = new UpsertIndex(existing, movie => movie.Title); + + var votesByTitle = movieVotes.GroupBy(v => TitleNormalizer.Normalize(v.MovieName)).ToDictionary(g => g.Key, g => g.ToList()); + var trackingByTitle = movieTrackingEvents.GroupBy(e => TitleNormalizer.Normalize(e.MovieName)).ToDictionary(g => g.Key, g => g.ToList()); + + var titles = votesByTitle.Select(g => (g.Key, Name: g.Value[0].MovieName)) + .Concat(trackingByTitle.Select(g => (g.Key, Name: g.Value[0].MovieName))) + .GroupBy(x => x.Key) + .Select(g => g.First()); + + foreach (var (key, movieName) in titles) + { + var tvTimeId = ResolveTvTimeId(movieName, movieIdByTitle); + + if (index.TryMatch(tvTimeId, movieName, out var existingMovie)) + { + await BackfillTvTimeIdAsync(movieRepository, index, existingMovie, tvTimeId, ownerId); + index.MarkMatched(existingMovie); + continue; + } + + var model = NewMovie(ownerId, movieName); + model.TvTimeId = tvTimeId; + + if (votesByTitle.TryGetValue(key, out var votes) && votes.Any(v => favoriteMovieUuids.Contains(v.Uuid))) + { + model.IsFavorite = true; + } + + if (trackingByTitle.TryGetValue(key, out var events)) + { + var watchDates = events.Where(e => e.EventType == MovieTrackingEventType.Watched).Select(e => e.CreatedAt).ToList(); + if (watchDates.Count > 0) model.FirstSeenAt = DateOnly.FromDateTime(watchDates.Min()); + if (watchDates.Count == 0 && events.Any(e => e.EventType == MovieTrackingEventType.WantToWatch)) model.WantToWatch = true; + } + + var created = await movieRepository.CreateAsync(model); + model.Id = created.Id; + index.MarkCreated(model); + index.Index(model); + await TryEnrichMovieAsync(model); + } + + result.MoviesCreated = index.CreatedCount; + result.MoviesSkipped = index.SkippedCount; + } + + /// + /// One-time adoption of a record created by an import that predated the stable-id matching: it exists + /// (matched by title) but has no TV Time id yet, so stamp the id on it now, without touching anything + /// else, so that every subsequent re-import matches it by id instead of title. A no-op for a record + /// that already carries an id (the steady-state re-import path, which writes nothing at all). + /// + private static async Task BackfillTvTimeIdAsync(IDataRepository repository, UpsertIndex index, TModel model, string tvTimeId, string ownerId) + where TModel : class, IHasIdAndOwnerId, IHasTvTimeId + { + if (!string.IsNullOrEmpty(model.TvTimeId)) return; + + model.TvTimeId = tvTimeId; + await repository.UpdateAsync(model.Id!, model, ownerId); + index.Index(model); + } + + private static TvShowModel NewShow(string ownerId, string title) => new() { OwnerId = ownerId, Title = title }; + + private static MovieModel NewMovie(string ownerId, string title) => new() { OwnerId = ownerId, Title = title }; + + private static EpisodeModel NewEpisode(string ownerId, string tvShowId, int seasonNumber, int episodeNumber) => + new() { OwnerId = ownerId, TvShowId = tvShowId, SeasonNumber = seasonNumber, EpisodeNumber = episodeNumber }; + + private static string FormatComments(IEnumerable<(DateTime CreatedAt, string Comment)> comments) => + string.Join('\n', comments.OrderBy(c => c.CreatedAt).Select(c => $"{c.CreatedAt:yyyy-MM-dd}: {c.Comment}")); + + /// + /// Best-effort background reference-data match for a newly-imported show, fired on its own DI scope + /// instead of awaited inline - a bulk import creating dozens of shows/movies must not block on a + /// sequential chain of TMDB HTTP calls (search + details + credits, per item) before finishing. Same + /// shape already used for single-item creation via TvShowController/MovieController.OnCreatedAsync. + /// A single show's TMDB lookup failing (rate limit, no network) must never fail the rest of the import. + /// + private Task TryEnrichShowAsync(TvShowModel show) + { + var title = show.Title; + var year = show.Year; + _ = Task.Run(async () => + { + try + { + using var scope = scopeFactory.CreateScope(); + var enrichmentService = scope.ServiceProvider.GetRequiredService(); + await enrichmentService.TryAutoResolveTvShowAsync(title, year); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Reference-data match failed for imported TV show '{Title}'.", title); + } + }); + return Task.CompletedTask; + } + + /// + /// Movie equivalent of . + /// + private Task TryEnrichMovieAsync(MovieModel movie) + { + var title = movie.Title; + var year = movie.Year; + _ = Task.Run(async () => + { + try + { + using var scope = scopeFactory.CreateScope(); + var enrichmentService = scope.ServiceProvider.GetRequiredService(); + await enrichmentService.TryAutoResolveMovieAsync(title, year); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Reference-data match failed for imported movie '{Title}'.", title); + } + }); + return Task.CompletedTask; + } + + private static TResult? ReadCsvEntry(ZipArchive archive, string entryName, Func parse) + where TResult : class + { + var entry = archive.GetEntry(entryName); + if (entry is null) return null; + using var stream = entry.Open(); + return parse(stream); + } + + /// + /// Resolves each imported show/movie to the existing record it should update, keyed by a stable TV + /// Time id. This is what makes a re-import idempotent even after reference enrichment has rewritten a + /// record's stored Title to a provider's canonical name: matching is by the immutable id, never by the + /// mutable title. It also tallies how many records were created versus skipped, de-duplicated by + /// reference identity so a show touched in both the followed-shows and episodes phases counts once. + /// + private sealed class UpsertIndex + where TModel : class, IHasTvTimeId + { + private readonly Func _title; + private readonly Dictionary _byTvTimeId = []; + private readonly Dictionary _byTitle = []; + private readonly HashSet _created = []; + private readonly HashSet _skipped = []; + + public UpsertIndex(IEnumerable existing, Func title) + { + _title = title; + foreach (var model in existing) + { + Index(model); + } + } + + public int CreatedCount => _created.Count; + + public int SkippedCount => _skipped.Count; + + /// Registers a model under its TV Time id (if set) and its normalized title. + public void Index(TModel model) + { + if (!string.IsNullOrEmpty(model.TvTimeId)) _byTvTimeId[model.TvTimeId] = model; + _byTitle.TryAdd(TitleNormalizer.Normalize(_title(model)), model); + } + + /// + /// Matches an imported item to an existing record. A hit on is the + /// steady-state path. The title fallback fires only for a record that has no TV Time id yet - one + /// created by an import predating this feature - so it gets adopted (and back-filled) exactly once + /// rather than duplicated; a record that already carries a *different* id is left alone, so two + /// genuinely different items sharing a title are never collapsed into one. + /// + public bool TryMatch(string tvTimeId, string title, out TModel model) + { + if (_byTvTimeId.TryGetValue(tvTimeId, out model!)) return true; + if (_byTitle.TryGetValue(TitleNormalizer.Normalize(title), out model!) && string.IsNullOrEmpty(model.TvTimeId)) return true; + + model = null!; + return false; + } + + public void MarkCreated(TModel model) => _created.Add(model); + + public void MarkMatched(TModel model) + { + // A record created earlier in this same run is not a pre-existing "skip". + if (!_created.Contains(model)) _skipped.Add(model); + } + } + + /// + /// Per-show rating/favorite/want-to-watch/notes, keyed by TV Time's show id. Built once from + /// tv_show_rate.csv/user_show_special_status.csv/show_comment.csv and applied both to shows found + /// via followed_tv_show.csv and to shows discovered only through episode watch history. + /// + private sealed class ShowEnrichment( + Dictionary ratingByShowId, + HashSet favoriteShowIds, + HashSet wantToWatchShowIds, + Dictionary notesByShowId) + { + public static ShowEnrichment Build(List showRatings, List showStatuses, List showComments) => + new( + showRatings.GroupBy(r => r.TvShowId).ToDictionary(g => g.Key, g => g.Last().Rating), + showStatuses.Where(s => s.Status == ShowStatusCsvParser.FavoriteStatus).Select(s => s.TvShowId).ToHashSet(), + showStatuses.Where(s => s.Status == ShowStatusCsvParser.ForLaterStatus).Select(s => s.TvShowId).ToHashSet(), + showComments.GroupBy(c => c.TvShowId).ToDictionary(g => g.Key, g => FormatComments(g.Select(c => (c.CreatedAt, c.Comment))))); + + public void ApplyTo(TvShowModel show, string? tvShowId) + { + if (tvShowId is null) return; + + if (ratingByShowId.TryGetValue(tvShowId, out var rating)) show.Rating = rating; + if (favoriteShowIds.Contains(tvShowId)) show.IsFavorite = true; + if (wantToWatchShowIds.Contains(tvShowId)) show.WantToWatch = true; + if (notesByShowId.TryGetValue(tvShowId, out var notes)) show.Notes = notes; + } + } +} diff --git a/src/WebApi/Jobs/JobStore.cs b/src/WebApi/Jobs/JobStore.cs new file mode 100644 index 00000000..ad46e27a --- /dev/null +++ b/src/WebApi/Jobs/JobStore.cs @@ -0,0 +1,63 @@ +using System.Collections.Concurrent; + +namespace Keeptrack.WebApi.Jobs; + +/// +/// Tracks the progress of a background job so the client can poll for real status instead of waiting on +/// a single opaque request - shared by every feature with a long-running admin/user-triggered action +/// (TV Time import, reference-data "sync now"), parameterized by that feature's own stage enum and result +/// DTO rather than duplicated per feature. In-memory only - fine for occasional, personal-scale actions; +/// jobs don't survive an app restart, an acceptable trade-off here. +/// +public class JobStore +{ + private readonly ConcurrentDictionary _jobs = new(); + + public Guid Create(string ownerId, TStage initialStage) + { + var jobId = Guid.NewGuid(); + _jobs[jobId] = new Job(ownerId, initialStage); + return jobId; + } + + public void UpdateStage(Guid jobId, TStage stage) + { + if (_jobs.TryGetValue(jobId, out var job)) job.Stage = stage; + } + + public void Complete(Guid jobId, TStage completedStage, TResult result) + { + if (!_jobs.TryGetValue(jobId, out var job)) return; + job.Stage = completedStage; + job.Result = result; + } + + public void Fail(Guid jobId, TStage failedStage, string errorMessage) + { + if (!_jobs.TryGetValue(jobId, out var job)) return; + job.Stage = failedStage; + job.ErrorMessage = errorMessage; + } + + /// + /// Returns null if the job doesn't exist or wasn't created by this owner - a caller can never observe + /// another user's job, even by guessing a job id. + /// + public (TStage Stage, TResult? Result, string? ErrorMessage)? GetStatus(Guid jobId, string ownerId) + { + if (!_jobs.TryGetValue(jobId, out var job) || job.OwnerId != ownerId) return null; + + return (job.Stage, job.Result, job.ErrorMessage); + } + + private sealed class Job(string ownerId, TStage stage) + { + public string OwnerId { get; } = ownerId; + + public TStage Stage { get; set; } = stage; + + public TResult? Result { get; set; } + + public string? ErrorMessage { get; set; } + } +} diff --git a/src/WebApi/Mappers/AlbumDtoMapper.cs b/src/WebApi/Mappers/AlbumDtoMapper.cs new file mode 100644 index 00000000..6194e6ea --- /dev/null +++ b/src/WebApi/Mappers/AlbumDtoMapper.cs @@ -0,0 +1,16 @@ +using Keeptrack.Domain.Models; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.WebApi.Mappers; + +[Mapper] +[UseStaticMapper(typeof(CommonDtoMappings))] +public partial class AlbumDtoMapper : IDtoMapper +{ + // see BookDtoMapper.ToModel for why MapValue (not MapperIgnoreTarget) is required here + [MapValue(nameof(AlbumModel.OwnerId), "")] + public partial AlbumModel ToModel(AlbumDto dto); + + [MapperIgnoreSource(nameof(AlbumModel.OwnerId))] + public partial AlbumDto ToDto(AlbumModel model); +} diff --git a/src/WebApi/Mappers/AlbumReferenceDtoMapper.cs b/src/WebApi/Mappers/AlbumReferenceDtoMapper.cs new file mode 100644 index 00000000..77ae8131 --- /dev/null +++ b/src/WebApi/Mappers/AlbumReferenceDtoMapper.cs @@ -0,0 +1,23 @@ +using Keeptrack.Domain.Models; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.WebApi.Mappers; + +/// +/// One-directional (Model -> Dto) - see for the general rationale. +/// / are ignored +/// here and hydrated manually by - same reasoning as +/// . +/// +[Mapper] +public partial class AlbumReferenceDtoMapper +{ + [MapperIgnoreTarget(nameof(AlbumReferenceDto.ArtistName))] + [MapperIgnoreTarget(nameof(AlbumReferenceDto.ArtistImageUrl))] + [MapperIgnoreSource(nameof(AlbumReferenceModel.TitleNormalized))] + [MapperIgnoreSource(nameof(AlbumReferenceModel.ArtistReferenceId))] + [MapperIgnoreSource(nameof(AlbumReferenceModel.ExternalIds))] + [MapperIgnoreSource(nameof(AlbumReferenceModel.MatchedAliases))] + [MapperIgnoreSource(nameof(AlbumReferenceModel.LastEnrichedAt))] + public partial AlbumReferenceDto ToDto(AlbumReferenceModel model); +} diff --git a/src/WebApi/Mappers/BookDtoMapper.cs b/src/WebApi/Mappers/BookDtoMapper.cs new file mode 100644 index 00000000..b8c4ea26 --- /dev/null +++ b/src/WebApi/Mappers/BookDtoMapper.cs @@ -0,0 +1,19 @@ +using Keeptrack.Domain.Models; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.WebApi.Mappers; + +[Mapper] +[UseStaticMapper(typeof(CommonDtoMappings))] +public partial class BookDtoMapper : IDtoMapper +{ + // OwnerId is `required` on BookModel, so it can't just be ignored (Mapperly's generated object + // initializer would fail to compile) - MapValue satisfies the required-member constructor while + // still leaving OwnerId to be set server-side from claims (DataCrudControllerBase), never from + // client input, exactly like the ignored member it replaces. + [MapValue(nameof(BookModel.OwnerId), "")] + public partial BookModel ToModel(BookDto dto); + + [MapperIgnoreSource(nameof(BookModel.OwnerId))] + public partial BookDto ToDto(BookModel model); +} diff --git a/src/WebApi/Mappers/BookReferenceDtoMapper.cs b/src/WebApi/Mappers/BookReferenceDtoMapper.cs new file mode 100644 index 00000000..560b2113 --- /dev/null +++ b/src/WebApi/Mappers/BookReferenceDtoMapper.cs @@ -0,0 +1,23 @@ +using Keeptrack.Domain.Models; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.WebApi.Mappers; + +/// +/// One-directional (Model -> Dto) - see for the general rationale. +/// / are ignored here +/// and hydrated manually by : the model only carries an +/// AuthorReferenceId, the Dto needs the person's name joined in from person_reference. +/// +[Mapper] +public partial class BookReferenceDtoMapper +{ + [MapperIgnoreTarget(nameof(BookReferenceDto.AuthorName))] + [MapperIgnoreTarget(nameof(BookReferenceDto.AuthorImageUrl))] + [MapperIgnoreSource(nameof(BookReferenceModel.TitleNormalized))] + [MapperIgnoreSource(nameof(BookReferenceModel.AuthorReferenceId))] + [MapperIgnoreSource(nameof(BookReferenceModel.ExternalIds))] + [MapperIgnoreSource(nameof(BookReferenceModel.MatchedAliases))] + [MapperIgnoreSource(nameof(BookReferenceModel.LastEnrichedAt))] + public partial BookReferenceDto ToDto(BookReferenceModel model); +} diff --git a/src/WebApi/Mappers/CarDtoMapper.cs b/src/WebApi/Mappers/CarDtoMapper.cs new file mode 100644 index 00000000..54821283 --- /dev/null +++ b/src/WebApi/Mappers/CarDtoMapper.cs @@ -0,0 +1,32 @@ +using Keeptrack.Domain.Models; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.WebApi.Mappers; + +[Mapper(EnumMappingStrategy = EnumMappingStrategy.ByName)] +[UseStaticMapper(typeof(CommonDtoMappings))] +public partial class CarDtoMapper : IDtoMapper +{ + // see BookDtoMapper.ToModel for why MapValue (not MapperIgnoreTarget) is required here + [MapValue(nameof(CarModel.OwnerId), "")] + public partial CarModel ToModel(CarDto dto); + + [MapperIgnoreSource(nameof(CarModel.OwnerId))] + public partial CarDto ToDto(CarModel model); + + /// + /// CarDto.EnergyType is nullable (CarEnergyType?) while CarModel.EnergyType is required - the same + /// "nullable DTO member mapping to a required model member" gotcha as CommonDtoMappings.ToRequiredString, + /// just for an enum instead of a string, so it can't reuse that helper. MapEnergyType below still gets + /// the generated, drift-checked ByName enum conversion; only the null -> default fallback is hand-written. + /// A Mapperly mapper class's partial methods must all be instance or all be static - both stay instance + /// to match ToModel/ToDto above. + /// + [UserMapping] + private Domain.Models.CarEnergyType ToRequiredCarEnergyType(Contracts.Dto.CarEnergyType? value) + { + return value.HasValue ? MapEnergyType(value.Value) : default; + } + + private partial Domain.Models.CarEnergyType MapEnergyType(Contracts.Dto.CarEnergyType value); +} diff --git a/src/WebApi/Mappers/CarHistoryDtoMapper.cs b/src/WebApi/Mappers/CarHistoryDtoMapper.cs new file mode 100644 index 00000000..f18c825b --- /dev/null +++ b/src/WebApi/Mappers/CarHistoryDtoMapper.cs @@ -0,0 +1,15 @@ +using Keeptrack.Domain.Models; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.WebApi.Mappers; + +[Mapper(EnumMappingStrategy = EnumMappingStrategy.ByName)] +public partial class CarHistoryDtoMapper : IDtoMapper +{ + // see BookDtoMapper.ToModel for why MapValue (not MapperIgnoreTarget) is required here + [MapValue(nameof(CarHistoryModel.OwnerId), "")] + public partial CarHistoryModel ToModel(CarHistoryDto dto); + + [MapperIgnoreSource(nameof(CarHistoryModel.OwnerId))] + public partial CarHistoryDto ToDto(CarHistoryModel model); +} diff --git a/src/WebApi/Mappers/CarMetricsDtoMapper.cs b/src/WebApi/Mappers/CarMetricsDtoMapper.cs new file mode 100644 index 00000000..2fc0be40 --- /dev/null +++ b/src/WebApi/Mappers/CarMetricsDtoMapper.cs @@ -0,0 +1,16 @@ +using Keeptrack.Domain.Models; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.WebApi.Mappers; + +/// +/// One-directional (Model -> Dto): is a pure Domain-level +/// computation with no Dto dependency, so maps its +/// result here. Mapperly preserves a null as null natively - +/// no AllowNull()-style opt-out needed, unlike the AutoMapper original. +/// +[Mapper] +public partial class CarMetricsDtoMapper +{ + public partial CarMetricsDto ToDto(CarMetricsModel model); +} diff --git a/src/WebApi/Mappers/CommonDtoMappings.cs b/src/WebApi/Mappers/CommonDtoMappings.cs new file mode 100644 index 00000000..7cd9f8c4 --- /dev/null +++ b/src/WebApi/Mappers/CommonDtoMappings.cs @@ -0,0 +1,23 @@ +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.WebApi.Mappers; + +/// +/// Shared fallback for a nullable DTO string member mapping to a Domain model member that has no +/// sensible default (typically required) - BookDto.Title is nullable while +/// BookModel.Title is required non-nullable, the InventoryPageBase new()- +/// constraint gotcha documented in CLAUDE.md. AutoMapper silently substituted "" for a null +/// source string everywhere; this reproduces that one specific behavior deliberately, for exact parity, +/// rather than letting a missing title become a null reference deeper in the model. Attached to every +/// DTO mapper that has at least one such member via [UseStaticMapper(typeof(CommonDtoMappings))]. +/// Tightening this to a real 400 validation error for a missing title is a deliberate follow-up, not +/// part of this mechanical migration - see docs/automapper-removal-plan.md. +/// +public static class CommonDtoMappings +{ + [UserMapping] + public static string ToRequiredString(string? value) + { + return value ?? string.Empty; + } +} diff --git a/src/WebApi/Mappers/EpisodeDtoMapper.cs b/src/WebApi/Mappers/EpisodeDtoMapper.cs new file mode 100644 index 00000000..2283f788 --- /dev/null +++ b/src/WebApi/Mappers/EpisodeDtoMapper.cs @@ -0,0 +1,15 @@ +using Keeptrack.Domain.Models; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.WebApi.Mappers; + +[Mapper] +public partial class EpisodeDtoMapper : IDtoMapper +{ + // see BookDtoMapper.ToModel for why MapValue (not MapperIgnoreTarget) is required here + [MapValue(nameof(EpisodeModel.OwnerId), "")] + public partial EpisodeModel ToModel(EpisodeDto dto); + + [MapperIgnoreSource(nameof(EpisodeModel.OwnerId))] + public partial EpisodeDto ToDto(EpisodeModel model); +} diff --git a/src/WebApi/Mappers/HouseDtoMapper.cs b/src/WebApi/Mappers/HouseDtoMapper.cs new file mode 100644 index 00000000..2f9e3274 --- /dev/null +++ b/src/WebApi/Mappers/HouseDtoMapper.cs @@ -0,0 +1,16 @@ +using Keeptrack.Domain.Models; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.WebApi.Mappers; + +[Mapper] +[UseStaticMapper(typeof(CommonDtoMappings))] +public partial class HouseDtoMapper : IDtoMapper +{ + // see BookDtoMapper.ToModel for why MapValue (not MapperIgnoreTarget) is required here + [MapValue(nameof(HouseModel.OwnerId), "")] + public partial HouseModel ToModel(HouseDto dto); + + [MapperIgnoreSource(nameof(HouseModel.OwnerId))] + public partial HouseDto ToDto(HouseModel model); +} diff --git a/src/WebApi/Mappers/HouseHistoryDtoMapper.cs b/src/WebApi/Mappers/HouseHistoryDtoMapper.cs new file mode 100644 index 00000000..99143bfd --- /dev/null +++ b/src/WebApi/Mappers/HouseHistoryDtoMapper.cs @@ -0,0 +1,15 @@ +using Keeptrack.Domain.Models; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.WebApi.Mappers; + +[Mapper(EnumMappingStrategy = EnumMappingStrategy.ByName)] +public partial class HouseHistoryDtoMapper : IDtoMapper +{ + // see BookDtoMapper.ToModel for why MapValue (not MapperIgnoreTarget) is required here + [MapValue(nameof(HouseHistoryModel.OwnerId), "")] + public partial HouseHistoryModel ToModel(HouseHistoryDto dto); + + [MapperIgnoreSource(nameof(HouseHistoryModel.OwnerId))] + public partial HouseHistoryDto ToDto(HouseHistoryModel model); +} diff --git a/src/WebApi/Mappers/HouseMetricsDtoMapper.cs b/src/WebApi/Mappers/HouseMetricsDtoMapper.cs new file mode 100644 index 00000000..3e761bb3 --- /dev/null +++ b/src/WebApi/Mappers/HouseMetricsDtoMapper.cs @@ -0,0 +1,15 @@ +using Keeptrack.Domain.Models; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.WebApi.Mappers; + +/// +/// One-directional (Model -> Dto): is a pure Domain-level +/// computation with no Dto dependency, so maps its +/// result here. +/// +[Mapper(EnumMappingStrategy = EnumMappingStrategy.ByName)] +public partial class HouseMetricsDtoMapper +{ + public partial HouseMetricsDto ToDto(HouseMetricsModel model); +} diff --git a/src/WebApi/Mappers/IDtoMapper.cs b/src/WebApi/Mappers/IDtoMapper.cs new file mode 100644 index 00000000..6526d1a9 --- /dev/null +++ b/src/WebApi/Mappers/IDtoMapper.cs @@ -0,0 +1,16 @@ +namespace Keeptrack.WebApi.Mappers; + +/// +/// Maps between a public REST DTO and its Domain model counterpart. Implemented per CRUD pair by a +/// Mapperly [Mapper] partial class (e.g. BookDtoMapper) and injected into +/// instead of AutoMapper's untyped +/// IMapper. Read-only feature controllers (WatchNext, Wishlist, Car/House metrics, +/// reference-data) use a small one-directional Model-to-DTO mapper class instead - they have no DTO -> +/// Model direction at all, so this bidirectional interface doesn't fit them. +/// +public interface IDtoMapper +{ + TModel ToModel(TDto dto); + + TDto ToDto(TModel model); +} diff --git a/src/WebApi/Mappers/InProgressShowDtoMapper.cs b/src/WebApi/Mappers/InProgressShowDtoMapper.cs new file mode 100644 index 00000000..c91c9842 --- /dev/null +++ b/src/WebApi/Mappers/InProgressShowDtoMapper.cs @@ -0,0 +1,16 @@ +using Keeptrack.Domain.Models; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.WebApi.Mappers; + +/// +/// One-directional (Model -> Dto): is a pure Domain-level +/// computation with no Dto dependency, so maps its result +/// here. Injected directly, same shape as the reference-data storage mappers - see +/// docs/automapper-removal-plan.md. +/// +[Mapper] +public partial class InProgressShowDtoMapper +{ + public partial InProgressShowDto ToDto(InProgressShowModel model); +} diff --git a/src/WebApi/Mappers/MovieDtoMapper.cs b/src/WebApi/Mappers/MovieDtoMapper.cs new file mode 100644 index 00000000..dd56e2dc --- /dev/null +++ b/src/WebApi/Mappers/MovieDtoMapper.cs @@ -0,0 +1,16 @@ +using Keeptrack.Domain.Models; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.WebApi.Mappers; + +[Mapper] +[UseStaticMapper(typeof(CommonDtoMappings))] +public partial class MovieDtoMapper : IDtoMapper +{ + // see BookDtoMapper.ToModel for why MapValue (not MapperIgnoreTarget) is required here + [MapValue(nameof(MovieModel.OwnerId), "")] + public partial MovieModel ToModel(MovieDto dto); + + [MapperIgnoreSource(nameof(MovieModel.OwnerId))] + public partial MovieDto ToDto(MovieModel model); +} diff --git a/src/WebApi/Mappers/MovieReferenceDtoMapper.cs b/src/WebApi/Mappers/MovieReferenceDtoMapper.cs new file mode 100644 index 00000000..76241ec9 --- /dev/null +++ b/src/WebApi/Mappers/MovieReferenceDtoMapper.cs @@ -0,0 +1,20 @@ +using Keeptrack.Domain.Models; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.WebApi.Mappers; + +/// +/// One-directional (Model -> Dto) - see for why, including the +/// ignore. +/// +[Mapper] +public partial class MovieReferenceDtoMapper +{ + [MapperIgnoreTarget(nameof(MovieReferenceDto.Cast))] + [MapperIgnoreSource(nameof(MovieReferenceModel.TitleNormalized))] + [MapperIgnoreSource(nameof(MovieReferenceModel.ExternalIds))] + [MapperIgnoreSource(nameof(MovieReferenceModel.MatchedAliases))] + [MapperIgnoreSource(nameof(MovieReferenceModel.Cast))] + [MapperIgnoreSource(nameof(MovieReferenceModel.LastEnrichedAt))] + public partial MovieReferenceDto ToDto(MovieReferenceModel model); +} diff --git a/src/WebApi/Mappers/PlaylistDtoMapper.cs b/src/WebApi/Mappers/PlaylistDtoMapper.cs new file mode 100644 index 00000000..80e44d93 --- /dev/null +++ b/src/WebApi/Mappers/PlaylistDtoMapper.cs @@ -0,0 +1,16 @@ +using Keeptrack.Domain.Models; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.WebApi.Mappers; + +[Mapper] +[UseStaticMapper(typeof(CommonDtoMappings))] +public partial class PlaylistDtoMapper : IDtoMapper +{ + // see BookDtoMapper.ToModel for why MapValue (not MapperIgnoreTarget) is required here + [MapValue(nameof(PlaylistModel.OwnerId), "")] + public partial PlaylistModel ToModel(PlaylistDto dto); + + [MapperIgnoreSource(nameof(PlaylistModel.OwnerId))] + public partial PlaylistDto ToDto(PlaylistModel model); +} diff --git a/src/WebApi/Mappers/SongDtoMapper.cs b/src/WebApi/Mappers/SongDtoMapper.cs new file mode 100644 index 00000000..4eabff60 --- /dev/null +++ b/src/WebApi/Mappers/SongDtoMapper.cs @@ -0,0 +1,16 @@ +using Keeptrack.Domain.Models; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.WebApi.Mappers; + +[Mapper] +[UseStaticMapper(typeof(CommonDtoMappings))] +public partial class SongDtoMapper : IDtoMapper +{ + // see BookDtoMapper.ToModel for why MapValue (not MapperIgnoreTarget) is required here + [MapValue(nameof(SongModel.OwnerId), "")] + public partial SongModel ToModel(SongDto dto); + + [MapperIgnoreSource(nameof(SongModel.OwnerId))] + public partial SongDto ToDto(SongModel model); +} diff --git a/src/WebApi/Mappers/TvShowDtoMapper.cs b/src/WebApi/Mappers/TvShowDtoMapper.cs new file mode 100644 index 00000000..a4ac75db --- /dev/null +++ b/src/WebApi/Mappers/TvShowDtoMapper.cs @@ -0,0 +1,16 @@ +using Keeptrack.Domain.Models; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.WebApi.Mappers; + +[Mapper(EnumMappingStrategy = EnumMappingStrategy.ByName)] +[UseStaticMapper(typeof(CommonDtoMappings))] +public partial class TvShowDtoMapper : IDtoMapper +{ + // see BookDtoMapper.ToModel for why MapValue (not MapperIgnoreTarget) is required here + [MapValue(nameof(TvShowModel.OwnerId), "")] + public partial TvShowModel ToModel(TvShowDto dto); + + [MapperIgnoreSource(nameof(TvShowModel.OwnerId))] + public partial TvShowDto ToDto(TvShowModel model); +} diff --git a/src/WebApi/Mappers/TvShowReferenceDtoMapper.cs b/src/WebApi/Mappers/TvShowReferenceDtoMapper.cs new file mode 100644 index 00000000..903b23bc --- /dev/null +++ b/src/WebApi/Mappers/TvShowReferenceDtoMapper.cs @@ -0,0 +1,23 @@ +using Keeptrack.Domain.Models; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.WebApi.Mappers; + +/// +/// One-directional (Model -> Dto): admins submit a LinkReferenceRequestDto, never a full +/// , so there's no Dto -> Model direction to map. +/// is ignored here and hydrated manually by - the +/// model only carries a PersonReferenceId, while the Dto needs the person's name/photo joined in +/// from person_reference, a join Mapperly has no repository access to perform. +/// +[Mapper] +public partial class TvShowReferenceDtoMapper +{ + [MapperIgnoreTarget(nameof(TvShowReferenceDto.Cast))] + [MapperIgnoreSource(nameof(TvShowReferenceModel.TitleNormalized))] + [MapperIgnoreSource(nameof(TvShowReferenceModel.ExternalIds))] + [MapperIgnoreSource(nameof(TvShowReferenceModel.MatchedAliases))] + [MapperIgnoreSource(nameof(TvShowReferenceModel.Cast))] + [MapperIgnoreSource(nameof(TvShowReferenceModel.LastEnrichedAt))] + public partial TvShowReferenceDto ToDto(TvShowReferenceModel model); +} diff --git a/src/WebApi/Mappers/VideoGameDtoMapper.cs b/src/WebApi/Mappers/VideoGameDtoMapper.cs new file mode 100644 index 00000000..5b4ffb4a --- /dev/null +++ b/src/WebApi/Mappers/VideoGameDtoMapper.cs @@ -0,0 +1,16 @@ +using Keeptrack.Domain.Models; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.WebApi.Mappers; + +[Mapper(EnumMappingStrategy = EnumMappingStrategy.ByName)] +[UseStaticMapper(typeof(CommonDtoMappings))] +public partial class VideoGameDtoMapper : IDtoMapper +{ + // see BookDtoMapper.ToModel for why MapValue (not MapperIgnoreTarget) is required here + [MapValue(nameof(VideoGameModel.OwnerId), "")] + public partial VideoGameModel ToModel(VideoGameDto dto); + + [MapperIgnoreSource(nameof(VideoGameModel.OwnerId))] + public partial VideoGameDto ToDto(VideoGameModel model); +} diff --git a/src/WebApi/Mappers/VideoGameReferenceDtoMapper.cs b/src/WebApi/Mappers/VideoGameReferenceDtoMapper.cs new file mode 100644 index 00000000..46e4d8c9 --- /dev/null +++ b/src/WebApi/Mappers/VideoGameReferenceDtoMapper.cs @@ -0,0 +1,17 @@ +using Keeptrack.Domain.Models; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.WebApi.Mappers; + +/// +/// One-directional (Model -> Dto) - see for the general rationale. +/// +[Mapper] +public partial class VideoGameReferenceDtoMapper +{ + [MapperIgnoreSource(nameof(VideoGameReferenceModel.TitleNormalized))] + [MapperIgnoreSource(nameof(VideoGameReferenceModel.ExternalIds))] + [MapperIgnoreSource(nameof(VideoGameReferenceModel.MatchedAliases))] + [MapperIgnoreSource(nameof(VideoGameReferenceModel.LastEnrichedAt))] + public partial VideoGameReferenceDto ToDto(VideoGameReferenceModel model); +} diff --git a/src/WebApi/MappingProfiles/CarDataStorageMappingProfile.cs b/src/WebApi/MappingProfiles/CarDataStorageMappingProfile.cs deleted file mode 100644 index 3815dd51..00000000 --- a/src/WebApi/MappingProfiles/CarDataStorageMappingProfile.cs +++ /dev/null @@ -1,67 +0,0 @@ -using Keeptrack.Domain.Models; -using Keeptrack.Infrastructure.MongoDb.Entities; - -namespace Keeptrack.WebApi.MappingProfiles; - -public class CarDataStorageMappingProfile : Profile -{ - public override string ProfileName - { - get { return "KeeptrackCarDataStorageMappingProfile"; } - } - - public CarDataStorageMappingProfile() - { - CreateMap(); - CreateMap(); - - MapCarHistoryModel(); - MapCarHistory(); - } - - private void MapCarHistoryModel() - { - CreateMap() - .ForMember(x => x.City, opt => opt.MapFrom( - x => x.Location != null ? x.Location.City : null)) - .ForMember(x => x.Longitude, opt => opt.MapFrom( - x => x.Coordinates != null ? x.Coordinates[0] : (double?)null)) - .ForMember(x => x.Latitude, opt => opt.MapFrom( - x => x.Coordinates != null ? x.Coordinates[1] : (double?)null)) - .ForMember(x => x.Amount, opt => opt.MapFrom( - x => x.Fuel != null ? x.Fuel.Amount : null)) - .ForMember(x => x.IsFullTank, opt => opt.MapFrom( - x => x.Fuel != null ? x.Fuel.IsFullTank : null)) - .ForMember(x => x.DeltaMileage, opt => opt.MapFrom( - x => x.Fuel != null ? x.Fuel.DeltaMileage : null)) - .ForMember(x => x.LastRefuelHistoryId, opt => opt.MapFrom( - x => x.Fuel != null ? x.Fuel.LastRefuelHistoryId : null)); - } - - private void MapCarHistory() - { - CreateMap() - .ForMember(x => x.Location, opt => opt.MapFrom( - x => x)) - .ForMember(x => x.Coordinates, opt => opt.MapFrom( - x => (x.Longitude.HasValue && x.Latitude.HasValue) ? new List { x.Longitude.Value, x.Latitude.Value } : null)) - .ForMember(x => x.Fuel, opt => opt.MapFrom( - x => x)) - .ForMember(x => x.Station, opt => opt.MapFrom( - x => x)); - - CreateMap(); - - CreateMap() - .ForMember(x => x.Category, opt => opt.MapFrom( - x => x.FuelCategory)) - .ForMember(x => x.Volume, opt => opt.MapFrom( - x => x.FuelVolume)) - .ForMember(x => x.UnitPrice, opt => opt.MapFrom( - x => x.FuelUnitPrice)); - - CreateMap() - .ForMember(x => x.BrandName, opt => opt.MapFrom( - x => x.StationBrandName)); - } -} diff --git a/src/WebApi/MappingProfiles/DataStorageMappingProfile.cs b/src/WebApi/MappingProfiles/DataStorageMappingProfile.cs deleted file mode 100644 index acf92a28..00000000 --- a/src/WebApi/MappingProfiles/DataStorageMappingProfile.cs +++ /dev/null @@ -1,48 +0,0 @@ -namespace Keeptrack.WebApi.MappingProfiles; - -public class DataStorageMappingProfile : Profile -{ - public override string ProfileName - { - get { return "KeeptrackDataStorageMappingProfile"; } - } - - public DataStorageMappingProfile() - { - CreateMap(); - CreateMap(); - - CreateMap() - .ForMember(x => x.AllocineId, opt => opt.MapFrom( - x => x.Allocine != null ? x.Allocine.Id : null)) - .ForMember(x => x.ImdbPageId, opt => opt.MapFrom( - x => x.Imdb != null ? x.Imdb.PageId : null)); - - CreateMap() - .ForMember(x => x.Allocine, opt => opt.MapFrom( - x => !string.IsNullOrEmpty(x.AllocineId) ? new Infrastructure.MongoDb.Entities.Allocine { Id = x.AllocineId } : null)) - .ForMember(x => x.Imdb, opt => opt.MapFrom( - x => !string.IsNullOrEmpty(x.ImdbPageId) ? new Infrastructure.MongoDb.Entities.Imdb { PageId = x.ImdbPageId } : null)); - - CreateMap(); - CreateMap(); - - CreateMap() - .ForMember(x => x.AllocineId, opt => opt.MapFrom( - x => x.Allocine != null ? x.Allocine.Id : null)) - .ForMember(x => x.ImdbPageId, opt => opt.MapFrom( - x => x.Imdb != null ? x.Imdb.PageId : null)); - - CreateMap() - .ForMember(x => x.Allocine, opt => opt.MapFrom( - x => !string.IsNullOrEmpty(x.AllocineId) ? new Infrastructure.MongoDb.Entities.Allocine { Id = x.AllocineId } : null)) - .ForMember(x => x.Imdb, opt => opt.MapFrom( - x => !string.IsNullOrEmpty(x.ImdbPageId) ? new Infrastructure.MongoDb.Entities.Imdb { PageId = x.ImdbPageId } : null)); - - CreateMap(); - CreateMap(); - - CreateMap().ConvertUsing(dt => DateOnly.FromDateTime(dt)); - CreateMap().ConvertUsing(d => d.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc)); - } -} diff --git a/src/WebApi/MappingProfiles/WebServiceMappingProfile.cs b/src/WebApi/MappingProfiles/WebServiceMappingProfile.cs deleted file mode 100644 index a0341fe0..00000000 --- a/src/WebApi/MappingProfiles/WebServiceMappingProfile.cs +++ /dev/null @@ -1,40 +0,0 @@ -namespace Keeptrack.WebApi.MappingProfiles; - -public class WebServiceMappingProfile : Profile -{ - public override string ProfileName - { - get { return "KeeptrackWebServiceMappingProfile"; } - } - - public WebServiceMappingProfile() - { - CreateMap() - .ForMember(x => x.OwnerId, opt => opt.Ignore()); - CreateMap(); - - CreateMap() - .ForMember(x => x.OwnerId, opt => opt.Ignore()); - CreateMap(); - - CreateMap() - .ForMember(x => x.OwnerId, opt => opt.Ignore()); - CreateMap(); - - CreateMap() - .ForMember(x => x.OwnerId, opt => opt.Ignore()); - CreateMap(); - - CreateMap() - .ForMember(x => x.OwnerId, opt => opt.Ignore()); - CreateMap(); - - CreateMap() - .ForMember(x => x.OwnerId, opt => opt.Ignore()); - CreateMap(); - - CreateMap() - .ForMember(x => x.OwnerId, opt => opt.Ignore()); - CreateMap(); - } -} diff --git a/src/WebApi/Program.cs b/src/WebApi/Program.cs index 377d033f..dca5bb95 100644 --- a/src/WebApi/Program.cs +++ b/src/WebApi/Program.cs @@ -7,17 +7,91 @@ // adds services to the container builder.Services.AddControllers(opts => { opts.Filters.Add(); }); builder.Services.AddOpenApi(); -builder.Services.AddAutoMapper(config => - { - config.AllowNullDestinationValues = false; - }, - typeof(Program).Assembly); builder.Services.AddHealthChecks(); +// a hosted BackgroundService (e.g. ReferenceSyncBackgroundService) already catches and logs every exception it can anticipate, +// but this is a systemic safety net for whatever it doesn't: by default, an unhandled exception escaping a BackgroundService.ExecuteAsync stops the whole host, +// taking every other endpoint down with it - "Ignore" logs it instead and lets the rest of the app keep serving requests. +builder.Services.Configure(opts => + opts.BackgroundServiceExceptionBehavior = BackgroundServiceExceptionBehavior.Ignore); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton, Keeptrack.WebApi.Mappers.BookDtoMapper>(); +builder.Services.AddSingleton, Keeptrack.WebApi.Mappers.CarDtoMapper>(); +builder.Services.AddSingleton, Keeptrack.WebApi.Mappers.CarHistoryDtoMapper>(); +builder.Services.AddSingleton, Keeptrack.WebApi.Mappers.HouseDtoMapper>(); +builder.Services.AddSingleton, Keeptrack.WebApi.Mappers.HouseHistoryDtoMapper>(); +builder.Services.AddSingleton, Keeptrack.WebApi.Mappers.EpisodeDtoMapper>(); +builder.Services.AddSingleton, Keeptrack.WebApi.Mappers.MovieDtoMapper>(); +builder.Services.AddSingleton, Keeptrack.WebApi.Mappers.AlbumDtoMapper>(); +builder.Services.AddSingleton, Keeptrack.WebApi.Mappers.TvShowDtoMapper>(); +builder.Services.AddSingleton, Keeptrack.WebApi.Mappers.SongDtoMapper>(); +builder.Services.AddSingleton, Keeptrack.WebApi.Mappers.PlaylistDtoMapper>(); +builder.Services.AddSingleton, Keeptrack.WebApi.Mappers.VideoGameDtoMapper>(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton>(); +builder.Services.AddSingleton>(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddSingleton(configuration.TmdbSettings); +builder.Services.AddHttpClient(client => +{ + client.BaseAddress = new Uri("https://api.themoviedb.org/3/"); + // AddStandardResilienceHandler's own TotalRequestTimeout (30s default) is meant to be the real bound + // on a call - but HttpClient's own Timeout (100s default, never otherwise touched here) wraps the + // whole pipeline including every retry, and silently wins whenever it's shorter than however long the + // resilience pipeline actually takes to give up. Disabling it lets the resilience handler's own + // timeout be authoritative instead of a stuck call hanging for a full 100s - see + // https://github.com/dotnet/extensions/issues/4770 (confirmed against this exact symptom on Discogs). + client.Timeout = Timeout.InfiniteTimeSpan; +}).AddStandardResilienceHandler(); +// which IBookReferenceClient implementation is registered is a deployment-time choice +// (ReferenceData:BookProvider / ReferenceData__BookProvider) - add a case here for each new provider. +switch (configuration.BookReferenceProvider) +{ + case "OpenLibrary": + builder.Services.AddHttpClient(client => + { + client.BaseAddress = new Uri("https://openlibrary.org/"); + client.DefaultRequestHeaders.Add("User-Agent", "Keeptrack/1.0 (+https://github.com/devpro/keeptrack)"); + client.Timeout = Timeout.InfiniteTimeSpan; + }).AddStandardResilienceHandler(); + break; + default: + throw new InvalidOperationException($"Unknown ReferenceData:BookProvider '{configuration.BookReferenceProvider}'. Supported providers: OpenLibrary."); +} +builder.Services.AddSingleton(configuration.RawgSettings); +builder.Services.AddHttpClient(client => +{ + client.BaseAddress = new Uri("https://api.rawg.io/api/"); + client.Timeout = Timeout.InfiniteTimeSpan; +}).AddStandardResilienceHandler(); +builder.Services.AddSingleton(configuration.DiscogsSettings); +builder.Services.AddHttpClient(client => +{ + client.BaseAddress = new Uri("https://api.discogs.com/"); + client.DefaultRequestHeaders.Add("User-Agent", "Keeptrack/1.0 (+https://github.com/devpro/keeptrack)"); + client.Timeout = Timeout.InfiniteTimeSpan; +}).AddStandardResilienceHandler(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddHostedService(); builder.Services.AddMongoDbInfrastructure(configuration); builder.Services.AddOpenApiWithBearerAuth(configuration); builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { + // Firebase's own claim names ("user_id", "role", ...) must stay exactly as issued - without this, + // some claim types (short JWT claim names like "role") get silently renamed to legacy ClaimTypes.* + // URIs by the token handler's inbound claim mapping, breaking RequireClaim("role", ...) checks + // that look for the literal type "role". + options.MapInboundClaims = false; options.Authority = configuration.JwtBearerSettings.Authority; options.TokenValidationParameters = new TokenValidationParameters { @@ -28,6 +102,10 @@ ValidateLifetime = true }; }); +builder.Services.AddAuthorization(options => +{ + options.AddPolicy("AdminOnly", policy => policy.RequireClaim("role", "admin")); +}); if (configuration.CorsAllowedOrigin.Count != 0) { builder.Services.AddCors(options => diff --git a/src/WebApi/ReferenceData/DiscogsClient.cs b/src/WebApi/ReferenceData/DiscogsClient.cs new file mode 100644 index 00000000..dccf0583 --- /dev/null +++ b/src/WebApi/ReferenceData/DiscogsClient.cs @@ -0,0 +1,164 @@ +using System.Text.Json.Serialization; +using System.Web; + +namespace Keeptrack.WebApi.ReferenceData; + +/// +/// Discogs REST client. Configured as a typed with a descriptive User-Agent +/// header (required by Discogs' API terms) - see Program.cs. The personal access token is appended as a +/// query parameter on every request, same convention as /. +/// Search is restricted to type=master so results resolve to a canonical release grouping rather +/// than every individual pressing/reissue. +/// +public class DiscogsClient(HttpClient http, DiscogsSettings settings) : IDiscogsClient +{ + public async Task> SearchAlbumsAsync(string title, int? year, string? artist = null, CancellationToken cancellationToken = default) + { + var results = await SearchAlbumsCoreAsync(title, year, artist, cancellationToken); + if (results.Count == 0 && !string.IsNullOrEmpty(artist)) + { + // Discogs' artist field must match its own index closely (exact spelling, formatting, or a + // disambiguation suffix like "Artist (2)" for a common name) - a mismatch there silently + // returns zero results even when the title alone would find the album (confirmed: searching + // "Born Pink" with an artist value that doesn't match Discogs' exact indexing returns nothing, + // while the same title alone finds it), so retry without it rather than reporting a false + // "not found". Year is kept, since no equivalent bug has been found for it (unlike Open + // Library's book-year problem - see IOpenLibraryClient.SearchBooksAsync). + results = await SearchAlbumsCoreAsync(title, year, null, cancellationToken); + } + + return results; + } + + private async Task> SearchAlbumsCoreAsync(string title, int? year, string? artist, CancellationToken cancellationToken) + { + var query = $"database/search?type=master&q={Encode(title)}&token={Token}" + + (year is null ? "" : $"&year={year}") + + (string.IsNullOrEmpty(artist) ? "" : $"&artist={Encode(artist)}"); + var response = await http.GetFromJsonAsync(query, cancellationToken); + return response?.Results.Select(r => + { + var (artist, albumTitle) = SplitArtistTitle(r.Title, title); + return new DiscogsSearchResult(r.Id.ToString(System.Globalization.CultureInfo.InvariantCulture), albumTitle, r.Year, artist, r.CoverImage ?? r.Thumb); + }).ToList() ?? []; + } + + public async Task GetAlbumDetailsAsync(string externalId, CancellationToken cancellationToken = default) + { + var details = await http.GetFromJsonAsync($"masters/{externalId}?token={Token}", cancellationToken); + if (details is null) return null; + + var genres = details.Genres.Concat(details.Styles).ToList(); + var image = details.Images.FirstOrDefault()?.Uri; + var primaryArtist = details.Artists.FirstOrDefault(); + var tracks = details.Tracklist + .Where(t => t.Type == "track") + .Select(t => new DiscogsTrack(t.Position ?? "", t.Title ?? "", t.Duration)) + .ToList(); + + return new DiscogsAlbumDetails( + externalId, details.Title ?? string.Empty, details.Year, details.Notes, + primaryArtist?.Name, primaryArtist?.Id.ToString(System.Globalization.CultureInfo.InvariantCulture), genres, image, tracks); + } + + private string Token => settings.Token; + + private static string Encode(string value) => HttpUtility.UrlEncode(value); + + /// + /// Discogs' search results title a release "Artist - Album Title" rather than exposing the two as + /// separate fields - split on the first " - " when present, falling back to the tenant's own search + /// title if the shape doesn't match (e.g. an artist name that itself contains " - "). + /// + private static (string? Artist, string Title) SplitArtistTitle(string? rawTitle, string fallbackTitle) + { + if (string.IsNullOrEmpty(rawTitle)) return (null, fallbackTitle); + var separatorIndex = rawTitle.IndexOf(" - ", System.StringComparison.Ordinal); + return separatorIndex < 0 ? (null, rawTitle) : (rawTitle[..separatorIndex], rawTitle[(separatorIndex + 3)..]); + } + + private sealed class DiscogsSearchResponse + { + [JsonPropertyName("results")] + public List Results { get; set; } = []; + } + + private sealed class DiscogsSearchItem + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("title")] + public string? Title { get; set; } + + [JsonPropertyName("year")] + public int? Year { get; set; } + + [JsonPropertyName("thumb")] + public string? Thumb { get; set; } + + [JsonPropertyName("cover_image")] + public string? CoverImage { get; set; } + } + + private sealed class DiscogsMasterResponse + { + [JsonPropertyName("title")] + public string? Title { get; set; } + + [JsonPropertyName("year")] + public int? Year { get; set; } + + [JsonPropertyName("notes")] + public string? Notes { get; set; } + + [JsonPropertyName("genres")] + public List Genres { get; set; } = []; + + [JsonPropertyName("styles")] + public List Styles { get; set; } = []; + + [JsonPropertyName("images")] + public List Images { get; set; } = []; + + [JsonPropertyName("artists")] + public List Artists { get; set; } = []; + + [JsonPropertyName("tracklist")] + public List Tracklist { get; set; } = []; + } + + /// + /// type_ distinguishes an actual track from a section heading some multi-part releases use + /// (e.g. "index") - only entries with type_ == "track" are real songs. + /// + private sealed class DiscogsTracklistItem + { + [JsonPropertyName("position")] + public string? Position { get; set; } + + [JsonPropertyName("type_")] + public string? Type { get; set; } + + [JsonPropertyName("title")] + public string? Title { get; set; } + + [JsonPropertyName("duration")] + public string? Duration { get; set; } + } + + private sealed class DiscogsImage + { + [JsonPropertyName("uri")] + public string? Uri { get; set; } + } + + private sealed class DiscogsArtist + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + } +} diff --git a/src/WebApi/ReferenceData/DiscogsSettings.cs b/src/WebApi/ReferenceData/DiscogsSettings.cs new file mode 100644 index 00000000..cfccff85 --- /dev/null +++ b/src/WebApi/ReferenceData/DiscogsSettings.cs @@ -0,0 +1,6 @@ +namespace Keeptrack.WebApi.ReferenceData; + +public class DiscogsSettings +{ + public required string Token { get; set; } +} diff --git a/src/WebApi/ReferenceData/IBookReferenceClient.cs b/src/WebApi/ReferenceData/IBookReferenceClient.cs new file mode 100644 index 00000000..411ab0b3 --- /dev/null +++ b/src/WebApi/ReferenceData/IBookReferenceClient.cs @@ -0,0 +1,39 @@ +namespace Keeptrack.WebApi.ReferenceData; + +/// +/// One book search hit - title, year, author and cover, enough for automatic matching or for an admin to +/// pick from when a match is ambiguous. +/// +public record BookSearchResult(string ExternalId, string Title, int? Year, string? Author, string? ImageUrl); + +public record BookDetails(string ExternalId, string Title, int? Year, string? Synopsis, string? Author, string? AuthorExternalId, List Genres, string? ImageUrl); + +/// +/// Provider-agnostic book lookup, backing 's book resolution/refresh +/// and 's admin search. Which concrete implementation is active is +/// a deployment-time choice (ReferenceData:BookProvider, see Program.cs) - nothing outside the +/// implementation itself (see ) should assume which provider is behind this +/// interface. Interface exists so tests use a fake - never call a real provider's API from a test. +/// +public interface IBookReferenceClient +{ + /// + /// The key this implementation's ids are stored under in BookReferenceModel.ExternalIds and in + /// person/author-reference lookups (e.g. "openlibrary") - same role as the literal "tmdb"/"rawg"/ + /// "discogs" strings the single-provider domains use directly, just not hardcodable in the shared + /// enrichment service here since more than one implementation of this interface can exist. Each + /// implementation owns its own key; callers must never hardcode a provider name. + /// + string ProviderKey { get; } + + /// + /// narrows the query when known - without it, a common title can return + /// dozens of unrelated results. is an optional hint; whether and how an + /// implementation uses it server-side is provider-specific (see e.g. 's + /// own reasoning for why it never filters by year). It is still returned per candidate for the + /// caller/admin to use when picking. + /// + Task> SearchBooksAsync(string title, int? year, string? author = null, CancellationToken cancellationToken = default); + + Task GetBookDetailsAsync(string externalId, CancellationToken cancellationToken = default); +} diff --git a/src/WebApi/ReferenceData/IDiscogsClient.cs b/src/WebApi/ReferenceData/IDiscogsClient.cs new file mode 100644 index 00000000..838bd5d0 --- /dev/null +++ b/src/WebApi/ReferenceData/IDiscogsClient.cs @@ -0,0 +1,32 @@ +namespace Keeptrack.WebApi.ReferenceData; + +/// +/// One Discogs master-release search hit - title, year, artist and cover art, enough for automatic +/// matching or for an admin to pick from when a match is ambiguous. +/// +public record DiscogsSearchResult(string ExternalId, string Title, int? Year, string? Artist, string? ImageUrl); + +public record DiscogsAlbumDetails(string ExternalId, string Title, int? Year, string? Synopsis, string? Artist, string? ArtistExternalId, List Genres, string? ImageUrl, List Tracks); + +/// +/// One tracklist entry from Discogs' /masters/{id} response - isn't +/// always numeric (vinyl releases use side+track like "A1"/"B2"), and is +/// Discogs' own "M:SS" text, often absent. +/// +public record DiscogsTrack(string Position, string Title, string? Duration); + +/// +/// Thin wrapper over the Discogs REST API. Interface exists so tests use a fake - never call the real +/// Discogs API from a test. Search is restricted to type=master so results resolve to a canonical +/// release grouping rather than every individual pressing/reissue. +/// +public interface IDiscogsClient +{ + /// + /// narrows the query when known (Discogs' own artist search field) - + /// without it, a common album title can return dozens of unrelated results from other artists. + /// + Task> SearchAlbumsAsync(string title, int? year, string? artist = null, CancellationToken cancellationToken = default); + + Task GetAlbumDetailsAsync(string externalId, CancellationToken cancellationToken = default); +} diff --git a/src/WebApi/ReferenceData/IRawgClient.cs b/src/WebApi/ReferenceData/IRawgClient.cs new file mode 100644 index 00000000..788d501c --- /dev/null +++ b/src/WebApi/ReferenceData/IRawgClient.cs @@ -0,0 +1,20 @@ +namespace Keeptrack.WebApi.ReferenceData; + +/// +/// One RAWG search hit - title, year and cover art, enough for automatic matching or for an admin +/// to pick from when a match is ambiguous. +/// +public record RawgSearchResult(string ExternalId, string Title, int? Year, string? ImageUrl); + +public record RawgGameDetails(string ExternalId, string Title, int? Year, string? Synopsis, List Genres, List Platforms, string? ImageUrl); + +/// +/// Thin wrapper over the RAWG Video Games Database REST API. Interface exists so tests use a fake - +/// never call the real RAWG API from a test. +/// +public interface IRawgClient +{ + Task> SearchGamesAsync(string title, int? year, CancellationToken cancellationToken = default); + + Task GetGameDetailsAsync(string externalId, CancellationToken cancellationToken = default); +} diff --git a/src/WebApi/ReferenceData/ITmdbClient.cs b/src/WebApi/ReferenceData/ITmdbClient.cs new file mode 100644 index 00000000..6342485c --- /dev/null +++ b/src/WebApi/ReferenceData/ITmdbClient.cs @@ -0,0 +1,50 @@ +namespace Keeptrack.WebApi.ReferenceData; + +/// +/// One TMDB search hit - title, year and a short synopsis, enough for automatic matching or for an +/// admin to pick from when a match is ambiguous. +/// +public record TmdbSearchResult(string TmdbId, string Title, int? Year, string? Synopsis, string? PosterUrl); + +public record TmdbEpisode(int SeasonNumber, int EpisodeNumber, string Title, DateOnly? AirDate); + +public record TmdbTvShowDetails(string TmdbId, string Title, int? Year, string? Synopsis, List Episodes, List Genres, string? PosterUrl); + +public record TmdbMovieDetails(string TmdbId, string Title, int? Year, string? Synopsis, List Genres, string? PosterUrl); + +/// +/// One credited cast member - is TMDB's person id, used to deduplicate +/// actors across every show/movie that credits them (see PersonReferenceModel). +/// +public record TmdbCastMember(string PersonTmdbId, string Name, string CharacterName, int Order, string? ProfileImageUrl); + +/// +/// Thin wrapper over the TMDB REST API. Interface exists so tests use a fake - never call the real +/// TMDB API from a test. +/// +public interface ITmdbClient +{ + Task> SearchTvShowAsync(string title, int? year, CancellationToken cancellationToken = default); + + Task> SearchMovieAsync(string title, int? year, CancellationToken cancellationToken = default); + + Task GetTvShowDetailsAsync(string tmdbId, CancellationToken cancellationToken = default); + + Task GetMovieDetailsAsync(string tmdbId, CancellationToken cancellationToken = default); + + Task> GetTvShowCastAsync(string tmdbId, CancellationToken cancellationToken = default); + + Task> GetMovieCastAsync(string tmdbId, CancellationToken cancellationToken = default); + + /// + /// Whether TMDB has recorded any change to this show since - a cheap (one + /// call, no season fan-out) pre-check the periodic reference sync uses to skip a full re-fetch of + /// unchanged shows. + /// + Task HasTvShowChangedSinceAsync(string tmdbId, DateTime since, CancellationToken cancellationToken = default); + + /// + /// Movie equivalent of . + /// + Task HasMovieChangedSinceAsync(string tmdbId, DateTime since, CancellationToken cancellationToken = default); +} diff --git a/src/WebApi/ReferenceData/OpenLibraryClient.cs b/src/WebApi/ReferenceData/OpenLibraryClient.cs new file mode 100644 index 00000000..a63a0eb6 --- /dev/null +++ b/src/WebApi/ReferenceData/OpenLibraryClient.cs @@ -0,0 +1,201 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using System.Web; + +namespace Keeptrack.WebApi.ReferenceData; + +/// +/// Open Library REST client. No API key required; registered as a typed with a +/// descriptive User-Agent header (Open Library's stated best practice for API consumers) - see Program.cs. +/// +public class OpenLibraryClient(HttpClient http) : IBookReferenceClient +{ + public string ProviderKey => "openlibrary"; + + /// + /// Deliberately does NOT filter server-side by : Open Library's + /// first_publish_year is the work's ORIGINAL publication year, which routinely differs from + /// whatever edition/printing year a tenant recorded (e.g. a 1997 first edition vs. a 2016 reprint) - + /// filtering on it would silently drop the real match instead of just ranking it lower. This is an + /// Open-Library-specific workaround, not a rule every must follow. + /// + public async Task> SearchBooksAsync(string title, int? year, string? author = null, CancellationToken cancellationToken = default) + { + var results = await SearchBooksCoreAsync(title, author, cancellationToken); + if (results.Count == 0 && !string.IsNullOrEmpty(author)) + { + // same "an optional narrowing parameter must not silently produce zero results" lesson as the + // year filter above: an author string that doesn't exactly match Open Library's own indexing + // (a middle name, a diacritic, "and" vs "&") can zero out results the title alone would find - + // see DiscogsClient.SearchAlbumsAsync for the equivalent fallback with the same rationale. + results = await SearchBooksCoreAsync(title, null, cancellationToken); + } + + return results; + } + + private async Task> SearchBooksCoreAsync(string title, string? author, CancellationToken cancellationToken) + { + // General relevance query (q=), not the title= field-scoped match: title= only matches a work's own + // canonical title text, which misses regional title variants entirely - confirmed against the real + // API for "Harry Potter and the Sorcerer's Stone" (the US title): title= only finds a handful of + // near-empty 1-edition work stubs, because Open Library's canonical work for this book is titled + // "Harry Potter and the Philosopher's Stone" (the UK title) with 398 editions - q= surfaces that + // well-populated canonical work first instead, since it ranks by relevance across alternate titles + // too, not just an exact field match. year is intentionally never sent as a query filter here - see + // this class's own SearchBooksAsync doc comment above. + var query = $"search.json?q={Encode(title)}" + (string.IsNullOrEmpty(author) ? "" : $"&author={Encode(author)}"); + var response = await http.GetFromJsonAsync(query, cancellationToken); + return response?.Docs + .Where(d => !string.IsNullOrEmpty(d.Key)) + .Select(d => new BookSearchResult(d.Key!, d.Title ?? title, d.FirstPublishYear, d.AuthorName.FirstOrDefault(), BuildCoverUrl(d.CoverId))) + .ToList() ?? []; + } + + public async Task GetBookDetailsAsync(string externalId, CancellationToken cancellationToken = default) + { + var work = await http.GetFromJsonAsync($"{externalId}.json", cancellationToken); + if (work is null) return null; + + var authorKey = work.Authors.Select(a => a.Author?.Key).FirstOrDefault(k => !string.IsNullOrEmpty(k)); + string? authorName = null; + string? authorExternalId = null; + if (!string.IsNullOrEmpty(authorKey)) + { + authorExternalId = authorKey.Split('/').Last(); + var author = await http.GetFromJsonAsync($"{authorKey}.json", cancellationToken); + authorName = author?.Name; + } + + var year = ParseYear(work.FirstPublishDate) ?? await FindPublishYearViaSearchAsync(externalId, cancellationToken); + + return new BookDetails( + externalId, + work.Title ?? string.Empty, + year, + ExtractDescription(work.Description), + authorName, + authorExternalId, + work.Subjects.Take(MaxGenres).ToList(), + BuildCoverUrl(work.Covers.FirstOrDefault())); + } + + /// + /// Falls back to the search index's first_publish_year when the work's own JSON has no + /// first_publish_date at all - confirmed against the real API this is common even for + /// well-known books (e.g. Lee Child's "Killing Floor", OL24477958W, has no first_publish_date + /// on its work document), while the search index's computed first_publish_year is reliable. + /// A single-document, id-scoped query (q=key:{workKey}), not a general title search - the exact + /// same work document, just re-fetched from the index instead of the object endpoint. + /// + private async Task FindPublishYearViaSearchAsync(string workKey, CancellationToken cancellationToken) + { + var response = await http.GetFromJsonAsync($"search.json?q={Encode($"key:{workKey}")}", cancellationToken); + return response?.Docs.FirstOrDefault()?.FirstPublishYear; + } + + /// + /// Open Library's work description field is documented as either a plain JSON string or an + /// object shaped { "type": "/type/text", "value": "..." } - both shapes are handled here so the + /// wire model can stay a plain instead of a custom converter. + /// + private static string? ExtractDescription(JsonElement? description) => description?.ValueKind switch + { + JsonValueKind.String => description.Value.GetString(), + JsonValueKind.Object when description.Value.TryGetProperty("value", out var value) => value.GetString(), + _ => null + }; + + private const int MaxGenres = 5; + + private static string Encode(string value) => HttpUtility.UrlEncode(value); + + /// + /// Matches a standalone 4-digit token (word-boundary delimited, so it can't match the first 4 digits of + /// a longer run) - first_publish_date is often "Month Day, Year" (e.g. "November 12, 1972"), and + /// naively stripping all digits and taking the first 4 previously mis-parsed that as 1219 (day "12" then + /// the leading digits of "1972") instead of 1972 - confirmed against the real API for Tolkien's "The + /// Fellowship of the Ring" (OL27513W). The year is the last such token, since it's always what trails + /// the day/month when both are present, and the only token when the date is a bare year. + /// + private static readonly Regex s_yearRegex = new(@"\b\d{4}\b", RegexOptions.Compiled); + + private static int? ParseYear(string? date) + { + if (string.IsNullOrEmpty(date)) return null; + var matches = s_yearRegex.Matches(date); + return matches.Count > 0 && int.TryParse(matches[^1].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var year) ? year : null; + } + + /// + /// Open Library's cover CDN is a separate, unauthenticated static-asset host explicitly meant for + /// direct hotlinking - same pattern as TMDB's image CDN, just this provider's own host/id shape. + /// + private static string? BuildCoverUrl(int? coverId) => + coverId is null or 0 ? null : $"https://covers.openlibrary.org/b/id/{coverId}-L.jpg"; + + private sealed class OpenLibrarySearchResponse + { + [JsonPropertyName("docs")] + public List Docs { get; set; } = []; + } + + private sealed class OpenLibrarySearchDoc + { + [JsonPropertyName("key")] + public string? Key { get; set; } + + [JsonPropertyName("title")] + public string? Title { get; set; } + + [JsonPropertyName("first_publish_year")] + public int? FirstPublishYear { get; set; } + + [JsonPropertyName("author_name")] + public List AuthorName { get; set; } = []; + + [JsonPropertyName("cover_i")] + public int? CoverId { get; set; } + } + + private sealed class OpenLibraryWorkResponse + { + [JsonPropertyName("title")] + public string? Title { get; set; } + + [JsonPropertyName("description")] + public JsonElement? Description { get; set; } + + [JsonPropertyName("first_publish_date")] + public string? FirstPublishDate { get; set; } + + [JsonPropertyName("subjects")] + public List Subjects { get; set; } = []; + + [JsonPropertyName("covers")] + public List Covers { get; set; } = []; + + [JsonPropertyName("authors")] + public List Authors { get; set; } = []; + } + + private sealed class OpenLibraryWorkAuthor + { + [JsonPropertyName("author")] + public OpenLibraryAuthorKey? Author { get; set; } + } + + private sealed class OpenLibraryAuthorKey + { + [JsonPropertyName("key")] + public string? Key { get; set; } + } + + private sealed class OpenLibraryAuthorResponse + { + [JsonPropertyName("name")] + public string? Name { get; set; } + } +} diff --git a/src/WebApi/ReferenceData/RawgClient.cs b/src/WebApi/ReferenceData/RawgClient.cs new file mode 100644 index 00000000..e676fd6b --- /dev/null +++ b/src/WebApi/ReferenceData/RawgClient.cs @@ -0,0 +1,101 @@ +using System.Globalization; +using System.Text.Json.Serialization; +using System.Web; + +namespace Keeptrack.WebApi.ReferenceData; + +/// +/// RAWG Video Games Database REST client. Configured as a typed (see Program.cs), +/// with the api key appended as a query parameter on every request, same convention as . +/// +public class RawgClient(HttpClient http, RawgSettings settings) : IRawgClient +{ + public async Task> SearchGamesAsync(string title, int? year, CancellationToken cancellationToken = default) + { + var query = $"games?key={ApiKey}&search={Encode(title)}&page_size={MaxResults}" + (year is null ? "" : $"&dates={year}-01-01,{year}-12-31"); + var response = await http.GetFromJsonAsync(query, cancellationToken); + return response?.Results.Select(r => new RawgSearchResult( + r.Id.ToString(CultureInfo.InvariantCulture), r.Name ?? title, ParseYear(r.Released), r.BackgroundImage)).ToList() ?? []; + } + + public async Task GetGameDetailsAsync(string externalId, CancellationToken cancellationToken = default) + { + var details = await http.GetFromJsonAsync($"games/{externalId}?key={ApiKey}", cancellationToken); + return details is null + ? null + : new RawgGameDetails( + externalId, details.Name ?? string.Empty, ParseYear(details.Released), details.DescriptionRaw, + details.Genres.Select(g => g.Name).ToList(), + details.Platforms.Select(p => p.Platform?.Name).OfType().ToList(), + details.BackgroundImage); + } + + private const int MaxResults = 5; + + private string ApiKey => settings.ApiKey; + + private static string Encode(string value) => HttpUtility.UrlEncode(value); + + private static int? ParseYear(string? date) => + !string.IsNullOrEmpty(date) && DateOnly.TryParse(date, CultureInfo.InvariantCulture, out var parsed) ? parsed.Year : null; + + private sealed class RawgSearchResponse + { + [JsonPropertyName("results")] + public List Results { get; set; } = []; + } + + private sealed class RawgSearchItem + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("released")] + public string? Released { get; set; } + + [JsonPropertyName("background_image")] + public string? BackgroundImage { get; set; } + } + + private sealed class RawgGameDetailsResponse + { + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("released")] + public string? Released { get; set; } + + [JsonPropertyName("description_raw")] + public string? DescriptionRaw { get; set; } + + [JsonPropertyName("background_image")] + public string? BackgroundImage { get; set; } + + [JsonPropertyName("genres")] + public List Genres { get; set; } = []; + + [JsonPropertyName("platforms")] + public List Platforms { get; set; } = []; + } + + private sealed class RawgGenre + { + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + } + + private sealed class RawgPlatformWrapper + { + [JsonPropertyName("platform")] + public RawgPlatform? Platform { get; set; } + } + + private sealed class RawgPlatform + { + [JsonPropertyName("name")] + public string? Name { get; set; } + } +} diff --git a/src/WebApi/ReferenceData/RawgSettings.cs b/src/WebApi/ReferenceData/RawgSettings.cs new file mode 100644 index 00000000..356c7b34 --- /dev/null +++ b/src/WebApi/ReferenceData/RawgSettings.cs @@ -0,0 +1,6 @@ +namespace Keeptrack.WebApi.ReferenceData; + +public class RawgSettings +{ + public required string ApiKey { get; set; } +} diff --git a/src/WebApi/ReferenceData/ReferenceDataAdminController.cs b/src/WebApi/ReferenceData/ReferenceDataAdminController.cs new file mode 100644 index 00000000..814ddbfa --- /dev/null +++ b/src/WebApi/ReferenceData/ReferenceDataAdminController.cs @@ -0,0 +1,342 @@ +using System.IO.Compression; +using System.Text.Json; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.WebApi.Controllers; +using Keeptrack.WebApi.Jobs; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Keeptrack.WebApi.ReferenceData; + +/// +/// Lets an admin/maintainer resolve titles the automatic match couldn't confidently handle (ambiguous or +/// zero results), across every reference-backed domain (TV shows, movies, books, video games, albums). +/// Not per-tenant CRUD, so this doesn't extend . +/// +[ApiController] +[Authorize(Policy = "AdminOnly")] +[Route("api/reference-data")] +public class ReferenceDataAdminController( + ITvShowRepository tvShowRepository, + IMovieRepository movieRepository, + IBookRepository bookRepository, + IVideoGameRepository videoGameRepository, + IAlbumRepository albumRepository, + ITmdbClient tmdbClient, + IBookReferenceClient bookReferenceClient, + IRawgClient rawgClient, + IDiscogsClient discogsClient, + ReferenceEnrichmentService enrichmentService, + JobStore syncJobStore, + IServiceScopeFactory scopeFactory, + ITvShowReferenceRepository tvShowReferenceRepository, + IMovieReferenceRepository movieReferenceRepository, + IPersonReferenceRepository personReferenceRepository, + IBookReferenceRepository bookReferenceRepository, + IVideoGameReferenceRepository videoGameReferenceRepository, + IAlbumReferenceRepository albumReferenceRepository) : ControllerBase +{ + private const string TvShowEntryName = "tvshow_reference.json"; + private const string MovieEntryName = "movie_reference.json"; + private const string PersonEntryName = "person_reference.json"; + private const string BookEntryName = "book_reference.json"; + private const string VideoGameEntryName = "videogame_reference.json"; + private const string AlbumEntryName = "album_reference.json"; + + /// + /// Every reference document (TV shows, movies, cast, books, video games, albums) as a zip, so an admin + /// can seed a fresh environment's reference data without re-earning every match one search at a time. + /// + [HttpGet("export")] + [ProducesResponseType(200)] + public async Task Export() + { + var tvShows = await tvShowReferenceRepository.FindAllAsync(); + var movies = await movieReferenceRepository.FindAllAsync(); + var people = await personReferenceRepository.FindAllAsync(); + var books = await bookReferenceRepository.FindAllAsync(); + var videoGames = await videoGameReferenceRepository.FindAllAsync(); + var albums = await albumReferenceRepository.FindAllAsync(); + + var buffer = new MemoryStream(); + await using (var archive = new ZipArchive(buffer, ZipArchiveMode.Create, leaveOpen: true)) + { + await WriteJsonEntryAsync(archive, TvShowEntryName, tvShows); + await WriteJsonEntryAsync(archive, MovieEntryName, movies); + await WriteJsonEntryAsync(archive, PersonEntryName, people); + await WriteJsonEntryAsync(archive, BookEntryName, books); + await WriteJsonEntryAsync(archive, VideoGameEntryName, videoGames); + await WriteJsonEntryAsync(archive, AlbumEntryName, albums); + } + + buffer.Position = 0; + return File(buffer, "application/zip", "keeptrack-reference-data.zip"); + } + + /// + /// Idempotent (upsert-by-id) re-import of a previously exported zip - re-running the same import + /// twice is a no-op the second time, since every document already carries the id it was exported with. + /// + [HttpPost("import")] + [RequestSizeLimit(50_000_000)] + [Consumes("multipart/form-data")] + [ProducesResponseType(200)] + [ProducesResponseType(400)] + public async Task> Import(IFormFile file) + { + if (file.Length == 0) return BadRequest(); + + await using var uploadStream = file.OpenReadStream(); + await using var archive = new ZipArchive(uploadStream, ZipArchiveMode.Read); + + var tvShowCount = 0; + var movieCount = 0; + var personCount = 0; + var bookCount = 0; + var videoGameCount = 0; + var albumCount = 0; + + foreach (var show in await ReadJsonEntryAsync(archive, TvShowEntryName)) + { + await tvShowReferenceRepository.UpsertAsync(show); + tvShowCount++; + } + + foreach (var movie in await ReadJsonEntryAsync(archive, MovieEntryName)) + { + await movieReferenceRepository.UpsertAsync(movie); + movieCount++; + } + + foreach (var person in await ReadJsonEntryAsync(archive, PersonEntryName)) + { + await personReferenceRepository.UpsertAsync(person); + personCount++; + } + + foreach (var book in await ReadJsonEntryAsync(archive, BookEntryName)) + { + await bookReferenceRepository.UpsertAsync(book); + bookCount++; + } + + foreach (var videoGame in await ReadJsonEntryAsync(archive, VideoGameEntryName)) + { + await videoGameReferenceRepository.UpsertAsync(videoGame); + videoGameCount++; + } + + foreach (var album in await ReadJsonEntryAsync(archive, AlbumEntryName)) + { + await albumReferenceRepository.UpsertAsync(album); + albumCount++; + } + + return Ok(new ReferenceDataImportResultDto + { + TvShowCount = tvShowCount, + MovieCount = movieCount, + PersonCount = personCount, + BookCount = bookCount, + VideoGameCount = videoGameCount, + AlbumCount = albumCount + }); + } + + private static async Task WriteJsonEntryAsync(ZipArchive archive, string entryName, T value) + { + var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal); + await using var entryStream = await entry.OpenAsync(); + await JsonSerializer.SerializeAsync(entryStream, value); + } + + private static async Task> ReadJsonEntryAsync(ZipArchive archive, string entryName) + { + var entry = archive.GetEntry(entryName); + if (entry is null) return []; + + await using var entryStream = await entry.OpenAsync(); + return await JsonSerializer.DeserializeAsync>(entryStream) ?? []; + } + + /// + /// Starts an immediate re-check of every reference document, regardless of how recently it was last + /// enriched - the same logic the periodic background sync runs on a schedule (see + /// ), just triggered on demand instead of waiting. Runs in + /// the background; poll with the returned job id for progress - a full + /// re-check across five domains can easily exceed a single request/response's own timeout. + /// + [HttpPost("sync-now")] + [ProducesResponseType(202)] + public ActionResult SyncNow() + { + var jobId = syncJobStore.Create(this.GetUserId(), ReferenceSyncStage.SyncingTvShows); + + _ = RunSyncJobAsync(jobId); + + return Accepted(new ReferenceSyncJobDto { JobId = jobId }); + } + + /// + /// Current status of a previously started sync job. + /// + [HttpGet("sync-now/{jobId:guid}")] + [ProducesResponseType(200)] + [ProducesResponseType(404)] + public ActionResult GetSyncStatus(Guid jobId) + { + var status = syncJobStore.GetStatus(jobId, this.GetUserId()); + if (status is null) return NotFound(); + + return Ok(new ReferenceSyncJobStatusDto { Stage = status.Value.Stage, Result = status.Value.Result, ErrorMessage = status.Value.ErrorMessage }); + } + + /// + /// Runs the sync on a background task using its own DI scope - the request that started it has + /// already completed by the time this runs, so it can't reuse the request's scoped + /// instance. + /// + private async Task RunSyncJobAsync(Guid jobId) + { + using var scope = scopeFactory.CreateScope(); + var scopedSyncService = scope.ServiceProvider.GetRequiredService(); + + try + { + var result = await scopedSyncService.SyncStaleReferencesAsync(TimeSpan.Zero, stage => syncJobStore.UpdateStage(jobId, stage)); + syncJobStore.Complete(jobId, ReferenceSyncStage.Completed, result); + } + catch (Exception ex) + { + syncJobStore.Fail(jobId, ReferenceSyncStage.Failed, ex.Message); + } + } + + /// + /// Distinct (title, year) pairs, across every tenant, still missing a reference-data link. + /// + [HttpGet("unresolved")] + [ProducesResponseType(200)] + public async Task>> GetUnresolved([FromQuery] ReferenceItemType type) + { + var pairs = type switch + { + ReferenceItemType.TvShow => await tvShowRepository.FindDistinctUnresolvedTitleYearsAsync(), + ReferenceItemType.Movie => await movieRepository.FindDistinctUnresolvedTitleYearsAsync(), + ReferenceItemType.Book => await bookRepository.FindDistinctUnresolvedTitleYearsAsync(), + ReferenceItemType.VideoGame => await videoGameRepository.FindDistinctUnresolvedTitleYearsAsync(), + ReferenceItemType.Album => await albumRepository.FindDistinctUnresolvedTitleYearsAsync(), + _ => throw new ArgumentOutOfRangeException(nameof(type)) + }; + + return Ok(pairs.Select(p => new UnresolvedReferenceDto { Type = type, Title = p.Title, Year = p.Year }).ToList()); + } + + /// + /// How many search candidates get enriched with a poster and top cast names (TV/movie only) - bounds + /// the extra per-candidate credits calls to a small, admin-facing action, not the full result page. + /// + private const int MaxEnrichedCandidates = 5; + + private const int MaxCastNamesPerCandidate = 3; + + /// + /// Live external-provider search, for an admin to pick the right candidate for an unresolved title. + /// TV show/movie candidates are additionally enriched with top-billed cast names to help tell apart + /// near-identical results (remakes, regional variants, sequels sharing a title). + /// + /// + /// is the book's author or the album's artist, when the caller has one - + /// passed straight through to the provider's own author/artist search field (see + /// /), + /// since a common title alone often returns many unrelated candidates. Ignored for TV shows/movies/video + /// games, which have no equivalent single-name creator field on this endpoint. + /// + [HttpGet("search")] + [ProducesResponseType(200)] + public async Task>> Search([FromQuery] ReferenceItemType type, [FromQuery] string title, [FromQuery] int? year, [FromQuery] string? creator = null) + { + switch (type) + { + case ReferenceItemType.TvShow: + case ReferenceItemType.Movie: + return Ok(await SearchTvShowOrMovieAsync(type, title, year)); + case ReferenceItemType.Book: + var books = await bookReferenceClient.SearchBooksAsync(title, year, creator); + return Ok(books.Take(MaxEnrichedCandidates) + .Select(r => new ReferenceSearchResultDto { ExternalId = r.ExternalId, Title = r.Title, Year = r.Year, Creator = r.Author, ImageUrl = r.ImageUrl }) + .ToList()); + case ReferenceItemType.VideoGame: + var games = await rawgClient.SearchGamesAsync(title, year); + return Ok(games.Take(MaxEnrichedCandidates) + .Select(r => new ReferenceSearchResultDto { ExternalId = r.ExternalId, Title = r.Title, Year = r.Year, ImageUrl = r.ImageUrl }) + .ToList()); + case ReferenceItemType.Album: + var albums = await discogsClient.SearchAlbumsAsync(title, year, creator); + return Ok(albums.Take(MaxEnrichedCandidates) + .Select(r => new ReferenceSearchResultDto { ExternalId = r.ExternalId, Title = r.Title, Year = r.Year, Creator = r.Artist, ImageUrl = r.ImageUrl }) + .ToList()); + default: + throw new ArgumentOutOfRangeException(nameof(type)); + } + } + + private async Task> SearchTvShowOrMovieAsync(ReferenceItemType type, string title, int? year) + { + var results = type == ReferenceItemType.TvShow + ? await tmdbClient.SearchTvShowAsync(title, year) + : await tmdbClient.SearchMovieAsync(title, year); + + var dtos = new List(); + foreach (var result in results.Take(MaxEnrichedCandidates)) + { + var cast = type == ReferenceItemType.TvShow + ? await tmdbClient.GetTvShowCastAsync(result.TmdbId) + : await tmdbClient.GetMovieCastAsync(result.TmdbId); + + dtos.Add(new ReferenceSearchResultDto + { + ExternalId = result.TmdbId, + Title = result.Title, + Year = result.Year, + Synopsis = result.Synopsis, + ImageUrl = result.PosterUrl, + TopCastNames = cast.OrderBy(c => c.Order).Take(MaxCastNamesPerCandidate).Select(c => c.Name).ToList() + }); + } + + return dtos; + } + + /// + /// Links every tenant's (Title, Year) match to the chosen external provider id and fetches its full details. + /// + [HttpPost("link")] + [ProducesResponseType(204)] + public async Task Link([FromBody] LinkReferenceRequestDto request) + { + switch (request.Type) + { + case ReferenceItemType.TvShow: + await enrichmentService.ResolveTvShowAsync(request.Title, request.Year, request.ExternalId); + break; + case ReferenceItemType.Movie: + await enrichmentService.ResolveMovieAsync(request.Title, request.Year, request.ExternalId); + break; + case ReferenceItemType.Book: + await enrichmentService.ResolveBookAsync(request.Title, request.Year, request.ExternalId); + break; + case ReferenceItemType.VideoGame: + await enrichmentService.ResolveVideoGameAsync(request.Title, request.Year, request.ExternalId); + break; + case ReferenceItemType.Album: + await enrichmentService.ResolveAlbumAsync(request.Title, request.Year, request.ExternalId); + break; + default: + throw new ArgumentOutOfRangeException(nameof(request)); + } + + return NoContent(); + } +} diff --git a/src/WebApi/ReferenceData/ReferenceDataController.cs b/src/WebApi/ReferenceData/ReferenceDataController.cs new file mode 100644 index 00000000..94678078 --- /dev/null +++ b/src/WebApi/ReferenceData/ReferenceDataController.cs @@ -0,0 +1,122 @@ +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.WebApi.Mappers; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Keeptrack.WebApi.ReferenceData; + +/// +/// Read-only access to the shared reference collection, for any authenticated user (not admin-only). +/// Kept separate from / +/// deliberately - this is shared data, not the tenant's own document. +/// +[ApiController] +[Authorize] +[Route("api/reference-data")] +public class ReferenceDataController( + TvShowReferenceDtoMapper tvShowReferenceMapper, + MovieReferenceDtoMapper movieReferenceMapper, + BookReferenceDtoMapper bookReferenceMapper, + VideoGameReferenceDtoMapper videoGameReferenceMapper, + AlbumReferenceDtoMapper albumReferenceMapper, + ITvShowReferenceRepository tvShowReferenceRepository, + IMovieReferenceRepository movieReferenceRepository, + IPersonReferenceRepository personReferenceRepository, + IBookReferenceRepository bookReferenceRepository, + IVideoGameReferenceRepository videoGameReferenceRepository, + IAlbumReferenceRepository albumReferenceRepository) + : ControllerBase +{ + [HttpGet("tv-shows/{referenceId}")] + [ProducesResponseType(200)] + [ProducesResponseType(404)] + public async Task> GetTvShow(string referenceId) + { + var model = await tvShowReferenceRepository.FindByIdAsync(referenceId); + if (model is null) return NotFound(); + + var dto = tvShowReferenceMapper.ToDto(model); + dto.Cast = await HydrateCastAsync(model.Cast); + return Ok(dto); + } + + [HttpGet("movies/{referenceId}")] + [ProducesResponseType(200)] + [ProducesResponseType(404)] + public async Task> GetMovie(string referenceId) + { + var model = await movieReferenceRepository.FindByIdAsync(referenceId); + if (model is null) return NotFound(); + + var dto = movieReferenceMapper.ToDto(model); + dto.Cast = await HydrateCastAsync(model.Cast); + return Ok(dto); + } + + [HttpGet("books/{referenceId}")] + [ProducesResponseType(200)] + [ProducesResponseType(404)] + public async Task> GetBook(string referenceId) + { + var model = await bookReferenceRepository.FindByIdAsync(referenceId); + if (model is null) return NotFound(); + + var dto = bookReferenceMapper.ToDto(model); + (dto.AuthorName, dto.AuthorImageUrl) = await HydratePersonAsync(model.AuthorReferenceId); + return Ok(dto); + } + + [HttpGet("video-games/{referenceId}")] + [ProducesResponseType(200)] + [ProducesResponseType(404)] + public async Task> GetVideoGame(string referenceId) + { + var model = await videoGameReferenceRepository.FindByIdAsync(referenceId); + return model is null ? NotFound() : Ok(videoGameReferenceMapper.ToDto(model)); + } + + [HttpGet("albums/{referenceId}")] + [ProducesResponseType(200)] + [ProducesResponseType(404)] + public async Task> GetAlbum(string referenceId) + { + var model = await albumReferenceRepository.FindByIdAsync(referenceId); + if (model is null) return NotFound(); + + var dto = albumReferenceMapper.ToDto(model); + (dto.ArtistName, dto.ArtistImageUrl) = await HydratePersonAsync(model.ArtistReferenceId); + return Ok(dto); + } + + /// + /// Joins a single *ReferenceId (a book's author, an album's artist) against person_reference - + /// the singular counterpart to 's per-member join. + /// + private async Task<(string? Name, string? ImageUrl)> HydratePersonAsync(string? personReferenceId) + { + if (string.IsNullOrEmpty(personReferenceId)) return (null, null); + + var person = await personReferenceRepository.FindByIdAsync(personReferenceId); + return person is null ? (null, null) : (person.Name, person.ProfileImageUrl); + } + + /// + /// Joins the embedded cast list against person_reference to build the fully-hydrated DTO the client + /// renders directly - see 's Cast ignore for why this + /// can't just be a generated mapper member mapping. + /// + private async Task> HydrateCastAsync(List cast) + { + var result = new List(); + foreach (var member in cast.OrderBy(c => c.Order)) + { + var person = await personReferenceRepository.FindByIdAsync(member.PersonReferenceId); + if (person is null) continue; + + result.Add(new CastMemberDto { Name = person.Name, CharacterName = member.CharacterName, ProfileImageUrl = person.ProfileImageUrl }); + } + + return result; + } +} diff --git a/src/WebApi/ReferenceData/ReferenceEnrichmentService.Albums.cs b/src/WebApi/ReferenceData/ReferenceEnrichmentService.Albums.cs new file mode 100644 index 00000000..3adc3dab --- /dev/null +++ b/src/WebApi/ReferenceData/ReferenceEnrichmentService.Albums.cs @@ -0,0 +1,133 @@ +using Keeptrack.Common.System; +using Keeptrack.Domain.Models; + +namespace Keeptrack.WebApi.ReferenceData; + +public partial class ReferenceEnrichmentService +{ + /// + /// User-triggered "check for reference match" for albums - see + /// for the full rationale (this is the same local-only, + /// no-HTTP-call lookup, just against album_reference). A successful match also sets + /// , and to the + /// reference's canonical values - the artist's name is joined from via + /// , and Genre from + /// (joined into the same single free-text field the tenant can otherwise edit by hand). + /// + public async Task TryLinkExistingAlbumReferenceAsync(AlbumModel model) + { + var reference = await albumReferenceRepository.FindByTitleYearAsync(model.Title, model.Year, model.Artist) + ?? await albumReferenceRepository.FindByTitleAsync(model.Title, model.Artist); + + if (reference is null) + { + if (!string.IsNullOrEmpty(model.ReferenceId)) + { + model.ReferenceId = string.Empty; + await albumRepository.UpdateAsync(model.Id!, model, model.OwnerId); + } + + return model; + } + + var originalTitle = model.Title; + var originalYear = model.Year; + var artistName = await ResolvePersonNameAsync(reference.ArtistReferenceId); + var genre = JoinGenres(reference.Genres); + + model.ReferenceId = reference.Id; + model.Title = reference.Title; + if (reference.Year is not null) model.Year = reference.Year; + if (!string.IsNullOrEmpty(artistName)) model.Artist = artistName; + if (genre is not null) model.Genre = genre; + await albumRepository.UpdateAsync(model.Id!, model, model.OwnerId); + await albumRepository.SetReferenceLinkAsync(originalTitle, originalYear, reference.Id!, reference.Title, reference.Year, artistName, genre); + + return model; + } + + /// + /// Best-effort automatic match for albums - see . Passing + /// narrows the Discogs search considerably - without it, a common album + /// title easily returns more than one candidate and the match is correctly left for the admin queue. + /// + public async Task TryAutoResolveAlbumAsync(string title, int? year, string? artist = null) + { + var candidates = await discogsClient.SearchAlbumsAsync(title, year, artist); + if (candidates.Count != 1) return; + await ResolveAlbumAsync(title, year, candidates[0].ExternalId); + } + + /// + /// Resolves a title+year to a specific Discogs master id, upserts the reference document, and + /// propagates the link - see . + /// + public async Task ResolveAlbumAsync(string title, int? year, string externalId) + { + var details = await discogsClient.GetAlbumDetailsAsync(externalId) + ?? throw new InvalidOperationException($"Discogs master {externalId} could not be fetched."); + + var existing = await albumReferenceRepository.FindByExternalIdAsync("discogs", externalId) + ?? (details.Artist is not null ? await albumReferenceRepository.FindByTitleYearAsync(title, year, details.Artist) : null) + ?? (details.Artist is not null ? await albumReferenceRepository.FindByTitleAsync(title, details.Artist) : null); + var externalIds = existing?.ExternalIds ?? new Dictionary(); + externalIds["discogs"] = externalId; + + var artistReferenceId = !string.IsNullOrEmpty(details.ArtistExternalId) + ? await ResolvePersonReferenceIdAsync("discogs", details.ArtistExternalId, details.Artist ?? "Unknown", null) + : existing?.ArtistReferenceId; + + var model = new AlbumReferenceModel + { + Id = existing?.Id, + Title = details.Title, + TitleNormalized = TitleNormalizer.Normalize(details.Title), + Year = details.Year ?? year, + Synopsis = details.Synopsis, + ArtistReferenceId = artistReferenceId, + ExternalIds = externalIds, + MatchedAliases = MergeMatchedAliases(existing?.MatchedAliases, (details.Title, details.Year ?? year, details.Artist), (title, year, details.Artist)), + Genres = details.Genres, + Tracks = MapTracks(details.Tracks), + ImageUrl = details.ImageUrl, + LastEnrichedAt = DateTime.UtcNow + }; + + var saved = await albumReferenceRepository.UpsertAsync(model); + await albumRepository.SetReferenceLinkAsync(title, year, saved.Id!, details.Title, saved.Year, details.Artist, JoinGenres(details.Genres)); + return saved; + } + + /// + /// Re-fetches an album reference from Discogs, always doing a full re-fetch when called (unlike TMDB, + /// Discogs exposes no per-id "has this changed" endpoint) - see + /// for the shared staleness-cutoff mechanism this is invoked from. A no-op (returns unchanged) for a + /// reference with no Discogs id or that Discogs no longer has details for. + /// + public async Task<(AlbumReferenceModel Model, bool DataChanged)> RefreshAlbumReferenceAsync(AlbumReferenceModel reference, CancellationToken cancellationToken = default) + { + var externalId = reference.ExternalIds.GetValueOrDefault("discogs"); + if (string.IsNullOrEmpty(externalId)) return (reference, false); + + var details = await discogsClient.GetAlbumDetailsAsync(externalId, cancellationToken); + if (details is null) return (reference, false); + + reference.Title = details.Title; + reference.Year = details.Year ?? reference.Year; + reference.Synopsis = details.Synopsis; + if (!string.IsNullOrEmpty(details.ArtistExternalId)) + { + reference.ArtistReferenceId = await ResolvePersonReferenceIdAsync("discogs", details.ArtistExternalId, details.Artist ?? "Unknown", null); + } + reference.Genres = details.Genres; + reference.Tracks = MapTracks(details.Tracks); + reference.ImageUrl = details.ImageUrl ?? reference.ImageUrl; + reference.MatchedAliases = MergeMatchedAliases(reference.MatchedAliases, (details.Title, reference.Year, details.Artist)); + reference.LastEnrichedAt = DateTime.UtcNow; + + return (await albumReferenceRepository.UpsertAsync(reference), true); + } + + private static List MapTracks(List tracks) => + tracks.Select(t => new ReferenceTrackModel { Position = t.Position, Title = t.Title, Duration = t.Duration }).ToList(); +} diff --git a/src/WebApi/ReferenceData/ReferenceEnrichmentService.Books.cs b/src/WebApi/ReferenceData/ReferenceEnrichmentService.Books.cs new file mode 100644 index 00000000..d4d37472 --- /dev/null +++ b/src/WebApi/ReferenceData/ReferenceEnrichmentService.Books.cs @@ -0,0 +1,131 @@ +using Keeptrack.Common.System; +using Keeptrack.Domain.Models; + +namespace Keeptrack.WebApi.ReferenceData; + +public partial class ReferenceEnrichmentService +{ + /// + /// User-triggered "check for reference match" for books - see + /// for the full rationale (this is the same local-only, + /// no-HTTP-call lookup, just against book_reference). A successful match also sets + /// , and to the + /// reference's canonical values - the author's name is joined from via + /// , and Genre from + /// (joined into the same single free-text field the tenant can otherwise edit by hand). + /// + public async Task TryLinkExistingBookReferenceAsync(BookModel model) + { + var reference = await bookReferenceRepository.FindByTitleYearAsync(model.Title, model.Year, model.Author) + ?? await bookReferenceRepository.FindByTitleAsync(model.Title, model.Author); + + if (reference is null) + { + if (!string.IsNullOrEmpty(model.ReferenceId)) + { + model.ReferenceId = string.Empty; + await bookRepository.UpdateAsync(model.Id!, model, model.OwnerId); + } + + return model; + } + + var originalTitle = model.Title; + var originalYear = model.Year; + var authorName = await ResolvePersonNameAsync(reference.AuthorReferenceId); + var genre = JoinGenres(reference.Genres); + + model.ReferenceId = reference.Id; + model.Title = reference.Title; + if (reference.Year is not null) model.Year = reference.Year; + if (!string.IsNullOrEmpty(authorName)) model.Author = authorName; + if (genre is not null) model.Genre = genre; + await bookRepository.UpdateAsync(model.Id!, model, model.OwnerId); + await bookRepository.SetReferenceLinkAsync(originalTitle, originalYear, reference.Id!, reference.Title, reference.Year, authorName, genre); + + return model; + } + + /// + /// Best-effort automatic match for books - see . Passing + /// narrows the configured book provider's search considerably - without it, + /// a common title easily returns more than one candidate and the match is correctly left for the + /// admin queue. + /// + public async Task TryAutoResolveBookAsync(string title, int? year, string? author = null) + { + var candidates = await bookReferenceClient.SearchBooksAsync(title, year, author); + if (candidates.Count != 1) return; + await ResolveBookAsync(title, year, candidates[0].ExternalId); + } + + /// + /// Resolves a title+year to a specific book provider id, upserts the reference document, and + /// propagates the link - see . + /// + public async Task ResolveBookAsync(string title, int? year, string externalId) + { + var details = await bookReferenceClient.GetBookDetailsAsync(externalId) + ?? throw new InvalidOperationException($"Book {externalId} could not be fetched from {bookReferenceClient.ProviderKey}."); + + var existing = await bookReferenceRepository.FindByExternalIdAsync(bookReferenceClient.ProviderKey, externalId) + ?? (details.Author is not null ? await bookReferenceRepository.FindByTitleYearAsync(title, year, details.Author) : null) + ?? (details.Author is not null ? await bookReferenceRepository.FindByTitleAsync(title, details.Author) : null); + var externalIds = existing?.ExternalIds ?? new Dictionary(); + externalIds[bookReferenceClient.ProviderKey] = externalId; + + var authorReferenceId = !string.IsNullOrEmpty(details.AuthorExternalId) + ? await ResolvePersonReferenceIdAsync(bookReferenceClient.ProviderKey, details.AuthorExternalId, details.Author ?? "Unknown", null) + : existing?.AuthorReferenceId; + + var model = new BookReferenceModel + { + Id = existing?.Id, + Title = details.Title, + TitleNormalized = TitleNormalizer.Normalize(details.Title), + Year = details.Year ?? year, + Synopsis = details.Synopsis, + AuthorReferenceId = authorReferenceId, + ExternalIds = externalIds, + MatchedAliases = MergeMatchedAliases(existing?.MatchedAliases, (details.Title, details.Year ?? year, details.Author), (title, year, details.Author)), + Genres = details.Genres, + ImageUrl = details.ImageUrl, + LastEnrichedAt = DateTime.UtcNow + }; + + var saved = await bookReferenceRepository.UpsertAsync(model); + await bookRepository.SetReferenceLinkAsync(title, year, saved.Id!, details.Title, saved.Year, details.Author, JoinGenres(details.Genres)); + return saved; + } + + /// + /// Re-fetches a book reference from the configured book provider, always doing a full re-fetch when + /// called (unlike TMDB, none of the book providers currently supported expose a per-id "has this + /// changed" endpoint, so there's no cheap pre-check to skip it) - see + /// for the shared staleness-cutoff mechanism this is invoked + /// from. A no-op (returns unchanged) for a reference with no id from the currently configured provider, + /// or that the provider no longer has details for. + /// + public async Task<(BookReferenceModel Model, bool DataChanged)> RefreshBookReferenceAsync(BookReferenceModel reference, CancellationToken cancellationToken = default) + { + var externalId = reference.ExternalIds.GetValueOrDefault(bookReferenceClient.ProviderKey); + if (string.IsNullOrEmpty(externalId)) return (reference, false); + + var details = await bookReferenceClient.GetBookDetailsAsync(externalId, cancellationToken); + if (details is null) return (reference, false); + + reference.Title = details.Title; + reference.Year = details.Year ?? reference.Year; + reference.Synopsis = details.Synopsis; + if (!string.IsNullOrEmpty(details.AuthorExternalId)) + { + reference.AuthorReferenceId = await ResolvePersonReferenceIdAsync(bookReferenceClient.ProviderKey, details.AuthorExternalId, details.Author ?? "Unknown", null); + } + reference.Genres = details.Genres; + reference.ImageUrl = details.ImageUrl ?? reference.ImageUrl; + reference.MatchedAliases = MergeMatchedAliases(reference.MatchedAliases, (details.Title, reference.Year, details.Author)); + reference.LastEnrichedAt = DateTime.UtcNow; + + return (await bookReferenceRepository.UpsertAsync(reference), true); + } +} diff --git a/src/WebApi/ReferenceData/ReferenceEnrichmentService.TvShowsAndMovies.cs b/src/WebApi/ReferenceData/ReferenceEnrichmentService.TvShowsAndMovies.cs new file mode 100644 index 00000000..7d21e622 --- /dev/null +++ b/src/WebApi/ReferenceData/ReferenceEnrichmentService.TvShowsAndMovies.cs @@ -0,0 +1,291 @@ +using Keeptrack.Common.System; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; + +namespace Keeptrack.WebApi.ReferenceData; + +public partial class ReferenceEnrichmentService +{ + /// + /// TMDB credits routinely list dozens of cast members; only the top-billed cast is shown on a + /// show/movie page, so only that many are fetched into the reference document. + /// + private const int MaxCastMembers = 15; + + /// + /// User-triggered "check for reference match" - looks only at the local reference collection (title+year, + /// falling back to title-only, against every (title, year) combination ever confirmed for that reference - + /// see ), never TMDB. Cheap enough to run on demand from a + /// detail page: no HTTP call, just an indexed Mongo lookup. Deliberately does NOT short-circuit when the + /// model already has a link: the whole point is to let a tenant who isn't happy with the current match + /// fix the title/year and re-check, replacing a wrong link - "don't guess" only applies to inventing a + /// match from nothing, not to re-verifying one the tenant explicitly asked to redo. Updates only this + /// tenant's own document directly (not the broad cross-tenant , + /// which refuses to touch already-linked documents by design), but still calls that method with the + /// pre-edit title/year afterward so any other still-unresolved tenant sharing that text benefits too. + /// A successful match also sets to the reference's own canonical year + /// (when it has one) - the tenant can still edit it afterward, but it's better pre-populated with a + /// trustworthy value than left at whatever the tenant originally guessed. If no match is found for the + /// current title/year and the document WAS linked, the link is cleared rather than left pointing at + /// something the tenant just told us (by editing the title) is wrong - clearing ReferenceId is + /// also exactly what puts it back into the admin's unresolved queue + /// () for a manual TMDB search. + /// + public async Task TryLinkExistingTvShowReferenceAsync(TvShowModel model) + { + var reference = await tvShowReferenceRepository.FindByTitleYearAsync(model.Title, model.Year) + ?? await tvShowReferenceRepository.FindByTitleAsync(model.Title); + + if (reference is null) + { + if (!string.IsNullOrEmpty(model.ReferenceId)) + { + model.ReferenceId = string.Empty; + await tvShowRepository.UpdateAsync(model.Id!, model, model.OwnerId); + } + + return model; + } + + var originalTitle = model.Title; + var originalYear = model.Year; + + model.ReferenceId = reference.Id; + model.Title = reference.Title; + if (reference.Year is not null) model.Year = reference.Year; + await tvShowRepository.UpdateAsync(model.Id!, model, model.OwnerId); + await tvShowRepository.SetReferenceLinkAsync(originalTitle, originalYear, reference.Id!, reference.Title, reference.Year); + + return model; + } + + /// + /// Movie equivalent of . + /// + public async Task TryLinkExistingMovieReferenceAsync(MovieModel model) + { + var reference = await movieReferenceRepository.FindByTitleYearAsync(model.Title, model.Year) + ?? await movieReferenceRepository.FindByTitleAsync(model.Title); + + if (reference is null) + { + if (!string.IsNullOrEmpty(model.ReferenceId)) + { + model.ReferenceId = string.Empty; + await movieRepository.UpdateAsync(model.Id!, model, model.OwnerId); + } + + return model; + } + + var originalTitle = model.Title; + var originalYear = model.Year; + + model.ReferenceId = reference.Id; + model.Title = reference.Title; + if (reference.Year is not null) model.Year = reference.Year; + await movieRepository.UpdateAsync(model.Id!, model, model.OwnerId); + await movieRepository.SetReferenceLinkAsync(originalTitle, originalYear, reference.Id!, reference.Title, reference.Year); + + return model; + } + + /// + /// Best-effort automatic match: does nothing if the search returns zero or more than one candidate, + /// leaving the show unresolved for the admin queue instead of guessing. + /// + public async Task TryAutoResolveTvShowAsync(string title, int? year) + { + var candidates = await tmdbClient.SearchTvShowAsync(title, year); + if (candidates.Count != 1) return; + await ResolveTvShowAsync(title, year, candidates[0].TmdbId); + } + + /// + /// Best-effort automatic match for movies - see . + /// + public async Task TryAutoResolveMovieAsync(string title, int? year) + { + var candidates = await tmdbClient.SearchMovieAsync(title, year); + if (candidates.Count != 1) return; + await ResolveMovieAsync(title, year, candidates[0].TmdbId); + } + + /// + /// Resolves a title+year to a specific TMDB show id (an admin's manual pick, or the single + /// confident automatic match), upserts the reference document, and propagates the link. + /// + public async Task ResolveTvShowAsync(string title, int? year, string tmdbId) + { + var details = await tmdbClient.GetTvShowDetailsAsync(tmdbId) + ?? throw new InvalidOperationException($"TMDB show {tmdbId} could not be fetched."); + var cast = await tmdbClient.GetTvShowCastAsync(tmdbId); + + // tmdbId is checked first and is authoritative: two tenants resolving the exact same TMDB show under + // different title text (a translation, a typo an admin corrected) must reuse the same reference + // document, not create a duplicate - title/year matching alone can't guarantee that, only the id can. + var existing = await tvShowReferenceRepository.FindByExternalIdAsync("tmdb", tmdbId) + ?? await tvShowReferenceRepository.FindByTitleYearAsync(title, year) + ?? await tvShowReferenceRepository.FindByTitleAsync(title); + var externalIds = existing?.ExternalIds ?? new Dictionary(); + externalIds["tmdb"] = tmdbId; + + var model = new TvShowReferenceModel + { + Id = existing?.Id, + Title = details.Title, + TitleNormalized = TitleNormalizer.Normalize(details.Title), + Year = details.Year ?? year, + Synopsis = details.Synopsis, + ExternalIds = externalIds, + // remembers both the canonical (TMDB title, TMDB year) and whatever (title, year) the tenant + // actually searched with - see MatchedAliases: this is what lets a later, differently-titled or + // differently-dated tenant match instantly + MatchedAliases = MergeMatchedAliases(existing?.MatchedAliases, (details.Title, details.Year ?? year, null), (title, year, null)), + Episodes = details.Episodes + .Select(e => new ReferenceEpisodeModel { SeasonNumber = e.SeasonNumber, EpisodeNumber = e.EpisodeNumber, Title = e.Title, AirDate = e.AirDate }) + .ToList(), + Genres = details.Genres, + Cast = await ResolveCastAsync(cast), + ImageUrl = details.PosterUrl, + LastEnrichedAt = DateTime.UtcNow + }; + + var saved = await tvShowReferenceRepository.UpsertAsync(model); + await tvShowRepository.SetReferenceLinkAsync(title, year, saved.Id!, details.Title, saved.Year); + return saved; + } + + /// + /// Movie equivalent of . + /// + public async Task ResolveMovieAsync(string title, int? year, string tmdbId) + { + var details = await tmdbClient.GetMovieDetailsAsync(tmdbId) + ?? throw new InvalidOperationException($"TMDB movie {tmdbId} could not be fetched."); + var cast = await tmdbClient.GetMovieCastAsync(tmdbId); + + // tmdbId is checked first and is authoritative: two tenants resolving the exact same TMDB movie under + // different title text (a translation, a typo an admin corrected) must reuse the same reference + // document, not create a duplicate - title/year matching alone can't guarantee that, only the id can. + var existing = await movieReferenceRepository.FindByExternalIdAsync("tmdb", tmdbId) + ?? await movieReferenceRepository.FindByTitleYearAsync(title, year) + ?? await movieReferenceRepository.FindByTitleAsync(title); + var externalIds = existing?.ExternalIds ?? new Dictionary(); + externalIds["tmdb"] = tmdbId; + + var model = new MovieReferenceModel + { + Id = existing?.Id, + Title = details.Title, + TitleNormalized = TitleNormalizer.Normalize(details.Title), + Year = details.Year ?? year, + Synopsis = details.Synopsis, + ExternalIds = externalIds, + // remembers both the canonical (TMDB title, TMDB year) and whatever (title, year) the tenant + // actually searched with - see MatchedAliases: this is what lets a later, differently-titled or + // differently-dated tenant match instantly + MatchedAliases = MergeMatchedAliases(existing?.MatchedAliases, (details.Title, details.Year ?? year, null), (title, year, null)), + Genres = details.Genres, + Cast = await ResolveCastAsync(cast), + ImageUrl = details.PosterUrl, + LastEnrichedAt = DateTime.UtcNow + }; + + var saved = await movieReferenceRepository.UpsertAsync(model); + await movieRepository.SetReferenceLinkAsync(title, year, saved.Id!, details.Title, saved.Year); + return saved; + } + + /// + /// Re-fetches a TV show reference from TMDB if anything has changed since + /// (skipping the expensive per-season episode fan-out when it hasn't), and always bumps LastEnrichedAt + /// so the periodic sync doesn't keep re-checking an up-to-date document every run. A no-op (returns + /// unchanged) for a reference with no TMDB id or that TMDB no longer has details for. + /// + public async Task<(TvShowReferenceModel Model, bool DataChanged)> RefreshTvShowReferenceAsync(TvShowReferenceModel reference, CancellationToken cancellationToken = default) + { + var tmdbId = reference.ExternalIds.GetValueOrDefault("tmdb"); + if (string.IsNullOrEmpty(tmdbId)) return (reference, false); + + if (reference.LastEnrichedAt is not null) + { + var changed = await tmdbClient.HasTvShowChangedSinceAsync(tmdbId, reference.LastEnrichedAt.Value, cancellationToken); + if (!changed) + { + reference.LastEnrichedAt = DateTime.UtcNow; + return (await tvShowReferenceRepository.UpsertAsync(reference), false); + } + } + + var details = await tmdbClient.GetTvShowDetailsAsync(tmdbId, cancellationToken); + if (details is null) return (reference, false); + var cast = await tmdbClient.GetTvShowCastAsync(tmdbId, cancellationToken); + + reference.Title = details.Title; + reference.Year = details.Year ?? reference.Year; + reference.Synopsis = details.Synopsis; + reference.Episodes = details.Episodes + .Select(e => new ReferenceEpisodeModel { SeasonNumber = e.SeasonNumber, EpisodeNumber = e.EpisodeNumber, Title = e.Title, AirDate = e.AirDate }) + .ToList(); + reference.Genres = details.Genres; + reference.Cast = await ResolveCastAsync(cast); + reference.ImageUrl = details.PosterUrl ?? reference.ImageUrl; + reference.MatchedAliases = MergeMatchedAliases(reference.MatchedAliases, (details.Title, reference.Year, null)); + reference.LastEnrichedAt = DateTime.UtcNow; + + return (await tvShowReferenceRepository.UpsertAsync(reference), true); + } + + /// + /// Movie equivalent of . + /// + public async Task<(MovieReferenceModel Model, bool DataChanged)> RefreshMovieReferenceAsync(MovieReferenceModel reference, CancellationToken cancellationToken = default) + { + var tmdbId = reference.ExternalIds.GetValueOrDefault("tmdb"); + if (string.IsNullOrEmpty(tmdbId)) return (reference, false); + + if (reference.LastEnrichedAt is not null) + { + var changed = await tmdbClient.HasMovieChangedSinceAsync(tmdbId, reference.LastEnrichedAt.Value, cancellationToken); + if (!changed) + { + reference.LastEnrichedAt = DateTime.UtcNow; + return (await movieReferenceRepository.UpsertAsync(reference), false); + } + } + + var details = await tmdbClient.GetMovieDetailsAsync(tmdbId, cancellationToken); + if (details is null) return (reference, false); + var cast = await tmdbClient.GetMovieCastAsync(tmdbId, cancellationToken); + + reference.Title = details.Title; + reference.Year = details.Year ?? reference.Year; + reference.Synopsis = details.Synopsis; + reference.Genres = details.Genres; + reference.Cast = await ResolveCastAsync(cast); + reference.ImageUrl = details.PosterUrl ?? reference.ImageUrl; + reference.MatchedAliases = MergeMatchedAliases(reference.MatchedAliases, (details.Title, reference.Year, null)); + reference.LastEnrichedAt = DateTime.UtcNow; + + return (await movieReferenceRepository.UpsertAsync(reference), true); + } + + /// + /// Upserts each cast member into the shared, owner-less person_reference collection (deduplicated by + /// TMDB person id - the same actor credited in two different shows only ever gets one document), then + /// returns the embedded cast list pointing at those documents. + /// + private async Task> ResolveCastAsync(IReadOnlyList cast) + { + var result = new List(); + + foreach (var member in cast.OrderBy(c => c.Order).Take(MaxCastMembers)) + { + var personReferenceId = await ResolvePersonReferenceIdAsync("tmdb", member.PersonTmdbId, member.Name, member.ProfileImageUrl); + result.Add(new CastMemberModel { PersonReferenceId = personReferenceId, CharacterName = member.CharacterName, Order = member.Order }); + } + + return result; + } +} diff --git a/src/WebApi/ReferenceData/ReferenceEnrichmentService.VideoGames.cs b/src/WebApi/ReferenceData/ReferenceEnrichmentService.VideoGames.cs new file mode 100644 index 00000000..2a117b31 --- /dev/null +++ b/src/WebApi/ReferenceData/ReferenceEnrichmentService.VideoGames.cs @@ -0,0 +1,114 @@ +using Keeptrack.Common.System; +using Keeptrack.Domain.Models; + +namespace Keeptrack.WebApi.ReferenceData; + +public partial class ReferenceEnrichmentService +{ + /// + /// User-triggered "check for reference match" for video games - see + /// for the full rationale (this is the same local-only, + /// no-HTTP-call lookup, just against videogame_reference). A successful match also sets + /// to the reference's canonical year. + /// is never touched - each entry describes this tenant's own copy/progress on that platform, not the + /// canonical release. + /// + public async Task TryLinkExistingVideoGameReferenceAsync(VideoGameModel model) + { + var reference = await videoGameReferenceRepository.FindByTitleYearAsync(model.Title, model.Year) + ?? await videoGameReferenceRepository.FindByTitleAsync(model.Title); + + if (reference is null) + { + if (!string.IsNullOrEmpty(model.ReferenceId)) + { + model.ReferenceId = string.Empty; + await videoGameRepository.UpdateAsync(model.Id!, model, model.OwnerId); + } + + return model; + } + + var originalTitle = model.Title; + var originalYear = model.Year; + + model.ReferenceId = reference.Id; + model.Title = reference.Title; + if (reference.Year is not null) model.Year = reference.Year; + await videoGameRepository.UpdateAsync(model.Id!, model, model.OwnerId); + await videoGameRepository.SetReferenceLinkAsync(originalTitle, originalYear, reference.Id!, reference.Title, reference.Year); + + return model; + } + + /// + /// Best-effort automatic match for video games - see . + /// + public async Task TryAutoResolveVideoGameAsync(string title, int? year) + { + var candidates = await rawgClient.SearchGamesAsync(title, year); + if (candidates.Count != 1) return; + await ResolveVideoGameAsync(title, year, candidates[0].ExternalId); + } + + /// + /// Resolves a title+year to a specific RAWG game id, upserts the reference document, and propagates + /// the link - see . + /// + public async Task ResolveVideoGameAsync(string title, int? year, string externalId) + { + var details = await rawgClient.GetGameDetailsAsync(externalId) + ?? throw new InvalidOperationException($"RAWG game {externalId} could not be fetched."); + + var existing = await videoGameReferenceRepository.FindByExternalIdAsync("rawg", externalId) + ?? await videoGameReferenceRepository.FindByTitleYearAsync(title, year) + ?? await videoGameReferenceRepository.FindByTitleAsync(title); + var externalIds = existing?.ExternalIds ?? new Dictionary(); + externalIds["rawg"] = externalId; + + var model = new VideoGameReferenceModel + { + Id = existing?.Id, + Title = details.Title, + TitleNormalized = TitleNormalizer.Normalize(details.Title), + Year = details.Year ?? year, + Synopsis = details.Synopsis, + Platforms = details.Platforms, + ExternalIds = externalIds, + MatchedAliases = MergeMatchedAliases(existing?.MatchedAliases, (details.Title, details.Year ?? year, null), (title, year, null)), + Genres = details.Genres, + ImageUrl = details.ImageUrl, + LastEnrichedAt = DateTime.UtcNow + }; + + var saved = await videoGameReferenceRepository.UpsertAsync(model); + await videoGameRepository.SetReferenceLinkAsync(title, year, saved.Id!, details.Title, saved.Year); + return saved; + } + + /// + /// Re-fetches a video game reference from RAWG, always doing a full re-fetch when called (unlike TMDB, + /// RAWG exposes no per-id "has this changed" endpoint) - see + /// for the shared staleness-cutoff mechanism this is invoked from. A no-op (returns unchanged) for a + /// reference with no RAWG id or that RAWG no longer has details for. + /// + public async Task<(VideoGameReferenceModel Model, bool DataChanged)> RefreshVideoGameReferenceAsync(VideoGameReferenceModel reference, CancellationToken cancellationToken = default) + { + var externalId = reference.ExternalIds.GetValueOrDefault("rawg"); + if (string.IsNullOrEmpty(externalId)) return (reference, false); + + var details = await rawgClient.GetGameDetailsAsync(externalId, cancellationToken); + if (details is null) return (reference, false); + + reference.Title = details.Title; + reference.Year = details.Year ?? reference.Year; + reference.Synopsis = details.Synopsis; + reference.Platforms = details.Platforms; + reference.Genres = details.Genres; + reference.ImageUrl = details.ImageUrl ?? reference.ImageUrl; + reference.MatchedAliases = MergeMatchedAliases(reference.MatchedAliases, (details.Title, reference.Year, null)); + reference.LastEnrichedAt = DateTime.UtcNow; + + return (await videoGameReferenceRepository.UpsertAsync(reference), true); + } +} diff --git a/src/WebApi/ReferenceData/ReferenceEnrichmentService.cs b/src/WebApi/ReferenceData/ReferenceEnrichmentService.cs new file mode 100644 index 00000000..bf9e3d0f --- /dev/null +++ b/src/WebApi/ReferenceData/ReferenceEnrichmentService.cs @@ -0,0 +1,111 @@ +using Keeptrack.Common.System; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; + +namespace Keeptrack.WebApi.ReferenceData; + +/// +/// Resolves a tracked item's title+year to a shared reference document and propagates its id to every +/// tenant's matching document. Shared by both the automatic (best-effort, background) path and the +/// admin manual-linking path so the "resolve and propagate" logic is never duplicated between them - +/// only how an external id gets picked differs. Split into one partial-class file per domain +/// (.TvShowsAndMovies.cs, .Books.cs, .VideoGames.cs, .Albums.cs) since each +/// domain's five-method template (TryLinkExisting/TryAutoResolve/Resolve/Refresh) is sizeable on its own; +/// this file holds the shared constructor and the one truly cross-domain helper, . +/// +public partial class ReferenceEnrichmentService( + ITmdbClient tmdbClient, + IBookReferenceClient bookReferenceClient, + IRawgClient rawgClient, + IDiscogsClient discogsClient, + ITvShowReferenceRepository tvShowReferenceRepository, + IMovieReferenceRepository movieReferenceRepository, + IPersonReferenceRepository personReferenceRepository, + IBookReferenceRepository bookReferenceRepository, + IVideoGameReferenceRepository videoGameReferenceRepository, + IAlbumReferenceRepository albumReferenceRepository, + ITvShowRepository tvShowRepository, + IMovieRepository movieRepository, + IBookRepository bookRepository, + IVideoGameRepository videoGameRepository, + IAlbumRepository albumRepository) +{ + /// + /// Combines whatever (title, year, creator) combinations a reference document already remembered with + /// the new ones just confirmed (e.g. the provider's canonical (title, year) and the (title, year) the + /// tenant actually searched with, which may differ from canonical in either field). Deduplicated, with + /// title/creator normalized. Shared by every domain - the alias shape () + /// is deliberately generic, not per-domain. ' Creator is null for + /// TV show/movie/video game (no creator dimension in their match key); Book/Album always pass their + /// resolved author/artist text - see for why it matters there. + /// + /// + /// The dedup check compares Creator directly (no null/empty-string normalization needed here): + /// DataStorageMappingProfile's ReferenceMatchModel -> ReferenceMatch map opts + /// Creator out of the profile-wide AllowNullDestinationValues = false (.ForMember(x => + /// x.Creator, opt => opt.AllowNull())), so a null Creator round-trips through Mongo as an + /// actual BSON null, not "" - keeping that distinction the database layer's job instead of a + /// workaround here. Getting this wrong once already duplicated an alias on every re-resolve/re-refresh + /// (confirmed against a real video game reference, RAWG's "God of War", that had accumulated an exact + /// duplicate this way) - see `scripts/dedupe-matched-aliases.js` for the one-off cleanup this needed. + /// + private static List MergeMatchedAliases(List? existing, params (string Title, int? Year, string? Creator)[] aliases) + { + var result = new List(existing ?? []); + foreach (var (title, year, creator) in aliases) + { + var normalized = TitleNormalizer.Normalize(title); + var normalizedCreator = creator is null ? null : TitleNormalizer.Normalize(creator); + if (!result.Any(m => m.Title == normalized && m.Year == year && m.Creator == normalizedCreator)) + { + result.Add(new Domain.Models.ReferenceMatchModel { Title = normalized, Year = year, Creator = normalizedCreator }); + } + } + + return result; + } + + /// + /// Dedupes a single credited individual/group into the shared, owner-less person_reference + /// collection by external provider id, returning the id of the (possibly just-created) document. + /// Shared by TV/movie cast (, one call per credited member), book authors, + /// and album artists - "Person" already meant "a named individual or group identified by an external + /// provider id", not "actor" specifically, so extending its use here needed no rename, just reuse. + /// + private async Task ResolvePersonReferenceIdAsync(string provider, string externalId, string name, string? imageUrl) + { + var existing = await personReferenceRepository.FindByExternalIdAsync(provider, externalId); + var person = new PersonReferenceModel + { + Id = existing?.Id, + Name = name, + ProfileImageUrl = imageUrl ?? existing?.ProfileImageUrl, + ExternalIds = existing?.ExternalIds ?? new Dictionary { [provider] = externalId } + }; + var saved = await personReferenceRepository.UpsertAsync(person); + return saved.Id!; + } + + /// + /// Looks up a person_reference document's by id - the inverse + /// of , used when propagating a book's author/album's artist + /// name onto the tenant's own document (see / + /// ). + /// + private async Task ResolvePersonNameAsync(string? personReferenceId) + { + if (string.IsNullOrEmpty(personReferenceId)) return null; + var person = await personReferenceRepository.FindByIdAsync(personReferenceId); + return person?.Name; + } + + /// + /// Joins a reference document's Genres list into the single free-text Genre field Book/Album + /// tenants own (there's no equivalent list on those two models - Genre there is a plain user-editable + /// string, same shape as / before linking). + /// Null (not overwritten) when the reference has no genres, same "don't overwrite with nothing" rule + /// / already + /// apply to Author/Artist/Year. + /// + private static string? JoinGenres(List genres) => genres.Count > 0 ? string.Join(", ", genres) : null; +} diff --git a/src/WebApi/ReferenceData/ReferenceSyncBackgroundService.cs b/src/WebApi/ReferenceData/ReferenceSyncBackgroundService.cs new file mode 100644 index 00000000..5e627917 --- /dev/null +++ b/src/WebApi/ReferenceData/ReferenceSyncBackgroundService.cs @@ -0,0 +1,42 @@ +namespace Keeptrack.WebApi.ReferenceData; + +/// +/// Keeps reference data fresh without any external scheduler (no Kubernetes CronJob, no separate worker +/// process): a plain in-process loop, ticking on its own DI scope since the +/// hosted service itself is a singleton. An admin can also force an immediate run via +/// POST /api/reference-data/sync-now (see ), which calls +/// the same - this loop only decides *when* to run it, not *how*. +/// +public class ReferenceSyncBackgroundService( + IServiceScopeFactory scopeFactory, IConfiguration configuration, ILogger logger) : BackgroundService +{ + private static readonly TimeSpan Interval = TimeSpan.FromHours(24); + private static readonly TimeSpan StaleAfter = TimeSpan.FromDays(3); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(Interval); + do + { + // checked on every tick (rather than once at startup) so the integration test host - which + // overrides Features:IsReferenceSyncEnabled to false to keep tests from firing real TMDB calls + // against shared test data - never runs this, regardless of when that override is merged in + // relative to this service's own construction. + if (!new AppConfiguration(configuration).IsReferenceSyncEnabled) continue; + + try + { + using var scope = scopeFactory.CreateScope(); + var syncService = scope.ServiceProvider.GetRequiredService(); + var result = await syncService.SyncStaleReferencesAsync(StaleAfter, cancellationToken: stoppingToken); + logger.LogInformation( + "Reference sync: {TvShowsChecked} TV show(s) checked ({TvShowsUpdated} updated), {MoviesChecked} movie(s) checked ({MoviesUpdated} updated).", + result.TvShowsChecked, result.TvShowsUpdated, result.MoviesChecked, result.MoviesUpdated); + } + catch (Exception ex) + { + logger.LogError(ex, "Reference sync run failed."); + } + } while (await timer.WaitForNextTickAsync(stoppingToken)); + } +} diff --git a/src/WebApi/ReferenceData/ReferenceSyncService.cs b/src/WebApi/ReferenceData/ReferenceSyncService.cs new file mode 100644 index 00000000..10e7a6a1 --- /dev/null +++ b/src/WebApi/ReferenceData/ReferenceSyncService.cs @@ -0,0 +1,117 @@ +using Keeptrack.Domain.Repositories; + +namespace Keeptrack.WebApi.ReferenceData; + +/// +/// Keeps the shared reference collections up to date with TMDB after their initial resolution - TMDB +/// data (episode air dates, genres, posters, cast) isn't static, so a show/movie resolved months ago can +/// drift out of date otherwise. Shared by the periodic background sync () +/// and the admin's on-demand "sync now" action, so both go through the exact same logic. +/// +public class ReferenceSyncService( + ITvShowReferenceRepository tvShowReferenceRepository, + IMovieReferenceRepository movieReferenceRepository, + IBookReferenceRepository bookReferenceRepository, + IVideoGameReferenceRepository videoGameReferenceRepository, + IAlbumReferenceRepository albumReferenceRepository, + ReferenceEnrichmentService enrichmentService, + ILogger logger) +{ + /// + /// Refreshes every reference document whose LastEnrichedAt is older than + /// (or unset). A failure on one document is logged and skipped rather than aborting the whole run - one + /// bad TMDB response shouldn't block every other show/movie from being checked. + /// + public async Task SyncStaleReferencesAsync(TimeSpan staleAfter, Action? onStageChanged = null, CancellationToken cancellationToken = default) + { + var cutoff = DateTime.UtcNow - staleAfter; + var result = new ReferenceSyncResultDto(); + + onStageChanged?.Invoke(ReferenceSyncStage.SyncingTvShows); + foreach (var reference in await tvShowReferenceRepository.FindAllAsync()) + { + if (reference.LastEnrichedAt is not null && reference.LastEnrichedAt > cutoff) continue; + + result.TvShowsChecked++; + try + { + var (_, changed) = await enrichmentService.RefreshTvShowReferenceAsync(reference, cancellationToken); + if (changed) result.TvShowsUpdated++; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to refresh TV show reference {ReferenceId}", reference.Id); + } + } + + onStageChanged?.Invoke(ReferenceSyncStage.SyncingMovies); + foreach (var reference in await movieReferenceRepository.FindAllAsync()) + { + if (reference.LastEnrichedAt is not null && reference.LastEnrichedAt > cutoff) continue; + + result.MoviesChecked++; + try + { + var (_, changed) = await enrichmentService.RefreshMovieReferenceAsync(reference, cancellationToken); + if (changed) result.MoviesUpdated++; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to refresh movie reference {ReferenceId}", reference.Id); + } + } + + onStageChanged?.Invoke(ReferenceSyncStage.SyncingBooks); + foreach (var reference in await bookReferenceRepository.FindAllAsync()) + { + if (reference.LastEnrichedAt is not null && reference.LastEnrichedAt > cutoff) continue; + + result.BooksChecked++; + try + { + var (_, changed) = await enrichmentService.RefreshBookReferenceAsync(reference, cancellationToken); + if (changed) result.BooksUpdated++; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to refresh book reference {ReferenceId}", reference.Id); + } + } + + onStageChanged?.Invoke(ReferenceSyncStage.SyncingVideoGames); + foreach (var reference in await videoGameReferenceRepository.FindAllAsync()) + { + if (reference.LastEnrichedAt is not null && reference.LastEnrichedAt > cutoff) continue; + + result.VideoGamesChecked++; + try + { + var (_, changed) = await enrichmentService.RefreshVideoGameReferenceAsync(reference, cancellationToken); + if (changed) result.VideoGamesUpdated++; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to refresh video game reference {ReferenceId}", reference.Id); + } + } + + onStageChanged?.Invoke(ReferenceSyncStage.SyncingAlbums); + foreach (var reference in await albumReferenceRepository.FindAllAsync()) + { + if (reference.LastEnrichedAt is not null && reference.LastEnrichedAt > cutoff) continue; + + result.AlbumsChecked++; + try + { + var (_, changed) = await enrichmentService.RefreshAlbumReferenceAsync(reference, cancellationToken); + if (changed) result.AlbumsUpdated++; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to refresh album reference {ReferenceId}", reference.Id); + } + } + + return result; + } +} diff --git a/src/WebApi/ReferenceData/TmdbClient.cs b/src/WebApi/ReferenceData/TmdbClient.cs new file mode 100644 index 00000000..25122da4 --- /dev/null +++ b/src/WebApi/ReferenceData/TmdbClient.cs @@ -0,0 +1,242 @@ +using System.Globalization; +using System.Text.Json.Serialization; +using System.Web; + +namespace Keeptrack.WebApi.ReferenceData; + +/// +/// TMDB v3 REST client. The first server-side outbound third-party HTTP call in the repo - +/// configured as a typed (see Program.cs), +/// with the api key appended as a query parameter on every request, matching TMDB's v3 authentication scheme. +/// +public class TmdbClient(HttpClient http, TmdbSettings settings) : ITmdbClient +{ + public async Task> SearchTvShowAsync(string title, int? year, CancellationToken cancellationToken = default) + { + var query = $"search/tv?api_key={ApiKey}&query={Encode(title)}" + (year is null ? "" : $"&first_air_date_year={year}"); + var response = await http.GetFromJsonAsync(query, cancellationToken); + return response?.Results.Select(r => new TmdbSearchResult( + r.Id.ToString(CultureInfo.InvariantCulture), r.Name ?? title, ParseYear(r.FirstAirDate), r.Overview, BuildImageUrl(r.PosterPath, PosterImageSize))).ToList() ?? []; + } + + public async Task> SearchMovieAsync(string title, int? year, CancellationToken cancellationToken = default) + { + var query = $"search/movie?api_key={ApiKey}&query={Encode(title)}" + (year is null ? "" : $"&year={year}"); + var response = await http.GetFromJsonAsync(query, cancellationToken); + return response?.Results.Select(r => new TmdbSearchResult( + r.Id.ToString(CultureInfo.InvariantCulture), r.Title ?? title, ParseYear(r.ReleaseDate), r.Overview, BuildImageUrl(r.PosterPath, PosterImageSize))).ToList() ?? []; + } + + public async Task GetTvShowDetailsAsync(string tmdbId, CancellationToken cancellationToken = default) + { + var details = await http.GetFromJsonAsync($"tv/{tmdbId}?api_key={ApiKey}", cancellationToken); + if (details is null) return null; + + var episodes = new List(); + foreach (var season in details.Seasons.Where(s => s.SeasonNumber > 0)) + { + var seasonDetails = await http.GetFromJsonAsync( + $"tv/{tmdbId}/season/{season.SeasonNumber}?api_key={ApiKey}", cancellationToken); + if (seasonDetails is null) continue; + + episodes.AddRange(seasonDetails.Episodes.Select(e => + new TmdbEpisode(season.SeasonNumber, e.EpisodeNumber, e.Name ?? $"Episode {e.EpisodeNumber}", ParseDate(e.AirDate)))); + } + + return new TmdbTvShowDetails( + tmdbId, details.Name ?? string.Empty, ParseYear(details.FirstAirDate), details.Overview, episodes, + details.Genres.Select(g => g.Name).ToList(), BuildImageUrl(details.PosterPath, PosterImageSize)); + } + + public async Task GetMovieDetailsAsync(string tmdbId, CancellationToken cancellationToken = default) + { + var details = await http.GetFromJsonAsync($"movie/{tmdbId}?api_key={ApiKey}", cancellationToken); + return details is null + ? null + : new TmdbMovieDetails( + tmdbId, details.Title ?? string.Empty, ParseYear(details.ReleaseDate), details.Overview, + details.Genres.Select(g => g.Name).ToList(), BuildImageUrl(details.PosterPath, PosterImageSize)); + } + + public async Task> GetTvShowCastAsync(string tmdbId, CancellationToken cancellationToken = default) => + await GetCastAsync($"tv/{tmdbId}/credits", cancellationToken); + + public async Task> GetMovieCastAsync(string tmdbId, CancellationToken cancellationToken = default) => + await GetCastAsync($"movie/{tmdbId}/credits", cancellationToken); + + private async Task> GetCastAsync(string path, CancellationToken cancellationToken) + { + var credits = await http.GetFromJsonAsync($"{path}?api_key={ApiKey}", cancellationToken); + return credits?.Cast.Select(c => new TmdbCastMember( + c.Id.ToString(CultureInfo.InvariantCulture), c.Name ?? string.Empty, c.Character ?? string.Empty, c.Order, + BuildImageUrl(c.ProfilePath, ProfileImageSize))).ToList() ?? []; + } + + public Task HasTvShowChangedSinceAsync(string tmdbId, DateTime since, CancellationToken cancellationToken = default) => + HasChangedSinceAsync("tv", tmdbId, since, cancellationToken); + + public Task HasMovieChangedSinceAsync(string tmdbId, DateTime since, CancellationToken cancellationToken = default) => + HasChangedSinceAsync("movie", tmdbId, since, cancellationToken); + + /// + /// TMDB's per-id "changes" endpoint (as opposed to the bulk /tv/changes, /movie/changes + /// endpoints which only cover the last 24-72h) reports whether anything changed since an arbitrary date - + /// one cheap call instead of blindly re-fetching details plus every season for a show that hasn't moved. + /// + private async Task HasChangedSinceAsync(string resourceType, string tmdbId, DateTime since, CancellationToken cancellationToken) + { + var startDate = since.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + var response = await http.GetFromJsonAsync( + $"{resourceType}/{tmdbId}/changes?api_key={ApiKey}&start_date={startDate}", cancellationToken); + return response?.Changes.Count > 0; + } + + private string ApiKey => settings.ApiKey; + + private const string PosterImageSize = "w500"; + private const string ProfileImageSize = "w185"; + + private static string Encode(string value) => HttpUtility.UrlEncode(value); + + private static int? ParseYear(string? date) => ParseDate(date)?.Year; + + private static DateOnly? ParseDate(string? date) => + !string.IsNullOrEmpty(date) && DateOnly.TryParse(date, CultureInfo.InvariantCulture, out var parsed) ? parsed : null; + + /// + /// TMDB's image CDN is a separate, unauthenticated static-asset host explicitly meant for direct + /// hotlinking (not the rate-limited API) - the standard pattern every TMDB-consuming app uses, so + /// this just builds the URL rather than downloading anything. + /// + private static string? BuildImageUrl(string? path, string size) => + string.IsNullOrEmpty(path) ? null : $"https://image.tmdb.org/t/p/{size}{path}"; + + private sealed class TmdbSearchResponse + { + [JsonPropertyName("results")] + public List Results { get; set; } = []; + } + + private sealed class TmdbSearchItem + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("title")] + public string? Title { get; set; } + + [JsonPropertyName("overview")] + public string? Overview { get; set; } + + [JsonPropertyName("first_air_date")] + public string? FirstAirDate { get; set; } + + [JsonPropertyName("release_date")] + public string? ReleaseDate { get; set; } + + [JsonPropertyName("poster_path")] + public string? PosterPath { get; set; } + } + + private sealed class TmdbTvShowDetailsResponse + { + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("overview")] + public string? Overview { get; set; } + + [JsonPropertyName("first_air_date")] + public string? FirstAirDate { get; set; } + + [JsonPropertyName("poster_path")] + public string? PosterPath { get; set; } + + [JsonPropertyName("genres")] + public List Genres { get; set; } = []; + + [JsonPropertyName("seasons")] + public List Seasons { get; set; } = []; + } + + private sealed class TmdbGenre + { + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + } + + private sealed class TmdbSeasonSummary + { + [JsonPropertyName("season_number")] + public int SeasonNumber { get; set; } + } + + private sealed class TmdbSeasonDetailsResponse + { + [JsonPropertyName("episodes")] + public List Episodes { get; set; } = []; + } + + private sealed class TmdbEpisodeWire + { + [JsonPropertyName("episode_number")] + public int EpisodeNumber { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("air_date")] + public string? AirDate { get; set; } + } + + private sealed class TmdbMovieDetailsResponse + { + [JsonPropertyName("title")] + public string? Title { get; set; } + + [JsonPropertyName("overview")] + public string? Overview { get; set; } + + [JsonPropertyName("release_date")] + public string? ReleaseDate { get; set; } + + [JsonPropertyName("poster_path")] + public string? PosterPath { get; set; } + + [JsonPropertyName("genres")] + public List Genres { get; set; } = []; + } + + private sealed class TmdbCreditsResponse + { + [JsonPropertyName("cast")] + public List Cast { get; set; } = []; + } + + private sealed class TmdbChangesResponse + { + [JsonPropertyName("changes")] + public List Changes { get; set; } = []; + } + + private sealed class TmdbCastMemberWire + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("character")] + public string? Character { get; set; } + + [JsonPropertyName("order")] + public int Order { get; set; } + + [JsonPropertyName("profile_path")] + public string? ProfilePath { get; set; } + } +} diff --git a/src/WebApi/ReferenceData/TmdbSettings.cs b/src/WebApi/ReferenceData/TmdbSettings.cs new file mode 100644 index 00000000..7211bef5 --- /dev/null +++ b/src/WebApi/ReferenceData/TmdbSettings.cs @@ -0,0 +1,6 @@ +namespace Keeptrack.WebApi.ReferenceData; + +public class TmdbSettings +{ + public required string ApiKey { get; set; } +} diff --git a/src/WebApi/WebApi.csproj b/src/WebApi/WebApi.csproj index bc78fd98..ba09f4a9 100644 --- a/src/WebApi/WebApi.csproj +++ b/src/WebApi/WebApi.csproj @@ -11,11 +11,14 @@ - + + + + diff --git a/src/WebApi/appsettings.json b/src/WebApi/appsettings.json index 6ef97305..f481fa59 100644 --- a/src/WebApi/appsettings.json +++ b/src/WebApi/appsettings.json @@ -13,7 +13,8 @@ }, "Features": { "IsScalarEnabled": false, - "IsHttpsRedirectionEnabled": true + "IsHttpsRedirectionEnabled": true, + "IsReferenceSyncEnabled": true }, "Infrastructure": { "MongoDB": { @@ -21,6 +22,18 @@ "DatabaseName": "" } }, + "Tmdb": { + "ApiKey": "" + }, + "Rawg": { + "ApiKey": "" + }, + "Discogs": { + "Token": "" + }, + "ReferenceData": { + "BookProvider": "OpenLibrary" + }, "Logging": { "LogLevel": { "Default": "Information", @@ -32,6 +45,6 @@ "OpenApi": { "Title": "Keeptrack Web API", "Description": "REST API for Keeptrack data", - "Version": "v1.1" + "Version": "v2.0" } } diff --git a/test/BlazorApp.UnitTests/Account/ReturnUrlResolverTest.cs b/test/BlazorApp.UnitTests/Account/ReturnUrlResolverTest.cs new file mode 100644 index 00000000..45e44da2 --- /dev/null +++ b/test/BlazorApp.UnitTests/Account/ReturnUrlResolverTest.cs @@ -0,0 +1,76 @@ +using System; +using AwesomeAssertions; +using Keeptrack.BlazorApp.Components.Account; +using Xunit; + +namespace Keeptrack.BlazorApp.UnitTests.Account; + +[Trait("Category", "UnitTests")] +public class ReturnUrlResolverTest +{ + private const string AppHost = "app.example:5207"; + + // Stand-ins for the production predicate (IUrlHelper.IsLocalUrl). Tests state the predicate's answer + // explicitly so each one targets the resolver's own branch logic, not IsLocalUrl's implementation. + private static readonly Func s_treatAsLocal = _ => true; + private static readonly Func s_treatAsNotLocal = _ => false; + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Resolve_ReturnsRoot_ForMissingReturnUrl(string? returnUrl) + { + ReturnUrlResolver.Resolve(returnUrl, AppHost, s_treatAsLocal).Should().Be("/"); + } + + [Fact] + public void Resolve_PassesThroughAUrlThePredicateAcceptsAsLocal() + { + ReturnUrlResolver.Resolve("/movies?page=2", AppHost, s_treatAsLocal) + .Should().Be("/movies?page=2"); + } + + [Fact] + public void Resolve_ReducesSameHostAbsoluteUrl_ToPathAndQuery() + { + // The genuine callers pass this app's own absolute NavigationManager.Uri. + ReturnUrlResolver.Resolve("https://app.example:5207/movies?page=2", AppHost, s_treatAsNotLocal) + .Should().Be("/movies?page=2"); + } + + [Fact] + public void Resolve_MatchesHostCaseInsensitively() + { + ReturnUrlResolver.Resolve("https://APP.EXAMPLE:5207/tv-shows", AppHost, s_treatAsNotLocal) + .Should().Be("/tv-shows"); + } + + [Fact] + public void Resolve_ReturnsRoot_ForADifferentHostAbsoluteUrl() + { + // The core open-redirect guard: never bounce a signed-in user off-site. + ReturnUrlResolver.Resolve("https://evil.example/movies", AppHost, s_treatAsNotLocal) + .Should().Be("/"); + } + + [Fact] + public void Resolve_ReturnsRoot_WhenUserInfoSpoofsTheHost() + { + // The parsed authority is evil.example (the part before '@' is userinfo), not the app host. + ReturnUrlResolver.Resolve("https://app.example:5207@evil.example/x", AppHost, s_treatAsNotLocal) + .Should().Be("/"); + } + + [Theory] + [InlineData("//evil.example/x")] + [InlineData("/\\evil.example")] + public void Resolve_ReturnsRoot_ForUrlsThePredicateRejects(string returnUrl) + { + // These are the dangerous shapes IUrlHelper.IsLocalUrl rejects (protocol-relative, backslash + // authority). The resolver defers that judgment to the predicate and does not smuggle them through + // the absolute-URL branch - neither parses as an absolute Uri - so they fall back to the app root. + ReturnUrlResolver.Resolve(returnUrl, AppHost, s_treatAsNotLocal) + .Should().Be("/"); + } +} diff --git a/test/BlazorApp.UnitTests/BlazorApp.UnitTests.csproj b/test/BlazorApp.UnitTests/BlazorApp.UnitTests.csproj new file mode 100644 index 00000000..89151a84 --- /dev/null +++ b/test/BlazorApp.UnitTests/BlazorApp.UnitTests.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + Exe + Keeptrack.BlazorApp.UnitTests + Keeptrack.BlazorApp.UnitTests + enable + false + true + + + + + + + + + + + + + + diff --git a/test/WebApi.IntegrationTests/Hosting/KestrelWebAppFactory.cs b/test/WebApi.IntegrationTests/Hosting/KestrelWebAppFactory.cs index f024bed5..28182180 100644 --- a/test/WebApi.IntegrationTests/Hosting/KestrelWebAppFactory.cs +++ b/test/WebApi.IntegrationTests/Hosting/KestrelWebAppFactory.cs @@ -1,9 +1,12 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Net; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; namespace Keeptrack.WebApi.IntegrationTests.Hosting; @@ -24,6 +27,21 @@ public KestrelWebAppFactory() } } + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + // the periodic reference sync fires real TMDB calls against whatever reference data the test + // Mongo instance happens to hold - undesirable noise/flakiness in a test run, not a regression to + // chase down; the app's own default (Features:IsReferenceSyncEnabled=true in appsettings.json) is + // unaffected, this only overrides the test host's configuration. ConfigureAppConfiguration (rather + // than UseSetting, which loads before appsettings.json and so gets overridden by it) is added last + // and wins. + builder.ConfigureAppConfiguration((_, config) => config.AddInMemoryCollection( + [ + new KeyValuePair("Features:IsReferenceSyncEnabled", "false") + ])); + base.ConfigureWebHost(builder); + } + public string ServerAddress { get diff --git a/test/WebApi.IntegrationTests/Resources/AlbumReferenceRepositoryTest.cs b/test/WebApi.IntegrationTests/Resources/AlbumReferenceRepositoryTest.cs new file mode 100644 index 00000000..49170c82 --- /dev/null +++ b/test/WebApi.IntegrationTests/Resources/AlbumReferenceRepositoryTest.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using AwesomeAssertions; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.Infrastructure.MongoDb.Entities; +using Keeptrack.WebApi.IntegrationTests.Hosting; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Driver; +using Xunit; + +namespace Keeptrack.WebApi.IntegrationTests.Resources; + +/// +/// Exercises / +/// against real MongoDB - same ElemMatch/MatchedAliases shape already verified for +/// (see TvShowReferenceRepositoryTest), applied to albums. +/// +public class AlbumReferenceRepositoryTest(KestrelWebAppFactory factory) : IClassFixture> +{ + [Fact] + public async Task FindByTitleYearAsync_MatchesAnAliasWhoseConfirmedYearDiffersFromTheDocumentsOwnCanonicalYear() + { + using var scope = factory.Services.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var alternateTitle = $"Alternate Album Title {Guid.NewGuid()}"; + + var created = await repository.UpsertAsync(new AlbumReferenceModel + { + Title = "Canonical Album Title", + TitleNormalized = "canonical album title", + Year = 2005, + ExternalIds = new Dictionary { ["discogs"] = "1" }, + MatchedAliases = [new ReferenceMatchModel { Title = alternateTitle.ToLowerInvariant(), Year = 2004, Creator = "some artist" }] + }); + + try + { + var found = await repository.FindByTitleYearAsync(alternateTitle, 2004, "Some Artist"); + + found.Should().NotBeNull(); + found!.Id.Should().Be(created.Id); + } + finally + { + await DeleteAsync(scope, created.Id!); + } + } + + [Fact] + public async Task FindByTitleAsync_MatchesAnAlternateTitle_IgnoringYear() + { + using var scope = factory.Services.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var alternateTitle = $"Alternate Album Title {Guid.NewGuid()}"; + + var created = await repository.UpsertAsync(new AlbumReferenceModel + { + Title = "Canonical Album Title", + TitleNormalized = "canonical album title", + Year = 2005, + ExternalIds = new Dictionary { ["discogs"] = "1" }, + MatchedAliases = [new ReferenceMatchModel { Title = alternateTitle.ToLowerInvariant(), Year = 2005, Creator = "some artist" }] + }); + + try + { + var found = await repository.FindByTitleAsync(alternateTitle, "Some Artist"); + + found.Should().NotBeNull(); + found!.Id.Should().Be(created.Id); + } + finally + { + await DeleteAsync(scope, created.Id!); + } + } + + [Fact] + public async Task UpsertAsync_AlwaysIncludesTheCanonicalTitleAndYearInMatchedAliases_EvenIfTheCallerForgot() + { + using var scope = factory.Services.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var title = $"Canonical Only Album Title {Guid.NewGuid()}"; + + var created = await repository.UpsertAsync(new AlbumReferenceModel + { + Title = title, + TitleNormalized = title.ToLowerInvariant(), + Year = 2010, + ExternalIds = new Dictionary { ["discogs"] = "1" } + }); + + try + { + // this safety-net alias has no Creator (the model only carries ArtistReferenceId, not + // denormalized text - see AlbumReferenceRepository.UpsertAsync), so it's unreachable via the + // creator-required FindByTitleAsync/FindByTitleYearAsync; assert on the stored alias directly. + var found = await repository.FindByIdAsync(created.Id!); + + found.Should().NotBeNull(); + found!.MatchedAliases.Should().ContainSingle(m => m.Title == title.ToLowerInvariant() && m.Year == 2010); + } + finally + { + await DeleteAsync(scope, created.Id!); + } + } + + private static async Task DeleteAsync(IServiceScope scope, string id) + { + var collection = scope.ServiceProvider.GetRequiredService().GetCollection("album_reference"); + await collection.DeleteOneAsync(Builders.Filter.Eq(x => x.Id, id), TestContext.Current.CancellationToken); + } +} diff --git a/test/WebApi.IntegrationTests/Resources/AlbumResourceTest.cs b/test/WebApi.IntegrationTests/Resources/AlbumResourceTest.cs new file mode 100644 index 00000000..9fc506bd --- /dev/null +++ b/test/WebApi.IntegrationTests/Resources/AlbumResourceTest.cs @@ -0,0 +1,99 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using AwesomeAssertions; +using Bogus; +using Keeptrack.Common.System; +using Keeptrack.WebApi.Contracts.Dto; +using Keeptrack.WebApi.IntegrationTests.Hosting; +using Xunit; + +namespace Keeptrack.WebApi.IntegrationTests.Resources; + +/// +/// Basic full-cycle CRUD coverage for the renamed Album type (formerly MusicAlbum) - closes +/// a gap flagged in docs/code-quality-findings.md ("MusicAlbum...still has none"), same shape as +/// . +/// +public class AlbumResourceTest(KestrelWebAppFactory factory) + : ResourceTestBase(factory) +{ + private const string ResourceEndpoint = "api/albums"; + + [Fact] + public async Task AlbumResourceFullCycle_IsOk() + { + await GetAsync($"/{ResourceEndpoint}", HttpStatusCode.Unauthorized); + + await Authenticate(); + + var input = new Faker() + .Rules((f, o) => { o.Artist = f.Random.AlphaNumeric(8); o.Title = f.Random.AlphaNumeric(14); }) + .Generate(); + var created = await PostAsync($"/{ResourceEndpoint}", input); + created.Id.Should().NotBeNullOrEmpty(); + + try + { + created.Title = "New shiny title"; + await PutAsync($"/{ResourceEndpoint}/{created.Id}", created); + + var updated = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); + updated.Should().BeEquivalentTo(created); + + var finalItems = await GetAsync>($"/{ResourceEndpoint}"); + var firstItem = finalItems.Items.FirstOrDefault(x => x.Id == updated.Id); + firstItem.Should().NotBeNull(); + firstItem.Title.Should().Be(updated.Title); + } + finally + { + await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); + } + } + + [Fact] + public async Task AlbumResourceUpdate_PersistsArtistChange_IsOk() + { + // regression test for a reported "editing the artist doesn't update the data" bug - full review of + // AlbumDetail.razor/Albums.razor/AlbumApiClient/DataCrudControllerBase/both mapper layers found + // no code-level cause (identical shape to Book's Author editing, which isn't reported as broken); this + // locks in that a PUT changing only Artist persists correctly end-to-end. + await Authenticate(); + + var created = await PostAsync($"/{ResourceEndpoint}", new AlbumDto { Title = "Artist Update Test", Artist = "Original Artist" }); + + try + { + created.Artist = "Updated Artist"; + await PutAsync($"/{ResourceEndpoint}/{created.Id}", created); + + var updated = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); + updated.Artist.Should().Be("Updated Artist"); + } + finally + { + await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); + } + } + + [Fact] + public async Task AlbumResourceSearch_FiltersToMatchingTitleOrArtist_IsOk() + { + await Authenticate(); + + var title = System.Guid.NewGuid().ToString(); + var created = await PostAsync($"/{ResourceEndpoint}", new AlbumDto { Title = title, Artist = "Search Test Artist" }); + + try + { + var results = await GetAsync>($"/{ResourceEndpoint}?search={title}"); + + results.Items.Should().ContainSingle(x => x.Id == created.Id); + } + finally + { + await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); + } + } +} diff --git a/test/WebApi.IntegrationTests/Resources/AuxiliaryResourceTest.cs b/test/WebApi.IntegrationTests/Resources/AuxiliaryResourceTest.cs index eae4444c..dd4c787c 100644 --- a/test/WebApi.IntegrationTests/Resources/AuxiliaryResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/AuxiliaryResourceTest.cs @@ -1,14 +1,14 @@ using System.Net; using System.Threading.Tasks; using AwesomeAssertions; -using Microsoft.AspNetCore.Mvc.Testing; +using Keeptrack.WebApi.IntegrationTests.Hosting; using Xunit; namespace Keeptrack.WebApi.IntegrationTests.Resources; [Trait("Category", "IntegrationTests")] -public class AuxiliaryResourceTest(WebApplicationFactory factory) - : IClassFixture> +public class AuxiliaryResourceTest(KestrelWebAppFactory factory) + : IClassFixture> { [Trait("Mode", "Readonly")] [Theory] diff --git a/test/WebApi.IntegrationTests/Resources/BookReferenceRepositoryTest.cs b/test/WebApi.IntegrationTests/Resources/BookReferenceRepositoryTest.cs new file mode 100644 index 00000000..f552435f --- /dev/null +++ b/test/WebApi.IntegrationTests/Resources/BookReferenceRepositoryTest.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using AwesomeAssertions; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.Infrastructure.MongoDb.Entities; +using Keeptrack.WebApi.IntegrationTests.Hosting; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Driver; +using Xunit; + +namespace Keeptrack.WebApi.IntegrationTests.Resources; + +/// +/// Exercises / +/// against real MongoDB - same ElemMatch/MatchedAliases shape already verified for +/// (see TvShowReferenceRepositoryTest), applied to books. +/// +public class BookReferenceRepositoryTest(KestrelWebAppFactory factory) : IClassFixture> +{ + [Fact] + public async Task FindByTitleYearAsync_MatchesAnAliasWhoseConfirmedYearDiffersFromTheDocumentsOwnCanonicalYear() + { + using var scope = factory.Services.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var alternateTitle = $"Alternate Book Title {Guid.NewGuid()}"; + + var created = await repository.UpsertAsync(new BookReferenceModel + { + Title = "Canonical Book Title", + TitleNormalized = "canonical book title", + Year = 2005, + ExternalIds = new Dictionary { ["openlibrary"] = "OL1W" }, + MatchedAliases = [new ReferenceMatchModel { Title = alternateTitle.ToLowerInvariant(), Year = 2004, Creator = "some author" }] + }); + + try + { + var found = await repository.FindByTitleYearAsync(alternateTitle, 2004, "Some Author"); + + found.Should().NotBeNull(); + found!.Id.Should().Be(created.Id); + } + finally + { + await DeleteAsync(scope, created.Id!); + } + } + + [Fact] + public async Task FindByTitleAsync_MatchesAnAlternateTitle_IgnoringYear() + { + using var scope = factory.Services.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var alternateTitle = $"Alternate Book Title {Guid.NewGuid()}"; + + var created = await repository.UpsertAsync(new BookReferenceModel + { + Title = "Canonical Book Title", + TitleNormalized = "canonical book title", + Year = 2005, + ExternalIds = new Dictionary { ["openlibrary"] = "OL1W" }, + MatchedAliases = [new ReferenceMatchModel { Title = alternateTitle.ToLowerInvariant(), Year = 2005, Creator = "some author" }] + }); + + try + { + var found = await repository.FindByTitleAsync(alternateTitle, "Some Author"); + + found.Should().NotBeNull(); + found!.Id.Should().Be(created.Id); + } + finally + { + await DeleteAsync(scope, created.Id!); + } + } + + [Fact] + public async Task UpsertAsync_AlwaysIncludesTheCanonicalTitleAndYearInMatchedAliases_EvenIfTheCallerForgot() + { + using var scope = factory.Services.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var title = $"Canonical Only Book Title {Guid.NewGuid()}"; + + var created = await repository.UpsertAsync(new BookReferenceModel + { + Title = title, + TitleNormalized = title.ToLowerInvariant(), + Year = 2010, + ExternalIds = new Dictionary { ["openlibrary"] = "OL1W" } + }); + + try + { + // this safety-net alias has no Creator (the model only carries AuthorReferenceId, not + // denormalized text - see BookReferenceRepository.UpsertAsync), so it's unreachable via the + // creator-required FindByTitleAsync/FindByTitleYearAsync; assert on the stored alias directly. + var found = await repository.FindByIdAsync(created.Id!); + + found.Should().NotBeNull(); + found!.MatchedAliases.Should().ContainSingle(m => m.Title == title.ToLowerInvariant() && m.Year == 2010); + } + finally + { + await DeleteAsync(scope, created.Id!); + } + } + + private static async Task DeleteAsync(IServiceScope scope, string id) + { + var collection = scope.ServiceProvider.GetRequiredService().GetCollection("book_reference"); + await collection.DeleteOneAsync(Builders.Filter.Eq(x => x.Id, id), TestContext.Current.CancellationToken); + } +} diff --git a/test/WebApi.IntegrationTests/Resources/BookResourceTest.cs b/test/WebApi.IntegrationTests/Resources/BookResourceTest.cs index 57c101f2..e7ea97ef 100644 --- a/test/WebApi.IntegrationTests/Resources/BookResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/BookResourceTest.cs @@ -22,8 +22,6 @@ public async Task BookResourceFullCycle_IsOk() await Authenticate(); - var initialItems = await GetAsync>($"/{ResourceEndpoint}"); - var input = new Faker() .Rules((f, o) => { o.Author = f.Random.AlphaNumeric(8); o.Title = f.Random.AlphaNumeric(14); }) .Generate(); @@ -39,7 +37,6 @@ public async Task BookResourceFullCycle_IsOk() updated.Should().BeEquivalentTo(created, x => x.Excluding(item => item.FirstReadAt)); // issue with DateTime and MongoDB var finalItems = await GetAsync>($"/{ResourceEndpoint}"); - finalItems.TotalCount.Should().BeGreaterThan(initialItems.TotalCount); var firstItem = finalItems.Items.FirstOrDefault(x => x.Id == updated.Id); firstItem.Should().NotBeNull(); firstItem.Title.Should().Be(updated.Title); @@ -49,4 +46,35 @@ public async Task BookResourceFullCycle_IsOk() await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); } } + + [Fact] + public async Task BookResourceOwnedAndWishlistedFilters_OnlyReturnMatchingItems_IsOk() + { + await Authenticate(); + + var uniqueTitle = $"OwnedWishlistTarget-{System.Guid.NewGuid():N}"; + var input = new Faker() + .Rules((f, o) => + { + o.Author = f.Random.AlphaNumeric(8); + o.Title = uniqueTitle; + o.IsOwned = true; + o.IsWishlisted = true; + }) + .Generate(); + var created = await PostAsync($"/{ResourceEndpoint}", input); + + try + { + var owned = await GetAsync>($"/{ResourceEndpoint}?IsOwned=true&search={uniqueTitle}"); + owned.Items.Should().ContainSingle(b => b.Id == created.Id); + + var wishlisted = await GetAsync>($"/{ResourceEndpoint}?IsWishlisted=true&search={uniqueTitle}"); + wishlisted.Items.Should().ContainSingle(b => b.Id == created.Id); + } + finally + { + await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); + } + } } diff --git a/test/WebApi.IntegrationTests/Resources/CarHistoryResourceTest.cs b/test/WebApi.IntegrationTests/Resources/CarHistoryResourceTest.cs new file mode 100644 index 00000000..f8b3bf40 --- /dev/null +++ b/test/WebApi.IntegrationTests/Resources/CarHistoryResourceTest.cs @@ -0,0 +1,115 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using AwesomeAssertions; +using Keeptrack.Common.System; +using Keeptrack.WebApi.Contracts.Dto; +using Keeptrack.WebApi.IntegrationTests.Hosting; +using Xunit; + +namespace Keeptrack.WebApi.IntegrationTests.Resources; + +/// +/// Basic full-cycle CRUD coverage for CarHistory, plus a regression test for the specific bug tracked +/// in docs/code-quality-findings.md: CarHistoryRepository.GetFilter used to combine two $text +/// expressions (one for CarId, one for the free-text search) in a single query, which MongoDB rejects +/// whenever both are supplied at once. +/// +public class CarHistoryResourceTest(KestrelWebAppFactory factory) + : ResourceTestBase(factory) +{ + private const string ResourceEndpoint = "api/car-history"; + + private static CarHistoryDto NewEntry(string carId, CarHistoryType eventType = CarHistoryType.Refuel) => new() + { + CarId = carId, + HistoryDate = DateTime.Today, + EventType = eventType, + Mileage = 1000, + Cost = 42.5, + Description = "Test entry" + }; + + [Fact] + public async Task CarHistoryResourceFullCycle_IsOk() + { + await GetAsync($"/{ResourceEndpoint}", HttpStatusCode.Unauthorized); + + await Authenticate(); + + // CarId is a required field on CarHistoryDto, so - same as Episode's required TvShowId - the list + // endpoint can only ever be called scoped to a car; a bare, unscoped list call isn't a real scenario + // this app has (CarDetail.razor always filters by CarId), and ASP.NET's automatic model validation + // rejects it with 400 ("The CarId field is required") before the request even reaches the repository. + var carId = Guid.NewGuid().ToString(); + var initialItems = await GetAsync>($"/{ResourceEndpoint}?CarId={carId}"); + + var created = await PostAsync($"/{ResourceEndpoint}", NewEntry(carId)); + created.Id.Should().NotBeNullOrEmpty(); + + try + { + created.Cost = 55.0; + await PutAsync($"/{ResourceEndpoint}/{created.Id}", created); + + var updated = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); + updated.Should().BeEquivalentTo(created); + + var finalItems = await GetAsync>($"/{ResourceEndpoint}?CarId={carId}"); + finalItems.TotalCount.Should().BeGreaterThan(initialItems.TotalCount); + } + finally + { + await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); + } + } + + [Fact] + public async Task CarHistoryResourceFilter_ByCarId_OnlyReturnsThatCarsEntries_IsOk() + { + await Authenticate(); + + var carId = Guid.NewGuid().ToString(); + var otherCarId = Guid.NewGuid().ToString(); + var created = await PostAsync($"/{ResourceEndpoint}", NewEntry(carId)); + var otherCreated = await PostAsync($"/{ResourceEndpoint}", NewEntry(otherCarId)); + + try + { + var results = await GetAsync>($"/{ResourceEndpoint}?CarId={carId}"); + results.Items.Should().ContainSingle(x => x.Id == created.Id); + results.Items.Should().NotContain(x => x.Id == otherCreated.Id); + } + finally + { + await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); + await DeleteAsync($"/{ResourceEndpoint}/{otherCreated.Id}"); + } + } + + /// + /// The actual regression case: supplying both a CarId filter and a free-text search at the same time used + /// to throw ("only one $text expression allowed per query") because both were built as $text clauses. + /// + [Fact] + public async Task CarHistoryResourceFilter_ByCarIdAndSearch_DoesNotThrow_IsOk() + { + await Authenticate(); + + var carId = Guid.NewGuid().ToString(); + var description = Guid.NewGuid().ToString(); + var entry = NewEntry(carId); + entry.Description = description; + var created = await PostAsync($"/{ResourceEndpoint}", entry); + + try + { + var results = await GetAsync>($"/{ResourceEndpoint}?CarId={carId}&search={description}"); + results.Items.Should().ContainSingle(x => x.Id == created.Id); + } + finally + { + await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); + } + } +} diff --git a/test/WebApi.IntegrationTests/Resources/CarResourceTest.cs b/test/WebApi.IntegrationTests/Resources/CarResourceTest.cs new file mode 100644 index 00000000..e7c5eb55 --- /dev/null +++ b/test/WebApi.IntegrationTests/Resources/CarResourceTest.cs @@ -0,0 +1,121 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using AwesomeAssertions; +using Bogus; +using Keeptrack.Common.System; +using Keeptrack.WebApi.Contracts.Dto; +using Keeptrack.WebApi.IntegrationTests.Hosting; +using Xunit; + +namespace Keeptrack.WebApi.IntegrationTests.Resources; + +/// +/// Basic full-cycle CRUD coverage for Car - closes the "Car has no controller or Blazor page" / +/// "no CRUD integration test" findings tracked in docs/code-quality-findings.md, same shape as +/// . +/// +public class CarResourceTest(KestrelWebAppFactory factory) + : ResourceTestBase(factory) +{ + private const string ResourceEndpoint = "api/cars"; + + [Fact] + public async Task CarResourceFullCycle_IsOk() + { + await GetAsync($"/{ResourceEndpoint}", HttpStatusCode.Unauthorized); + + await Authenticate(); + + var input = new Faker() + .Rules((f, o) => + { + o.Name = f.Random.AlphaNumeric(14); + o.Manufacturer = f.Vehicle.Manufacturer(); + o.Model = f.Vehicle.Model(); + o.Year = f.Random.Int(1990, 2024); + o.LicensePlate = f.Random.AlphaNumeric(8); + o.EnergyType = CarEnergyType.Combustion; + }) + .Generate(); + var created = await PostAsync($"/{ResourceEndpoint}", input); + created.Id.Should().NotBeNullOrEmpty(); + + try + { + created.Name = "New shiny name"; + await PutAsync($"/{ResourceEndpoint}/{created.Id}", created); + + var updated = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); + updated.Should().BeEquivalentTo(created); + + var finalItems = await GetAsync>($"/{ResourceEndpoint}"); + var firstItem = finalItems.Items.FirstOrDefault(x => x.Id == updated.Id); + firstItem.Should().NotBeNull(); + firstItem.Name.Should().Be(updated.Name); + } + finally + { + await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); + } + } + + /// + /// CarRepository previously had no GetFilter override at all, so search silently fell back to the base + /// class's $text query against an index that didn't even cover Car's field name (see + /// docs/code-quality-findings.md) - this proves a name search now actually finds the car. + /// + [Fact] + public async Task CarResourceSearch_FiltersByName_IsOk() + { + await Authenticate(); + + var name = System.Guid.NewGuid().ToString(); + var created = await PostAsync($"/{ResourceEndpoint}", new CarDto { Name = name }); + + try + { + var results = await GetAsync>($"/{ResourceEndpoint}?search={name}"); + results.Items.Should().ContainSingle(x => x.Id == created.Id); + } + finally + { + await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); + } + } + + [Fact] + public async Task CarResourceMetrics_ReturnsNotFound_ForACarThatDoesNotExist() + { + await Authenticate(); + + // a freshly generated, syntactically valid ObjectId that can't collide with any real document - a + // malformed id (e.g. a GUID) throws a MongoDB BSON FormatException instead of a clean 404, a + // pre-existing gap in MongoDbRepositoryBase shared by every entity type, not something introduced by + // Car; out of scope to fix here. (A fixed all-zeros id was tried first and turned out to already + // exist as fixture data in the shared integration test database, masking the actual check.) + await GetAsync($"/{ResourceEndpoint}/{MongoDB.Bson.ObjectId.GenerateNewId()}/metrics", HttpStatusCode.NotFound); + } + + [Fact] + public async Task CarResourceMetrics_ReturnsEmptyMetrics_ForACarWithNoHistoryYet() + { + await Authenticate(); + + var created = await PostAsync($"/{ResourceEndpoint}", new CarDto { Name = System.Guid.NewGuid().ToString() }); + + try + { + var metrics = await GetAsync($"/{ResourceEndpoint}/{created.Id}/metrics"); + metrics.FuelConsumption.Should().BeEmpty(); + metrics.ElectricConsumption.Should().BeEmpty(); + metrics.CostHistory.Should().BeEmpty(); + metrics.MileageWarnings.Should().BeEmpty(); + metrics.NextMaintenance.Should().BeNull(); + } + finally + { + await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); + } + } +} diff --git a/test/WebApi.IntegrationTests/Resources/HouseHistoryResourceTest.cs b/test/WebApi.IntegrationTests/Resources/HouseHistoryResourceTest.cs new file mode 100644 index 00000000..8c9a9118 --- /dev/null +++ b/test/WebApi.IntegrationTests/Resources/HouseHistoryResourceTest.cs @@ -0,0 +1,103 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using AwesomeAssertions; +using Keeptrack.Common.System; +using Keeptrack.WebApi.Contracts.Dto; +using Keeptrack.WebApi.IntegrationTests.Hosting; +using Xunit; + +namespace Keeptrack.WebApi.IntegrationTests.Resources; + +/// +/// Basic full-cycle CRUD coverage for HouseHistory, same shape as . +/// +public class HouseHistoryResourceTest(KestrelWebAppFactory factory) + : ResourceTestBase(factory) +{ + private const string ResourceEndpoint = "api/house-history"; + + private static HouseHistoryDto NewEntry(string houseId, HouseEventType eventType = HouseEventType.Maintenance) => new() + { + HouseId = houseId, + HistoryDate = DateOnly.FromDateTime(DateTime.Today), + EventType = eventType, + Cost = 42.5, + Description = "Test entry" + }; + + [Fact] + public async Task HouseHistoryResourceFullCycle_IsOk() + { + await GetAsync($"/{ResourceEndpoint}", HttpStatusCode.Unauthorized); + + await Authenticate(); + + var houseId = Guid.NewGuid().ToString(); + var initialItems = await GetAsync>($"/{ResourceEndpoint}?HouseId={houseId}"); + + var created = await PostAsync($"/{ResourceEndpoint}", NewEntry(houseId)); + created.Id.Should().NotBeNullOrEmpty(); + + try + { + created.Cost = 55.0; + await PutAsync($"/{ResourceEndpoint}/{created.Id}", created); + + var updated = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); + updated.Should().BeEquivalentTo(created); + + var finalItems = await GetAsync>($"/{ResourceEndpoint}?HouseId={houseId}"); + finalItems.TotalCount.Should().BeGreaterThan(initialItems.TotalCount); + } + finally + { + await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); + } + } + + [Fact] + public async Task HouseHistoryResourceFilter_ByHouseId_OnlyReturnsThatHousesEntries_IsOk() + { + await Authenticate(); + + var houseId = Guid.NewGuid().ToString(); + var otherHouseId = Guid.NewGuid().ToString(); + var created = await PostAsync($"/{ResourceEndpoint}", NewEntry(houseId)); + var otherCreated = await PostAsync($"/{ResourceEndpoint}", NewEntry(otherHouseId)); + + try + { + var results = await GetAsync>($"/{ResourceEndpoint}?HouseId={houseId}"); + results.Items.Should().ContainSingle(x => x.Id == created.Id); + results.Items.Should().NotContain(x => x.Id == otherCreated.Id); + } + finally + { + await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); + await DeleteAsync($"/{ResourceEndpoint}/{otherCreated.Id}"); + } + } + + [Fact] + public async Task HouseHistoryResourceFilter_ByHouseIdAndSearch_DoesNotThrow_IsOk() + { + await Authenticate(); + + var houseId = Guid.NewGuid().ToString(); + var description = Guid.NewGuid().ToString(); + var entry = NewEntry(houseId); + entry.Description = description; + var created = await PostAsync($"/{ResourceEndpoint}", entry); + + try + { + var results = await GetAsync>($"/{ResourceEndpoint}?HouseId={houseId}&search={description}"); + results.Items.Should().ContainSingle(x => x.Id == created.Id); + } + finally + { + await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); + } + } +} diff --git a/test/WebApi.IntegrationTests/Resources/HouseResourceTest.cs b/test/WebApi.IntegrationTests/Resources/HouseResourceTest.cs new file mode 100644 index 00000000..87b1a801 --- /dev/null +++ b/test/WebApi.IntegrationTests/Resources/HouseResourceTest.cs @@ -0,0 +1,127 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using AwesomeAssertions; +using Bogus; +using Keeptrack.Common.System; +using Keeptrack.WebApi.Contracts.Dto; +using Keeptrack.WebApi.IntegrationTests.Hosting; +using Xunit; + +namespace Keeptrack.WebApi.IntegrationTests.Resources; + +/// +/// Basic full-cycle CRUD coverage for House, same shape as . +/// +public class HouseResourceTest(KestrelWebAppFactory factory) + : ResourceTestBase(factory) +{ + private const string ResourceEndpoint = "api/houses"; + + [Fact] + public async Task HouseResourceFullCycle_IsOk() + { + await GetAsync($"/{ResourceEndpoint}", HttpStatusCode.Unauthorized); + + await Authenticate(); + + var input = new Faker() + .Rules((f, o) => + { + o.Name = f.Random.AlphaNumeric(14); + o.Address = f.Address.StreetAddress(); + o.City = f.Address.City(); + o.PostalCode = f.Address.ZipCode(); + o.Country = f.Address.Country(); + }) + .Generate(); + var created = await PostAsync($"/{ResourceEndpoint}", input); + created.Id.Should().NotBeNullOrEmpty(); + + try + { + created.Name = "New shiny name"; + await PutAsync($"/{ResourceEndpoint}/{created.Id}", created); + + var updated = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); + updated.Should().BeEquivalentTo(created); + + var finalItems = await GetAsync>($"/{ResourceEndpoint}"); + var firstItem = finalItems.Items.FirstOrDefault(x => x.Id == updated.Id); + firstItem.Should().NotBeNull(); + firstItem.Name.Should().Be(updated.Name); + } + finally + { + await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); + } + } + + [Fact] + public async Task HouseResourceSearch_FiltersByName_IsOk() + { + await Authenticate(); + + var name = System.Guid.NewGuid().ToString(); + var created = await PostAsync($"/{ResourceEndpoint}", new HouseDto { Name = name }); + + try + { + var results = await GetAsync>($"/{ResourceEndpoint}?search={name}"); + results.Items.Should().ContainSingle(x => x.Id == created.Id); + } + finally + { + await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); + } + } + + [Fact] + public async Task HouseResourceMetrics_ReturnsNotFound_ForAHouseThatDoesNotExist() + { + await Authenticate(); + + await GetAsync($"/{ResourceEndpoint}/{MongoDB.Bson.ObjectId.GenerateNewId()}/metrics", HttpStatusCode.NotFound); + } + + [Fact] + public async Task HouseResourceMetrics_ReturnsEmptyMetrics_ForAHouseWithNoHistoryYet() + { + await Authenticate(); + + var created = await PostAsync($"/{ResourceEndpoint}", new HouseDto { Name = System.Guid.NewGuid().ToString() }); + + try + { + var metrics = await GetAsync($"/{ResourceEndpoint}/{created.Id}/metrics"); + metrics.CostHistory.Should().BeEmpty(); + } + finally + { + await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); + } + } + + /// + /// HouseHistory is a separate top-level collection referencing its house by id (see CLAUDE.md's "Child + /// entities" section) - without HouseController.OnDeletedAsync cascading the delete, a deleted house's + /// history would be orphaned in MongoDB forever, only ever reachable via the now-gone house id. + /// + [Fact] + public async Task HouseResourceDelete_CascadesToItsHistory_IsOk() + { + await Authenticate(); + + var house = await PostAsync($"/{ResourceEndpoint}", new HouseDto { Name = System.Guid.NewGuid().ToString() }); + var entry = await PostAsync("/api/house-history", new HouseHistoryDto + { + HouseId = house.Id!, + HistoryDate = System.DateOnly.FromDateTime(System.DateTime.Today), + EventType = HouseEventType.Maintenance + }); + + await DeleteAsync($"/{ResourceEndpoint}/{house.Id}"); + + await GetAsync($"/api/house-history/{entry.Id}", HttpStatusCode.NotFound); + } +} diff --git a/test/WebApi.IntegrationTests/Resources/MovieResourceTest.cs b/test/WebApi.IntegrationTests/Resources/MovieResourceTest.cs index a9aaf726..0eb8ae34 100644 --- a/test/WebApi.IntegrationTests/Resources/MovieResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/MovieResourceTest.cs @@ -1,4 +1,5 @@ -using System.Linq; +using System; +using System.Linq; using System.Net; using System.Threading.Tasks; using AwesomeAssertions; @@ -22,8 +23,6 @@ public async Task MovieResourceLocalhostFullCycle_IsOk() await Authenticate(); - var initialItems = await GetAsync>($"/{ResourceEndpoint}"); - var input = new Faker() .Rules((f, o) => { o.Title = f.Random.AlphaNumeric(14); }) .Generate(); @@ -39,7 +38,6 @@ public async Task MovieResourceLocalhostFullCycle_IsOk() updated.Should().BeEquivalentTo(created); var finalItems = await GetAsync>($"/{ResourceEndpoint}"); - finalItems.TotalCount.Should().BeGreaterThan(initialItems.TotalCount); var firstItem = finalItems.Items.FirstOrDefault(x => x.Id == updated.Id); firstItem.Should().NotBeNull(); firstItem.Title.Should().Be(updated.Title); @@ -49,4 +47,57 @@ public async Task MovieResourceLocalhostFullCycle_IsOk() await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); } } + + [Fact] + public async Task MovieResourceSearch_FiltersToMatchingTitle_IsOk() + { + await Authenticate(); + + var uniqueTitle = $"UniqueSearchTarget-{Guid.NewGuid():N}"; + var input = new Faker().Rules((f, o) => { o.Title = uniqueTitle; }).Generate(); + var created = await PostAsync($"/{ResourceEndpoint}", input); + + try + { + var matching = await GetAsync>($"/{ResourceEndpoint}?search={uniqueTitle}"); + matching.Items.Should().Contain(m => m.Id == created.Id); + + var nonMatching = await GetAsync>($"/{ResourceEndpoint}?search={Guid.NewGuid():N}"); + nonMatching.Items.Should().NotContain(m => m.Id == created.Id); + } + finally + { + await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); + } + } + + [Fact] + public async Task MovieResourceOwnedAndWishlistedFilters_OnlyReturnMatchingItems_IsOk() + { + await Authenticate(); + + var uniqueTitle = $"OwnedWishlistTarget-{Guid.NewGuid():N}"; + var input = new Faker() + .Rules((f, o) => + { + o.Title = uniqueTitle; + o.IsOwned = true; + o.IsWishlisted = true; + }) + .Generate(); + var created = await PostAsync($"/{ResourceEndpoint}", input); + + try + { + var owned = await GetAsync>($"/{ResourceEndpoint}?IsOwned=true&search={uniqueTitle}"); + owned.Items.Should().ContainSingle(m => m.Id == created.Id); + + var wishlisted = await GetAsync>($"/{ResourceEndpoint}?IsWishlisted=true&search={uniqueTitle}"); + wishlisted.Items.Should().ContainSingle(m => m.Id == created.Id); + } + finally + { + await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); + } + } } diff --git a/test/WebApi.IntegrationTests/Resources/PersonReferenceRepositoryTest.cs b/test/WebApi.IntegrationTests/Resources/PersonReferenceRepositoryTest.cs new file mode 100644 index 00000000..cf88603b --- /dev/null +++ b/test/WebApi.IntegrationTests/Resources/PersonReferenceRepositoryTest.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using AwesomeAssertions; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.WebApi.IntegrationTests.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Keeptrack.WebApi.IntegrationTests.Resources; + +/// +/// Exercises directly against real MongoDB - the dictionary-key +/// filter behind FindByExternalIdAsync (a string field-path filter, not an expression indexer - +/// see the comment in PersonReferenceRepository) is exactly the kind of new, hand-written Mongo +/// query that has hidden real bugs in this codebase before. +/// +public class PersonReferenceRepositoryTest(KestrelWebAppFactory factory) : IClassFixture> +{ + [Fact] + public async Task UpsertAsync_ThenFindByExternalIdAsync_RoundTripsAndDeduplicatesByProviderAndId() + { + using var scope = factory.Services.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var tmdbId = Guid.NewGuid().ToString(); + + var created = await repository.UpsertAsync(new PersonReferenceModel + { + Name = "Test Actor", + ExternalIds = new Dictionary { ["tmdb"] = tmdbId } + }); + + try + { + var found = await repository.FindByExternalIdAsync("tmdb", tmdbId); + found.Should().NotBeNull(); + found!.Id.Should().Be(created.Id); + found.Name.Should().Be("Test Actor"); + + // upserting again with the same id (simulating a second show crediting the same actor) must + // update the existing document, not create a second one + var updated = await repository.UpsertAsync(new PersonReferenceModel + { + Id = created.Id, + Name = "Test Actor (updated)", + ExternalIds = new Dictionary { ["tmdb"] = tmdbId } + }); + + updated.Id.Should().Be(created.Id); + (await repository.FindByExternalIdAsync("tmdb", tmdbId))!.Name.Should().Be("Test Actor (updated)"); + } + finally + { + var collection = scope.ServiceProvider.GetRequiredService() + .GetCollection("person_reference"); + await collection.DeleteOneAsync( + MongoDB.Driver.Builders.Filter.Eq(x => x.Id, created.Id), + TestContext.Current.CancellationToken); + } + } + + [Fact] + public async Task FindByExternalIdAsync_ReturnsNull_WhenNoPersonHasThatExternalId() + { + using var scope = factory.Services.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + + var found = await repository.FindByExternalIdAsync("tmdb", Guid.NewGuid().ToString()); + + found.Should().BeNull(); + } +} diff --git a/test/WebApi.IntegrationTests/Resources/PlaylistResourceTest.cs b/test/WebApi.IntegrationTests/Resources/PlaylistResourceTest.cs new file mode 100644 index 00000000..0e079c22 --- /dev/null +++ b/test/WebApi.IntegrationTests/Resources/PlaylistResourceTest.cs @@ -0,0 +1,82 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using AwesomeAssertions; +using Bogus; +using Keeptrack.Common.System; +using Keeptrack.WebApi.Contracts.Dto; +using Keeptrack.WebApi.IntegrationTests.Hosting; +using Xunit; + +namespace Keeptrack.WebApi.IntegrationTests.Resources; + +/// +/// Basic full-cycle CRUD coverage for Playlist, same shape as , +/// plus a check that round-trips its order through a PUT - this is the +/// field that actually encodes playback order, so a bug here would silently scramble every playlist. +/// +public class PlaylistResourceTest(KestrelWebAppFactory factory) + : ResourceTestBase(factory) +{ + private const string ResourceEndpoint = "api/playlists"; + + [Fact] + public async Task PlaylistResourceFullCycle_IsOk() + { + await GetAsync($"/{ResourceEndpoint}", HttpStatusCode.Unauthorized); + + await Authenticate(); + + var input = new Faker() + .Rules((f, o) => { o.Title = f.Random.AlphaNumeric(14); }) + .Generate(); + var created = await PostAsync($"/{ResourceEndpoint}", input); + created.Id.Should().NotBeNullOrEmpty(); + + try + { + created.Title = "New shiny title"; + await PutAsync($"/{ResourceEndpoint}/{created.Id}", created); + + var updated = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); + updated.Should().BeEquivalentTo(created); + + var finalItems = await GetAsync>($"/{ResourceEndpoint}"); + var firstItem = finalItems.Items.FirstOrDefault(x => x.Id == updated.Id); + firstItem.Should().NotBeNull(); + firstItem.Title.Should().Be(updated.Title); + } + finally + { + await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); + } + } + + [Fact] + public async Task PlaylistResourceUpdate_PersistsSongIdsInOrder_IsOk() + { + await Authenticate(); + + var created = await PostAsync($"/{ResourceEndpoint}", new PlaylistDto { Title = "Order Test Playlist" }); + + try + { + created.SongIds = ["song-c", "song-a", "song-b"]; + await PutAsync($"/{ResourceEndpoint}/{created.Id}", created); + + var updated = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); + updated.SongIds.Should().ContainInOrder("song-c", "song-a", "song-b"); + + created.SongIds = ["song-a", "song-b"]; + await PutAsync($"/{ResourceEndpoint}/{created.Id}", created); + + var afterRemoval = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); + afterRemoval.SongIds.Should().ContainInOrder("song-a", "song-b"); + afterRemoval.SongIds.Should().HaveCount(2); + } + finally + { + await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); + } + } +} diff --git a/test/WebApi.IntegrationTests/Resources/ReferenceDataAdminResourceTest.cs b/test/WebApi.IntegrationTests/Resources/ReferenceDataAdminResourceTest.cs new file mode 100644 index 00000000..3a91d01f --- /dev/null +++ b/test/WebApi.IntegrationTests/Resources/ReferenceDataAdminResourceTest.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using AwesomeAssertions; +using Keeptrack.WebApi.Contracts.Dto; +using Keeptrack.WebApi.IntegrationTests.Hosting; +using Xunit; + +namespace Keeptrack.WebApi.IntegrationTests.Resources; + +/// +/// The standard test user carries the Firebase role: admin custom claim (see CONTRIBUTING.md's +/// "Admin role" section), so admin-gated endpoints can be exercised end-to-end over HTTP with the same +/// single test account - there's no separate non-admin account to prove the "AdminOnly" policy actually +/// rejects a non-admin caller. +/// +public class ReferenceDataAdminResourceTest(KestrelWebAppFactory factory) + : ResourceTestBase(factory) +{ + private static readonly TimeSpan PollTimeout = TimeSpan.FromMinutes(2); + private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(500); + + [Fact] + public async Task GetUnresolved_WithAdminRole_IsOk() + { + await Authenticate(); + + await GetAsync>("/api/reference-data/unresolved?type=TvShow"); + } + + /// + /// Exercises the full "sync now" background job lifecycle over HTTP: POST starts the job and returns + /// immediately (202 + job id) rather than blocking on every reference document, then polling GET + /// reaches a terminal stage with a result - the actual fix for the timeout reported against this + /// endpoint (see docs/code-quality-findings.md). + /// + [Fact] + public async Task SyncNow_StartsAJob_AndPollingReachesACompletedResult() + { + await Authenticate(); + + var job = await PostAsync("/api/reference-data/sync-now", null, HttpStatusCode.Accepted); + job.Should().NotBeNull(); + job!.JobId.Should().NotBeEmpty(); + + var deadline = DateTime.UtcNow + PollTimeout; + ReferenceSyncJobStatusDto status; + do + { + status = await GetAsync($"/api/reference-data/sync-now/{job.JobId}"); + if (status.Stage is ReferenceSyncStage.Completed or ReferenceSyncStage.Failed) break; + await Task.Delay(PollInterval, TestContext.Current.CancellationToken); + } while (DateTime.UtcNow < deadline); + + status.Stage.Should().Be(ReferenceSyncStage.Completed, status.ErrorMessage); + status.Result.Should().NotBeNull(); + } + + [Fact] + public async Task SyncNowStatus_ForAnUnknownJobId_IsNotFound() + { + await Authenticate(); + + await GetAsync($"/api/reference-data/sync-now/{Guid.NewGuid()}", HttpStatusCode.NotFound); + } +} diff --git a/test/WebApi.IntegrationTests/Resources/ReferenceDataExportImportTest.cs b/test/WebApi.IntegrationTests/Resources/ReferenceDataExportImportTest.cs new file mode 100644 index 00000000..2b183090 --- /dev/null +++ b/test/WebApi.IntegrationTests/Resources/ReferenceDataExportImportTest.cs @@ -0,0 +1,225 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using AwesomeAssertions; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.Infrastructure.MongoDb.Entities; +using Keeptrack.WebApi.IntegrationTests.Hosting; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Driver; +using Xunit; + +namespace Keeptrack.WebApi.IntegrationTests.Resources; + +/// +/// Exercises the reference repositories' FindAllAsync (backs the admin zip export) directly against +/// real MongoDB, and confirms re-upserting an already-exported document (the zip import path) is a true +/// no-op the second time - the whole point of "idempotent" for POST /api/reference-data/import. +/// +public class ReferenceDataExportImportTest(KestrelWebAppFactory factory) : IClassFixture> +{ + [Fact] + public async Task TvShowReferenceRepository_FindAllAsync_IncludesEveryDocument() + { + using var scope = factory.Services.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var title = $"Export Test Show {Guid.NewGuid()}"; + + var created = await repository.UpsertAsync(new TvShowReferenceModel + { + Title = title, + TitleNormalized = title.ToLowerInvariant(), + Year = 2020, + ExternalIds = new Dictionary { ["tmdb"] = "1" } + }); + + try + { + var all = await repository.FindAllAsync(); + + all.Should().Contain(m => m.Id == created.Id && m.Title == title); + } + finally + { + await DeleteAsync(scope, "tvshow_reference", created.Id!); + } + } + + [Fact] + public async Task TvShowReferenceRepository_ReimportingTheSameExportedDocument_IsANoOp() + { + using var scope = factory.Services.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var title = $"Reimport Test Show {Guid.NewGuid()}"; + + var created = await repository.UpsertAsync(new TvShowReferenceModel + { + Title = title, + TitleNormalized = title.ToLowerInvariant(), + Year = 2020, + ExternalIds = new Dictionary { ["tmdb"] = "1" } + }); + + try + { + // simulates re-running an import of a previously exported document: same id, same content + await repository.UpsertAsync(new TvShowReferenceModel + { + Id = created.Id, + Title = title, + TitleNormalized = title.ToLowerInvariant(), + Year = 2020, + ExternalIds = new Dictionary { ["tmdb"] = "1" } + }); + + var all = await repository.FindAllAsync(); + + all.Count(m => m.Id == created.Id).Should().Be(1); + } + finally + { + await DeleteAsync(scope, "tvshow_reference", created.Id!); + } + } + + [Fact] + public async Task MovieReferenceRepository_FindAllAsync_IncludesEveryDocument() + { + using var scope = factory.Services.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var title = $"Export Test Movie {Guid.NewGuid()}"; + + var created = await repository.UpsertAsync(new MovieReferenceModel + { + Title = title, + TitleNormalized = title.ToLowerInvariant(), + Year = 2020, + ExternalIds = new Dictionary { ["tmdb"] = "1" } + }); + + try + { + var all = await repository.FindAllAsync(); + + all.Should().Contain(m => m.Id == created.Id && m.Title == title); + } + finally + { + await DeleteAsync(scope, "movie_reference", created.Id!); + } + } + + [Fact] + public async Task PersonReferenceRepository_FindAllAsync_IncludesEveryDocument() + { + using var scope = factory.Services.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var tmdbId = Guid.NewGuid().ToString(); + + var created = await repository.UpsertAsync(new PersonReferenceModel + { + Name = "Export Test Actor", + ExternalIds = new Dictionary { ["tmdb"] = tmdbId } + }); + + try + { + var all = await repository.FindAllAsync(); + + all.Should().Contain(p => p.Id == created.Id && p.Name == "Export Test Actor"); + } + finally + { + await DeleteAsync(scope, "person_reference", created.Id!); + } + } + + [Fact] + public async Task BookReferenceRepository_FindAllAsync_IncludesEveryDocument() + { + using var scope = factory.Services.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var title = $"Export Test Book {Guid.NewGuid()}"; + + var created = await repository.UpsertAsync(new BookReferenceModel + { + Title = title, + TitleNormalized = title.ToLowerInvariant(), + Year = 2020, + ExternalIds = new Dictionary { ["openlibrary"] = "OL1W" } + }); + + try + { + var all = await repository.FindAllAsync(); + + all.Should().Contain(m => m.Id == created.Id && m.Title == title); + } + finally + { + await DeleteAsync(scope, "book_reference", created.Id!); + } + } + + [Fact] + public async Task VideoGameReferenceRepository_FindAllAsync_IncludesEveryDocument() + { + using var scope = factory.Services.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var title = $"Export Test Game {Guid.NewGuid()}"; + + var created = await repository.UpsertAsync(new VideoGameReferenceModel + { + Title = title, + TitleNormalized = title.ToLowerInvariant(), + Year = 2020, + ExternalIds = new Dictionary { ["rawg"] = "1" } + }); + + try + { + var all = await repository.FindAllAsync(); + + all.Should().Contain(m => m.Id == created.Id && m.Title == title); + } + finally + { + await DeleteAsync(scope, "videogame_reference", created.Id!); + } + } + + [Fact] + public async Task AlbumReferenceRepository_FindAllAsync_IncludesEveryDocument() + { + using var scope = factory.Services.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var title = $"Export Test Album {Guid.NewGuid()}"; + + var created = await repository.UpsertAsync(new AlbumReferenceModel + { + Title = title, + TitleNormalized = title.ToLowerInvariant(), + Year = 2020, + ExternalIds = new Dictionary { ["discogs"] = "1" } + }); + + try + { + var all = await repository.FindAllAsync(); + + all.Should().Contain(m => m.Id == created.Id && m.Title == title); + } + finally + { + await DeleteAsync(scope, "album_reference", created.Id!); + } + } + + private static async Task DeleteAsync(IServiceScope scope, string collectionName, string id) where TEntity : class + { + var collection = scope.ServiceProvider.GetRequiredService().GetCollection(collectionName); + await collection.DeleteOneAsync(Builders.Filter.Eq("_id", id), TestContext.Current.CancellationToken); + } +} diff --git a/test/WebApi.IntegrationTests/Resources/RefreshReferenceResourceTest.cs b/test/WebApi.IntegrationTests/Resources/RefreshReferenceResourceTest.cs new file mode 100644 index 00000000..9fd0954b --- /dev/null +++ b/test/WebApi.IntegrationTests/Resources/RefreshReferenceResourceTest.cs @@ -0,0 +1,223 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using AwesomeAssertions; +using Keeptrack.Common.System; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.Infrastructure.MongoDb.Entities; +using Keeptrack.WebApi.Contracts.Dto; +using Keeptrack.WebApi.IntegrationTests.Hosting; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Driver; +using Xunit; + +namespace Keeptrack.WebApi.IntegrationTests.Resources; + +/// +/// Exercises POST /api/tv-shows/{id}/refresh-reference and /api/movies/{id}/refresh-reference - the +/// user-triggered, exact-match-only re-check against the local reference collection. Deliberately not +/// admin-gated (unlike the TMDB search/link endpoints), so the standard test user can call it directly. +/// +public class RefreshReferenceResourceTest(KestrelWebAppFactory factory) + : ResourceTestBase(factory) +{ + [Fact] + public async Task RefreshReference_LinksTvShow_WhenAnExistingReferenceMatchesByTitleAndYear() + { + using var scope = Factory.Services.CreateScope(); + var referenceRepository = scope.ServiceProvider.GetRequiredService(); + var title = $"Refresh Reference Test Show {Guid.NewGuid()}"; + const int year = 2019; + + var reference = await referenceRepository.UpsertAsync(new TvShowReferenceModel + { + Title = "Canonical Title", + TitleNormalized = "canonical title", + Year = year, + ExternalIds = new Dictionary { ["tmdb"] = "1" }, + // the reference is only found by the show's own (title, year) via its aliases - a real reference + // resolved from this show would carry exactly this alias (see MatchedAliases / TryLinkExisting...) + MatchedAliases = [new ReferenceMatchModel { Title = TitleNormalizer.Normalize(title), Year = year }] + }); + + await Authenticate(); + var created = await PostAsync("/api/tv-shows", new TvShowDto { Title = title, Year = year }); + + try + { + var refreshed = await PostAsync($"/api/tv-shows/{created.Id}/refresh-reference", null, HttpStatusCode.OK); + + refreshed!.ReferenceId.Should().Be(reference.Id); + refreshed.Title.Should().Be("Canonical Title"); + } + finally + { + await DeleteAsync($"/api/tv-shows/{created.Id}"); + await DeleteReferenceAsync(scope, "tvshow_reference", reference.Id!); + } + } + + [Fact] + public async Task RefreshReference_LeavesTvShowUnresolved_WhenNoMatchingReferenceExists() + { + await Authenticate(); + var title = $"Refresh Reference No Match {Guid.NewGuid()}"; + var created = await PostAsync("/api/tv-shows", new TvShowDto { Title = title, Year = 2019 }); + + try + { + var refreshed = await PostAsync($"/api/tv-shows/{created.Id}/refresh-reference", null, HttpStatusCode.OK); + + refreshed!.ReferenceId.Should().BeNullOrEmpty(); + } + finally + { + await DeleteAsync($"/api/tv-shows/{created.Id}"); + } + } + + [Fact] + public async Task RefreshReference_LinksMovie_WhenAnExistingReferenceMatchesByTitleAndYear() + { + using var scope = Factory.Services.CreateScope(); + var referenceRepository = scope.ServiceProvider.GetRequiredService(); + var title = $"Refresh Reference Test Movie {Guid.NewGuid()}"; + const int year = 2019; + + var reference = await referenceRepository.UpsertAsync(new MovieReferenceModel + { + Title = "Canonical Movie Title", + TitleNormalized = "canonical movie title", + Year = year, + ExternalIds = new Dictionary { ["tmdb"] = "1" }, + MatchedAliases = [new ReferenceMatchModel { Title = TitleNormalizer.Normalize(title), Year = year }] + }); + + await Authenticate(); + var created = await PostAsync("/api/movies", new MovieDto { Title = title, Year = year }); + + try + { + var refreshed = await PostAsync($"/api/movies/{created.Id}/refresh-reference", null, HttpStatusCode.OK); + + refreshed!.ReferenceId.Should().Be(reference.Id); + refreshed.Title.Should().Be("Canonical Movie Title"); + } + finally + { + await DeleteAsync($"/api/movies/{created.Id}"); + await DeleteReferenceAsync(scope, "movie_reference", reference.Id!); + } + } + + [Fact] + public async Task RefreshReference_LinksBook_WhenAnExistingReferenceMatchesByTitleAndYear() + { + using var scope = Factory.Services.CreateScope(); + var referenceRepository = scope.ServiceProvider.GetRequiredService(); + var title = $"Refresh Reference Test Book {Guid.NewGuid()}"; + const int year = 2019; + + var reference = await referenceRepository.UpsertAsync(new BookReferenceModel + { + Title = "Canonical Book Title", + TitleNormalized = "canonical book title", + Year = year, + ExternalIds = new Dictionary { ["openlibrary"] = "OL1W" }, + // book/album aliases also carry the normalized creator - the lookup matches title+year+creator + MatchedAliases = [new ReferenceMatchModel { Title = TitleNormalizer.Normalize(title), Year = year, Creator = TitleNormalizer.Normalize("Some Author") }] + }); + + await Authenticate(); + var created = await PostAsync("/api/books", new BookDto { Title = title, Author = "Some Author", Year = year }); + + try + { + var refreshed = await PostAsync($"/api/books/{created.Id}/refresh-reference", null, HttpStatusCode.OK); + + refreshed!.ReferenceId.Should().Be(reference.Id); + refreshed.Title.Should().Be("Canonical Book Title"); + } + finally + { + await DeleteAsync($"/api/books/{created.Id}"); + await DeleteReferenceAsync(scope, "book_reference", reference.Id!); + } + } + + [Fact] + public async Task RefreshReference_LinksVideoGame_WhenAnExistingReferenceMatchesByTitleAndYear() + { + using var scope = Factory.Services.CreateScope(); + var referenceRepository = scope.ServiceProvider.GetRequiredService(); + var title = $"Refresh Reference Test Game {Guid.NewGuid()}"; + const int year = 2019; + + var reference = await referenceRepository.UpsertAsync(new VideoGameReferenceModel + { + Title = "Canonical Game Title", + TitleNormalized = "canonical game title", + Year = year, + ExternalIds = new Dictionary { ["rawg"] = "1" }, + MatchedAliases = [new ReferenceMatchModel { Title = TitleNormalizer.Normalize(title), Year = year }] + }); + + await Authenticate(); + var created = await PostAsync("/api/video-games", new VideoGameDto { Title = title, Year = year }); + + try + { + var refreshed = await PostAsync($"/api/video-games/{created.Id}/refresh-reference", null, HttpStatusCode.OK); + + refreshed!.ReferenceId.Should().Be(reference.Id); + refreshed.Title.Should().Be("Canonical Game Title"); + } + finally + { + await DeleteAsync($"/api/video-games/{created.Id}"); + await DeleteReferenceAsync(scope, "videogame_reference", reference.Id!); + } + } + + [Fact] + public async Task RefreshReference_LinksAlbum_WhenAnExistingReferenceMatchesByTitleAndYear() + { + using var scope = Factory.Services.CreateScope(); + var referenceRepository = scope.ServiceProvider.GetRequiredService(); + var title = $"Refresh Reference Test Album {Guid.NewGuid()}"; + const int year = 2019; + + var reference = await referenceRepository.UpsertAsync(new AlbumReferenceModel + { + Title = "Canonical Album Title", + TitleNormalized = "canonical album title", + Year = year, + ExternalIds = new Dictionary { ["discogs"] = "1" }, + MatchedAliases = [new ReferenceMatchModel { Title = TitleNormalizer.Normalize(title), Year = year, Creator = TitleNormalizer.Normalize("Some Artist") }] + }); + + await Authenticate(); + var created = await PostAsync("/api/albums", new AlbumDto { Title = title, Artist = "Some Artist", Year = year }); + + try + { + var refreshed = await PostAsync($"/api/albums/{created.Id}/refresh-reference", null, HttpStatusCode.OK); + + refreshed!.ReferenceId.Should().Be(reference.Id); + refreshed.Title.Should().Be("Canonical Album Title"); + } + finally + { + await DeleteAsync($"/api/albums/{created.Id}"); + await DeleteReferenceAsync(scope, "album_reference", reference.Id!); + } + } + + private static async Task DeleteReferenceAsync(IServiceScope scope, string collectionName, string id) where TEntity : class + { + var collection = scope.ServiceProvider.GetRequiredService().GetCollection(collectionName); + await collection.DeleteOneAsync(Builders.Filter.Eq("_id", id), TestContext.Current.CancellationToken); + } +} diff --git a/test/WebApi.IntegrationTests/Resources/ResourceTestBase.cs b/test/WebApi.IntegrationTests/Resources/ResourceTestBase.cs index 4e07c8f3..31d97852 100644 --- a/test/WebApi.IntegrationTests/Resources/ResourceTestBase.cs +++ b/test/WebApi.IntegrationTests/Resources/ResourceTestBase.cs @@ -17,6 +17,13 @@ public abstract class ResourceTestBase(KestrelWebAppFactory factory) { private const string MediaTypeJson = "application/json"; + /// + /// Exposes the factory to subclasses that also need a DI scope (e.g. to seed data directly via a + /// repository) alongside the HTTP helpers below - avoids a second, redundant capture of the same + /// constructor parameter as its own field. + /// + protected KestrelWebAppFactory Factory => factory; + private HttpClient _httpClient = null!; public ValueTask InitializeAsync() @@ -69,6 +76,21 @@ protected async Task PutAsync(string url, T body, HttpStatusCode httpStatusCo response.StatusCode.Should().Be(httpStatusCode); } + protected async Task PostFileAsync(string url, string fieldName, byte[] fileContent, string fileName, HttpStatusCode httpStatusCode = HttpStatusCode.OK) + { + using var content = new MultipartFormDataContent(); + using var byteContent = new ByteArrayContent(fileContent); + content.Add(byteContent, fieldName, fileName); + + var response = await _httpClient.PostAsync(url, content); + response.StatusCode.Should().Be(httpStatusCode); + + var stringResponse = await response.Content.ReadAsStringAsync(); + var output = JsonSerializer.Deserialize(stringResponse, JsonSerializerOptions.Web); + output.Should().NotBeNull(); + return output; + } + protected async Task DeleteAsync(string url, HttpStatusCode httpStatusCode = HttpStatusCode.NoContent) { var response = await _httpClient.DeleteAsync(url); diff --git a/test/WebApi.IntegrationTests/Resources/SongResourceTest.cs b/test/WebApi.IntegrationTests/Resources/SongResourceTest.cs new file mode 100644 index 00000000..34b3cbf0 --- /dev/null +++ b/test/WebApi.IntegrationTests/Resources/SongResourceTest.cs @@ -0,0 +1,100 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using AwesomeAssertions; +using Bogus; +using Keeptrack.Common.System; +using Keeptrack.WebApi.Contracts.Dto; +using Keeptrack.WebApi.IntegrationTests.Hosting; +using Xunit; + +namespace Keeptrack.WebApi.IntegrationTests.Resources; + +/// +/// Basic full-cycle CRUD coverage for Song, same shape as . Song +/// has no dedicated list page in the UI - it's created/managed only from within a playlist - but still +/// gets full CRUD via the API so the same song can be created once and reused across playlists. +/// +public class SongResourceTest(KestrelWebAppFactory factory) + : ResourceTestBase(factory) +{ + private const string ResourceEndpoint = "api/songs"; + + [Fact] + public async Task SongResourceFullCycle_IsOk() + { + await GetAsync($"/{ResourceEndpoint}", HttpStatusCode.Unauthorized); + + await Authenticate(); + + var input = new Faker() + .Rules((f, o) => { o.Title = f.Random.AlphaNumeric(14); o.Artist = f.Random.AlphaNumeric(8); }) + .Generate(); + var created = await PostAsync($"/{ResourceEndpoint}", input); + created.Id.Should().NotBeNullOrEmpty(); + + try + { + created.Title = "New shiny title"; + await PutAsync($"/{ResourceEndpoint}/{created.Id}", created); + + var updated = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); + updated.Should().BeEquivalentTo(created); + + var finalItems = await GetAsync>($"/{ResourceEndpoint}"); + var firstItem = finalItems.Items.FirstOrDefault(x => x.Id == updated.Id); + firstItem.Should().NotBeNull(); + firstItem.Title.Should().Be(updated.Title); + } + finally + { + await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); + } + } + + [Fact] + public async Task SongResourceCreate_PersistsAlbumLink_IsOk() + { + await Authenticate(); + + var created = await PostAsync($"/{ResourceEndpoint}", new SongDto { Title = "Time Is Running Out", Artist = "Muse", AlbumId = "some-album-id" }); + + try + { + var fetched = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); + fetched.AlbumId.Should().Be("some-album-id"); + } + finally + { + await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); + } + } + + /// + /// Backs SongApiClient.GetOrCreateForTrackAsync's dedupe lookup - picking the same track twice + /// must find the one already-created song for it, not create a duplicate. Two songs share an + /// AlbumId here specifically to prove the filter matches on both fields together, not either alone. + /// + [Fact] + public async Task SongResourceSearch_FiltersToMatchingAlbumIdAndTrackPosition_IsOk() + { + await Authenticate(); + + const string albumId = "shared-album-id"; + var trackOne = await PostAsync($"/{ResourceEndpoint}", new SongDto { Title = "Apocalypse Please", AlbumId = albumId, TrackPosition = "2" }); + var trackTwo = await PostAsync($"/{ResourceEndpoint}", new SongDto { Title = "Time Is Running Out", AlbumId = albumId, TrackPosition = "3" }); + + try + { + var results = await GetAsync>($"/{ResourceEndpoint}?AlbumId={albumId}&TrackPosition=3"); + + results.Items.Should().ContainSingle(x => x.Id == trackTwo.Id); + results.Items.Should().NotContain(x => x.Id == trackOne.Id); + } + finally + { + await DeleteAsync($"/{ResourceEndpoint}/{trackOne.Id}"); + await DeleteAsync($"/{ResourceEndpoint}/{trackTwo.Id}"); + } + } +} diff --git a/test/WebApi.IntegrationTests/Resources/TvShowReferenceLinkingTest.cs b/test/WebApi.IntegrationTests/Resources/TvShowReferenceLinkingTest.cs new file mode 100644 index 00000000..b3565bfb --- /dev/null +++ b/test/WebApi.IntegrationTests/Resources/TvShowReferenceLinkingTest.cs @@ -0,0 +1,104 @@ +using System; +using System.Threading.Tasks; +using AwesomeAssertions; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.WebApi.IntegrationTests.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Keeptrack.WebApi.IntegrationTests.Resources; + +/// +/// Exercises and +/// directly against real MongoDB - +/// these are new, hand-written Mongo queries (case-insensitive regex match, a "don't overwrite an +/// existing link" guard, a $group aggregation), exactly the kind of per-type override logic that has +/// historically hidden real bugs in this codebase (see the CarHistory $text-vs-$eq finding in +/// docs/code-quality-findings.md). Goes through the repository resolved from the test host's DI +/// container rather than HTTP, since the cross-tenant propagation this proves has nothing to do with +/// the calling user's own identity/role. +/// +public class TvShowReferenceLinkingTest(KestrelWebAppFactory factory) : IClassFixture> +{ + [Fact] + public async Task SetReferenceLinkAsync_UpdatesEveryMatchingTenantsShow_ButNotOthers() + { + using var scope = factory.Services.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var title = $"Reference Linking Test Show {Guid.NewGuid()}"; + var canonicalTitle = $"Canonical {title}"; + const int year = 2021; + + var tenantAShow = await repository.CreateAsync(new TvShowModel { OwnerId = "reference-link-tenant-a", Title = title, Year = year }); + // different casing, different tenant: the match is case-insensitive and crosses tenants by design + var tenantBShow = await repository.CreateAsync(new TvShowModel { OwnerId = "reference-link-tenant-b", Title = title.ToUpperInvariant(), Year = year }); + var differentYearShow = await repository.CreateAsync(new TvShowModel { OwnerId = "reference-link-tenant-a", Title = title, Year = year + 1 }); + var alreadyLinkedShow = await repository.CreateAsync(new TvShowModel + { + OwnerId = "reference-link-tenant-c", + Title = title, + Year = year, + ReferenceId = "pre-existing-link" + }); + + try + { + var modifiedCount = await repository.SetReferenceLinkAsync(title, year, "reference-123", canonicalTitle); + + modifiedCount.Should().Be(2); + var tenantAResult = (await repository.FindOneAsync(tenantAShow.Id!, "reference-link-tenant-a"))!; + tenantAResult.ReferenceId.Should().Be("reference-123"); + // the tenant's own title is replaced with the reference's canonical name, not just the id + tenantAResult.Title.Should().Be(canonicalTitle); + (await repository.FindOneAsync(tenantBShow.Id!, "reference-link-tenant-b"))!.ReferenceId.Should().Be("reference-123"); + // An unset ReferenceId can round-trip as either "" (documents written before the + // AutoMapper -> Mapperly migration) or null (new writes) - BeNullOrEmpty is the correct + // "still unresolved" check that covers both generations. + (await repository.FindOneAsync(differentYearShow.Id!, "reference-link-tenant-a"))!.ReferenceId.Should().BeNullOrEmpty(); + // a show that already has a link is never clobbered by a later automatic/admin resolution + var alreadyLinkedResult = (await repository.FindOneAsync(alreadyLinkedShow.Id!, "reference-link-tenant-c"))!; + alreadyLinkedResult.ReferenceId.Should().Be("pre-existing-link"); + alreadyLinkedResult.Title.Should().Be(title); + } + finally + { + await repository.DeleteAsync(tenantAShow.Id!, "reference-link-tenant-a"); + await repository.DeleteAsync(tenantBShow.Id!, "reference-link-tenant-b"); + await repository.DeleteAsync(differentYearShow.Id!, "reference-link-tenant-a"); + await repository.DeleteAsync(alreadyLinkedShow.Id!, "reference-link-tenant-c"); + } + } + + [Fact] + public async Task FindDistinctUnresolvedTitleYearsAsync_ReturnsDistinctUnlinkedTitleYearPairs() + { + using var scope = factory.Services.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var title = $"Unresolved Test Show {Guid.NewGuid()}"; + + var showA = await repository.CreateAsync(new TvShowModel { OwnerId = "unresolved-tenant-a", Title = title, Year = 2022 }); + var showB = await repository.CreateAsync(new TvShowModel { OwnerId = "unresolved-tenant-b", Title = title, Year = 2022 }); + var linkedShow = await repository.CreateAsync(new TvShowModel + { + OwnerId = "unresolved-tenant-c", + Title = title, + Year = 2022, + ReferenceId = "already-linked" + }); + + try + { + var unresolved = await repository.FindDistinctUnresolvedTitleYearsAsync(); + + // two unlinked shows sharing (title, year) collapse into one queue entry; the already-linked one doesn't appear + unresolved.Should().ContainSingle(p => p.Title == title && p.Year == 2022); + } + finally + { + await repository.DeleteAsync(showA.Id!, "unresolved-tenant-a"); + await repository.DeleteAsync(showB.Id!, "unresolved-tenant-b"); + await repository.DeleteAsync(linkedShow.Id!, "unresolved-tenant-c"); + } + } +} diff --git a/test/WebApi.IntegrationTests/Resources/TvShowReferenceRepositoryTest.cs b/test/WebApi.IntegrationTests/Resources/TvShowReferenceRepositoryTest.cs new file mode 100644 index 00000000..cc8e4d4e --- /dev/null +++ b/test/WebApi.IntegrationTests/Resources/TvShowReferenceRepositoryTest.cs @@ -0,0 +1,187 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using AwesomeAssertions; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.Infrastructure.MongoDb.Entities; +using Keeptrack.WebApi.IntegrationTests.Hosting; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Driver; +using Xunit; + +namespace Keeptrack.WebApi.IntegrationTests.Resources; + +/// +/// Exercises / +/// against real MongoDB - both now match against every entry in MatchedAliases (an ElemMatch +/// filter over the embedded (title, year) array, backing a compound multikey index), not just the +/// document's own canonical TitleNormalized/Year. This is exactly the kind of hand-written +/// Mongo filter that has hidden real bugs before (see docs/code-quality-findings.md), so it's verified +/// against a real database, not mocks. +/// +public class TvShowReferenceRepositoryTest(KestrelWebAppFactory factory) : IClassFixture> +{ + [Fact] + public async Task FindByTitleYearAsync_MatchesAnAlternateTitle_NotJustTheCanonicalOne() + { + using var scope = factory.Services.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var alternateTitle = $"Alternate Title {Guid.NewGuid()}"; + const int year = 2005; + + var created = await repository.UpsertAsync(new TvShowReferenceModel + { + Title = "Canonical Title", + TitleNormalized = "canonical title", + Year = year, + ExternalIds = new Dictionary { ["tmdb"] = "1" }, + MatchedAliases = [new ReferenceMatchModel { Title = alternateTitle.ToLowerInvariant(), Year = year }] + }); + + try + { + // case-insensitive: normalization lower-cases before comparing + var found = await repository.FindByTitleYearAsync(alternateTitle.ToUpperInvariant(), year); + + found.Should().NotBeNull(); + found!.Id.Should().Be(created.Id); + } + finally + { + await DeleteAsync(scope, created.Id!); + } + } + + [Fact] + public async Task FindByTitleYearAsync_MatchesAnAliasWhoseConfirmedYearDiffersFromTheDocumentsOwnCanonicalYear() + { + // regression: a single top-level Year scalar AND-ed against the title-array-contains filter would + // reject a tenant whose recorded year genuinely differs from whichever year happens to be this + // document's own canonical one - year now travels with its specific title variant instead. + using var scope = factory.Services.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var alternateTitle = $"Alternate Title {Guid.NewGuid()}"; + + var created = await repository.UpsertAsync(new TvShowReferenceModel + { + Title = "Canonical Title", + TitleNormalized = "canonical title", + Year = 2005, + ExternalIds = new Dictionary { ["tmdb"] = "1" }, + MatchedAliases = [new ReferenceMatchModel { Title = alternateTitle.ToLowerInvariant(), Year = 2004 }] + }); + + try + { + var found = await repository.FindByTitleYearAsync(alternateTitle, 2004); + + found.Should().NotBeNull(); + found!.Id.Should().Be(created.Id); + } + finally + { + await DeleteAsync(scope, created.Id!); + } + } + + [Fact] + public async Task FindByTitleAsync_MatchesAnAlternateTitle_IgnoringYear() + { + using var scope = factory.Services.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var alternateTitle = $"Alternate Title {Guid.NewGuid()}"; + + var created = await repository.UpsertAsync(new TvShowReferenceModel + { + Title = "Canonical Title", + TitleNormalized = "canonical title", + Year = 2005, + ExternalIds = new Dictionary { ["tmdb"] = "1" }, + MatchedAliases = [new ReferenceMatchModel { Title = alternateTitle.ToLowerInvariant(), Year = 2005 }] + }); + + try + { + var found = await repository.FindByTitleAsync(alternateTitle); + + found.Should().NotBeNull(); + found!.Id.Should().Be(created.Id); + } + finally + { + await DeleteAsync(scope, created.Id!); + } + } + + [Fact] + public async Task UpsertAsync_PersistsANullCreator_AsAnActualBsonNullNotAnEmptyString() + { + // Regression: DataStorageMappingProfile's ReferenceMatchModel -> ReferenceMatch map opts Creator out + // of the profile-wide AllowNullDestinationValues = false (Program.cs), specifically so a null Creator + // (TV show/movie/video game have no creator dimension) round-trips as a real null, not "". Getting + // this wrong once let a null Creator silently become "" on save, which broke MergeMatchedAliases' + // in-memory dedup comparison and duplicated an alias on every re-resolve/re-refresh (confirmed + // against a real video game reference, RAWG's "God of War", that had accumulated an exact duplicate + // this way - see scripts/dedupe-matched-aliases.js). Only a real MongoDB round-trip can catch this; + // a mocked repository never exercises the actual mapper/BSON serialization behavior. + using var scope = factory.Services.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var title = $"Null Creator Title {Guid.NewGuid()}"; + + var created = await repository.UpsertAsync(new TvShowReferenceModel + { + Title = title, + TitleNormalized = title.ToLowerInvariant(), + Year = 2010, + ExternalIds = new Dictionary { ["tmdb"] = "1" }, + MatchedAliases = [new ReferenceMatchModel { Title = title.ToLowerInvariant(), Year = 2010, Creator = null }] + }); + + try + { + var collection = scope.ServiceProvider.GetRequiredService().GetCollection("tvshow_reference"); + var stored = await collection.Find(x => x.Id == created.Id).FirstOrDefaultAsync(TestContext.Current.CancellationToken); + + stored.MatchedAliases.Should().ContainSingle(m => m.Creator == null); + } + finally + { + await DeleteAsync(scope, created.Id!); + } + } + + [Fact] + public async Task UpsertAsync_AlwaysIncludesTheCanonicalTitleAndYearInMatchedAliases_EvenIfTheCallerForgot() + { + using var scope = factory.Services.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var title = $"Canonical Only Title {Guid.NewGuid()}"; + + var created = await repository.UpsertAsync(new TvShowReferenceModel + { + Title = title, + TitleNormalized = title.ToLowerInvariant(), + Year = 2010, + ExternalIds = new Dictionary { ["tmdb"] = "1" } + }); + + try + { + var found = await repository.FindByTitleAsync(title); + + found.Should().NotBeNull(); + found!.Id.Should().Be(created.Id); + } + finally + { + await DeleteAsync(scope, created.Id!); + } + } + + private static async Task DeleteAsync(IServiceScope scope, string id) + { + var collection = scope.ServiceProvider.GetRequiredService().GetCollection("tvshow_reference"); + await collection.DeleteOneAsync(Builders.Filter.Eq(x => x.Id, id), TestContext.Current.CancellationToken); + } +} diff --git a/test/WebApi.IntegrationTests/Resources/TvShowResourceTest.cs b/test/WebApi.IntegrationTests/Resources/TvShowResourceTest.cs new file mode 100644 index 00000000..91c53978 --- /dev/null +++ b/test/WebApi.IntegrationTests/Resources/TvShowResourceTest.cs @@ -0,0 +1,41 @@ +using System.Threading.Tasks; +using AwesomeAssertions; +using Keeptrack.Common.System; +using Keeptrack.WebApi.Contracts.Dto; +using Keeptrack.WebApi.IntegrationTests.Hosting; +using Xunit; + +namespace Keeptrack.WebApi.IntegrationTests.Resources; + +public class TvShowResourceTest(KestrelWebAppFactory factory) + : ResourceTestBase(factory) +{ + private const string ResourceEndpoint = "api/tv-shows"; + + [Fact] + public async Task TvShowResourceOwnedAndWishlistedFilters_OnlyReturnMatchingItems_IsOk() + { + await Authenticate(); + + var title = System.Guid.NewGuid().ToString(); + var created = await PostAsync($"/{ResourceEndpoint}", new TvShowDto + { + Title = title, + IsOwned = true, + IsWishlisted = true + }); + + try + { + var owned = await GetAsync>($"/{ResourceEndpoint}?IsOwned=true&search={title}"); + owned.Items.Should().ContainSingle(s => s.Id == created.Id); + + var wishlisted = await GetAsync>($"/{ResourceEndpoint}?IsWishlisted=true&search={title}"); + wishlisted.Items.Should().ContainSingle(s => s.Id == created.Id); + } + finally + { + await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); + } + } +} diff --git a/test/WebApi.IntegrationTests/Resources/TvTimeFixtureZipBuilder.cs b/test/WebApi.IntegrationTests/Resources/TvTimeFixtureZipBuilder.cs new file mode 100644 index 00000000..39f14d2f --- /dev/null +++ b/test/WebApi.IntegrationTests/Resources/TvTimeFixtureZipBuilder.cs @@ -0,0 +1,208 @@ +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text; + +namespace Keeptrack.WebApi.IntegrationTests.Resources; + +/// +/// Builds a small, synthetic TV Time GDPR export zip for tests: representative rows of each file +/// shape the importer reads, for a single fake show/movie. Never use the real personal export in tests. +/// +internal static class TvTimeFixtureZipBuilder +{ + public const string ShowTitle = "Keeptrack Integration Test Show"; + + /// + /// A show with genuine watch history and a rating, but that never appears in followed_tv_show.csv - + /// this is exactly the real-world case ("The Pitt" in the user's own export) that was silently + /// dropped before: the importer must still create it and import its episode, not skip it. + /// + public const string OrphanShowTitle = "Keeptrack Orphan Show"; + + public const string MovieTitle = "Keeptrack Integration Test Movie"; + + private const string MovieUuid = "11111111-1111-1111-1111-111111111111"; + + public static byte[] Build() + { + var entries = new Dictionary + { + ["followed_tv_show.csv"] = $""" + updated_at,active,notification_type,folder_id,archived,notification_offset,user_id,tv_show_id,tv_show_name,created_at,diffusion + 2020-01-01 00:00:00,1,2,,0,1440,999,999001,{ShowTitle},2020-01-01 00:00:00,original + + """, + ["seen_episode_source.csv"] = $""" + updated_at,tv_show_name,episode_season_number,episode_number,user_id,episode_id,source,created_at + 2020-01-02 00:00:00,{ShowTitle},1,1,999,1,episode-detail,2020-01-02 00:00:00 + 2020-01-03 00:00:00,{ShowTitle},1,2,999,2,episode-detail,2020-01-03 00:00:00 + + """, + // covers episodes marked watched other ways, which seen_episode_source.csv alone misses (the real bug + // this fixture guards against): one extra episode each from the legacy and current tracking exports. + ["tracking-prod-records.csv"] = BuildCsv( + ["series_name", "uuid", "type-uuid-n", "watch_count", "type", "updated_at", "created_at", "series_id", "user_id", + "watches", "movie_name", "runtime", "entity_type", "alpha_range_key", "follow_date_range_key", "release_date", + "release_date_range_key", "rewatch_count", "series_uuid", "season_number", "episode_id", "watch_date", + "episode_number", "total_movies_runtime", "total_series_runtime", "country", "bulk_type", + "watched_episode_range_key", "watch_date_range_key", "unitarian"], + new Dictionary + { + ["series_name"] = ShowTitle, + ["uuid"] = "aaaa", + ["type-uuid-n"] = "count-watch-episode-series-aaaa", + ["type"] = "count-watch-episode-series", + ["created_at"] = "2020-01-04 00:00:00", + ["series_id"] = "999001", + ["user_id"] = "999" + }, + new Dictionary + { + ["series_name"] = ShowTitle, + ["uuid"] = "bbbb", + ["type-uuid-n"] = "watch-bbbb-0", + ["type"] = "watch", + ["created_at"] = "2020-01-04 00:00:00", + ["series_id"] = "999001", + ["user_id"] = "999", + ["entity_type"] = "episode", + ["season_number"] = "1", + ["episode_number"] = "3" + }, + // movie watch event: the only source of a movie's watched date (confirmed against a real + // export - the rating/emotion vote files never carry one) + new Dictionary + { + ["uuid"] = "movie-watch-1", + ["type"] = "watch", + ["created_at"] = "2020-01-07 00:00:00", + ["user_id"] = "999", + ["movie_name"] = MovieTitle, + ["entity_type"] = "movie" + }), + ["tracking-prod-records-v2.csv"] = BuildCsv( + ["s_id", "user_id", "episode_id", "series_name", "gsi", "runtime", "created_at", "season_number", "episode_number", + "ep_no", "ep_id", "s_no", "key", "ep_watch_count", "total_movies_runtime", "total_series_runtime", + "series_follow_count", "movie_watch_count", "updated_at", "is_followed", "most_recent_ep_watched", "is_for_later", + "uuid", "followed_at", "is_archived", "is_unitary", "rewatch_count", "bulk_type", "is_special"], + new Dictionary + { + ["s_id"] = "999001", + ["user_id"] = "999", + ["series_name"] = ShowTitle, + ["created_at"] = "2020-01-01 00:00:00", + ["key"] = "user-series-999001", + ["ep_watch_count"] = "4", + ["updated_at"] = "2020-01-05 00:00:00", + ["is_followed"] = "true", + ["uuid"] = "999001-summary", + ["is_archived"] = "false" + }, + new Dictionary + { + ["s_id"] = "999001", + ["user_id"] = "999", + ["episode_id"] = "4", + ["series_name"] = ShowTitle, + ["gsi"] = "watch-episode-1578182400", + ["created_at"] = "2020-01-05 00:00:00", + ["season_number"] = "2", + ["episode_number"] = "1", + ["key"] = "watch-episode-999001-cccc", + ["updated_at"] = "2020-01-05 00:00:00", + ["is_unitary"] = "true" + }, + // OrphanShowTitle: deliberately has NO row in followed_tv_show.csv + new Dictionary + { + ["s_id"] = "999003", + ["user_id"] = "999", + ["episode_id"] = "5", + ["series_name"] = OrphanShowTitle, + ["gsi"] = "watch-episode-1578268800", + ["created_at"] = "2020-01-06 00:00:00", + ["season_number"] = "1", + ["episode_number"] = "1", + ["key"] = "watch-episode-999003-dddd", + ["updated_at"] = "2020-01-06 00:00:00", + ["is_unitary"] = "true" + }), + ["user_tv_show_data.csv"] = $""" + user_id,tv_show_id,is_followed,is_favorited,nb_episodes_seen,tv_show_name + 999,999001,1,1,5,{ShowTitle} + + """, + ["tv_show_rate.csv"] = BuildCsv( + ["created_at", "updated_at", "tv_show_name", "user_id", "tv_show_id", "rating"], + new Dictionary + { + ["created_at"] = "2020-01-01 00:00:00", + ["updated_at"] = "2020-01-01 00:00:00", + ["tv_show_name"] = ShowTitle, + ["user_id"] = "999", + ["tv_show_id"] = "999001", + ["rating"] = "4.5" + }, + new Dictionary + { + ["created_at"] = "2020-01-06 00:00:00", + ["updated_at"] = "2020-01-06 00:00:00", + ["tv_show_name"] = OrphanShowTitle, + ["user_id"] = "999", + ["tv_show_id"] = "999003", + ["rating"] = "3.5" + }), + ["user_show_special_status.csv"] = $""" + created_at,updated_at,tv_show_name,user_id,tv_show_id,status + 2020-01-01 00:00:00,2020-01-01 00:00:00,{ShowTitle},999,999001,favorite + + """, + ["show_comment.csv"] = $""" + spoiler_count,nb_likes,lang,tv_show_name,created_at,parent_comment_id,source,only_to_fans,user_id,posted_on_fb,posted_on_twitter,comment_type,highlight_level,extended_comment,id,tv_show_id,comment,updated_at,unappropriate_count,depth,valid,same_ip_likes,featured + 0,0,en,{ShowTitle},2020-01-01 00:00:00,,mobile,1,999,0,0,comment,5,null,1,999001,Great show,2020-01-01 00:00:00,0,0,1,0,0 + + """, + ["episode_comment.csv"] = $""" + source,episode_number,episode_id,updated_at,spoiler_count,nb_likes,depth,comment_type,lang,highlight_level,same_ip_likes,episode_season_number,id,posted_on_fb,parent_comment_id,valid,nb_points,tv_show_name,user_id,comment,created_at,posted_on_twitter,unappropriate_count,extended_comment + mobile,1,1,2020-01-02 00:00:00,0,0,1,comment,en,5,0,1,1,0,,0,0,{ShowTitle},999,Great pilot,2020-01-02 00:00:00,0,0,null + + """, + ["ratings-v2-prod-votes.csv"] = $""" + uuid,user_id,episode_id,movie_name,vote_key,series_name,season_number,episode_number + {MovieUuid},999,0,{MovieTitle},{MovieUuid}-999-3,,, + + """, + ["lists-prod-lists.csv"] = $""" + s_key,user_id,objects,name,is_public,created_at,ordering,type,description,lists,list_count + favorite-movies,999,"[map[created_at:1.577836800e+09 type:movie uuid:{MovieUuid}]]",,false,2020-01-01 00:00:00,0,list,,, + + """ + }; + + using var zipStream = new MemoryStream(); + using (var archive = new ZipArchive(zipStream, ZipArchiveMode.Create, leaveOpen: true)) + { + foreach (var (fileName, content) in entries) + { + var entry = archive.CreateEntry(fileName); + using var writer = new StreamWriter(entry.Open(), Encoding.UTF8); + writer.Write(content); + } + } + + return zipStream.ToArray(); + } + + /// + /// Builds a CSV from an explicit header list and one dictionary of column values per row (columns + /// not present in a row default to empty) - avoids manually counting commas for wide, mostly-empty rows. + /// + private static string BuildCsv(string[] headers, params Dictionary[] rows) + { + var lines = new List { string.Join(',', headers) }; + lines.AddRange(rows.Select(row => string.Join(',', headers.Select(h => row.GetValueOrDefault(h, string.Empty))))); + return string.Join('\n', lines) + '\n'; + } +} diff --git a/test/WebApi.IntegrationTests/Resources/TvTimeImportResourceTest.cs b/test/WebApi.IntegrationTests/Resources/TvTimeImportResourceTest.cs new file mode 100644 index 00000000..71c8b0ff --- /dev/null +++ b/test/WebApi.IntegrationTests/Resources/TvTimeImportResourceTest.cs @@ -0,0 +1,129 @@ +using System; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using AwesomeAssertions; +using Keeptrack.Common.System; +using Keeptrack.WebApi.Contracts.Dto; +using Keeptrack.WebApi.IntegrationTests.Hosting; +using Xunit; + +namespace Keeptrack.WebApi.IntegrationTests.Resources; + +public class TvTimeImportResourceTest(KestrelWebAppFactory factory) + : ResourceTestBase(factory) +{ + [Fact] + public async Task ImportTvTime_UpsertsShowsEpisodesAndMovies_AndIsIdempotent() + { + await Authenticate(); + + var zip = TvTimeFixtureZipBuilder.Build(); + + var firstJob = await PostFileAsync("/api/import/tv-time", "file", zip, "gdpr-data.zip", HttpStatusCode.Accepted); + var firstResult = await PollForResultAsync(firstJob.JobId); + // ShowTitle (from followed_tv_show.csv) + OrphanShowTitle (has watch history but is absent from + // followed_tv_show.csv - the importer must create it anyway, not skip it) + firstResult.ShowsCreated.Should().Be(2); + // 4 for ShowTitle (2 from seen_episode_source.csv + 1 from tracking-prod-records.csv + 1 from + // tracking-prod-records-v2.csv) + 1 for OrphanShowTitle (tracking-prod-records-v2.csv only) + firstResult.EpisodesCreated.Should().Be(5); + firstResult.MoviesCreated.Should().Be(1); + // user_tv_show_data.csv reports 5 episodes seen, but only 4 got a watch date from the sources above + firstResult.Warnings.Should().Contain(w => w.Contains(TvTimeFixtureZipBuilder.ShowTitle) && w.Contains("4 of 5")); + + try + { + var shows = await GetAsync>($"/api/tv-shows?search={Uri.EscapeDataString(TvTimeFixtureZipBuilder.ShowTitle)}"); + var show = shows.Items.Should().ContainSingle().Subject; + show.Rating.Should().Be(4.5f); + show.IsFavorite.Should().BeTrue(); + show.Notes.Should().Contain("Great show"); + + var episodes = await GetAsync>($"/api/episodes?TvShowId={show.Id}"); + episodes.Items.Should().HaveCount(4); + // episode notes are date-prefixed by FormatComments (same as show notes above), e.g. "2020-01-02: Great pilot" + episodes.Items.Should().Contain(e => e.SeasonNumber == 1 && e.EpisodeNumber == 1 && e.Notes != null && e.Notes.Contains("Great pilot")); + episodes.Items.Should().Contain(e => e.SeasonNumber == 1 && e.EpisodeNumber == 3); + episodes.Items.Should().Contain(e => e.SeasonNumber == 2 && e.EpisodeNumber == 1); + + // the real-world bug this guards against: a show with genuine watch history but no + // followed_tv_show.csv row must still be created, with its own rating applied by id, and + // its episode imported - not silently skipped with a "wasn't found" warning. + var orphanShows = await GetAsync>($"/api/tv-shows?search={Uri.EscapeDataString(TvTimeFixtureZipBuilder.OrphanShowTitle)}"); + var orphanShow = orphanShows.Items.Should().ContainSingle().Subject; + orphanShow.Rating.Should().Be(3.5f); + + var orphanEpisodes = await GetAsync>($"/api/episodes?TvShowId={orphanShow.Id}"); + orphanEpisodes.Items.Should().ContainSingle(e => e.SeasonNumber == 1 && e.EpisodeNumber == 1); + + var movies = await GetAsync>($"/api/movies?search={Uri.EscapeDataString(TvTimeFixtureZipBuilder.MovieTitle)}"); + var movie = movies.Items.Should().ContainSingle().Subject; + movie.IsFavorite.Should().BeTrue(); + // the only source of a movie's watched date is a "watch"/"movie" row in tracking-prod-records.csv - + // confirmed against a real export, where the rating/emotion vote files never carry one + movie.FirstSeenAt.Should().Be(new DateOnly(2020, 1, 7)); + + // re-importing the same export must recognize everything by its stable TV Time id and skip it, + // never duplicate + var secondJob = await PostFileAsync("/api/import/tv-time", "file", zip, "gdpr-data.zip", HttpStatusCode.Accepted); + var secondResult = await PollForResultAsync(secondJob.JobId); + secondResult.ShowsCreated.Should().Be(0); + secondResult.ShowsSkipped.Should().Be(2); + secondResult.EpisodesCreated.Should().Be(0); + secondResult.EpisodesSkipped.Should().Be(5); + secondResult.MoviesCreated.Should().Be(0); + secondResult.MoviesSkipped.Should().Be(1); + + var showsAfterReimport = await GetAsync>($"/api/tv-shows?search={Uri.EscapeDataString(TvTimeFixtureZipBuilder.ShowTitle)}"); + showsAfterReimport.Items.Should().ContainSingle(); + + var orphanShowsAfterReimport = await GetAsync>($"/api/tv-shows?search={Uri.EscapeDataString(TvTimeFixtureZipBuilder.OrphanShowTitle)}"); + orphanShowsAfterReimport.Items.Should().ContainSingle(); + } + finally + { + foreach (var title in new[] { TvTimeFixtureZipBuilder.ShowTitle, TvTimeFixtureZipBuilder.OrphanShowTitle }) + { + var shows = await GetAsync>($"/api/tv-shows?search={Uri.EscapeDataString(title)}"); + foreach (var show in shows.Items) + { + var episodes = await GetAsync>($"/api/episodes?TvShowId={show.Id}"); + foreach (var episode in episodes.Items.Where(e => e.Id is not null)) + { + await DeleteAsync($"/api/episodes/{episode.Id}"); + } + + await DeleteAsync($"/api/tv-shows/{show.Id}"); + } + } + + var movies = await GetAsync>($"/api/movies?search={Uri.EscapeDataString(TvTimeFixtureZipBuilder.MovieTitle)}"); + foreach (var movie in movies.Items.Where(m => m.Id is not null)) + { + await DeleteAsync($"/api/movies/{movie.Id}"); + } + } + } + + private async Task PollForResultAsync(Guid jobId) + { + for (var attempt = 0; attempt < 100; attempt++) + { + var status = await GetAsync($"/api/import/tv-time/{jobId}"); + switch (status.Stage) + { + case ImportStage.Completed: + status.Result.Should().NotBeNull(); + return status.Result!; + case ImportStage.Failed: + throw new InvalidOperationException($"Import job failed: {status.ErrorMessage}"); + default: + await Task.Delay(100); + break; + } + } + + throw new TimeoutException("Import job did not complete in time."); + } +} diff --git a/test/WebApi.IntegrationTests/Resources/VideoGameReferenceRepositoryTest.cs b/test/WebApi.IntegrationTests/Resources/VideoGameReferenceRepositoryTest.cs new file mode 100644 index 00000000..c6e266d8 --- /dev/null +++ b/test/WebApi.IntegrationTests/Resources/VideoGameReferenceRepositoryTest.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using AwesomeAssertions; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.Infrastructure.MongoDb.Entities; +using Keeptrack.WebApi.IntegrationTests.Hosting; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Driver; +using Xunit; + +namespace Keeptrack.WebApi.IntegrationTests.Resources; + +/// +/// Exercises / +/// against real MongoDB - same ElemMatch/MatchedAliases shape already verified for +/// (see TvShowReferenceRepositoryTest), applied to video games. +/// +public class VideoGameReferenceRepositoryTest(KestrelWebAppFactory factory) : IClassFixture> +{ + [Fact] + public async Task FindByTitleYearAsync_MatchesAnAliasWhoseConfirmedYearDiffersFromTheDocumentsOwnCanonicalYear() + { + using var scope = factory.Services.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var alternateTitle = $"Alternate Game Title {Guid.NewGuid()}"; + + var created = await repository.UpsertAsync(new VideoGameReferenceModel + { + Title = "Canonical Game Title", + TitleNormalized = "canonical game title", + Year = 2005, + ExternalIds = new Dictionary { ["rawg"] = "1" }, + MatchedAliases = [new ReferenceMatchModel { Title = alternateTitle.ToLowerInvariant(), Year = 2004 }] + }); + + try + { + var found = await repository.FindByTitleYearAsync(alternateTitle, 2004); + + found.Should().NotBeNull(); + found!.Id.Should().Be(created.Id); + } + finally + { + await DeleteAsync(scope, created.Id!); + } + } + + [Fact] + public async Task FindByTitleAsync_MatchesAnAlternateTitle_IgnoringYear() + { + using var scope = factory.Services.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var alternateTitle = $"Alternate Game Title {Guid.NewGuid()}"; + + var created = await repository.UpsertAsync(new VideoGameReferenceModel + { + Title = "Canonical Game Title", + TitleNormalized = "canonical game title", + Year = 2005, + ExternalIds = new Dictionary { ["rawg"] = "1" }, + MatchedAliases = [new ReferenceMatchModel { Title = alternateTitle.ToLowerInvariant(), Year = 2005 }] + }); + + try + { + var found = await repository.FindByTitleAsync(alternateTitle); + + found.Should().NotBeNull(); + found!.Id.Should().Be(created.Id); + } + finally + { + await DeleteAsync(scope, created.Id!); + } + } + + [Fact] + public async Task UpsertAsync_AlwaysIncludesTheCanonicalTitleAndYearInMatchedAliases_EvenIfTheCallerForgot() + { + using var scope = factory.Services.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var title = $"Canonical Only Game Title {Guid.NewGuid()}"; + + var created = await repository.UpsertAsync(new VideoGameReferenceModel + { + Title = title, + TitleNormalized = title.ToLowerInvariant(), + Year = 2010, + ExternalIds = new Dictionary { ["rawg"] = "1" } + }); + + try + { + var found = await repository.FindByTitleAsync(title); + + found.Should().NotBeNull(); + found!.Id.Should().Be(created.Id); + } + finally + { + await DeleteAsync(scope, created.Id!); + } + } + + private static async Task DeleteAsync(IServiceScope scope, string id) + { + var collection = scope.ServiceProvider.GetRequiredService().GetCollection("videogame_reference"); + await collection.DeleteOneAsync(Builders.Filter.Eq(x => x.Id, id), TestContext.Current.CancellationToken); + } +} diff --git a/test/WebApi.IntegrationTests/Resources/VideoGameResourceTest.cs b/test/WebApi.IntegrationTests/Resources/VideoGameResourceTest.cs new file mode 100644 index 00000000..db8f7ac3 --- /dev/null +++ b/test/WebApi.IntegrationTests/Resources/VideoGameResourceTest.cs @@ -0,0 +1,109 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using AwesomeAssertions; +using Bogus; +using Keeptrack.Common.System; +using Keeptrack.WebApi.Contracts.Dto; +using Keeptrack.WebApi.IntegrationTests.Hosting; +using Xunit; + +namespace Keeptrack.WebApi.IntegrationTests.Resources; + +/// +/// Basic full-cycle CRUD coverage for VideoGame - closes a gap flagged in +/// docs/code-quality-findings.md ("...VideoGame still have none"), same shape as . +/// +public class VideoGameResourceTest(KestrelWebAppFactory factory) + : ResourceTestBase(factory) +{ + private const string ResourceEndpoint = "api/video-games"; + + [Fact] + public async Task VideoGameResourceFullCycle_IsOk() + { + await GetAsync($"/{ResourceEndpoint}", HttpStatusCode.Unauthorized); + + await Authenticate(); + + var input = new Faker() + .Rules((f, o) => + { + o.Title = f.Random.AlphaNumeric(14); + o.Platforms = [new VideoGamePlatformDto { Platform = "PC", CopyType = VideoGameCopyType.Physical, State = "Current" }]; + }) + .Generate(); + var created = await PostAsync($"/{ResourceEndpoint}", input); + created.Id.Should().NotBeNullOrEmpty(); + + try + { + created.Title = "New shiny title"; + await PutAsync($"/{ResourceEndpoint}/{created.Id}", created); + + var updated = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); + updated.Should().BeEquivalentTo(created); + + var finalItems = await GetAsync>($"/{ResourceEndpoint}"); + var firstItem = finalItems.Items.FirstOrDefault(x => x.Id == updated.Id); + firstItem.Should().NotBeNull(); + firstItem.Title.Should().Be(updated.Title); + } + finally + { + await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); + } + } + + [Fact] + public async Task VideoGameResourceSearch_FiltersToMatchingPlatform_IsOk() + { + await Authenticate(); + + var title = System.Guid.NewGuid().ToString(); + var created = await PostAsync($"/{ResourceEndpoint}", new VideoGameDto + { + Title = title, + Platforms = [new VideoGamePlatformDto { Platform = "PS5", CopyType = VideoGameCopyType.Physical, State = "Available" }] + }); + + try + { + var results = await GetAsync>($"/{ResourceEndpoint}?platform=PS5&search={title}"); + + results.Items.Should().ContainSingle(x => x.Id == created.Id); + } + finally + { + await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); + } + } + + [Fact] + public async Task VideoGameResourceOwnedAndWishlistedFilters_OnlyReturnMatchingItems_IsOk() + { + await Authenticate(); + + var title = System.Guid.NewGuid().ToString(); + var created = await PostAsync($"/{ResourceEndpoint}", new VideoGameDto + { + Title = title, + Platforms = [new VideoGamePlatformDto { Platform = "PS5", CopyType = VideoGameCopyType.Physical, State = "Available" }], + IsOwned = true, + IsWishlisted = true + }); + + try + { + var owned = await GetAsync>($"/{ResourceEndpoint}?IsOwned=true&search={title}"); + owned.Items.Should().ContainSingle(x => x.Id == created.Id); + + var wishlisted = await GetAsync>($"/{ResourceEndpoint}?IsWishlisted=true&search={title}"); + wishlisted.Items.Should().ContainSingle(x => x.Id == created.Id); + } + finally + { + await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); + } + } +} diff --git a/test/WebApi.IntegrationTests/WebApi.IntegrationTests.csproj b/test/WebApi.IntegrationTests/WebApi.IntegrationTests.csproj index c8f9a64a..9326cc03 100644 --- a/test/WebApi.IntegrationTests/WebApi.IntegrationTests.csproj +++ b/test/WebApi.IntegrationTests/WebApi.IntegrationTests.csproj @@ -12,7 +12,6 @@ - diff --git a/test/WebApi.UnitTests/Filters/ApiExceptionFilterAttributeTest.cs b/test/WebApi.UnitTests/Filters/ApiExceptionFilterAttributeTest.cs new file mode 100644 index 00000000..e0ae9934 --- /dev/null +++ b/test/WebApi.UnitTests/Filters/ApiExceptionFilterAttributeTest.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using AwesomeAssertions; +using Keeptrack.WebApi.Filters; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Keeptrack.WebApi.UnitTests.Filters; + +[Trait("Category", "UnitTests")] +public class ApiExceptionFilterAttributeTest +{ + private readonly ApiExceptionFilterAttribute _filter = new(NullLogger.Instance); + + private static ExceptionContext CreateContext(Exception exception) + { + var httpContext = new DefaultHttpContext(); + var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + return new ExceptionContext(actionContext, new List()) { Exception = exception }; + } + + [Fact] + public void OnException_MapsArgumentExceptionTo400() + { + var context = CreateContext(new ArgumentException("bad input")); + + _filter.OnException(context); + + context.HttpContext.Response.StatusCode.Should().Be(StatusCodes.Status400BadRequest); + } + + [Fact] + public void OnException_MapsArgumentNullExceptionTo400() + { + var context = CreateContext(new ArgumentNullException("param")); + + _filter.OnException(context); + + context.HttpContext.Response.StatusCode.Should().Be(StatusCodes.Status400BadRequest); + } + + /// + /// Everything else, including an exception surfacing from a failed external provider call (TMDB/RAWG/Open + /// Library/Discogs) once the HTTP resilience handler's retries are exhausted, maps to 500 - the request + /// still fails cleanly with a JSON body instead of an unhandled exception taking the process down. + /// + [Fact] + public void OnException_MapsAnyOtherExceptionTo500() + { + var context = CreateContext(new InvalidOperationException("external provider call failed")); + + _filter.OnException(context); + + context.HttpContext.Response.StatusCode.Should().Be(StatusCodes.Status500InternalServerError); + } + + [Fact] + public void OnException_SetsAJsonErrorBodyRatherThanLeakingTheRawException() + { + var context = CreateContext(new InvalidOperationException("boom")); + + _filter.OnException(context); + + var result = context.Result.Should().BeOfType().Subject; + result.Value.Should().BeEquivalentTo(new { error = "boom" }); + } +} diff --git a/test/WebApi.UnitTests/Import/Parsers/CsvTestHelper.cs b/test/WebApi.UnitTests/Import/Parsers/CsvTestHelper.cs new file mode 100644 index 00000000..84933929 --- /dev/null +++ b/test/WebApi.UnitTests/Import/Parsers/CsvTestHelper.cs @@ -0,0 +1,10 @@ +using System.IO; +using System.Text; + +namespace Keeptrack.WebApi.UnitTests.Import.Parsers; + +internal static class CsvTestHelper +{ + // FIXME: is it disposed? + public static Stream ToStream(string csv) => new MemoryStream(Encoding.UTF8.GetBytes(csv)); +} diff --git a/test/WebApi.UnitTests/Import/Parsers/EpisodeCommentsCsvParserTest.cs b/test/WebApi.UnitTests/Import/Parsers/EpisodeCommentsCsvParserTest.cs new file mode 100644 index 00000000..a75bf627 --- /dev/null +++ b/test/WebApi.UnitTests/Import/Parsers/EpisodeCommentsCsvParserTest.cs @@ -0,0 +1,26 @@ +using AwesomeAssertions; +using Keeptrack.WebApi.Import.Parsers; +using Xunit; + +namespace Keeptrack.WebApi.UnitTests.Import.Parsers; + +[Trait("Category", "UnitTests")] +public class EpisodeCommentsCsvParserTest +{ + [Fact] + public void Parse_ReturnsShowTitleSeasonEpisodeAndComment() + { + const string csv = """ + source,episode_number,episode_id,updated_at,spoiler_count,nb_likes,depth,comment_type,lang,highlight_level,same_ip_likes,episode_season_number,id,posted_on_fb,parent_comment_id,valid,nb_points,tv_show_name,user_id,comment,created_at,posted_on_twitter,unappropriate_count,extended_comment + mobile,10,6347559,2018-01-07 14:38:00,0,0,1,comment,en,5,0,1,10687229,0,10266504,0,0,Dark,13397917,I think so too,2018-01-07 14:38:00,0,0,null + """; + + var result = EpisodeCommentsCsvParser.Parse(CsvTestHelper.ToStream(csv)); + + result.Should().ContainSingle(); + result[0].ShowTitle.Should().Be("Dark"); + result[0].SeasonNumber.Should().Be(1); + result[0].EpisodeNumber.Should().Be(10); + result[0].Comment.Should().Be("I think so too"); + } +} diff --git a/test/WebApi.UnitTests/Import/Parsers/EpisodeWatchCsvParserTest.cs b/test/WebApi.UnitTests/Import/Parsers/EpisodeWatchCsvParserTest.cs new file mode 100644 index 00000000..20839bf8 --- /dev/null +++ b/test/WebApi.UnitTests/Import/Parsers/EpisodeWatchCsvParserTest.cs @@ -0,0 +1,30 @@ +using System; +using AwesomeAssertions; +using Keeptrack.WebApi.Import.Parsers; +using Xunit; + +namespace Keeptrack.WebApi.UnitTests.Import.Parsers; + +[Trait("Category", "UnitTests")] +public class EpisodeWatchCsvParserTest +{ + [Fact] + public void Parse_ExtractsOnlyGenuineEpisodeWatchRows() + { + const string csv = """ + s_id,user_id,episode_id,series_name,gsi,runtime,created_at,season_number,episode_number,ep_no,ep_id,s_no,key,ep_watch_count,total_movies_runtime,total_series_runtime,series_follow_count,movie_watch_count,updated_at,is_followed,most_recent_ep_watched,is_for_later,uuid,followed_at,is_archived,is_unitary,rewatch_count,bulk_type,is_special + 258541,13397917,,Chicago Fire,,,2017-06-09 22:13:33,,,,,,user-series-e8fb922a-0799-4daf-b1b9-2d3d25f92e8e,292,,,,,2026-05-04 11:47:17,true,map[ep_id:1.1680091e+07 ep_no:18 s_no:14 uuid:40c0029d-af3f-49fb-968f-195f01df9463 watch_date:1.777895237135508e+15],false,e8fb922a-0799-4daf-b1b9-2d3d25f92e8e,,false,,,, + 258541,13397917,8956036,Chicago Fire,watch-episode-1702296819,2520,2023-12-11 12:13:39,10,14,14,8956036,10,watch-episode-e8fb922a-0799-4daf-b1b9-2d3d25f92e8e-001e2d1e-1621-4380-8f76-8f3730dd6a01,,,,,,2023-12-11 12:13:39,,,,,,,true,,, + """; + + var result = EpisodeWatchCsvParser.Parse(CsvTestHelper.ToStream(csv)); + + // the per-show summary row (empty "gsi") is skipped; only the "watch-episode-*" row survives + result.Should().ContainSingle(); + result[0].ShowTitle.Should().Be("Chicago Fire"); + result[0].TvShowId.Should().Be("258541"); + result[0].SeasonNumber.Should().Be(10); + result[0].EpisodeNumber.Should().Be(14); + result[0].WatchedAt.Should().Be(new DateTime(2023, 12, 11, 12, 13, 39)); + } +} diff --git a/test/WebApi.UnitTests/Import/Parsers/FavoriteMoviesListParserTest.cs b/test/WebApi.UnitTests/Import/Parsers/FavoriteMoviesListParserTest.cs new file mode 100644 index 00000000..5796b685 --- /dev/null +++ b/test/WebApi.UnitTests/Import/Parsers/FavoriteMoviesListParserTest.cs @@ -0,0 +1,41 @@ +using AwesomeAssertions; +using Keeptrack.WebApi.Import.Parsers; +using Xunit; + +namespace Keeptrack.WebApi.UnitTests.Import.Parsers; + +[Trait("Category", "UnitTests")] +public class FavoriteMoviesListParserTest +{ + [Fact] + public void ParseObjectsField_ExtractsOnlyMovieUuids() + { + const string objectsField = "[map[created_at:1.566819863e+09 type:movie uuid:1be8d227-5d39-4561-8dfa-7520b8c51d0f] " + + "map[created_at:1.563396726e+09 id:70761 type:series] " + + "map[created_at:1.586728877e+09 type:movie uuid:f891ce51-fcd0-47ff-b05d-7131efafa20e]]"; + + var result = FavoriteMoviesListParser.ParseObjectsField(objectsField); + + result.Should().BeEquivalentTo(["1be8d227-5d39-4561-8dfa-7520b8c51d0f", "f891ce51-fcd0-47ff-b05d-7131efafa20e"]); + } + + [Fact] + public void ParseObjectsField_IgnoresSeriesEntriesThatAlsoCarryAUuid() + { + const string objectsField = "[map[created_at:1.729463362e+09 id:269586 type:series uuid:39fe9fe1-5dc7-4b4d-92f2-1b536c17f0ed]]"; + + var result = FavoriteMoviesListParser.ParseObjectsField(objectsField); + + result.Should().BeEmpty(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void ParseObjectsField_ReturnsEmptySet_WhenFieldIsMissing(string? objectsField) + { + var result = FavoriteMoviesListParser.ParseObjectsField(objectsField); + + result.Should().BeEmpty(); + } +} diff --git a/test/WebApi.UnitTests/Import/Parsers/FollowedShowsCsvParserTest.cs b/test/WebApi.UnitTests/Import/Parsers/FollowedShowsCsvParserTest.cs new file mode 100644 index 00000000..8e405abf --- /dev/null +++ b/test/WebApi.UnitTests/Import/Parsers/FollowedShowsCsvParserTest.cs @@ -0,0 +1,24 @@ +using AwesomeAssertions; +using Keeptrack.WebApi.Import.Parsers; +using Xunit; + +namespace Keeptrack.WebApi.UnitTests.Import.Parsers; + +[Trait("Category", "UnitTests")] +public class FollowedShowsCsvParserTest +{ + [Fact] + public void Parse_ReturnsShowIdAndTitle() + { + const string csv = """ + updated_at,active,notification_type,folder_id,archived,notification_offset,user_id,tv_show_id,tv_show_name,created_at,diffusion + 2017-12-23 22:16:20,1,2,,0,1440,13397917,70327,Buffy the Vampire Slayer,2017-12-23 22:16:20,original + """; + + var result = FollowedShowsCsvParser.Parse(CsvTestHelper.ToStream(csv)); + + result.Should().ContainSingle(); + result[0].TvShowId.Should().Be("70327"); + result[0].Title.Should().Be("Buffy the Vampire Slayer"); + } +} diff --git a/test/WebApi.UnitTests/Import/Parsers/LegacyEpisodeWatchCsvParserTest.cs b/test/WebApi.UnitTests/Import/Parsers/LegacyEpisodeWatchCsvParserTest.cs new file mode 100644 index 00000000..c1f9c1c5 --- /dev/null +++ b/test/WebApi.UnitTests/Import/Parsers/LegacyEpisodeWatchCsvParserTest.cs @@ -0,0 +1,30 @@ +using System; +using AwesomeAssertions; +using Keeptrack.WebApi.Import.Parsers; +using Xunit; + +namespace Keeptrack.WebApi.UnitTests.Import.Parsers; + +[Trait("Category", "UnitTests")] +public class LegacyEpisodeWatchCsvParserTest +{ + [Fact] + public void Parse_ExtractsOnlyGenuineEpisodeWatchRows() + { + const string csv = """ + series_name,uuid,type-uuid-n,watch_count,type,updated_at,created_at,series_id,user_id,watches,movie_name,runtime,entity_type,alpha_range_key,follow_date_range_key,release_date,release_date_range_key,rewatch_count,series_uuid,season_number,episode_id,watch_date,episode_number,total_movies_runtime,total_series_runtime,country,bulk_type,watched_episode_range_key,watch_date_range_key,unitarian + Chicago Fire,e8fb922a-0799-4daf-b1b9-2d3d25f92e8e,count-watch-episode-series-e8fb922a-0799-4daf-b1b9-2d3d25f92e8e,16,count-watch-episode-series,2021-10-28 10:53:37,2021-01-19 14:37:14,258541,13397917,,,,,,,,,,,,,,,,,,,,, + Chicago Fire,01ef3f79-a57b-4913-861e-1c9c3a4f7122,watch-01ef3f79-a57b-4913-861e-1c9c3a4f7122-0,,watch,2021-06-22 11:22:30,2021-06-22 11:22:30,258541,13397917,,,,episode,,,,,,e8fb922a-0799-4daf-b1b9-2d3d25f92e8e,9,8305607,1624360950,16,,,us,,watched-episode-e8fb922a-0799-4daf-b1b9-2d3d25f92e8e-01ef3f79-a57b-4913-861e-1c9c3a4f7122,watch-date-1624360950,true + """; + + var result = LegacyEpisodeWatchCsvParser.Parse(CsvTestHelper.ToStream(csv)); + + // the "count-watch-episode-series" summary row is skipped; only the "watch"/"episode" row survives + result.Should().ContainSingle(); + result[0].ShowTitle.Should().Be("Chicago Fire"); + result[0].TvShowId.Should().Be("258541"); + result[0].SeasonNumber.Should().Be(9); + result[0].EpisodeNumber.Should().Be(16); + result[0].WatchedAt.Should().Be(new DateTime(2021, 6, 22, 11, 22, 30)); + } +} diff --git a/test/WebApi.UnitTests/Import/Parsers/MovieTrackingEventsCsvParserTest.cs b/test/WebApi.UnitTests/Import/Parsers/MovieTrackingEventsCsvParserTest.cs new file mode 100644 index 00000000..a9a318bb --- /dev/null +++ b/test/WebApi.UnitTests/Import/Parsers/MovieTrackingEventsCsvParserTest.cs @@ -0,0 +1,35 @@ +using System; +using AwesomeAssertions; +using Keeptrack.WebApi.Import.Parsers; +using Xunit; + +namespace Keeptrack.WebApi.UnitTests.Import.Parsers; + +[Trait("Category", "UnitTests")] +public class MovieTrackingEventsCsvParserTest +{ + private const string Header = + "series_name,uuid,type-uuid-n,watch_count,type,updated_at,created_at,series_id,user_id,watches,movie_name,runtime,entity_type,alpha_range_key,follow_date_range_key,release_date,release_date_range_key,rewatch_count,series_uuid,season_number,episode_id,watch_date,episode_number,total_movies_runtime,total_series_runtime,country,bulk_type,watched_episode_range_key,watch_date_range_key,unitarian"; + + [Fact] + public void Parse_ExtractsWatchTowatchAndFollowMovieRows_AndSkipsEpisodeRows() + { + var csv = string.Join('\n', + Header, + // watch: entity_type "movie", type "watch" + ",008d62e0-8f51-496d-bbc4-278866bbe82d,watch-008d62e0-0,,watch,2020-02-11 23:09:28,2020-02-11 23:09:28,,13397917,,Fury,,movie,,,0001-01-01 00:00:00,,0,,,,,,,,,,,,", + // towatch: entity_type "movie", type "towatch" + ",05807f03-e989-47e3-8494-7964df0a7f12,towatch-05807f03,,towatch,2022-07-07 22:55:23,2022-07-07 22:55:23,,13397917,,Thor: Love and Thunder,7140,movie,,,2022-07-13 00:00:00,,,,,,,,,,,,,,", + // follow: entity_type "movie", type "follow" (no watch/towatch signal, still exists) + ",0075d259-c3b2-43ad-a481-1b3af1ef80da,follow-0075d259-0,,follow,2023-10-19 22:30:22,2023-10-19 22:30:22,,13397917,,Centurion,5520,movie,,,2009-07-21 00:00:00,,,,,,,,,,,,,,", + // a genuine episode watch row (entity_type "episode") must be skipped entirely + "Chicago Fire,01ef3f79-a57b-4913-861e-1c9c3a4f7122,watch-01ef3f79-0,,watch,2021-06-22 11:22:30,2021-06-22 11:22:30,258541,13397917,,,,episode,,,,,,e8fb922a,9,8305607,1624360950,16,,,us,,watched-episode,watch-date-1624360950,true"); + + var result = MovieTrackingEventsCsvParser.Parse(CsvTestHelper.ToStream(csv)); + + result.Should().HaveCount(3); + result.Should().Contain(r => r.MovieName == "Fury" && r.EventType == MovieTrackingEventType.Watched && r.CreatedAt == new DateTime(2020, 2, 11, 23, 9, 28)); + result.Should().Contain(r => r.MovieName == "Thor: Love and Thunder" && r.EventType == MovieTrackingEventType.WantToWatch); + result.Should().Contain(r => r.MovieName == "Centurion" && r.EventType == MovieTrackingEventType.Followed); + } +} diff --git a/test/WebApi.UnitTests/Import/Parsers/MovieVotesCsvParserTest.cs b/test/WebApi.UnitTests/Import/Parsers/MovieVotesCsvParserTest.cs new file mode 100644 index 00000000..0d943ba4 --- /dev/null +++ b/test/WebApi.UnitTests/Import/Parsers/MovieVotesCsvParserTest.cs @@ -0,0 +1,39 @@ +using AwesomeAssertions; +using Keeptrack.WebApi.Import.Parsers; +using Xunit; + +namespace Keeptrack.WebApi.UnitTests.Import.Parsers; + +[Trait("Category", "UnitTests")] +public class MovieVotesCsvParserTest +{ + [Fact] + public void Parse_SkipsRowsWithoutAMovieName() + { + const string csv = """ + uuid,user_id,episode_id,movie_name,vote_key,series_name,season_number,episode_number + 4273cc26-528e-463e-814e-534e3e50eceb,13397917,0,Bohemian Rhapsody,4273cc26-528e-463e-814e-534e3e50eceb-13397917-3,,, + aaaaaaaa-528e-463e-814e-534e3e50eceb,13397917,4383263,,aaaaaaaa-13397917-3,The Americans,1,1 + """; + + var result = MovieVotesCsvParser.Parse(CsvTestHelper.ToStream(csv)); + + result.Should().ContainSingle(); + result[0].Uuid.Should().Be("4273cc26-528e-463e-814e-534e3e50eceb"); + result[0].MovieName.Should().Be("Bohemian Rhapsody"); + } + + [Fact] + public void Parse_HandlesTheDifferentColumnOrderOfTheLiveVotesFile() + { + const string csv = """ + vote_key,episode_id,user_id,uuid,movie_name + 46166a01-b4cf-41ec-8a3b-51e1adbe9c44-13397917-29,0,13397917,46166a01-b4cf-41ec-8a3b-51e1adbe9c44,Terminator: Dark Fate + """; + + var result = MovieVotesCsvParser.Parse(CsvTestHelper.ToStream(csv)); + + result.Should().ContainSingle(); + result[0].MovieName.Should().Be("Terminator: Dark Fate"); + } +} diff --git a/test/WebApi.UnitTests/Import/Parsers/SeenEpisodesCsvParserTest.cs b/test/WebApi.UnitTests/Import/Parsers/SeenEpisodesCsvParserTest.cs new file mode 100644 index 00000000..3b418cd9 --- /dev/null +++ b/test/WebApi.UnitTests/Import/Parsers/SeenEpisodesCsvParserTest.cs @@ -0,0 +1,27 @@ +using System; +using AwesomeAssertions; +using Keeptrack.WebApi.Import.Parsers; +using Xunit; + +namespace Keeptrack.WebApi.UnitTests.Import.Parsers; + +[Trait("Category", "UnitTests")] +public class SeenEpisodesCsvParserTest +{ + [Fact] + public void Parse_ReturnsShowTitleSeasonEpisodeAndWatchedDate() + { + const string csv = """ + updated_at,tv_show_name,episode_season_number,episode_number,user_id,episode_id,source,created_at + 2018-10-08 20:35:17,Charmed,1,1,13397917,16029,episode-detail,2018-10-08 20:35:17 + """; + + var result = SeenEpisodesCsvParser.Parse(CsvTestHelper.ToStream(csv)); + + result.Should().ContainSingle(); + result[0].ShowTitle.Should().Be("Charmed"); + result[0].SeasonNumber.Should().Be(1); + result[0].EpisodeNumber.Should().Be(1); + result[0].WatchedAt.Should().Be(new DateTime(2018, 10, 8, 20, 35, 17)); + } +} diff --git a/test/WebApi.UnitTests/Import/Parsers/ShowActivityCsvParserTest.cs b/test/WebApi.UnitTests/Import/Parsers/ShowActivityCsvParserTest.cs new file mode 100644 index 00000000..a1d6b55b --- /dev/null +++ b/test/WebApi.UnitTests/Import/Parsers/ShowActivityCsvParserTest.cs @@ -0,0 +1,24 @@ +using AwesomeAssertions; +using Keeptrack.WebApi.Import.Parsers; +using Xunit; + +namespace Keeptrack.WebApi.UnitTests.Import.Parsers; + +[Trait("Category", "UnitTests")] +public class ShowActivityCsvParserTest +{ + [Fact] + public void Parse_ReturnsShowIdAndEpisodesSeenCount() + { + const string csv = """ + user_id,tv_show_id,is_followed,is_favorited,nb_episodes_seen,tv_show_name + 13397917,258541,1,0,291,Chicago Fire + """; + + var result = ShowActivityCsvParser.Parse(CsvTestHelper.ToStream(csv)); + + result.Should().ContainSingle(); + result[0].TvShowId.Should().Be("258541"); + result[0].EpisodesSeenCount.Should().Be(291); + } +} diff --git a/test/WebApi.UnitTests/Import/Parsers/ShowCommentsCsvParserTest.cs b/test/WebApi.UnitTests/Import/Parsers/ShowCommentsCsvParserTest.cs new file mode 100644 index 00000000..8a942c31 --- /dev/null +++ b/test/WebApi.UnitTests/Import/Parsers/ShowCommentsCsvParserTest.cs @@ -0,0 +1,22 @@ +using AwesomeAssertions; +using Keeptrack.WebApi.Import.Parsers; +using Xunit; + +namespace Keeptrack.WebApi.UnitTests.Import.Parsers; + +[Trait("Category", "UnitTests")] +public class ShowCommentsCsvParserTest +{ + [Fact] + public void Parse_PreservesMultiLineQuotedComment() + { + const string csv = "spoiler_count,nb_likes,lang,tv_show_name,created_at,parent_comment_id,source,only_to_fans,user_id,posted_on_fb,posted_on_twitter,comment_type,highlight_level,extended_comment,id,tv_show_id,comment,updated_at,unappropriate_count,depth,valid,same_ip_likes,featured\n" + + "0,3,en,Dark,2019-05-12 18:45:53,,mobile,1,13397917,0,0,comment,5,null,1244578,334824,\"Amazing show, really worth watching.\nI don't know another show like this one.\",2019-05-12 18:45:53,0,0,1,0,0\n"; + + var result = ShowCommentsCsvParser.Parse(CsvTestHelper.ToStream(csv)); + + result.Should().ContainSingle(); + result[0].TvShowId.Should().Be("334824"); + result[0].Comment.Should().Be("Amazing show, really worth watching.\nI don't know another show like this one."); + } +} diff --git a/test/WebApi.UnitTests/Import/Parsers/ShowRatingsCsvParserTest.cs b/test/WebApi.UnitTests/Import/Parsers/ShowRatingsCsvParserTest.cs new file mode 100644 index 00000000..918254e9 --- /dev/null +++ b/test/WebApi.UnitTests/Import/Parsers/ShowRatingsCsvParserTest.cs @@ -0,0 +1,24 @@ +using AwesomeAssertions; +using Keeptrack.WebApi.Import.Parsers; +using Xunit; + +namespace Keeptrack.WebApi.UnitTests.Import.Parsers; + +[Trait("Category", "UnitTests")] +public class ShowRatingsCsvParserTest +{ + [Fact] + public void Parse_ReturnsShowIdAndRating() + { + const string csv = """ + created_at,updated_at,tv_show_name,user_id,tv_show_id,rating + 2018-06-01 20:16:07,2018-06-01 20:16:07,Prison Break,13397917,75340,2.50 + """; + + var result = ShowRatingsCsvParser.Parse(CsvTestHelper.ToStream(csv)); + + result.Should().ContainSingle(); + result[0].TvShowId.Should().Be("75340"); + result[0].Rating.Should().Be(2.50f); + } +} diff --git a/test/WebApi.UnitTests/Import/Parsers/ShowStatusCsvParserTest.cs b/test/WebApi.UnitTests/Import/Parsers/ShowStatusCsvParserTest.cs new file mode 100644 index 00000000..9cf8a098 --- /dev/null +++ b/test/WebApi.UnitTests/Import/Parsers/ShowStatusCsvParserTest.cs @@ -0,0 +1,26 @@ +using System.Linq; +using AwesomeAssertions; +using Keeptrack.WebApi.Import.Parsers; +using Xunit; + +namespace Keeptrack.WebApi.UnitTests.Import.Parsers; + +[Trait("Category", "UnitTests")] +public class ShowStatusCsvParserTest +{ + [Fact] + public void Parse_ReturnsFavoriteAndForLaterStatuses() + { + const string csv = """ + created_at,updated_at,tv_show_name,user_id,tv_show_id,status + 2018-10-21 10:25:34,2018-10-21 10:25:34,Charmed,13397917,70626,for_later + 2018-11-14 20:25:51,2018-11-14 20:25:51,ER,13397917,70761,favorite + """; + + var result = ShowStatusCsvParser.Parse(CsvTestHelper.ToStream(csv)); + + result.Should().HaveCount(2); + result.Single(r => r.TvShowId == "70626").Status.Should().Be(ShowStatusCsvParser.ForLaterStatus); + result.Single(r => r.TvShowId == "70761").Status.Should().Be(ShowStatusCsvParser.FavoriteStatus); + } +} diff --git a/test/WebApi.UnitTests/Import/TvTimeImportServiceIdempotencyTest.cs b/test/WebApi.UnitTests/Import/TvTimeImportServiceIdempotencyTest.cs new file mode 100644 index 00000000..8915fe95 --- /dev/null +++ b/test/WebApi.UnitTests/Import/TvTimeImportServiceIdempotencyTest.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using AwesomeAssertions; +using Keeptrack.Common.System; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.WebApi.Import; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Keeptrack.WebApi.UnitTests.Import; + +/// +/// The bug these tests guard: shows/movies used to be matched by title, but reference enrichment +/// rewrites the stored Title to a provider's canonical name after the first import - so re-importing the +/// same export no longer matched and created duplicates (which then cascaded to duplicated episodes under +/// the new show id). The fix stamps every imported record with a stable, enrichment-immutable TvTimeId +/// and matches on that. +/// +[Trait("Category", "UnitTests")] +public class TvTimeImportServiceIdempotencyTest +{ + private const string OwnerId = "owner-1"; + private const string ShowTitle = "Test Show"; + private const string MovieTitle = "Test Movie"; + + [Fact] + public async Task ReImport_AfterEnrichmentRenamedTitles_DoesNotDuplicate_AndSkipsExisting() + { + var shows = new FakeTvShowRepository(); + var movies = new FakeMovieRepository(); + var episodes = new FakeEpisodeRepository(); + var service = NewService(shows, movies, episodes); + + var zip = BuildZip(); + + var first = await service.ImportAsync(new MemoryStream(zip), OwnerId); + first.ShowsCreated.Should().Be(1); + first.EpisodesCreated.Should().Be(2); + first.MoviesCreated.Should().Be(1); + first.ShowsSkipped.Should().Be(0); + first.EpisodesSkipped.Should().Be(0); + first.MoviesSkipped.Should().Be(0); + + // Simulate what reference enrichment does after the first import: it overwrites the stored Title + // with the provider's canonical name. This is exactly what used to break title-based matching. + shows.Single().Title = "Some Completely Different Canonical Show Name"; + movies.Single().Title = "Some Completely Different Canonical Movie Name"; + + var second = await service.ImportAsync(new MemoryStream(zip), OwnerId); + + // Nothing new created, everything recognized as already present - and, crucially, no duplicates. + second.ShowsCreated.Should().Be(0); + second.EpisodesCreated.Should().Be(0); + second.MoviesCreated.Should().Be(0); + second.ShowsSkipped.Should().Be(1); + second.EpisodesSkipped.Should().Be(2); + second.MoviesSkipped.Should().Be(1); + + shows.Items.Should().HaveCount(1); + movies.Items.Should().HaveCount(1); + episodes.Items.Should().HaveCount(2); + } + + [Fact] + public async Task Import_AdoptsAPreExistingRecordThatHasNoTvTimeIdYet_InsteadOfDuplicating() + { + var shows = new FakeTvShowRepository(); + var movies = new FakeMovieRepository(); + var episodes = new FakeEpisodeRepository(); + + // A record created by an import that predated stable-id matching: same title, but no TvTimeId. + await shows.CreateAsync(new TvShowModel { OwnerId = OwnerId, Title = ShowTitle, TvTimeId = null }); + + var service = NewService(shows, movies, episodes); + + var result = await service.ImportAsync(new MemoryStream(BuildZip()), OwnerId); + + result.ShowsCreated.Should().Be(0); + result.ShowsSkipped.Should().Be(1); + shows.Items.Should().HaveCount(1, "the pre-existing record is adopted, not duplicated"); + shows.Single().TvTimeId.Should().Be("100", "its stable id is back-filled so the next re-import matches by id"); + } + + private static TvTimeImportService NewService( + FakeTvShowRepository shows, FakeMovieRepository movies, FakeEpisodeRepository episodes) => + new(shows, episodes, movies, new ThrowingScopeFactory(), NullLogger.Instance); + + /// + /// A minimal but realistic export: a followed show carrying its TV Time id, two of its episodes in the + /// id-less seen_episode_source.csv (which must still resolve to that show), and a movie discovered + /// through a tracking watch event carrying its stable per-movie uuid. + /// + private static byte[] BuildZip() + { + var entries = new Dictionary + { + ["followed_tv_show.csv"] = $"tv_show_id,tv_show_name\n100,{ShowTitle}\n", + ["seen_episode_source.csv"] = + "tv_show_name,episode_season_number,episode_number,created_at\n" + + $"{ShowTitle},1,1,2020-01-02 00:00:00\n" + + $"{ShowTitle},1,2,2020-01-03 00:00:00\n", + ["tracking-prod-records.csv"] = + "series_name,uuid,type,created_at,series_id,user_id,movie_name,entity_type,season_number,episode_number\n" + + $",movie-uuid-1,watch,2020-01-07 00:00:00,,999,{MovieTitle},movie,,\n" + }; + + using var zipStream = new MemoryStream(); + using (var archive = new ZipArchive(zipStream, ZipArchiveMode.Create, leaveOpen: true)) + { + foreach (var (name, content) in entries) + { + using var writer = new StreamWriter(archive.CreateEntry(name).Open(), Encoding.UTF8); + writer.Write(content); + } + } + + return zipStream.ToArray(); + } + + /// The import fires a best-effort reference match on a background DI scope; this stands in for + /// the scope factory and makes that fire-and-forget path a caught no-op, which the import tolerates. + private sealed class ThrowingScopeFactory : IServiceScopeFactory + { + public IServiceScope CreateScope() => throw new InvalidOperationException("No DI scope in unit tests."); + } + + private class InMemoryRepository(Func? matchesInput = null) + where TModel : class, IHasIdAndOwnerId + { + private readonly Func _matchesInput = matchesInput ?? ((_, _) => true); + + public List Items { get; } = []; + + public TModel Single() => Items.Single(); + + public Task FindOneAsync(string id, string ownerId) => + Task.FromResult(Items.FirstOrDefault(x => x.Id == id && x.OwnerId == ownerId)); + + public Task> FindAllAsync(string ownerId, int page, int pageSize, string? search, TModel input) + { + var items = Items.Where(x => x.OwnerId == ownerId && _matchesInput(x, input)).ToList(); + return Task.FromResult(new PagedResult(items, items.Count, page, pageSize)); + } + + public Task CreateAsync(TModel model) + { + model.Id ??= Guid.NewGuid().ToString(); + Items.Add(model); + return Task.FromResult(model); + } + + public Task UpdateAsync(string id, TModel model, string ownerId) + { + var index = Items.FindIndex(x => x.Id == id && x.OwnerId == ownerId); + if (index < 0) return Task.FromResult(0L); + Items[index] = model; + return Task.FromResult(1L); + } + + public Task DeleteAsync(string id, string ownerId) => + Task.FromResult((long)Items.RemoveAll(x => x.Id == id && x.OwnerId == ownerId)); + } + + private sealed class FakeTvShowRepository : InMemoryRepository, ITvShowRepository + { + public Task SetReferenceLinkAsync(string title, int? year, string referenceId, string canonicalTitle, int? canonicalYear = null) => + Task.FromResult(0L); + + public Task> FindDistinctUnresolvedTitleYearsAsync() => + Task.FromResult>([]); + } + + private sealed class FakeMovieRepository : InMemoryRepository, IMovieRepository + { + public Task SetReferenceLinkAsync(string title, int? year, string referenceId, string canonicalTitle, int? canonicalYear = null) => + Task.FromResult(0L); + + public Task> FindDistinctUnresolvedTitleYearsAsync() => + Task.FromResult>([]); + } + + private sealed class FakeEpisodeRepository() + : InMemoryRepository((episode, input) => episode.TvShowId == input.TvShowId), IEpisodeRepository; +} diff --git a/test/WebApi.UnitTests/Jobs/JobStoreTest.cs b/test/WebApi.UnitTests/Jobs/JobStoreTest.cs new file mode 100644 index 00000000..e18c5b20 --- /dev/null +++ b/test/WebApi.UnitTests/Jobs/JobStoreTest.cs @@ -0,0 +1,91 @@ +using System; +using AwesomeAssertions; +using Keeptrack.WebApi.Jobs; +using Xunit; + +namespace Keeptrack.WebApi.UnitTests.Jobs; + +/// +/// Covers directly - the shared job-tracking logic behind both TV +/// Time import and reference-data "sync now", generalized out of the old TV-Time-only ImportJobStore +/// so the two features share one implementation instead of duplicating the same create/update/complete/ +/// fail/owner-scoping logic. +/// +[Trait("Category", "UnitTests")] +public class JobStoreTest +{ + private enum TestStage + { + Running, + Completed, + Failed + } + + [Fact] + public void Create_ThenGetStatus_ReflectsTheInitialStage() + { + var store = new JobStore(); + + var jobId = store.Create("owner-1", TestStage.Running); + var status = store.GetStatus(jobId, "owner-1"); + + status.Should().NotBeNull(); + status!.Value.Stage.Should().Be(TestStage.Running); + status.Value.Result.Should().BeNull(); + status.Value.ErrorMessage.Should().BeNull(); + } + + [Fact] + public void UpdateStage_IsReflectedOnTheNextGetStatus() + { + var store = new JobStore(); + var jobId = store.Create("owner-1", TestStage.Running); + + store.UpdateStage(jobId, TestStage.Completed); + + store.GetStatus(jobId, "owner-1")!.Value.Stage.Should().Be(TestStage.Completed); + } + + [Fact] + public void Complete_SetsTheTerminalStageAndResult() + { + var store = new JobStore(); + var jobId = store.Create("owner-1", TestStage.Running); + + store.Complete(jobId, TestStage.Completed, "all done"); + + var status = store.GetStatus(jobId, "owner-1"); + status!.Value.Stage.Should().Be(TestStage.Completed); + status.Value.Result.Should().Be("all done"); + } + + [Fact] + public void Fail_SetsTheTerminalStageAndErrorMessage() + { + var store = new JobStore(); + var jobId = store.Create("owner-1", TestStage.Running); + + store.Fail(jobId, TestStage.Failed, "boom"); + + var status = store.GetStatus(jobId, "owner-1"); + status!.Value.Stage.Should().Be(TestStage.Failed); + status.Value.ErrorMessage.Should().Be("boom"); + } + + [Fact] + public void GetStatus_ReturnsNull_ForAnUnknownJobId() + { + var store = new JobStore(); + + store.GetStatus(Guid.NewGuid(), "owner-1").Should().BeNull(); + } + + [Fact] + public void GetStatus_ReturnsNull_ForAJobBelongingToADifferentOwner() + { + var store = new JobStore(); + var jobId = store.Create("owner-1", TestStage.Running); + + store.GetStatus(jobId, "owner-2").Should().BeNull(); + } +} diff --git a/test/WebApi.UnitTests/MappingProfiles/AutoMapperConfigurationTest.cs b/test/WebApi.UnitTests/MappingProfiles/AutoMapperConfigurationTest.cs deleted file mode 100644 index 4f4a23bc..00000000 --- a/test/WebApi.UnitTests/MappingProfiles/AutoMapperConfigurationTest.cs +++ /dev/null @@ -1,20 +0,0 @@ -using AutoMapper; -using Microsoft.Extensions.Logging.Abstractions; -using Xunit; - -namespace Keeptrack.WebApi.UnitTests.MappingProfiles; - -[Trait("Category", "UnitTests")] -public class AutoMapperConfigurationTest -{ - [Fact] - public void WebApiAutoMapperProfile_ShouldBeValid() - { - var config = new MapperConfiguration(config => - { - config.AddMaps(typeof(Program).Assembly); - }, NullLoggerFactory.Instance); - - config.AssertConfigurationIsValid(); - } -} diff --git a/test/WebApi.UnitTests/ReferenceData/ExternalProviderResilienceTest.cs b/test/WebApi.UnitTests/ReferenceData/ExternalProviderResilienceTest.cs new file mode 100644 index 00000000..5abb44a3 --- /dev/null +++ b/test/WebApi.UnitTests/ReferenceData/ExternalProviderResilienceTest.cs @@ -0,0 +1,92 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using AwesomeAssertions; +using Keeptrack.WebApi.ReferenceData; +using Microsoft.Extensions.DependencyInjection; +using Polly; +using Xunit; + +namespace Keeptrack.WebApi.UnitTests.ReferenceData; + +/// +/// Exercises the same AddStandardResilienceHandler() wiring Program.cs applies to every external +/// provider client (TMDB/RAWG/Open Library/Discogs) - tested once here against as +/// a representative example, since the wiring is identical for all four and duplicating this per-provider +/// would just be the same test four times over. Confirms the actual regression this handler guards against: +/// a transient failure from the external provider is retried and recovered rather than surfacing to the +/// caller, and a provider that stays down fails cleanly (a normal exception, mapped to a JSON 500 by +/// ) instead of hanging indefinitely. +/// +[Trait("Category", "UnitTests")] +public class ExternalProviderResilienceTest +{ + private static IRawgClient BuildClient(HttpMessageHandler primaryHandler, Action? configure = null) + { + var services = new ServiceCollection(); + services.AddSingleton(new RawgSettings { ApiKey = "test-key" }); + services.AddHttpClient(client => client.BaseAddress = new Uri("https://example.test/")) + .ConfigurePrimaryHttpMessageHandler(() => primaryHandler) + .AddStandardResilienceHandler(options => + { + // shortened so the test runs fast and deterministically - same strategies as production + // (retry, attempt timeout, total timeout, circuit breaker), just faster delays. + options.Retry.MaxRetryAttempts = 3; + options.Retry.Delay = TimeSpan.FromMilliseconds(1); + options.Retry.BackoffType = DelayBackoffType.Constant; + options.Retry.UseJitter = false; + options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(2); + options.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(4); + options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(5); + configure?.Invoke(options); + }); + + return services.BuildServiceProvider().GetRequiredService(); + } + + private sealed class StubHttpMessageHandler(Func respond) : HttpMessageHandler + { + public int CallCount { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + CallCount++; + return Task.FromResult(respond(CallCount)); + } + } + + private static HttpResponseMessage SuccessResponse() => new(HttpStatusCode.OK) + { + Content = new StringContent( + """{"results":[{"id":1,"name":"Nioh 3","released":"2026-03-01","background_image":null}]}""", + Encoding.UTF8, "application/json") + }; + + [Fact] + public async Task StandardResilienceHandler_RetriesATransientFailureAndRecovers() + { + var handler = new StubHttpMessageHandler(callCount => + callCount < 3 ? new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) : SuccessResponse()); + var client = BuildClient(handler); + + var results = await client.SearchGamesAsync("Nioh 3", null, TestContext.Current.CancellationToken); + + results.Should().ContainSingle(r => r.Title == "Nioh 3"); + handler.CallCount.Should().Be(3); + } + + [Fact] + public async Task StandardResilienceHandler_FailsCleanlyInsteadOfHangingWhenTheProviderStaysDown() + { + var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)); + var client = BuildClient(handler); + + var act = async () => await client.SearchGamesAsync("Nioh 3", null); + + await act.Should().ThrowAsync(); + handler.CallCount.Should().Be(4); // 1 initial attempt + 3 retries, then it gives up + } +} diff --git a/test/WebApi.UnitTests/ReferenceData/FakeBookReferenceClient.cs b/test/WebApi.UnitTests/ReferenceData/FakeBookReferenceClient.cs new file mode 100644 index 00000000..31c0efe0 --- /dev/null +++ b/test/WebApi.UnitTests/ReferenceData/FakeBookReferenceClient.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Keeptrack.WebApi.ReferenceData; + +namespace Keeptrack.WebApi.UnitTests.ReferenceData; + +internal sealed class FakeBookReferenceClient : IBookReferenceClient +{ + private readonly List _searchResults; + + public string ProviderKey => "openlibrary"; + + public Dictionary Details { get; } = new(); + + /// The author passed to the most recent call, for assertions. + public string? LastSearchAuthor { get; private set; } + + private FakeBookReferenceClient(List searchResults) => _searchResults = searchResults; + + public static FakeBookReferenceClient Empty() => new([]); + + public static FakeBookReferenceClient WithSearchResults(params BookSearchResult[] results) => new([.. results]); + + public Task> SearchBooksAsync(string title, int? year, string? author = null, CancellationToken cancellationToken = default) + { + LastSearchAuthor = author; + return Task.FromResult>(_searchResults); + } + + public Task GetBookDetailsAsync(string externalId, CancellationToken cancellationToken = default) => + Task.FromResult(Details.GetValueOrDefault(externalId)); +} diff --git a/test/WebApi.UnitTests/ReferenceData/FakeDiscogsClient.cs b/test/WebApi.UnitTests/ReferenceData/FakeDiscogsClient.cs new file mode 100644 index 00000000..abec5346 --- /dev/null +++ b/test/WebApi.UnitTests/ReferenceData/FakeDiscogsClient.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Keeptrack.WebApi.ReferenceData; + +namespace Keeptrack.WebApi.UnitTests.ReferenceData; + +internal sealed class FakeDiscogsClient : IDiscogsClient +{ + private readonly List _searchResults; + + public Dictionary Details { get; } = new(); + + /// The artist passed to the most recent call, for assertions. + public string? LastSearchArtist { get; private set; } + + private FakeDiscogsClient(List searchResults) => _searchResults = searchResults; + + public static FakeDiscogsClient Empty() => new([]); + + public static FakeDiscogsClient WithSearchResults(params DiscogsSearchResult[] results) => new([.. results]); + + public Task> SearchAlbumsAsync(string title, int? year, string? artist = null, CancellationToken cancellationToken = default) + { + LastSearchArtist = artist; + return Task.FromResult>(_searchResults); + } + + public Task GetAlbumDetailsAsync(string externalId, CancellationToken cancellationToken = default) => + Task.FromResult(Details.GetValueOrDefault(externalId)); +} diff --git a/test/WebApi.UnitTests/ReferenceData/FakeRawgClient.cs b/test/WebApi.UnitTests/ReferenceData/FakeRawgClient.cs new file mode 100644 index 00000000..f86e5514 --- /dev/null +++ b/test/WebApi.UnitTests/ReferenceData/FakeRawgClient.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Keeptrack.WebApi.ReferenceData; + +namespace Keeptrack.WebApi.UnitTests.ReferenceData; + +internal sealed class FakeRawgClient : IRawgClient +{ + private readonly List _searchResults; + + public Dictionary Details { get; } = new(); + + private FakeRawgClient(List searchResults) => _searchResults = searchResults; + + public static FakeRawgClient Empty() => new([]); + + public static FakeRawgClient WithSearchResults(params RawgSearchResult[] results) => new([.. results]); + + public Task> SearchGamesAsync(string title, int? year, CancellationToken cancellationToken = default) => + Task.FromResult>(_searchResults); + + public Task GetGameDetailsAsync(string externalId, CancellationToken cancellationToken = default) => + Task.FromResult(Details.GetValueOrDefault(externalId)); +} diff --git a/test/WebApi.UnitTests/ReferenceData/OpenLibraryClientTest.cs b/test/WebApi.UnitTests/ReferenceData/OpenLibraryClientTest.cs new file mode 100644 index 00000000..5e0c6a31 --- /dev/null +++ b/test/WebApi.UnitTests/ReferenceData/OpenLibraryClientTest.cs @@ -0,0 +1,64 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using AwesomeAssertions; +using Keeptrack.WebApi.ReferenceData; +using Xunit; + +namespace Keeptrack.WebApi.UnitTests.ReferenceData; + +/// +/// 's year parsing used to strip all digits out of +/// first_publish_date and take the first 4 - correct for a bare year ("1954") but wrong whenever the +/// date includes a day-of-month before the year ("November 12, 1972"), where it read the day's digits +/// followed by the year's leading digits ("12" + "19" -> 1219) instead of the actual year. Confirmed against +/// the real API for Tolkien's "The Fellowship of the Ring" (OL27513W), whose real +/// first_publish_date is "November 12, 1972". +/// +[Trait("Category", "UnitTests")] +public class OpenLibraryClientTest +{ + private sealed class StubHttpMessageHandler(Func respond) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(respond(request), Encoding.UTF8, "application/json") + }); + } + + private static IBookReferenceClient BuildClient(Func respond) + { + var http = new HttpClient(new StubHttpMessageHandler(respond)) { BaseAddress = new Uri("https://openlibrary.org/") }; + return new OpenLibraryClient(http); + } + + [Fact] + public void ProviderKey_IsOpenLibrary() + { + BuildClient(_ => "").ProviderKey.Should().Be("openlibrary"); + } + + [Fact] + public async Task GetBookDetailsAsync_ParsesTheYearFromAMonthDayYearPublishDate() + { + var client = BuildClient(_ => """{"title":"The Fellowship of the Ring","first_publish_date":"November 12, 1972","authors":[]}"""); + + var details = await client.GetBookDetailsAsync("/works/OL27513W", TestContext.Current.CancellationToken); + + details!.Year.Should().Be(1972); + } + + [Fact] + public async Task GetBookDetailsAsync_ParsesTheYearFromABareYearPublishDate() + { + var client = BuildClient(_ => """{"title":"The Lord of the Rings","first_publish_date":"1954","authors":[]}"""); + + var details = await client.GetBookDetailsAsync("/works/OL27448W", TestContext.Current.CancellationToken); + + details!.Year.Should().Be(1954); + } +} diff --git a/test/WebApi.UnitTests/ReferenceData/ReferenceEnrichmentServiceTest.cs b/test/WebApi.UnitTests/ReferenceData/ReferenceEnrichmentServiceTest.cs new file mode 100644 index 00000000..70e05012 --- /dev/null +++ b/test/WebApi.UnitTests/ReferenceData/ReferenceEnrichmentServiceTest.cs @@ -0,0 +1,1005 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AwesomeAssertions; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.WebApi.ReferenceData; +using Moq; +using Xunit; + +namespace Keeptrack.WebApi.UnitTests.ReferenceData; + +[Trait("Category", "UnitTests")] +public class ReferenceEnrichmentServiceTest +{ + private readonly Mock _tvShowReferenceRepository = new(); + private readonly Mock _movieReferenceRepository = new(); + private readonly Mock _personReferenceRepository = new(); + private readonly Mock _bookReferenceRepository = new(); + private readonly Mock _videoGameReferenceRepository = new(); + private readonly Mock _albumReferenceRepository = new(); + private readonly Mock _tvShowRepository = new(); + private readonly Mock _movieRepository = new(); + private readonly Mock _bookRepository = new(); + private readonly Mock _videoGameRepository = new(); + private readonly Mock _albumRepository = new(); + + private ReferenceEnrichmentService CreateService( + FakeTmdbClient tmdbClient, + FakeBookReferenceClient? bookReferenceClient = null, + FakeRawgClient? rawgClient = null, + FakeDiscogsClient? discogsClient = null) => new( + tmdbClient, bookReferenceClient ?? FakeBookReferenceClient.Empty(), rawgClient ?? FakeRawgClient.Empty(), discogsClient ?? FakeDiscogsClient.Empty(), + _tvShowReferenceRepository.Object, _movieReferenceRepository.Object, _personReferenceRepository.Object, + _bookReferenceRepository.Object, _videoGameReferenceRepository.Object, _albumReferenceRepository.Object, + _tvShowRepository.Object, _movieRepository.Object, _bookRepository.Object, _videoGameRepository.Object, _albumRepository.Object); + + [Fact] + public async Task TryAutoResolveTvShowAsync_DoesNothing_WhenSearchReturnsNoResults() + { + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults()); + + await service.TryAutoResolveTvShowAsync("Some Show", 2020); + + _tvShowRepository.Verify(r => r.SetReferenceLinkAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task TryAutoResolveTvShowAsync_DoesNothing_WhenSearchIsAmbiguous() + { + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults( + new TmdbSearchResult("1", "Some Show", 2020, null, null), + new TmdbSearchResult("2", "Some Show", 2020, null, null))); + + await service.TryAutoResolveTvShowAsync("Some Show", 2020); + + _tvShowRepository.Verify(r => r.SetReferenceLinkAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task TryAutoResolveTvShowAsync_ResolvesAndPropagates_WhenExactlyOneCandidate() + { + var tmdbClient = FakeTmdbClient.WithTvShowSearchResults(new TmdbSearchResult("42", "Some Show", 2020, "Synopsis", null)); + tmdbClient.TvShowDetails["42"] = new TmdbTvShowDetails("42", "Some Show", 2020, "Synopsis", [], [], null); + _tvShowReferenceRepository + .Setup(r => r.UpsertAsync(It.IsAny())) + .ReturnsAsync((TvShowReferenceModel m) => { m.Id ??= "generated-id"; return m; }); + var service = CreateService(tmdbClient); + + await service.TryAutoResolveTvShowAsync("Some Show", 2020); + + _tvShowReferenceRepository.Verify(r => r.UpsertAsync(It.Is(m => m.ExternalIds["tmdb"] == "42")), Times.Once); + _tvShowRepository.Verify(r => r.SetReferenceLinkAsync("Some Show", 2020, It.IsAny(), "Some Show", It.IsAny()), Times.Once); + } + + [Fact] + public async Task ResolveTvShowAsync_PropagatesTheUpsertedReferenceId() + { + var tmdbClient = FakeTmdbClient.WithTvShowSearchResults(); + tmdbClient.TvShowDetails["42"] = new TmdbTvShowDetails("42", "Some Show", 2020, "Synopsis", [], [], null); + _tvShowReferenceRepository + .Setup(r => r.UpsertAsync(It.IsAny())) + .ReturnsAsync((TvShowReferenceModel m) => { m.Id = "reference-1"; return m; }); + var service = CreateService(tmdbClient); + + var result = await service.ResolveTvShowAsync("Some Show", 2020, "42"); + + result.Id.Should().Be("reference-1"); + _tvShowRepository.Verify(r => r.SetReferenceLinkAsync("Some Show", 2020, "reference-1", "Some Show", It.IsAny()), Times.Once); + } + + [Fact] + public async Task ResolveTvShowAsync_ReusesTheSameReferenceByTmdbId_EvenWhenTitleTextDiffersEntirely() + { + // regression test: resolving the exact same TMDB show twice under two completely unrelated title + // strings (e.g. a translation an admin didn't recognize) used to create a second, duplicate + // reference document, because the "does this already exist" check only ever looked at title/year - + // tmdbId is now checked first and is authoritative. + var tmdbClient = FakeTmdbClient.WithTvShowSearchResults(); + tmdbClient.TvShowDetails["42"] = new TmdbTvShowDetails("42", "The Wire", 2002, "Synopsis", [], [], null); + _tvShowReferenceRepository + .Setup(r => r.FindByExternalIdAsync("tmdb", "42")) + .ReturnsAsync(new TvShowReferenceModel + { + Id = "reference-1", + Title = "The Wire", + TitleNormalized = "the wire", + ExternalIds = new Dictionary { ["tmdb"] = "42" }, + MatchedAliases = [new ReferenceMatchModel { Title = "the wire", Year = 2002 }] + }); + _tvShowReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((TvShowReferenceModel m) => m); + var service = CreateService(tmdbClient); + + var result = await service.ResolveTvShowAsync("Totally Unrelated Search Text", null, "42"); + + result.Id.Should().Be("reference-1"); + _tvShowReferenceRepository.Verify(r => r.UpsertAsync(It.Is(m => m.Id == "reference-1")), Times.Once); + } + + [Fact] + public async Task ResolveTvShowAsync_RecordsBothTheSearchedAndCanonicalTitleAsMatchedAliases() + { + var tmdbClient = FakeTmdbClient.WithTvShowSearchResults(); + tmdbClient.TvShowDetails["42"] = new TmdbTvShowDetails("42", "The Wire", 2002, "Synopsis", [], [], null); + _tvShowReferenceRepository + .Setup(r => r.UpsertAsync(It.IsAny())) + .ReturnsAsync((TvShowReferenceModel m) => { m.Id ??= "reference-1"; return m; }); + var service = CreateService(tmdbClient); + + // the tenant searched with a different-language title than TMDB's canonical English one + await service.ResolveTvShowAsync("Le Fil", 2002, "42"); + + _tvShowReferenceRepository.Verify(r => r.UpsertAsync(It.Is( + m => m.MatchedAliases.Any(a => a.Title == "the wire" && a.Year == 2002) + && m.MatchedAliases.Any(a => a.Title == "le fil" && a.Year == 2002))), Times.Once); + } + + [Fact] + public async Task ResolveTvShowAsync_PreservesPreviouslyKnownMatchedAliases_WhenReResolvingAnExistingReference() + { + var tmdbClient = FakeTmdbClient.WithTvShowSearchResults(); + tmdbClient.TvShowDetails["42"] = new TmdbTvShowDetails("42", "The Wire", 2002, "Synopsis", [], [], null); + _tvShowReferenceRepository + .Setup(r => r.FindByTitleYearAsync("Le Fil", 2002)) + .ReturnsAsync(new TvShowReferenceModel + { + Id = "reference-1", + Title = "The Wire", + TitleNormalized = "the wire", + ExternalIds = [], + MatchedAliases = [new ReferenceMatchModel { Title = "the wire", Year = 2002 }, new ReferenceMatchModel { Title = "il filo", Year = 2001 }] + }); + _tvShowReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((TvShowReferenceModel m) => m); + var service = CreateService(tmdbClient); + + await service.ResolveTvShowAsync("Le Fil", 2002, "42"); + + // an alias contributed by a third tenant earlier (il filo) must survive a later re-resolution + _tvShowReferenceRepository.Verify(r => r.UpsertAsync(It.Is( + m => m.MatchedAliases.Any(a => a.Title == "the wire" && a.Year == 2002) + && m.MatchedAliases.Any(a => a.Title == "il filo" && a.Year == 2001) + && m.MatchedAliases.Any(a => a.Title == "le fil" && a.Year == 2002))), Times.Once); + } + + [Fact] + public async Task ResolveTvShowAsync_ReusesExistingPersonReference_ForAnAlreadyKnownActor() + { + var tmdbClient = FakeTmdbClient.WithTvShowSearchResults(); + tmdbClient.TvShowDetails["42"] = new TmdbTvShowDetails("42", "Some Show", 2020, "Synopsis", [], [], null); + tmdbClient.Cast["42"] = [new TmdbCastMember("99", "Actor Name", "A Character", 0, null)]; + + _tvShowReferenceRepository + .Setup(r => r.UpsertAsync(It.IsAny())) + .ReturnsAsync((TvShowReferenceModel m) => { m.Id ??= "reference-1"; return m; }); + _personReferenceRepository + .Setup(r => r.FindByExternalIdAsync("tmdb", "99")) + .ReturnsAsync(new PersonReferenceModel { Id = "person-1", Name = "Actor Name", ExternalIds = new Dictionary { ["tmdb"] = "99" } }); + _personReferenceRepository + .Setup(r => r.UpsertAsync(It.IsAny())) + .ReturnsAsync((PersonReferenceModel m) => m); + + var service = CreateService(tmdbClient); + + var result = await service.ResolveTvShowAsync("Some Show", 2020, "42"); + + // the same actor already known from a previous resolution must be reused, not duplicated + _personReferenceRepository.Verify(r => r.UpsertAsync(It.Is(m => m.Id == "person-1")), Times.Once); + result.Cast.Should().ContainSingle(c => c.PersonReferenceId == "person-1" && c.CharacterName == "A Character"); + } + + [Fact] + public async Task TryLinkExistingTvShowReferenceAsync_RelinksToTheNewMatch_WhenTitleWasEditedAwayFromTheCurrentLink() + { + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults()); + var model = new TvShowModel { Id = "show-1", OwnerId = "owner", Title = "A Different Show", Year = 2021, ReferenceId = "old-reference" }; + _tvShowReferenceRepository + .Setup(r => r.FindByTitleYearAsync("A Different Show", 2021)) + .ReturnsAsync(new TvShowReferenceModel { Id = "new-reference", Title = "A Different Show", TitleNormalized = "a different show", ExternalIds = [] }); + + var result = await service.TryLinkExistingTvShowReferenceAsync(model); + + result.ReferenceId.Should().Be("new-reference"); + _tvShowRepository.Verify(r => r.UpdateAsync("show-1", It.Is(m => m.ReferenceId == "new-reference"), "owner"), Times.Once); + } + + [Fact] + public async Task TryLinkExistingTvShowReferenceAsync_Unlinks_WhenAlreadyLinkedButNoMatchFoundForTheCurrentTitle() + { + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults()); + var model = new TvShowModel { Id = "show-1", OwnerId = "owner", Title = "Some Show", Year = 2020, ReferenceId = "old-reference" }; + _tvShowReferenceRepository.Setup(r => r.FindByTitleYearAsync("Some Show", 2020)).ReturnsAsync((TvShowReferenceModel?)null); + _tvShowReferenceRepository.Setup(r => r.FindByTitleAsync("Some Show")).ReturnsAsync((TvShowReferenceModel?)null); + + var result = await service.TryLinkExistingTvShowReferenceAsync(model); + + // no match for the current title means the previously stored link no longer corresponds to what + // the tenant just told us is correct - clear it (which also puts it back in the admin's unresolved queue) + result.ReferenceId.Should().BeEmpty(); + _tvShowRepository.Verify(r => r.UpdateAsync("show-1", It.Is(m => m.ReferenceId == string.Empty), "owner"), Times.Once); + } + + [Fact] + public async Task TryLinkExistingTvShowReferenceAsync_FallsBackToTitleOnlyMatch_WhenTheTenantHasNoYearSet() + { + // regression test: the title-only fallback used to be skipped whenever Year was null, which meant + // any linked show with no recorded year would unlink itself on every refresh, since the title+year + // query can never succeed with a null year against a reference that has a real one. + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults()); + var model = new TvShowModel { Id = "show-1", OwnerId = "owner", Title = "Some Show", Year = null, ReferenceId = "reference-1" }; + _tvShowReferenceRepository.Setup(r => r.FindByTitleYearAsync("Some Show", null)).ReturnsAsync((TvShowReferenceModel?)null); + _tvShowReferenceRepository + .Setup(r => r.FindByTitleAsync("Some Show")) + .ReturnsAsync(new TvShowReferenceModel { Id = "reference-1", Title = "Some Show", TitleNormalized = "some show", Year = 2020, ExternalIds = [] }); + + var result = await service.TryLinkExistingTvShowReferenceAsync(model); + + result.ReferenceId.Should().Be("reference-1"); + } + + [Fact] + public async Task TryLinkExistingTvShowReferenceAsync_LinksAndUpdatesTitle_OnTitleYearMatch() + { + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults()); + var model = new TvShowModel { Id = "show-1", OwnerId = "owner", Title = "Some Typo'd Show", Year = 2020 }; + _tvShowReferenceRepository + .Setup(r => r.FindByTitleYearAsync("Some Typo'd Show", 2020)) + .ReturnsAsync(new TvShowReferenceModel { Id = "reference-1", Title = "Some Show", TitleNormalized = "some show", ExternalIds = [] }); + + var result = await service.TryLinkExistingTvShowReferenceAsync(model); + + result.ReferenceId.Should().Be("reference-1"); + result.Title.Should().Be("Some Show"); + _tvShowRepository.Verify(r => r.UpdateAsync("show-1", It.Is(m => m.ReferenceId == "reference-1"), "owner"), Times.Once); + _tvShowRepository.Verify(r => r.SetReferenceLinkAsync("Some Typo'd Show", 2020, "reference-1", "Some Show", It.IsAny()), Times.Once); + } + + [Fact] + public async Task TryLinkExistingTvShowReferenceAsync_UpdatesYearToTheReferencesCanonicalYear_OnLink() + { + // the tenant's own recorded year is pre-populated with the reference's canonical year on link - + // still freely editable afterward, but starts from a trustworthy value instead of the tenant's guess + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults()); + var model = new TvShowModel { Id = "show-1", OwnerId = "owner", Title = "Some Show", Year = 2019 }; + _tvShowReferenceRepository + .Setup(r => r.FindByTitleYearAsync("Some Show", 2019)) + .ReturnsAsync(new TvShowReferenceModel { Id = "reference-1", Title = "Some Show", TitleNormalized = "some show", Year = 2020, ExternalIds = [] }); + + var result = await service.TryLinkExistingTvShowReferenceAsync(model); + + result.Year.Should().Be(2020); + _tvShowRepository.Verify(r => r.UpdateAsync("show-1", It.Is(m => m.Year == 2020), "owner"), Times.Once); + _tvShowRepository.Verify(r => r.SetReferenceLinkAsync("Some Show", 2019, "reference-1", "Some Show", 2020), Times.Once); + } + + [Fact] + public async Task TryLinkExistingTvShowReferenceAsync_FallsBackToTitleOnlyMatch_WhenTitleYearMatchMisses() + { + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults()); + var model = new TvShowModel { Id = "show-1", OwnerId = "owner", Title = "Some Show", Year = 1999 }; + _tvShowReferenceRepository.Setup(r => r.FindByTitleYearAsync("Some Show", 1999)).ReturnsAsync((TvShowReferenceModel?)null); + _tvShowReferenceRepository + .Setup(r => r.FindByTitleAsync("Some Show")) + .ReturnsAsync(new TvShowReferenceModel { Id = "reference-1", Title = "Some Show", TitleNormalized = "some show", ExternalIds = [] }); + + var result = await service.TryLinkExistingTvShowReferenceAsync(model); + + result.ReferenceId.Should().Be("reference-1"); + } + + [Fact] + public async Task TryLinkExistingTvShowReferenceAsync_ReturnsUnchanged_WhenNoMatchFound() + { + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults()); + var model = new TvShowModel { Id = "show-1", OwnerId = "owner", Title = "Some Show", Year = 2020 }; + _tvShowReferenceRepository.Setup(r => r.FindByTitleYearAsync("Some Show", 2020)).ReturnsAsync((TvShowReferenceModel?)null); + _tvShowReferenceRepository.Setup(r => r.FindByTitleAsync("Some Show")).ReturnsAsync((TvShowReferenceModel?)null); + + var result = await service.TryLinkExistingTvShowReferenceAsync(model); + + // was never linked and still isn't - nothing to clear, so no write should happen at all + result.ReferenceId.Should().BeNullOrEmpty(); + _tvShowRepository.Verify(r => r.SetReferenceLinkAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _tvShowRepository.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task TryLinkExistingMovieReferenceAsync_LinksAndUpdatesTitle_OnTitleYearMatch() + { + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults()); + var model = new MovieModel { Id = "movie-1", OwnerId = "owner", Title = "Some Typo'd Movie", Year = 2020 }; + _movieReferenceRepository + .Setup(r => r.FindByTitleYearAsync("Some Typo'd Movie", 2020)) + .ReturnsAsync(new MovieReferenceModel { Id = "reference-1", Title = "Some Movie", TitleNormalized = "some movie", ExternalIds = [] }); + + var result = await service.TryLinkExistingMovieReferenceAsync(model); + + result.ReferenceId.Should().Be("reference-1"); + result.Title.Should().Be("Some Movie"); + _movieRepository.Verify(r => r.UpdateAsync("movie-1", It.Is(m => m.ReferenceId == "reference-1"), "owner"), Times.Once); + _movieRepository.Verify(r => r.SetReferenceLinkAsync("Some Typo'd Movie", 2020, "reference-1", "Some Movie", It.IsAny()), Times.Once); + } + + [Fact] + public async Task TryLinkExistingMovieReferenceAsync_UpdatesYearToTheReferencesCanonicalYear_OnLink() + { + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults()); + var model = new MovieModel { Id = "movie-1", OwnerId = "owner", Title = "Some Movie", Year = 2019 }; + _movieReferenceRepository + .Setup(r => r.FindByTitleYearAsync("Some Movie", 2019)) + .ReturnsAsync(new MovieReferenceModel { Id = "reference-1", Title = "Some Movie", TitleNormalized = "some movie", Year = 2020, ExternalIds = [] }); + + var result = await service.TryLinkExistingMovieReferenceAsync(model); + + result.Year.Should().Be(2020); + _movieRepository.Verify(r => r.UpdateAsync("movie-1", It.Is(m => m.Year == 2020), "owner"), Times.Once); + _movieRepository.Verify(r => r.SetReferenceLinkAsync("Some Movie", 2019, "reference-1", "Some Movie", 2020), Times.Once); + } + + [Fact] + public async Task TryLinkExistingMovieReferenceAsync_Unlinks_WhenAlreadyLinkedButNoMatchFoundForTheCurrentTitle() + { + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults()); + var model = new MovieModel { Id = "movie-1", OwnerId = "owner", Title = "Some Movie", Year = 2020, ReferenceId = "old-reference" }; + _movieReferenceRepository.Setup(r => r.FindByTitleYearAsync("Some Movie", 2020)).ReturnsAsync((MovieReferenceModel?)null); + _movieReferenceRepository.Setup(r => r.FindByTitleAsync("Some Movie")).ReturnsAsync((MovieReferenceModel?)null); + + var result = await service.TryLinkExistingMovieReferenceAsync(model); + + result.ReferenceId.Should().BeEmpty(); + _movieRepository.Verify(r => r.UpdateAsync("movie-1", It.Is(m => m.ReferenceId == string.Empty), "owner"), Times.Once); + } + + [Fact] + public async Task RefreshTvShowReferenceAsync_ReturnsUnchanged_WhenReferenceHasNoTmdbId() + { + var tmdbClient = FakeTmdbClient.WithTvShowSearchResults(); + var service = CreateService(tmdbClient); + var reference = new TvShowReferenceModel { Id = "reference-1", Title = "Some Show", TitleNormalized = "some show", ExternalIds = [] }; + + var (result, changed) = await service.RefreshTvShowReferenceAsync(reference, TestContext.Current.CancellationToken); + + changed.Should().BeFalse(); + result.Should().BeSameAs(reference); + _tvShowReferenceRepository.Verify(r => r.UpsertAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task RefreshTvShowReferenceAsync_OnlyBumpsLastEnrichedAt_WhenTmdbReportsNoChanges() + { + var tmdbClient = FakeTmdbClient.WithTvShowSearchResults(); + tmdbClient.ChangedSince["42"] = false; + var lastEnrichedAt = DateTime.UtcNow.AddDays(-5); + var reference = new TvShowReferenceModel + { + Id = "reference-1", + Title = "Some Show", + TitleNormalized = "some show", + ExternalIds = new Dictionary { ["tmdb"] = "42" }, + LastEnrichedAt = lastEnrichedAt + }; + _tvShowReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((TvShowReferenceModel m) => m); + var service = CreateService(tmdbClient); + + var (result, changed) = await service.RefreshTvShowReferenceAsync(reference, TestContext.Current.CancellationToken); + + changed.Should().BeFalse(); + result.LastEnrichedAt.Should().BeAfter(lastEnrichedAt); + // no changes reported: the expensive details/season fetch must never happen + tmdbClient.TvShowDetailsRequested.Should().NotContain("42"); + } + + [Fact] + public async Task RefreshTvShowReferenceAsync_RefetchesDetails_WhenTmdbReportsChanges() + { + var tmdbClient = FakeTmdbClient.WithTvShowSearchResults(); + tmdbClient.ChangedSince["42"] = true; + tmdbClient.TvShowDetails["42"] = new TmdbTvShowDetails("42", "Some Show - Updated", 2020, "New synopsis", [], ["Drama"], null); + var reference = new TvShowReferenceModel + { + Id = "reference-1", + Title = "Some Show", + TitleNormalized = "some show", + ExternalIds = new Dictionary { ["tmdb"] = "42" }, + LastEnrichedAt = DateTime.UtcNow.AddDays(-5) + }; + _tvShowReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((TvShowReferenceModel m) => m); + var service = CreateService(tmdbClient); + + var (result, changed) = await service.RefreshTvShowReferenceAsync(reference, TestContext.Current.CancellationToken); + + changed.Should().BeTrue(); + result.Title.Should().Be("Some Show - Updated"); + result.Synopsis.Should().Be("New synopsis"); + result.Genres.Should().Contain("Drama"); + } + + [Fact] + public async Task RefreshTvShowReferenceAsync_RefetchesDetails_WhenNeverPreviouslyEnriched() + { + // no LastEnrichedAt to compare against: always do the full fetch, never call the changes pre-check + var tmdbClient = FakeTmdbClient.WithTvShowSearchResults(); + tmdbClient.TvShowDetails["42"] = new TmdbTvShowDetails("42", "Some Show", 2020, "Synopsis", [], [], null); + var reference = new TvShowReferenceModel + { + Id = "reference-1", + Title = "Some Show", + TitleNormalized = "some show", + ExternalIds = new Dictionary { ["tmdb"] = "42" }, + LastEnrichedAt = null + }; + _tvShowReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((TvShowReferenceModel m) => m); + var service = CreateService(tmdbClient); + + var (_, changed) = await service.RefreshTvShowReferenceAsync(reference, TestContext.Current.CancellationToken); + + changed.Should().BeTrue(); + tmdbClient.ChangesRequested.Should().NotContain("42"); + } + + [Fact] + public async Task TryAutoResolveBookAsync_DoesNothing_WhenSearchIsAmbiguous() + { + var bookReferenceClient = FakeBookReferenceClient.WithSearchResults( + new BookSearchResult("OL1W", "Some Book", 2020, "Some Author", null), + new BookSearchResult("OL2W", "Some Book", 2020, "Some Author", null)); + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults(), bookReferenceClient); + + await service.TryAutoResolveBookAsync("Some Book", 2020); + + _bookRepository.Verify(r => r.SetReferenceLinkAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task TryAutoResolveBookAsync_ResolvesAndPropagates_WhenExactlyOneCandidate() + { + var bookReferenceClient = FakeBookReferenceClient.WithSearchResults(new BookSearchResult("OL1W", "Some Book", 2020, "Some Author", null)); + bookReferenceClient.Details["OL1W"] = new BookDetails("OL1W", "Some Book", 2020, "Synopsis", "Some Author", "OL1A", [], null); + _bookReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((BookReferenceModel m) => { m.Id ??= "generated-id"; return m; }); + _personReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((PersonReferenceModel m) => { m.Id ??= "person-1"; return m; }); + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults(), bookReferenceClient); + + await service.TryAutoResolveBookAsync("Some Book", 2020); + + _bookReferenceRepository.Verify(r => r.UpsertAsync(It.Is(m => m.ExternalIds["openlibrary"] == "OL1W")), Times.Once); + _bookRepository.Verify(r => r.SetReferenceLinkAsync("Some Book", 2020, It.IsAny(), "Some Book", It.IsAny(), "Some Author"), Times.Once); + } + + [Fact] + public async Task TryAutoResolveBookAsync_PassesTheAuthorThroughToTheBookSearch() + { + // regression: a common title without an author hint returns many unrelated candidates - the + // author must reach IBookReferenceClient.SearchBooksAsync, not just get dropped along the way. + var bookReferenceClient = FakeBookReferenceClient.WithSearchResults(new BookSearchResult("OL1W", "Some Book", 2020, "Lee Child", null)); + bookReferenceClient.Details["OL1W"] = new BookDetails("OL1W", "Some Book", 2020, "Synopsis", "Lee Child", "OL1A", [], null); + _bookReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((BookReferenceModel m) => { m.Id ??= "generated-id"; return m; }); + _personReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((PersonReferenceModel m) => { m.Id ??= "person-1"; return m; }); + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults(), bookReferenceClient); + + await service.TryAutoResolveBookAsync("Killing Floor", 2016, "Lee Child"); + + bookReferenceClient.LastSearchAuthor.Should().Be("Lee Child"); + } + + [Fact] + public async Task ResolveBookAsync_PropagatesTheUpsertedReferenceId() + { + var bookReferenceClient = FakeBookReferenceClient.Empty(); + bookReferenceClient.Details["OL1W"] = new BookDetails("OL1W", "Some Book", 2020, "Synopsis", "Some Author", "OL1A", [], null); + _bookReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((BookReferenceModel m) => { m.Id = "reference-1"; return m; }); + _personReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((PersonReferenceModel m) => { m.Id ??= "person-1"; return m; }); + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults(), bookReferenceClient); + + var result = await service.ResolveBookAsync("Some Book", 2020, "OL1W"); + + result.Id.Should().Be("reference-1"); + result.AuthorReferenceId.Should().Be("person-1"); + _bookRepository.Verify(r => r.SetReferenceLinkAsync("Some Book", 2020, "reference-1", "Some Book", It.IsAny(), "Some Author"), Times.Once); + } + + [Fact] + public async Task TryLinkExistingBookReferenceAsync_LinksAndUpdatesTitleAndAuthor_OnTitleYearMatch() + { + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults()); + var model = new BookModel { Id = "book-1", OwnerId = "owner", Title = "Some Typo'd Book", Author = "Wrong Author", Year = 2020 }; + _bookReferenceRepository + .Setup(r => r.FindByTitleYearAsync("Some Typo'd Book", 2020, "Wrong Author")) + .ReturnsAsync(new BookReferenceModel { Id = "reference-1", Title = "Some Book", TitleNormalized = "some book", AuthorReferenceId = "person-1", ExternalIds = [] }); + _personReferenceRepository + .Setup(r => r.FindByIdAsync("person-1")) + .ReturnsAsync(new PersonReferenceModel { Id = "person-1", Name = "Correct Author", ExternalIds = new Dictionary { ["openlibrary"] = "OL1A" } }); + + var result = await service.TryLinkExistingBookReferenceAsync(model); + + result.ReferenceId.Should().Be("reference-1"); + result.Title.Should().Be("Some Book"); + result.Author.Should().Be("Correct Author"); + _bookRepository.Verify(r => r.UpdateAsync("book-1", It.Is(m => m.ReferenceId == "reference-1"), "owner"), Times.Once); + } + + [Fact] + public async Task TryLinkExistingBookReferenceAsync_UpdatesYearToTheReferencesCanonicalYear_OnLink() + { + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults()); + var model = new BookModel { Id = "book-1", OwnerId = "owner", Title = "Some Book", Author = "Some Author", Year = 2019 }; + _bookReferenceRepository + .Setup(r => r.FindByTitleYearAsync("Some Book", 2019, "Some Author")) + .ReturnsAsync(new BookReferenceModel { Id = "reference-1", Title = "Some Book", TitleNormalized = "some book", Year = 2020, ExternalIds = [] }); + + var result = await service.TryLinkExistingBookReferenceAsync(model); + + result.Year.Should().Be(2020); + _bookRepository.Verify(r => r.UpdateAsync("book-1", It.Is(m => m.Year == 2020), "owner"), Times.Once); + } + + [Fact] + public async Task TryLinkExistingBookReferenceAsync_SetsGenreFromTheReferencesGenres_OnLink() + { + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults()); + var model = new BookModel { Id = "book-1", OwnerId = "owner", Title = "Some Book", Author = "Some Author", Year = 2020 }; + _bookReferenceRepository + .Setup(r => r.FindByTitleYearAsync("Some Book", 2020, "Some Author")) + .ReturnsAsync(new BookReferenceModel { Id = "reference-1", Title = "Some Book", TitleNormalized = "some book", ExternalIds = [], Genres = ["Thriller", "Mystery"] }); + + var result = await service.TryLinkExistingBookReferenceAsync(model); + + result.Genre.Should().Be("Thriller, Mystery"); + _bookRepository.Verify(r => r.UpdateAsync("book-1", It.Is(m => m.Genre == "Thriller, Mystery"), "owner"), Times.Once); + _bookRepository.Verify(r => r.SetReferenceLinkAsync("Some Book", 2020, "reference-1", "Some Book", It.IsAny(), It.IsAny(), "Thriller, Mystery"), Times.Once); + } + + [Fact] + public async Task TryLinkExistingBookReferenceAsync_Unlinks_WhenAlreadyLinkedButNoMatchFoundForTheCurrentTitle() + { + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults()); + var model = new BookModel { Id = "book-1", OwnerId = "owner", Title = "Some Book", Author = "Some Author", Year = 2020, ReferenceId = "old-reference" }; + _bookReferenceRepository.Setup(r => r.FindByTitleYearAsync("Some Book", 2020, "Some Author")).ReturnsAsync((BookReferenceModel?)null); + _bookReferenceRepository.Setup(r => r.FindByTitleAsync("Some Book", "Some Author")).ReturnsAsync((BookReferenceModel?)null); + + var result = await service.TryLinkExistingBookReferenceAsync(model); + + result.ReferenceId.Should().BeEmpty(); + _bookRepository.Verify(r => r.UpdateAsync("book-1", It.Is(m => m.ReferenceId == string.Empty), "owner"), Times.Once); + } + + [Fact] + public async Task RefreshBookReferenceAsync_ReturnsUnchanged_WhenReferenceHasNoExternalId() + { + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults()); + var reference = new BookReferenceModel { Id = "reference-1", Title = "Some Book", TitleNormalized = "some book", ExternalIds = [] }; + + var (result, changed) = await service.RefreshBookReferenceAsync(reference, TestContext.Current.CancellationToken); + + changed.Should().BeFalse(); + result.Should().BeSameAs(reference); + _bookReferenceRepository.Verify(r => r.UpsertAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task RefreshBookReferenceAsync_AlwaysRefetches_RegardlessOfLastEnrichedAt() + { + // Open Library exposes no "changed since" endpoint (unlike TMDB) - every refresh call does a full + // re-fetch, even when LastEnrichedAt is very recent. + var bookReferenceClient = FakeBookReferenceClient.Empty(); + bookReferenceClient.Details["OL1W"] = new BookDetails("OL1W", "Some Book - Updated", 2020, "New synopsis", "Some Author", "OL1A", ["Fiction"], null); + var reference = new BookReferenceModel + { + Id = "reference-1", + Title = "Some Book", + TitleNormalized = "some book", + ExternalIds = new Dictionary { ["openlibrary"] = "OL1W" }, + LastEnrichedAt = DateTime.UtcNow + }; + _bookReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((BookReferenceModel m) => m); + _personReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((PersonReferenceModel m) => { m.Id ??= "person-1"; return m; }); + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults(), bookReferenceClient); + + var (result, changed) = await service.RefreshBookReferenceAsync(reference, TestContext.Current.CancellationToken); + + changed.Should().BeTrue(); + result.Title.Should().Be("Some Book - Updated"); + result.Genres.Should().Contain("Fiction"); + } + + [Fact] + public async Task TryAutoResolveVideoGameAsync_DoesNothing_WhenSearchIsAmbiguous() + { + var rawgClient = FakeRawgClient.WithSearchResults( + new RawgSearchResult("1", "Some Game", 2020, null), + new RawgSearchResult("2", "Some Game", 2020, null)); + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults(), rawgClient: rawgClient); + + await service.TryAutoResolveVideoGameAsync("Some Game", 2020); + + _videoGameRepository.Verify(r => r.SetReferenceLinkAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task TryAutoResolveVideoGameAsync_ResolvesAndPropagates_WhenExactlyOneCandidate() + { + var rawgClient = FakeRawgClient.WithSearchResults(new RawgSearchResult("1", "Some Game", 2020, null)); + rawgClient.Details["1"] = new RawgGameDetails("1", "Some Game", 2020, "Synopsis", [], [], null); + _videoGameReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((VideoGameReferenceModel m) => { m.Id ??= "generated-id"; return m; }); + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults(), rawgClient: rawgClient); + + await service.TryAutoResolveVideoGameAsync("Some Game", 2020); + + _videoGameReferenceRepository.Verify(r => r.UpsertAsync(It.Is(m => m.ExternalIds["rawg"] == "1")), Times.Once); + _videoGameRepository.Verify(r => r.SetReferenceLinkAsync("Some Game", 2020, It.IsAny(), "Some Game", It.IsAny()), Times.Once); + } + + [Fact] + public async Task ResolveVideoGameAsync_PropagatesTheUpsertedReferenceId() + { + var rawgClient = FakeRawgClient.Empty(); + rawgClient.Details["1"] = new RawgGameDetails("1", "Some Game", 2020, "Synopsis", [], [], null); + _videoGameReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((VideoGameReferenceModel m) => { m.Id = "reference-1"; return m; }); + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults(), rawgClient: rawgClient); + + var result = await service.ResolveVideoGameAsync("Some Game", 2020, "1"); + + result.Id.Should().Be("reference-1"); + _videoGameRepository.Verify(r => r.SetReferenceLinkAsync("Some Game", 2020, "reference-1", "Some Game", It.IsAny()), Times.Once); + } + + [Fact] + public async Task TryLinkExistingVideoGameReferenceAsync_LinksAndUpdatesTitle_OnTitleYearMatch() + { + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults()); + var model = new VideoGameModel + { + Id = "game-1", + OwnerId = "owner", + Title = "Some Typo'd Game", + Year = 2020, + Platforms = [new VideoGamePlatformModel { Platform = "PC", State = "Current" }] + }; + _videoGameReferenceRepository + .Setup(r => r.FindByTitleYearAsync("Some Typo'd Game", 2020)) + .ReturnsAsync(new VideoGameReferenceModel { Id = "reference-1", Title = "Some Game", TitleNormalized = "some game", ExternalIds = [] }); + + var result = await service.TryLinkExistingVideoGameReferenceAsync(model); + + result.ReferenceId.Should().Be("reference-1"); + result.Title.Should().Be("Some Game"); + result.Platforms.Should().ContainSingle(p => p.Platform == "PC"); + _videoGameRepository.Verify(r => r.UpdateAsync("game-1", It.Is(m => m.ReferenceId == "reference-1"), "owner"), Times.Once); + } + + [Fact] + public async Task TryLinkExistingVideoGameReferenceAsync_UpdatesYearToTheReferencesCanonicalYear_OnLink() + { + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults()); + var model = new VideoGameModel + { + Id = "game-1", + OwnerId = "owner", + Title = "Some Game", + Year = 2019, + Platforms = [new VideoGamePlatformModel { Platform = "PC", State = "Current" }] + }; + _videoGameReferenceRepository + .Setup(r => r.FindByTitleYearAsync("Some Game", 2019)) + .ReturnsAsync(new VideoGameReferenceModel { Id = "reference-1", Title = "Some Game", TitleNormalized = "some game", Year = 2020, ExternalIds = [] }); + + var result = await service.TryLinkExistingVideoGameReferenceAsync(model); + + result.Year.Should().Be(2020); + _videoGameRepository.Verify(r => r.UpdateAsync("game-1", It.Is(m => m.Year == 2020), "owner"), Times.Once); + } + + [Fact] + public async Task TryLinkExistingVideoGameReferenceAsync_Unlinks_WhenAlreadyLinkedButNoMatchFoundForTheCurrentTitle() + { + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults()); + var model = new VideoGameModel + { + Id = "game-1", + OwnerId = "owner", + Title = "Some Game", + Year = 2020, + ReferenceId = "old-reference", + Platforms = [new VideoGamePlatformModel { Platform = "PC", State = "Current" }] + }; + _videoGameReferenceRepository.Setup(r => r.FindByTitleYearAsync("Some Game", 2020)).ReturnsAsync((VideoGameReferenceModel?)null); + _videoGameReferenceRepository.Setup(r => r.FindByTitleAsync("Some Game")).ReturnsAsync((VideoGameReferenceModel?)null); + + var result = await service.TryLinkExistingVideoGameReferenceAsync(model); + + result.ReferenceId.Should().BeEmpty(); + _videoGameRepository.Verify(r => r.UpdateAsync("game-1", It.Is(m => m.ReferenceId == string.Empty), "owner"), Times.Once); + } + + [Fact] + public async Task RefreshVideoGameReferenceAsync_ReturnsUnchanged_WhenReferenceHasNoExternalId() + { + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults()); + var reference = new VideoGameReferenceModel { Id = "reference-1", Title = "Some Game", TitleNormalized = "some game", ExternalIds = [] }; + + var (result, changed) = await service.RefreshVideoGameReferenceAsync(reference, TestContext.Current.CancellationToken); + + changed.Should().BeFalse(); + result.Should().BeSameAs(reference); + _videoGameReferenceRepository.Verify(r => r.UpsertAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task RefreshVideoGameReferenceAsync_AlwaysRefetches_RegardlessOfLastEnrichedAt() + { + // RAWG exposes no "changed since" endpoint (unlike TMDB) - every refresh call does a full + // re-fetch, even when LastEnrichedAt is very recent. + var rawgClient = FakeRawgClient.Empty(); + rawgClient.Details["1"] = new RawgGameDetails("1", "Some Game - Updated", 2020, "New synopsis", ["Action"], ["PC"], null); + var reference = new VideoGameReferenceModel + { + Id = "reference-1", + Title = "Some Game", + TitleNormalized = "some game", + ExternalIds = new Dictionary { ["rawg"] = "1" }, + LastEnrichedAt = DateTime.UtcNow + }; + _videoGameReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((VideoGameReferenceModel m) => m); + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults(), rawgClient: rawgClient); + + var (result, changed) = await service.RefreshVideoGameReferenceAsync(reference, TestContext.Current.CancellationToken); + + changed.Should().BeTrue(); + result.Title.Should().Be("Some Game - Updated"); + result.Platforms.Should().Contain("PC"); + } + + [Fact] + public async Task RefreshVideoGameReferenceAsync_DoesNotDuplicateAnAliasAlreadyPersistedWithANullCreator() + { + // Regression: MergeMatchedAliases must recognize an existing alias with Creator = null as the same + // alias it's about to re-add with a freshly-computed null Creator (TV show/movie/video game domains + // have no creator dimension), or every refresh appends a fresh, indistinguishable duplicate forever. + // This used to fail because a null Creator silently round-tripped through Mongo as "" (a global + // AllowNullDestinationValues = false default), which the in-memory comparison here didn't account + // for - confirmed against a real RAWG-backed video game reference ("God of War") that had + // accumulated an exact duplicate {title, year, creator: ""} entry from being resolved/refreshed + // more than once. Fixed at the mapping layer instead (DataStorageMappingProfile opts Creator out of + // AllowNullDestinationValues, so it stays a real null in Mongo) rather than papering over it here. + var rawgClient = FakeRawgClient.Empty(); + rawgClient.Details["1"] = new RawgGameDetails("1", "Some Game", 2020, "Synopsis", ["Action"], ["PC"], null); + var reference = new VideoGameReferenceModel + { + Id = "reference-1", + Title = "Some Game", + TitleNormalized = "some game", + ExternalIds = new Dictionary { ["rawg"] = "1" }, + MatchedAliases = [new ReferenceMatchModel { Title = "some game", Year = 2020, Creator = null }] + }; + _videoGameReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((VideoGameReferenceModel m) => m); + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults(), rawgClient: rawgClient); + + var (result, _) = await service.RefreshVideoGameReferenceAsync(reference, TestContext.Current.CancellationToken); + + result.MatchedAliases.Should().ContainSingle(); + } + + [Fact] + public async Task TryAutoResolveAlbumAsync_DoesNothing_WhenSearchIsAmbiguous() + { + var discogsClient = FakeDiscogsClient.WithSearchResults( + new DiscogsSearchResult("1", "Some Album", 2020, "Some Artist", null), + new DiscogsSearchResult("2", "Some Album", 2020, "Some Artist", null)); + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults(), discogsClient: discogsClient); + + await service.TryAutoResolveAlbumAsync("Some Album", 2020); + + _albumRepository.Verify(r => r.SetReferenceLinkAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task TryAutoResolveAlbumAsync_ResolvesAndPropagates_WhenExactlyOneCandidate() + { + var discogsClient = FakeDiscogsClient.WithSearchResults(new DiscogsSearchResult("1", "Some Album", 2020, "Some Artist", null)); + discogsClient.Details["1"] = new DiscogsAlbumDetails("1", "Some Album", 2020, "Synopsis", "Some Artist", "100", [], null, []); + _albumReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((AlbumReferenceModel m) => { m.Id ??= "generated-id"; return m; }); + _personReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((PersonReferenceModel m) => { m.Id ??= "person-1"; return m; }); + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults(), discogsClient: discogsClient); + + await service.TryAutoResolveAlbumAsync("Some Album", 2020); + + _albumReferenceRepository.Verify(r => r.UpsertAsync(It.Is(m => m.ExternalIds["discogs"] == "1")), Times.Once); + _albumRepository.Verify(r => r.SetReferenceLinkAsync("Some Album", 2020, It.IsAny(), "Some Album", It.IsAny(), "Some Artist"), Times.Once); + } + + [Fact] + public async Task TryAutoResolveAlbumAsync_PassesTheArtistThroughToTheDiscogsSearch() + { + // regression: a common album title without an artist hint returns many unrelated candidates - the + // artist must reach IDiscogsClient.SearchAlbumsAsync, not just get dropped along the way. + var discogsClient = FakeDiscogsClient.WithSearchResults(new DiscogsSearchResult("1", "Some Album", 2020, "Pink Floyd", null)); + discogsClient.Details["1"] = new DiscogsAlbumDetails("1", "Some Album", 2020, "Synopsis", "Pink Floyd", "100", [], null, []); + _albumReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((AlbumReferenceModel m) => { m.Id ??= "generated-id"; return m; }); + _personReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((PersonReferenceModel m) => { m.Id ??= "person-1"; return m; }); + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults(), discogsClient: discogsClient); + + await service.TryAutoResolveAlbumAsync("The Dark Side of the Moon", 1973, "Pink Floyd"); + + discogsClient.LastSearchArtist.Should().Be("Pink Floyd"); + } + + [Fact] + public async Task ResolveAlbumAsync_PropagatesTheUpsertedReferenceId() + { + var discogsClient = FakeDiscogsClient.Empty(); + discogsClient.Details["1"] = new DiscogsAlbumDetails("1", "Some Album", 2020, "Synopsis", "Some Artist", "100", [], null, []); + _albumReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((AlbumReferenceModel m) => { m.Id = "reference-1"; return m; }); + _personReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((PersonReferenceModel m) => { m.Id ??= "person-1"; return m; }); + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults(), discogsClient: discogsClient); + + var result = await service.ResolveAlbumAsync("Some Album", 2020, "1"); + + result.Id.Should().Be("reference-1"); + result.ArtistReferenceId.Should().Be("person-1"); + _albumRepository.Verify(r => r.SetReferenceLinkAsync("Some Album", 2020, "reference-1", "Some Album", It.IsAny(), "Some Artist"), Times.Once); + } + + [Fact] + public async Task TryLinkExistingAlbumReferenceAsync_LinksAndUpdatesTitleAndArtist_OnTitleYearMatch() + { + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults()); + var model = new AlbumModel { Id = "album-1", OwnerId = "owner", Title = "Some Typo'd Album", Artist = "Wrong Artist", Year = 2020 }; + _albumReferenceRepository + .Setup(r => r.FindByTitleYearAsync("Some Typo'd Album", 2020, "Wrong Artist")) + .ReturnsAsync(new AlbumReferenceModel { Id = "reference-1", Title = "Some Album", TitleNormalized = "some album", ArtistReferenceId = "person-1", ExternalIds = [] }); + _personReferenceRepository + .Setup(r => r.FindByIdAsync("person-1")) + .ReturnsAsync(new PersonReferenceModel { Id = "person-1", Name = "Correct Artist", ExternalIds = new Dictionary { ["discogs"] = "100" } }); + + var result = await service.TryLinkExistingAlbumReferenceAsync(model); + + result.ReferenceId.Should().Be("reference-1"); + result.Title.Should().Be("Some Album"); + result.Artist.Should().Be("Correct Artist"); + _albumRepository.Verify(r => r.UpdateAsync("album-1", It.Is(m => m.ReferenceId == "reference-1"), "owner"), Times.Once); + } + + [Fact] + public async Task TryLinkExistingAlbumReferenceAsync_UpdatesYearToTheReferencesCanonicalYear_OnLink() + { + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults()); + var model = new AlbumModel { Id = "album-1", OwnerId = "owner", Title = "Some Album", Artist = "Some Artist", Year = 2019 }; + _albumReferenceRepository + .Setup(r => r.FindByTitleYearAsync("Some Album", 2019, "Some Artist")) + .ReturnsAsync(new AlbumReferenceModel { Id = "reference-1", Title = "Some Album", TitleNormalized = "some album", Year = 2020, ExternalIds = [] }); + + var result = await service.TryLinkExistingAlbumReferenceAsync(model); + + result.Year.Should().Be(2020); + _albumRepository.Verify(r => r.UpdateAsync("album-1", It.Is(m => m.Year == 2020), "owner"), Times.Once); + } + + [Fact] + public async Task TryLinkExistingAlbumReferenceAsync_SetsGenreFromTheReferencesGenres_OnLink() + { + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults()); + var model = new AlbumModel { Id = "album-1", OwnerId = "owner", Title = "Some Album", Artist = "Some Artist", Year = 2020 }; + _albumReferenceRepository + .Setup(r => r.FindByTitleYearAsync("Some Album", 2020, "Some Artist")) + .ReturnsAsync(new AlbumReferenceModel { Id = "reference-1", Title = "Some Album", TitleNormalized = "some album", ExternalIds = [], Genres = ["Pop", "K-pop"] }); + + var result = await service.TryLinkExistingAlbumReferenceAsync(model); + + result.Genre.Should().Be("Pop, K-pop"); + _albumRepository.Verify(r => r.UpdateAsync("album-1", It.Is(m => m.Genre == "Pop, K-pop"), "owner"), Times.Once); + _albumRepository.Verify(r => r.SetReferenceLinkAsync("Some Album", 2020, "reference-1", "Some Album", It.IsAny(), It.IsAny(), "Pop, K-pop"), Times.Once); + } + + [Fact] + public async Task TryLinkExistingAlbumReferenceAsync_Unlinks_WhenAlreadyLinkedButNoMatchFoundForTheCurrentTitle() + { + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults()); + var model = new AlbumModel { Id = "album-1", OwnerId = "owner", Title = "Some Album", Artist = "Some Artist", Year = 2020, ReferenceId = "old-reference" }; + _albumReferenceRepository.Setup(r => r.FindByTitleYearAsync("Some Album", 2020, "Some Artist")).ReturnsAsync((AlbumReferenceModel?)null); + _albumReferenceRepository.Setup(r => r.FindByTitleAsync("Some Album", "Some Artist")).ReturnsAsync((AlbumReferenceModel?)null); + + var result = await service.TryLinkExistingAlbumReferenceAsync(model); + + result.ReferenceId.Should().BeEmpty(); + _albumRepository.Verify(r => r.UpdateAsync("album-1", It.Is(m => m.ReferenceId == string.Empty), "owner"), Times.Once); + } + + [Fact] + public async Task RefreshAlbumReferenceAsync_ReturnsUnchanged_WhenReferenceHasNoExternalId() + { + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults()); + var reference = new AlbumReferenceModel { Id = "reference-1", Title = "Some Album", TitleNormalized = "some album", ExternalIds = [] }; + + var (result, changed) = await service.RefreshAlbumReferenceAsync(reference, TestContext.Current.CancellationToken); + + changed.Should().BeFalse(); + result.Should().BeSameAs(reference); + _albumReferenceRepository.Verify(r => r.UpsertAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task RefreshAlbumReferenceAsync_AlwaysRefetches_RegardlessOfLastEnrichedAt() + { + // Discogs exposes no "changed since" endpoint (unlike TMDB) - every refresh call does a full + // re-fetch, even when LastEnrichedAt is very recent. + var discogsClient = FakeDiscogsClient.Empty(); + discogsClient.Details["1"] = new DiscogsAlbumDetails("1", "Some Album - Updated", 2020, "New synopsis", "Some Artist", "100", ["Rock"], null, + [new DiscogsTrack("1", "Intro", "0:22"), new DiscogsTrack("2", "Apocalypse Please", "4:12")]); + var reference = new AlbumReferenceModel + { + Id = "reference-1", + Title = "Some Album", + TitleNormalized = "some album", + ExternalIds = new Dictionary { ["discogs"] = "1" }, + LastEnrichedAt = DateTime.UtcNow + }; + _albumReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((AlbumReferenceModel m) => m); + _personReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((PersonReferenceModel m) => { m.Id ??= "person-1"; return m; }); + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults(), discogsClient: discogsClient); + + var (result, changed) = await service.RefreshAlbumReferenceAsync(reference, TestContext.Current.CancellationToken); + + changed.Should().BeTrue(); + result.Title.Should().Be("Some Album - Updated"); + result.Genres.Should().Contain("Rock"); + result.Tracks.Should().SatisfyRespectively( + t => { t.Position.Should().Be("1"); t.Title.Should().Be("Intro"); t.Duration.Should().Be("0:22"); }, + t => { t.Position.Should().Be("2"); t.Title.Should().Be("Apocalypse Please"); t.Duration.Should().Be("4:12"); }); + } + + private sealed class FakeTmdbClient : ITmdbClient + { + private readonly List _tvShowSearchResults; + + public Dictionary TvShowDetails { get; } = new(); + + public Dictionary MovieDetails { get; } = new(); + + public Dictionary> Cast { get; } = new(); + + /// Whether TMDB reports a change for a given id - defaults to true (changed) when unset. + public Dictionary ChangedSince { get; } = new(); + + public List TvShowDetailsRequested { get; } = []; + + public List ChangesRequested { get; } = []; + + private FakeTmdbClient(List tvShowSearchResults) => _tvShowSearchResults = tvShowSearchResults; + + public static FakeTmdbClient WithTvShowSearchResults(params TmdbSearchResult[] results) => new([.. results]); + + public Task> SearchTvShowAsync(string title, int? year, CancellationToken cancellationToken = default) => + Task.FromResult>(_tvShowSearchResults); + + public Task> SearchMovieAsync(string title, int? year, CancellationToken cancellationToken = default) => + Task.FromResult>([]); + + public Task GetTvShowDetailsAsync(string tmdbId, CancellationToken cancellationToken = default) + { + TvShowDetailsRequested.Add(tmdbId); + return Task.FromResult(TvShowDetails.GetValueOrDefault(tmdbId)); + } + + public Task GetMovieDetailsAsync(string tmdbId, CancellationToken cancellationToken = default) => + Task.FromResult(MovieDetails.GetValueOrDefault(tmdbId)); + + public Task> GetTvShowCastAsync(string tmdbId, CancellationToken cancellationToken = default) => + Task.FromResult>(Cast.GetValueOrDefault(tmdbId) ?? []); + + public Task> GetMovieCastAsync(string tmdbId, CancellationToken cancellationToken = default) => + Task.FromResult>(Cast.GetValueOrDefault(tmdbId) ?? []); + + public Task HasTvShowChangedSinceAsync(string tmdbId, DateTime since, CancellationToken cancellationToken = default) + { + ChangesRequested.Add(tmdbId); + return Task.FromResult(ChangedSince.GetValueOrDefault(tmdbId, true)); + } + + public Task HasMovieChangedSinceAsync(string tmdbId, DateTime since, CancellationToken cancellationToken = default) + { + ChangesRequested.Add(tmdbId); + return Task.FromResult(ChangedSince.GetValueOrDefault(tmdbId, true)); + } + } +} diff --git a/test/WebApi.UnitTests/ReferenceData/ReferenceSyncServiceTest.cs b/test/WebApi.UnitTests/ReferenceData/ReferenceSyncServiceTest.cs new file mode 100644 index 00000000..55c72353 --- /dev/null +++ b/test/WebApi.UnitTests/ReferenceData/ReferenceSyncServiceTest.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using AwesomeAssertions; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.WebApi.Contracts.Dto; +using Keeptrack.WebApi.ReferenceData; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Keeptrack.WebApi.UnitTests.ReferenceData; + +[Trait("Category", "UnitTests")] +public class ReferenceSyncServiceTest +{ + private readonly Mock _tvShowReferenceRepository = new(); + private readonly Mock _movieReferenceRepository = new(); + private readonly Mock _personReferenceRepository = new(); + private readonly Mock _bookReferenceRepository = new(); + private readonly Mock _videoGameReferenceRepository = new(); + private readonly Mock _albumReferenceRepository = new(); + private readonly Mock _tvShowRepository = new(); + private readonly Mock _movieRepository = new(); + private readonly Mock _bookRepository = new(); + private readonly Mock _videoGameRepository = new(); + private readonly Mock _albumRepository = new(); + + private ReferenceSyncService CreateService(FakeTmdbClient tmdbClient) + { + _bookReferenceRepository.Setup(r => r.FindAllAsync()).ReturnsAsync([]); + _videoGameReferenceRepository.Setup(r => r.FindAllAsync()).ReturnsAsync([]); + _albumReferenceRepository.Setup(r => r.FindAllAsync()).ReturnsAsync([]); + + var enrichmentService = new ReferenceEnrichmentService( + tmdbClient, FakeBookReferenceClient.Empty(), FakeRawgClient.Empty(), FakeDiscogsClient.Empty(), + _tvShowReferenceRepository.Object, _movieReferenceRepository.Object, _personReferenceRepository.Object, + _bookReferenceRepository.Object, _videoGameReferenceRepository.Object, _albumReferenceRepository.Object, + _tvShowRepository.Object, _movieRepository.Object, _bookRepository.Object, _videoGameRepository.Object, _albumRepository.Object); + return new ReferenceSyncService( + _tvShowReferenceRepository.Object, _movieReferenceRepository.Object, + _bookReferenceRepository.Object, _videoGameReferenceRepository.Object, _albumReferenceRepository.Object, + enrichmentService, NullLogger.Instance); + } + + [Fact] + public async Task SyncStaleReferencesAsync_Skips_ReferenceEnrichedMoreRecentlyThanStaleAfter() + { + _tvShowReferenceRepository.Setup(r => r.FindAllAsync()).ReturnsAsync([ + new TvShowReferenceModel + { + Id = "reference-1", Title = "Some Show", TitleNormalized = "some show", + ExternalIds = new Dictionary { ["tmdb"] = "42" }, LastEnrichedAt = DateTime.UtcNow + } + ]); + _movieReferenceRepository.Setup(r => r.FindAllAsync()).ReturnsAsync([]); + var service = CreateService(FakeTmdbClient.Empty()); + + var result = await service.SyncStaleReferencesAsync(TimeSpan.FromDays(3), cancellationToken: TestContext.Current.CancellationToken); + + result.TvShowsChecked.Should().Be(0); + _tvShowReferenceRepository.Verify(r => r.UpsertAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task SyncStaleReferencesAsync_Refreshes_ReferenceOlderThanStaleAfter() + { + var tmdbClient = FakeTmdbClient.Empty(); + tmdbClient.TvShowDetails["42"] = new TmdbTvShowDetails("42", "Some Show", 2020, "Synopsis", [], [], null); + _tvShowReferenceRepository.Setup(r => r.FindAllAsync()).ReturnsAsync([ + new TvShowReferenceModel + { + Id = "reference-1", Title = "Some Show", TitleNormalized = "some show", + ExternalIds = new Dictionary { ["tmdb"] = "42" }, LastEnrichedAt = DateTime.UtcNow.AddDays(-10) + } + ]); + _movieReferenceRepository.Setup(r => r.FindAllAsync()).ReturnsAsync([]); + _tvShowReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((TvShowReferenceModel m) => m); + var service = CreateService(tmdbClient); + + var result = await service.SyncStaleReferencesAsync(TimeSpan.FromDays(3), cancellationToken: TestContext.Current.CancellationToken); + + result.TvShowsChecked.Should().Be(1); + result.TvShowsUpdated.Should().Be(1); + } + + [Fact] + public async Task SyncStaleReferencesAsync_ContinuesPastAFailedReference_AndStillProcessesTheRest() + { + var tmdbClient = FakeTmdbClient.Empty(); + tmdbClient.TvShowDetails["good"] = new TmdbTvShowDetails("good", "Good Show", 2020, null, [], [], null); + tmdbClient.ThrowForTmdbId = "bad"; + _tvShowReferenceRepository.Setup(r => r.FindAllAsync()).ReturnsAsync([ + new TvShowReferenceModel + { + Id = "reference-bad", Title = "Bad Show", TitleNormalized = "bad show", + ExternalIds = new Dictionary { ["tmdb"] = "bad" }, LastEnrichedAt = null + }, + new TvShowReferenceModel + { + Id = "reference-good", Title = "Good Show", TitleNormalized = "good show", + ExternalIds = new Dictionary { ["tmdb"] = "good" }, LastEnrichedAt = null + } + ]); + _movieReferenceRepository.Setup(r => r.FindAllAsync()).ReturnsAsync([]); + _tvShowReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((TvShowReferenceModel m) => m); + var service = CreateService(tmdbClient); + + var result = await service.SyncStaleReferencesAsync(TimeSpan.FromDays(3), cancellationToken: TestContext.Current.CancellationToken); + + result.TvShowsChecked.Should().Be(2); + result.TvShowsUpdated.Should().Be(1); + } + + /// + /// Backs the admin "sync now" progress bar (ReferenceDataAdminController.SyncNow/ + /// ReferenceDataAdminPage.razor's polling loop) - confirms the stage callback fires once per + /// domain, in the same order the UI's progress-percent mapping assumes. + /// + [Fact] + public async Task SyncStaleReferencesAsync_InvokesOnStageChanged_OncePerDomainInOrder() + { + _tvShowReferenceRepository.Setup(r => r.FindAllAsync()).ReturnsAsync([]); + _movieReferenceRepository.Setup(r => r.FindAllAsync()).ReturnsAsync([]); + var service = CreateService(FakeTmdbClient.Empty()); + var stages = new List(); + + await service.SyncStaleReferencesAsync(TimeSpan.FromDays(3), stages.Add, TestContext.Current.CancellationToken); + + stages.Should().ContainInOrder( + ReferenceSyncStage.SyncingTvShows, + ReferenceSyncStage.SyncingMovies, + ReferenceSyncStage.SyncingBooks, + ReferenceSyncStage.SyncingVideoGames, + ReferenceSyncStage.SyncingAlbums); + } + + private sealed class FakeTmdbClient : ITmdbClient + { + public Dictionary TvShowDetails { get; } = new(); + + public Dictionary MovieDetails { get; } = new(); + + public string? ThrowForTmdbId { get; set; } + + public static FakeTmdbClient Empty() => new(); + + public Task> SearchTvShowAsync(string title, int? year, CancellationToken cancellationToken = default) => + Task.FromResult>([]); + + public Task> SearchMovieAsync(string title, int? year, CancellationToken cancellationToken = default) => + Task.FromResult>([]); + + public Task GetTvShowDetailsAsync(string tmdbId, CancellationToken cancellationToken = default) + { + if (tmdbId == ThrowForTmdbId) throw new InvalidOperationException("Simulated TMDB failure."); + return Task.FromResult(TvShowDetails.GetValueOrDefault(tmdbId)); + } + + public Task GetMovieDetailsAsync(string tmdbId, CancellationToken cancellationToken = default) + { + if (tmdbId == ThrowForTmdbId) throw new InvalidOperationException("Simulated TMDB failure."); + return Task.FromResult(MovieDetails.GetValueOrDefault(tmdbId)); + } + + public Task> GetTvShowCastAsync(string tmdbId, CancellationToken cancellationToken = default) => + Task.FromResult>([]); + + public Task> GetMovieCastAsync(string tmdbId, CancellationToken cancellationToken = default) => + Task.FromResult>([]); + + public Task HasTvShowChangedSinceAsync(string tmdbId, DateTime since, CancellationToken cancellationToken = default) => + Task.FromResult(true); + + public Task HasMovieChangedSinceAsync(string tmdbId, DateTime since, CancellationToken cancellationToken = default) => + Task.FromResult(true); + } +} diff --git a/test/WebApi.UnitTests/Services/CarMetricsServiceTest.cs b/test/WebApi.UnitTests/Services/CarMetricsServiceTest.cs new file mode 100644 index 00000000..6b8a6ac4 --- /dev/null +++ b/test/WebApi.UnitTests/Services/CarMetricsServiceTest.cs @@ -0,0 +1,189 @@ +using System; +using AwesomeAssertions; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Services; +using Xunit; + +namespace Keeptrack.WebApi.UnitTests.Services; + +[Trait("Category", "UnitTests")] +public class CarMetricsServiceTest +{ + private readonly CarMetricsService _service = new(); + + private static CarHistoryModel Refuel( + string id, DateTime date, int mileage, + double? fuelVolume = null, double? electricVolume = null, bool isFullRefill = true, + double? deltaMileage = null, double? cost = null) => + new() + { + Id = id, + OwnerId = "owner", + CarId = "car-1", + HistoryDate = date, + Mileage = mileage, + EventType = CarHistoryType.Refuel, + FuelVolume = fuelVolume, + ElectricVolume = electricVolume, + IsFullRefill = isFullRefill, + DeltaMileage = deltaMileage, + Cost = cost + }; + + private static CarHistoryModel Maintenance(string id, DateTime date, int? mileage = null, double? cost = null) => + new() { Id = id, OwnerId = "owner", CarId = "car-1", HistoryDate = date, Mileage = mileage, EventType = CarHistoryType.Maintenance, Cost = cost }; + + [Fact] + public void ComputeMetrics_FuelConsumption_OnlyEmitsAPointAcrossAFullRefill() + { + var history = new[] + { + Refuel("h1", new DateTime(2024, 1, 1), 1000, fuelVolume: 40, isFullRefill: true), + Refuel("h2", new DateTime(2024, 1, 15), 1200, fuelVolume: 20, isFullRefill: false), + Refuel("h3", new DateTime(2024, 2, 1), 1400, fuelVolume: 25, isFullRefill: true) + }; + + var result = _service.ComputeMetrics(history); + + result.FuelConsumption.Should().ContainSingle(); + result.FuelConsumption[0].ValuePer100Km.Should().BeApproximately(11.25, 0.001); + result.AverageFuelConsumptionPer100Km.Should().BeApproximately(11.25, 0.001); + } + + [Fact] + public void ComputeMetrics_FuelAndElectricConsumption_AreComputedIndependentlyForAHybrid() + { + var history = new[] + { + Refuel("f1", new DateTime(2024, 1, 1), 1000, fuelVolume: 40, isFullRefill: true), + Refuel("f2", new DateTime(2024, 2, 1), 1500, fuelVolume: 35, isFullRefill: true), + Refuel("e1", new DateTime(2024, 1, 10), 1100, electricVolume: 15, isFullRefill: true), + Refuel("e2", new DateTime(2024, 1, 20), 1300, electricVolume: 30, isFullRefill: true) + }; + + var result = _service.ComputeMetrics(history); + + result.FuelConsumption.Should().ContainSingle(); + result.FuelConsumption[0].ValuePer100Km.Should().BeApproximately(35.0 / 500 * 100, 0.001); + + result.ElectricConsumption.Should().ContainSingle(); + result.ElectricConsumption[0].ValuePer100Km.Should().BeApproximately(30.0 / 200 * 100, 0.001); + } + + [Fact] + public void ComputeMetrics_CostHistory_GroupsByMonthAndSplitsFuelFromMaintenance() + { + var history = new[] + { + Refuel("h1", new DateTime(2024, 1, 5), 1000, fuelVolume: 40, cost: 60), + Maintenance("h2", new DateTime(2024, 1, 20), 1050, cost: 150), + Refuel("h3", new DateTime(2024, 2, 5), 1500, fuelVolume: 40, cost: 65) + }; + + var result = _service.ComputeMetrics(history); + + result.CostHistory.Should().HaveCount(2); + var january = result.CostHistory[0]; + january.Period.Should().Be(new DateOnly(2024, 1, 1)); + january.FuelCost.Should().Be(60); + january.MaintenanceCost.Should().Be(150); + january.TotalCost.Should().Be(210); + + result.TotalCost.Should().Be(275); + } + + [Fact] + public void ComputeMetrics_NextMaintenance_IsNullWhenNoMaintenanceHistoryExists() + { + var history = new[] { Refuel("h1", new DateTime(2024, 1, 1), 1000, fuelVolume: 40) }; + + var result = _service.ComputeMetrics(history); + + result.NextMaintenance.Should().BeNull(); + } + + [Fact] + public void ComputeMetrics_NextMaintenance_IsOneYearAfterTheLastMaintenanceEvent() + { + var lastMaintenance = DateOnly.FromDateTime(DateTime.Today).AddMonths(-10); + var history = new[] { Maintenance("h1", lastMaintenance.ToDateTime(TimeOnly.MinValue)) }; + + var result = _service.ComputeMetrics(history); + + result.NextMaintenance.Should().NotBeNull(); + result.NextMaintenance!.LastMaintenanceDate.Should().Be(lastMaintenance); + result.NextMaintenance.DueDate.Should().Be(lastMaintenance.AddYears(1)); + result.NextMaintenance.MonthsRemaining.Should().Be(2); + } + + [Fact] + public void ComputeMetrics_NextMaintenance_ReportsNegativeMonthsWhenOverdue() + { + var lastMaintenance = DateOnly.FromDateTime(DateTime.Today).AddMonths(-14); + var history = new[] { Maintenance("h1", lastMaintenance.ToDateTime(TimeOnly.MinValue)) }; + + var result = _service.ComputeMetrics(history); + + result.NextMaintenance!.MonthsRemaining.Should().Be(-2); + } + + [Fact] + public void ComputeMetrics_MileageWarnings_FlagsAnOdometerRegression() + { + var history = new[] + { + Refuel("h1", new DateTime(2024, 1, 1), 5000, fuelVolume: 40), + Refuel("h2", new DateTime(2024, 2, 1), 4800, fuelVolume: 40) + }; + + var result = _service.ComputeMetrics(history); + + result.MileageWarnings.Should().ContainSingle(w => w.CarHistoryId == "h2"); + } + + [Fact] + public void ComputeMetrics_MileageWarnings_FlagsADeltaMismatchAndSuggestsAMissingEntry() + { + // the trip computer says 300 km since the last refuel, but the odometer jumped 900 km since the + // previous entry in the app - a refuel was very likely never logged in between + var history = new[] + { + Refuel("h1", new DateTime(2024, 1, 1), 1000, fuelVolume: 40), + Refuel("h2", new DateTime(2024, 2, 1), 1900, fuelVolume: 40, deltaMileage: 300) + }; + + var result = _service.ComputeMetrics(history); + + result.MileageWarnings.Should().ContainSingle(w => w.CarHistoryId == "h2"); + result.MileageWarnings[0].Message.Should().Contain("missing"); + } + + [Fact] + public void ComputeMetrics_MileageWarnings_FlagsADeltaMismatchWithoutSuggestingAMissingEntryWhenSmaller() + { + var history = new[] + { + Refuel("h1", new DateTime(2024, 1, 1), 1000, fuelVolume: 40), + Refuel("h2", new DateTime(2024, 2, 1), 1300, fuelVolume: 40, deltaMileage: 500) + }; + + var result = _service.ComputeMetrics(history); + + result.MileageWarnings.Should().ContainSingle(w => w.CarHistoryId == "h2"); + result.MileageWarnings[0].Message.Should().NotContain("missing"); + } + + [Fact] + public void ComputeMetrics_MileageWarnings_IsSilentWhenDeltaMileageMatchesWithinTolerance() + { + var history = new[] + { + Refuel("h1", new DateTime(2024, 1, 1), 1000, fuelVolume: 40), + Refuel("h2", new DateTime(2024, 2, 1), 1500, fuelVolume: 40, deltaMileage: 500.4) + }; + + var result = _service.ComputeMetrics(history); + + result.MileageWarnings.Should().BeEmpty(); + } +} diff --git a/test/WebApi.UnitTests/Services/HouseMetricsServiceTest.cs b/test/WebApi.UnitTests/Services/HouseMetricsServiceTest.cs new file mode 100644 index 00000000..fe1d3597 --- /dev/null +++ b/test/WebApi.UnitTests/Services/HouseMetricsServiceTest.cs @@ -0,0 +1,74 @@ +using System; +using AwesomeAssertions; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Services; +using Xunit; + +namespace Keeptrack.WebApi.UnitTests.Services; + +[Trait("Category", "UnitTests")] +public class HouseMetricsServiceTest +{ + private readonly HouseMetricsService _service = new(); + + private static HouseHistoryModel Entry(string id, DateOnly date, HouseEventType eventType, double? cost = null) => + new() { Id = id, OwnerId = "owner", HouseId = "house-1", HistoryDate = date, EventType = eventType, Cost = cost }; + + [Fact] + public void ComputeMetrics_CostHistory_IsEmpty_WhenThereIsNoHistory() + { + var result = _service.ComputeMetrics([]); + + result.CostHistory.Should().BeEmpty(); + } + + [Fact] + public void ComputeMetrics_CostHistory_ExcludesEntriesWithNoCost() + { + var history = new[] { Entry("h1", new DateOnly(2024, 3, 1), HouseEventType.Maintenance, cost: null) }; + + var result = _service.ComputeMetrics(history); + + result.CostHistory.Should().BeEmpty(); + } + + [Fact] + public void ComputeMetrics_CostHistory_GroupsByYearAndSumsCost() + { + var history = new[] + { + Entry("h1", new DateOnly(2024, 1, 5), HouseEventType.Bill, cost: 60), + Entry("h2", new DateOnly(2024, 6, 1), HouseEventType.Maintenance, cost: 150), + Entry("h3", new DateOnly(2025, 2, 1), HouseEventType.Purchase, cost: 400) + }; + + var result = _service.ComputeMetrics(history); + + result.CostHistory.Should().HaveCount(2); + var year2024 = result.CostHistory[0]; + year2024.Year.Should().Be(2024); + year2024.TotalCost.Should().Be(210); + + var year2025 = result.CostHistory[1]; + year2025.Year.Should().Be(2025); + year2025.TotalCost.Should().Be(400); + } + + [Fact] + public void ComputeMetrics_CostHistory_BreaksDownByCategoryWithinAYear() + { + var history = new[] + { + Entry("h1", new DateOnly(2024, 1, 5), HouseEventType.Bill, cost: 60), + Entry("h2", new DateOnly(2024, 2, 5), HouseEventType.Bill, cost: 40), + Entry("h3", new DateOnly(2024, 6, 1), HouseEventType.Maintenance, cost: 150) + }; + + var result = _service.ComputeMetrics(history); + + var year2024 = result.CostHistory.Should().ContainSingle().Subject; + year2024.CostByCategory.Should().HaveCount(2); + year2024.CostByCategory.Should().ContainSingle(c => c.EventType == HouseEventType.Bill && c.Cost == 100); + year2024.CostByCategory.Should().ContainSingle(c => c.EventType == HouseEventType.Maintenance && c.Cost == 150); + } +} diff --git a/test/WebApi.UnitTests/Services/WatchNextServiceTest.cs b/test/WebApi.UnitTests/Services/WatchNextServiceTest.cs new file mode 100644 index 00000000..a25e3956 --- /dev/null +++ b/test/WebApi.UnitTests/Services/WatchNextServiceTest.cs @@ -0,0 +1,195 @@ +using System; +using System.Collections.Generic; +using AwesomeAssertions; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Services; +using Xunit; + +namespace Keeptrack.WebApi.UnitTests.Services; + +[Trait("Category", "UnitTests")] +public class WatchNextServiceTest +{ + private static TvShowModel Show(string id, string title, TvShowStatus? status = TvShowStatus.Current, string? referenceId = null) => + new() { Id = id, OwnerId = "owner", Title = title, State = status, ReferenceId = referenceId }; + + private static EpisodeModel Episode(string showId, int season, int episode, DateOnly? watchedAt = null) => + new() { OwnerId = "owner", TvShowId = showId, SeasonNumber = season, EpisodeNumber = episode, WatchedAt = watchedAt }; + + private static ReferenceEpisodeModel RefEpisode(int season, int episode, string title, DateOnly? airDate = null) => + new() { SeasonNumber = season, EpisodeNumber = episode, Title = title, AirDate = airDate }; + + private static TvShowReferenceModel Reference(params ReferenceEpisodeModel[] episodes) => new() + { + Title = "Reference Title", + TitleNormalized = "reference title", + ExternalIds = new Dictionary(), + Episodes = [.. episodes] + }; + + [Fact] + public void ComputeInProgressShows_IncludesShowWithAConfirmedAiredUnwatchedNextEpisode() + { + var shows = new[] { Show("show-1", "Dark", referenceId: "ref-1") }; + var episodes = new[] { Episode("show-1", 1, 1, new DateOnly(2024, 1, 1)) }; + var references = new Dictionary + { + ["show-1"] = Reference(RefEpisode(1, 1, "Ep1"), RefEpisode(1, 2, "Ep2", new DateOnly(2024, 1, 8))) + }; + + var result = WatchNextService.ComputeInProgressShows(shows, episodes, references); + + result.Should().ContainSingle(); + result[0].TvShowTitle.Should().Be("Dark"); + result[0].LastSeasonNumber.Should().Be(1); + result[0].LastEpisodeNumber.Should().Be(1); + result[0].NextSeasonNumber.Should().Be(1); + result[0].NextEpisodeNumber.Should().Be(2); + result[0].NextEpisodeTitle.Should().Be("Ep2"); + } + + [Fact] + public void ComputeInProgressShows_ExcludesFinishedShows() + { + var shows = new[] { Show("show-1", "Dark", status: TvShowStatus.Finished, referenceId: "ref-1") }; + var episodes = new[] { Episode("show-1", 1, 1) }; + var references = new Dictionary + { + ["show-1"] = Reference(RefEpisode(1, 2, "Ep2")) + }; + + var result = WatchNextService.ComputeInProgressShows(shows, episodes, references); + + result.Should().BeEmpty(); + } + + [Fact] + public void ComputeInProgressShows_ExcludesStoppedShows() + { + var shows = new[] { Show("show-1", "Dark", status: TvShowStatus.Stopped, referenceId: "ref-1") }; + var episodes = new[] { Episode("show-1", 1, 1) }; + var references = new Dictionary + { + ["show-1"] = Reference(RefEpisode(1, 2, "Ep2")) + }; + + var result = WatchNextService.ComputeInProgressShows(shows, episodes, references); + + result.Should().BeEmpty(); + } + + [Fact] + public void ComputeInProgressShows_ExcludesShowsWithNoStatusSet() + { + var shows = new[] { Show("show-1", "Dark", status: null, referenceId: "ref-1") }; + var episodes = new[] { Episode("show-1", 1, 1) }; + var references = new Dictionary + { + ["show-1"] = Reference(RefEpisode(1, 2, "Ep2")) + }; + + var result = WatchNextService.ComputeInProgressShows(shows, episodes, references); + + result.Should().BeEmpty(); + } + + [Fact] + public void ComputeInProgressShows_ExcludesShowsWithNoWatchedEpisodes() + { + var shows = new[] { Show("show-1", "Dark", referenceId: "ref-1") }; + var references = new Dictionary { ["show-1"] = Reference(RefEpisode(1, 1, "Ep1")) }; + + var result = WatchNextService.ComputeInProgressShows(shows, [], references); + + result.Should().BeEmpty(); + } + + [Fact] + public void ComputeInProgressShows_ExcludesShowsWithNoReferenceLinked() + { + var shows = new[] { Show("show-1", "Dark", referenceId: null) }; + var episodes = new[] { Episode("show-1", 1, 1) }; + + var result = WatchNextService.ComputeInProgressShows(shows, episodes, new Dictionary()); + + result.Should().BeEmpty(); + } + + [Fact] + public void ComputeInProgressShows_ExcludesShowsFullyCaughtUpWithTheReferenceGuide() + { + var shows = new[] { Show("show-1", "Dark", referenceId: "ref-1") }; + var episodes = new[] { Episode("show-1", 1, 2, new DateOnly(2024, 1, 8)) }; + var references = new Dictionary + { + // no episode after S1E2 in the guide at all + ["show-1"] = Reference(RefEpisode(1, 1, "Ep1"), RefEpisode(1, 2, "Ep2")) + }; + + var result = WatchNextService.ComputeInProgressShows(shows, episodes, references); + + result.Should().BeEmpty(); + } + + [Fact] + public void ComputeInProgressShows_ExcludesShowsWhoseOnlyUnwatchedEpisodeHasNotAiredYet() + { + var shows = new[] { Show("show-1", "Dark", referenceId: "ref-1") }; + var episodes = new[] { Episode("show-1", 1, 1, new DateOnly(2024, 1, 1)) }; + var references = new Dictionary + { + ["show-1"] = Reference(RefEpisode(1, 1, "Ep1"), RefEpisode(1, 2, "Ep2", DateOnly.FromDateTime(DateTime.Today.AddDays(30)))) + }; + + var result = WatchNextService.ComputeInProgressShows(shows, episodes, references); + + result.Should().BeEmpty(); + } + + [Fact] + public void ComputeInProgressShows_OrdersByLastWatchedDescending() + { + var shows = new[] { Show("show-1", "Older", referenceId: "ref-1"), Show("show-2", "Newer", referenceId: "ref-2") }; + var episodes = new[] + { + Episode("show-1", 1, 1, new DateOnly(2024, 1, 1)), + Episode("show-2", 1, 1, new DateOnly(2024, 6, 1)) + }; + var references = new Dictionary + { + ["show-1"] = Reference(RefEpisode(1, 1, "Ep1"), RefEpisode(1, 2, "Ep2")), + ["show-2"] = Reference(RefEpisode(1, 1, "Ep1"), RefEpisode(1, 2, "Ep2")) + }; + + var result = WatchNextService.ComputeInProgressShows(shows, episodes, references); + + result.Should().HaveCount(2); + result[0].TvShowTitle.Should().Be("Newer"); + result[1].TvShowTitle.Should().Be("Older"); + } + + private static MovieModel Movie(string id, string title, DateOnly? firstSeenAt = null) => + new() { Id = id, OwnerId = "owner", Title = title, WantToWatch = true, FirstSeenAt = firstSeenAt }; + + [Fact] + public void FilterMoviesToWatch_IncludesMoviesNotYetSeen() + { + var movies = new[] { Movie("movie-1", "Dune") }; + + var result = WatchNextService.FilterMoviesToWatch(movies); + + result.Should().ContainSingle(m => m.Id == "movie-1"); + } + + [Fact] + public void FilterMoviesToWatch_ExcludesMoviesAlreadyMarkedAsSeen() + { + // toggling "want to watch" on a movie's detail page doesn't clear the flag once it's marked watched - + // an already-seen movie shouldn't linger in the watchlist regardless + var movies = new[] { Movie("movie-1", "Dune", new DateOnly(2024, 1, 1)) }; + + var result = WatchNextService.FilterMoviesToWatch(movies); + + result.Should().BeEmpty(); + } +} diff --git a/test/WebApi.UnitTests/Services/WishlistServiceTest.cs b/test/WebApi.UnitTests/Services/WishlistServiceTest.cs new file mode 100644 index 00000000..fda62927 --- /dev/null +++ b/test/WebApi.UnitTests/Services/WishlistServiceTest.cs @@ -0,0 +1,82 @@ +using AwesomeAssertions; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Services; +using Xunit; + +namespace Keeptrack.WebApi.UnitTests.Services; + +[Trait("Category", "UnitTests")] +public class WishlistServiceTest +{ + [Fact] + public void SortMovies_OrdersByTitleAscending() + { + var movies = new[] + { + new MovieModel { Id = "1", OwnerId = "owner", Title = "Zodiac" }, + new MovieModel { Id = "2", OwnerId = "owner", Title = "Arrival" } + }; + + var result = WishlistService.SortMovies(movies); + + result.Should().HaveCount(2); + result[0].Title.Should().Be("Arrival"); + result[1].Title.Should().Be("Zodiac"); + } + + [Fact] + public void SortTvShows_OrdersByTitleAscending() + { + var shows = new[] + { + new TvShowModel { Id = "1", OwnerId = "owner", Title = "Yellowjackets" }, + new TvShowModel { Id = "2", OwnerId = "owner", Title = "Dark" } + }; + + var result = WishlistService.SortTvShows(shows); + + result.Should().HaveCount(2); + result[0].Title.Should().Be("Dark"); + result[1].Title.Should().Be("Yellowjackets"); + } + + [Fact] + public void SortBooks_OrdersByTitleAscending() + { + var books = new[] + { + new BookModel { Id = "1", OwnerId = "owner", Title = "The Two Towers", Author = "Tolkien" }, + new BookModel { Id = "2", OwnerId = "owner", Title = "Dune", Author = "Herbert" } + }; + + var result = WishlistService.SortBooks(books); + + result.Should().HaveCount(2); + result[0].Title.Should().Be("Dune"); + result[1].Title.Should().Be("The Two Towers"); + } + + [Fact] + public void SortVideoGames_OrdersByTitleAscending() + { + var games = new[] + { + new VideoGameModel { Id = "1", OwnerId = "owner", Title = "Zelda", Platforms = [new VideoGamePlatformModel { Platform = "Switch", State = "Available" }] }, + new VideoGameModel { Id = "2", OwnerId = "owner", Title = "Elden Ring", Platforms = [new VideoGamePlatformModel { Platform = "PS5", State = "Available" }] } + }; + + var result = WishlistService.SortVideoGames(games); + + result.Should().HaveCount(2); + result[0].Title.Should().Be("Elden Ring"); + result[1].Title.Should().Be("Zelda"); + } + + [Fact] + public void SortMovies_ReturnsEmptyListWhenNoMoviesWishlisted() + { + var result = WishlistService.SortMovies([]); + + result.Should().BeEmpty(); + } +}