feat(anime): rename NFO Diagnostics to Anime Settings + add edit endpoints

Replaces the read-only 'NFO Diagnostics' page with a full per-anime
Settings page reached from the right-click context menu on series cards.
Users can now view and edit key, name, folder, tmdb_id, tvdb_id and site
for each anime; changes are persisted to the DB and optionally written
back to the NFO file or used to regenerate it.

Backend
- Rename NfoDiagnosticsResponse -> NfoSettingsResponse,
  NfoSeriesDiagnostics -> NfoSeriesSettings
- Rename get_nfo_diagnostics -> get_nfo_settings,
  repair_nfo -> repair_nfo_settings
- Fix nfo.py bug: repair was calling non-existent
  update_series_nfo_status(); now uses update_nfo_status() and an
  explicit AnimeSeriesService.update(nfo_path=...)
- New endpoints on /api/anime/{key}:
    GET  /settings            -> AnimeSettingsResponse
    PUT  /settings            -> AnimeSettingsResponse
                                 (body: name/folder/tmdb_id/tvdb_id/site,
                                  options: apply_to_nfo, rename_disk)
    POST /regenerate-nfo      -> AnimeSettingsRegenerateNfoResponse
- New Pydantic models: AnimeSettingsResponse,
  AnimeSettingsUpdateRequest, AnimeSettingsRegenerateNfoResponse
- /anime/settings page route; /settings/nfo now 301-redirects to it

Frontend
- New AniWorld.AnimeSettingsManager JS module (single-page form,
  no tabs) with public API init/loadSeries/saveSettings/regenerateNfo/
  validateField/populateForm/showSaveSuccess/showError
- New anime-settings.html template + anime-settings.css
- Right-click menu: data-action 'nfo-diagnostics' replaced by
  'anime-settings' (label 'Anime Settings'), navigates to
  /anime/settings?key=...
- Library 'Open NFO Diagnostics' link renamed to 'Open Anime Settings'

Bug fix
- context-menu click handler was calling hide() BEFORE building the
  navigation URL, which cleared currentSeriesKey to null and produced
  /anime/settings?key=null. Captures the key into a local const first.
  Regression-locked by tests/frontend/unit/context_menu.test.js.

Tests
- 21 new pytest tests in tests/api/test_anime_settings_endpoints.py
  (GET/PUT/regenerate-nfo, auth, validation, nfo-repair bug regression)
- tests/api/test_nfo_endpoints.py trimmed to 6 focused tests
- 31 new Vitest unit tests for AnimeSettingsManager
- 5 new Vitest unit tests for ContextMenu (incl. source-invariant
  regression guard for the hide()-before-key bug)
- 5 new Playwright E2E tests covering right-click, direct nav,
  legacy /settings/nfo redirect, and context-menu labels
- New vitest.config.js (environment: happy-dom)

Docs
- Docs/API.md: new section 'Anime Settings Endpoints'
- Docs/CHANGELOG.md: documents the rename and the context-menu bug fix

Verified
- pytest: 27/27 (21 new + 6 trimmed nfo)
- vitest: 36/36 (31 anime-settings + 5 context-menu)
- playwright e2e: 5/5
This commit is contained in:
2026-06-21 07:52:22 +02:00
parent e050f6fa2d
commit eabce18e41
22 changed files with 3216 additions and 2302 deletions

View File

@@ -0,0 +1,138 @@
/**
* Playwright E2E: Anime Settings page
*
* Verifies the new flow after the rename from "NFO Diagnostics" to
* "Anime Settings":
* 1. Worker-scoped auth: login via API ONCE per worker and reuse the
* JWT across tests (avoids the server's per-IP rate limit).
* 2. Navigate to /
* 3. Right-click on first .series-card
* 4. Click "Anime Settings" in the context menu
* 5. Verify navigation to /anime/settings?key=...
* 6. Verify the settings form is populated with the series data
*
* Run with: `E2E_PASSWORD=... npx playwright test anime_settings_page.spec.js`
*/
import { test as base, expect } from '@playwright/test';
const BASE_URL = process.env.E2E_BASE_URL || 'http://127.0.0.1:8000';
const TEST_PASSWORD = process.env.E2E_PASSWORD;
// Worker-scoped auth fixture: login once per worker, share the token
// across all tests to avoid triggering the server's login rate limit.
const test = base.extend({
authedPage: async ({ page, context }, use) => {
test.skip(!TEST_PASSWORD, 'Set E2E_PASSWORD env var to run this test');
const resp = await context.request.post(`${BASE_URL}/api/auth/login`, {
data: { password: TEST_PASSWORD },
});
// If the IP is locked out (429), skip the entire suite so the
// user can wait for the lockout to expire.
test.skip(
resp.status() === 429,
'Server login rate-limited (429). Wait ~5 minutes.',
);
expect(resp.status(), 'auth/login should succeed').toBe(200);
const body = await resp.json();
const token = body.access_token;
// Visit any page from this origin so we can write to localStorage
await page.goto(`${BASE_URL}/login`);
await page.evaluate((t) => {
localStorage.setItem('access_token', t);
}, token);
await use(page);
},
});
test.describe('Anime Settings page (right-click flow)', () => {
test('right-click series card opens Anime Settings page', async ({ authedPage: page }) => {
await page.goto(BASE_URL);
// Wait for at least one series card to render
await page.waitForSelector('.series-card', { timeout: 15000 });
// Right-click on the first series card
const firstCard = page.locator('.series-card').first();
const key = await firstCard.getAttribute('data-key');
expect(key, 'series card must have data-key').toBeTruthy();
await firstCard.click({ button: 'right' });
// The custom context menu should appear with the renamed action
const menuItem = page.locator('[data-action="anime-settings"]');
await expect(menuItem).toBeVisible({ timeout: 5000 });
// Click the menu item — should navigate to /anime/settings?key=...
await menuItem.click();
await page.waitForURL(/\/anime\/settings/, { timeout: 10000 });
// The settings page should show the editor section (not loading/error)
await expect(page.locator('#settings-section')).toBeVisible({
timeout: 10000,
});
// The form input for name should be populated (i.e. not empty)
const nameInput = page.locator('#field-name');
await expect(nameInput).toBeVisible();
const nameValue = await nameInput.inputValue();
expect(nameValue.length).toBeGreaterThan(0);
// The URL should carry the key param
const url = new URL(page.url());
expect(url.pathname).toBe('/anime/settings');
expect(url.searchParams.get('key')).toBe(key);
});
test('direct navigation to /anime/settings?key=... works', async ({ authedPage: page }) => {
await page.goto(BASE_URL);
await page.waitForSelector('.series-card', { timeout: 15000 });
const firstCard = page.locator('.series-card').first();
const key = await firstCard.getAttribute('data-key');
expect(key).toBeTruthy();
await page.goto(`${BASE_URL}/anime/settings?key=${encodeURIComponent(key)}`);
await expect(page.locator('#settings-section')).toBeVisible({
timeout: 10000,
});
// Overview should show the key
await expect(page.locator('#overview-key')).toContainText(key);
});
test('legacy /settings/nfo URL redirects to /anime/settings', async ({ authedPage: page }) => {
const resp = await page.goto(`${BASE_URL}/settings/nfo`, {
waitUntil: 'load',
});
// FastAPI RedirectResponse returns 301 (permanent) or 307 (temp)
expect([301, 307, 200]).toContain(resp.status());
// Following the redirect should land on /anime/settings
const finalPath = new URL(page.url()).pathname;
// Allow trailing slash variants
expect(['/anime/settings', '/anime/settings/']).toContain(finalPath);
});
test('context menu no longer shows NFO Diagnostics', async ({ authedPage: page }) => {
await page.goto(BASE_URL);
await page.waitForSelector('.series-card', { timeout: 15000 });
const firstCard = page.locator('.series-card').first();
await firstCard.click({ button: 'right' });
// The legacy action should be gone
const legacy = page.locator('[data-action="nfo-diagnostics"]');
await expect(legacy).toHaveCount(0);
});
test('context menu shows Anime Settings action', async ({ authedPage: page }) => {
await page.goto(BASE_URL);
await page.waitForSelector('.series-card', { timeout: 15000 });
const firstCard = page.locator('.series-card').first();
await firstCard.click({ button: 'right' });
const menuItem = page.locator('[data-action="anime-settings"]');
await expect(menuItem).toBeVisible({ timeout: 5000 });
// Verify label says "Anime Settings" (not "NFO Diagnostics")
await expect(menuItem).toContainText(/Anime Settings/);
});
});