Compare commits

...

16 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
30 changed files with 2119 additions and 131 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): "
@@ -1936,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)
@@ -1945,6 +1965,20 @@ class AnimeService:
key, exc, key, exc,
) )
# Broadcast the broader series_list_changed event so any
# connected client that missed the series_deleted message
# (or whose local state drifted) can re-sync by re-fetching
# /api/anime. This is the durable fix for the
# "deleted but still listed" bug.
try:
await self._broadcast_series_list_changed(reason="deleted")
except Exception as exc:
logger.warning(
"Failed to broadcast series_list_changed after delete: "
"key=%s error=%s",
key, exc,
)
# --- Build message --- # --- Build message ---
self._build_delete_message(result) self._build_delete_message(result)
@@ -2124,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

@@ -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

@@ -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

@@ -646,6 +646,111 @@ class TestDeleteSeriesService:
db_delete_mock.assert_not_called() db_delete_mock.assert_not_called()
assert result.success is False 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) # Orphan folder recovery (DB row gone, folder still on disk)
# ------------------------------------------------------------------ # ------------------------------------------------------------------

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}"
)