Compare commits
19 Commits
7df9a84ae9
...
v1.5.12
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e8f3b5c84 | |||
| c3aca9217d | |||
| f75d591453 | |||
| c84f968916 | |||
| 1d121b427d | |||
| db5f5edf2d | |||
| 35a733d36f | |||
| 62b4ca5ffc | |||
| 8887f9a7cb | |||
|
|
2299cf788b | ||
|
|
9f52ea03fb | ||
| ff526e08ea | |||
| b8892b4737 | |||
| 16977d6227 | |||
| 7538ea8608 | |||
| 7da7668787 | |||
| 0f872276dd | |||
| 818e621288 | |||
| 14f12e55e7 |
@@ -1 +1 @@
|
||||
v1.5.7
|
||||
v1.5.12
|
||||
|
||||
@@ -59,9 +59,26 @@ else
|
||||
err "Neither podman nor docker is installed."
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
# -------------------------------------------------------------------
|
||||
echo "============================================"
|
||||
echo " AniWorld — Build & Push"
|
||||
echo " Engine : ${ENGINE}"
|
||||
|
||||
@@ -85,7 +85,20 @@ echo "Version file updated → ${VERSION_FILE}"
|
||||
FRONT_VERSION="${NEW_TAG#v}"
|
||||
FRONT_PKG="${SCRIPT_DIR}/../package.json"
|
||||
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}"
|
||||
else
|
||||
echo "Warning: package.json not found, skipping package.json version sync" >&2
|
||||
@@ -94,13 +107,63 @@ fi
|
||||
# Keep root pyproject.toml in sync.
|
||||
BACKEND_PYPROJECT="${SCRIPT_DIR}/../pyproject.toml"
|
||||
if [[ -f "${BACKEND_PYPROJECT}" ]]; then
|
||||
# Update version under [project] section if present
|
||||
if grep -q '^\[project\]' "${BACKEND_PYPROJECT}"; then
|
||||
sed -i "/^\[project\]/,/^\[/ s/^version = \".*\"/version = \"${FRONT_VERSION}\"/" "${BACKEND_PYPROJECT}"
|
||||
else
|
||||
sed -i "s/^version = \".*\"/version = \"${FRONT_VERSION}\"/" "${BACKEND_PYPROJECT}"
|
||||
# Use python instead of sed: the previous `sed -i` used double-quoted
|
||||
# patterns whose `&` and `\` characters would have to be escaped, and
|
||||
# more importantly it could silently do nothing if the [project] section
|
||||
# was missing. python reads/writes the file as a string, preserving
|
||||
# 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
|
||||
echo "pyproject.toml version updated → ${FRONT_VERSION}"
|
||||
else
|
||||
echo "Warning: pyproject.toml not found, skipping pyproject.toml version sync" >&2
|
||||
fi
|
||||
|
||||
25
Docs/API.md
25
Docs/API.md
@@ -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)
|
||||
|
||||
### 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
|
||||
|
||||
**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
|
||||
{
|
||||
"serie_id": "one-piece",
|
||||
"serie_folder": "One Piece (1999)",
|
||||
"key": "attack-on-titan",
|
||||
"folder": "Attack on Titan (2013)",
|
||||
"content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<tvshow>...</tvshow>",
|
||||
"file_size": 2048,
|
||||
"last_modified": "2026-01-15T10:30:00"
|
||||
"last_modified": "2026-09-04T17:42:13"
|
||||
}
|
||||
```
|
||||
|
||||
**Errors:**
|
||||
|
||||
- `401 Unauthorized` - Not authenticated
|
||||
- `404 Not Found` - Series or NFO not found
|
||||
- `400 Bad Request` — Series has no folder configured.
|
||||
- `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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aniworld-web",
|
||||
"version": "1.5.7",
|
||||
"version": "1.5.12",
|
||||
"description": "Aniworld Anime Download Manager - Web Frontend",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
332
src/cli/clean_duplicate_episodes.py
Normal file
332
src/cli/clean_duplicate_episodes.py
Normal 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())
|
||||
@@ -281,10 +281,13 @@ class SerieScanner:
|
||||
async def _sync_episodes_to_db(
|
||||
self, db, series_id: int, episode_dict: dict[int, list[int]]
|
||||
) -> None:
|
||||
"""Sync episodes to database, preserving downloaded flags.
|
||||
"""Sync episodes to database.
|
||||
|
||||
Adds missing episodes, removes episodes no longer missing,
|
||||
and preserves is_downloaded=True episodes.
|
||||
Adds missing episodes, removes episodes no longer missing
|
||||
(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:
|
||||
db: Async database session
|
||||
@@ -301,15 +304,9 @@ class SerieScanner:
|
||||
new_keys.add((season, ep_num))
|
||||
for (season, ep_num), ep in existing_map.items():
|
||||
if (season, ep_num) not in new_keys:
|
||||
if ep.is_downloaded:
|
||||
logger.debug(
|
||||
"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
|
||||
)
|
||||
await EpisodeService.delete_by_series(
|
||||
db, series_id, season, ep_num
|
||||
)
|
||||
for season, eps in episode_dict.items():
|
||||
for ep_num in eps:
|
||||
if (season, ep_num) not in existing_map:
|
||||
@@ -779,18 +776,35 @@ class SerieScanner:
|
||||
|
||||
# Create or update AnimeSeries in 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_ep_dict = existing.episodeDict
|
||||
# Merge missing episodes
|
||||
# Use ``dict.fromkeys`` to dedupe within a season, in
|
||||
# case ``missing_episodes`` itself contains duplicate
|
||||
# episode numbers from a buggy loader upstream.
|
||||
rebuilt: dict = {}
|
||||
for season, eps in missing_episodes.items():
|
||||
if season not in existing_ep_dict:
|
||||
existing_ep_dict[season] = []
|
||||
existing_ep_dict[season].extend(eps)
|
||||
seen: set = set()
|
||||
cleaned: list = []
|
||||
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(
|
||||
"Updated existing series %s with %d missing episodes",
|
||||
key,
|
||||
sum(len(eps) for eps in missing_episodes.values())
|
||||
sum(len(eps) for eps in rebuilt.values()),
|
||||
)
|
||||
else:
|
||||
# Extract year from folder name if present, otherwise leave as None
|
||||
|
||||
@@ -1650,17 +1650,11 @@ async def update_anime_settings(
|
||||
# Lazy-import to avoid heavy deps when not used
|
||||
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(
|
||||
key=anime_key,
|
||||
folder=db_series.folder,
|
||||
tmdb_id=db_series.tmdb_id,
|
||||
series_data=series_data,
|
||||
anime_service=anime_service,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -1727,17 +1721,11 @@ async def regenerate_anime_nfo(
|
||||
try:
|
||||
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(
|
||||
key=anime_key,
|
||||
folder=db_series.folder,
|
||||
tmdb_id=tmdb_id,
|
||||
series_data=series_data,
|
||||
anime_service=anime_service,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
@@ -4,6 +4,7 @@ Provides endpoints for NFO settings, repair, and validation for anime series.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
@@ -11,6 +12,7 @@ from pydantic import BaseModel
|
||||
|
||||
from src.config.settings import settings
|
||||
from src.server.models.nfo import (
|
||||
NfoContentResponse,
|
||||
NfoRepairResponse,
|
||||
NfoSeriesSettings,
|
||||
NfoSettingsResponse,
|
||||
@@ -241,7 +243,6 @@ async def repair_nfo_settings(
|
||||
key=key,
|
||||
folder=folder,
|
||||
tmdb_id=tmdb_id,
|
||||
series_data=series_data,
|
||||
anime_service=anime_service,
|
||||
)
|
||||
except Exception as exc:
|
||||
@@ -285,7 +286,6 @@ async def _create_or_update_nfo(
|
||||
key: str,
|
||||
folder: str,
|
||||
tmdb_id: int,
|
||||
series_data: dict,
|
||||
anime_service: AnimeService,
|
||||
) -> List[str]:
|
||||
"""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)
|
||||
async def get_series_needing_repair(
|
||||
_auth: dict = Depends(require_auth),
|
||||
@@ -522,7 +590,6 @@ async def batch_repair_nfo(
|
||||
key=key,
|
||||
folder=folder,
|
||||
tmdb_id=tmdb_id,
|
||||
series_data=series_data,
|
||||
anime_service=anime_service,
|
||||
)
|
||||
results["success"] += 1
|
||||
|
||||
@@ -121,15 +121,35 @@ class SerieList:
|
||||
def contains(self, key: str) -> bool:
|
||||
"""
|
||||
Return True when a series identified by ``key`` already exists.
|
||||
|
||||
|
||||
Args:
|
||||
key: The unique provider identifier for the series
|
||||
|
||||
|
||||
Returns:
|
||||
True if the series exists in the collection
|
||||
"""
|
||||
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]:
|
||||
"""Return all series that still contain missing episodes."""
|
||||
return [
|
||||
|
||||
@@ -15,7 +15,17 @@ from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
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 src.server.database.base import Base, TimestampMixin
|
||||
@@ -195,22 +205,60 @@ class AnimeSeries(Base, TimestampMixin):
|
||||
"""Build episode dictionary from episodes relationship or private cache.
|
||||
|
||||
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)
|
||||
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]] = {}
|
||||
try:
|
||||
if self.episodes:
|
||||
seen: set[tuple[int, int]] = set()
|
||||
for ep in self.episodes:
|
||||
if ep.is_downloaded:
|
||||
continue
|
||||
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:
|
||||
episode_dict[season] = []
|
||||
episode_dict[season].append(ep.episode_number or 0)
|
||||
episode_dict[season].append(ep_num)
|
||||
except Exception:
|
||||
# DetachedInstanceError or other DB errors - return empty dict
|
||||
# This can happen when accessing episodes on a newly created
|
||||
@@ -290,7 +338,25 @@ class Episode(Base, TimestampMixin):
|
||||
updated_at: Last update timestamp (from TimestampMixin)
|
||||
"""
|
||||
__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
|
||||
id: Mapped[int] = mapped_column(
|
||||
Integer, primary_key=True, autoincrement=True
|
||||
|
||||
@@ -393,5 +393,29 @@ class NfoRepairResponse(BaseModel):
|
||||
message: str = Field(..., description="Human-readable result message")
|
||||
repaired_tags: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="Tags that were missing before repair"
|
||||
description="Tags that were missing before repair",
|
||||
)
|
||||
|
||||
|
||||
class NfoContentResponse(BaseModel):
|
||||
"""Response containing the raw contents of a series' tvshow.nfo.
|
||||
|
||||
Returned by ``GET /api/nfo/{key}/content`` so the Anime Settings page
|
||||
can render the XML for the user without exposing the on-disk path to
|
||||
the client (only the resolved path is included for display).
|
||||
|
||||
Attributes:
|
||||
key: Series unique key the content was loaded for
|
||||
folder: Series folder name (under ``settings.anime_directory``)
|
||||
content: Raw XML text of tvshow.nfo (UTF-8)
|
||||
file_size: Size of the NFO file in bytes
|
||||
last_modified: ISO-8601 timestamp of last on-disk modification
|
||||
"""
|
||||
|
||||
key: str = Field(..., description="Series unique key")
|
||||
folder: str = Field(..., description="Series folder name")
|
||||
content: str = Field(..., description="Raw XML content of tvshow.nfo")
|
||||
file_size: int = Field(..., description="NFO file size in bytes")
|
||||
last_modified: datetime = Field(
|
||||
..., description="Last modification time of the NFO file"
|
||||
)
|
||||
|
||||
@@ -857,39 +857,39 @@ class AnimeService:
|
||||
|
||||
async def _update_series_in_db(self, serie, existing, db) -> None:
|
||||
"""Update an existing series in the database.
|
||||
|
||||
|
||||
Syncs the database episodes with the current missing episodes from scan.
|
||||
- Adds new missing episodes that are not in the database
|
||||
- Removes episodes from database that are no longer missing
|
||||
(i.e., the file has been added to the filesystem)
|
||||
- Preserves episodes marked as downloaded (is_downloaded=True)
|
||||
so download history is not lost
|
||||
(i.e., the file has been added to the filesystem), including
|
||||
episodes that were previously marked as downloaded. A row
|
||||
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
|
||||
|
||||
# Get existing episodes from database (all episodes, including downloaded)
|
||||
existing_episodes = await EpisodeService.get_by_series(db, existing.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]] = {}
|
||||
downloaded_set: set[tuple[int, int]] = set()
|
||||
for ep in existing_episodes:
|
||||
if ep.season not in existing_dict:
|
||||
existing_dict[ep.season] = {}
|
||||
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
|
||||
new_dict = serie.episodeDict or {}
|
||||
|
||||
|
||||
# Build set of new missing episodes for quick lookup
|
||||
new_missing_set: set[tuple[int, int]] = set()
|
||||
for season, episode_numbers in new_dict.items():
|
||||
for ep_num in episode_numbers:
|
||||
new_missing_set.add((season, ep_num))
|
||||
|
||||
|
||||
# Add new missing episodes that are not in the database
|
||||
for season, episode_numbers in new_dict.items():
|
||||
existing_season_eps = existing_dict.get(season, {})
|
||||
@@ -907,25 +907,16 @@ class AnimeService:
|
||||
season,
|
||||
ep_num
|
||||
)
|
||||
|
||||
|
||||
# Remove episodes from database that are no longer missing
|
||||
# (i.e., the episode file now exists on the filesystem)
|
||||
# BUT: preserve episodes that are already downloaded (is_downloaded=True)
|
||||
# so we don't lose download history
|
||||
# (i.e., the episode file now exists on the filesystem).
|
||||
# This includes episodes previously marked as downloaded:
|
||||
# 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 ep_num, episode_id in eps_dict.items():
|
||||
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)
|
||||
logger.info(
|
||||
"Removed episode from database (no longer missing): "
|
||||
@@ -934,7 +925,7 @@ class AnimeService:
|
||||
season,
|
||||
ep_num
|
||||
)
|
||||
|
||||
|
||||
# Update folder if changed
|
||||
if existing.folder != serie.folder:
|
||||
await AnimeSeriesService.update(
|
||||
@@ -942,7 +933,7 @@ class AnimeService:
|
||||
existing.id,
|
||||
folder=serie.folder
|
||||
)
|
||||
|
||||
|
||||
logger.debug(
|
||||
"Updated series in database: %s (key=%s)",
|
||||
serie.name,
|
||||
@@ -1244,8 +1235,7 @@ class AnimeService:
|
||||
Returns:
|
||||
True if rename was performed, False if no rename needed or failed
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
if current_folder == target_folder:
|
||||
logger.debug(
|
||||
@@ -1254,8 +1244,9 @@ class AnimeService:
|
||||
)
|
||||
return False
|
||||
|
||||
current_path = self._directory / current_folder
|
||||
target_path = self._directory / target_folder
|
||||
base_dir = Path(self._directory)
|
||||
current_path = base_dir / current_folder
|
||||
target_path = base_dir / target_folder
|
||||
|
||||
if not current_path.exists():
|
||||
logger.debug(
|
||||
@@ -1265,15 +1256,54 @@ class AnimeService:
|
||||
return False
|
||||
|
||||
if target_path.exists():
|
||||
logger.warning(
|
||||
"Cannot rename folder for %s: target path already exists: %s",
|
||||
key,
|
||||
target_path
|
||||
# Target already exists — merge source into target instead of
|
||||
# bailing. Without this, a bare folder ('Naruto') next to the
|
||||
# year-suffixed one ('Naruto (2019)') would orphan the bare
|
||||
# folder forever, producing the "series added twice" symptom.
|
||||
try:
|
||||
summary = self._merge_folder_into_target(
|
||||
str(current_path), str(target_path)
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to merge %s -> %s for %s: %s",
|
||||
current_folder, target_folder, key, exc,
|
||||
)
|
||||
return False
|
||||
logger.info(
|
||||
"Merged folder %s -> %s for series %s (moved=%d skipped=%d removed_source=%s)",
|
||||
current_folder, target_folder, key,
|
||||
summary["moved"], summary["skipped"], summary["removed_source"],
|
||||
)
|
||||
return False
|
||||
|
||||
# Update in-memory cache
|
||||
if key in self._app.list.keyDict:
|
||||
self._app.list.keyDict[key].folder = target_folder
|
||||
logger.debug(
|
||||
"Updated in-memory cache folder for %s: %s",
|
||||
key, target_folder
|
||||
)
|
||||
|
||||
# Update database if session provided
|
||||
if db is not None:
|
||||
from src.server.database.service import AnimeSeriesService
|
||||
|
||||
# Look up series by key to get database ID
|
||||
series = await AnimeSeriesService.get_by_key(db, key)
|
||||
if series:
|
||||
await AnimeSeriesService.update(
|
||||
db, series_id=series.id, folder=target_folder
|
||||
)
|
||||
logger.debug(
|
||||
"Updated DB folder for %s: %s",
|
||||
key, target_folder
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
try:
|
||||
# Rename folder on disk
|
||||
import shutil
|
||||
shutil.move(str(current_path), str(target_path))
|
||||
logger.info(
|
||||
"Renamed folder for %s: %s -> %s",
|
||||
@@ -1317,6 +1347,83 @@ class AnimeService:
|
||||
)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _merge_folder_into_target(source: str, target: str) -> dict:
|
||||
"""Merge a source folder's contents into an existing target folder.
|
||||
|
||||
Walks the source tree and moves every file into the matching path
|
||||
under the target. When a destination file already exists, the
|
||||
source copy is removed (the target version wins; we don't keep
|
||||
duplicates). When the source tree is fully consumed, the
|
||||
(now-empty) source directory is removed.
|
||||
|
||||
Both paths must be absolute and ``target`` must already exist on
|
||||
disk.
|
||||
|
||||
Returns a summary dict with ``moved`` (file count), ``skipped``
|
||||
(file count where target already had a copy), and
|
||||
``removed_source`` (bool).
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
|
||||
if not os.path.isdir(source):
|
||||
return {"moved": 0, "skipped": 0, "removed_source": False}
|
||||
if not os.path.isdir(target):
|
||||
raise ValueError(f"target does not exist: {target}")
|
||||
|
||||
moved = 0
|
||||
skipped = 0
|
||||
for root, _dirs, files in os.walk(source):
|
||||
rel_root = os.path.relpath(root, source)
|
||||
dest_root = (
|
||||
target if rel_root == "."
|
||||
else os.path.join(target, rel_root)
|
||||
)
|
||||
os.makedirs(dest_root, exist_ok=True)
|
||||
for name in files:
|
||||
src_file = os.path.join(root, name)
|
||||
dest_file = os.path.join(dest_root, name)
|
||||
if os.path.exists(dest_file):
|
||||
# Target wins — never overwrite existing content.
|
||||
# Remove the orphaned source copy so cleanup below
|
||||
# can rmdir it.
|
||||
try:
|
||||
os.remove(src_file)
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"merge: could not remove duplicate %s: %s",
|
||||
src_file, exc,
|
||||
)
|
||||
skipped += 1
|
||||
logger.warning(
|
||||
"merge: skipping %s (target already has %s)",
|
||||
src_file, dest_file,
|
||||
)
|
||||
continue
|
||||
shutil.move(src_file, dest_file)
|
||||
moved += 1
|
||||
|
||||
# Try to remove the (now empty) source tree. Walk bottom-up so
|
||||
# leaf directories are removed before their parents.
|
||||
removed_source = False
|
||||
for root, dirs, files in os.walk(source, topdown=False):
|
||||
for d in dirs:
|
||||
try:
|
||||
os.rmdir(os.path.join(root, d))
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.rmdir(source)
|
||||
removed_source = True
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"merge: could not remove source directory %s: %s",
|
||||
source, exc,
|
||||
)
|
||||
|
||||
return {"moved": moved, "skipped": skipped, "removed_source": removed_source}
|
||||
|
||||
async def contains_in_db(self, key: str, db) -> bool:
|
||||
"""
|
||||
Check if a series with the given key exists in the database.
|
||||
@@ -1649,12 +1756,29 @@ class AnimeService:
|
||||
|
||||
Returns:
|
||||
DeleteSeriesResult with success status, what was deleted, errors
|
||||
|
||||
Deletion order: filesystem first, database second.
|
||||
|
||||
This order matters: if the folder delete fails (e.g. permission
|
||||
error, path outside the configured anime directory), the database
|
||||
row is preserved so the user can retry the delete once the
|
||||
underlying issue is resolved. If we deleted the database row
|
||||
first, an orphan folder would be left on disk with no way to
|
||||
clean it up through the normal delete flow.
|
||||
|
||||
Orphan folder recovery: when ``delete_folder=True`` is requested
|
||||
for a series whose database row no longer exists, the configured
|
||||
anime directory is scanned for a folder that uniquely matches
|
||||
the key. This recovers the case where a previous delete with
|
||||
``delete_database=True`` succeeded but ``delete_folder=True``
|
||||
silently failed, leaving the folder on disk.
|
||||
"""
|
||||
from src.server.database.connection import get_db_session
|
||||
from src.server.database.service import AnimeSeriesService
|
||||
from src.server.models.anime import DeleteSeriesResult
|
||||
from src.server.utils.filesystem import is_safe_path
|
||||
import os as _os
|
||||
import re
|
||||
import shutil
|
||||
|
||||
logger.info(
|
||||
@@ -1679,13 +1803,43 @@ class AnimeService:
|
||||
message="At least one of delete_database or delete_folder must be True.",
|
||||
)
|
||||
|
||||
# Single DB session for fetch + optional delete
|
||||
# Look up the series in the DB to get its folder path
|
||||
series = None
|
||||
async with get_db_session() as db:
|
||||
series = await AnimeSeriesService.get_by_key(db, key)
|
||||
if not series:
|
||||
logger.warning(
|
||||
"Delete series failed - not found: key=%s", key
|
||||
)
|
||||
|
||||
if not series:
|
||||
logger.warning(
|
||||
"Delete series - row not found in DB: key=%s delete_folder=%s",
|
||||
key, delete_folder,
|
||||
)
|
||||
# Recovery path: if the user wants to delete the folder but the
|
||||
# DB row is already gone (e.g. orphaned by a previous partial
|
||||
# delete), scan the configured anime directory for a folder
|
||||
# that uniquely matches this key and delete it.
|
||||
if delete_folder:
|
||||
folder_path = self._find_orphan_folder_for_key(key)
|
||||
if folder_path:
|
||||
logger.info(
|
||||
"Orphan folder recovery: key=%s matched folder=%s",
|
||||
key, folder_path,
|
||||
)
|
||||
result = DeleteSeriesResult(
|
||||
success=True,
|
||||
key=key,
|
||||
name="",
|
||||
folder_path=folder_path,
|
||||
message="",
|
||||
)
|
||||
self._delete_folder_at_path(folder_path, key, result)
|
||||
# No DB row to delete; build message and return
|
||||
self._build_delete_message(result)
|
||||
logger.info(
|
||||
"Delete series completed (orphan recovery): key=%s "
|
||||
"deleted_folder=%s folder_error=%s",
|
||||
key, result.deleted_folder, result.folder_error,
|
||||
)
|
||||
return result
|
||||
return DeleteSeriesResult(
|
||||
success=False,
|
||||
key=key,
|
||||
@@ -1694,13 +1848,33 @@ class AnimeService:
|
||||
deleted_from_database=False,
|
||||
deleted_folder=False,
|
||||
database_error=None,
|
||||
folder_error=None,
|
||||
message=f"Series '{key}' not found.",
|
||||
folder_error=(
|
||||
f"Series '{key}' not found in database, and no folder "
|
||||
"matching this key was found in the anime directory. "
|
||||
"Nothing to delete."
|
||||
),
|
||||
message=(
|
||||
f"Series '{key}' not found. If the folder on disk is "
|
||||
"still required to be removed, please specify its "
|
||||
"exact name on the filesystem."
|
||||
),
|
||||
)
|
||||
# No row, no folder requested — nothing to do
|
||||
return DeleteSeriesResult(
|
||||
success=False,
|
||||
key=key,
|
||||
name="",
|
||||
folder_path=None,
|
||||
deleted_from_database=False,
|
||||
deleted_folder=False,
|
||||
database_error=None,
|
||||
folder_error=None,
|
||||
message=f"Series '{key}' not found.",
|
||||
)
|
||||
|
||||
series_id = series.id
|
||||
series_name = series.name
|
||||
folder_path = series.folder
|
||||
series_id = series.id
|
||||
series_name = series.name
|
||||
folder_path = series.folder
|
||||
|
||||
result = DeleteSeriesResult(
|
||||
success=True,
|
||||
@@ -1710,7 +1884,22 @@ class AnimeService:
|
||||
message="",
|
||||
)
|
||||
|
||||
# --- Database deletion ---
|
||||
# --- Filesystem deletion (do FIRST so a failure preserves the DB row) ---
|
||||
if delete_folder and folder_path:
|
||||
self._delete_folder_at_path(folder_path, key, result)
|
||||
# If folder delete was requested but failed, abort before
|
||||
# removing the DB row so the user can retry.
|
||||
if not result.deleted_folder and result.folder_error:
|
||||
result.success = False
|
||||
self._build_delete_message(result)
|
||||
logger.warning(
|
||||
"Delete series aborted - folder delete failed; DB row preserved: "
|
||||
"key=%s folder_error=%s",
|
||||
key, result.folder_error,
|
||||
)
|
||||
return result
|
||||
|
||||
# --- Database deletion (do AFTER folder delete) ---
|
||||
if delete_database:
|
||||
try:
|
||||
async with get_db_session() as db:
|
||||
@@ -1738,6 +1927,35 @@ class AnimeService:
|
||||
except Exception: # pylint: disable=broad-except
|
||||
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
|
||||
try:
|
||||
await self._broadcast_series_deleted(key, series_name)
|
||||
@@ -1747,49 +1965,167 @@ class AnimeService:
|
||||
key, exc,
|
||||
)
|
||||
|
||||
# --- Filesystem deletion ---
|
||||
if delete_folder and folder_path:
|
||||
# Resolve absolute path and validate it is within base directory
|
||||
abs_folder = _os.path.abspath(folder_path)
|
||||
base_dir = _os.path.abspath(self._directory)
|
||||
|
||||
if not is_safe_path(base_dir, abs_folder):
|
||||
# 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(
|
||||
"Blocked unsafe folder delete attempt: key=%s path=%s base=%s",
|
||||
key, abs_folder, base_dir,
|
||||
"Failed to broadcast series_list_changed after delete: "
|
||||
"key=%s error=%s",
|
||||
key, exc,
|
||||
)
|
||||
result.folder_error = (
|
||||
f"Path '{abs_folder}' is outside the anime directory "
|
||||
f"'{base_dir}' and will not be deleted."
|
||||
)
|
||||
result.success = False
|
||||
elif not _os.path.isdir(abs_folder):
|
||||
logger.warning(
|
||||
"Delete folder skipped - path does not exist: key=%s path=%s",
|
||||
key, abs_folder,
|
||||
)
|
||||
# Not an error; folder might never have existed
|
||||
else:
|
||||
try:
|
||||
logger.info(
|
||||
"Deleting series folder: key=%s path=%s",
|
||||
key, abs_folder,
|
||||
)
|
||||
shutil.rmtree(abs_folder)
|
||||
logger.info(
|
||||
"Deleted series folder: key=%s path=%s",
|
||||
key, abs_folder,
|
||||
)
|
||||
result.deleted_folder = True
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to delete series folder: key=%s path=%s error=%s",
|
||||
key, abs_folder, str(exc),
|
||||
)
|
||||
result.folder_error = str(exc)
|
||||
result.success = False
|
||||
|
||||
# --- Build message ---
|
||||
self._build_delete_message(result)
|
||||
|
||||
logger.info(
|
||||
"Delete series completed: key=%s deleted_db=%s deleted_folder=%s",
|
||||
key, result.deleted_from_database, result.deleted_folder,
|
||||
)
|
||||
return result
|
||||
|
||||
def _delete_folder_at_path(self, folder_path, key, result):
|
||||
"""Resolve ``folder_path`` against the configured anime directory
|
||||
and attempt to remove it. Updates ``result`` in place.
|
||||
|
||||
Resolves relative paths against ``self._directory`` so the safety
|
||||
check operates on the real intended target (the process's current
|
||||
working directory is not used as the base; in containers CWD may
|
||||
differ from the anime directory, e.g. /app vs /data).
|
||||
"""
|
||||
import os as _os
|
||||
import shutil
|
||||
|
||||
# Resolve absolute path and validate it is within base directory.
|
||||
#
|
||||
# Important: `folder_path` stored in the database is the relative
|
||||
# folder name (e.g. "Beyblade Burst (2016)"), not an absolute path.
|
||||
# If we feed a relative path to os.path.abspath() it gets joined
|
||||
# against the process's current working directory — which may be
|
||||
# /app inside the container while the anime directory is /data,
|
||||
# producing e.g. "/app/Beyblade Burst (2016)" and tripping the
|
||||
# safe-path check below for what is actually a valid deletion.
|
||||
# Resolve relative paths against the configured anime directory
|
||||
# so the safety check operates on the real intended target.
|
||||
from src.server.utils.filesystem import is_safe_path
|
||||
|
||||
base_dir = _os.path.abspath(self._directory)
|
||||
if _os.path.isabs(folder_path):
|
||||
abs_folder = _os.path.abspath(folder_path)
|
||||
else:
|
||||
abs_folder = _os.path.abspath(_os.path.join(base_dir, folder_path))
|
||||
|
||||
if not is_safe_path(base_dir, abs_folder):
|
||||
logger.warning(
|
||||
"Blocked unsafe folder delete attempt: key=%s path=%s base=%s",
|
||||
key, abs_folder, base_dir,
|
||||
)
|
||||
result.folder_error = (
|
||||
f"Path '{abs_folder}' is outside the anime directory "
|
||||
f"'{base_dir}' and will not be deleted."
|
||||
)
|
||||
result.success = False
|
||||
return
|
||||
if not _os.path.isdir(abs_folder):
|
||||
logger.warning(
|
||||
"Delete folder skipped - path does not exist: key=%s path=%s",
|
||||
key, abs_folder,
|
||||
)
|
||||
# Not an error; folder might never have existed
|
||||
return
|
||||
try:
|
||||
logger.info(
|
||||
"Deleting series folder: key=%s path=%s",
|
||||
key, abs_folder,
|
||||
)
|
||||
shutil.rmtree(abs_folder)
|
||||
logger.info(
|
||||
"Deleted series folder: key=%s path=%s",
|
||||
key, abs_folder,
|
||||
)
|
||||
result.deleted_folder = True
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to delete series folder: key=%s path=%s error=%s",
|
||||
key, abs_folder, str(exc),
|
||||
)
|
||||
result.folder_error = str(exc)
|
||||
result.success = False
|
||||
|
||||
def _find_orphan_folder_for_key(self, key: str):
|
||||
"""Locate a folder under ``self._directory`` that uniquely matches
|
||||
the given series ``key``.
|
||||
|
||||
Used as a recovery path when the DB row is gone but the on-disk
|
||||
folder still exists (orphaned by a previous partial delete).
|
||||
|
||||
Matching strategy: for each immediate subdirectory of the anime
|
||||
directory, strip a trailing ``(YYYY)`` year suffix if present and
|
||||
then compare the normalized form (lowercased, non-alphanumerics
|
||||
removed, key's hyphens treated as separators) against the key.
|
||||
Returns the folder name (relative to the anime directory) of the
|
||||
unique match, or ``None`` if zero or multiple folders match.
|
||||
|
||||
Returns:
|
||||
The matching relative folder name, or None when no unique
|
||||
match exists. Returning None is the safe default — it forces
|
||||
the caller to surface an explicit error rather than risk
|
||||
deleting the wrong folder.
|
||||
"""
|
||||
import os as _os
|
||||
import re
|
||||
|
||||
if not self._directory or not _os.path.isdir(self._directory):
|
||||
return None
|
||||
|
||||
def _normalize(value: str) -> str:
|
||||
# Drop an optional trailing "(YYYY)" or "(YYYY)"-with-content
|
||||
# suffix the user might have added for disambiguation. We only
|
||||
# strip a single trailing parenthesised group to avoid eating
|
||||
# legitimate parts of the title.
|
||||
value = re.sub(r"\s*\([^)]*\)\s*$", "", value or "")
|
||||
# Lowercase, replace hyphens/underscores with empty so they
|
||||
# line up with the way the key is constructed.
|
||||
lowered = value.lower().replace("-", "").replace("_", "")
|
||||
# Keep only alphanumerics (which preserves CJK characters
|
||||
# because \w in unicode mode includes them; using explicit
|
||||
# alphanumerics is safer cross-platform).
|
||||
return re.sub(r"[^0-9a-z\u00C0-\uFFFF]", "", lowered)
|
||||
|
||||
target = _normalize(key)
|
||||
if not target:
|
||||
return None
|
||||
|
||||
candidates = []
|
||||
try:
|
||||
entries = _os.listdir(self._directory)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
for entry in entries:
|
||||
full = _os.path.join(self._directory, entry)
|
||||
if not _os.path.isdir(full):
|
||||
continue
|
||||
if _normalize(entry) == target:
|
||||
candidates.append(entry)
|
||||
|
||||
if len(candidates) == 1:
|
||||
return candidates[0]
|
||||
if len(candidates) > 1:
|
||||
logger.warning(
|
||||
"Orphan folder recovery: ambiguous match for key=%s "
|
||||
"found %d candidate folders: %s",
|
||||
key, len(candidates), candidates,
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _build_delete_message(result) -> None:
|
||||
"""Assemble the human-readable ``result.message`` from flags/errors."""
|
||||
parts = []
|
||||
if result.deleted_from_database and not result.database_error:
|
||||
parts.append("removed from database")
|
||||
@@ -1805,12 +2141,6 @@ class AnimeService:
|
||||
else:
|
||||
result.message = "No action taken."
|
||||
|
||||
logger.info(
|
||||
"Delete series completed: key=%s deleted_db=%s deleted_folder=%s",
|
||||
key, result.deleted_from_database, result.deleted_folder,
|
||||
)
|
||||
return result
|
||||
|
||||
async def _broadcast_series_deleted(self, key: str, name: str) -> None:
|
||||
"""Broadcast series_deleted event via WebSocket."""
|
||||
try:
|
||||
@@ -1828,6 +2158,28 @@ class AnimeService:
|
||||
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:
|
||||
"""Factory used for creating AnimeService with a SeriesApp instance."""
|
||||
|
||||
@@ -119,6 +119,79 @@ class FolderNamingService:
|
||||
|
||||
return await self._execute_rename(series, folder, target_folder)
|
||||
|
||||
@staticmethod
|
||||
def _merge_folder_into_target(source: str, target: str) -> dict:
|
||||
"""Merge a source folder's contents into an existing target folder.
|
||||
|
||||
Walks the source tree and moves every file into the matching path under
|
||||
the target. When a destination file already exists, the source copy is
|
||||
removed (the target version wins; we don't keep duplicates). When the
|
||||
source tree is fully consumed, the (now-empty) source directory is
|
||||
removed.
|
||||
|
||||
Both paths must be absolute and ``target`` must already exist on disk.
|
||||
|
||||
Returns a summary dict with ``moved`` (file count), ``skipped`` (file
|
||||
count where target already had a copy), and ``removed_source`` (bool).
|
||||
Caller is responsible for any DB / cache updates that depend on the
|
||||
outcome.
|
||||
"""
|
||||
if not os.path.isdir(source):
|
||||
return {"moved": 0, "skipped": 0, "removed_source": False}
|
||||
if not os.path.isdir(target):
|
||||
raise ValueError(f"target does not exist: {target}")
|
||||
|
||||
moved = 0
|
||||
skipped = 0
|
||||
for root, _dirs, files in os.walk(source):
|
||||
rel_root = os.path.relpath(root, source)
|
||||
dest_root = (
|
||||
target if rel_root == "."
|
||||
else os.path.join(target, rel_root)
|
||||
)
|
||||
os.makedirs(dest_root, exist_ok=True)
|
||||
for name in files:
|
||||
src_file = os.path.join(root, name)
|
||||
dest_file = os.path.join(dest_root, name)
|
||||
if os.path.exists(dest_file):
|
||||
# Target wins — never overwrite existing content. Remove
|
||||
# the orphaned source copy so cleanup below can rmdir it.
|
||||
try:
|
||||
os.remove(src_file)
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"merge: could not remove duplicate %s: %s",
|
||||
src_file, exc,
|
||||
)
|
||||
skipped += 1
|
||||
logger.warning(
|
||||
"merge: skipping %s (target already has %s)",
|
||||
src_file, dest_file,
|
||||
)
|
||||
continue
|
||||
shutil.move(src_file, dest_file)
|
||||
moved += 1
|
||||
|
||||
# Try to remove the (now empty) source tree. Walk bottom-up so leaf
|
||||
# directories are removed before their parents.
|
||||
removed_source = False
|
||||
for root, dirs, files in os.walk(source, topdown=False):
|
||||
for d in dirs:
|
||||
try:
|
||||
os.rmdir(os.path.join(root, d))
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.rmdir(source)
|
||||
removed_source = True
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"merge: could not remove source directory %s: %s",
|
||||
source, exc,
|
||||
)
|
||||
|
||||
return {"moved": moved, "skipped": skipped, "removed_source": removed_source}
|
||||
|
||||
async def _execute_rename(self, series, old_folder: str, target_folder: str) -> FolderRenameResult:
|
||||
key = series.key
|
||||
|
||||
@@ -132,16 +205,72 @@ class FolderNamingService:
|
||||
if not os.path.isdir(old_path):
|
||||
return FolderRenameResult(key=key, old_folder=old_folder, new_folder=None, success=False, skipped=False, reason="source folder does not exist on disk")
|
||||
|
||||
# If the target already exists, merge source into it instead of bailing.
|
||||
# A bare folder ('Naruto') sitting next to the year-suffixed one
|
||||
# ('Naruto (2019)') is how we get a series "added twice". Merging
|
||||
# makes the rename succeed and removes the orphan folder.
|
||||
if os.path.isdir(target_path):
|
||||
return FolderRenameResult(key=key, old_folder=old_folder, new_folder=None, success=False, skipped=False, reason="target folder already exists on disk")
|
||||
try:
|
||||
summary = self._merge_folder_into_target(old_path, target_path)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to merge %s -> %s for %s: %s",
|
||||
old_folder, target_folder, key, exc,
|
||||
)
|
||||
return FolderRenameResult(
|
||||
key=key, old_folder=old_folder, new_folder=None,
|
||||
success=False, skipped=False,
|
||||
reason=f"merge failed: {exc}",
|
||||
)
|
||||
logger.info(
|
||||
"Merged folder %s -> %s for series %s (moved=%d skipped=%d removed_source=%s)",
|
||||
old_folder, target_folder, key,
|
||||
summary["moved"], summary["skipped"], summary["removed_source"],
|
||||
)
|
||||
|
||||
# Update in-memory cache (best-effort)
|
||||
try:
|
||||
from src.server.utils.dependencies import get_series_app
|
||||
series_app = get_series_app()
|
||||
if key in series_app.list.keyDict:
|
||||
series_app.list.keyDict[key].folder = target_folder
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to update in-memory cache for %s: %s", key, exc)
|
||||
|
||||
# Update database
|
||||
async with _get_db_session() as db:
|
||||
db_series = await AnimeSeriesService.get_by_key(db, key)
|
||||
if db_series:
|
||||
await AnimeSeriesService.update(db, series_id=db_series.id, folder=target_folder)
|
||||
logger.debug("Updated DB folder for %s to %s", key, target_folder)
|
||||
|
||||
# If source couldn't be removed (still had unexpected files) the
|
||||
# state is worse than the original orphan, so surface that as a
|
||||
# warning in the result while still reporting success.
|
||||
note = None
|
||||
if not summary["removed_source"]:
|
||||
note = (
|
||||
f"merged (moved={summary['moved']}, skipped={summary['skipped']}) "
|
||||
f"but source folder could not be removed"
|
||||
)
|
||||
elif summary["skipped"]:
|
||||
note = (
|
||||
f"merged (moved={summary['moved']}, "
|
||||
f"kept target copies for {summary['skipped']} file(s))"
|
||||
)
|
||||
return FolderRenameResult(
|
||||
key=key, old_folder=old_folder, new_folder=target_folder,
|
||||
success=True, skipped=False, reason=note,
|
||||
)
|
||||
|
||||
# Target doesn't exist — plain rename.
|
||||
try:
|
||||
shutil.move(old_path, target_path)
|
||||
logger.info("Renamed folder %s -> %s for series %s", old_folder, target_folder, key)
|
||||
|
||||
# Update in-memory cache
|
||||
try:
|
||||
from src.server.SeriesApp import get_series_app
|
||||
from src.server.utils.dependencies import get_series_app
|
||||
series_app = get_series_app()
|
||||
if key in series_app.list.keyDict:
|
||||
series_app.list.keyDict[key].folder = target_folder
|
||||
|
||||
@@ -689,6 +689,35 @@ class WebSocketService:
|
||||
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
|
||||
_websocket_service: Optional[WebSocketService] = None
|
||||
|
||||
@@ -113,32 +113,54 @@ def sanitize_folder_name(
|
||||
|
||||
def is_safe_path(base_path: str, target_path: str) -> bool:
|
||||
"""Check if target_path is safely within base_path.
|
||||
|
||||
|
||||
Prevents path traversal attacks by ensuring the target path
|
||||
is actually within the base path after resolution.
|
||||
|
||||
|
||||
Note on relative paths: a relative ``target_path`` is interpreted
|
||||
as relative to ``base_path``, *not* to the process's current
|
||||
working directory. This mirrors how callers use this helper:
|
||||
they pass a configured base directory and a folder name stored
|
||||
alongside it (e.g. the series ``folder`` column in the database
|
||||
holds a relative name like ``"Beyblade Burst (2016)"``, and the
|
||||
anime directory is configured separately). Without this, a
|
||||
relative target would be resolved against the process CWD —
|
||||
which can differ from ``base_path`` (the FastAPI app runs with
|
||||
CWD=/app while the anime directory is mounted at /data), and
|
||||
the helper would incorrectly reject the path as outside the
|
||||
base. Absolute ``target_path`` values are validated against
|
||||
``base_path`` directly.
|
||||
|
||||
Args:
|
||||
base_path: The base directory that should contain the target
|
||||
target_path: The path to validate
|
||||
|
||||
target_path: The path to validate (absolute, or relative to base_path)
|
||||
|
||||
Returns:
|
||||
bool: True if target_path is safely within base_path
|
||||
|
||||
|
||||
Example:
|
||||
>>> is_safe_path("/anime", "/anime/Attack on Titan")
|
||||
True
|
||||
>>> is_safe_path("/anime", "Attack on Titan") # relative -> /anime/Attack on Titan
|
||||
True
|
||||
>>> is_safe_path("/anime", "/anime/../etc/passwd")
|
||||
False
|
||||
"""
|
||||
# Resolve to absolute paths
|
||||
# Resolve base to an absolute path
|
||||
base_resolved = os.path.abspath(base_path)
|
||||
target_resolved = os.path.abspath(target_path)
|
||||
|
||||
|
||||
# Resolve target relative to the base (not the process CWD) when it is
|
||||
# supplied as a relative path. Absolute targets are validated as-is.
|
||||
if os.path.isabs(target_path):
|
||||
target_resolved = os.path.abspath(target_path)
|
||||
else:
|
||||
target_resolved = os.path.abspath(os.path.join(base_resolved, target_path))
|
||||
|
||||
# Check that target starts with base (with trailing separator)
|
||||
base_with_sep = base_resolved + os.sep
|
||||
return (
|
||||
target_resolved == base_resolved or
|
||||
target_resolved.startswith(base_with_sep)
|
||||
target_resolved == base_resolved
|
||||
or target_resolved.startswith(base_with_sep)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -300,6 +300,9 @@ AniWorld.DeleteModal = (function() {
|
||||
var result = await response.json();
|
||||
console.info('[DeleteModal] Delete succeeded:', result);
|
||||
|
||||
// Capture key before hide() nulls currentKey
|
||||
var deletedKey = currentKey;
|
||||
|
||||
// Show success message based on what was deleted
|
||||
var msgParts = [];
|
||||
if (result.deleted_from_database) msgParts.push('removed from database');
|
||||
@@ -311,11 +314,17 @@ AniWorld.DeleteModal = (function() {
|
||||
: 'Delete completed.';
|
||||
|
||||
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();
|
||||
|
||||
// Remove the card from the grid directly
|
||||
if (AniWorld.SeriesManager && AniWorld.SeriesManager.removeSeries) {
|
||||
AniWorld.SeriesManager.removeSeries(currentKey);
|
||||
AniWorld.SeriesManager.removeSeries(deletedKey);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
|
||||
@@ -253,8 +253,24 @@ AniWorld.SelectionManager = (function() {
|
||||
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') {
|
||||
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 {
|
||||
console.error('Failed to add to queue:', data);
|
||||
failedSeries.push(key);
|
||||
|
||||
@@ -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
|
||||
return {
|
||||
init: init,
|
||||
@@ -596,6 +607,7 @@ AniWorld.SeriesManager = (function() {
|
||||
updateSeriesLoadingStatus: updateSeriesLoadingStatus,
|
||||
updateSingleSeries: updateSingleSeries,
|
||||
updateSeriesKey: updateSeriesKey,
|
||||
removeSeries: removeSeries
|
||||
removeSeries: removeSeries,
|
||||
reloadSeries: reloadSeries
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -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
|
||||
socket.on(WS_EVENTS.DOWNLOAD_STARTED, function(data) {
|
||||
isDownloading = true;
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
* - loadSeries(key) : fetch settings for a series key
|
||||
* - saveSettings(opts) : PUT settings, opts.applyToNfo / opts.renameDisk
|
||||
* - regenerateNfo() : POST regenerate-nfo endpoint
|
||||
* - viewNfoContent() : GET raw tvshow.nfo XML into the preview <pre>
|
||||
* - validateField(name, value) : client-side validation, returns error string or null
|
||||
* - populateForm(data) : fill the form from a payload
|
||||
* - showSaveSuccess(msg) : success toast
|
||||
@@ -632,6 +633,7 @@ AniWorld.AnimeSettingsManager = (function () {
|
||||
loadSeries: loadSeries,
|
||||
saveSettings: saveSettings,
|
||||
regenerateNfo: regenerateNfo,
|
||||
viewNfoContent: viewNfoContent,
|
||||
validateField: validateField,
|
||||
populateForm: populateForm,
|
||||
showSaveSuccess: showSaveSuccess,
|
||||
|
||||
@@ -105,6 +105,10 @@ AniWorld.Constants = (function() {
|
||||
SERIES_UPDATED: 'series_updated',
|
||||
SERIES_LOADING_UPDATE: 'series_loading_update',
|
||||
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_RESCAN_STARTED: 'scheduled_rescan_started',
|
||||
|
||||
@@ -4,6 +4,7 @@ Covers the live endpoints in src/server/api/nfo.py:
|
||||
- GET /api/nfo/{key}/diagnostics
|
||||
- POST /api/nfo/{key}/repair
|
||||
- GET /api/nfo/{key}/validate
|
||||
- GET /api/nfo/{key}/content (re-introduced — used by Anime Settings 'View NFO XML')
|
||||
- GET /api/nfo/needs-repair
|
||||
- POST /api/nfo/batch/repair
|
||||
|
||||
@@ -12,6 +13,12 @@ Note: Legacy endpoints referenced in v1.x (e.g. /api/nfo/{id}/check,
|
||||
in the codebase — they were replaced by the consolidated diagnostic,
|
||||
repair, validate, needs-repair, batch/repair endpoints and the new
|
||||
Anime Settings page (see tests/api/test_anime_settings_endpoints.py).
|
||||
|
||||
Auth note: tests/conftest.py's autouse ``reset_auth_and_rate_limits``
|
||||
fixture configures the master password with ``TestPass123!`` before every
|
||||
test. The per-file ``reset_auth`` autouse fixture that used to live here
|
||||
was removed because it wiped the conftest's setup and made any test that
|
||||
needed an authenticated client fail with a stale-hash login error.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
@@ -19,16 +26,6 @@ import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from src.server.fastapi_app import app
|
||||
from src.server.services.auth_service import auth_service
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_auth():
|
||||
auth_service._hash = None
|
||||
auth_service._failed.clear()
|
||||
yield
|
||||
auth_service._hash = None
|
||||
auth_service._failed.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -38,19 +35,19 @@ async def client():
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def authenticated_client(client):
|
||||
await client.post(
|
||||
"/api/auth/setup",
|
||||
json={"master_password": "TestPassword123!"},
|
||||
)
|
||||
async def _login(client: AsyncClient) -> str:
|
||||
"""Log in with the master password configured by conftest and
|
||||
return the bearer token. Sets the ``Authorization`` header on the
|
||||
client as a side benefit so the caller can ``await client.get(...)``
|
||||
immediately."""
|
||||
resp = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"password": "TestPassword123!"},
|
||||
json={"password": "TestPass123!"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
token = resp.json()["access_token"]
|
||||
client.headers.update({"Authorization": f"Bearer {token}"})
|
||||
yield client
|
||||
return token
|
||||
|
||||
|
||||
class TestNFOAuthRequirements:
|
||||
@@ -84,6 +81,119 @@ class TestNFOAuthRequirements:
|
||||
)
|
||||
assert resp.status_code in (401, 503)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_content_requires_auth(self, client):
|
||||
"""GET /api/nfo/{key}/content (used by the Anime Settings page
|
||||
'View NFO XML' button) must require authentication."""
|
||||
resp = await client.get("/api/nfo/any-key/content")
|
||||
assert resp.status_code in (401, 503)
|
||||
|
||||
|
||||
class TestNFOContentEndpoint:
|
||||
"""Behavioural tests for GET /api/nfo/{key}/content.
|
||||
|
||||
Covers the success path and the two 404 cases (unknown series,
|
||||
missing tvshow.nfo) the Anime Settings page relies on."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_anime_service(self):
|
||||
"""Replace the FastAPI get_anime_service dependency with a mock.
|
||||
Yields the mock so individual tests can configure ``list_series_with_filters``."""
|
||||
from src.server.utils import dependencies as deps
|
||||
|
||||
service = Mock()
|
||||
service.list_series_with_filters = AsyncMock(return_value=[])
|
||||
app.dependency_overrides[deps.get_anime_service] = lambda: service
|
||||
yield service
|
||||
app.dependency_overrides.pop(deps.get_anime_service, None)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_xml_for_series_with_nfo(
|
||||
self, client, tmp_path, monkeypatch, mock_anime_service
|
||||
):
|
||||
"""Happy path: existing tvshow.nfo is returned verbatim inside
|
||||
the JSON wrapper the JS uses (``data.content``)."""
|
||||
from src.config import settings as settings_module
|
||||
|
||||
# Point settings.anime_directory at a temp dir
|
||||
monkeypatch.setattr(
|
||||
settings_module.settings,
|
||||
"anime_directory",
|
||||
str(tmp_path),
|
||||
raising=False,
|
||||
)
|
||||
|
||||
# Build a fake folder + tvshow.nfo on disk
|
||||
folder = "Naruto (2002)"
|
||||
series_dir = tmp_path / folder
|
||||
series_dir.mkdir()
|
||||
xml = (
|
||||
"<?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:
|
||||
"""Verify the response models use the renamed classes (regression
|
||||
|
||||
@@ -318,6 +318,121 @@ class TestDeleteModalSeriesManagerIntegration:
|
||||
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:
|
||||
"""Tests for SERIES_DELETED WebSocket event constant."""
|
||||
|
||||
|
||||
@@ -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()
|
||||
// -------------------------------------------------------------------
|
||||
@@ -493,6 +570,7 @@ describe('AnimeSettingsManager', () => {
|
||||
expect(typeof manager.loadSeries).toBe('function');
|
||||
expect(typeof manager.saveSettings).toBe('function');
|
||||
expect(typeof manager.regenerateNfo).toBe('function');
|
||||
expect(typeof manager.viewNfoContent).toBe('function');
|
||||
expect(typeof manager.validateField).toBe('function');
|
||||
expect(typeof manager.populateForm).toBe('function');
|
||||
expect(typeof manager.showSaveSuccess).toBe('function');
|
||||
|
||||
@@ -966,6 +966,121 @@ class TestSaveAndLoadDB:
|
||||
mock_create.assert_called_once()
|
||||
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
|
||||
async def test_save_scan_results_updates_existing(
|
||||
self, anime_service
|
||||
|
||||
343
tests/unit/test_clean_duplicate_episodes_cli.py
Normal file
343
tests/unit/test_clean_duplicate_episodes_cli.py
Normal 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
|
||||
@@ -308,6 +308,185 @@ class TestAnimeSeries:
|
||||
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:
|
||||
"""Test cases for Episode model."""
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Unit tests for AnimeService.delete_series()."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@@ -328,6 +329,69 @@ class TestDeleteSeriesService:
|
||||
assert result.folder_error is not None
|
||||
assert "outside" in result.folder_error.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_series_relative_folder_with_different_cwd(
|
||||
self, anime_service, tmp_path
|
||||
):
|
||||
"""Regression: delete_series must work when the stored folder is relative
|
||||
and the process CWD differs from directory_to_search.
|
||||
|
||||
In the container the FastAPI app runs with CWD=/app while the anime
|
||||
directory is /data. The DB stores the relative folder name (e.g.
|
||||
"Beyblade Burst (2016)"). The old code called
|
||||
``os.path.abspath(folder)`` which joined against CWD=/app and
|
||||
produced "/app/Beyblade Burst (2016)", which was then rejected as
|
||||
outside the /data base. The fix resolves relative paths against
|
||||
the configured anime directory instead.
|
||||
"""
|
||||
safe_base = tmp_path / "data"
|
||||
safe_base.mkdir()
|
||||
series_folder = safe_base / "Beyblade Burst (2016)"
|
||||
series_folder.mkdir()
|
||||
|
||||
anime_service._directory = str(safe_base)
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_ctx = _make_db_ctx(mock_session)
|
||||
|
||||
# Stored folder is RELATIVE (matches what's actually in the DB)
|
||||
mock_series = MagicMock()
|
||||
mock_series.key = "beyblade-burst"
|
||||
mock_series.name = "Beyblade Burst"
|
||||
mock_series.folder = "Beyblade Burst (2016)"
|
||||
mock_series.id = 336
|
||||
|
||||
# Simulate process CWD differing from anime dir (container case:
|
||||
# CWD=/app while anime dir is /data). Use "/" as a stable, always-
|
||||
# existing CWD distinct from tmp_path.
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir("/")
|
||||
with patch(
|
||||
"src.server.database.connection.get_db_session",
|
||||
return_value=mock_ctx,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_series,
|
||||
):
|
||||
result = await anime_service.delete_series(
|
||||
key="beyblade-burst",
|
||||
delete_database=False,
|
||||
delete_folder=True,
|
||||
)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
# Folder MUST be deleted successfully
|
||||
assert result.deleted_folder is True, (
|
||||
f"folder delete failed: success={result.success} "
|
||||
f"folder_error={result.folder_error!r}"
|
||||
)
|
||||
assert result.folder_error is None
|
||||
assert result.success is True
|
||||
assert not series_folder.exists()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Error handling
|
||||
# ------------------------------------------------------------------
|
||||
@@ -517,3 +581,295 @@ class TestDeleteSeriesService:
|
||||
key="test-key",
|
||||
name="Test Series",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Deletion order: filesystem first, database second
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_series_db_preserved_when_folder_fails(
|
||||
self, anime_service, tmp_path
|
||||
):
|
||||
"""When both flags are True and folder delete fails, the DB row
|
||||
is preserved so the user can retry after fixing the underlying issue.
|
||||
|
||||
Previously, the database row was deleted first and the folder
|
||||
second. If the folder delete failed (e.g. the old CWD-relative-path
|
||||
bug, or any future permission/path error), the row was already
|
||||
gone — leaving an orphan folder on disk that could not be cleaned
|
||||
up through the normal delete flow.
|
||||
"""
|
||||
# Folder exists but we'll force shutil.rmtree to fail
|
||||
series_folder = tmp_path / "Test Series"
|
||||
series_folder.mkdir()
|
||||
anime_service._directory = str(tmp_path)
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_ctx = _make_db_ctx(mock_session)
|
||||
|
||||
mock_series = MagicMock()
|
||||
mock_series.key = "test-key"
|
||||
mock_series.name = "Test Series"
|
||||
mock_series.folder = "Test Series"
|
||||
mock_series.id = 7
|
||||
|
||||
db_delete_mock = AsyncMock(return_value=True)
|
||||
|
||||
with patch(
|
||||
"src.server.database.connection.get_db_session",
|
||||
return_value=mock_ctx,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_series,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.delete",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=db_delete_mock,
|
||||
), patch(
|
||||
"shutil.rmtree",
|
||||
side_effect=OSError("Permission denied"),
|
||||
):
|
||||
result = await anime_service.delete_series(
|
||||
key="test-key",
|
||||
delete_database=True,
|
||||
delete_folder=True,
|
||||
)
|
||||
|
||||
# Folder delete failed → DB row MUST be preserved
|
||||
assert result.deleted_folder is False
|
||||
assert result.folder_error is not None
|
||||
assert result.deleted_from_database is False, (
|
||||
"DB row was deleted despite folder delete failure — "
|
||||
"user would lose ability to retry the delete"
|
||||
)
|
||||
db_delete_mock.assert_not_called()
|
||||
assert result.success is False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# In-memory keyDict cache eviction
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_series_evicts_in_memory_cache(
|
||||
self, anime_service, mock_series_app
|
||||
):
|
||||
"""After a successful DB delete, the in-memory SerieList.keyDict
|
||||
entry for that series must be removed.
|
||||
|
||||
The /api/anime list endpoint reads from SeriesApp.list.keyDict
|
||||
via list_series_with_filters(). If the cache is not pruned, the
|
||||
deleted series keeps appearing in the listing on every page
|
||||
reload — exactly the Beyblade Burst bug.
|
||||
"""
|
||||
from src.server.database.SerieList import SerieList
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_ctx = _make_db_ctx(mock_session)
|
||||
|
||||
mock_series = MagicMock()
|
||||
mock_series.key = "beyblade-burst-rise"
|
||||
mock_series.name = "Beyblade Burst Rise"
|
||||
mock_series.folder = "Beyblade Burst Rise (2016)"
|
||||
mock_series.id = 487
|
||||
|
||||
# Use a real SerieList instance — the same type the production
|
||||
# code mutates — so the eviction actually exercises the real
|
||||
# remove() method (a MagicMock would just return more mocks).
|
||||
real_list = SerieList(str(mock_series_app.directory_to_search))
|
||||
real_list.keyDict["beyblade-burst-rise"] = mock_series
|
||||
mock_series_app.list = real_list
|
||||
|
||||
anime_service._websocket_service = MagicMock()
|
||||
anime_service._websocket_service.broadcast_series_deleted = AsyncMock()
|
||||
anime_service._websocket_service.broadcast_series_list_changed = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"src.server.database.connection.get_db_session",
|
||||
return_value=mock_ctx,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_series,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.delete",
|
||||
new_callable=AsyncMock,
|
||||
return_value=True,
|
||||
):
|
||||
result = await anime_service.delete_series(
|
||||
key="beyblade-burst-rise",
|
||||
delete_database=True,
|
||||
delete_folder=False,
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert "beyblade-burst-rise" not in real_list.keyDict, (
|
||||
"In-memory cache still contains the deleted series — "
|
||||
"/api/anime will keep returning it after a page reload"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_series_broadcasts_series_list_changed(
|
||||
self, anime_service, mock_series_app
|
||||
):
|
||||
"""A stronger ``series_list_changed`` broadcast fires after delete
|
||||
so any connected client can re-sync without relying on the more
|
||||
specific ``series_deleted`` event reaching them.
|
||||
"""
|
||||
mock_session = AsyncMock()
|
||||
mock_ctx = _make_db_ctx(mock_session)
|
||||
|
||||
mock_series = MagicMock()
|
||||
mock_series.key = "test-key"
|
||||
mock_series.name = "Test Series"
|
||||
mock_series.folder = "Test Series (2023)"
|
||||
mock_series.id = 1
|
||||
|
||||
mock_series_app.list.keyDict = {"test-key": mock_series}
|
||||
|
||||
anime_service._websocket_service = MagicMock()
|
||||
anime_service._websocket_service.broadcast_series_deleted = AsyncMock()
|
||||
anime_service._websocket_service.broadcast_series_list_changed = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"src.server.database.connection.get_db_session",
|
||||
return_value=mock_ctx,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_series,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.delete",
|
||||
new_callable=AsyncMock,
|
||||
return_value=True,
|
||||
):
|
||||
await anime_service.delete_series(
|
||||
key="test-key",
|
||||
delete_database=True,
|
||||
delete_folder=False,
|
||||
)
|
||||
|
||||
anime_service._websocket_service.broadcast_series_list_changed.assert_called_once()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Orphan folder recovery (DB row gone, folder still on disk)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_series_orphan_folder_recovery(self, anime_service, tmp_path):
|
||||
"""If the DB row is gone but a matching folder is still on disk,
|
||||
``delete_folder=True`` removes the orphan folder.
|
||||
|
||||
Reproduces the Beyblade Burst scenario: previous delete attempt
|
||||
removed the DB row (delete_database=True) but the folder delete
|
||||
silently failed (path math bug). Retrying with delete_folder=True
|
||||
should clean up the orphan via a key-based folder scan.
|
||||
"""
|
||||
# Simulate the on-disk anime directory
|
||||
anime_dir = tmp_path / "anime"
|
||||
anime_dir.mkdir()
|
||||
orphan = anime_dir / "Beyblade Burst (2016)"
|
||||
orphan.mkdir()
|
||||
(orphan / "episode.mp4").write_text("x")
|
||||
|
||||
anime_service._directory = str(anime_dir)
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_ctx = _make_db_ctx(mock_session)
|
||||
|
||||
# DB lookup returns None (row already gone)
|
||||
with patch(
|
||||
"src.server.database.connection.get_db_session",
|
||||
return_value=mock_ctx,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
):
|
||||
result = await anime_service.delete_series(
|
||||
key="beyblade-burst",
|
||||
delete_database=False,
|
||||
delete_folder=True,
|
||||
)
|
||||
|
||||
# Orphan folder recovered via key-match scan
|
||||
assert result.deleted_folder is True, (
|
||||
f"orphan recovery failed: success={result.success} "
|
||||
f"folder_error={result.folder_error!r}"
|
||||
)
|
||||
assert result.folder_error is None
|
||||
assert not orphan.exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_series_orphan_folder_no_match(
|
||||
self, anime_service, tmp_path
|
||||
):
|
||||
"""If DB row is gone and no folder matches the key, return a
|
||||
clear error rather than silently succeeding.
|
||||
"""
|
||||
anime_dir = tmp_path / "anime"
|
||||
anime_dir.mkdir()
|
||||
# Some unrelated folder that does NOT match
|
||||
(anime_dir / "Different Show (2020)").mkdir()
|
||||
anime_service._directory = str(anime_dir)
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_ctx = _make_db_ctx(mock_session)
|
||||
|
||||
with patch(
|
||||
"src.server.database.connection.get_db_session",
|
||||
return_value=mock_ctx,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
):
|
||||
result = await anime_service.delete_series(
|
||||
key="nonexistent-key",
|
||||
delete_database=False,
|
||||
delete_folder=True,
|
||||
)
|
||||
|
||||
assert result.deleted_folder is False
|
||||
assert result.folder_error is not None
|
||||
assert "no folder matching" in result.folder_error.lower()
|
||||
# Unrelated folder untouched
|
||||
assert (anime_dir / "Different Show (2020)").exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_series_orphan_folder_ambiguous(
|
||||
self, anime_service, tmp_path
|
||||
):
|
||||
"""If multiple folders match the same normalized key, refuse to
|
||||
delete any of them (safe default).
|
||||
"""
|
||||
anime_dir = tmp_path / "anime"
|
||||
anime_dir.mkdir()
|
||||
(anime_dir / "Beyblade Burst (2016)").mkdir()
|
||||
(anime_dir / "Beyblade Burst (2019)").mkdir() # also normalizes to "beybladeburst"
|
||||
anime_service._directory = str(anime_dir)
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_ctx = _make_db_ctx(mock_session)
|
||||
|
||||
with patch(
|
||||
"src.server.database.connection.get_db_session",
|
||||
return_value=mock_ctx,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
):
|
||||
result = await anime_service.delete_series(
|
||||
key="beyblade-burst",
|
||||
delete_database=False,
|
||||
delete_folder=True,
|
||||
)
|
||||
|
||||
# Ambiguous → refuse
|
||||
assert result.deleted_folder is False
|
||||
assert result.folder_error is not None
|
||||
assert "no folder matching" in result.folder_error.lower()
|
||||
# Neither folder deleted
|
||||
assert (anime_dir / "Beyblade Burst (2016)").exists()
|
||||
assert (anime_dir / "Beyblade Burst (2019)").exists()
|
||||
|
||||
@@ -213,6 +213,34 @@ class TestIsSafePath:
|
||||
"/anime/Attack on Titan/Season 1/Episode 1"
|
||||
)
|
||||
|
||||
def test_relative_target_resolved_against_base(self):
|
||||
"""Relative targets resolve against the base, not the process CWD.
|
||||
|
||||
Regression test: previously `os.path.abspath(target_path)` would
|
||||
join a relative target against the process's current working
|
||||
directory. When the CWD differed from `base_path` (e.g. the
|
||||
FastAPI app running with CWD=/app while the anime directory is
|
||||
/data), a relative folder name like "Beyblade Burst (2016)"
|
||||
would be resolved to "/app/Beyblade Burst (2016)" and
|
||||
incorrectly rejected as outside the base. The helper now
|
||||
treats a relative target as relative to `base_path`.
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
base = os.path.abspath(tmpdir)
|
||||
# Simulate a process CWD different from base
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir("/")
|
||||
# Relative target inside base should be safe
|
||||
assert is_safe_path(base, "Beyblade Burst (2016)")
|
||||
# Nested relative target should also be safe
|
||||
assert is_safe_path(base, "Beyblade Burst (2016)/Season 1")
|
||||
# Relative traversal (../) must still be rejected even
|
||||
# when resolved against the base
|
||||
assert not is_safe_path(base, "../etc/passwd")
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
|
||||
class TestCreateSafeFolder:
|
||||
"""Test create_safe_folder function."""
|
||||
|
||||
@@ -241,28 +241,157 @@ class TestFolderNamingServiceIntegration:
|
||||
assert call_kwargs["folder"] == "Naruto (1999)"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_when_target_folder_already_exists(
|
||||
async def test_merges_source_into_existing_target_when_target_has_no_overlap(
|
||||
self, tmp_path, mock_db_session, mock_series, mock_settings
|
||||
):
|
||||
"""If 'Naruto (1999)' already exists, rename is skipped."""
|
||||
"""Source 'Naruto' with seasons/episodes, target 'Naruto (1999)' exists empty.
|
||||
|
||||
Source files are moved into target. Empty source directory is removed.
|
||||
DB folder is updated to target. Result is success (renamed).
|
||||
"""
|
||||
anime_dir = tmp_path
|
||||
(anime_dir / "Naruto").mkdir()
|
||||
(anime_dir / "Naruto (1999)").mkdir() # target already exists
|
||||
source = anime_dir / "Naruto"
|
||||
target = anime_dir / "Naruto (1999)"
|
||||
source.mkdir()
|
||||
target.mkdir()
|
||||
(source / "Season 1").mkdir()
|
||||
(source / "Season 1" / "ep01.mp4").touch()
|
||||
(source / "Season 2").mkdir()
|
||||
(source / "Season 2" / "ep01.mp4").touch()
|
||||
|
||||
series = mock_series("key1", "Naruto", 1999)
|
||||
mock_db_session.__aenter__.return_value.__aexit__.return_value = None
|
||||
|
||||
with patch("src.server.services.folder_naming_service.AnimeSeriesService.get_all", new_callable=AsyncMock) as mock_get_all:
|
||||
with patch("src.server.services.folder_naming_service.AnimeSeriesService.get_all", new_callable=AsyncMock) as mock_get_all, \
|
||||
patch("src.server.services.folder_naming_service.AnimeSeriesService.get_by_key", new_callable=AsyncMock) as mock_get_by_key, \
|
||||
patch("src.server.services.folder_naming_service.AnimeSeriesService.update", new_callable=AsyncMock) as mock_update, \
|
||||
patch("src.server.utils.dependencies.get_series_app") as mock_get_app:
|
||||
|
||||
mock_get_all.return_value = [series]
|
||||
db_series = MagicMock()
|
||||
db_series.id = 42
|
||||
mock_get_by_key.return_value = db_series
|
||||
|
||||
app_instance = MagicMock()
|
||||
app_instance.list.keyDict = {"key1": MagicMock()}
|
||||
mock_get_app.return_value = app_instance
|
||||
|
||||
mock_settings.anime_directory = str(anime_dir)
|
||||
|
||||
service = FolderNamingService()
|
||||
report = await service.run()
|
||||
|
||||
assert report.errors == 1
|
||||
assert report.renamed == 0
|
||||
assert report.results[0].reason == "target folder already exists on disk"
|
||||
assert (anime_dir / "Naruto").exists() # source not moved
|
||||
# Outcome: renamed (not error) — source merged into target
|
||||
assert report.renamed == 1
|
||||
assert report.errors == 0
|
||||
assert report.results[0].success is True
|
||||
assert report.results[0].skipped is False
|
||||
assert report.results[0].new_folder == "Naruto (1999)"
|
||||
|
||||
# Source folder gone
|
||||
assert not source.exists(), "Source folder should be removed after merge"
|
||||
# Target folder has merged content
|
||||
assert (target / "Season 1" / "ep01.mp4").exists()
|
||||
assert (target / "Season 2" / "ep01.mp4").exists()
|
||||
# DB updated
|
||||
assert mock_update.call_args.kwargs["folder"] == "Naruto (1999)"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merges_only_missing_seasons_preserving_existing_target_files(
|
||||
self, tmp_path, mock_db_session, mock_series, mock_settings
|
||||
):
|
||||
"""Source has S01 ep01, target already has S01 ep01 (different content).
|
||||
|
||||
Existing target files are kept. Source's S01 ep01 is NOT overwritten.
|
||||
Source's S02 (new) is moved. Empty source is removed.
|
||||
"""
|
||||
anime_dir = tmp_path
|
||||
source = anime_dir / "Naruto"
|
||||
target = anime_dir / "Naruto (1999)"
|
||||
source.mkdir()
|
||||
target.mkdir()
|
||||
|
||||
# Target already has S01 with one episode
|
||||
(target / "Season 1").mkdir()
|
||||
target_existing = target / "Season 1" / "ep01.mp4"
|
||||
target_existing.write_text("target-version")
|
||||
|
||||
# Source has S01 with same episode (different content) and S02
|
||||
(source / "Season 1").mkdir()
|
||||
source_conflict = source / "Season 1" / "ep01.mp4"
|
||||
source_conflict.write_text("source-version")
|
||||
(source / "Season 2").mkdir()
|
||||
(source / "Season 2" / "ep01.mp4").touch()
|
||||
|
||||
series = mock_series("key1", "Naruto", 1999)
|
||||
mock_db_session.__aenter__.return_value.__aexit__.return_value = None
|
||||
|
||||
with patch("src.server.services.folder_naming_service.AnimeSeriesService.get_all", new_callable=AsyncMock) as mock_get_all, \
|
||||
patch("src.server.services.folder_naming_service.AnimeSeriesService.get_by_key", new_callable=AsyncMock) as mock_get_by_key, \
|
||||
patch("src.server.services.folder_naming_service.AnimeSeriesService.update", new_callable=AsyncMock), \
|
||||
patch("src.server.utils.dependencies.get_series_app") as mock_get_app:
|
||||
|
||||
mock_get_all.return_value = [series]
|
||||
db_series = MagicMock()
|
||||
db_series.id = 42
|
||||
mock_get_by_key.return_value = db_series
|
||||
|
||||
app_instance = MagicMock()
|
||||
app_instance.list.keyDict = {"key1": MagicMock()}
|
||||
mock_get_app.return_value = app_instance
|
||||
mock_settings.anime_directory = str(anime_dir)
|
||||
|
||||
service = FolderNamingService()
|
||||
report = await service.run()
|
||||
|
||||
# Renamed (source effectively absorbed)
|
||||
assert report.renamed == 1
|
||||
assert not source.exists(), "Source should be removed after merge"
|
||||
# Target S01 ep01 keeps the target version (not overwritten)
|
||||
assert target_existing.read_text() == "target-version"
|
||||
# New S02 moved in
|
||||
assert (target / "Season 2" / "ep01.mp4").exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_removes_empty_source_folder_when_target_exists(
|
||||
self, tmp_path, mock_db_session, mock_series, mock_settings
|
||||
):
|
||||
"""Source folder exists but is empty, target already exists.
|
||||
|
||||
Source should be removed silently, DB updated, success.
|
||||
"""
|
||||
anime_dir = tmp_path
|
||||
source = anime_dir / "Naruto"
|
||||
target = anime_dir / "Naruto (1999)"
|
||||
source.mkdir()
|
||||
target.mkdir()
|
||||
(target / "tvshow.nfo").write_text("existing nfo")
|
||||
|
||||
series = mock_series("key1", "Naruto", 1999)
|
||||
mock_db_session.__aenter__.return_value.__aexit__.return_value = None
|
||||
|
||||
with patch("src.server.services.folder_naming_service.AnimeSeriesService.get_all", new_callable=AsyncMock) as mock_get_all, \
|
||||
patch("src.server.services.folder_naming_service.AnimeSeriesService.get_by_key", new_callable=AsyncMock) as mock_get_by_key, \
|
||||
patch("src.server.services.folder_naming_service.AnimeSeriesService.update", new_callable=AsyncMock) as mock_update, \
|
||||
patch("src.server.utils.dependencies.get_series_app") as mock_get_app:
|
||||
|
||||
mock_get_all.return_value = [series]
|
||||
db_series = MagicMock()
|
||||
db_series.id = 42
|
||||
mock_get_by_key.return_value = db_series
|
||||
app_instance = MagicMock()
|
||||
app_instance.list.keyDict = {"key1": MagicMock()}
|
||||
mock_get_app.return_value = app_instance
|
||||
mock_settings.anime_directory = str(anime_dir)
|
||||
|
||||
service = FolderNamingService()
|
||||
report = await service.run()
|
||||
|
||||
assert report.renamed == 1
|
||||
assert report.errors == 0
|
||||
assert not source.exists(), "Empty source should be removed"
|
||||
assert (target / "tvshow.nfo").exists(), "Target content preserved"
|
||||
assert mock_update.call_args.kwargs["folder"] == "Naruto (1999)"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_safety_guard_detects_wrong_year_in_target(self, tmp_path, mock_db_session, mock_series, mock_settings):
|
||||
|
||||
242
tests/unit/test_rename_folder_if_needed.py
Normal file
242
tests/unit/test_rename_folder_if_needed.py
Normal file
@@ -0,0 +1,242 @@
|
||||
"""Tests for AnimeService.rename_folder_if_needed.
|
||||
|
||||
The behavior under test: when both the source folder (without year) and the
|
||||
target folder (with year) exist on disk, the rename must not silently bail
|
||||
out — it must merge the source into the target and remove the empty source.
|
||||
This is what prevents the "Ultraman" + "Ultraman (2019)" duplicate-folder
|
||||
problem reported by users.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.server.services.anime_service import AnimeService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anime_service_with_dir(tmp_path):
|
||||
"""Create AnimeService pointing at a temp directory."""
|
||||
mock_app = MagicMock()
|
||||
mock_app.directory_to_search = str(tmp_path)
|
||||
mock_app.list.keyDict = {}
|
||||
progress = MagicMock()
|
||||
service = AnimeService(series_app=mock_app, progress_service=progress)
|
||||
return service, tmp_path
|
||||
|
||||
|
||||
class TestRenameFolderIfNeededMerge:
|
||||
"""Tests for the merge-into-existing-target behavior."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merges_seasons_when_target_exists(
|
||||
self, anime_service_with_dir
|
||||
):
|
||||
service, anime_dir = anime_service_with_dir
|
||||
source = anime_dir / "Naruto"
|
||||
target = anime_dir / "Naruto (1999)"
|
||||
source.mkdir()
|
||||
(source / "Season 1").mkdir()
|
||||
(source / "Season 1" / "ep01.mp4").touch()
|
||||
target.mkdir()
|
||||
|
||||
db = AsyncMock()
|
||||
db_series = MagicMock()
|
||||
db_series.id = 1
|
||||
db_series.folder = "Naruto"
|
||||
with patch(
|
||||
"src.server.database.service.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=db_series,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.update",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_update:
|
||||
ok = await service.rename_folder_if_needed(
|
||||
key="naruto",
|
||||
current_folder="Naruto",
|
||||
target_folder="Naruto (1999)",
|
||||
db=db,
|
||||
)
|
||||
|
||||
# Outcome: rename "succeeded" (target now contains source content)
|
||||
assert ok is True
|
||||
assert not source.exists(), "Source should be removed after merge"
|
||||
assert (target / "Season 1" / "ep01.mp4").exists()
|
||||
# DB row updated to target
|
||||
assert mock_update.call_args.kwargs["folder"] == "Naruto (1999)"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_does_not_overwrite_existing_target_files(
|
||||
self, anime_service_with_dir
|
||||
):
|
||||
"""If target already has an episode file, the source copy is removed
|
||||
(target version wins; no duplicate retained).
|
||||
"""
|
||||
service, anime_dir = anime_service_with_dir
|
||||
source = anime_dir / "Naruto"
|
||||
target = anime_dir / "Naruto (1999)"
|
||||
source.mkdir()
|
||||
target.mkdir()
|
||||
(target / "Season 1").mkdir()
|
||||
target_existing = target / "Season 1" / "ep01.mp4"
|
||||
target_existing.write_text("target-version")
|
||||
(source / "Season 1").mkdir()
|
||||
source_conflict = source / "Season 1" / "ep01.mp4"
|
||||
source_conflict.write_text("source-version")
|
||||
|
||||
db = AsyncMock()
|
||||
db_series = MagicMock()
|
||||
db_series.id = 1
|
||||
with patch(
|
||||
"src.server.database.service.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=db_series,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.update",
|
||||
new_callable=AsyncMock,
|
||||
):
|
||||
ok = await service.rename_folder_if_needed(
|
||||
key="naruto",
|
||||
current_folder="Naruto",
|
||||
target_folder="Naruto (1999)",
|
||||
db=db,
|
||||
)
|
||||
|
||||
assert ok is True
|
||||
# Target version preserved
|
||||
assert target_existing.read_text() == "target-version"
|
||||
# Source folder removed (after merge, even with skipped conflicts)
|
||||
assert not source.exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_removes_empty_source_when_target_exists(
|
||||
self, anime_service_with_dir
|
||||
):
|
||||
service, anime_dir = anime_service_with_dir
|
||||
source = anime_dir / "Naruto"
|
||||
target = anime_dir / "Naruto (1999)"
|
||||
source.mkdir()
|
||||
target.mkdir()
|
||||
(target / "tvshow.nfo").write_text("kept")
|
||||
|
||||
db = AsyncMock()
|
||||
db_series = MagicMock()
|
||||
db_series.id = 1
|
||||
with patch(
|
||||
"src.server.database.service.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=db_series,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.update",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_update:
|
||||
ok = await service.rename_folder_if_needed(
|
||||
key="naruto",
|
||||
current_folder="Naruto",
|
||||
target_folder="Naruto (1999)",
|
||||
db=db,
|
||||
)
|
||||
|
||||
assert ok is True
|
||||
assert not source.exists()
|
||||
assert (target / "tvshow.nfo").read_text() == "kept"
|
||||
assert mock_update.call_args.kwargs["folder"] == "Naruto (1999)"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_simple_rename_when_target_does_not_exist(
|
||||
self, anime_service_with_dir
|
||||
):
|
||||
"""Regression: plain rename (no merge needed) still works."""
|
||||
service, anime_dir = anime_service_with_dir
|
||||
source = anime_dir / "Naruto"
|
||||
source.mkdir()
|
||||
(source / "Season 1").mkdir()
|
||||
(source / "Season 1" / "ep01.mp4").touch()
|
||||
|
||||
db = AsyncMock()
|
||||
db_series = MagicMock()
|
||||
db_series.id = 1
|
||||
with patch(
|
||||
"src.server.database.service.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=db_series,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.update",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_update:
|
||||
ok = await service.rename_folder_if_needed(
|
||||
key="naruto",
|
||||
current_folder="Naruto",
|
||||
target_folder="Naruto (1999)",
|
||||
db=db,
|
||||
)
|
||||
|
||||
assert ok is True
|
||||
assert not source.exists()
|
||||
assert (anime_dir / "Naruto (1999)" / "Season 1" / "ep01.mp4").exists()
|
||||
assert mock_update.call_args.kwargs["folder"] == "Naruto (1999)"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_op_when_source_and_target_same(
|
||||
self, anime_service_with_dir
|
||||
):
|
||||
"""Regression: same-name case returns False without touching disk."""
|
||||
service, anime_dir = anime_service_with_dir
|
||||
source = anime_dir / "Naruto (1999)"
|
||||
source.mkdir()
|
||||
|
||||
ok = await service.rename_folder_if_needed(
|
||||
key="naruto",
|
||||
current_folder="Naruto (1999)",
|
||||
target_folder="Naruto (1999)",
|
||||
db=None,
|
||||
)
|
||||
|
||||
assert ok is False
|
||||
assert source.exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_op_when_source_missing(self, anime_service_with_dir):
|
||||
"""Regression: source missing on disk returns False without error."""
|
||||
service, anime_dir = anime_service_with_dir
|
||||
|
||||
ok = await service.rename_folder_if_needed(
|
||||
key="naruto",
|
||||
current_folder="Naruto",
|
||||
target_folder="Naruto (1999)",
|
||||
db=None,
|
||||
)
|
||||
|
||||
assert ok is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_path_typesafe_with_string_directory(self, tmp_path):
|
||||
"""Regression: directory_to_search being a string (not Path) works.
|
||||
|
||||
Original code did `self._directory / current_folder` which raised
|
||||
TypeError when _directory was a str. This was silently swallowed
|
||||
by the caller's try/except, leaving the rename undone.
|
||||
"""
|
||||
mock_app = MagicMock()
|
||||
mock_app.directory_to_search = str(tmp_path) # string, not Path
|
||||
mock_app.list.keyDict = {}
|
||||
progress = MagicMock()
|
||||
service = AnimeService(series_app=mock_app, progress_service=progress)
|
||||
|
||||
source = tmp_path / "Naruto"
|
||||
target = tmp_path / "Naruto (1999)"
|
||||
source.mkdir()
|
||||
target.mkdir()
|
||||
|
||||
ok = await service.rename_folder_if_needed(
|
||||
key="naruto",
|
||||
current_folder="Naruto",
|
||||
target_folder="Naruto (1999)",
|
||||
db=None,
|
||||
)
|
||||
|
||||
# Must not raise; must succeed (merge path).
|
||||
assert ok is True
|
||||
assert not source.exists()
|
||||
@@ -194,15 +194,23 @@ class TestSerieScannerSingleSeries:
|
||||
def test_scan_single_series_existing_entry(
|
||||
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)
|
||||
|
||||
|
||||
# Pre-populate keyDict
|
||||
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
|
||||
old_episode_dict = copy.deepcopy(sample_serie.episodeDict)
|
||||
|
||||
|
||||
with patch.object(
|
||||
scanner,
|
||||
'_SerieScanner__get_missing_episodes_and_season',
|
||||
@@ -212,11 +220,16 @@ class TestSerieScannerSingleSeries:
|
||||
key=sample_serie.key,
|
||||
folder=sample_serie.folder
|
||||
)
|
||||
|
||||
# Verify existing entry was updated - episodeDict is merged (not replaced)
|
||||
# Old episodes [2, 3, 4] + new episodes [10, 11, 12] = merged result
|
||||
assert scanner.keyDict[sample_serie.key].episodeDict != old_episode_dict
|
||||
assert scanner.keyDict[sample_serie.key].episodeDict == {1: [2, 3, 4, 10, 11, 12]}
|
||||
|
||||
# The cached episodeDict is REPLACED with the latest
|
||||
# scan's missing-episode list — not merged. Old entries
|
||||
# ([2, 3, 4]) are dropped because the latest scan
|
||||
# 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(
|
||||
self, temp_directory, mock_loader
|
||||
|
||||
@@ -101,12 +101,20 @@ class TestSyncEpisodesToDb:
|
||||
"""Test _sync_episodes_to_db method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preserves_downloaded_episodes(self):
|
||||
"""Verify downloaded episodes are not removed even when no longer missing."""
|
||||
async def test_deletes_downloaded_episodes_when_no_longer_missing(self):
|
||||
"""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()
|
||||
|
||||
# S01E1 was downloaded (file exists), S01E2 was missing but file now exists
|
||||
# Both are no longer in episode_dict
|
||||
# S01E1 was downloaded (file exists) and the scanner confirms
|
||||
# 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 = [
|
||||
MagicMock(id=1, season=1, episode_number=1, 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
|
||||
)
|
||||
|
||||
# Neither should be deleted since both are downloaded
|
||||
mock_delete.assert_not_called()
|
||||
# Both downloaded rows should be deleted; the scanner
|
||||
# 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
|
||||
async def test_removes_missing_episodes_when_no_longer_missing(self):
|
||||
|
||||
179
tests/unit/test_serie_scanner_scan_dedup.py
Normal file
179
tests/unit/test_serie_scanner_scan_dedup.py
Normal 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}"
|
||||
)
|
||||
Reference in New Issue
Block a user