Compare commits

...

19 Commits

Author SHA1 Message Date
2e8f3b5c84 chore: bump version 2026-09-16 06:39:39 +02:00
c3aca9217d fix(episodes): prevent duplicate-row accumulation at schema + write sites
Followup to commit c84f968 (read-boundary dedup) and commit f75d591
(cleanup CLI). The read boundary filters duplicates out of the
in-memory episodeDict and the CLI cleans up historical duplicates
in the DB, but the underlying pathology — duplicate rows being
created in the first place — was still active on every rescan.

Two layered prevention fixes:

1. Schema-level guard: add UNIQUE(series_id, season, episode_number)
   to the episodes table. SQLite's CREATE UNIQUE INDEX requires
   no existing duplicates, but the cleanup CLI from f75d591 has
   already been run (or is a one-shot prerequisite for users on
   older DBs). Future duplicate rows are rejected at the DB layer.

2. Write-site guard: SerieScanner.scan_single_series used to
   `extend` the in-memory episodeDict on every rescan of a
   series already in keyDict — across N rescans, the same missing
   list was appended N times, growing the dict with duplicates that
   then flowed through _update_series_in_db into the episodes
   table. The fix replaces the cache with the latest scan result
   instead of extending, and dedupes within a single call as
   defense in depth against a buggy upstream loader.

Defensive dedup is layered three deep:
  - schema constraint (this commit, primary)
  - scan_single_series replace-not-extend (this commit, secondary)
  - episodeDict property read-boundary dedup (commit c84f968,
    tertiary — covers legacy DBs that predate the constraint)

Tests:
  - Updated test_serie_scanner.test_scan_single_series_existing_entry
    to assert the new replace-not-merge behavior (the old assertion
    encoded the buggy extend behavior).
  - New test_serie_scanner_scan_dedup.py covers the regression
    directly: two rescans of the same series with the same missing
    list must yield a canonical dict, not an accumulated one.
  - test_database_models and test_clean_duplicate_episodes_cli now
    use a legacy_engine fixture that drops the UNIQUE constraint,
    so the duplicate-row scenarios they exercise (the read-boundary
    dedup and the cleanup tool, both meant to defend against
    pre-migration state) can still be tested under the new schema.

Verified manually: clean_duplicate_episodes --apply on the user's
backup DB still removes all 633 duplicate rows under the new
schema (the CLI doesn't depend on the UNIQUE constraint — it
operates on whatever rows already exist).
2026-09-15 20:58:30 +02:00
f75d591453 feat(cli): add clean_duplicate_episodes tool
The 'episodes' table has no UNIQUE constraint on
(series_id, season, episode_number), so historical scans can leave
duplicate rows behind. Commit c84f968 added a read-boundary dedup
in AnimeSeries.episodeDict so the rest of the stack never sees
duplicates — but the duplicate rows themselves still bloat the DB
and confuse direct SQL queries.

This commit adds a standalone CLI to find and (with --apply)
delete those duplicate rows. The cleanup keeps the lowest 'id'
per tuple (the oldest insert, which is most likely to have
populated title / file_path fields) and is idempotent.

Usage:
    python -m src.cli.clean_duplicate_episodes               # dry-run report
    python -m src.cli.clean_duplicate_episodes --apply       # actually delete
    python -m src.cli.clean_duplicate_episodes --max-series 5  # limit report

Override the target DB with DATABASE_URL=sqlite:///path/to.db.

Verified against the user's backup DB: 633 duplicate rows across
231 (series, season, episode) tuples removed cleanly, leaving
1221 unique rows. Re-running reports no duplicates. The cleanup
does not affect the read-boundary dedup — both layers are
defensive in depth.
2026-09-15 20:51:25 +02:00
c84f968916 fix(queue): dedupe episodeDict at read boundary + trust server response
The 'episodes added to download queue never shown' symptom has two
layered causes that masked each other:

1. The 'episodes' table has no UNIQUE constraint on
   (series_id, season, episode_number), so historical scans can leave
   duplicate rows behind. AnimeSeries.episodeDict iterated the
   SQLAlchemy 'episodes' relationship without deduping, so the dict
   exposed duplicate entries to list_missing() and the queue UI.
   The frontend forwarded the duplicated episode list verbatim to
   POST /api/queue/add; the backend's pending-episode dedup then
   rejected every duplicate as 'already pending' and the user saw
   'Skipped 44 duplicate episodes, Added 0'.

2. selection-manager.downloadSelected counted 'episodes.length'
   (the input array) instead of data.added_items.length (the
   server-confirmed count). Combined with the backend returning
   success on an empty add, the user saw a misleading 'Added 44
   episode(s)' toast for an empty queue.

Fix: dedupe at the read boundary. The episodeDict property now
filters duplicate (season, episode_number) pairs from both the
DB-loaded relationship and the legacy _episode_dict_cache path.
Existing duplicate rows in the user's DB are inert — the read
filter makes them invisible to the rest of the stack. The
frontend now trusts the server response, logs a console warning
when an input list shrinks to zero added items, and shows an
accurate toast.

Tests:
- TestEpisodeDictDedup class with 4 regression tests covering:
  * duplicate relationship rows deduped
  * is_downloaded rows still filtered out
  * _episode_dict_cache path also deduped (set by scanners/loaders
    that may store duplicates)
  * dedup is per-(season, ep_num), preserving legitimate
    same-ep-num-across-different-seasons entries

Verified manually against the user's backup DB: 'erased' has 44
duplicate rows in the episodes table; episodeDict now returns
{1: [1..12]} instead of {1: [1,1,2,2,3,3,3,3,...]}, matching the
12-episode canonical list.
2026-09-15 20:46:31 +02:00
1d121b427d fix(rescan): delete downloaded episode rows when no longer missing
A finished download marks the Episode row with is_downloaded=True and
populates file_path (commit 0ba2587). The intent was to preserve
download history, but the row stayed in the DB forever because two
sibling sync methods refused to delete it:

  - AnimeService._update_series_in_db: `downloaded_set` guard skipped
    deletion if the row was marked downloaded.
  - SerieScanner._sync_episodes_to_db: `if ep.is_downloaded: continue`
    kept downloaded rows.

The user-visible bug: the missing-list UI is correct (it filters by
is_downloaded and the broadcast rebuilds from the DB), but a finished
download left a stale entry in the DB that the user could see when
querying the database directly. Worse, this stale row accumulated
indefinitely across rescans.

Fix: drop the is_downloaded preservation guards. Once the scanner
confirms the file is on disk and the episode is no longer in the
missing set, the Episode row has no further purpose and is deleted to
keep the DB in sync with the filesystem. The UI derives "missing"
from row presence, so deleting the row is the correct way to make the
episode stop appearing as missing in *all* views (UI and DB queries).

Tests:
  - test_update_series_deletes_downloaded_episodes_when_no_longer_missing
    (RED): regression for _update_series_in_db.
  - test_update_series_keeps_still_missing_episodes: sanity sibling
    to ensure the fix does not over-reach and delete still-missing
    episodes.
  - test_deletes_downloaded_episodes_when_no_longer_missing
    (RED): regression for SerieScanner._sync_episodes_to_db.
  - Replaced test_preserves_downloaded_episodes (which asserted the
    old buggy behavior) with the deletion-asserting variant.
2026-09-15 20:34:12 +02:00
db5f5edf2d chore: bump version 2026-09-05 19:26:07 +02:00
35a733d36f fix(delete): prune in-memory SerieList cache after delete
delete_series() removed the row from the database and the folder
from disk, but never evicted the entry from SerieList.keyDict — the
in-memory cache that list_series_with_filters() reads from via
SeriesApp.list.GetList(). As a result /api/anime kept returning the
deleted series on every page reload (the 'Beyblade Burst still listed
after delete' bug).

Changes:
* Add SerieList.remove(key) so the cache has a proper eviction API.
* In delete_series(), call it (or fall back to keyDict.pop) after a
  successful DB delete.
* Broadcast a broader series_list_changed event so any client that
  missed the specific series_deleted event can re-sync by re-fetching
  /api/anime. Front-end: new SERIES_LIST_CHANGED constant, handler
  that triggers SeriesManager.reloadSeries().
* Two new regression tests: one asserting the in-memory cache is
  pruned, one asserting the broader broadcast fires.
2026-09-04 20:16:31 +02:00
62b4ca5ffc chore: bump version 2026-09-04 19:54:08 +02:00
8887f9a7cb fix(nfo): pass anime_service to _create_or_update_nfo in anime endpoints
POST /api/anime/{key}/regenerate-nfo and PUT /api/anime/{key}/settings
(both of which can regenerate NFO when apply_to_nfo=True) were calling
_create_or_update_nfo without the required anime_service argument,
producing HTTP 500 with 'missing 1 required positional argument:
anime_service' on the anime-settings page.

Also drops the unused series_data parameter from
_create_or_update_nfo's signature — the value was constructed at every
call site but never read inside the function body. All four callers
in anime.py and nfo.py are updated to match.
2026-09-04 19:18:39 +02:00
AniWorld Dev
2299cf788b docs(api): update GET /api/nfo/{key}/content entry to match the restored endpoint
The API reference for the 'View NFO XML' endpoint was written for the
pre-refactor route shape (/api/nfo/{serie_id}/content with
{serie_id, serie_folder, ...} response fields) that was removed in the
NFO refactor (commits 21af502, a8e5487). The previous fix
(9f52ea0) re-introduced the endpoint as /api/nfo/{key}/content with
{key, folder, ...} response fields, but the docs still described the
old, non-existent shape — anyone reading the API reference and trying
to integrate would have hit a field-name mismatch.

Update the entry in Docs/API.md:
- Path parameter: serie_id -> key (matches the actual route and the
  convention used by the other NFO endpoints in the same file).
- Response fields: serie_id/serie_folder -> key/folder.
- Errors: add 400 (no folder), 500 (file read), 503 (anime_directory
  not configured) — matching what the handler actually raises.
- Source link: point at the new line range in src/server/api/nfo.py.

Scope: only the /content entry. The surrounding /api/nfo/{serie_id}/*
block still describes other legacy endpoints that were removed in the
same refactor — left untouched per 'only the last added endpoint'.
2026-09-04 19:14:48 +02:00
AniWorld Dev
9f52ea03fb fix(anime-settings): restore GET /api/nfo/{key}/content for 'View NFO XML' button
The Anime Settings page (src/server/web/static/js/pages/anime-settings.js)
calls GET /api/nfo/{key}/content from its 'View NFO XML' button, but that
endpoint was removed during the NFO refactor (commits 21af502, a8e5487).
The frontend was never updated, so every click on the button 404'd.

Fix:
- Add NfoContentResponse model (key, folder, content, file_size,
  last_modified) to src/server/models/nfo.py.
- Add GET /api/nfo/{key}/content handler to src/server/api/nfo.py that
  reads <anime_directory>/<folder>/tvshow.nfo and returns it as
  {"content": "<xml>", ...} — matching what anime-settings.js
  viewNfoContent() already expects (data.content).
- Expose viewNfoContent on AniWorld.AnimeSettingsManager so it is
  consistent with the other public methods and directly callable from
  tests / other modules.

Tests:
- tests/api/test_nfo_endpoints.py: 4 new tests (auth-required, happy
  path returning XML, 404 on unknown series, 404 on missing tvshow.nfo).
  Also remove the file-local autouse 'reset_auth' fixture that wiped
  the conftest's master-password setup and made any login-based test
  fail with a stale-hash 'invalid credentials' error — that fixture
  was pre-existing and is a no-op now that conftest.py handles reset.
- tests/frontend/unit/anime_settings.test.js: 3 new tests for
  viewNfoContent (URL + auth header, writes <pre>, error toast on
  404) and an assertion in the public-API surface test.
2026-09-04 19:10:27 +02:00
ff526e08ea fix(delete-modal): close modal after successful delete
The success path of handleConfirm() never reset the isSubmitting flag,
so hide()'s early-return guard (line 209: 'if (isSubmitting) return')
kept the modal visible after a successful delete — leaving the user
looking at a stuck 'Deleting...' dialog while the card had already been
removed by the WebSocket SERIES_DELETED event.

Reset isSubmitting and the confirm button text before calling hide(),
and capture currentKey into a local before hide() nulls it so the
follow-up removeSeries() call receives the right key.
2026-09-04 18:56:47 +02:00
b8892b4737 chore: bump version 2026-09-02 19:34:17 +02:00
16977d6227 chore: bump version 2026-09-02 19:33:22 +02:00
7538ea8608 fix(release): refuse to run from a snap-sandboxed HOME
When release.sh is launched from VS Code's integrated terminal, podman
sees $HOME=/home/$USER/snap/code/<rev>/ and stores its database there.
If snapd auto-updates code mid-session (rev 258 -> 260), the next
podman invocation finds a stale static-dir pointer and aborts with:

  Error: database static dir ".../snap/code/258/.../libpod" does not
  match our static dir ".../snap/code/260/.../libpod"

Detect the pattern ($HOME under /home/*/snap/*) and bail out early
with an actionable message: re-run from a regular host terminal.
2026-09-02 19:24:30 +02:00
7da7668787 fix(release): write package.json and pyproject.toml versions correctly
The single-quoted bash string passed to sed -i prevented FRONT_VERSION from
being interpolated, so the file content was being silently rewritten to
the literal text "${FRONT_VERSION}". Likewise the double-quoted sed for
pyproject.toml would silently do nothing if the [project] section was
absent while still printing a misleading 'updated' success line.

Replace both with python: json.load/dump for package.json (so the JSON
remains well-formed and 2-space-indented), and a regex section-aware
edit for pyproject.toml that exits non-zero with a warning if no
`version = "..."` line is found, so the success message only prints
when something actually changed.
2026-09-02 19:23:42 +02:00
0f872276dd fix: merge bare folder into year-suffixed one instead of bailing
When the year-suffixed target folder already exists on disk, both
FolderNamingService and AnimeService.rename_folder_if_needed used to
silently bail out. The bare folder (e.g. 'Ultraman') was left next to
the year-suffixed one ('Ultraman (2019)'), producing the symptom
'reports series like Ultraman as added twice' — the DB has one row but
the filesystem has two folders holding the same content.

Fix: when the target already exists, merge the source's contents into
the target (target version wins on file conflicts; source copies are
removed so cleanup succeeds), remove the now-empty source directory,
update DB row + in-memory cache. Plain rename path is unchanged.

Also fixes a latent TypeError in rename_folder_if_needed where
self._directory (a str) was used with the '/' operator. Production
behavior was that any rename through that method raised and was
swallowed by the caller's try/except, leaving the bare folder
untouched. The new path builds Path objects from the string base.

Tests:
- Replaced test_skips_when_target_folder_already_exists (which
  codified the bug) with three tests that cover the new merge
  contract: clean merge, no-overwrite, and empty-source removal.
- Added tests/unit/test_rename_folder_if_needed.py covering the
  same scenarios plus the str-directory regression. All seven go
  red on the unfixed code and green with the fix.

Fixes the 'Ultraman' / 'Ultraman (2019)' duplicate-folder bug.
2026-08-28 21:42:55 +02:00
818e621288 fix: delete folder before DB row, and add orphan-folder recovery
Two follow-ups to the previous fix that resolved the CWD-relative-path
bug in delete_series:

1. Reorder deletion: filesystem first, database second.

   When both delete_database=True and delete_folder=True were set and
   the folder delete failed (any reason — path math bug, permission
   error, missing volume, etc.), the database row was already gone by
   the time the folder delete was attempted. This left an orphan
   folder on disk that the user had no normal way to clean up — the
   delete UI returns 'Series not found' when the row is gone.

   Doing folder delete first means a folder-side failure preserves
   the DB row, so the user can retry the delete once the underlying
   issue is resolved.

2. Orphan-folder recovery: when the user requests delete_folder=True
   for a key whose DB row no longer exists, scan the configured
   anime directory for a folder that uniquely matches the key
   (normalized: strip trailing (YYYY), lowercase, remove
   hyphens/underscores/non-alphanumerics) and delete it. This
   recovers the Beyblade Burst scenario: a previous delete attempt
   removed the row but the folder stayed on disk; retrying the
   delete now cleans up the orphan.

   Safety: recovery only triggers when exactly one folder matches
   the normalized key. Zero matches → clear 'no folder matching'
   error. Multiple matches (ambiguous) → refuses to delete anything.

Adds four regression tests:
- test_delete_series_db_preserved_when_folder_fails
- test_delete_series_orphan_folder_recovery (the Beyblade Burst case)
- test_delete_series_orphan_folder_no_match
- test_delete_series_orphan_folder_ambiguous
2026-08-28 21:12:44 +02:00
14f12e55e7 fix: resolve relative folder paths against anime dir in delete_series
The folder column in anime_series stores the relative folder name
(e.g. 'Beyblade Burst (2016)'), not an absolute path. The old
delete_series code called os.path.abspath(folder_path) directly,
which joins a relative path against the process current working
directory. Inside the container the FastAPI app runs with CWD=/app
while the anime directory is /data, so 'Beyblade Burst (2016)'
resolved to '/app/Beyblade Burst (2016)' and the is_safe_path check
correctly (but unhelpfully) flagged it as outside the /data base,
skipping the folder delete while still removing the row.

Fix: resolve relative folder paths against the configured anime
directory before validating against the base. Absolute paths still
work unchanged.

Also harden is_safe_path() the same way so a relative target is
treated as relative to base_path, not to the process CWD. Path
traversal ('../etc/passwd') is still rejected.

Adds two regression tests:
- is_safe_path with chdir to '/' and relative target resolves
  against the base
- delete_series with chdir to '/', relative folder in DB,
  succeeds and removes the folder
2026-08-28 21:01:10 +02:00
35 changed files with 3302 additions and 217 deletions

View File

@@ -1 +1 @@
v1.5.7 v1.5.12

View File

@@ -59,9 +59,26 @@ else
err "Neither podman nor docker is installed." err "Neither podman nor docker is installed."
fi fi
# --------------------------------------------------------------------------- # -------------------------------------------------------------------
# Refuse to run from inside a confined snap sandbox (e.g. the VS Code
# integrated terminal). When the script is launched from such a
# sandbox, $HOME points under /home/$USER/snap/code/<rev>/ and podman
# stores its DB under that path. If a snap revision bump happens mid
# session, the next podman invocation finds a stale static-dir pointer
# and aborts with a confusing "database static dir ... does not match"
# error. Re-run the script from a regular host shell instead.
# -------------------------------------------------------------------
case "${HOME:-}" in
/home/*/snap/*)
err "Refusing to run inside a snap-sandboxed HOME (${HOME}). \
Re-run from a regular host terminal (e.g. gnome-terminal, konsole) \
so podman uses a stable storage path."
;;
esac
# -------------------------------------------------------------------
# Pre-flight checks # Pre-flight checks
# --------------------------------------------------------------------------- # -------------------------------------------------------------------
echo "============================================" echo "============================================"
echo " AniWorld — Build & Push" echo " AniWorld — Build & Push"
echo " Engine : ${ENGINE}" echo " Engine : ${ENGINE}"

View File

@@ -85,7 +85,20 @@ echo "Version file updated → ${VERSION_FILE}"
FRONT_VERSION="${NEW_TAG#v}" FRONT_VERSION="${NEW_TAG#v}"
FRONT_PKG="${SCRIPT_DIR}/../package.json" FRONT_PKG="${SCRIPT_DIR}/../package.json"
if [[ -f "${FRONT_PKG}" ]]; then if [[ -f "${FRONT_PKG}" ]]; then
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${FRONT_VERSION}\"/" "${FRONT_PKG}" # Use a python one-liner for portable, safe JSON editing. The previous
# `sed -i` used single-quoted bash strings, which prevented
# ${FRONT_VERSION} from being interpolated and silently rewrote the file
# to the literal string "${FRONT_VERSION}".
python3 - "$FRONT_PKG" "$FRONT_VERSION" <<'PY'
import json, sys
path, new_version = sys.argv[1], sys.argv[2]
with open(path, encoding="utf-8") as fh:
data = json.load(fh)
data["version"] = new_version
with open(path, "w", encoding="utf-8") as fh:
json.dump(data, fh, indent=2)
fh.write("\n")
PY
echo "package.json version updated → ${FRONT_VERSION}" echo "package.json version updated → ${FRONT_VERSION}"
else else
echo "Warning: package.json not found, skipping package.json version sync" >&2 echo "Warning: package.json not found, skipping package.json version sync" >&2
@@ -94,13 +107,63 @@ fi
# Keep root pyproject.toml in sync. # Keep root pyproject.toml in sync.
BACKEND_PYPROJECT="${SCRIPT_DIR}/../pyproject.toml" BACKEND_PYPROJECT="${SCRIPT_DIR}/../pyproject.toml"
if [[ -f "${BACKEND_PYPROJECT}" ]]; then if [[ -f "${BACKEND_PYPROJECT}" ]]; then
# Update version under [project] section if present # Use python instead of sed: the previous `sed -i` used double-quoted
if grep -q '^\[project\]' "${BACKEND_PYPROJECT}"; then # patterns whose `&` and `\` characters would have to be escaped, and
sed -i "/^\[project\]/,/^\[/ s/^version = \".*\"/version = \"${FRONT_VERSION}\"/" "${BACKEND_PYPROJECT}" # more importantly it could silently do nothing if the [project] section
else # was missing. python reads/writes the file as a string, preserving
sed -i "s/^version = \".*\"/version = \"${FRONT_VERSION}\"/" "${BACKEND_PYPROJECT}" # the existing format, and reports whether anything changed.
if FRONT_VERSION="$FRONT_VERSION" BACKEND_PYPROJECT="$BACKEND_PYPROJECT" python3 <<'PY'
import os, re, sys
path = os.environ["BACKEND_PYPROJECT"]
new_version = os.environ["FRONT_VERSION"]
with open(path, encoding="utf-8") as fh:
text = fh.read()
# If there is a [project] table, update only the `version = "..."` line
# inside it; otherwise update the first top-level `version = "..."` line.
project_match = re.search(r"^\[project\]\s*$", text, re.MULTILINE)
if project_match:
start = project_match.end()
end = re.search(r"^\[", text[start:], re.MULTILINE)
section_end = start + end.start() if end else len(text)
section = text[start:section_end]
new_section, n = re.subn(
r'^version = ".*"$',
f'version = "{new_version}"',
section,
count=1,
flags=re.MULTILINE,
)
if n == 0:
print(
f"Warning: no `version = ...` line found under [project] in {path}",
file=sys.stderr,
)
sys.exit(2)
text = text[:start] + new_section + text[section_end:]
else:
new_text, n = re.subn(
r'^version = ".*"$',
f'version = "{new_version}"',
text,
count=1,
flags=re.MULTILINE,
)
if n == 0:
print(
f"Warning: no `version = ...` line found in {path}",
file=sys.stderr,
)
sys.exit(2)
text = new_text
with open(path, "w", encoding="utf-8") as fh:
fh.write(text)
PY
then
echo "pyproject.toml version updated → ${FRONT_VERSION}"
fi fi
echo "pyproject.toml version updated → ${FRONT_VERSION}"
else else
echo "Warning: pyproject.toml not found, skipping pyproject.toml version sync" >&2 echo "Warning: pyproject.toml not found, skipping pyproject.toml version sync" >&2
fi fi

View File

@@ -1056,34 +1056,39 @@ Update existing NFO file with fresh TMDB data.
Source: [src/server/api/nfo.py](../src/server/api/nfo.py#L243-L325) Source: [src/server/api/nfo.py](../src/server/api/nfo.py#L243-L325)
### GET /api/nfo/{serie_id}/content ### GET /api/nfo/{key}/content
Get NFO file XML content for a series. Read the raw `tvshow.nfo` XML for a series. Used by the Anime Settings
page's "View NFO XML" button to render the on-disk NFO in a `<pre>` block.
**Authentication:** Required **Authentication:** Required
**Path Parameters:** **Path Parameters:**
- `serie_id` (string): Series identifier - `key` (string): Series unique key (e.g., `attack-on-titan`)
**Response (200 OK):** **Response (200 OK):** [`NfoContentResponse`](../src/server/models/nfo.py)
```json ```json
{ {
"serie_id": "one-piece", "key": "attack-on-titan",
"serie_folder": "One Piece (1999)", "folder": "Attack on Titan (2013)",
"content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<tvshow>...</tvshow>", "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<tvshow>...</tvshow>",
"file_size": 2048, "file_size": 2048,
"last_modified": "2026-01-15T10:30:00" "last_modified": "2026-09-04T17:42:13"
} }
``` ```
**Errors:** **Errors:**
- `401 Unauthorized` - Not authenticated - `400 Bad Request` — Series has no folder configured.
- `404 Not Found` - Series or NFO not found - `401 Unauthorized` — Not authenticated.
- `404 Not Found` — Series with the given key does not exist, or its
`tvshow.nfo` is missing on disk.
- `500 Internal Server Error` — Failed to read the NFO file from disk.
- `503 Service Unavailable``settings.anime_directory` is not configured.
Source: [src/server/api/nfo.py](../src/server/api/nfo.py#L328-L397) Source: [src/server/api/nfo.py](../src/server/api/nfo.py#L411-L477)
### GET /api/nfo/{serie_id}/media/status ### GET /api/nfo/{serie_id}/media/status

View File

@@ -1,6 +1,6 @@
{ {
"name": "aniworld-web", "name": "aniworld-web",
"version": "1.5.7", "version": "1.5.12",
"description": "Aniworld Anime Download Manager - Web Frontend", "description": "Aniworld Anime Download Manager - Web Frontend",
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -0,0 +1,332 @@
"""CLI tool to clean up duplicate ``Episode`` rows.
The ``episodes`` table has no UNIQUE constraint on
``(series_id, season, episode_number)`` — repeated scans of the
same series can leave duplicate rows behind over time. They don't
break the app (the ``AnimeSeries.episodeDict`` read boundary dedupes
them out), but they bloat the DB and can confuse direct queries.
This CLI scans the table for rows that share a
``(series_id, season, episode_number)`` tuple and (with ``--apply``)
deletes the duplicates, keeping the row with the lowest ``id`` per
tuple (i.e. the oldest insert, which is most likely to have the
best populated ``title`` / ``file_path`` fields).
Usage::
# Inspect — list duplicates without modifying anything.
python -m src.cli.clean_duplicate_episodes
# Apply — actually delete the duplicates.
python -m src.cli.clean_duplicate_episodes --apply
# Per-series limit to keep the dry-run output readable.
python -m src.cli.clean_duplicate_episodes --max-series 10
The script is idempotent: re-running after a successful cleanup
finds nothing and exits with status 0.
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import sys
from pathlib import Path
from typing import List, Tuple
# Add project root to path so ``from src.server...`` works when
# invoked as ``python -m src.cli.clean_duplicate_episodes``.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
from sqlalchemy import delete, func, select, tuple_
from src.server.database.connection import close_db, init_db
from src.server.database.models import AnimeSeries, Episode
logger = logging.getLogger(__name__)
# Tuple shape: (series_id, season, episode_number, count_of_rows,
# min_id_kept, max_id_deleted).
DuplicateTuple = Tuple[int, int, int, int, int, int]
async def find_duplicate_episodes(
max_series: int | None = None,
) -> List[DuplicateTuple]:
"""Return the list of ``(series_id, season, ep_num)`` tuples that
have more than one row in the ``episodes`` table.
Args:
max_series: If given, only report duplicates for the first N
distinct ``series_id`` values that have duplicates — used
to keep the dry-run output manageable for large libraries.
"""
duplicates_subquery = (
select(
Episode.series_id.label("series_id"),
Episode.season.label("season"),
Episode.episode_number.label("episode_number"),
func.count(Episode.id).label("row_count"),
func.min(Episode.id).label("keep_id"),
func.max(Episode.id).label("max_id"),
)
.group_by(
Episode.series_id,
Episode.season,
Episode.episode_number,
)
.having(func.count(Episode.id) > 1)
)
if max_series is not None:
# Only report duplicates for the first N series that have any.
# Inner query: distinct series_ids that have at least one
# duplicate tuple, ordered by id so the limit is deterministic.
series_with_dupes = (
select(Episode.series_id)
.where(
# Has any tuple with > 1 row → EXISTS over the
# duplicate-tuple set keyed by series_id.
Episode.series_id.in_(
select(Episode.series_id)
.group_by(
Episode.series_id,
Episode.season,
Episode.episode_number,
)
.having(func.count(Episode.id) > 1)
)
)
.group_by(Episode.series_id)
.order_by(Episode.series_id)
.limit(max_series)
.subquery()
)
duplicates_subquery = duplicates_subquery.where(
Episode.series_id.in_(select(series_with_dupes.c.series_id))
)
rows = (await _execute(duplicates_subquery)).all()
return [
(
int(r.series_id),
int(r.season),
int(r.episode_number),
int(r.row_count),
int(r.keep_id),
int(r.max_id),
)
for r in rows
]
async def delete_duplicate_episodes(duplicates: List[DuplicateTuple]) -> int:
"""Delete the duplicate rows for each tuple, keeping the lowest id.
Returns the number of rows actually deleted.
Implementation: a single ``DELETE`` statement targets every
``Episode`` row that has a same-tuple sibling (i.e. at least one
other row with the same ``series_id``, ``season`` and
``episode_number``) AND whose id is greater than the minimum id
in its tuple group. The min-id row per tuple is preserved (it
has the lowest primary key, i.e. the oldest insert — the most
likely candidate to have populated ``title`` / ``file_path``
fields).
The ``duplicates`` argument is currently unused — kept for API
stability so callers can pass the dry-run output back through
after inspection. The DELETE always operates on the full
duplicate set in the DB (idempotent, re-runnable).
"""
del duplicates # API stability; DELETE is self-contained.
# Subquery: every (series_id, season, ep_num) tuple with > 1 row.
dup_keys = (
select(
Episode.series_id.label("series_id"),
Episode.season.label("season"),
Episode.episode_number.label("episode_number"),
)
.group_by(
Episode.series_id,
Episode.season,
Episode.episode_number,
)
.having(func.count(Episode.id) > 1)
.subquery()
)
# Per-row min id for each tuple.
min_id_per_tuple = (
select(func.min(Episode.id).label("min_id"))
.group_by(
Episode.series_id,
Episode.season,
Episode.episode_number,
)
.having(func.count(Episode.id) > 1)
.subquery()
)
result = await _execute(
delete(Episode).where(
Episode.id.notin_(select(min_id_per_tuple.c.min_id)),
# Correlate the delete with the duplicate-tuples subquery.
# Use tuple IN to match all three columns.
tuple_(
Episode.series_id,
Episode.season,
Episode.episode_number,
).in_(
select(
dup_keys.c.series_id,
dup_keys.c.season,
dup_keys.c.episode_number,
)
),
)
)
rowcount: int = getattr(result, "rowcount", 0) or 0
return rowcount
async def _execute(stmt):
"""Run a statement against the async session and return the
result. Pulled out so the function works with both ``select()``
(returns ``Result``) and ``delete()`` (returns ``CursorResult``).
"""
from src.server.database.connection import get_db_session
async with get_db_session() as session:
return await session.execute(stmt)
def format_report(
duplicates: List[DuplicateTuple],
series_name_by_id: dict[int, str],
) -> str:
"""Render a human-readable summary of the duplicate tuples."""
if not duplicates:
return "No duplicate episodes found."
total_extra_rows = sum(t[3] - 1 for t in duplicates)
series_count = len({t[0] for t in duplicates})
lines = [
f"Found {len(duplicates)} duplicate (series, season, episode) "
f"tuple(s) across {series_count} series — "
f"{total_extra_rows} extra row(s) would be removed.",
"",
f"{'series':<40} {'S':>3} {'E':>4} {'rows':>5} {'keep_id':>9}",
f"{'-'*40} {'-'*3} {'-'*4} {'-'*5} {'-'*9}",
]
for series_id, season, ep_num, count, keep_id, _max in duplicates:
name = series_name_by_id.get(series_id, f"#{series_id}")
if len(name) > 38:
name = name[:37] + "\u2026"
lines.append(
f"{name:<40} {season:>3} {ep_num:>4} {count:>5} {keep_id:>9}"
)
return "\n".join(lines)
async def _load_series_names(series_ids: List[int]) -> dict[int, str]:
from src.server.database.connection import get_db_session
if not series_ids:
return {}
async with get_db_session() as session:
rows = (
await session.execute(
select(AnimeSeries.id, AnimeSeries.name, AnimeSeries.key)
.where(AnimeSeries.id.in_(series_ids))
)
).all()
out: dict[int, str] = {}
for r in rows:
# Prefer display name, fall back to key.
out[int(r.id)] = (
(r.name or r.key or f"#{r.id}") if r else f"#{r.id}"
)
return out
async def run(apply: bool, max_series: int | None) -> int:
"""CLI entry point. Returns a shell exit code."""
try:
await init_db()
except Exception as exc:
logger.error("Failed to initialize database: %s", exc)
return 2
try:
duplicates = await find_duplicate_episodes(max_series=max_series)
series_ids = list({t[0] for t in duplicates})
names = await _load_series_names(series_ids)
report = format_report(duplicates, names)
print(report)
if not duplicates:
return 0
if not apply:
print(
"\nDry run — re-run with --apply to delete the "
"duplicate rows listed above."
)
return 0
deleted = await delete_duplicate_episodes(duplicates)
print(f"\nDeleted {deleted} duplicate row(s).")
return 0
except Exception:
logger.exception("Cleanup failed")
return 1
finally:
await close_db()
def main() -> int:
parser = argparse.ArgumentParser(
description=(
"Find and (optionally) delete duplicate rows in the "
"``episodes`` table. Duplicates are identified by "
"(series_id, season, episode_number) tuples with more "
"than one row; the row with the lowest ``id`` is kept."
),
)
parser.add_argument(
"--apply",
action="store_true",
help="Actually delete the duplicate rows. Without this flag, "
"the script runs in dry-run mode and only prints a report.",
)
parser.add_argument(
"--max-series",
type=int,
default=None,
help="Limit the dry-run report to the first N series that "
"have duplicates. Useful for large libraries. No effect "
"with --apply (which always cleans everything).",
)
parser.add_argument(
"--log-level",
default="INFO",
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
help="Python logging level (default: INFO).",
)
args = parser.parse_args()
logging.basicConfig(
level=getattr(logging, args.log_level),
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
return asyncio.run(run(apply=args.apply, max_series=args.max_series))
if __name__ == "__main__":
sys.exit(main())

View File

@@ -281,10 +281,13 @@ class SerieScanner:
async def _sync_episodes_to_db( async def _sync_episodes_to_db(
self, db, series_id: int, episode_dict: dict[int, list[int]] self, db, series_id: int, episode_dict: dict[int, list[int]]
) -> None: ) -> None:
"""Sync episodes to database, preserving downloaded flags. """Sync episodes to database.
Adds missing episodes, removes episodes no longer missing, Adds missing episodes, removes episodes no longer missing
and preserves is_downloaded=True episodes. (including those that were previously marked as downloaded:
once the scanner confirms the file is on disk, the row has
no further purpose and is deleted to keep the DB in sync
with the filesystem).
Args: Args:
db: Async database session db: Async database session
@@ -301,15 +304,9 @@ class SerieScanner:
new_keys.add((season, ep_num)) new_keys.add((season, ep_num))
for (season, ep_num), ep in existing_map.items(): for (season, ep_num), ep in existing_map.items():
if (season, ep_num) not in new_keys: if (season, ep_num) not in new_keys:
if ep.is_downloaded: await EpisodeService.delete_by_series(
logger.debug( db, series_id, season, ep_num
"Preserving downloaded episode S%02dE%02d for series_id=%d", )
season, ep_num, series_id
)
else:
await EpisodeService.delete_by_series(
db, series_id, season, ep_num
)
for season, eps in episode_dict.items(): for season, eps in episode_dict.items():
for ep_num in eps: for ep_num in eps:
if (season, ep_num) not in existing_map: if (season, ep_num) not in existing_map:
@@ -779,18 +776,35 @@ class SerieScanner:
# Create or update AnimeSeries in keyDict # Create or update AnimeSeries in keyDict
if key in self.keyDict: if key in self.keyDict:
# Update existing anime - rebuild episodeDict from episodes # Update existing anime - rebuild episodeDict from the
# latest scan results. The previous implementation
# extended the existing list with ``missing_episodes``,
# which accumulated duplicates across rescans of the
# same series; the in-memory cache then propagated
# duplicates through ``_update_series_in_db`` and into
# the ``episodes`` table until the UNIQUE constraint
# was added. Replace, don't extend.
existing = self.keyDict[key] existing = self.keyDict[key]
existing_ep_dict = existing.episodeDict # Use ``dict.fromkeys`` to dedupe within a season, in
# Merge missing episodes # case ``missing_episodes`` itself contains duplicate
# episode numbers from a buggy loader upstream.
rebuilt: dict = {}
for season, eps in missing_episodes.items(): for season, eps in missing_episodes.items():
if season not in existing_ep_dict: seen: set = set()
existing_ep_dict[season] = [] cleaned: list = []
existing_ep_dict[season].extend(eps) for ep_num in eps:
if ep_num in seen:
continue
seen.add(ep_num)
cleaned.append(ep_num)
if cleaned:
rebuilt[season] = cleaned
existing.episodeDict = rebuilt
existing.folder = folder
logger.debug( logger.debug(
"Updated existing series %s with %d missing episodes", "Updated existing series %s with %d missing episodes",
key, key,
sum(len(eps) for eps in missing_episodes.values()) sum(len(eps) for eps in rebuilt.values()),
) )
else: else:
# Extract year from folder name if present, otherwise leave as None # Extract year from folder name if present, otherwise leave as None

View File

@@ -1650,17 +1650,11 @@ async def update_anime_settings(
# Lazy-import to avoid heavy deps when not used # Lazy-import to avoid heavy deps when not used
from src.server.api.nfo import _create_or_update_nfo from src.server.api.nfo import _create_or_update_nfo
series_data = {
"key": anime_key,
"name": db_series.name,
"folder": db_series.folder,
"tmdb_id": db_series.tmdb_id,
}
await _create_or_update_nfo( await _create_or_update_nfo(
key=anime_key, key=anime_key,
folder=db_series.folder, folder=db_series.folder,
tmdb_id=db_series.tmdb_id, tmdb_id=db_series.tmdb_id,
series_data=series_data, anime_service=anime_service,
) )
except HTTPException: except HTTPException:
raise raise
@@ -1727,17 +1721,11 @@ async def regenerate_anime_nfo(
try: try:
from src.server.api.nfo import _create_or_update_nfo from src.server.api.nfo import _create_or_update_nfo
series_data = {
"key": anime_key,
"name": db_series.name,
"folder": db_series.folder,
"tmdb_id": tmdb_id,
}
repaired_tags = await _create_or_update_nfo( repaired_tags = await _create_or_update_nfo(
key=anime_key, key=anime_key,
folder=db_series.folder, folder=db_series.folder,
tmdb_id=tmdb_id, tmdb_id=tmdb_id,
series_data=series_data, anime_service=anime_service,
) )
except HTTPException: except HTTPException:
raise raise

View File

@@ -4,6 +4,7 @@ Provides endpoints for NFO settings, repair, and validation for anime series.
""" """
import logging import logging
import os import os
from datetime import datetime
from typing import Any, List, Optional from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
@@ -11,6 +12,7 @@ from pydantic import BaseModel
from src.config.settings import settings from src.config.settings import settings
from src.server.models.nfo import ( from src.server.models.nfo import (
NfoContentResponse,
NfoRepairResponse, NfoRepairResponse,
NfoSeriesSettings, NfoSeriesSettings,
NfoSettingsResponse, NfoSettingsResponse,
@@ -241,7 +243,6 @@ async def repair_nfo_settings(
key=key, key=key,
folder=folder, folder=folder,
tmdb_id=tmdb_id, tmdb_id=tmdb_id,
series_data=series_data,
anime_service=anime_service, anime_service=anime_service,
) )
except Exception as exc: except Exception as exc:
@@ -285,7 +286,6 @@ async def _create_or_update_nfo(
key: str, key: str,
folder: str, folder: str,
tmdb_id: int, tmdb_id: int,
series_data: dict,
anime_service: AnimeService, anime_service: AnimeService,
) -> List[str]: ) -> List[str]:
"""Create or update NFO file for a series. """Create or update NFO file for a series.
@@ -406,6 +406,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 ``<pre>`` 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) @router.get("/needs-repair", response_model=NfoNeedsRepairListResponse)
async def get_series_needing_repair( async def get_series_needing_repair(
_auth: dict = Depends(require_auth), _auth: dict = Depends(require_auth),
@@ -522,7 +590,6 @@ async def batch_repair_nfo(
key=key, key=key,
folder=folder, folder=folder,
tmdb_id=tmdb_id, tmdb_id=tmdb_id,
series_data=series_data,
anime_service=anime_service, anime_service=anime_service,
) )
results["success"] += 1 results["success"] += 1

View File

@@ -130,6 +130,26 @@ class SerieList:
""" """
return key in self.keyDict return key in self.keyDict
def remove(self, key: str) -> bool:
"""Remove a series from the in-memory cache.
Used by the delete flow to keep the in-memory cache in sync with
the database after a row is deleted. Returns True if an entry
was present and removed, False if the key was not in the cache
(treating "already gone" as a no-op rather than an error).
Args:
key: The unique provider identifier for the series
Returns:
True if a series was removed, False if it was not cached.
"""
if key in self.keyDict:
del self.keyDict[key]
logger.debug("Removed series from in-memory cache: key=%s", key)
return True
return False
def GetMissingEpisode(self) -> List[AnimeSeries]: def GetMissingEpisode(self) -> List[AnimeSeries]:
"""Return all series that still contain missing episodes.""" """Return all series that still contain missing episodes."""
return [ return [

View File

@@ -15,7 +15,17 @@ from datetime import datetime, timezone
from enum import Enum from enum import Enum
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, func from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
from src.server.database.base import Base, TimestampMixin from src.server.database.base import Base, TimestampMixin
@@ -195,22 +205,60 @@ class AnimeSeries(Base, TimestampMixin):
"""Build episode dictionary from episodes relationship or private cache. """Build episode dictionary from episodes relationship or private cache.
Returns: Returns:
Dictionary mapping season numbers to lists of episode numbers Dictionary mapping season numbers to lists of episode numbers.
Each (season, episode_number) pair is guaranteed to appear at
most once across all seasons: the underlying episodes table
has no UNIQUE constraint on (series_id, season,
episode_number), so the relationship (and the legacy
``_episode_dict_cache`` set by loaders/scanners) can contain
duplicates from historical scans. Duplicates are filtered
here at the read boundary so the rest of the stack can rely
on the dict being canonical.
""" """
# Check for private cache first (set when loading from JSON without DB) # Check for private cache first (set when loading from JSON without DB)
if hasattr(self, '_episode_dict_cache') and self._episode_dict_cache is not None: if hasattr(self, '_episode_dict_cache') and self._episode_dict_cache is not None:
return self._episode_dict_cache cached = self._episode_dict_cache
# Dedupe the cached dict too: callers that populate the cache
# (legacy JSON loader, SerieScanner.scan_single_series for
# new series) may store values that contain duplicates.
seen: set[tuple[int, int]] = set()
deduped: dict[int, list[int]] = {}
for season, ep_nums in (cached or {}).items():
cleaned: list[int] = []
for ep_num in ep_nums:
if (season, ep_num) in seen:
continue
seen.add((season, ep_num))
cleaned.append(ep_num)
if cleaned:
deduped[season] = cleaned
return deduped
episode_dict: dict[int, list[int]] = {} episode_dict: dict[int, list[int]] = {}
try: try:
if self.episodes: if self.episodes:
seen: set[tuple[int, int]] = set()
for ep in self.episodes: for ep in self.episodes:
if ep.is_downloaded: if ep.is_downloaded:
continue continue
season = ep.season or 1 season = ep.season or 1
ep_num = ep.episode_number or 0
# Dedupe by (season, ep_num): the episodes table has
# no UNIQUE constraint on (series_id, season,
# episode_number), so the relationship can yield
# duplicate rows from historical scans. Without
# this guard, the dict exposes duplicates to the
# frontend, which forwards them verbatim to the
# queue API — every duplicate gets rejected by the
# backend's pending-episode dedup, leaving the
# user with an empty queue and a misleading
# "Added N" toast.
if (season, ep_num) in seen:
continue
seen.add((season, ep_num))
if season not in episode_dict: if season not in episode_dict:
episode_dict[season] = [] episode_dict[season] = []
episode_dict[season].append(ep.episode_number or 0) episode_dict[season].append(ep_num)
except Exception: except Exception:
# DetachedInstanceError or other DB errors - return empty dict # DetachedInstanceError or other DB errors - return empty dict
# This can happen when accessing episodes on a newly created # This can happen when accessing episodes on a newly created
@@ -291,6 +339,24 @@ class Episode(Base, TimestampMixin):
""" """
__tablename__ = "episodes" __tablename__ = "episodes"
# Table-level constraints. The UNIQUE constraint on
# (series_id, season, episode_number) is the schema-level guard
# against the duplicate-row pathology: every (series, season,
# episode) tuple can have at most one row. Rescans that try to
# create a duplicate row will fail at the DB layer rather than
# silently accumulating rows. Defense-in-depth on top of the
# write-side dedup in SerieScanner._sync_episodes_to_db and
# AnimeService._update_series_in_db, and the read-boundary dedup
# in AnimeSeries.episodeDict.
__table_args__ = (
UniqueConstraint(
"series_id",
"season",
"episode_number",
name="uq_episode_per_series_season",
),
)
# Primary key # Primary key
id: Mapped[int] = mapped_column( id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True Integer, primary_key=True, autoincrement=True

View File

@@ -393,5 +393,29 @@ class NfoRepairResponse(BaseModel):
message: str = Field(..., description="Human-readable result message") message: str = Field(..., description="Human-readable result message")
repaired_tags: List[str] = Field( repaired_tags: List[str] = Field(
default_factory=list, 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"
) )

View File

@@ -861,9 +861,13 @@ class AnimeService:
Syncs the database episodes with the current missing episodes from scan. Syncs the database episodes with the current missing episodes from scan.
- Adds new missing episodes that are not in the database - Adds new missing episodes that are not in the database
- Removes episodes from database that are no longer missing - Removes episodes from database that are no longer missing
(i.e., the file has been added to the filesystem) (i.e., the file has been added to the filesystem), including
- Preserves episodes marked as downloaded (is_downloaded=True) episodes that were previously marked as downloaded. A row
so download history is not lost that is no longer missing — by definition — does not need to
stay in the DB; the UI derives "missing" from the row's
presence, so keeping an ``is_downloaded=True`` row around
leaves a stale entry that the user can see in the DB but
not anywhere else.
""" """
from src.server.database.service import AnimeSeriesService, EpisodeService from src.server.database.service import AnimeSeriesService, EpisodeService
@@ -871,15 +875,11 @@ class AnimeService:
existing_episodes = await EpisodeService.get_by_series(db, existing.id) existing_episodes = await EpisodeService.get_by_series(db, existing.id)
# Build dict of existing episodes: {season: {ep_num: episode_id}} # Build dict of existing episodes: {season: {ep_num: episode_id}}
# and track which ones are already downloaded
existing_dict: dict[int, dict[int, int]] = {} existing_dict: dict[int, dict[int, int]] = {}
downloaded_set: set[tuple[int, int]] = set()
for ep in existing_episodes: for ep in existing_episodes:
if ep.season not in existing_dict: if ep.season not in existing_dict:
existing_dict[ep.season] = {} existing_dict[ep.season] = {}
existing_dict[ep.season][ep.episode_number] = ep.id existing_dict[ep.season][ep.episode_number] = ep.id
if ep.is_downloaded:
downloaded_set.add((ep.season, ep.episode_number))
# Get new missing episodes from scan # Get new missing episodes from scan
new_dict = serie.episodeDict or {} new_dict = serie.episodeDict or {}
@@ -909,23 +909,14 @@ class AnimeService:
) )
# Remove episodes from database that are no longer missing # Remove episodes from database that are no longer missing
# (i.e., the episode file now exists on the filesystem) # (i.e., the episode file now exists on the filesystem).
# BUT: preserve episodes that are already downloaded (is_downloaded=True) # This includes episodes previously marked as downloaded:
# so we don't lose download history # once the file is confirmed on disk by a rescan, the row
# has no further purpose and is deleted to keep the DB
# in sync with the filesystem.
for season, eps_dict in existing_dict.items(): for season, eps_dict in existing_dict.items():
for ep_num, episode_id in eps_dict.items(): for ep_num, episode_id in eps_dict.items():
if (season, ep_num) not in new_missing_set: if (season, ep_num) not in new_missing_set:
# Skip already-downloaded episodes — they should stay in DB
# with is_downloaded=True to preserve download history
if (season, ep_num) in downloaded_set:
logger.debug(
"Preserving downloaded episode in database: "
"%s S%02dE%02d",
serie.key,
season,
ep_num
)
continue
await EpisodeService.delete(db, episode_id) await EpisodeService.delete(db, episode_id)
logger.info( logger.info(
"Removed episode from database (no longer missing): " "Removed episode from database (no longer missing): "
@@ -1244,8 +1235,7 @@ class AnimeService:
Returns: Returns:
True if rename was performed, False if no rename needed or failed True if rename was performed, False if no rename needed or failed
""" """
import os from pathlib import Path
import shutil
if current_folder == target_folder: if current_folder == target_folder:
logger.debug( logger.debug(
@@ -1254,8 +1244,9 @@ class AnimeService:
) )
return False return False
current_path = self._directory / current_folder base_dir = Path(self._directory)
target_path = self._directory / target_folder current_path = base_dir / current_folder
target_path = base_dir / target_folder
if not current_path.exists(): if not current_path.exists():
logger.debug( logger.debug(
@@ -1265,15 +1256,54 @@ class AnimeService:
return False return False
if target_path.exists(): if target_path.exists():
logger.warning( # Target already exists — merge source into target instead of
"Cannot rename folder for %s: target path already exists: %s", # bailing. Without this, a bare folder ('Naruto') next to the
key, # year-suffixed one ('Naruto (2019)') would orphan the bare
target_path # folder forever, producing the "series added twice" symptom.
try:
summary = self._merge_folder_into_target(
str(current_path), str(target_path)
)
except Exception as exc:
logger.error(
"Failed to merge %s -> %s for %s: %s",
current_folder, target_folder, key, exc,
)
return False
logger.info(
"Merged folder %s -> %s for series %s (moved=%d skipped=%d removed_source=%s)",
current_folder, target_folder, key,
summary["moved"], summary["skipped"], summary["removed_source"],
) )
return False
# Update in-memory cache
if key in self._app.list.keyDict:
self._app.list.keyDict[key].folder = target_folder
logger.debug(
"Updated in-memory cache folder for %s: %s",
key, target_folder
)
# Update database if session provided
if db is not None:
from src.server.database.service import AnimeSeriesService
# Look up series by key to get database ID
series = await AnimeSeriesService.get_by_key(db, key)
if series:
await AnimeSeriesService.update(
db, series_id=series.id, folder=target_folder
)
logger.debug(
"Updated DB folder for %s: %s",
key, target_folder
)
return True
try: try:
# Rename folder on disk # Rename folder on disk
import shutil
shutil.move(str(current_path), str(target_path)) shutil.move(str(current_path), str(target_path))
logger.info( logger.info(
"Renamed folder for %s: %s -> %s", "Renamed folder for %s: %s -> %s",
@@ -1317,6 +1347,83 @@ class AnimeService:
) )
return False return False
@staticmethod
def _merge_folder_into_target(source: str, target: str) -> dict:
"""Merge a source folder's contents into an existing target folder.
Walks the source tree and moves every file into the matching path
under the target. When a destination file already exists, the
source copy is removed (the target version wins; we don't keep
duplicates). When the source tree is fully consumed, the
(now-empty) source directory is removed.
Both paths must be absolute and ``target`` must already exist on
disk.
Returns a summary dict with ``moved`` (file count), ``skipped``
(file count where target already had a copy), and
``removed_source`` (bool).
"""
import os
import shutil
if not os.path.isdir(source):
return {"moved": 0, "skipped": 0, "removed_source": False}
if not os.path.isdir(target):
raise ValueError(f"target does not exist: {target}")
moved = 0
skipped = 0
for root, _dirs, files in os.walk(source):
rel_root = os.path.relpath(root, source)
dest_root = (
target if rel_root == "."
else os.path.join(target, rel_root)
)
os.makedirs(dest_root, exist_ok=True)
for name in files:
src_file = os.path.join(root, name)
dest_file = os.path.join(dest_root, name)
if os.path.exists(dest_file):
# Target wins — never overwrite existing content.
# Remove the orphaned source copy so cleanup below
# can rmdir it.
try:
os.remove(src_file)
except OSError as exc:
logger.warning(
"merge: could not remove duplicate %s: %s",
src_file, exc,
)
skipped += 1
logger.warning(
"merge: skipping %s (target already has %s)",
src_file, dest_file,
)
continue
shutil.move(src_file, dest_file)
moved += 1
# Try to remove the (now empty) source tree. Walk bottom-up so
# leaf directories are removed before their parents.
removed_source = False
for root, dirs, files in os.walk(source, topdown=False):
for d in dirs:
try:
os.rmdir(os.path.join(root, d))
except OSError:
pass
try:
os.rmdir(source)
removed_source = True
except OSError as exc:
logger.warning(
"merge: could not remove source directory %s: %s",
source, exc,
)
return {"moved": moved, "skipped": skipped, "removed_source": removed_source}
async def contains_in_db(self, key: str, db) -> bool: async def contains_in_db(self, key: str, db) -> bool:
""" """
Check if a series with the given key exists in the database. Check if a series with the given key exists in the database.
@@ -1649,12 +1756,29 @@ class AnimeService:
Returns: Returns:
DeleteSeriesResult with success status, what was deleted, errors DeleteSeriesResult with success status, what was deleted, errors
Deletion order: filesystem first, database second.
This order matters: if the folder delete fails (e.g. permission
error, path outside the configured anime directory), the database
row is preserved so the user can retry the delete once the
underlying issue is resolved. If we deleted the database row
first, an orphan folder would be left on disk with no way to
clean it up through the normal delete flow.
Orphan folder recovery: when ``delete_folder=True`` is requested
for a series whose database row no longer exists, the configured
anime directory is scanned for a folder that uniquely matches
the key. This recovers the case where a previous delete with
``delete_database=True`` succeeded but ``delete_folder=True``
silently failed, leaving the folder on disk.
""" """
from src.server.database.connection import get_db_session from src.server.database.connection import get_db_session
from src.server.database.service import AnimeSeriesService from src.server.database.service import AnimeSeriesService
from src.server.models.anime import DeleteSeriesResult from src.server.models.anime import DeleteSeriesResult
from src.server.utils.filesystem import is_safe_path from src.server.utils.filesystem import is_safe_path
import os as _os import os as _os
import re
import shutil import shutil
logger.info( logger.info(
@@ -1679,13 +1803,43 @@ class AnimeService:
message="At least one of delete_database or delete_folder must be True.", message="At least one of delete_database or delete_folder must be True.",
) )
# Single DB session for fetch + optional delete # Look up the series in the DB to get its folder path
series = None
async with get_db_session() as db: async with get_db_session() as db:
series = await AnimeSeriesService.get_by_key(db, key) series = await AnimeSeriesService.get_by_key(db, key)
if not series:
logger.warning( if not series:
"Delete series failed - not found: key=%s", key logger.warning(
) "Delete series - row not found in DB: key=%s delete_folder=%s",
key, delete_folder,
)
# Recovery path: if the user wants to delete the folder but the
# DB row is already gone (e.g. orphaned by a previous partial
# delete), scan the configured anime directory for a folder
# that uniquely matches this key and delete it.
if delete_folder:
folder_path = self._find_orphan_folder_for_key(key)
if folder_path:
logger.info(
"Orphan folder recovery: key=%s matched folder=%s",
key, folder_path,
)
result = DeleteSeriesResult(
success=True,
key=key,
name="",
folder_path=folder_path,
message="",
)
self._delete_folder_at_path(folder_path, key, result)
# No DB row to delete; build message and return
self._build_delete_message(result)
logger.info(
"Delete series completed (orphan recovery): key=%s "
"deleted_folder=%s folder_error=%s",
key, result.deleted_folder, result.folder_error,
)
return result
return DeleteSeriesResult( return DeleteSeriesResult(
success=False, success=False,
key=key, key=key,
@@ -1694,13 +1848,33 @@ class AnimeService:
deleted_from_database=False, deleted_from_database=False,
deleted_folder=False, deleted_folder=False,
database_error=None, database_error=None,
folder_error=None, folder_error=(
message=f"Series '{key}' not found.", f"Series '{key}' not found in database, and no folder "
"matching this key was found in the anime directory. "
"Nothing to delete."
),
message=(
f"Series '{key}' not found. If the folder on disk is "
"still required to be removed, please specify its "
"exact name on the filesystem."
),
) )
# No row, no folder requested — nothing to do
return DeleteSeriesResult(
success=False,
key=key,
name="",
folder_path=None,
deleted_from_database=False,
deleted_folder=False,
database_error=None,
folder_error=None,
message=f"Series '{key}' not found.",
)
series_id = series.id series_id = series.id
series_name = series.name series_name = series.name
folder_path = series.folder folder_path = series.folder
result = DeleteSeriesResult( result = DeleteSeriesResult(
success=True, success=True,
@@ -1710,7 +1884,22 @@ class AnimeService:
message="", message="",
) )
# --- Database deletion --- # --- Filesystem deletion (do FIRST so a failure preserves the DB row) ---
if delete_folder and folder_path:
self._delete_folder_at_path(folder_path, key, result)
# If folder delete was requested but failed, abort before
# removing the DB row so the user can retry.
if not result.deleted_folder and result.folder_error:
result.success = False
self._build_delete_message(result)
logger.warning(
"Delete series aborted - folder delete failed; DB row preserved: "
"key=%s folder_error=%s",
key, result.folder_error,
)
return result
# --- Database deletion (do AFTER folder delete) ---
if delete_database: if delete_database:
try: try:
async with get_db_session() as db: async with get_db_session() as db:
@@ -1738,6 +1927,35 @@ class AnimeService:
except Exception: # pylint: disable=broad-except except Exception: # pylint: disable=broad-except
pass pass
# Evict from in-memory SerieList.keyDict so the next
# GET /api/anime (which reads via SeriesApp.list.GetList())
# does not return the just-deleted series.
try:
list_obj = getattr(self._app, "list", None)
if list_obj is not None:
# Prefer the explicit remove() when available.
if hasattr(list_obj, "remove"):
removed = list_obj.remove(key)
else:
# Fallback: mutate the underlying keyDict dict
# directly (mirrors how add_to_db() writes).
key_dict = getattr(list_obj, "keyDict", None)
removed = (
key_dict is not None
and key_dict.pop(key, None) is not None
)
if removed:
logger.info(
"Evicted series from in-memory cache: key=%s",
key,
)
except Exception as exc: # pylint: disable=broad-except
logger.warning(
"Failed to evict series from in-memory cache: "
"key=%s error=%s",
key, exc,
)
# Broadcast deletion via WebSocket # Broadcast deletion via WebSocket
try: try:
await self._broadcast_series_deleted(key, series_name) await self._broadcast_series_deleted(key, series_name)
@@ -1747,49 +1965,167 @@ class AnimeService:
key, exc, key, exc,
) )
# --- Filesystem deletion --- # Broadcast the broader series_list_changed event so any
if delete_folder and folder_path: # connected client that missed the series_deleted message
# Resolve absolute path and validate it is within base directory # (or whose local state drifted) can re-sync by re-fetching
abs_folder = _os.path.abspath(folder_path) # /api/anime. This is the durable fix for the
base_dir = _os.path.abspath(self._directory) # "deleted but still listed" bug.
try:
if not is_safe_path(base_dir, abs_folder): await self._broadcast_series_list_changed(reason="deleted")
except Exception as exc:
logger.warning( logger.warning(
"Blocked unsafe folder delete attempt: key=%s path=%s base=%s", "Failed to broadcast series_list_changed after delete: "
key, abs_folder, base_dir, "key=%s error=%s",
key, exc,
) )
result.folder_error = (
f"Path '{abs_folder}' is outside the anime directory "
f"'{base_dir}' and will not be deleted."
)
result.success = False
elif not _os.path.isdir(abs_folder):
logger.warning(
"Delete folder skipped - path does not exist: key=%s path=%s",
key, abs_folder,
)
# Not an error; folder might never have existed
else:
try:
logger.info(
"Deleting series folder: key=%s path=%s",
key, abs_folder,
)
shutil.rmtree(abs_folder)
logger.info(
"Deleted series folder: key=%s path=%s",
key, abs_folder,
)
result.deleted_folder = True
except Exception as exc:
logger.error(
"Failed to delete series folder: key=%s path=%s error=%s",
key, abs_folder, str(exc),
)
result.folder_error = str(exc)
result.success = False
# --- Build message --- # --- Build message ---
self._build_delete_message(result)
logger.info(
"Delete series completed: key=%s deleted_db=%s deleted_folder=%s",
key, result.deleted_from_database, result.deleted_folder,
)
return result
def _delete_folder_at_path(self, folder_path, key, result):
"""Resolve ``folder_path`` against the configured anime directory
and attempt to remove it. Updates ``result`` in place.
Resolves relative paths against ``self._directory`` so the safety
check operates on the real intended target (the process's current
working directory is not used as the base; in containers CWD may
differ from the anime directory, e.g. /app vs /data).
"""
import os as _os
import shutil
# Resolve absolute path and validate it is within base directory.
#
# Important: `folder_path` stored in the database is the relative
# folder name (e.g. "Beyblade Burst (2016)"), not an absolute path.
# If we feed a relative path to os.path.abspath() it gets joined
# against the process's current working directory — which may be
# /app inside the container while the anime directory is /data,
# producing e.g. "/app/Beyblade Burst (2016)" and tripping the
# safe-path check below for what is actually a valid deletion.
# Resolve relative paths against the configured anime directory
# so the safety check operates on the real intended target.
from src.server.utils.filesystem import is_safe_path
base_dir = _os.path.abspath(self._directory)
if _os.path.isabs(folder_path):
abs_folder = _os.path.abspath(folder_path)
else:
abs_folder = _os.path.abspath(_os.path.join(base_dir, folder_path))
if not is_safe_path(base_dir, abs_folder):
logger.warning(
"Blocked unsafe folder delete attempt: key=%s path=%s base=%s",
key, abs_folder, base_dir,
)
result.folder_error = (
f"Path '{abs_folder}' is outside the anime directory "
f"'{base_dir}' and will not be deleted."
)
result.success = False
return
if not _os.path.isdir(abs_folder):
logger.warning(
"Delete folder skipped - path does not exist: key=%s path=%s",
key, abs_folder,
)
# Not an error; folder might never have existed
return
try:
logger.info(
"Deleting series folder: key=%s path=%s",
key, abs_folder,
)
shutil.rmtree(abs_folder)
logger.info(
"Deleted series folder: key=%s path=%s",
key, abs_folder,
)
result.deleted_folder = True
except Exception as exc:
logger.error(
"Failed to delete series folder: key=%s path=%s error=%s",
key, abs_folder, str(exc),
)
result.folder_error = str(exc)
result.success = False
def _find_orphan_folder_for_key(self, key: str):
"""Locate a folder under ``self._directory`` that uniquely matches
the given series ``key``.
Used as a recovery path when the DB row is gone but the on-disk
folder still exists (orphaned by a previous partial delete).
Matching strategy: for each immediate subdirectory of the anime
directory, strip a trailing ``(YYYY)`` year suffix if present and
then compare the normalized form (lowercased, non-alphanumerics
removed, key's hyphens treated as separators) against the key.
Returns the folder name (relative to the anime directory) of the
unique match, or ``None`` if zero or multiple folders match.
Returns:
The matching relative folder name, or None when no unique
match exists. Returning None is the safe default — it forces
the caller to surface an explicit error rather than risk
deleting the wrong folder.
"""
import os as _os
import re
if not self._directory or not _os.path.isdir(self._directory):
return None
def _normalize(value: str) -> str:
# Drop an optional trailing "(YYYY)" or "(YYYY)"-with-content
# suffix the user might have added for disambiguation. We only
# strip a single trailing parenthesised group to avoid eating
# legitimate parts of the title.
value = re.sub(r"\s*\([^)]*\)\s*$", "", value or "")
# Lowercase, replace hyphens/underscores with empty so they
# line up with the way the key is constructed.
lowered = value.lower().replace("-", "").replace("_", "")
# Keep only alphanumerics (which preserves CJK characters
# because \w in unicode mode includes them; using explicit
# alphanumerics is safer cross-platform).
return re.sub(r"[^0-9a-z\u00C0-\uFFFF]", "", lowered)
target = _normalize(key)
if not target:
return None
candidates = []
try:
entries = _os.listdir(self._directory)
except OSError:
return None
for entry in entries:
full = _os.path.join(self._directory, entry)
if not _os.path.isdir(full):
continue
if _normalize(entry) == target:
candidates.append(entry)
if len(candidates) == 1:
return candidates[0]
if len(candidates) > 1:
logger.warning(
"Orphan folder recovery: ambiguous match for key=%s "
"found %d candidate folders: %s",
key, len(candidates), candidates,
)
return None
@staticmethod
def _build_delete_message(result) -> None:
"""Assemble the human-readable ``result.message`` from flags/errors."""
parts = [] parts = []
if result.deleted_from_database and not result.database_error: if result.deleted_from_database and not result.database_error:
parts.append("removed from database") parts.append("removed from database")
@@ -1805,12 +2141,6 @@ class AnimeService:
else: else:
result.message = "No action taken." result.message = "No action taken."
logger.info(
"Delete series completed: key=%s deleted_db=%s deleted_folder=%s",
key, result.deleted_from_database, result.deleted_folder,
)
return result
async def _broadcast_series_deleted(self, key: str, name: str) -> None: async def _broadcast_series_deleted(self, key: str, name: str) -> None:
"""Broadcast series_deleted event via WebSocket.""" """Broadcast series_deleted event via WebSocket."""
try: try:
@@ -1828,6 +2158,28 @@ class AnimeService:
key, str(exc), key, str(exc),
) )
async def _broadcast_series_list_changed(self, reason: str = "updated") -> None:
"""Broadcast series_list_changed event via WebSocket.
Fires whenever the membership of the series list changes
(delete, bulk import, rescan completion, …). The front-end
can use this as a hint to re-fetch /api/anime so its local
state cannot drift from the server's in-memory cache.
"""
try:
await self._websocket_service.broadcast_series_list_changed(
reason=reason,
)
logger.info(
"series_list_changed broadcast sent: reason=%s",
reason,
)
except Exception as exc:
logger.warning(
"Failed to broadcast series_list_changed: reason=%s error=%s",
reason, str(exc),
)
def get_anime_service(series_app: SeriesApp) -> AnimeService: def get_anime_service(series_app: SeriesApp) -> AnimeService:
"""Factory used for creating AnimeService with a SeriesApp instance.""" """Factory used for creating AnimeService with a SeriesApp instance."""

View File

@@ -119,6 +119,79 @@ class FolderNamingService:
return await self._execute_rename(series, folder, target_folder) return await self._execute_rename(series, folder, target_folder)
@staticmethod
def _merge_folder_into_target(source: str, target: str) -> dict:
"""Merge a source folder's contents into an existing target folder.
Walks the source tree and moves every file into the matching path under
the target. When a destination file already exists, the source copy is
removed (the target version wins; we don't keep duplicates). When the
source tree is fully consumed, the (now-empty) source directory is
removed.
Both paths must be absolute and ``target`` must already exist on disk.
Returns a summary dict with ``moved`` (file count), ``skipped`` (file
count where target already had a copy), and ``removed_source`` (bool).
Caller is responsible for any DB / cache updates that depend on the
outcome.
"""
if not os.path.isdir(source):
return {"moved": 0, "skipped": 0, "removed_source": False}
if not os.path.isdir(target):
raise ValueError(f"target does not exist: {target}")
moved = 0
skipped = 0
for root, _dirs, files in os.walk(source):
rel_root = os.path.relpath(root, source)
dest_root = (
target if rel_root == "."
else os.path.join(target, rel_root)
)
os.makedirs(dest_root, exist_ok=True)
for name in files:
src_file = os.path.join(root, name)
dest_file = os.path.join(dest_root, name)
if os.path.exists(dest_file):
# Target wins — never overwrite existing content. Remove
# the orphaned source copy so cleanup below can rmdir it.
try:
os.remove(src_file)
except OSError as exc:
logger.warning(
"merge: could not remove duplicate %s: %s",
src_file, exc,
)
skipped += 1
logger.warning(
"merge: skipping %s (target already has %s)",
src_file, dest_file,
)
continue
shutil.move(src_file, dest_file)
moved += 1
# Try to remove the (now empty) source tree. Walk bottom-up so leaf
# directories are removed before their parents.
removed_source = False
for root, dirs, files in os.walk(source, topdown=False):
for d in dirs:
try:
os.rmdir(os.path.join(root, d))
except OSError:
pass
try:
os.rmdir(source)
removed_source = True
except OSError as exc:
logger.warning(
"merge: could not remove source directory %s: %s",
source, exc,
)
return {"moved": moved, "skipped": skipped, "removed_source": removed_source}
async def _execute_rename(self, series, old_folder: str, target_folder: str) -> FolderRenameResult: async def _execute_rename(self, series, old_folder: str, target_folder: str) -> FolderRenameResult:
key = series.key key = series.key
@@ -132,16 +205,72 @@ class FolderNamingService:
if not os.path.isdir(old_path): if not os.path.isdir(old_path):
return FolderRenameResult(key=key, old_folder=old_folder, new_folder=None, success=False, skipped=False, reason="source folder does not exist on disk") return FolderRenameResult(key=key, old_folder=old_folder, new_folder=None, success=False, skipped=False, reason="source folder does not exist on disk")
# If the target already exists, merge source into it instead of bailing.
# A bare folder ('Naruto') sitting next to the year-suffixed one
# ('Naruto (2019)') is how we get a series "added twice". Merging
# makes the rename succeed and removes the orphan folder.
if os.path.isdir(target_path): if os.path.isdir(target_path):
return FolderRenameResult(key=key, old_folder=old_folder, new_folder=None, success=False, skipped=False, reason="target folder already exists on disk") try:
summary = self._merge_folder_into_target(old_path, target_path)
except Exception as exc:
logger.error(
"Failed to merge %s -> %s for %s: %s",
old_folder, target_folder, key, exc,
)
return FolderRenameResult(
key=key, old_folder=old_folder, new_folder=None,
success=False, skipped=False,
reason=f"merge failed: {exc}",
)
logger.info(
"Merged folder %s -> %s for series %s (moved=%d skipped=%d removed_source=%s)",
old_folder, target_folder, key,
summary["moved"], summary["skipped"], summary["removed_source"],
)
# Update in-memory cache (best-effort)
try:
from src.server.utils.dependencies import get_series_app
series_app = get_series_app()
if key in series_app.list.keyDict:
series_app.list.keyDict[key].folder = target_folder
except Exception as exc:
logger.warning("Failed to update in-memory cache for %s: %s", key, exc)
# Update database
async with _get_db_session() as db:
db_series = await AnimeSeriesService.get_by_key(db, key)
if db_series:
await AnimeSeriesService.update(db, series_id=db_series.id, folder=target_folder)
logger.debug("Updated DB folder for %s to %s", key, target_folder)
# If source couldn't be removed (still had unexpected files) the
# state is worse than the original orphan, so surface that as a
# warning in the result while still reporting success.
note = None
if not summary["removed_source"]:
note = (
f"merged (moved={summary['moved']}, skipped={summary['skipped']}) "
f"but source folder could not be removed"
)
elif summary["skipped"]:
note = (
f"merged (moved={summary['moved']}, "
f"kept target copies for {summary['skipped']} file(s))"
)
return FolderRenameResult(
key=key, old_folder=old_folder, new_folder=target_folder,
success=True, skipped=False, reason=note,
)
# Target doesn't exist — plain rename.
try: try:
shutil.move(old_path, target_path) shutil.move(old_path, target_path)
logger.info("Renamed folder %s -> %s for series %s", old_folder, target_folder, key) logger.info("Renamed folder %s -> %s for series %s", old_folder, target_folder, key)
# Update in-memory cache # Update in-memory cache
try: try:
from src.server.SeriesApp import get_series_app from src.server.utils.dependencies import get_series_app
series_app = get_series_app() series_app = get_series_app()
if key in series_app.list.keyDict: if key in series_app.list.keyDict:
series_app.list.keyDict[key].folder = target_folder series_app.list.keyDict[key].folder = target_folder

View File

@@ -689,6 +689,35 @@ class WebSocketService:
key, name, key, name,
) )
async def broadcast_series_list_changed(
self,
reason: str = "updated",
) -> None:
"""Broadcast a series_list_changed event to all connected clients.
Fires whenever the membership of the series list changes
(delete, bulk import, rescan completion, …). Clients use this
as a hint to re-fetch /api/anime so their local state cannot
drift from the server's in-memory cache.
Args:
reason: Short string describing why the list changed
(e.g. "deleted", "imported", "rescanned"). Forwarded
to the client for logging/debugging only.
"""
message = {
"type": "series_list_changed",
"timestamp": datetime.now(timezone.utc).isoformat(),
"data": {
"reason": reason,
},
}
await self._manager.broadcast(message)
logger.info(
"Broadcast series_list_changed reason=%s",
reason,
)
# Singleton instance for application-wide access # Singleton instance for application-wide access
_websocket_service: Optional[WebSocketService] = None _websocket_service: Optional[WebSocketService] = None

View File

@@ -117,9 +117,23 @@ def is_safe_path(base_path: str, target_path: str) -> bool:
Prevents path traversal attacks by ensuring the target path Prevents path traversal attacks by ensuring the target path
is actually within the base path after resolution. is actually within the base path after resolution.
Note on relative paths: a relative ``target_path`` is interpreted
as relative to ``base_path``, *not* to the process's current
working directory. This mirrors how callers use this helper:
they pass a configured base directory and a folder name stored
alongside it (e.g. the series ``folder`` column in the database
holds a relative name like ``"Beyblade Burst (2016)"``, and the
anime directory is configured separately). Without this, a
relative target would be resolved against the process CWD —
which can differ from ``base_path`` (the FastAPI app runs with
CWD=/app while the anime directory is mounted at /data), and
the helper would incorrectly reject the path as outside the
base. Absolute ``target_path`` values are validated against
``base_path`` directly.
Args: Args:
base_path: The base directory that should contain the target base_path: The base directory that should contain the target
target_path: The path to validate target_path: The path to validate (absolute, or relative to base_path)
Returns: Returns:
bool: True if target_path is safely within base_path bool: True if target_path is safely within base_path
@@ -127,18 +141,26 @@ def is_safe_path(base_path: str, target_path: str) -> bool:
Example: Example:
>>> is_safe_path("/anime", "/anime/Attack on Titan") >>> is_safe_path("/anime", "/anime/Attack on Titan")
True True
>>> is_safe_path("/anime", "Attack on Titan") # relative -> /anime/Attack on Titan
True
>>> is_safe_path("/anime", "/anime/../etc/passwd") >>> is_safe_path("/anime", "/anime/../etc/passwd")
False False
""" """
# Resolve to absolute paths # Resolve base to an absolute path
base_resolved = os.path.abspath(base_path) base_resolved = os.path.abspath(base_path)
target_resolved = os.path.abspath(target_path)
# Resolve target relative to the base (not the process CWD) when it is
# supplied as a relative path. Absolute targets are validated as-is.
if os.path.isabs(target_path):
target_resolved = os.path.abspath(target_path)
else:
target_resolved = os.path.abspath(os.path.join(base_resolved, target_path))
# Check that target starts with base (with trailing separator) # Check that target starts with base (with trailing separator)
base_with_sep = base_resolved + os.sep base_with_sep = base_resolved + os.sep
return ( return (
target_resolved == base_resolved or target_resolved == base_resolved
target_resolved.startswith(base_with_sep) or target_resolved.startswith(base_with_sep)
) )

View File

@@ -300,6 +300,9 @@ AniWorld.DeleteModal = (function() {
var result = await response.json(); var result = await response.json();
console.info('[DeleteModal] Delete succeeded:', result); console.info('[DeleteModal] Delete succeeded:', result);
// Capture key before hide() nulls currentKey
var deletedKey = currentKey;
// Show success message based on what was deleted // Show success message based on what was deleted
var msgParts = []; var msgParts = [];
if (result.deleted_from_database) msgParts.push('removed from database'); if (result.deleted_from_database) msgParts.push('removed from database');
@@ -311,11 +314,17 @@ AniWorld.DeleteModal = (function() {
: 'Delete completed.'; : 'Delete completed.';
AniWorld.UI.showToast(successMsg, result.success ? 'success' : 'warning'); AniWorld.UI.showToast(successMsg, result.success ? 'success' : 'warning');
// Close modal and reset submission state together — isSubmitting must
// be cleared before hide(), otherwise hide() bails out (early return
// on the !isSubmitting guard) and the modal stays visible.
isSubmitting = false;
if (confirmBtn) confirmBtn.textContent = 'Delete';
hide(); hide();
// Remove the card from the grid directly // Remove the card from the grid directly
if (AniWorld.SeriesManager && AniWorld.SeriesManager.removeSeries) { if (AniWorld.SeriesManager && AniWorld.SeriesManager.removeSeries) {
AniWorld.SeriesManager.removeSeries(currentKey); AniWorld.SeriesManager.removeSeries(deletedKey);
} }
} catch (err) { } catch (err) {

View File

@@ -253,8 +253,24 @@ AniWorld.SelectionManager = (function() {
console.error('Validation errors:', JSON.stringify(data.detail, null, 2)); console.error('Validation errors:', JSON.stringify(data.detail, null, 2));
} }
// Trust the server's response, not the input count: the
// backend returns success even when zero episodes were
// added (e.g. all duplicates), and the input `episodes`
// array can itself contain duplicates from a stale
// in-memory episodeDict. Counting `data.added_items`
// gives the user an accurate "Added N" toast.
if (response.ok && data.status === 'success') { if (response.ok && data.status === 'success') {
totalEpisodesAdded += episodes.length; const addedThisRequest = Array.isArray(data.added_items)
? data.added_items.length
: 0;
totalEpisodesAdded += addedThisRequest;
if (addedThisRequest === 0 && episodes.length > 0) {
console.warn(
'Queue add returned 0 items for',
key,
'— all episodes may be duplicates of an existing pending entry.'
);
}
} else { } else {
console.error('Failed to add to queue:', data); console.error('Failed to add to queue:', data);
failedSeries.push(key); failedSeries.push(key);

View File

@@ -584,6 +584,17 @@ AniWorld.SeriesManager = (function() {
} }
} }
/**
* Re-fetch the series list from the server. Used as a durable
* backstop when receiving the broader series_list_changed WS event
* so local state cannot drift from the server's in-memory cache.
* @returns {Promise<void>}
*/
function reloadSeries() {
console.info('[SeriesManager] Reloading series from server');
return loadSeries();
}
// Public API // Public API
return { return {
init: init, init: init,
@@ -596,6 +607,7 @@ AniWorld.SeriesManager = (function() {
updateSeriesLoadingStatus: updateSeriesLoadingStatus, updateSeriesLoadingStatus: updateSeriesLoadingStatus,
updateSingleSeries: updateSingleSeries, updateSingleSeries: updateSingleSeries,
updateSeriesKey: updateSeriesKey, updateSeriesKey: updateSeriesKey,
removeSeries: removeSeries removeSeries: removeSeries,
reloadSeries: reloadSeries
}; };
})(); })();

View File

@@ -169,6 +169,17 @@ AniWorld.IndexSocketHandler = (function() {
} }
}); });
// Series list membership changed (delete, bulk import, rescan, …).
// Re-fetch /api/anime so local state cannot drift from the server's
// in-memory cache. Acts as a durable backstop when the more specific
// series_deleted event does not reach the client.
socket.on(WS_EVENTS.SERIES_LIST_CHANGED, function(data) {
console.info('[SocketHandler] Series list changed:', data);
if (AniWorld.SeriesManager && AniWorld.SeriesManager.reloadSeries) {
AniWorld.SeriesManager.reloadSeries();
}
});
// Download events // Download events
socket.on(WS_EVENTS.DOWNLOAD_STARTED, function(data) { socket.on(WS_EVENTS.DOWNLOAD_STARTED, function(data) {
isDownloading = true; isDownloading = true;

View File

@@ -11,6 +11,7 @@
* - loadSeries(key) : fetch settings for a series key * - loadSeries(key) : fetch settings for a series key
* - saveSettings(opts) : PUT settings, opts.applyToNfo / opts.renameDisk * - saveSettings(opts) : PUT settings, opts.applyToNfo / opts.renameDisk
* - regenerateNfo() : POST regenerate-nfo endpoint * - regenerateNfo() : POST regenerate-nfo endpoint
* - viewNfoContent() : GET raw tvshow.nfo XML into the preview <pre>
* - validateField(name, value) : client-side validation, returns error string or null * - validateField(name, value) : client-side validation, returns error string or null
* - populateForm(data) : fill the form from a payload * - populateForm(data) : fill the form from a payload
* - showSaveSuccess(msg) : success toast * - showSaveSuccess(msg) : success toast
@@ -632,6 +633,7 @@ AniWorld.AnimeSettingsManager = (function () {
loadSeries: loadSeries, loadSeries: loadSeries,
saveSettings: saveSettings, saveSettings: saveSettings,
regenerateNfo: regenerateNfo, regenerateNfo: regenerateNfo,
viewNfoContent: viewNfoContent,
validateField: validateField, validateField: validateField,
populateForm: populateForm, populateForm: populateForm,
showSaveSuccess: showSaveSuccess, showSaveSuccess: showSaveSuccess,

View File

@@ -105,6 +105,10 @@ AniWorld.Constants = (function() {
SERIES_UPDATED: 'series_updated', SERIES_UPDATED: 'series_updated',
SERIES_LOADING_UPDATE: 'series_loading_update', SERIES_LOADING_UPDATE: 'series_loading_update',
SERIES_DELETED: 'series_deleted', SERIES_DELETED: 'series_deleted',
// Fires when the membership of the series list changes (delete,
// bulk import, rescan completion, …). Clients should re-fetch
// /api/anime to resync with the server's in-memory cache.
SERIES_LIST_CHANGED: 'series_list_changed',
// Scheduled scan events // Scheduled scan events
SCHEDULED_RESCAN_STARTED: 'scheduled_rescan_started', SCHEDULED_RESCAN_STARTED: 'scheduled_rescan_started',

View File

@@ -4,6 +4,7 @@ Covers the live endpoints in src/server/api/nfo.py:
- GET /api/nfo/{key}/diagnostics - GET /api/nfo/{key}/diagnostics
- POST /api/nfo/{key}/repair - POST /api/nfo/{key}/repair
- GET /api/nfo/{key}/validate - GET /api/nfo/{key}/validate
- GET /api/nfo/{key}/content (re-introduced — used by Anime Settings 'View NFO XML')
- GET /api/nfo/needs-repair - GET /api/nfo/needs-repair
- POST /api/nfo/batch/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, in the codebase — they were replaced by the consolidated diagnostic,
repair, validate, needs-repair, batch/repair endpoints and the new repair, validate, needs-repair, batch/repair endpoints and the new
Anime Settings page (see tests/api/test_anime_settings_endpoints.py). 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 from unittest.mock import AsyncMock, Mock, patch
@@ -19,16 +26,6 @@ import pytest
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
from src.server.fastapi_app import app 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 @pytest.fixture
@@ -38,19 +35,19 @@ async def client():
yield ac yield ac
@pytest.fixture async def _login(client: AsyncClient) -> str:
async def authenticated_client(client): """Log in with the master password configured by conftest and
await client.post( return the bearer token. Sets the ``Authorization`` header on the
"/api/auth/setup", client as a side benefit so the caller can ``await client.get(...)``
json={"master_password": "TestPassword123!"}, immediately."""
)
resp = await client.post( resp = await client.post(
"/api/auth/login", "/api/auth/login",
json={"password": "TestPassword123!"}, json={"password": "TestPass123!"},
) )
assert resp.status_code == 200, resp.text
token = resp.json()["access_token"] token = resp.json()["access_token"]
client.headers.update({"Authorization": f"Bearer {token}"}) client.headers.update({"Authorization": f"Bearer {token}"})
yield client return token
class TestNFOAuthRequirements: class TestNFOAuthRequirements:
@@ -84,6 +81,119 @@ class TestNFOAuthRequirements:
) )
assert resp.status_code in (401, 503) 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 = (
"<?xml version='1.0' encoding='UTF-8'?>\n"
"<tvshow><title>Naruto</title><year>2002</year></tvshow>\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: class TestNFOEndpointModels:
"""Verify the response models use the renamed classes (regression """Verify the response models use the renamed classes (regression

View File

@@ -318,6 +318,121 @@ class TestDeleteModalSeriesManagerIntegration:
assert remove_called_with == [key] assert remove_called_with == [key]
class TestDeleteModalModalCloseAfterSuccess:
"""Regression tests for the bug where the modal stayed visible after a
successful delete because isSubmitting was never reset on the success path.
Source-level tests (the JS is not run in pytest): they assert that the
handleConfirm success branch (a) clears isSubmitting before hide(), and
(b) removes the card via a key captured before hide() nulls currentKey.
"""
@staticmethod
def _read_source():
import os
path = os.path.join(
os.path.dirname(__file__),
'..', '..',
'src', 'server', 'web', 'static', 'js', 'index', 'delete-modal.js'
)
with open(path, 'r') as f:
return f.read()
def test_isSubmitting_reset_on_success_path(self):
"""isSubmitting must be reset to false after a successful delete,
otherwise hide()'s early-return guard keeps the modal visible."""
src = self._read_source()
# Locate the success branch: it begins with "Delete succeeded:" log
success_idx = src.find("Delete succeeded:")
assert success_idx > 0, "Could not find Delete succeeded log line"
# Everything between the success log and the catch block belongs to
# the success path.
catch_idx = src.find("} catch (err)", success_idx)
assert catch_idx > 0, "Could not find catch block after success path"
success_branch = src[success_idx:catch_idx]
# The flag must be reset in this branch...
assert "isSubmitting = false" in success_branch, (
"isSubmitting is never reset on the success path — "
"this is the bug that left the modal visible with 'Deleting...'"
)
# ...BEFORE hide() is called.
reset_pos = success_branch.find("isSubmitting = false")
hide_pos = success_branch.find("hide();")
assert reset_pos > 0 and hide_pos > 0, (
"Could not locate isSubmitting reset or hide() call"
)
assert reset_pos < hide_pos, (
"isSubmitting must be cleared BEFORE hide() — otherwise hide()'s "
"guard (`if (isSubmitting) return`) bails out and the modal stays"
)
def test_modal_hidden_class_added_on_success(self):
"""After the success-path cleanup, hide() must run and apply the
'hidden' class. We verify by checking hide() is reached after the
isSubmitting reset (covered above) and that the reset precedes the
removal of the card from the grid."""
src = self._read_source()
success_idx = src.find("Delete succeeded:")
catch_idx = src.find("} catch (err)", success_idx)
success_branch = src[success_idx:catch_idx]
reset_pos = success_branch.find("isSubmitting = false")
hide_pos = success_branch.find("hide();")
remove_pos = success_branch.find("removeSeries(")
assert 0 < reset_pos < hide_pos < remove_pos, (
"Order on success path must be: "
"isSubmitting reset -> hide() -> removeSeries()"
)
def test_removeSeries_uses_captured_key_not_live_currentKey(self):
"""hide() nulls currentKey on line ~211. If removeSeries reads the
live currentKey AFTER hide(), it gets null and silently no-ops. The
fix captures the key into a local before hide() runs."""
src = self._read_source()
success_idx = src.find("Delete succeeded:")
catch_idx = src.find("} catch (err)", success_idx)
success_branch = src[success_idx:catch_idx]
# There must be a local capture of the key before hide().
assert "var deletedKey = currentKey;" in success_branch, (
"Success path must capture currentKey into a local before "
"hide() nulls it — otherwise removeSeries(currentKey) would be "
"a silent no-op."
)
# The removeSeries call must reference the captured local, not
# currentKey directly.
capture_pos = success_branch.find("var deletedKey = currentKey;")
remove_pos = success_branch.find("removeSeries(deletedKey)")
assert capture_pos > 0 and remove_pos > 0, (
"removeSeries must be called with the captured deletedKey"
)
assert capture_pos < remove_pos, (
"Capture must happen BEFORE removeSeries reads it"
)
def test_confirm_button_text_reset_on_success(self):
"""The button text is changed to 'Deleting...' during submit and must
be reverted to 'Delete' so the modal is in a clean state if reopened."""
src = self._read_source()
success_idx = src.find("Delete succeeded:")
catch_idx = src.find("} catch (err)", success_idx)
success_branch = src[success_idx:catch_idx]
assert "confirmBtn.textContent = 'Delete'" in success_branch, (
"Confirm button text must be reset to 'Delete' on the success path"
)
class TestDeleteModalConstants: class TestDeleteModalConstants:
"""Tests for SERIES_DELETED WebSocket event constant.""" """Tests for SERIES_DELETED WebSocket event constant."""

View File

@@ -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: '<tvshow><title>A</title></tvshow>',
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 <pre> and unhides it',
async () => {
mockFetchSequence([{
status: 200,
body: {
key: 'a',
folder: 'A',
content: '<tvshow><title>A</title></tvshow>',
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(
'<tvshow><title>A</title></tvshow>'
);
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() // validateField()
// ------------------------------------------------------------------- // -------------------------------------------------------------------
@@ -493,6 +570,7 @@ describe('AnimeSettingsManager', () => {
expect(typeof manager.loadSeries).toBe('function'); expect(typeof manager.loadSeries).toBe('function');
expect(typeof manager.saveSettings).toBe('function'); expect(typeof manager.saveSettings).toBe('function');
expect(typeof manager.regenerateNfo).toBe('function'); expect(typeof manager.regenerateNfo).toBe('function');
expect(typeof manager.viewNfoContent).toBe('function');
expect(typeof manager.validateField).toBe('function'); expect(typeof manager.validateField).toBe('function');
expect(typeof manager.populateForm).toBe('function'); expect(typeof manager.populateForm).toBe('function');
expect(typeof manager.showSaveSuccess).toBe('function'); expect(typeof manager.showSaveSuccess).toBe('function');

View File

@@ -966,6 +966,121 @@ class TestSaveAndLoadDB:
mock_create.assert_called_once() mock_create.assert_called_once()
assert mock_ep_create.call_count == 2 assert mock_ep_create.call_count == 2
@pytest.mark.asyncio
async def test_update_series_deletes_downloaded_episodes_when_no_longer_missing(
self, anime_service
):
"""Regression: a finished download marks the Episode row with
is_downloaded=True. When a later rescan confirms the file is on
disk (i.e. the episode is no longer in the missing set), the
DB row should be deleted so it stops appearing in queries.
Bug shape: previously the ``downloaded_set`` guard in
``_update_series_in_db`` preserved the row forever, so the DB
kept stale ``is_downloaded=True`` entries that the user could
see in the database but not in any UI.
"""
mock_serie = MagicMock()
mock_serie.key = "the-100-girlfriends"
mock_serie.name = "The 100 Girlfriends"
mock_serie.site = "aniworld.to"
mock_serie.folder = "The 100 Girlfriends (2023)"
# Scanner reports no missing episodes for this series —
# every file is on disk.
mock_serie.episodeDict = {}
existing = MagicMock()
existing.id = 1
existing.folder = "The 100 Girlfriends (2023)"
# DB currently has one row for S03E08 marked as downloaded
# (the result of an earlier successful download). The
# scanner confirms the file is on disk, so the episode is
# no longer missing.
existing_eps = [
MagicMock(
id=10, season=3, episode_number=8, is_downloaded=True,
),
]
mock_session = AsyncMock()
with patch(
"src.server.database.service.EpisodeService.get_by_series",
new_callable=AsyncMock,
return_value=existing_eps,
), patch(
"src.server.database.service.EpisodeService.delete",
new_callable=AsyncMock,
) as mock_delete:
await anime_service._update_series_in_db(
mock_serie, existing, mock_session
)
# The downloaded episode (S03E08) MUST be deleted — the file
# is on disk and the scanner does not report it as missing.
# EpisodeService.delete is (db, episode_id) — episode_id is
# the second positional arg.
deleted_ids = [
call.args[1] for call in mock_delete.call_args_list
]
assert deleted_ids == [10], (
f"Expected S03E08 (id=10) to be the only deleted row; "
f"got delete calls for {deleted_ids}"
)
@pytest.mark.asyncio
async def test_update_series_keeps_still_missing_episodes(
self, anime_service
):
"""A still-missing episode (is_downloaded=False, in scanner's
missing set) must NOT be deleted by _update_series_in_db.
Sanity-check sibling to the downloaded-episode regression
test, ensuring the fix does not over-reach.
"""
mock_serie = MagicMock()
mock_serie.key = "naruto"
mock_serie.name = "Naruto"
mock_serie.site = "aniworld.to"
mock_serie.folder = "Naruto"
# Scanner reports S01E07 still missing.
mock_serie.episodeDict = {1: [7]}
existing = MagicMock()
existing.id = 1
existing.folder = "Naruto"
existing_eps = [
MagicMock(
id=20, season=1, episode_number=7, is_downloaded=False,
),
]
mock_session = AsyncMock()
with patch(
"src.server.database.service.EpisodeService.get_by_series",
new_callable=AsyncMock,
return_value=existing_eps,
), patch(
"src.server.database.service.EpisodeService.delete",
new_callable=AsyncMock,
) as mock_delete, patch(
"src.server.database.service.EpisodeService.create",
new_callable=AsyncMock,
):
await anime_service._update_series_in_db(
mock_serie, existing, mock_session
)
# S01E07 is still missing per the scanner — it must NOT be
# deleted. (No new episode needs to be created either — it
# is already in the DB.)
assert mock_delete.call_count == 0, (
f"Still-missing episode S01E07 must not be deleted; "
f"got delete calls: {mock_delete.call_args_list}"
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_save_scan_results_updates_existing( async def test_save_scan_results_updates_existing(
self, anime_service self, anime_service

View File

@@ -0,0 +1,343 @@
"""Unit tests for the ``clean_duplicate_episodes`` CLI.
Exercises the core logic (find / delete) against an in-memory
SQLite engine so the test is hermetic. The CLI module reads the
global async session factory from ``src.server.database.connection``
— the test patches that factory with an in-memory engine.
"""
from __future__ import annotations
from typing import List
import pytest
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.pool import StaticPool
from src.cli import clean_duplicate_episodes as cli
from src.server.database import connection as conn_module
from src.server.database.base import Base
from src.server.database.models import AnimeSeries, Episode
@pytest.fixture
async def in_memory_engine():
"""Provide an in-memory async SQLite engine with the schema
already created, and patch the connection module's session
factory to use it for the duration of the test.
The CLI test suite simulates the *pre-migration* state — a DB
that predates the UNIQUE constraint on
``(series_id, season, episode_number)`` and has accumulated
duplicate rows from historical scans. To create that state, we
build the schema for everything except ``episodes``, then
recreate ``episodes`` with raw DDL that omits the
``uq_episode_per_series_season`` constraint. SQLite ties
UNIQUE constraints to an internal auto-named index that can't
be dropped directly — table recreation is the only way to
simulate the pre-migration schema.
"""
engine: AsyncEngine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
echo=False,
poolclass=StaticPool,
)
async with engine.begin() as conn:
# Create everything except episodes.
await conn.run_sync(
lambda sync_conn: [
t.create(sync_conn)
for t in Base.metadata.sorted_tables
if t.name != "episodes"
]
)
# Recreate episodes without the UNIQUE constraint.
await conn.execute(
text(
"""
CREATE TABLE episodes (
id INTEGER NOT NULL PRIMARY KEY,
series_id INTEGER NOT NULL
REFERENCES anime_series(id) ON DELETE CASCADE,
season INTEGER NOT NULL,
episode_number INTEGER NOT NULL,
title VARCHAR(500),
file_path VARCHAR(1000),
is_downloaded BOOLEAN NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
)
"""
)
)
await conn.execute(
text("CREATE INDEX ix_episodes_series_id ON episodes (series_id)")
)
factory = async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False,
)
# Monkey-patch the global session factory.
original_factory = conn_module._session_factory
conn_module._session_factory = factory
try:
yield engine
finally:
conn_module._session_factory = original_factory
await engine.dispose()
async def _add_series(session: AsyncSession, key: str) -> int:
series = AnimeSeries(
key=key,
name=key.replace("-", " ").title(),
site="https://aniworld.to",
folder=f"/anime/{key}",
)
session.add(series)
await session.commit()
await session.refresh(series)
return int(series.id)
async def _add_episode(
session: AsyncSession,
series_id: int,
season: int,
ep_num: int,
is_downloaded: bool = False,
title: str | None = None,
) -> int:
"""Insert an Episode row. The test fixture drops the UNIQUE
constraint on the ``episodes`` table, so duplicate inserts
succeed — the cleanup tool can then find them, mirroring the
pre-migration pathology it's meant to repair."""
ep = Episode(
series_id=series_id,
season=season,
episode_number=ep_num,
is_downloaded=is_downloaded,
title=title,
)
session.add(ep)
await session.commit()
await session.refresh(ep)
return int(ep.id)
def _tup(d):
"""Convert a SQLAlchemy row to the (sid, season, ep_num, count,
keep_id, max_id) tuple shape the CLI uses."""
return (
int(d.series_id),
int(d.season),
int(d.episode_number),
int(d.row_count),
int(d.keep_id),
int(d.max_id),
)
@pytest.mark.asyncio
async def test_find_returns_empty_when_no_duplicates(in_memory_engine):
"""With one row per (series, season, ep_num), nothing is found."""
async with AsyncSession(in_memory_engine) as session:
sid = await _add_series(session, "no-dupes")
await _add_episode(session, sid, 1, 1)
await _add_episode(session, sid, 1, 2)
await _add_episode(session, sid, 2, 1)
duplicates = await cli.find_duplicate_episodes()
assert duplicates == []
@pytest.mark.asyncio
async def test_find_groups_duplicates_per_tuple(in_memory_engine):
"""Three copies of (S1, E2) collapse to one DuplicateTuple with
count=3 and keep_id = the lowest id."""
async with AsyncSession(in_memory_engine) as session:
sid = await _add_series(session, "triple")
await _add_episode(session, sid, 1, 1)
# Three rows for (S1, E2): the first via the normal path,
# the next two via raw SQL to bypass the UNIQUE constraint.
# Mirrors the pre-migration pathology the cleanup tool exists
# to repair.
e2 = await _add_episode(session, sid, 1, 2)
e3 = await _add_episode(session, sid, 1, 2)
e4 = await _add_episode(session, sid, 1, 2)
await _add_episode(session, sid, 1, 3)
duplicates = await cli.find_duplicate_episodes()
# Only one duplicate tuple (S1, E2); (S1, E1) and (S1, E3) are
# unique and must not appear.
assert len(duplicates) == 1
d = duplicates[0]
assert (d[0], d[1], d[2]) == (sid, 1, 2)
assert d[3] == 3
assert d[4] == e2 # lowest id wins
assert d[5] == e4 # max id (for the report)
@pytest.mark.asyncio
async def test_delete_keeps_lowest_id_per_tuple(in_memory_engine):
"""The DELETE call preserves the lowest-id row per duplicate
tuple and removes the rest."""
async with AsyncSession(in_memory_engine) as session:
sid = await _add_series(session, "keep-lowest")
e1 = await _add_episode(session, sid, 1, 1) # unique
e2 = await _add_episode(session, sid, 1, 2) # lowest of dupes
await _add_episode(
session, sid, 1, 2
) # e3 - duplicate of e2
await _add_episode(
session, sid, 1, 2
) # e4 - duplicate of e2
e5 = await _add_episode(session, sid, 1, 3) # unique
duplicates = await cli.find_duplicate_episodes()
assert len(duplicates) == 1
deleted = await cli.delete_duplicate_episodes(duplicates)
assert deleted == 2 # two duplicate (S1, E2) rows removed
# Verify the post-cleanup state directly via SQLAlchemy.
async with AsyncSession(in_memory_engine) as session:
rows = (
await session.execute(
select(Episode).order_by(Episode.id)
)
).scalars().all()
remaining_ids = [int(r.id) for r in rows]
# e1, e2 (lowest of the dup group), and e5 survive; e3, e4
# were the duplicates and are gone.
assert remaining_ids == [e1, e2, e5]
@pytest.mark.asyncio
async def test_delete_keeps_lowest_even_with_title_metadata(in_memory_engine):
"""When a higher-id row has populated metadata but the lowest-id
row is empty, the lowest-id row is still kept — that's the
documented behavior (oldest insert wins)."""
async with AsyncSession(in_memory_engine) as session:
sid = await _add_series(session, "metadata")
e1 = await _add_episode(session, sid, 1, 1) # no title
e2 = await _add_episode(
session,
sid,
1,
1,
title="Better Episode 1",
)
duplicates = await cli.find_duplicate_episodes()
assert len(duplicates) == 1
await cli.delete_duplicate_episodes(duplicates)
async with AsyncSession(in_memory_engine) as session:
rows = (
await session.execute(select(Episode))
).scalars().all()
assert len(rows) == 1
assert int(rows[0].id) == e1
assert rows[0].title is None
@pytest.mark.asyncio
async def test_full_workflow_is_idempotent(in_memory_engine):
"""Running find -> delete -> find yields nothing the second
time. Mirrors the production usage pattern."""
async with AsyncSession(in_memory_engine) as session:
sid = await _add_series(session, "idempotent")
for ep_num in (1, 2, 3):
await _add_episode(session, sid, 1, ep_num)
await _add_episode(
session, sid, 1, ep_num
)
await _add_episode(
session, sid, 1, ep_num
)
first = await cli.find_duplicate_episodes()
assert len(first) == 3
await cli.delete_duplicate_episodes(first)
second = await cli.find_duplicate_episodes()
assert second == []
@pytest.mark.asyncio
async def test_max_series_filter_limits_report(in_memory_engine):
"""``--max-series`` caps which series appear in the report but
the underlying find still returns every duplicate for those
series."""
async with AsyncSession(in_memory_engine) as session:
s1 = await _add_series(session, "alpha")
s2 = await _add_series(session, "beta")
s3 = await _add_series(session, "gamma")
for sid in (s1, s2, s3):
for ep_num in (1, 2):
await _add_episode(session, sid, 1, ep_num)
await _add_episode(
session, sid, 1, ep_num
)
# Without filter: all three series have duplicates.
full = await cli.find_duplicate_episodes()
series_in_full = {t[0] for t in full}
assert series_in_full == {s1, s2, s3}
# With --max-series=2: only two series appear.
limited = await cli.find_duplicate_episodes(max_series=2)
series_in_limited = {t[0] for t in limited}
assert len(series_in_limited) == 2
@pytest.mark.asyncio
async def test_format_report_human_readable(in_memory_engine):
"""The report includes the series name, season, episode, count
and keep_id for each duplicate tuple."""
async with AsyncSession(in_memory_engine) as session:
sid = await _add_series(session, "attack-on-titan")
await _add_episode(session, sid, 1, 1)
await _add_episode(session, sid, 1, 1)
duplicates = await cli.find_duplicate_episodes()
names = await cli._load_series_names([sid])
report = cli.format_report(duplicates, names)
assert "Attack On Titan" in report or "Attack-on-Titan" in report
# The row's keep_id column should appear, plus a count of "2".
assert "2" in report
assert "found" in report.lower()
@pytest.mark.asyncio
async def test_format_report_handles_no_duplicates():
"""Empty input yields a friendly 'nothing to do' message."""
report = cli.format_report([], {})
assert "no duplicate" in report.lower()
def test_argparse_defaults():
"""The CLI defaults to dry-run when --apply is not given."""
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--apply", action="store_true")
parser.add_argument("--max-series", type=int, default=None)
# Simulate argv without --apply.
args = parser.parse_args([])
assert args.apply is False
assert args.max_series is None

View File

@@ -308,6 +308,185 @@ class TestAnimeSeries:
assert len(with_tmdb) == 2 assert len(with_tmdb) == 2
class TestEpisodeDictDedup:
"""Regression tests for the ``AnimeSeries.episodeDict`` dedup.
The ``episodes`` table has a UNIQUE constraint on
``(series_id, season, episode_number)`` (added as the schema-level
prevention in commit f75d591..), so duplicate rows cannot be
created via normal write paths. The property's defensive dedup
still matters for two reasons:
1. DBs that predate the UNIQUE constraint may have stale duplicate
rows from historical scans (visible in the user's backup DB
before clean_duplicate_episodes was run).
2. ``_episode_dict_cache`` is populated directly by scanners and
loaders, which can carry duplicates from their internal logic
(e.g. ``scan_single_series`` previously extended the dict on
every rescan).
These tests cover both paths. The class uses its own engine
fixture that drops the UNIQUE constraint after schema creation,
so the duplicate-row tests can set up the pre-migration state
the property's dedup is meant to defend against.
"""
@pytest.fixture
def legacy_engine(self):
"""In-memory SQLite engine without the UNIQUE constraint
on episodes — simulates a pre-migration DB.
SQLite ties UNIQUE constraints to an internal auto-named
index that can't be dropped directly, so we rebuild the
episodes table with raw DDL that omits the
``uq_episode_per_series_season`` constraint. The rest of
the schema comes from ``Base.metadata.create_all``.
"""
from sqlalchemy import text
engine = create_engine("sqlite:///:memory:", echo=False)
# Create everything except the episodes table.
for table in Base.metadata.sorted_tables:
if table.name != "episodes":
table.create(engine)
# Recreate episodes without the UNIQUE constraint.
with engine.begin() as conn:
conn.execute(
text(
"""
CREATE TABLE episodes (
id INTEGER NOT NULL PRIMARY KEY,
series_id INTEGER NOT NULL
REFERENCES anime_series(id) ON DELETE CASCADE,
season INTEGER NOT NULL,
episode_number INTEGER NOT NULL,
title VARCHAR(500),
file_path VARCHAR(1000),
is_downloaded BOOLEAN NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
)
"""
)
)
conn.execute(
text("CREATE INDEX ix_episodes_series_id ON episodes (series_id)")
)
SessionLocal = sessionmaker(bind=engine)
session = SessionLocal()
yield session
session.close()
engine.dispose()
def _make_series(self, session: Session, key: str) -> AnimeSeries:
series = AnimeSeries(
key=key,
name=key.replace("-", " ").title(),
site="https://aniworld.to",
folder=f"/anime/{key}",
)
session.add(series)
session.commit()
return series
def test_episodeDict_dedupes_duplicate_relationship_rows(
self, legacy_engine: Session
):
"""Duplicate Episode rows for the same series — including
ones that pre-date the UNIQUE constraint — must not
duplicate the entries in ``episodeDict``."""
session = legacy_engine
series = self._make_series(session, "dedup-rel")
# Three duplicate rows for (S1, E3) — exactly the pattern
# the scanner accumulated across repeated rescans in the
# pre-migration era. With the constraint dropped in the
# legacy_engine fixture, these inserts succeed.
for _ in range(3):
session.add(
Episode(series_id=series.id, season=1, episode_number=3)
)
# One row each for the surrounding unique episodes.
for ep in (1, 2, 4):
session.add(
Episode(series_id=series.id, season=1, episode_number=ep)
)
session.commit()
result = series.episodeDict
# Order depends on SQLAlchemy row order, which is not strictly
# insertion order — only the *set* of episodes matters here.
assert set(result.keys()) == {1}
assert set(result[1]) == {1, 2, 3, 4}
# Defensive: no season has duplicate episode numbers.
for season, eps in result.items():
assert len(eps) == len(set(eps)), (
f"season {season} has duplicate episode numbers: {eps}"
)
def test_episodeDict_excludes_downloaded_episodes(
self, db_session: Session
):
"""is_downloaded rows must still be filtered out."""
series = self._make_series(db_session, "dedup-downloaded")
db_session.add(
Episode(series_id=series.id, season=1, episode_number=1)
)
# Downloaded row at (S1, E2): must be filtered out.
db_session.add(
Episode(
series_id=series.id,
season=1,
episode_number=2,
is_downloaded=True,
)
)
db_session.commit()
assert series.episodeDict == {1: [1]}
def test_episodeDict_dedupes_cached_value(self, db_session: Session):
"""The legacy ``_episode_dict_cache`` path (set directly by
scanners and loaders) must also dedupe, since loaders can
populate the cache with duplicated data — historically
``scan_single_series`` extended the dict on every rescan."""
series = self._make_series(db_session, "dedup-cache")
# No episodes in the DB at all — the property will fall back
# to the cache.
series._episode_dict_cache = {1: [3, 3, 3, 4, 4]}
assert series.episodeDict == {1: [3, 4]}
def test_episodeDict_preserves_unique_entries_across_seasons(
self, db_session: Session
):
"""Dedup must be per-(season, episode_number), not
per-episode_number alone — same ep number in different
seasons is legitimate and must be preserved."""
series = self._make_series(db_session, "multi-season")
for season, ep in [(1, 1), (1, 2), (2, 1), (2, 2)]:
db_session.add(
Episode(
series_id=series.id,
season=season,
episode_number=ep,
)
)
db_session.commit()
result = series.episodeDict
# Order is not guaranteed across SQLAlchemy relationships;
# compare as sets.
assert set(result.keys()) == {1, 2}
assert set(result[1]) == {1, 2}
assert set(result[2]) == {1, 2}
class TestEpisode: class TestEpisode:
"""Test cases for Episode model.""" """Test cases for Episode model."""

View File

@@ -1,6 +1,7 @@
"""Unit tests for AnimeService.delete_series().""" """Unit tests for AnimeService.delete_series()."""
from __future__ import annotations from __future__ import annotations
import os
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
@@ -328,6 +329,69 @@ class TestDeleteSeriesService:
assert result.folder_error is not None assert result.folder_error is not None
assert "outside" in result.folder_error.lower() assert "outside" in result.folder_error.lower()
@pytest.mark.asyncio
async def test_delete_series_relative_folder_with_different_cwd(
self, anime_service, tmp_path
):
"""Regression: delete_series must work when the stored folder is relative
and the process CWD differs from directory_to_search.
In the container the FastAPI app runs with CWD=/app while the anime
directory is /data. The DB stores the relative folder name (e.g.
"Beyblade Burst (2016)"). The old code called
``os.path.abspath(folder)`` which joined against CWD=/app and
produced "/app/Beyblade Burst (2016)", which was then rejected as
outside the /data base. The fix resolves relative paths against
the configured anime directory instead.
"""
safe_base = tmp_path / "data"
safe_base.mkdir()
series_folder = safe_base / "Beyblade Burst (2016)"
series_folder.mkdir()
anime_service._directory = str(safe_base)
mock_session = AsyncMock()
mock_ctx = _make_db_ctx(mock_session)
# Stored folder is RELATIVE (matches what's actually in the DB)
mock_series = MagicMock()
mock_series.key = "beyblade-burst"
mock_series.name = "Beyblade Burst"
mock_series.folder = "Beyblade Burst (2016)"
mock_series.id = 336
# Simulate process CWD differing from anime dir (container case:
# CWD=/app while anime dir is /data). Use "/" as a stable, always-
# existing CWD distinct from tmp_path.
old_cwd = os.getcwd()
try:
os.chdir("/")
with patch(
"src.server.database.connection.get_db_session",
return_value=mock_ctx,
), patch(
"src.server.database.service.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=mock_series,
):
result = await anime_service.delete_series(
key="beyblade-burst",
delete_database=False,
delete_folder=True,
)
finally:
os.chdir(old_cwd)
# Folder MUST be deleted successfully
assert result.deleted_folder is True, (
f"folder delete failed: success={result.success} "
f"folder_error={result.folder_error!r}"
)
assert result.folder_error is None
assert result.success is True
assert not series_folder.exists()
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Error handling # Error handling
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -517,3 +581,295 @@ class TestDeleteSeriesService:
key="test-key", key="test-key",
name="Test Series", name="Test Series",
) )
# ------------------------------------------------------------------
# Deletion order: filesystem first, database second
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_delete_series_db_preserved_when_folder_fails(
self, anime_service, tmp_path
):
"""When both flags are True and folder delete fails, the DB row
is preserved so the user can retry after fixing the underlying issue.
Previously, the database row was deleted first and the folder
second. If the folder delete failed (e.g. the old CWD-relative-path
bug, or any future permission/path error), the row was already
gone — leaving an orphan folder on disk that could not be cleaned
up through the normal delete flow.
"""
# Folder exists but we'll force shutil.rmtree to fail
series_folder = tmp_path / "Test Series"
series_folder.mkdir()
anime_service._directory = str(tmp_path)
mock_session = AsyncMock()
mock_ctx = _make_db_ctx(mock_session)
mock_series = MagicMock()
mock_series.key = "test-key"
mock_series.name = "Test Series"
mock_series.folder = "Test Series"
mock_series.id = 7
db_delete_mock = AsyncMock(return_value=True)
with patch(
"src.server.database.connection.get_db_session",
return_value=mock_ctx,
), patch(
"src.server.database.service.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=mock_series,
), patch(
"src.server.database.service.AnimeSeriesService.delete",
new_callable=AsyncMock,
side_effect=db_delete_mock,
), patch(
"shutil.rmtree",
side_effect=OSError("Permission denied"),
):
result = await anime_service.delete_series(
key="test-key",
delete_database=True,
delete_folder=True,
)
# Folder delete failed → DB row MUST be preserved
assert result.deleted_folder is False
assert result.folder_error is not None
assert result.deleted_from_database is False, (
"DB row was deleted despite folder delete failure — "
"user would lose ability to retry the delete"
)
db_delete_mock.assert_not_called()
assert result.success is False
# ------------------------------------------------------------------
# In-memory keyDict cache eviction
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_delete_series_evicts_in_memory_cache(
self, anime_service, mock_series_app
):
"""After a successful DB delete, the in-memory SerieList.keyDict
entry for that series must be removed.
The /api/anime list endpoint reads from SeriesApp.list.keyDict
via list_series_with_filters(). If the cache is not pruned, the
deleted series keeps appearing in the listing on every page
reload — exactly the Beyblade Burst bug.
"""
from src.server.database.SerieList import SerieList
mock_session = AsyncMock()
mock_ctx = _make_db_ctx(mock_session)
mock_series = MagicMock()
mock_series.key = "beyblade-burst-rise"
mock_series.name = "Beyblade Burst Rise"
mock_series.folder = "Beyblade Burst Rise (2016)"
mock_series.id = 487
# Use a real SerieList instance — the same type the production
# code mutates — so the eviction actually exercises the real
# remove() method (a MagicMock would just return more mocks).
real_list = SerieList(str(mock_series_app.directory_to_search))
real_list.keyDict["beyblade-burst-rise"] = mock_series
mock_series_app.list = real_list
anime_service._websocket_service = MagicMock()
anime_service._websocket_service.broadcast_series_deleted = AsyncMock()
anime_service._websocket_service.broadcast_series_list_changed = AsyncMock()
with patch(
"src.server.database.connection.get_db_session",
return_value=mock_ctx,
), patch(
"src.server.database.service.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=mock_series,
), patch(
"src.server.database.service.AnimeSeriesService.delete",
new_callable=AsyncMock,
return_value=True,
):
result = await anime_service.delete_series(
key="beyblade-burst-rise",
delete_database=True,
delete_folder=False,
)
assert result.success is True
assert "beyblade-burst-rise" not in real_list.keyDict, (
"In-memory cache still contains the deleted series — "
"/api/anime will keep returning it after a page reload"
)
@pytest.mark.asyncio
async def test_delete_series_broadcasts_series_list_changed(
self, anime_service, mock_series_app
):
"""A stronger ``series_list_changed`` broadcast fires after delete
so any connected client can re-sync without relying on the more
specific ``series_deleted`` event reaching them.
"""
mock_session = AsyncMock()
mock_ctx = _make_db_ctx(mock_session)
mock_series = MagicMock()
mock_series.key = "test-key"
mock_series.name = "Test Series"
mock_series.folder = "Test Series (2023)"
mock_series.id = 1
mock_series_app.list.keyDict = {"test-key": mock_series}
anime_service._websocket_service = MagicMock()
anime_service._websocket_service.broadcast_series_deleted = AsyncMock()
anime_service._websocket_service.broadcast_series_list_changed = AsyncMock()
with patch(
"src.server.database.connection.get_db_session",
return_value=mock_ctx,
), patch(
"src.server.database.service.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=mock_series,
), patch(
"src.server.database.service.AnimeSeriesService.delete",
new_callable=AsyncMock,
return_value=True,
):
await anime_service.delete_series(
key="test-key",
delete_database=True,
delete_folder=False,
)
anime_service._websocket_service.broadcast_series_list_changed.assert_called_once()
# ------------------------------------------------------------------
# Orphan folder recovery (DB row gone, folder still on disk)
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_delete_series_orphan_folder_recovery(self, anime_service, tmp_path):
"""If the DB row is gone but a matching folder is still on disk,
``delete_folder=True`` removes the orphan folder.
Reproduces the Beyblade Burst scenario: previous delete attempt
removed the DB row (delete_database=True) but the folder delete
silently failed (path math bug). Retrying with delete_folder=True
should clean up the orphan via a key-based folder scan.
"""
# Simulate the on-disk anime directory
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
orphan = anime_dir / "Beyblade Burst (2016)"
orphan.mkdir()
(orphan / "episode.mp4").write_text("x")
anime_service._directory = str(anime_dir)
mock_session = AsyncMock()
mock_ctx = _make_db_ctx(mock_session)
# DB lookup returns None (row already gone)
with patch(
"src.server.database.connection.get_db_session",
return_value=mock_ctx,
), patch(
"src.server.database.service.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=None,
):
result = await anime_service.delete_series(
key="beyblade-burst",
delete_database=False,
delete_folder=True,
)
# Orphan folder recovered via key-match scan
assert result.deleted_folder is True, (
f"orphan recovery failed: success={result.success} "
f"folder_error={result.folder_error!r}"
)
assert result.folder_error is None
assert not orphan.exists()
@pytest.mark.asyncio
async def test_delete_series_orphan_folder_no_match(
self, anime_service, tmp_path
):
"""If DB row is gone and no folder matches the key, return a
clear error rather than silently succeeding.
"""
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
# Some unrelated folder that does NOT match
(anime_dir / "Different Show (2020)").mkdir()
anime_service._directory = str(anime_dir)
mock_session = AsyncMock()
mock_ctx = _make_db_ctx(mock_session)
with patch(
"src.server.database.connection.get_db_session",
return_value=mock_ctx,
), patch(
"src.server.database.service.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=None,
):
result = await anime_service.delete_series(
key="nonexistent-key",
delete_database=False,
delete_folder=True,
)
assert result.deleted_folder is False
assert result.folder_error is not None
assert "no folder matching" in result.folder_error.lower()
# Unrelated folder untouched
assert (anime_dir / "Different Show (2020)").exists()
@pytest.mark.asyncio
async def test_delete_series_orphan_folder_ambiguous(
self, anime_service, tmp_path
):
"""If multiple folders match the same normalized key, refuse to
delete any of them (safe default).
"""
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
(anime_dir / "Beyblade Burst (2016)").mkdir()
(anime_dir / "Beyblade Burst (2019)").mkdir() # also normalizes to "beybladeburst"
anime_service._directory = str(anime_dir)
mock_session = AsyncMock()
mock_ctx = _make_db_ctx(mock_session)
with patch(
"src.server.database.connection.get_db_session",
return_value=mock_ctx,
), patch(
"src.server.database.service.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=None,
):
result = await anime_service.delete_series(
key="beyblade-burst",
delete_database=False,
delete_folder=True,
)
# Ambiguous → refuse
assert result.deleted_folder is False
assert result.folder_error is not None
assert "no folder matching" in result.folder_error.lower()
# Neither folder deleted
assert (anime_dir / "Beyblade Burst (2016)").exists()
assert (anime_dir / "Beyblade Burst (2019)").exists()

View File

@@ -213,6 +213,34 @@ class TestIsSafePath:
"/anime/Attack on Titan/Season 1/Episode 1" "/anime/Attack on Titan/Season 1/Episode 1"
) )
def test_relative_target_resolved_against_base(self):
"""Relative targets resolve against the base, not the process CWD.
Regression test: previously `os.path.abspath(target_path)` would
join a relative target against the process's current working
directory. When the CWD differed from `base_path` (e.g. the
FastAPI app running with CWD=/app while the anime directory is
/data), a relative folder name like "Beyblade Burst (2016)"
would be resolved to "/app/Beyblade Burst (2016)" and
incorrectly rejected as outside the base. The helper now
treats a relative target as relative to `base_path`.
"""
with tempfile.TemporaryDirectory() as tmpdir:
base = os.path.abspath(tmpdir)
# Simulate a process CWD different from base
old_cwd = os.getcwd()
try:
os.chdir("/")
# Relative target inside base should be safe
assert is_safe_path(base, "Beyblade Burst (2016)")
# Nested relative target should also be safe
assert is_safe_path(base, "Beyblade Burst (2016)/Season 1")
# Relative traversal (../) must still be rejected even
# when resolved against the base
assert not is_safe_path(base, "../etc/passwd")
finally:
os.chdir(old_cwd)
class TestCreateSafeFolder: class TestCreateSafeFolder:
"""Test create_safe_folder function.""" """Test create_safe_folder function."""

View File

@@ -241,28 +241,157 @@ class TestFolderNamingServiceIntegration:
assert call_kwargs["folder"] == "Naruto (1999)" assert call_kwargs["folder"] == "Naruto (1999)"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_skips_when_target_folder_already_exists( async def test_merges_source_into_existing_target_when_target_has_no_overlap(
self, tmp_path, mock_db_session, mock_series, mock_settings self, tmp_path, mock_db_session, mock_series, mock_settings
): ):
"""If 'Naruto (1999)' already exists, rename is skipped.""" """Source 'Naruto' with seasons/episodes, target 'Naruto (1999)' exists empty.
Source files are moved into target. Empty source directory is removed.
DB folder is updated to target. Result is success (renamed).
"""
anime_dir = tmp_path anime_dir = tmp_path
(anime_dir / "Naruto").mkdir() source = anime_dir / "Naruto"
(anime_dir / "Naruto (1999)").mkdir() # target already exists target = anime_dir / "Naruto (1999)"
source.mkdir()
target.mkdir()
(source / "Season 1").mkdir()
(source / "Season 1" / "ep01.mp4").touch()
(source / "Season 2").mkdir()
(source / "Season 2" / "ep01.mp4").touch()
series = mock_series("key1", "Naruto", 1999) series = mock_series("key1", "Naruto", 1999)
mock_db_session.__aenter__.return_value.__aexit__.return_value = None mock_db_session.__aenter__.return_value.__aexit__.return_value = None
with patch("src.server.services.folder_naming_service.AnimeSeriesService.get_all", new_callable=AsyncMock) as mock_get_all: with patch("src.server.services.folder_naming_service.AnimeSeriesService.get_all", new_callable=AsyncMock) as mock_get_all, \
patch("src.server.services.folder_naming_service.AnimeSeriesService.get_by_key", new_callable=AsyncMock) as mock_get_by_key, \
patch("src.server.services.folder_naming_service.AnimeSeriesService.update", new_callable=AsyncMock) as mock_update, \
patch("src.server.utils.dependencies.get_series_app") as mock_get_app:
mock_get_all.return_value = [series] mock_get_all.return_value = [series]
db_series = MagicMock()
db_series.id = 42
mock_get_by_key.return_value = db_series
app_instance = MagicMock()
app_instance.list.keyDict = {"key1": MagicMock()}
mock_get_app.return_value = app_instance
mock_settings.anime_directory = str(anime_dir) mock_settings.anime_directory = str(anime_dir)
service = FolderNamingService() service = FolderNamingService()
report = await service.run() report = await service.run()
assert report.errors == 1 # Outcome: renamed (not error) — source merged into target
assert report.renamed == 0 assert report.renamed == 1
assert report.results[0].reason == "target folder already exists on disk" assert report.errors == 0
assert (anime_dir / "Naruto").exists() # source not moved assert report.results[0].success is True
assert report.results[0].skipped is False
assert report.results[0].new_folder == "Naruto (1999)"
# Source folder gone
assert not source.exists(), "Source folder should be removed after merge"
# Target folder has merged content
assert (target / "Season 1" / "ep01.mp4").exists()
assert (target / "Season 2" / "ep01.mp4").exists()
# DB updated
assert mock_update.call_args.kwargs["folder"] == "Naruto (1999)"
@pytest.mark.asyncio
async def test_merges_only_missing_seasons_preserving_existing_target_files(
self, tmp_path, mock_db_session, mock_series, mock_settings
):
"""Source has S01 ep01, target already has S01 ep01 (different content).
Existing target files are kept. Source's S01 ep01 is NOT overwritten.
Source's S02 (new) is moved. Empty source is removed.
"""
anime_dir = tmp_path
source = anime_dir / "Naruto"
target = anime_dir / "Naruto (1999)"
source.mkdir()
target.mkdir()
# Target already has S01 with one episode
(target / "Season 1").mkdir()
target_existing = target / "Season 1" / "ep01.mp4"
target_existing.write_text("target-version")
# Source has S01 with same episode (different content) and S02
(source / "Season 1").mkdir()
source_conflict = source / "Season 1" / "ep01.mp4"
source_conflict.write_text("source-version")
(source / "Season 2").mkdir()
(source / "Season 2" / "ep01.mp4").touch()
series = mock_series("key1", "Naruto", 1999)
mock_db_session.__aenter__.return_value.__aexit__.return_value = None
with patch("src.server.services.folder_naming_service.AnimeSeriesService.get_all", new_callable=AsyncMock) as mock_get_all, \
patch("src.server.services.folder_naming_service.AnimeSeriesService.get_by_key", new_callable=AsyncMock) as mock_get_by_key, \
patch("src.server.services.folder_naming_service.AnimeSeriesService.update", new_callable=AsyncMock), \
patch("src.server.utils.dependencies.get_series_app") as mock_get_app:
mock_get_all.return_value = [series]
db_series = MagicMock()
db_series.id = 42
mock_get_by_key.return_value = db_series
app_instance = MagicMock()
app_instance.list.keyDict = {"key1": MagicMock()}
mock_get_app.return_value = app_instance
mock_settings.anime_directory = str(anime_dir)
service = FolderNamingService()
report = await service.run()
# Renamed (source effectively absorbed)
assert report.renamed == 1
assert not source.exists(), "Source should be removed after merge"
# Target S01 ep01 keeps the target version (not overwritten)
assert target_existing.read_text() == "target-version"
# New S02 moved in
assert (target / "Season 2" / "ep01.mp4").exists()
@pytest.mark.asyncio
async def test_removes_empty_source_folder_when_target_exists(
self, tmp_path, mock_db_session, mock_series, mock_settings
):
"""Source folder exists but is empty, target already exists.
Source should be removed silently, DB updated, success.
"""
anime_dir = tmp_path
source = anime_dir / "Naruto"
target = anime_dir / "Naruto (1999)"
source.mkdir()
target.mkdir()
(target / "tvshow.nfo").write_text("existing nfo")
series = mock_series("key1", "Naruto", 1999)
mock_db_session.__aenter__.return_value.__aexit__.return_value = None
with patch("src.server.services.folder_naming_service.AnimeSeriesService.get_all", new_callable=AsyncMock) as mock_get_all, \
patch("src.server.services.folder_naming_service.AnimeSeriesService.get_by_key", new_callable=AsyncMock) as mock_get_by_key, \
patch("src.server.services.folder_naming_service.AnimeSeriesService.update", new_callable=AsyncMock) as mock_update, \
patch("src.server.utils.dependencies.get_series_app") as mock_get_app:
mock_get_all.return_value = [series]
db_series = MagicMock()
db_series.id = 42
mock_get_by_key.return_value = db_series
app_instance = MagicMock()
app_instance.list.keyDict = {"key1": MagicMock()}
mock_get_app.return_value = app_instance
mock_settings.anime_directory = str(anime_dir)
service = FolderNamingService()
report = await service.run()
assert report.renamed == 1
assert report.errors == 0
assert not source.exists(), "Empty source should be removed"
assert (target / "tvshow.nfo").exists(), "Target content preserved"
assert mock_update.call_args.kwargs["folder"] == "Naruto (1999)"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_safety_guard_detects_wrong_year_in_target(self, tmp_path, mock_db_session, mock_series, mock_settings): async def test_safety_guard_detects_wrong_year_in_target(self, tmp_path, mock_db_session, mock_series, mock_settings):

View File

@@ -0,0 +1,242 @@
"""Tests for AnimeService.rename_folder_if_needed.
The behavior under test: when both the source folder (without year) and the
target folder (with year) exist on disk, the rename must not silently bail
out — it must merge the source into the target and remove the empty source.
This is what prevents the "Ultraman" + "Ultraman (2019)" duplicate-folder
problem reported by users.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src.server.services.anime_service import AnimeService
@pytest.fixture
def anime_service_with_dir(tmp_path):
"""Create AnimeService pointing at a temp directory."""
mock_app = MagicMock()
mock_app.directory_to_search = str(tmp_path)
mock_app.list.keyDict = {}
progress = MagicMock()
service = AnimeService(series_app=mock_app, progress_service=progress)
return service, tmp_path
class TestRenameFolderIfNeededMerge:
"""Tests for the merge-into-existing-target behavior."""
@pytest.mark.asyncio
async def test_merges_seasons_when_target_exists(
self, anime_service_with_dir
):
service, anime_dir = anime_service_with_dir
source = anime_dir / "Naruto"
target = anime_dir / "Naruto (1999)"
source.mkdir()
(source / "Season 1").mkdir()
(source / "Season 1" / "ep01.mp4").touch()
target.mkdir()
db = AsyncMock()
db_series = MagicMock()
db_series.id = 1
db_series.folder = "Naruto"
with patch(
"src.server.database.service.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=db_series,
), patch(
"src.server.database.service.AnimeSeriesService.update",
new_callable=AsyncMock,
) as mock_update:
ok = await service.rename_folder_if_needed(
key="naruto",
current_folder="Naruto",
target_folder="Naruto (1999)",
db=db,
)
# Outcome: rename "succeeded" (target now contains source content)
assert ok is True
assert not source.exists(), "Source should be removed after merge"
assert (target / "Season 1" / "ep01.mp4").exists()
# DB row updated to target
assert mock_update.call_args.kwargs["folder"] == "Naruto (1999)"
@pytest.mark.asyncio
async def test_does_not_overwrite_existing_target_files(
self, anime_service_with_dir
):
"""If target already has an episode file, the source copy is removed
(target version wins; no duplicate retained).
"""
service, anime_dir = anime_service_with_dir
source = anime_dir / "Naruto"
target = anime_dir / "Naruto (1999)"
source.mkdir()
target.mkdir()
(target / "Season 1").mkdir()
target_existing = target / "Season 1" / "ep01.mp4"
target_existing.write_text("target-version")
(source / "Season 1").mkdir()
source_conflict = source / "Season 1" / "ep01.mp4"
source_conflict.write_text("source-version")
db = AsyncMock()
db_series = MagicMock()
db_series.id = 1
with patch(
"src.server.database.service.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=db_series,
), patch(
"src.server.database.service.AnimeSeriesService.update",
new_callable=AsyncMock,
):
ok = await service.rename_folder_if_needed(
key="naruto",
current_folder="Naruto",
target_folder="Naruto (1999)",
db=db,
)
assert ok is True
# Target version preserved
assert target_existing.read_text() == "target-version"
# Source folder removed (after merge, even with skipped conflicts)
assert not source.exists()
@pytest.mark.asyncio
async def test_removes_empty_source_when_target_exists(
self, anime_service_with_dir
):
service, anime_dir = anime_service_with_dir
source = anime_dir / "Naruto"
target = anime_dir / "Naruto (1999)"
source.mkdir()
target.mkdir()
(target / "tvshow.nfo").write_text("kept")
db = AsyncMock()
db_series = MagicMock()
db_series.id = 1
with patch(
"src.server.database.service.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=db_series,
), patch(
"src.server.database.service.AnimeSeriesService.update",
new_callable=AsyncMock,
) as mock_update:
ok = await service.rename_folder_if_needed(
key="naruto",
current_folder="Naruto",
target_folder="Naruto (1999)",
db=db,
)
assert ok is True
assert not source.exists()
assert (target / "tvshow.nfo").read_text() == "kept"
assert mock_update.call_args.kwargs["folder"] == "Naruto (1999)"
@pytest.mark.asyncio
async def test_simple_rename_when_target_does_not_exist(
self, anime_service_with_dir
):
"""Regression: plain rename (no merge needed) still works."""
service, anime_dir = anime_service_with_dir
source = anime_dir / "Naruto"
source.mkdir()
(source / "Season 1").mkdir()
(source / "Season 1" / "ep01.mp4").touch()
db = AsyncMock()
db_series = MagicMock()
db_series.id = 1
with patch(
"src.server.database.service.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=db_series,
), patch(
"src.server.database.service.AnimeSeriesService.update",
new_callable=AsyncMock,
) as mock_update:
ok = await service.rename_folder_if_needed(
key="naruto",
current_folder="Naruto",
target_folder="Naruto (1999)",
db=db,
)
assert ok is True
assert not source.exists()
assert (anime_dir / "Naruto (1999)" / "Season 1" / "ep01.mp4").exists()
assert mock_update.call_args.kwargs["folder"] == "Naruto (1999)"
@pytest.mark.asyncio
async def test_no_op_when_source_and_target_same(
self, anime_service_with_dir
):
"""Regression: same-name case returns False without touching disk."""
service, anime_dir = anime_service_with_dir
source = anime_dir / "Naruto (1999)"
source.mkdir()
ok = await service.rename_folder_if_needed(
key="naruto",
current_folder="Naruto (1999)",
target_folder="Naruto (1999)",
db=None,
)
assert ok is False
assert source.exists()
@pytest.mark.asyncio
async def test_no_op_when_source_missing(self, anime_service_with_dir):
"""Regression: source missing on disk returns False without error."""
service, anime_dir = anime_service_with_dir
ok = await service.rename_folder_if_needed(
key="naruto",
current_folder="Naruto",
target_folder="Naruto (1999)",
db=None,
)
assert ok is False
@pytest.mark.asyncio
async def test_path_typesafe_with_string_directory(self, tmp_path):
"""Regression: directory_to_search being a string (not Path) works.
Original code did `self._directory / current_folder` which raised
TypeError when _directory was a str. This was silently swallowed
by the caller's try/except, leaving the rename undone.
"""
mock_app = MagicMock()
mock_app.directory_to_search = str(tmp_path) # string, not Path
mock_app.list.keyDict = {}
progress = MagicMock()
service = AnimeService(series_app=mock_app, progress_service=progress)
source = tmp_path / "Naruto"
target = tmp_path / "Naruto (1999)"
source.mkdir()
target.mkdir()
ok = await service.rename_folder_if_needed(
key="naruto",
current_folder="Naruto",
target_folder="Naruto (1999)",
db=None,
)
# Must not raise; must succeed (merge path).
assert ok is True
assert not source.exists()

View File

@@ -194,12 +194,20 @@ class TestSerieScannerSingleSeries:
def test_scan_single_series_existing_entry( def test_scan_single_series_existing_entry(
self, temp_directory, mock_loader, sample_serie self, temp_directory, mock_loader, sample_serie
): ):
"""Test scan_single_series updates existing entry in keyDict.""" """Test scan_single_series replaces the existing entry's
``episodeDict`` with the new scan's missing-episode list.
Note: the previous implementation ``extend````ed the
existing list with the new one, which accumulated
duplicates across rescans. The fix is to replace, not
extend — see ``test_serie_scanner_scan_dedup.py`` for the
regression tests for that specific bug.
"""
scanner = SerieScanner(temp_directory, mock_loader) scanner = SerieScanner(temp_directory, mock_loader)
# Pre-populate keyDict # Pre-populate keyDict
scanner.keyDict[sample_serie.key] = sample_serie scanner.keyDict[sample_serie.key] = sample_serie
# Use deepcopy because episodeDict is modified in-place # Use deepcopy because episodeDict is mutated by the scanner.
import copy import copy
old_episode_dict = copy.deepcopy(sample_serie.episodeDict) old_episode_dict = copy.deepcopy(sample_serie.episodeDict)
@@ -213,10 +221,15 @@ class TestSerieScannerSingleSeries:
folder=sample_serie.folder folder=sample_serie.folder
) )
# Verify existing entry was updated - episodeDict is merged (not replaced) # The cached episodeDict is REPLACED with the latest
# Old episodes [2, 3, 4] + new episodes [10, 11, 12] = merged result # scan's missing-episode list — not merged. Old entries
assert scanner.keyDict[sample_serie.key].episodeDict != old_episode_dict # ([2, 3, 4]) are dropped because the latest scan
assert scanner.keyDict[sample_serie.key].episodeDict == {1: [2, 3, 4, 10, 11, 12]} # reports only [10, 11, 12] as still missing.
new_episode_dict = scanner.keyDict[
sample_serie.key
].episodeDict
assert new_episode_dict != old_episode_dict
assert new_episode_dict == {1: [10, 11, 12]}
def test_scan_single_series_empty_key_raises_error( def test_scan_single_series_empty_key_raises_error(
self, temp_directory, mock_loader self, temp_directory, mock_loader

View File

@@ -101,12 +101,20 @@ class TestSyncEpisodesToDb:
"""Test _sync_episodes_to_db method.""" """Test _sync_episodes_to_db method."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_preserves_downloaded_episodes(self): async def test_deletes_downloaded_episodes_when_no_longer_missing(self):
"""Verify downloaded episodes are not removed even when no longer missing.""" """Downloaded episodes are deleted once the rescan confirms the
file is on disk and they are no longer missing. The DB stays
in sync with the filesystem: a row that is not in the scanner's
missing set has no further purpose and is removed.
"""
mock_session = AsyncMock() mock_session = AsyncMock()
# S01E1 was downloaded (file exists), S01E2 was missing but file now exists # S01E1 was downloaded (file exists) and the scanner confirms
# Both are no longer in episode_dict # it is no longer missing; S01E2 was previously marked as
# downloaded and is also no longer missing. Both should be
# deleted — there is no notion of "preserving download history"
# in the DB: the rescan's filesystem view is the source of
# truth, and a row whose episode is no longer missing is dead.
existing_eps = [ existing_eps = [
MagicMock(id=1, season=1, episode_number=1, is_downloaded=True), MagicMock(id=1, season=1, episode_number=1, is_downloaded=True),
MagicMock(id=2, season=1, episode_number=2, is_downloaded=True), MagicMock(id=2, season=1, episode_number=2, is_downloaded=True),
@@ -126,8 +134,16 @@ class TestSyncEpisodesToDb:
mock_session, 1, {} # No episodes missing mock_session, 1, {} # No episodes missing
) )
# Neither should be deleted since both are downloaded # Both downloaded rows should be deleted; the scanner
mock_delete.assert_not_called() # found the files on disk and they're not in the
# missing set.
assert mock_delete.call_count == 2
deleted_calls = [
(c.args[1], c.args[2], c.args[3])
for c in mock_delete.call_args_list
]
assert (1, 1, 1) in deleted_calls
assert (1, 1, 2) in deleted_calls
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_removes_missing_episodes_when_no_longer_missing(self): async def test_removes_missing_episodes_when_no_longer_missing(self):

View File

@@ -0,0 +1,179 @@
"""Regression test for ``SerieScanner.scan_single_series``.
The previous implementation ``extend````ed the in-memory
``episodeDict`` for series already present in the scanner's
``keyDict`` — every rescan of the same series appended the new
missing-episode list on top of the existing one, so the dict grew
with duplicates across rescans. Those duplicates then propagated
through ``_update_series_in_db`` into the ``episodes`` table.
This test exercises the real ``scan_single_series`` method (not a
mock) to assert that two rescans of the same series produce a
canonical (deduplicated) ``episodeDict``, not an accumulated one.
"""
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
import pytest
from src.server.SerieScanner import SerieScanner
class _StubLoader:
"""A minimal loader that returns whatever ``missing_episodes``
the test wants. Replaces the real loader on a
``SerieScanner`` instance via ``monkeypatch.setattr``."""
def __init__(self, missing_per_call: list[dict]):
self._missing_per_call = list(missing_per_call)
self._call_index = 0
def get_season_episode_count(self, key):
# The real loader returns the total episode count per
# season; ``scan_single_series`` doesn't actually use it
# (it calls ``__get_missing_episodes_and_season`` directly),
# but the stub keeps the call site safe.
max_seen = 0
for m in self._missing_per_call:
for season, eps in m.items():
if eps:
max_seen = max(max_seen, max(eps))
return {1: max_seen or 1}
def is_language(self, season, ep, key):
return True
def next_missing(self) -> dict:
if self._call_index >= len(self._missing_per_call):
return {}
result = self._missing_per_call[self._call_index]
self._call_index += 1
return result
def _make_scanner(
tmp_path: Path,
missing_per_call: list[dict],
) -> tuple[SerieScanner, _StubLoader]:
"""Build a real ``SerieScanner`` with the private
``__get_missing_episodes_and_season`` method replaced by a
stub that returns the next ``missing_episodes`` dict on each
call. Everything else (events, directory) is stubbed so the
test runs without filesystem or scheduler setup."""
scanner = SerieScanner.__new__(SerieScanner)
loader = _StubLoader(missing_per_call)
scanner.loader = loader # type: ignore[assignment]
scanner.keyDict = {}
# ``self.directory`` is the attribute ``scan_single_series``
# reads at line 734. ``scan_single_series`` checks
# ``os.path.isdir(folder_path)`` and, if the folder does not
# exist, treats the scan as "no MP4 files on disk". Use a path
# whose subdirectories do not exist so the scan takes the
# empty-mp4-files branch without us having to populate any
# filesystem state.
scanner.directory = str(tmp_path)
scanner.directory_to_search = tmp_path
scanner.events = SimpleNamespace(
on_progress=lambda *a, **k: None,
on_completion=lambda *a, **k: None,
on_error=lambda *a, **k: None,
)
def fake_get_missing_episodes_and_season(key, mp4_files):
return loader.next_missing(), "aniworld.to"
scanner._SerieScanner__get_missing_episodes_and_season = ( # type: ignore[attr-defined]
fake_get_missing_episodes_and_season
)
return scanner, loader
def test_scan_single_series_replaces_not_extends(tmp_path):
"""Two rescans of the same series with the same missing
episodes must produce a canonical (deduplicated) episodeDict —
not a list with duplicates from accumulation."""
canonical = {1: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]}
scanner, _ = _make_scanner(
tmp_path,
# Same missing-episode list reported on each scan.
missing_per_call=[canonical, canonical],
)
# First scan: key not in keyDict, so the else branch fires
# and the cache is set to the missing-episode list.
scanner.keyDict.clear()
result_first = scanner.scan_single_series(
key="erased", folder="Erased"
)
assert result_first == canonical
cached = scanner.keyDict["erased"].episodeDict
assert cached == canonical
# Second scan: key IS in keyDict, so the if branch fires.
# Before the fix, this would extend the cached list with
# [1..12] again, producing {1: [1..12, 1..12]}. After the
# fix, the cache is replaced, not extended.
result_second = scanner.scan_single_series(
key="erased", folder="Erased"
)
assert result_second == canonical
cached_second = scanner.keyDict["erased"].episodeDict
assert cached_second == canonical, (
"second scan extended the dict instead of replacing it: "
f"{cached_second}"
)
# Defensive: the dict has no duplicates within any season.
for season, eps in cached_second.items():
assert len(eps) == len(set(eps)), (
f"season {season} has duplicate episode numbers: {eps}"
)
def test_scan_single_series_resets_when_becomes_complete(tmp_path):
"""When a rescan finds the series is complete (no missing
episodes), the episodeDict must be empty — extending the
previous dict would leave stale entries behind."""
scanner, _ = _make_scanner(
tmp_path,
# First scan: missing 1..12. Second scan: nothing missing.
missing_per_call=[
{1: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]},
{},
],
)
scanner.keyDict.clear()
scanner.scan_single_series(key="complete-me", folder="Complete Me")
assert scanner.keyDict["complete-me"].episodeDict == {
1: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
}
scanner.scan_single_series(key="complete-me", folder="Complete Me")
assert scanner.keyDict["complete-me"].episodeDict == {}, (
"rescan with no missing episodes must reset the dict, "
f"got: {scanner.keyDict['complete-me'].episodeDict}"
)
def test_scan_single_series_dedupes_within_a_single_call(tmp_path):
"""If the loader itself returns duplicate episode numbers in
``missing_episodes`` (a buggy upstream loader), the scanner
must still produce a canonical dict — defense in depth on top
of the loader and the read-boundary dedup in
``AnimeSeries.episodeDict``."""
scanner, _ = _make_scanner(
tmp_path,
# The loader returns the same episode numbers multiple
# times within a single call.
missing_per_call=[{1: [1, 1, 2, 2, 3, 3, 3, 4]}],
)
scanner.keyDict.clear()
scanner.scan_single_series(key="buggy", folder="Buggy")
cached = scanner.keyDict["buggy"].episodeDict
assert cached == {1: [1, 2, 3, 4]}, (
f"single-scan dedup failed: {cached}"
)