diff --git a/docs/data-model.md b/docs/data-model.md
index 10651b6..380aa3d 100644
--- a/docs/data-model.md
+++ b/docs/data-model.md
@@ -18,7 +18,7 @@ The fields tracked per collection category. Decided in the Phase 1 spec discussi
| `OwnerId` | string (FK → AspNetUsers) | in code | Indexed; required filter on every read/write |
| `Title` | string (req, 500) | in code | Indexed |
| `Year` | int? | in code | Release / publication year |
-| `Genres` | string? | in code | Comma-separated; future: normalize to a Genre table |
+| `Genres` | many-to-many → `Genre` | in code | Relational, like Tags — see **Genres** below |
| `Barcode` | string? | in code | Indexed; key for Phase 3 scan |
| `ImagePath` | string? | in code | Local cover/poster path (downloaded in Phase 2) |
| `Notes` | string? | in code | Free-form |
@@ -103,6 +103,31 @@ Three EF-Core-managed join tables (auto-created via `HasMany(x => x.Tags).WithMa
API: `GET/POST/DELETE /api/tags`, plus `tags: string[]` on each item DTO that resolves on save (create-or-find by name).
+## Genres
+
+Many-to-many, scoped per owner — same shape as **Tags** above.
+
+```csharp
+public class Genre
+{
+ public int Id { get; set; }
+ public string OwnerId { get; set; } = string.Empty; // indexed; genres are user-scoped
+ public string Name { get; set; } = string.Empty; // unique per (OwnerId, Name)
+}
+```
+
+Three EF-Core-managed join tables (auto-created via `HasMany(x => x.Genres).WithMany()`):
+
+- `GenreMovie(GenresId, MoviesId)`
+- `GenreMusicAlbum(GenresId, MusicAlbumsId)`
+- `GameGenre(GamesId, GenresId)`
+
+`Genre.Name` is normalized lowercase on save (trim → lowercase → drop whitespace-only → distinct), mirroring `Tag.Name`.
+
+API: `genres: string[]` on each item DTO, resolved on save (create-or-find by name), plus a bulk-update `genres` replace-set (`null` clears, malformed → 400). The list endpoints' `?genre=` filter is **exact membership**, not substring.
+
+The migration that introduced this (`AddGenres`, owner decision 2026-08-24) was **schema-only**: the legacy comma-separated `Movies.Genres` / `MusicAlbums.Genres` string columns were dropped with no data backfill — existing genre values were discarded, not migrated.
+
## Store imports / provenance (in code — Steam first)
Backing tables for the "connect a digital store & import owned games" feature
@@ -217,4 +242,3 @@ Tracked separately from Phase 1 in its own GitHub issue (filed as a follow-up to
- **Goldmine media + sleeve grading for music** — sticking with one generic `Condition` field.
- **Region / Edition / Packaging / DiscCount / HasManual / HasBox / vinyl color / RPM / weight** — collector-tier fields, deferred.
- **Cast list for movies, tracklist for music, full company-roles for games** — providers expose these. Full provider payloads are not persisted in the application database. The memory cache is restart-ephemeral; opted-in Redis stores TTL-bounded payloads at rest and must be secured. A full re-fetch after cache expiry re-queries the provider.
-- **Many-to-many `Genre`** — staying with CSV until a filter UI demands it.
diff --git a/src/client/components/AlbumForm.test.tsx b/src/client/components/AlbumForm.test.tsx
index 6bc00dd..c0d1b38 100644
--- a/src/client/components/AlbumForm.test.tsx
+++ b/src/client/components/AlbumForm.test.tsx
@@ -136,3 +136,28 @@ describe('AlbumForm — Fetch metadata by MusicBrainz Release ID', () => {
expect(screen.getByDisplayValue('f4e51c80-99e2-39e1-8062-c9b8e2685bdf')).toBeInTheDocument();
});
});
+
+describe('AlbumForm — Genres', () => {
+ it('typing a genre renders it as a chip and submits it as an array', async () => {
+ const { onSubmit } = renderForm();
+ const user = userEvent.setup();
+
+ const genreInput = screen.getByPlaceholderText('Add genre…');
+ await user.type(genreInput, 'Rock');
+ await user.keyboard('{Enter}');
+
+ expect(screen.getByText('rock')).toBeInTheDocument();
+
+ const titleLabel = screen.getByText('Title');
+ const titleInput = titleLabel.parentElement?.querySelector('input') as HTMLInputElement;
+ await user.type(titleInput, 'Test Album');
+ const artistLabel = screen.getByText('Artist');
+ const artistInput = artistLabel.parentElement?.querySelector('input') as HTMLInputElement;
+ await user.type(artistInput, 'Test Artist');
+ await user.click(screen.getByRole('button', { name: 'Save' }));
+
+ expect(onSubmit).toHaveBeenCalledWith(
+ expect.objectContaining({ genres: ['rock'] }),
+ );
+ });
+});
diff --git a/src/client/components/AlbumForm.tsx b/src/client/components/AlbumForm.tsx
index 8364539..83f96cb 100644
--- a/src/client/components/AlbumForm.tsx
+++ b/src/client/components/AlbumForm.tsx
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
-import { Button, CoverPreview, ExternalIdField, Field, Input, SectionHeading, Select, Textarea } from './ui';
+import { Button, CoverPreview, ExternalIdField, Field, Input, SectionHeading, Select, TagInput, Textarea } from './ui';
import CoverEditor from './CoverEditor';
import CoverFormLayout from './CoverFormLayout';
import PersonalAcquisitionSection from './PersonalAcquisitionSection';
@@ -37,6 +37,7 @@ const empty: Album = {
status: 'Owned',
listenCount: 0,
tags: [],
+ genres: [],
};
export default function AlbumForm({ initial, prefillLookup, prefillBarcode, submitting, submitLabel = 'Save', onSubmit, onDelete }: Props) {
@@ -133,7 +134,7 @@ export default function AlbumForm({ initial, prefillLookup, prefillBarcode, subm
set('label', e.target.value || null)} />
- set('genres', e.target.value || null)} />
+ set('genres', next)} placeholder="Add genre…" />
set('barcode', e.target.value || null)} />
diff --git a/src/client/components/CollectionList.test.tsx b/src/client/components/CollectionList.test.tsx
index aae0851..be74a4f 100644
--- a/src/client/components/CollectionList.test.tsx
+++ b/src/client/components/CollectionList.test.tsx
@@ -120,6 +120,28 @@ describe('CollectionList — bulk select + update', () => {
});
});
+ it('exposes a Genres field in the bulk modal, mapped to updates.genres', async () => {
+ const fetchSpy = mockFetch();
+ renderList();
+ const user = userEvent.setup();
+
+ await screen.findByText('Inception');
+ const checkboxes = screen.getAllByLabelText('Select item');
+ await user.click(checkboxes[0]);
+ await user.click(screen.getByRole('button', { name: 'Edit selected' }));
+
+ expect(screen.getByText('Genres')).toBeInTheDocument();
+ await user.type(screen.getByPlaceholderText('Add genre…'), 'Sci-Fi{Enter}');
+ await user.click(screen.getByRole('button', { name: 'Confirm' }));
+
+ await waitFor(() => {
+ const bulkCall = fetchSpy.mock.calls.find(([u]) => String(u) === '/api/movies/bulk');
+ expect(bulkCall).toBeDefined();
+ });
+ const [, init] = fetchSpy.mock.calls.find(([u]) => String(u) === '/api/movies/bulk')!;
+ expect(JSON.parse(init!.body as string)).toEqual({ ids: [1], updates: { genres: ['sci-fi'] } });
+ });
+
it('Confirm is disabled until a field is set, and enables once one is', async () => {
const fetchSpy = mockFetch();
renderList();
diff --git a/src/client/components/CollectionList.tsx b/src/client/components/CollectionList.tsx
index ef402e0..891aa28 100644
--- a/src/client/components/CollectionList.tsx
+++ b/src/client/components/CollectionList.tsx
@@ -268,6 +268,7 @@ export default function CollectionList({ type, title, newPa
const [bulkStatus, setBulkStatus] = useState('');
const [bulkRating, setBulkRating] = useState(null);
const [bulkTags, setBulkTags] = useState([]);
+ const [bulkGenres, setBulkGenres] = useState([]);
const [bulkAcquiredOn, setBulkAcquiredOn] = useState('');
const [bulkWatchStatus, setBulkWatchStatus] = useState('');
@@ -277,6 +278,7 @@ export default function CollectionList({ type, title, newPa
setBulkStatus('');
setBulkRating(null);
setBulkTags([]);
+ setBulkGenres([]);
setBulkAcquiredOn('');
setBulkWatchStatus('');
setBulkModalOpen(true);
@@ -284,7 +286,7 @@ export default function CollectionList({ type, title, newPa
const closeBulkEdit = () => setBulkModalOpen(false);
const hasBulkEdit = Boolean(
- bulkStatus || bulkRating != null || bulkTags.length > 0 || bulkAcquiredOn
+ bulkStatus || bulkRating != null || bulkTags.length > 0 || bulkGenres.length > 0 || bulkAcquiredOn
|| (category === 'movies' && bulkWatchStatus),
);
@@ -293,6 +295,7 @@ export default function CollectionList({ type, title, newPa
if (bulkStatus) updates.status = bulkStatus;
if (bulkRating != null) updates.personalRating = bulkRating;
if (bulkTags.length > 0) updates.tags = bulkTags;
+ if (bulkGenres.length > 0) updates.genres = bulkGenres;
if (bulkAcquiredOn) updates.acquiredOn = bulkAcquiredOn;
if (category === 'movies' && bulkWatchStatus) updates.watchStatus = bulkWatchStatus;
@@ -423,6 +426,10 @@ export default function CollectionList({ type, title, newPa
+
+
+
+
setBulkAcquiredOn(e.target.value)} />
diff --git a/src/client/components/FiltersPanel.test.tsx b/src/client/components/FiltersPanel.test.tsx
index 328755d..3108b98 100644
--- a/src/client/components/FiltersPanel.test.tsx
+++ b/src/client/components/FiltersPanel.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from '@testing-library/react';
+import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import type { Filters } from '../services/filters';
@@ -26,6 +26,14 @@ describe('FiltersPanel', () => {
expect(onChange).toHaveBeenCalledWith({ director: 'Nolan', tag: ['imax', 'sci-fi'] });
});
+ it('sends the exact genre value entered, not a partial token', async () => {
+ const onChange = renderPanel('movies', { director: 'Nolan' });
+ await userEvent.click(screen.getByRole('button', { name: /Filters/ }));
+ fireEvent.change(screen.getByPlaceholderText('exact match'), { target: { value: 'sci-fi' } });
+
+ expect(onChange).toHaveBeenLastCalledWith({ director: 'Nolan', genre: 'sci-fi' });
+ });
+
it('counts one rendered year-range chip as one active filter', () => {
renderPanel('movies', { yearFrom: 2000, yearTo: 2020 });
expect(screen.getByRole('button', { name: /Filters \(1\)/ })).toBeInTheDocument();
diff --git a/src/client/components/FiltersPanel.tsx b/src/client/components/FiltersPanel.tsx
index d8830a9..fa89433 100644
--- a/src/client/components/FiltersPanel.tsx
+++ b/src/client/components/FiltersPanel.tsx
@@ -100,7 +100,7 @@ function MovieFields({ value, onChange }: { value: Filters<'movies'>; onChange:
set('studio', e.target.value || undefined)} />
- set('genre', e.target.value || undefined)} placeholder="substring match" />
+ set('genre', e.target.value || undefined)} placeholder="exact match" />
- set('genre', e.target.value || undefined)} placeholder="substring match" />
+ set('genre', e.target.value || undefined)} placeholder="exact match" />
)}
- {(item.genres || item.label) && (
+ {(item.genres?.length || item.label) && (
Metadata
-
+ {!!item.genres?.length && (
+
+ {item.genres.map((genre) => (
+
+ ))}
+
+ )}
)}
{(item.barcode || item.musicBrainzReleaseId || item.discogsId) && (
diff --git a/src/client/services/collection.ts b/src/client/services/collection.ts
index 97c5f0e..d09b2aa 100644
--- a/src/client/services/collection.ts
+++ b/src/client/services/collection.ts
@@ -116,6 +116,7 @@ export interface BulkUpdates {
description?: string | null;
notes?: string | null;
tags?: string[];
+ genres?: string[];
// movies
watchStatus?: WatchStatus;
watchCount?: number;
diff --git a/src/client/services/filters.ts b/src/client/services/filters.ts
index a74511b..b1347cd 100644
--- a/src/client/services/filters.ts
+++ b/src/client/services/filters.ts
@@ -49,6 +49,7 @@ export interface GameFilters {
/** Comma-joined store names, e.g. "Steam,Epic"; the server any-of matches
* the bits and accepts a legacy single name like "Steam". */
digitalStore?: string;
+ genre?: string;
status?: CollectionStatus;
completionStatus?: CompletionStatus;
ratingMin?: number;
@@ -140,6 +141,7 @@ const SCHEMA: Record> = {
yearFrom: 'number', yearTo: 'number',
publisher: 'string', developer: 'string',
platform: 'string', digital: 'boolean', digitalStore: 'string',
+ genre: 'string',
status: 'string', completionStatus: 'string',
ratingMin: 'number',
tag: 'string[]',
diff --git a/src/client/services/types.ts b/src/client/services/types.ts
index b8054ba..19b7fcf 100644
--- a/src/client/services/types.ts
+++ b/src/client/services/types.ts
@@ -49,6 +49,7 @@ export interface CollectionItemBase {
acquisitionCurrency?: string | null; // 3-letter ISO 4217
acquisitionSource?: string | null;
tags?: string[];
+ genres?: string[];
}
// ---------- Movies ----------
@@ -75,7 +76,6 @@ export interface Movie extends CollectionItemBase {
director?: string | null;
runtimeMinutes?: number | null;
studio?: string | null;
- genres?: string | null;
barcode?: string | null;
tmdbId?: string | null;
imdbId?: string | null;
@@ -105,7 +105,6 @@ export interface Album extends CollectionItemBase {
releaseDate?: string | null;
format: MusicFormat;
label?: string | null;
- genres?: string | null;
barcode?: string | null;
musicBrainzReleaseId?: string | null;
discogsId?: string | null;
diff --git a/src/server/Collectify.Api/Endpoints/CollectionEndpoints.cs b/src/server/Collectify.Api/Endpoints/CollectionEndpoints.cs
index c95af6a..0dc3735 100644
--- a/src/server/Collectify.Api/Endpoints/CollectionEndpoints.cs
+++ b/src/server/Collectify.Api/Endpoints/CollectionEndpoints.cs
@@ -17,6 +17,7 @@ namespace Collectify.Api.Endpoints;
public interface ICollectionEntryDto
{
string[]? Tags { get; }
+ string[]? Genres { get; }
}
/// A bulk-updatable field. Authoring the value to a strongly-typed
@@ -80,7 +81,7 @@ public static IEndpointRouteBuilder MapCollectionEndpoints(
HttpContext ctx) =>
{
var ownerId = users.GetUserId(ctx.User)!;
- var q = cfg.Set(db).AsNoTracking().Include(e => e.Tags).Where(e => e.OwnerId == ownerId);
+ var q = cfg.Set(db).AsNoTracking().Include(e => e.Tags).Include(e => e.Genres).Where(e => e.OwnerId == ownerId);
if (!string.IsNullOrWhiteSpace(query))
q = cfg.SearchFilter!(q, query);
@@ -111,7 +112,7 @@ public static IEndpointRouteBuilder MapCollectionEndpoints(
group.MapGet("/{id:int}", async (int id, CollectifyDbContext db, UserManager users, HttpContext ctx) =>
{
var ownerId = users.GetUserId(ctx.User)!;
- var e = await cfg.Set(db).AsNoTracking().Include(x => x.Tags)
+ var e = await cfg.Set(db).AsNoTracking().Include(x => x.Tags).Include(x => x.Genres)
.FirstOrDefaultAsync(x => x.Id == id && x.OwnerId == ownerId);
return e is null ? Results.NotFound() : Results.Ok(cfg.ToDto(e));
});
@@ -124,6 +125,7 @@ public static IEndpointRouteBuilder MapCollectionEndpoints(
cfg.Apply(e, dto);
e.ImagePath = await covers.EnsureLocalAsync(e.ImagePath, ct);
e.Tags = await TagResolver.ResolveAsync(db, ownerId, dto.Tags);
+ e.Genres = await GenreResolver.ResolveAsync(db, ownerId, dto.Genres);
cfg.Set(db).Add(e);
await db.SaveChangesAsync(ct);
return Results.Created($"{cfg.RoutePrefix}/{e.Id}", cfg.ToDto(e));
@@ -133,12 +135,13 @@ public static IEndpointRouteBuilder MapCollectionEndpoints(
{
if (cfg.Validate(dto) is { } error) return error;
var ownerId = users.GetUserId(ctx.User)!;
- var e = await cfg.Set(db).Include(x => x.Tags)
+ var e = await cfg.Set(db).Include(x => x.Tags).Include(x => x.Genres)
.FirstOrDefaultAsync(x => x.Id == id && x.OwnerId == ownerId, ct);
if (e is null) return Results.NotFound();
cfg.Apply(e, dto);
e.ImagePath = await covers.EnsureLocalAsync(e.ImagePath, ct);
e.Tags = await TagResolver.ResolveAsync(db, ownerId, dto.Tags);
+ e.Genres = await GenreResolver.ResolveAsync(db, ownerId, dto.Genres);
e.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
return Results.Ok(cfg.ToDto(e));
@@ -169,12 +172,13 @@ public static IEndpointRouteBuilder MapCollectionEndpoints(
return Results.BadRequest(new { error = "updates must not be empty." });
foreach (var key in req.Updates.Keys)
- if (key != "tags" && !bulk.ContainsKey(key))
+ if (key != "tags" && key != "genres" && !bulk.ContainsKey(key))
return Results.BadRequest(new { error = $"Unknown bulk-update field '{key}'." });
var ownerId = users.GetUserId(ctx.User)!;
var rows = await cfg.Set(db)
.Include(e => e.Tags)
+ .Include(e => e.Genres)
.Where(e => req.Ids.Contains(e.Id) && e.OwnerId == ownerId)
.ToListAsync(ct);
@@ -204,9 +208,26 @@ public static IEndpointRouteBuilder MapCollectionEndpoints(
foreach (var row in rows) row.Tags = resolved;
}
+ if (req.Updates.TryGetValue("genres", out var genresEl))
+ {
+ string[]? names;
+ try
+ {
+ names = genresEl.ValueKind == JsonValueKind.Null
+ ? null
+ : JsonSerializer.Deserialize(genresEl.GetRawText());
+ }
+ catch (JsonException)
+ {
+ return Results.BadRequest(new { error = "genres: invalid value for genres." });
+ }
+ var resolved = await GenreResolver.ResolveAsync(db, ownerId, names);
+ foreach (var row in rows) row.Genres = resolved;
+ }
+
foreach (var (key, value) in req.Updates)
{
- if (key == "tags") continue;
+ if (key == "tags" || key == "genres") continue;
var field = bulk[key];
foreach (var row in rows)
{
diff --git a/src/server/Collectify.Api/Endpoints/GamesEndpoints.cs b/src/server/Collectify.Api/Endpoints/GamesEndpoints.cs
index e229d8c..f9266b4 100644
--- a/src/server/Collectify.Api/Endpoints/GamesEndpoints.cs
+++ b/src/server/Collectify.Api/Endpoints/GamesEndpoints.cs
@@ -22,6 +22,7 @@ public record GameDto(
string? Publisher,
string? Developer,
int DigitalStores,
+ string[]? Genres,
string? Barcode,
string? IgdbId,
string? ImagePath,
@@ -127,6 +128,18 @@ public record GameDto(
q = q.Where(g => (g.DigitalStores & stores.Value) != 0);
}
+ if (request.Query.TryGetValue("genre", out var genreValues))
+ {
+ if (genreValues.Count > 1)
+ return (q, Results.BadRequest(new { error = "Query parameter 'genre' must have a single value." }));
+ var genre = genreValues.ToString();
+ if (!string.IsNullOrWhiteSpace(genre))
+ {
+ var normalized = new[] { genre.Trim().ToLowerInvariant() };
+ q = q.Where(g => g.Genres.Any(x => normalized.Contains(x.Name)));
+ }
+ }
+
return (q, null);
},
OnDelete = (db, id, ownerId) =>
@@ -243,6 +256,7 @@ public static IEndpointRouteBuilder MapGamesEndpoints(this IEndpointRouteBuilder
private static GameDto ToDto(Game g) => new(
g.Id, g.Title, g.Platform, g.PlatformLegacy, g.Year, g.Publisher, g.Developer, (int)g.DigitalStores,
+ GenreResolver.ToNameArray(g.Genres),
g.Barcode, g.IgdbId, g.ImagePath, g.Description, g.Notes,
g.PersonalRating, g.Status, g.Condition,
g.AcquiredOn, g.AcquisitionPrice, g.AcquisitionCurrency, g.AcquisitionSource,
diff --git a/src/server/Collectify.Api/Endpoints/GenreResolver.cs b/src/server/Collectify.Api/Endpoints/GenreResolver.cs
new file mode 100644
index 0000000..8a6db08
--- /dev/null
+++ b/src/server/Collectify.Api/Endpoints/GenreResolver.cs
@@ -0,0 +1,42 @@
+using Collectify.Domain.Entities;
+using Collectify.Infrastructure.Data;
+using Microsoft.EntityFrameworkCore;
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("Collectify.Tests")]
+
+namespace Collectify.Api.Endpoints;
+
+internal static class GenreResolver
+{
+ public static async Task> ResolveAsync(
+ CollectifyDbContext db,
+ string ownerId,
+ IEnumerable? names)
+ {
+ if (names is null) return [];
+
+ var normalized = names
+ .Where(n => !string.IsNullOrWhiteSpace(n))
+ .Select(n => n.Trim().ToLowerInvariant())
+ .Distinct()
+ .ToList();
+ if (normalized.Count == 0) return [];
+
+ var existing = await db.Genres
+ .Where(g => g.OwnerId == ownerId && normalized.Contains(g.Name))
+ .ToListAsync();
+
+ var missing = normalized.Except(existing.Select(g => g.Name)).ToList();
+ foreach (var name in missing)
+ {
+ var g = new Genre { OwnerId = ownerId, Name = name };
+ db.Genres.Add(g);
+ existing.Add(g);
+ }
+ return existing;
+ }
+
+ public static string[] ToNameArray(IEnumerable genres) =>
+ genres.Select(g => g.Name).OrderBy(n => n).ToArray();
+}
diff --git a/src/server/Collectify.Api/Endpoints/MoviesEndpoints.cs b/src/server/Collectify.Api/Endpoints/MoviesEndpoints.cs
index 153f906..f558f6e 100644
--- a/src/server/Collectify.Api/Endpoints/MoviesEndpoints.cs
+++ b/src/server/Collectify.Api/Endpoints/MoviesEndpoints.cs
@@ -22,7 +22,7 @@ public record MovieDto(
string? Director,
int? RuntimeMinutes,
string? Studio,
- string? Genres,
+ string[]? Genres,
string? Barcode,
string? TmdbId,
string? ImdbId,
@@ -101,13 +101,11 @@ public record MovieDto(
{
if (genreValues.Count > 1)
return (q, Results.BadRequest(new { error = "Query parameter 'genre' must have a single value." }));
- // Genres is stored as a comma-separated string; substring
- // match is good enough for the volume here.
var genre = genreValues.ToString();
if (!string.IsNullOrWhiteSpace(genre))
{
- var like = $"%{genre}%";
- q = q.Where(m => m.Genres != null && EF.Functions.Like(m.Genres, like));
+ var normalized = new[] { genre.Trim().ToLowerInvariant() };
+ q = q.Where(m => m.Genres.Any(g => normalized.Contains(g.Name)));
}
}
@@ -196,7 +194,7 @@ public static IEndpointRouteBuilder MapMoviesEndpoints(this IEndpointRouteBuilde
private static MovieDto ToDto(Movie m) => new(
m.Id, m.Title, m.OriginalTitle, m.Year, (int)m.Formats, m.Director, m.RuntimeMinutes,
- m.Studio, m.Genres, m.Barcode, m.TmdbId, m.ImdbId, m.ImagePath, m.Description, m.Notes,
+ m.Studio, GenreResolver.ToNameArray(m.Genres), m.Barcode, m.TmdbId, m.ImdbId, m.ImagePath, m.Description, m.Notes,
m.PersonalRating, m.Status, m.Condition,
m.AcquiredOn, m.AcquisitionPrice, m.AcquisitionCurrency, m.AcquisitionSource,
m.WatchStatus, m.LastWatchedOn, m.WatchCount,
@@ -213,7 +211,6 @@ private static void ApplyDto(Movie m, MovieDto dto)
m.Director = dto.Director;
m.RuntimeMinutes = dto.RuntimeMinutes;
m.Studio = dto.Studio;
- m.Genres = dto.Genres;
m.Barcode = dto.Barcode;
m.TmdbId = dto.TmdbId;
m.ImdbId = dto.ImdbId;
diff --git a/src/server/Collectify.Api/Endpoints/MusicEndpoints.cs b/src/server/Collectify.Api/Endpoints/MusicEndpoints.cs
index 3eac320..ab57523 100644
--- a/src/server/Collectify.Api/Endpoints/MusicEndpoints.cs
+++ b/src/server/Collectify.Api/Endpoints/MusicEndpoints.cs
@@ -14,7 +14,7 @@ public record AlbumDto(
int? Year,
MusicFormat Format,
string? Label,
- string? Genres,
+ string[]? Genres,
string? Barcode,
string? MusicBrainzReleaseId,
string? DiscogsId,
@@ -93,8 +93,8 @@ public record AlbumDto(
var genre = genreValues.ToString();
if (!string.IsNullOrWhiteSpace(genre))
{
- var like = $"%{genre}%";
- q = q.Where(a => a.Genres != null && EF.Functions.Like(a.Genres, like));
+ var normalized = new[] { genre.Trim().ToLowerInvariant() };
+ q = q.Where(a => a.Genres.Any(g => normalized.Contains(g.Name)));
}
}
@@ -135,7 +135,7 @@ public static IEndpointRouteBuilder MapMusicEndpoints(this IEndpointRouteBuilder
}
private static AlbumDto ToDto(MusicAlbum a) => new(
- a.Id, a.Title, a.ArtistName, a.Year, a.Format, a.Label, a.Genres, a.Barcode,
+ a.Id, a.Title, a.ArtistName, a.Year, a.Format, a.Label, GenreResolver.ToNameArray(a.Genres), a.Barcode,
a.MusicBrainzReleaseId, a.DiscogsId, a.ImagePath, a.Description, a.Notes,
a.PersonalRating, a.Status, a.Condition,
a.AcquiredOn, a.AcquisitionPrice, a.AcquisitionCurrency, a.AcquisitionSource,
@@ -151,7 +151,6 @@ private static void ApplyDto(MusicAlbum a, AlbumDto dto)
a.Year = dto.Year;
a.Format = dto.Format;
a.Label = dto.Label;
- a.Genres = dto.Genres;
a.Barcode = dto.Barcode;
a.MusicBrainzReleaseId = dto.MusicBrainzReleaseId;
a.DiscogsId = dto.DiscogsId;
diff --git a/src/server/Collectify.Domain/Entities/Game.cs b/src/server/Collectify.Domain/Entities/Game.cs
index 5dfc9ab..0729580 100644
--- a/src/server/Collectify.Domain/Entities/Game.cs
+++ b/src/server/Collectify.Domain/Entities/Game.cs
@@ -57,6 +57,7 @@ public class Game : ICollectionEntry
public DateOnly? LastPlayedOn { get; set; }
public ICollection Tags { get; set; } = new List();
+ public ICollection Genres { get; set; } = new List();
public DateTime AddedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
diff --git a/src/server/Collectify.Domain/Entities/Genre.cs b/src/server/Collectify.Domain/Entities/Genre.cs
new file mode 100644
index 0000000..0bdded3
--- /dev/null
+++ b/src/server/Collectify.Domain/Entities/Genre.cs
@@ -0,0 +1,12 @@
+namespace Collectify.Domain.Entities;
+
+public class Genre
+{
+ public int Id { get; set; }
+ public string OwnerId { get; set; } = string.Empty;
+ public string Name { get; set; } = string.Empty;
+
+ public ICollection Movies { get; set; } = new List();
+ public ICollection MusicAlbums { get; set; } = new List();
+ public ICollection Games { get; set; } = new List();
+}
diff --git a/src/server/Collectify.Domain/Entities/ICollectionEntry.cs b/src/server/Collectify.Domain/Entities/ICollectionEntry.cs
index 7886e4a..253c59c 100644
--- a/src/server/Collectify.Domain/Entities/ICollectionEntry.cs
+++ b/src/server/Collectify.Domain/Entities/ICollectionEntry.cs
@@ -19,4 +19,5 @@ public interface ICollectionEntry
DateTime AddedAt { get; }
DateTime UpdatedAt { get; set; }
ICollection Tags { get; set; }
+ ICollection Genres { get; set; }
}
diff --git a/src/server/Collectify.Domain/Entities/Movie.cs b/src/server/Collectify.Domain/Entities/Movie.cs
index 4496f1c..4710e2f 100644
--- a/src/server/Collectify.Domain/Entities/Movie.cs
+++ b/src/server/Collectify.Domain/Entities/Movie.cs
@@ -14,7 +14,6 @@ public class Movie : ICollectionEntry
public string? Director { get; set; }
public int? RuntimeMinutes { get; set; }
public string? Studio { get; set; }
- public string? Genres { get; set; }
public string? Barcode { get; set; }
public string? TmdbId { get; set; }
@@ -42,6 +41,7 @@ public class Movie : ICollectionEntry
public int WatchCount { get; set; }
public ICollection Tags { get; set; } = new List();
+ public ICollection Genres { get; set; } = new List();
public DateTime AddedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
diff --git a/src/server/Collectify.Domain/Entities/MusicAlbum.cs b/src/server/Collectify.Domain/Entities/MusicAlbum.cs
index 08c5283..480dea1 100644
--- a/src/server/Collectify.Domain/Entities/MusicAlbum.cs
+++ b/src/server/Collectify.Domain/Entities/MusicAlbum.cs
@@ -12,7 +12,6 @@ public class MusicAlbum : ICollectionEntry
public int? Year { get; set; }
public MusicFormat Format { get; set; } = MusicFormat.Cd;
public string? Label { get; set; }
- public string? Genres { get; set; }
public string? Barcode { get; set; }
public string? MusicBrainzReleaseId { get; set; }
@@ -35,6 +34,7 @@ public class MusicAlbum : ICollectionEntry
public DateOnly? LastPlayedOn { get; set; }
public ICollection Tags { get; set; } = new List();
+ public ICollection Genres { get; set; } = new List();
public DateTime AddedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
diff --git a/src/server/Collectify.Infrastructure/Data/CollectifyDbContext.cs b/src/server/Collectify.Infrastructure/Data/CollectifyDbContext.cs
index 55d836b..c403da0 100644
--- a/src/server/Collectify.Infrastructure/Data/CollectifyDbContext.cs
+++ b/src/server/Collectify.Infrastructure/Data/CollectifyDbContext.cs
@@ -12,6 +12,7 @@ public CollectifyDbContext(DbContextOptions options) : base
public DbSet Movies => Set();
public DbSet MusicAlbums => Set();
public DbSet Games => Set();
+ public DbSet Genres => Set();
public DbSet Tags => Set();
public DbSet CoverImages => Set();
public DbSet GameStoreConnections => Set();
@@ -80,6 +81,12 @@ protected override void OnModelCreating(ModelBuilder builder)
e.HasIndex(t => new { t.OwnerId, t.Name }).IsUnique();
});
+ builder.Entity(e =>
+ {
+ e.Property(g => g.Name).HasMaxLength(100).IsRequired();
+ e.HasIndex(g => new { g.OwnerId, g.Name }).IsUnique();
+ });
+
builder.Entity(e =>
{
e.HasKey(c => c.Hash);
diff --git a/src/server/Collectify.Infrastructure/Data/Migrations/20260824193956_AddGenres.Designer.cs b/src/server/Collectify.Infrastructure/Data/Migrations/20260824193956_AddGenres.Designer.cs
new file mode 100644
index 0000000..175ffe7
--- /dev/null
+++ b/src/server/Collectify.Infrastructure/Data/Migrations/20260824193956_AddGenres.Designer.cs
@@ -0,0 +1,968 @@
+//
+using System;
+using Collectify.Infrastructure.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace Collectify.Infrastructure.Data.Migrations
+{
+ [DbContext(typeof(CollectifyDbContext))]
+ [Migration("20260824193956_AddGenres")]
+ partial class AddGenres
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "10.0.11");
+
+ modelBuilder.Entity("Collectify.Domain.Entities.CoverImage", b =>
+ {
+ b.Property("Hash")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("AddedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Bytes")
+ .IsRequired()
+ .HasColumnType("BLOB");
+
+ b.Property("ContentType")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Hash");
+
+ b.ToTable("CoverImages");
+ });
+
+ modelBuilder.Entity("Collectify.Domain.Entities.Game", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AcquiredOn")
+ .HasColumnType("TEXT");
+
+ b.Property("AcquisitionCurrency")
+ .HasMaxLength(3)
+ .HasColumnType("TEXT");
+
+ b.Property("AcquisitionPrice")
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("AcquisitionSource")
+ .HasColumnType("TEXT");
+
+ b.Property("AddedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("AgeRating")
+ .HasColumnType("TEXT");
+
+ b.Property("Barcode")
+ .HasColumnType("TEXT");
+
+ b.Property("CompletionStatus")
+ .HasColumnType("INTEGER");
+
+ b.Property("Condition")
+ .HasColumnType("INTEGER");
+
+ b.Property("Description")
+ .HasColumnType("TEXT");
+
+ b.Property("Developer")
+ .HasColumnType("TEXT");
+
+ b.Property("DigitalStores")
+ .HasColumnType("INTEGER");
+
+ b.Property("HoursPlayed")
+ .HasColumnType("INTEGER");
+
+ b.Property("IgdbId")
+ .HasColumnType("TEXT");
+
+ b.Property("ImagePath")
+ .HasColumnType("TEXT");
+
+ b.Property("LastPlayedOn")
+ .HasColumnType("TEXT");
+
+ b.Property("Notes")
+ .HasColumnType("TEXT");
+
+ b.Property("OwnerId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("ParentGameId")
+ .HasColumnType("INTEGER");
+
+ b.Property("PersonalRating")
+ .HasColumnType("INTEGER");
+
+ b.Property("Platform")
+ .HasColumnType("INTEGER");
+
+ b.Property("PlatformLegacy")
+ .HasColumnType("TEXT");
+
+ b.Property("Publisher")
+ .HasColumnType("TEXT");
+
+ b.Property("ReleaseDate")
+ .HasColumnType("TEXT");
+
+ b.Property("Status")
+ .HasColumnType("INTEGER");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Year")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Barcode");
+
+ b.HasIndex("OwnerId");
+
+ b.HasIndex("ParentGameId");
+
+ b.HasIndex("Title");
+
+ b.HasIndex("ParentGameId", "OwnerId");
+
+ b.ToTable("Games");
+ });
+
+ modelBuilder.Entity("Collectify.Domain.Entities.GameStoreConnection", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("ExternalAccountId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("ExternalDisplayName")
+ .HasMaxLength(200)
+ .HasColumnType("TEXT");
+
+ b.Property("LastSyncedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("LinkedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("OwnerId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Store")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OwnerId", "Store")
+ .IsUnique();
+
+ b.ToTable("GameStoreConnections");
+ });
+
+ modelBuilder.Entity("Collectify.Domain.Entities.GameStoreOwnedTitle", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("ExternalAccountId")
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("ExternalGameId")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("GameId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ImportedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("OwnerId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("ParentExternalGameId")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("Store")
+ .HasColumnType("INTEGER");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GameId", "OwnerId");
+
+ b.HasIndex("OwnerId", "Store", "ExternalGameId")
+ .IsUnique();
+
+ b.ToTable("GameStoreOwnedTitles");
+ });
+
+ modelBuilder.Entity("Collectify.Domain.Entities.Genre", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("OwnerId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OwnerId", "Name")
+ .IsUnique();
+
+ b.ToTable("Genres");
+ });
+
+ modelBuilder.Entity("Collectify.Domain.Entities.Movie", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AcquiredOn")
+ .HasColumnType("TEXT");
+
+ b.Property("AcquisitionCurrency")
+ .HasMaxLength(3)
+ .HasColumnType("TEXT");
+
+ b.Property("AcquisitionPrice")
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("AcquisitionSource")
+ .HasColumnType("TEXT");
+
+ b.Property("AddedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Barcode")
+ .HasColumnType("TEXT");
+
+ b.Property("Cast")
+ .HasColumnType("TEXT");
+
+ b.Property("Condition")
+ .HasColumnType("INTEGER");
+
+ b.Property("Description")
+ .HasColumnType("TEXT");
+
+ b.Property("Director")
+ .HasColumnType("TEXT");
+
+ b.Property("Formats")
+ .HasColumnType("INTEGER");
+
+ b.Property("ImagePath")
+ .HasColumnType("TEXT");
+
+ b.Property("ImdbId")
+ .HasColumnType("TEXT");
+
+ b.Property("LastWatchedOn")
+ .HasColumnType("TEXT");
+
+ b.Property("Notes")
+ .HasColumnType("TEXT");
+
+ b.Property("OriginalTitle")
+ .HasColumnType("TEXT");
+
+ b.Property("OwnerId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("PersonalRating")
+ .HasColumnType("INTEGER");
+
+ b.Property("ProviderRating")
+ .HasColumnType("REAL");
+
+ b.Property("ReleaseDate")
+ .HasColumnType("TEXT");
+
+ b.Property("RuntimeMinutes")
+ .HasColumnType("INTEGER");
+
+ b.Property("Status")
+ .HasColumnType("INTEGER");
+
+ b.Property("Studio")
+ .HasColumnType("TEXT");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b.Property("TmdbId")
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("WatchCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("WatchStatus")
+ .HasColumnType("INTEGER");
+
+ b.Property("Year")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Barcode");
+
+ b.HasIndex("OwnerId");
+
+ b.HasIndex("Title");
+
+ b.ToTable("Movies");
+ });
+
+ modelBuilder.Entity("Collectify.Domain.Entities.MusicAlbum", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AcquiredOn")
+ .HasColumnType("TEXT");
+
+ b.Property("AcquisitionCurrency")
+ .HasMaxLength(3)
+ .HasColumnType("TEXT");
+
+ b.Property("AcquisitionPrice")
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("AcquisitionSource")
+ .HasColumnType("TEXT");
+
+ b.Property("AddedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("ArtistName")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b.Property("Barcode")
+ .HasColumnType("TEXT");
+
+ b.Property("Condition")
+ .HasColumnType("INTEGER");
+
+ b.Property("Description")
+ .HasColumnType("TEXT");
+
+ b.Property("DiscogsId")
+ .HasColumnType("TEXT");
+
+ b.Property("Format")
+ .HasColumnType("INTEGER");
+
+ b.Property("ImagePath")
+ .HasColumnType("TEXT");
+
+ b.Property("Label")
+ .HasColumnType("TEXT");
+
+ b.Property("LastPlayedOn")
+ .HasColumnType("TEXT");
+
+ b.Property("ListenCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("MusicBrainzReleaseId")
+ .HasColumnType("TEXT");
+
+ b.Property("Notes")
+ .HasColumnType("TEXT");
+
+ b.Property("OwnerId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("PersonalRating")
+ .HasColumnType("INTEGER");
+
+ b.Property("ReleaseDate")
+ .HasColumnType("TEXT");
+
+ b.Property("Status")
+ .HasColumnType("INTEGER");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Year")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ArtistName");
+
+ b.HasIndex("Barcode");
+
+ b.HasIndex("OwnerId");
+
+ b.HasIndex("Title");
+
+ b.ToTable("MusicAlbums");
+ });
+
+ modelBuilder.Entity("Collectify.Domain.Entities.SteamAuthRequest", b =>
+ {
+ b.Property("StateHash")
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("Consumed")
+ .HasColumnType("INTEGER");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("TEXT");
+
+ b.Property("OwnerId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("StateHash");
+
+ b.HasIndex("ExpiresAt");
+
+ b.ToTable("SteamAuthRequests");
+ });
+
+ modelBuilder.Entity("Collectify.Domain.Entities.Tag", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("OwnerId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OwnerId", "Name")
+ .IsUnique();
+
+ b.ToTable("Tags");
+ });
+
+ modelBuilder.Entity("Collectify.Infrastructure.Identity.AppUser", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("AccessFailedCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("TEXT");
+
+ b.Property("Email")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("EmailConfirmed")
+ .HasColumnType("INTEGER");
+
+ b.Property("LockoutEnabled")
+ .HasColumnType("INTEGER");
+
+ b.Property("LockoutEnd")
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedEmail")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedUserName")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("PasswordHash")
+ .HasColumnType("TEXT");
+
+ b.Property("PhoneNumber")
+ .HasColumnType("TEXT");
+
+ b.Property("PhoneNumberConfirmed")
+ .HasColumnType("INTEGER");
+
+ b.Property("SecurityStamp")
+ .HasColumnType("TEXT");
+
+ b.Property("TwoFactorEnabled")
+ .HasColumnType("INTEGER");
+
+ b.Property("UserName")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedEmail")
+ .HasDatabaseName("EmailIndex");
+
+ b.HasIndex("NormalizedUserName")
+ .IsUnique()
+ .HasDatabaseName("UserNameIndex");
+
+ b.ToTable("AspNetUsers", (string)null);
+ });
+
+ modelBuilder.Entity("GameGenre", b =>
+ {
+ b.Property("GamesId")
+ .HasColumnType("INTEGER");
+
+ b.Property("GenresId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("GamesId", "GenresId");
+
+ b.HasIndex("GenresId");
+
+ b.ToTable("GameGenre");
+ });
+
+ modelBuilder.Entity("GameTag", b =>
+ {
+ b.Property("GamesId")
+ .HasColumnType("INTEGER");
+
+ b.Property("TagsId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("GamesId", "TagsId");
+
+ b.HasIndex("TagsId");
+
+ b.ToTable("GameTag");
+ });
+
+ modelBuilder.Entity("GenreMovie", b =>
+ {
+ b.Property("GenresId")
+ .HasColumnType("INTEGER");
+
+ b.Property("MoviesId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("GenresId", "MoviesId");
+
+ b.HasIndex("MoviesId");
+
+ b.ToTable("GenreMovie");
+ });
+
+ modelBuilder.Entity("GenreMusicAlbum", b =>
+ {
+ b.Property("GenresId")
+ .HasColumnType("INTEGER");
+
+ b.Property("MusicAlbumsId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("GenresId", "MusicAlbumsId");
+
+ b.HasIndex("MusicAlbumsId");
+
+ b.ToTable("GenreMusicAlbum");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedName")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedName")
+ .IsUnique()
+ .HasDatabaseName("RoleNameIndex");
+
+ b.ToTable("AspNetRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("ClaimType")
+ .HasColumnType("TEXT");
+
+ b.Property("ClaimValue")
+ .HasColumnType("TEXT");
+
+ b.Property("RoleId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetRoleClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("ClaimType")
+ .HasColumnType("TEXT");
+
+ b.Property("ClaimValue")
+ .HasColumnType("TEXT");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.Property("LoginProvider")
+ .HasColumnType("TEXT");
+
+ b.Property("ProviderKey")
+ .HasColumnType("TEXT");
+
+ b.Property("ProviderDisplayName")
+ .HasColumnType("TEXT");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("LoginProvider", "ProviderKey");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserLogins", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property("RoleId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("UserId", "RoleId");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetUserRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property("LoginProvider")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.Property("Value")
+ .HasColumnType("TEXT");
+
+ b.HasKey("UserId", "LoginProvider", "Name");
+
+ b.ToTable("AspNetUserTokens", (string)null);
+ });
+
+ modelBuilder.Entity("MovieTag", b =>
+ {
+ b.Property("MoviesId")
+ .HasColumnType("INTEGER");
+
+ b.Property("TagsId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("MoviesId", "TagsId");
+
+ b.HasIndex("TagsId");
+
+ b.ToTable("MovieTag");
+ });
+
+ modelBuilder.Entity("MusicAlbumTag", b =>
+ {
+ b.Property("MusicAlbumsId")
+ .HasColumnType("INTEGER");
+
+ b.Property("TagsId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("MusicAlbumsId", "TagsId");
+
+ b.HasIndex("TagsId");
+
+ b.ToTable("MusicAlbumTag");
+ });
+
+ modelBuilder.Entity("Collectify.Domain.Entities.Game", b =>
+ {
+ b.HasOne("Collectify.Domain.Entities.Game", "ParentGame")
+ .WithMany("Dlc")
+ .HasForeignKey("ParentGameId", "OwnerId")
+ .HasPrincipalKey("Id", "OwnerId")
+ .OnDelete(DeleteBehavior.Restrict);
+
+ b.Navigation("ParentGame");
+ });
+
+ modelBuilder.Entity("Collectify.Domain.Entities.GameStoreOwnedTitle", b =>
+ {
+ b.HasOne("Collectify.Domain.Entities.Game", null)
+ .WithMany()
+ .HasForeignKey("GameId", "OwnerId")
+ .HasPrincipalKey("Id", "OwnerId")
+ .OnDelete(DeleteBehavior.Restrict);
+ });
+
+ modelBuilder.Entity("GameGenre", b =>
+ {
+ b.HasOne("Collectify.Domain.Entities.Game", null)
+ .WithMany()
+ .HasForeignKey("GamesId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Collectify.Domain.Entities.Genre", null)
+ .WithMany()
+ .HasForeignKey("GenresId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("GameTag", b =>
+ {
+ b.HasOne("Collectify.Domain.Entities.Game", null)
+ .WithMany()
+ .HasForeignKey("GamesId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Collectify.Domain.Entities.Tag", null)
+ .WithMany()
+ .HasForeignKey("TagsId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("GenreMovie", b =>
+ {
+ b.HasOne("Collectify.Domain.Entities.Genre", null)
+ .WithMany()
+ .HasForeignKey("GenresId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Collectify.Domain.Entities.Movie", null)
+ .WithMany()
+ .HasForeignKey("MoviesId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("GenreMusicAlbum", b =>
+ {
+ b.HasOne("Collectify.Domain.Entities.Genre", null)
+ .WithMany()
+ .HasForeignKey("GenresId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Collectify.Domain.Entities.MusicAlbum", null)
+ .WithMany()
+ .HasForeignKey("MusicAlbumsId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.HasOne("Collectify.Infrastructure.Identity.AppUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.HasOne("Collectify.Infrastructure.Identity.AppUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
+ {
+ b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Collectify.Infrastructure.Identity.AppUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.HasOne("Collectify.Infrastructure.Identity.AppUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("MovieTag", b =>
+ {
+ b.HasOne("Collectify.Domain.Entities.Movie", null)
+ .WithMany()
+ .HasForeignKey("MoviesId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Collectify.Domain.Entities.Tag", null)
+ .WithMany()
+ .HasForeignKey("TagsId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("MusicAlbumTag", b =>
+ {
+ b.HasOne("Collectify.Domain.Entities.MusicAlbum", null)
+ .WithMany()
+ .HasForeignKey("MusicAlbumsId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Collectify.Domain.Entities.Tag", null)
+ .WithMany()
+ .HasForeignKey("TagsId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Collectify.Domain.Entities.Game", b =>
+ {
+ b.Navigation("Dlc");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/server/Collectify.Infrastructure/Data/Migrations/20260824193956_AddGenres.cs b/src/server/Collectify.Infrastructure/Data/Migrations/20260824193956_AddGenres.cs
new file mode 100644
index 0000000..f774037
--- /dev/null
+++ b/src/server/Collectify.Infrastructure/Data/Migrations/20260824193956_AddGenres.cs
@@ -0,0 +1,157 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Collectify.Infrastructure.Data.Migrations
+{
+ ///
+ public partial class AddGenres : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "Genres",
+ table: "MusicAlbums");
+
+ migrationBuilder.DropColumn(
+ name: "Genres",
+ table: "Movies");
+
+ migrationBuilder.CreateTable(
+ name: "Genres",
+ columns: table => new
+ {
+ Id = table.Column(type: "INTEGER", nullable: false)
+ .Annotation("Sqlite:Autoincrement", true),
+ OwnerId = table.Column(type: "TEXT", nullable: false),
+ Name = table.Column(type: "TEXT", maxLength: 100, nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Genres", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "GameGenre",
+ columns: table => new
+ {
+ GamesId = table.Column(type: "INTEGER", nullable: false),
+ GenresId = table.Column(type: "INTEGER", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_GameGenre", x => new { x.GamesId, x.GenresId });
+ table.ForeignKey(
+ name: "FK_GameGenre_Games_GamesId",
+ column: x => x.GamesId,
+ principalTable: "Games",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ table.ForeignKey(
+ name: "FK_GameGenre_Genres_GenresId",
+ column: x => x.GenresId,
+ principalTable: "Genres",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "GenreMovie",
+ columns: table => new
+ {
+ GenresId = table.Column(type: "INTEGER", nullable: false),
+ MoviesId = table.Column(type: "INTEGER", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_GenreMovie", x => new { x.GenresId, x.MoviesId });
+ table.ForeignKey(
+ name: "FK_GenreMovie_Genres_GenresId",
+ column: x => x.GenresId,
+ principalTable: "Genres",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ table.ForeignKey(
+ name: "FK_GenreMovie_Movies_MoviesId",
+ column: x => x.MoviesId,
+ principalTable: "Movies",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "GenreMusicAlbum",
+ columns: table => new
+ {
+ GenresId = table.Column(type: "INTEGER", nullable: false),
+ MusicAlbumsId = table.Column(type: "INTEGER", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_GenreMusicAlbum", x => new { x.GenresId, x.MusicAlbumsId });
+ table.ForeignKey(
+ name: "FK_GenreMusicAlbum_Genres_GenresId",
+ column: x => x.GenresId,
+ principalTable: "Genres",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ table.ForeignKey(
+ name: "FK_GenreMusicAlbum_MusicAlbums_MusicAlbumsId",
+ column: x => x.MusicAlbumsId,
+ principalTable: "MusicAlbums",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_GameGenre_GenresId",
+ table: "GameGenre",
+ column: "GenresId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_GenreMovie_MoviesId",
+ table: "GenreMovie",
+ column: "MoviesId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_GenreMusicAlbum_MusicAlbumsId",
+ table: "GenreMusicAlbum",
+ column: "MusicAlbumsId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Genres_OwnerId_Name",
+ table: "Genres",
+ columns: new[] { "OwnerId", "Name" },
+ unique: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "GameGenre");
+
+ migrationBuilder.DropTable(
+ name: "GenreMovie");
+
+ migrationBuilder.DropTable(
+ name: "GenreMusicAlbum");
+
+ migrationBuilder.DropTable(
+ name: "Genres");
+
+ migrationBuilder.AddColumn(
+ name: "Genres",
+ table: "MusicAlbums",
+ type: "TEXT",
+ nullable: true);
+
+ migrationBuilder.AddColumn(
+ name: "Genres",
+ table: "Movies",
+ type: "TEXT",
+ nullable: true);
+ }
+ }
+}
diff --git a/src/server/Collectify.Infrastructure/Data/Migrations/CollectifyDbContextModelSnapshot.cs b/src/server/Collectify.Infrastructure/Data/Migrations/CollectifyDbContextModelSnapshot.cs
index 72f06e6..03d3f65 100644
--- a/src/server/Collectify.Infrastructure/Data/Migrations/CollectifyDbContextModelSnapshot.cs
+++ b/src/server/Collectify.Infrastructure/Data/Migrations/CollectifyDbContextModelSnapshot.cs
@@ -235,6 +235,29 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.ToTable("GameStoreOwnedTitles");
});
+ modelBuilder.Entity("Collectify.Domain.Entities.Genre", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("OwnerId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OwnerId", "Name")
+ .IsUnique();
+
+ b.ToTable("Genres");
+ });
+
modelBuilder.Entity("Collectify.Domain.Entities.Movie", b =>
{
b.Property("Id")
@@ -275,9 +298,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property("Formats")
.HasColumnType("INTEGER");
- b.Property("Genres")
- .HasColumnType("TEXT");
-
b.Property("ImagePath")
.HasColumnType("TEXT");
@@ -388,9 +408,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property("Format")
.HasColumnType("INTEGER");
- b.Property("Genres")
- .HasColumnType("TEXT");
-
b.Property("ImagePath")
.HasColumnType("TEXT");
@@ -559,6 +576,21 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.ToTable("AspNetUsers", (string)null);
});
+ modelBuilder.Entity("GameGenre", b =>
+ {
+ b.Property("GamesId")
+ .HasColumnType("INTEGER");
+
+ b.Property("GenresId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("GamesId", "GenresId");
+
+ b.HasIndex("GenresId");
+
+ b.ToTable("GameGenre");
+ });
+
modelBuilder.Entity("GameTag", b =>
{
b.Property("GamesId")
@@ -574,6 +606,36 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.ToTable("GameTag");
});
+ modelBuilder.Entity("GenreMovie", b =>
+ {
+ b.Property("GenresId")
+ .HasColumnType("INTEGER");
+
+ b.Property("MoviesId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("GenresId", "MoviesId");
+
+ b.HasIndex("MoviesId");
+
+ b.ToTable("GenreMovie");
+ });
+
+ modelBuilder.Entity("GenreMusicAlbum", b =>
+ {
+ b.Property("GenresId")
+ .HasColumnType("INTEGER");
+
+ b.Property("MusicAlbumsId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("GenresId", "MusicAlbumsId");
+
+ b.HasIndex("MusicAlbumsId");
+
+ b.ToTable("GenreMusicAlbum");
+ });
+
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property