Skip to content

Commit b1be127

Browse files
committed
fix: discovery genres are a constrained AniList multi-select instead of free text
1 parent 8297854 commit b1be127

10 files changed

Lines changed: 134 additions & 41 deletions

File tree

api/API/Controllers/DiscoverController.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ public class DiscoverController(DiscoveryCache cache, KenkuSettings settings, AP
2323
{
2424
private static readonly TimeSpan Ttl = TimeSpan.FromHours(1);
2525

26+
/// <summary>AniList's supported genres — the set a genre rail can be configured for.</summary>
27+
/// <response code="200"></response>
28+
[HttpGet("Genres")]
29+
[ProducesResponseType<IReadOnlyList<string>>(Status200OK, "application/json")]
30+
public Ok<IReadOnlyList<string>> GetGenres() => TypedResults.Ok(AniListGenres.All);
31+
2632
/// <summary>Manga trending on AniList right now.</summary>
2733
/// <response code="200"></response>
2834
[HttpGet("Manga")]

api/API/Controllers/SettingsController.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -325,10 +325,12 @@ public Results<Ok, BadRequest<string>> SetReleaseSelection([FromBody] ReleaseSel
325325
[ProducesResponseType(Status200OK)]
326326
public Ok SetDiscoveryGenres([FromBody] string[] genres)
327327
{
328+
// Keep only real AniList genres, in their canonical casing — anything else (e.g. "Gore")
329+
// would just produce an empty rail, so it is dropped rather than persisted.
328330
settings.SetDiscoveryGenres(genres
329-
.Select(g => g.Trim())
330-
.Where(g => g.Length > 0)
331-
.Distinct(StringComparer.OrdinalIgnoreCase)
331+
.Select(API.Discovery.AniListGenres.Canonical)
332+
.OfType<string>()
333+
.Distinct()
332334
.ToList());
333335
return TypedResults.Ok();
334336
}

api/API/Discovery/AniListGenres.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
namespace API.Discovery;
2+
3+
/// <summary>
4+
/// AniList's fixed genre vocabulary (its GenreCollection). A genre rail only returns results for one
5+
/// of these exact names, so the configured genres are constrained to this set — a free-text genre
6+
/// like "Gore" would silently match nothing.
7+
/// </summary>
8+
public static class AniListGenres
9+
{
10+
public static readonly IReadOnlyList<string> All =
11+
[
12+
"Action", "Adventure", "Comedy", "Drama", "Ecchi", "Fantasy", "Horror", "Mahou Shoujo",
13+
"Mecha", "Music", "Mystery", "Psychological", "Romance", "Sci-Fi", "Slice of Life",
14+
"Sports", "Supernatural", "Thriller",
15+
];
16+
17+
/// <summary>The canonical genre matching <paramref name="name"/> case-insensitively, or null if
18+
/// it is not an AniList genre.</summary>
19+
public static string? Canonical(string name) =>
20+
All.FirstOrDefault(g => g.Equals(name.Trim(), StringComparison.OrdinalIgnoreCase));
21+
}

api/API/openapi/API_v2.json

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -907,6 +907,29 @@
907907
}
908908
}
909909
},
910+
"/v2/Discover/Genres": {
911+
"get": {
912+
"tags": [
913+
"Discover"
914+
],
915+
"summary": "AniList's supported genres — the set a genre rail can be configured for.",
916+
"responses": {
917+
"200": {
918+
"description": "",
919+
"content": {
920+
"application/json; x-version=2.0": {
921+
"schema": {
922+
"type": "array",
923+
"items": {
924+
"type": "string"
925+
}
926+
}
927+
}
928+
}
929+
}
930+
}
931+
}
932+
},
910933
"/v2/Discover/Manga": {
911934
"get": {
912935
"tags": [

api/Tests/Unit/Controllers/DiscoverControllerTests.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,17 @@ private sealed class FakeLatestSource(KenkuSettings s, List<DiscoveryEntry> late
3535
internal override Task<string[]> GetChapterImageUrls(API.Schema.SeriesContext.SourceId<API.Schema.SeriesContext.Chapter> id) => throw new NotSupportedException();
3636
}
3737

38+
[Fact]
39+
public void Genres_ReturnsAniListSupportedGenres()
40+
{
41+
var ok = CreateController().GetGenres();
42+
43+
Assert.Contains("Action", ok.Value!);
44+
Assert.Contains("Slice of Life", ok.Value!);
45+
// "Gore" is not an AniList genre — it must not be offered.
46+
Assert.DoesNotContain("Gore", ok.Value!);
47+
}
48+
3849
[Fact]
3950
public async Task Manga_ReturnsTheTrendingRail()
4051
{

api/Tests/Unit/Controllers/SettingsControllerTests.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,4 +358,16 @@ public void SetDiscoveryGenres_TrimsDedupesAndPersists()
358358
Assert.Equal(["Action", "Romance"], _settings.DiscoveryGenres);
359359
Assert.Contains("Romance", File.ReadAllText(_settings.SettingsFilePath));
360360
}
361+
362+
[Fact]
363+
public void SetDiscoveryGenres_KeepsOnlyKnownAniListGenres_Canonicalized()
364+
{
365+
Directory.CreateDirectory(_settings.WorkingDirectory);
366+
367+
// "Gore" is not an AniList genre and must be dropped; valid genres are canonicalized to
368+
// AniList's exact casing ("sci-fi" -> "Sci-Fi") so the rail query actually matches.
369+
CreateController().SetDiscoveryGenres(["Gore", "sci-fi", "ACTION"]);
370+
371+
Assert.Equal(["Sci-Fi", "Action"], _settings.DiscoveryGenres);
372+
}
361373
}

web/website/app/components/DiscoveryGenresField.vue

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
<template>
22
<UFormField label="Genre rails" description="AniList genres that each get their own rail on the Discover page — changes apply immediately.">
3-
<!-- add-on-blur: a half-typed genre commits when focus leaves the input instead of being
4-
silently dropped; every committed change saves itself (no Save button to race). -->
5-
<UInputTags v-model="genres" add-on-blur placeholder="Add a genre…" class="w-72" />
3+
<USelectMenu
4+
v-model="genres"
5+
:items="available ?? []"
6+
multiple
7+
searchable
8+
placeholder="Pick genres…"
9+
class="w-72" />
610
</UFormField>
711
</template>
812

@@ -11,20 +15,26 @@ const { $api } = useNuxtApp();
1115
const toast = useToast();
1216
1317
const { data: settings } = useApi('/v2/Settings', { key: FetchKeys.Settings.All, server: false });
18+
const { data: available } = useApi('/v2/Discover/Genres', { key: FetchKeys.Discover.Genres, server: false });
19+
20+
const same = (a: string[], b: string[]) => a.length === b.length && a.every((x, i) => x === b[i]);
21+
1422
const genres = ref<string[]>([]);
15-
watch(settings, (s) => { if (s) genres.value = [...(s.discoveryGenres ?? [])]; }, { immediate: true });
23+
// Sync from settings only when it actually differs, so a post-save refetch doesn't reset the field.
24+
watch(settings, (s) => {
25+
const saved = s?.discoveryGenres ?? [];
26+
if (!same(genres.value, saved)) genres.value = [...saved];
27+
}, { immediate: true });
1628
17-
// Self-stabilizing: edits that already match the saved list (initial load, post-save refresh) are
18-
// no-ops, so this can't loop with the settings watch above.
29+
// Auto-save on selection change; the saved/echo case is a no-op, so this never loops with the sync above.
1930
watch(genres, async (next) => {
2031
const saved = settings.value?.discoveryGenres ?? [];
21-
if (next.length === saved.length && next.every((g, i) => g === saved[i])) return;
32+
if (same(next, saved)) return;
2233
try {
2334
await $api('/v2/Settings/DiscoveryGenres', { method: 'PATCH', body: next });
2435
await refreshNuxtData(FetchKeys.Settings.All);
2536
toast.add({ title: 'Discovery genres saved', icon: 'i-lucide-check', color: 'success', duration: 1500 });
2637
} catch {
27-
// A chip the server never accepted must not sit there looking saved.
2838
genres.value = [...saved];
2939
toast.add({ title: "Couldn't save discovery genres", icon: 'i-lucide-triangle-alert', color: 'error' });
3040
}

web/website/app/composables/FetchKeys.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export const FetchKeys = {
2020
Feed: 'Discover/Feed',
2121
TopRated: 'Discover/TopRated',
2222
New: 'Discover/New',
23+
Genres: 'Discover/Genres',
2324
Genre: (genre: string) => `Discover/Genre/${genre}`,
2425
},
2526
};

web/website/test/component/DiscoveryGenresField.test.ts

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@ registerEndpoint('/v2/Settings', () => ({
1111
apiKey: '',
1212
metronConfigured: false,
1313
discoveryGenres: ['Action', 'Romance'],
14+
discoveryFeeds: ['manga'],
1415
syncedIndexers: [],
1516
downloadClients: [],
1617
}));
18+
registerEndpoint('/v2/Discover/Genres', () => ['Action', 'Horror', 'Romance', 'Sci-Fi', 'Thriller']);
1719
registerEndpoint('/v2/Settings/DiscoveryGenres', {
1820
method: 'PATCH',
1921
handler: async (event) => {
@@ -23,36 +25,42 @@ registerEndpoint('/v2/Settings/DiscoveryGenres', {
2325
},
2426
});
2527

28+
const findSelect = (wrapper: Awaited<ReturnType<typeof mountSuspended>>) =>
29+
wrapper.findComponent({ name: 'USelectMenu' });
30+
2631
describe('DiscoveryGenresField', () => {
2732
beforeEach(() => {
2833
patchedBody = null;
2934
patchFails = false;
3035
clearNuxtData();
3136
});
3237

33-
it('reverts the chip when the save fails so the UI never lies about what is saved', async () => {
34-
patchFails = true;
38+
it('offers only AniList genres and pre-selects the configured ones', async () => {
3539
const wrapper = await mountSuspended(DiscoveryGenresField);
36-
await vi.waitFor(() => expect(wrapper.text()).toContain('Action'));
37-
38-
const input = wrapper.find('input');
39-
await input.setValue('Horror');
40-
await input.trigger('keydown', { key: 'Enter' });
4140

42-
await vi.waitFor(() => expect(wrapper.text()).not.toContain('Horror'));
43-
expect(wrapper.text()).toContain('Action');
44-
expect(wrapper.text()).toContain('Romance');
41+
await vi.waitFor(() => expect(findSelect(wrapper).props('items')).toContain('Action'));
42+
const select = findSelect(wrapper);
43+
expect(select.props('items')).toEqual(['Action', 'Horror', 'Romance', 'Sci-Fi', 'Thriller']);
44+
expect(select.props('items')).not.toContain('Gore');
45+
expect(select.props('modelValue')).toEqual(['Action', 'Romance']);
4546
});
4647

47-
it('shows the configured genres and saves as soon as one is committed', async () => {
48+
it('saves the new selection when genres change', async () => {
4849
const wrapper = await mountSuspended(DiscoveryGenresField);
49-
await vi.waitFor(() => expect(wrapper.text()).toContain('Action'));
50-
expect(wrapper.text()).toContain('Romance');
50+
await vi.waitFor(() => expect(findSelect(wrapper).props('modelValue')).toEqual(['Action', 'Romance']));
5151

52-
const input = wrapper.find('input');
53-
await input.setValue('Horror');
54-
await input.trigger('keydown', { key: 'Enter' });
52+
await findSelect(wrapper).vm.$emit('update:modelValue', ['Action', 'Romance', 'Horror']);
5553

5654
await vi.waitFor(() => expect(patchedBody).toEqual(['Action', 'Romance', 'Horror']));
5755
});
56+
57+
it('reverts the selection when the save fails', async () => {
58+
patchFails = true;
59+
const wrapper = await mountSuspended(DiscoveryGenresField);
60+
await vi.waitFor(() => expect(findSelect(wrapper).props('modelValue')).toEqual(['Action', 'Romance']));
61+
62+
await findSelect(wrapper).vm.$emit('update:modelValue', ['Action', 'Romance', 'Horror']);
63+
64+
await vi.waitFor(() => expect(findSelect(wrapper).props('modelValue')).toEqual(['Action', 'Romance']));
65+
});
5866
});

web/website/test/e2e/discover-genres.spec.ts

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ function stubApi(page: Page, state: { genres: string[] }) {
1515
page.route('**/v2/**', (r) => r.fulfill({ json: [] })),
1616
page.route('**/v2/Settings', (r) =>
1717
r.fulfill({
18-
json: { apiKey: '', metronConfigured: false, discoveryGenres: state.genres, syncedIndexers: [], downloadClients: [] },
18+
json: { apiKey: '', metronConfigured: false, discoveryGenres: state.genres, discoveryFeeds: ['manga'], syncedIndexers: [], downloadClients: [] },
1919
})),
20+
page.route('**/v2/Discover/Genres', (r) => r.fulfill({ json: ['Action', 'Horror', 'Romance', 'Sci-Fi', 'Thriller'] })),
2021
page.route('**/v2/Settings/DiscoveryGenres', async (r) => {
2122
state.genres = r.request().postDataJSON() as string[];
2223
return r.fulfill({ json: {} });
@@ -27,38 +28,36 @@ function stubApi(page: Page, state: { genres: string[] }) {
2728
]);
2829
}
2930

30-
test('editing discovery genres updates the rails on the next Discover visit', async ({ page }) => {
31+
test('picking a genre updates the rails on the next Discover visit', async ({ page }) => {
3132
const state = { genres: ['Action'] };
3233
await stubApi(page, state);
3334

3435
await page.goto('/discover');
3536
await expect(page.getByText('Sakamoto Days')).toBeVisible();
3637

37-
// Edit genres on the settings page (SPA navigation, warm caches). Committing a tag saves it.
38+
// Edit genres on the settings page (SPA navigation, warm caches). Picking a genre auto-saves.
3839
await page.getByRole('link', { name: 'Settings' }).first().click();
3940
await page.getByRole('tab', { name: 'Discovery' }).click();
40-
const tags = page.getByPlaceholder('Add a genre…');
41-
await tags.click();
42-
await tags.fill('Horror');
43-
await tags.press('Enter');
41+
await page.getByRole('button', { name: 'Show popup' }).click();
42+
await page.getByRole('option', { name: 'Horror', exact: true }).click();
4443
await expect.poll(() => state.genres).toEqual(['Action', 'Horror']);
4544

4645
// Back to Discover the way a user would — through the nav, not a reload.
46+
await page.keyboard.press('Escape');
4747
await page.getByRole('link', { name: 'Discover' }).first().click();
4848
await expect(page.getByText('Uzumaki')).toBeVisible();
4949
});
5050

51-
test('a genre typed but not committed with Enter is still saved', async ({ page }) => {
51+
test('the genre list offers only AniList genres — no free-text like "Gore"', async ({ page }) => {
5252
const state = { genres: ['Action'] };
5353
await stubApi(page, state);
5454

5555
await page.goto('/settings');
5656
await page.getByRole('tab', { name: 'Discovery' }).click();
57-
const tags = page.getByPlaceholder('Add a genre…');
58-
await tags.click();
59-
await tags.fill('Horror');
60-
// No Enter — the user types it and moves on, expecting it to count.
61-
await tags.press('Tab');
57+
await page.getByRole('button', { name: 'Show popup' }).click();
6258

63-
await expect.poll(() => state.genres).toEqual(['Action', 'Horror']);
59+
// The valid genres are offered; an unsupported genre simply isn't in the list to pick.
60+
await expect(page.getByRole('option', { name: 'Horror', exact: true })).toBeVisible();
61+
await expect(page.getByRole('option', { name: 'Gore', exact: true })).toHaveCount(0);
62+
expect(state.genres).toEqual(['Action']);
6463
});

0 commit comments

Comments
 (0)