diff --git a/src/server/api/nfo.py b/src/server/api/nfo.py index c94d50e..e1ea67b 100644 --- a/src/server/api/nfo.py +++ b/src/server/api/nfo.py @@ -4,6 +4,7 @@ Provides endpoints for NFO settings, repair, and validation for anime series. """ import logging import os +from datetime import datetime from typing import Any, List, Optional from fastapi import APIRouter, Depends, HTTPException, status @@ -11,6 +12,7 @@ from pydantic import BaseModel from src.config.settings import settings from src.server.models.nfo import ( + NfoContentResponse, NfoRepairResponse, NfoSeriesSettings, NfoSettingsResponse, @@ -406,6 +408,74 @@ async def validate_nfo( ) +@router.get("/{key}/content", response_model=NfoContentResponse) +async def get_nfo_content( + key: str, + _auth: dict = Depends(require_auth), + anime_service: AnimeService = Depends(get_anime_service), +) -> NfoContentResponse: + """Read and return the raw tvshow.nfo XML for a series. + + Used by the Anime Settings page's "View NFO XML" button to display the + on-disk NFO contents inside a ``
`` block. The XML is returned as
+ plain text inside a JSON wrapper so the same auth/header pipeline as the
+ other NFO endpoints can be reused.
+
+ Args:
+ key: Series unique key
+ _auth: Authentication dependency
+ anime_service: AnimeService dependency
+
+ Returns:
+ NfoContentResponse with raw XML in ``content``, the on-disk path,
+ file size and last-modified timestamp.
+
+ Raises:
+ HTTPException 404: If the series or its tvshow.nfo file is not found
+ HTTPException 400: If the series has no folder configured
+ HTTPException 503: If ``settings.anime_directory`` is not configured
+ """
+ series_data = await _get_series_data(anime_service, key)
+ if not series_data:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"Series not found: {key}",
+ )
+
+ folder = series_data.get("folder", "")
+ if not folder:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"Series has no folder configured: {key}",
+ )
+
+ nfo_path = _get_nfo_path(folder)
+ if not os.path.isfile(nfo_path):
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"No tvshow.nfo file found for series '{key}'",
+ )
+
+ try:
+ stat = os.stat(nfo_path)
+ with open(nfo_path, "r", encoding="utf-8") as f:
+ xml_text = f.read()
+ except OSError as exc:
+ logger.error("Failed to read NFO file %s: %s", nfo_path, exc)
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=f"Failed to read NFO file: {exc}",
+ ) from exc
+
+ return NfoContentResponse(
+ key=key,
+ folder=folder,
+ content=xml_text,
+ file_size=stat.st_size,
+ last_modified=datetime.fromtimestamp(stat.st_mtime),
+ )
+
+
@router.get("/needs-repair", response_model=NfoNeedsRepairListResponse)
async def get_series_needing_repair(
_auth: dict = Depends(require_auth),
diff --git a/src/server/models/nfo.py b/src/server/models/nfo.py
index 63eff95..d83e2f8 100644
--- a/src/server/models/nfo.py
+++ b/src/server/models/nfo.py
@@ -393,5 +393,29 @@ class NfoRepairResponse(BaseModel):
message: str = Field(..., description="Human-readable result message")
repaired_tags: List[str] = Field(
default_factory=list,
- description="Tags that were missing before repair"
+ description="Tags that were missing before repair",
+ )
+
+
+class NfoContentResponse(BaseModel):
+ """Response containing the raw contents of a series' tvshow.nfo.
+
+ Returned by ``GET /api/nfo/{key}/content`` so the Anime Settings page
+ can render the XML for the user without exposing the on-disk path to
+ the client (only the resolved path is included for display).
+
+ Attributes:
+ key: Series unique key the content was loaded for
+ folder: Series folder name (under ``settings.anime_directory``)
+ content: Raw XML text of tvshow.nfo (UTF-8)
+ file_size: Size of the NFO file in bytes
+ last_modified: ISO-8601 timestamp of last on-disk modification
+ """
+
+ key: str = Field(..., description="Series unique key")
+ folder: str = Field(..., description="Series folder name")
+ content: str = Field(..., description="Raw XML content of tvshow.nfo")
+ file_size: int = Field(..., description="NFO file size in bytes")
+ last_modified: datetime = Field(
+ ..., description="Last modification time of the NFO file"
)
diff --git a/src/server/web/static/js/pages/anime-settings.js b/src/server/web/static/js/pages/anime-settings.js
index 03857d3..ee8c57a 100644
--- a/src/server/web/static/js/pages/anime-settings.js
+++ b/src/server/web/static/js/pages/anime-settings.js
@@ -11,6 +11,7 @@
* - loadSeries(key) : fetch settings for a series key
* - saveSettings(opts) : PUT settings, opts.applyToNfo / opts.renameDisk
* - regenerateNfo() : POST regenerate-nfo endpoint
+ * - viewNfoContent() : GET raw tvshow.nfo XML into the preview
* - validateField(name, value) : client-side validation, returns error string or null
* - populateForm(data) : fill the form from a payload
* - showSaveSuccess(msg) : success toast
@@ -632,6 +633,7 @@ AniWorld.AnimeSettingsManager = (function () {
loadSeries: loadSeries,
saveSettings: saveSettings,
regenerateNfo: regenerateNfo,
+ viewNfoContent: viewNfoContent,
validateField: validateField,
populateForm: populateForm,
showSaveSuccess: showSaveSuccess,
diff --git a/tests/api/test_nfo_endpoints.py b/tests/api/test_nfo_endpoints.py
index 87059bc..ecc6694 100644
--- a/tests/api/test_nfo_endpoints.py
+++ b/tests/api/test_nfo_endpoints.py
@@ -4,6 +4,7 @@ Covers the live endpoints in src/server/api/nfo.py:
- GET /api/nfo/{key}/diagnostics
- POST /api/nfo/{key}/repair
- GET /api/nfo/{key}/validate
+- GET /api/nfo/{key}/content (re-introduced — used by Anime Settings 'View NFO XML')
- GET /api/nfo/needs-repair
- POST /api/nfo/batch/repair
@@ -12,6 +13,12 @@ Note: Legacy endpoints referenced in v1.x (e.g. /api/nfo/{id}/check,
in the codebase — they were replaced by the consolidated diagnostic,
repair, validate, needs-repair, batch/repair endpoints and the new
Anime Settings page (see tests/api/test_anime_settings_endpoints.py).
+
+Auth note: tests/conftest.py's autouse ``reset_auth_and_rate_limits``
+fixture configures the master password with ``TestPass123!`` before every
+test. The per-file ``reset_auth`` autouse fixture that used to live here
+was removed because it wiped the conftest's setup and made any test that
+needed an authenticated client fail with a stale-hash login error.
"""
from unittest.mock import AsyncMock, Mock, patch
@@ -19,16 +26,6 @@ import pytest
from httpx import ASGITransport, AsyncClient
from src.server.fastapi_app import app
-from src.server.services.auth_service import auth_service
-
-
-@pytest.fixture(autouse=True)
-def reset_auth():
- auth_service._hash = None
- auth_service._failed.clear()
- yield
- auth_service._hash = None
- auth_service._failed.clear()
@pytest.fixture
@@ -38,19 +35,19 @@ async def client():
yield ac
-@pytest.fixture
-async def authenticated_client(client):
- await client.post(
- "/api/auth/setup",
- json={"master_password": "TestPassword123!"},
- )
+async def _login(client: AsyncClient) -> str:
+ """Log in with the master password configured by conftest and
+ return the bearer token. Sets the ``Authorization`` header on the
+ client as a side benefit so the caller can ``await client.get(...)``
+ immediately."""
resp = await client.post(
"/api/auth/login",
- json={"password": "TestPassword123!"},
+ json={"password": "TestPass123!"},
)
+ assert resp.status_code == 200, resp.text
token = resp.json()["access_token"]
client.headers.update({"Authorization": f"Bearer {token}"})
- yield client
+ return token
class TestNFOAuthRequirements:
@@ -84,6 +81,119 @@ class TestNFOAuthRequirements:
)
assert resp.status_code in (401, 503)
+ @pytest.mark.asyncio
+ async def test_get_content_requires_auth(self, client):
+ """GET /api/nfo/{key}/content (used by the Anime Settings page
+ 'View NFO XML' button) must require authentication."""
+ resp = await client.get("/api/nfo/any-key/content")
+ assert resp.status_code in (401, 503)
+
+
+class TestNFOContentEndpoint:
+ """Behavioural tests for GET /api/nfo/{key}/content.
+
+ Covers the success path and the two 404 cases (unknown series,
+ missing tvshow.nfo) the Anime Settings page relies on."""
+
+ @pytest.fixture
+ def mock_anime_service(self):
+ """Replace the FastAPI get_anime_service dependency with a mock.
+ Yields the mock so individual tests can configure ``list_series_with_filters``."""
+ from src.server.utils import dependencies as deps
+
+ service = Mock()
+ service.list_series_with_filters = AsyncMock(return_value=[])
+ app.dependency_overrides[deps.get_anime_service] = lambda: service
+ yield service
+ app.dependency_overrides.pop(deps.get_anime_service, None)
+
+ @pytest.mark.asyncio
+ async def test_returns_xml_for_series_with_nfo(
+ self, client, tmp_path, monkeypatch, mock_anime_service
+ ):
+ """Happy path: existing tvshow.nfo is returned verbatim inside
+ the JSON wrapper the JS uses (``data.content``)."""
+ from src.config import settings as settings_module
+
+ # Point settings.anime_directory at a temp dir
+ monkeypatch.setattr(
+ settings_module.settings,
+ "anime_directory",
+ str(tmp_path),
+ raising=False,
+ )
+
+ # Build a fake folder + tvshow.nfo on disk
+ folder = "Naruto (2002)"
+ series_dir = tmp_path / folder
+ series_dir.mkdir()
+ xml = (
+ "\n"
+ "Naruto 2002 \n"
+ )
+ (series_dir / "tvshow.nfo").write_text(xml, encoding="utf-8")
+
+ mock_anime_service.list_series_with_filters = AsyncMock(
+ return_value=[{"key": "naruto", "folder": folder}]
+ )
+
+ await _login(client)
+
+ resp = await client.get("/api/nfo/naruto/content")
+ assert resp.status_code == 200, resp.text
+ body = resp.json()
+ assert body["key"] == "naruto"
+ assert body["folder"] == folder
+ assert body["content"] == xml
+ assert body["file_size"] == len(xml.encode("utf-8"))
+ assert "last_modified" in body
+
+ @pytest.mark.asyncio
+ async def test_404_when_series_unknown(
+ self, client, tmp_path, monkeypatch, mock_anime_service
+ ):
+ from src.config import settings as settings_module
+
+ monkeypatch.setattr(
+ settings_module.settings,
+ "anime_directory",
+ str(tmp_path),
+ raising=False,
+ )
+ mock_anime_service.list_series_with_filters = AsyncMock(return_value=[])
+
+ await _login(client)
+
+ resp = await client.get("/api/nfo/missing/content")
+ assert resp.status_code == 404
+ assert "not found" in resp.json()["detail"].lower()
+
+ @pytest.mark.asyncio
+ async def test_404_when_nfo_file_missing(
+ self, client, tmp_path, monkeypatch, mock_anime_service
+ ):
+ """Series exists with a configured folder but no tvshow.nfo yet."""
+ from src.config import settings as settings_module
+
+ folder = "Empty"
+ (tmp_path / folder).mkdir()
+
+ monkeypatch.setattr(
+ settings_module.settings,
+ "anime_directory",
+ str(tmp_path),
+ raising=False,
+ )
+ mock_anime_service.list_series_with_filters = AsyncMock(
+ return_value=[{"key": "empty", "folder": folder}]
+ )
+
+ await _login(client)
+
+ resp = await client.get("/api/nfo/empty/content")
+ assert resp.status_code == 404
+ assert "tvshow.nfo" in resp.json()["detail"].lower()
+
class TestNFOEndpointModels:
"""Verify the response models use the renamed classes (regression
diff --git a/tests/frontend/unit/anime_settings.test.js b/tests/frontend/unit/anime_settings.test.js
index 3055c30..bd23b80 100644
--- a/tests/frontend/unit/anime_settings.test.js
+++ b/tests/frontend/unit/anime_settings.test.js
@@ -375,6 +375,83 @@ describe('AnimeSettingsManager', () => {
});
});
+ // -------------------------------------------------------------------
+ // viewNfoContent()
+ // -------------------------------------------------------------------
+
+ describe('viewNfoContent()', () => {
+ beforeEach(async () => {
+ // Seed currentKey via loadSeries so viewNfoContent has a key.
+ delete window.location;
+ window.location = { search: '?key=a', href: 'http://x/?key=a' };
+ mockFetchSequence([{
+ status: 200,
+ body: {
+ key: 'a', name: 'A', folder: 'A', site: 's',
+ tmdb_id: null, tvdb_id: null, has_nfo: true,
+ nfo_path: '/anime/A/tvshow.nfo', episode_count: 0,
+ missing_episode_count: 0, loading_status: 'completed',
+ },
+ }]);
+ await manager.loadSeries('a');
+ });
+
+ it('fetches /api/nfo/{key}/content with auth header', async () => {
+ mockFetchSequence([{
+ status: 200,
+ body: {
+ key: 'a',
+ folder: 'A',
+ content: 'A ',
+ file_size: 30,
+ last_modified: '2026-06-01T00:00:00',
+ },
+ }]);
+
+ await manager.viewNfoContent();
+ const [url, opts] = global.fetch.mock.calls[0];
+ expect(url).toBe('/api/nfo/a/content');
+ expect(opts.method).toBe('GET');
+ expect(opts.headers.Authorization).toBe('Bearer fake-jwt-token');
+ });
+
+ it('writes the content into the #nfo-content and unhides it',
+ async () => {
+ mockFetchSequence([{
+ status: 200,
+ body: {
+ key: 'a',
+ folder: 'A',
+ content: 'A ',
+ file_size: 30,
+ last_modified: '2026-06-01T00:00:00',
+ },
+ }]);
+
+ const pre = document.getElementById('nfo-content');
+ expect(pre.classList.contains('hidden')).toBe(true);
+
+ await manager.viewNfoContent();
+ expect(pre.textContent).toBe(
+ 'A '
+ );
+ expect(pre.classList.contains('hidden')).toBe(false);
+ });
+
+ it('shows an error toast when the backend returns 404', async () => {
+ mockFetchSequence([{
+ status: 404,
+ ok: false,
+ body: { detail: 'Not Found' },
+ }]);
+ await manager.viewNfoContent();
+ expect(global.AniWorld.UI.showToast).toHaveBeenCalledWith(
+ expect.stringContaining('NFO'),
+ 'error'
+ );
+ });
+ });
+
// -------------------------------------------------------------------
// validateField()
// -------------------------------------------------------------------
@@ -493,6 +570,7 @@ describe('AnimeSettingsManager', () => {
expect(typeof manager.loadSeries).toBe('function');
expect(typeof manager.saveSettings).toBe('function');
expect(typeof manager.regenerateNfo).toBe('function');
+ expect(typeof manager.viewNfoContent).toBe('function');
expect(typeof manager.validateField).toBe('function');
expect(typeof manager.populateForm).toBe('function');
expect(typeof manager.showSaveSuccess).toBe('function');