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"
+            "Naruto2002\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');