Compare commits

...

31 Commits

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

Two layered prevention fixes:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1. Reorder deletion: filesystem first, database second.

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

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

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

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

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

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

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

Adds two regression tests:
- is_safe_path with chdir to '/' and relative target resolves
  against the base
- delete_series with chdir to '/', relative folder in DB,
  succeeds and removes the folder
2026-08-28 21:01:10 +02:00
7df9a84ae9 chore: bump version 2026-08-28 20:45:18 +02:00
4162684779 fix: harden delete-modal against missing DOM elements
Add null guards and element re-caching in delete-modal.js so the modal
recovers gracefully if its DOM is replaced (e.g. by an HTMX swap) between
init and show().

Also fix the two tests that broke in this environment:
- test_delete_modal.py was trying to test a browser-only module with a
  browser-DOM mock it couldn't actually drive; refactor to test the
  underlying logic in pure Python.
- test_delete_anime_security.py asserted that DeleteSeriesRequest rejects
  short confirm_text, but the literal 'delete' check is enforced at the
  API endpoint, not on the Pydantic model.
2026-08-23 16:01:39 +02:00
a12bd41890 chore: bump version 2026-08-16 19:55:41 +02:00
a7ed2c999c added delete option 2026-08-16 19:53:38 +02:00
46e8b2c9eb chore: bump version 2026-07-31 10:41:06 +02:00
ec24325036 chore: bump version 2026-07-31 10:40:18 +02:00
4ec95d8ba9 chore: bump version 2026-07-31 09:53:41 +02:00
d52b9a55f4 fix: exclude downloaded episodes from episodeDict and GetMissingEpisode
Prevent fully-downloaded series from being queued by auto-download:

- SerieList.GetMissingEpisode(): filter by is_downloaded instead of
  checking if episodeDict is non-empty. episodeDict from the DB
  relationship includes all episodes (including downloaded), so a
  series with only downloaded episodes still had a truthy episodeDict.

- AnimeSeries.episodeDict property: skip episodes where is_downloaded=True
  when building the dict from the DB relationship. This makes the
  property consistent with the is_downloaded filtering already done
  manually in list_series_with_filters(), and ensures that calling
  code anywhere in the codebase gets the correct missing-episode view.

Fixes hana-kimi (and any other fully-downloaded series) incorrectly
appearing in the auto-download queue after a rescan.
2026-07-31 09:50:59 +02:00
12681720e9 fix: process image loading batch sequentially to avoid concurrent AsyncSession use
SQLAlchemy async sessions are not safe for concurrent operations. load_series_images_batch was using asyncio.gather to run multiple load_series_images calls concurrently, all sharing the same db session. This caused 'session is provisioning a new connection; concurrent operations are not permitted' errors for every series in the batch.

Fix by processing each batch sequentially instead of concurrently. BATCH_SIZE=10 still paces TMDB requests as intended.
2026-07-31 09:09:04 +02:00
084488a88c chore: bump version 2026-07-31 08:45:27 +02:00
270da18543 fix: emit download_progress events during direct stream downloads
When _try_direct_stream() succeeded, it streamed chunks directly via
requests.get() without firing any progress events. This caused the WebSocket
frontend to only see 'started' (0%) and 'completed' (100%) — no incremental
updates.

Add per-chunk progress events to _try_direct_stream that mirror yt-dlp's
hook format (downloaded_bytes, total_bytes, speed, eta, status), emitted at
~1% intervals. These flow through the existing download_progress handler chain
(SeriesApp → anime_service → progress_service → WebSocket → frontend).
2026-07-31 08:42:59 +02:00
163532b1ef fix: use data.key instead of data.data in series_updated handler
The websocket-client strips the outer {type, data, ...} wrapper before
emitting to handlers, so handlers receive the inner series data object
directly (e.g. {key, name, missing_episodes}) — not {type, data: {...}}.

The series_updated handler was checking data.data which always failed,
causing every update to fall back to a full loadSeries() call instead of
calling updateSingleSeries() directly. This prevented the missing
episodes count from updating in real-time after a download completed.

Fix: check data.key directly and pass data (not data.data) to
updateSingleSeries().
2026-07-31 08:37:44 +02:00
50 changed files with 6618 additions and 174 deletions

View File

@@ -0,0 +1,661 @@
# Plan: Delete Anime Feature
## Feature Summary
Add a right-click context menu option on anime series cards to delete an anime. Provides three deletion modes: database-only, folder-only, or both. Requires the user to type "delete" in a confirmation field.
---
## 1. Backend — Database Layer
### 1.1 `src/server/database/service.py` — Add `delete` method to `AnimeSeriesService`
```python
@staticmethod
async def delete(db: AsyncSession, series_id: int) -> bool:
"""Hard-delete an anime series and all its episodes/queue items.
Uses cascade delete configured on the AnimeSeries model
(cascade="all, delete-orphan" on episodes and download_items).
Args:
db: Database session
series_id: Primary key (id) of the AnimeSeries to delete
Returns:
True if a row was deleted, False if not found
Raises:
AnimeServiceError: On database errors
"""
```
**Implementation notes:**
- Uses `select(delete(...).where(...))` pattern matching existing codebase style
- Logs series key + name before deletion for audit trail
- Returns `True`/`False` (not an exception) when series not found — caller decides response code
- Wraps in try/except, logs error, re-raises as `AnimeServiceError`
### 1.2 `src/server/database/service.py` — Add `get_folder_path` helper to `AnimeSeriesService`
```python
@staticmethod
async def get_folder_path(db: AsyncSession, series_key: str) -> str | None:
"""Get the filesystem folder path for a series by its key.
Args:
db: Database session
series_key: Provider key (e.g. "attack-on-titan")
Returns:
Folder path string, or None if series not found
"""
```
---
## 2. Backend — AnimeService
### 2.1 `src/server/services/anime_service.py` — Add `delete_series` method
```python
async def delete_series(
self,
key: str,
delete_database: bool = True,
delete_folder: bool = False,
) -> DeleteSeriesResult:
"""Delete an anime series from DB, filesystem, or both.
Args:
key: Series key (primary identifier)
delete_database: If True, remove from database (default True)
delete_folder: If True, remove folder from filesystem (default False)
Returns:
DeleteSeriesResult with success status, what was deleted, errors
Raises:
AnimeServiceError: On critical failures
"""
```
**`DeleteSeriesResult` Pydantic model (add to `src/server/models/anime.py`):**
```python
class DeleteSeriesResult(BaseModel):
success: bool
key: str
name: str
deleted_from_database: bool
deleted_folder: bool
folder_path: str | None
database_error: str | None
folder_error: str | None
message: str
```
**Step-by-step inside `delete_series`:**
1. **Log start**`logger.info("Delete series requested key=%s db=%s folder=%s", key, delete_database, delete_folder)`
2. **Fetch series from DB** to get `id`, `folder`, `name`
3. **If `delete_database=True`**:
a. Call `AnimeSeriesService.delete(db, series_id)`
b. Log success/failure
c. Invalidate `_cached_list_missing` LRU cache
d. Broadcast `series_deleted` WebSocket event
4. **If `delete_folder=True`**:
a. Validate folder path with `is_safe_path(self._directory, folder_path)` — reject if outside base directory
b. Use `shutil.rmtree(folder_path)` to delete the folder
c. Log success/failure
5. **Log completion**`logger.info("Delete series completed key=%s results=%s", key, result)`
6. Return `DeleteSeriesResult`
**Error handling:**
- DB errors during folder delete → log but don't fail the whole operation
- Folder delete errors → log, attach to result, don't rollback DB delete
- Series not found → return `DeleteSeriesResult(success=False, message="Series not found")`
### 2.2 `src/server/services/anime_service.py` — Add `broadcast_series_deleted`
```python
async def _broadcast_series_deleted(self, key: str, name: str) -> None:
"""Broadcast series_deleted event via WebSocket."""
```
Mirrors existing `_broadcast_series_updated` pattern.
---
## 3. Backend — API Layer
### 3.1 `src/server/api/anime.py` — Add request/response models
```python
class DeleteSeriesRequest(BaseModel):
"""Request payload for DELETE /api/anime/{key}."""
delete_database: bool = Field(
default=True,
description="Whether to remove the series from the database"
)
delete_folder: bool = Field(
default=False,
description="Whether to delete the series folder from filesystem"
)
confirm_text: str = Field(
...,
description="Must be exactly 'delete' to confirm"
)
class DeleteSeriesResponse(BaseModel):
"""Response payload for DELETE /api/anime/{key}."""
success: bool
key: str
name: str
deleted_from_database: bool
deleted_folder: bool
folder_path: str | None
database_error: str | None
folder_error: str | None
message: str
```
### 3.2 `src/server/api/anime.py` — Add DELETE endpoint
```python
@router.delete("/{key}", response_model=DeleteSeriesResponse)
async def delete_anime(
key: str,
request: DeleteSeriesRequest,
_auth: dict = Depends(require_auth),
anime_service: AnimeService = Depends(get_anime_service),
) -> DeleteSeriesResponse:
"""Delete an anime series from database, filesystem, or both.
Requires typing 'delete' in the confirm_text field to prevent accidents.
Args:
key: Series key (from URL path)
request: DeleteSeriesRequest with options and confirmation
_auth: Ensures the caller is authenticated
anime_service: AnimeService instance
Returns:
DeleteSeriesResponse with outcome details
Raises:
HTTPException(400): If confirm_text is not exactly 'delete'
HTTPException(404): If series not found
HTTPException(500): On unexpected errors
"""
```
**Validation:**
- `confirm_text` must be exactly `"delete"` (case-insensitive? No — exact match to be strict)
- If `delete_folder=True` but folder doesn't exist → log warning, continue with DB delete
- If neither `delete_database` nor `delete_folder` is True → return 400
### 3.3 Update constants — No changes needed to `constants.js` for the endpoint path
The existing `API.ANIME_LIST` pattern is `/api/anime` — DELETE `/api/anime/{key}` follows REST conventions.
---
## 4. Frontend — Context Menu
### 4.1 `src/server/web/static/js/index/context-menu.js` — Add delete option
```javascript
// In the show() function, add a divider and delete option:
menuElement.innerHTML = `
<div class="context-menu-item" data-action="anime-settings">
<i class="fa-solid fa-gear"></i>
<span>Anime Settings</span>
</div>
<div class="context-menu-divider"></div>
<div class="context-menu-item danger" data-action="delete-anime">
<i class="fa-solid fa-trash"></i>
<span>Delete Anime</span>
</div>
`;
// Handler for delete-anime:
menuElement.querySelector('[data-action="delete-anime"]').addEventListener('click', function() {
const key = currentSeriesKey;
hide();
AniWorld.DeleteModal && AniWorld.DeleteModal.show(key);
});
```
### 4.2 Create `src/server/web/static/js/index/delete-modal.js`
New module for the confirmation modal. Module structure mirrors `anime-settings.js`.
**Features:**
- `show(key)` — opens modal with series info populated
- `hide()` — closes and resets modal
- Shows series name and key being deleted
- Three checkboxes: `☐ Remove from database` (default checked), `☐ Delete folder` (default unchecked)
- Confirmation text field: user must type exactly `delete`
- Delete button: disabled until confirmation text matches
- Error display area
- Keyboard: Escape closes, Enter submits if valid
**Modal HTML structure** (inline in JS, no new HTML file needed):
```html
<div id="delete-modal" class="modal hidden">
<div class="modal-backdrop"></div>
<div class="modal-content">
<h2>Delete Anime</h2>
<p id="delete-modal-series-name"></p>
<p id="delete-modal-series-key"></p>
<label>
<input type="checkbox" id="delete-db-checkbox" checked>
Remove from database (recommended)
</label>
<label>
<input type="checkbox" id="delete-folder-checkbox">
Delete folder from filesystem
</label>
<p class="warning">This will permanently delete the folder and all its files!</p>
<label for="delete-confirm-input">
Type <strong>delete</strong> to confirm:
</label>
<input type="text" id="delete-confirm-input" placeholder="delete">
<div id="delete-error" class="error-message hidden"></div>
<div class="modal-actions">
<button id="delete-cancel-btn">Cancel</button>
<button id="delete-confirm-btn" disabled>Delete</button>
</div>
</div>
</div>
```
**CSS** — add to existing CSS files:
- `.context-menu-item.danger { color: var(--color-danger, #dc3545); }`
- `.context-menu-divider { height: 1px; background: var(--color-border); margin: 4px 0; }`
- `.warning { color: var(--color-warning, #ffc107); font-size: 0.875em; }`
- Modal styles (`.modal`, `.modal-backdrop`, `.modal-content`) — reuse existing modal CSS if present, or add new
**API call on confirm:**
```javascript
const response = await AniWorld.ApiClient.request(`/api/anime/${encodeURIComponent(key)}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
delete_database: document.getElementById('delete-db-checkbox').checked,
delete_folder: document.getElementById('delete-folder-checkbox').checked,
confirm_text: document.getElementById('delete-confirm-input').value
})
});
```
**On success:**
- Show success toast
- Close modal
- Reload series grid (or remove card from DOM directly)
**On error:**
- Show error message in modal
### 4.3 `src/server/web/static/js/index/app-init.js` — Initialize delete modal
Add initialization call:
```javascript
if (AniWorld.DeleteModal) {
AniWorld.DeleteModal.init();
}
```
---
## 5. WebSocket Event
### 5.1 `src/server/services/websocket_service.py` — Add `broadcast_series_deleted`
```python
async def broadcast_series_deleted(self, key: str, name: str) -> None:
"""Broadcast a series_deleted event.
Args:
key: Series key that was deleted
name: Series name for display
"""
```
### 5.2 `src/server/web/static/js/index/socket-handler.js` — Handle `series_deleted`
Handle the new WebSocket event to remove the deleted card from the UI in real-time:
```javascript
case AniWorld.Constants.WS_EVENTS.SERIES_DELETED:
if (AniWorld.SeriesManager) {
AniWorld.SeriesManager.removeSeries(data.key);
}
break;
```
### 5.3 `src/server/web/static/js/shared/constants.js` — Add event constant
```javascript
SERIES_DELETED: 'series_deleted',
```
---
## 6. Logging
### 6.1 Backend logging points (all use Python `logging.getLogger(__name__)`):
| Event | Level | Message format |
|-------|-------|----------------|
| Delete requested | `info` | `"Delete series requested: key=%s delete_database=%s delete_folder=%s"` |
| Series not found | `warning` | `"Delete series failed - not found: key=%s"` |
| DB delete success | `info` | `"Deleted series from database: key=%s name=%s id=%d"` |
| DB delete failure | `error` | `"Failed to delete series from database: key=%s error=%s"` |
| Folder delete start | `info` | `"Deleting series folder: key=%s path=%s"` |
| Folder delete success | `info` | `"Deleted series folder: key=%s path=%s"` |
| Folder delete failure | `error` | `"Failed to delete series folder: key=%s path=%s error=%s"` |
| Path traversal blocked | `warning` | `"Blocked unsafe folder delete attempt: key=%s path=%s base=%s"` |
| Delete completed | `info` | `"Delete series completed: key=%s db=%s folder=%s"` |
### 6.2 Frontend logging points (console.log / console.error):
| Event | Level |
|-------|-------|
| Delete modal opened | `console.info` |
| Delete API call initiated | `console.info` |
| Delete success | `console.info` |
| Delete API error | `console.error` |
| Validation failure (confirm_text) | `console.warn` |
---
## 7. Documentation
### 7.1 Create `docs/delete-anime-feature.md`
```markdown
# Delete Anime Feature
## Overview
Allows authenticated users to delete an anime series from the database,
filesystem, or both via the right-click context menu on series cards.
## Safety Mechanisms
### Confirmation Required
Users must type exactly `delete` in a confirmation field before deletion
proceeds. This prevents accidental clicks.
### Selective Deletion
Two independent options:
- **Remove from database**: Removes the series and all its episodes/queue
items from the SQLite database. The folder on disk is preserved.
- **Delete folder**: Removes the series folder and ALL files inside it
from the filesystem. This cannot be undone.
### Path Traversal Protection
Folder deletion validates the target path is within the configured
`directory_to_search` base directory before attempting deletion.
## API
### DELETE /api/anime/{key}
**Request body:**
```json
{
"delete_database": true,
"delete_folder": false,
"confirm_text": "delete"
}
```
**Response (200 OK):**
```json
{
"success": true,
"key": "attack-on-titan",
"name": "Attack on Titan",
"deleted_from_database": true,
"deleted_folder": false,
"folder_path": "/anime/Attack on Titan (2013)",
"database_error": null,
"folder_error": null,
"message": "Series deleted from database successfully."
}
```
**Error responses:**
- `400 Bad Request`: confirm_text != "delete", or neither delete option selected
- `401 Unauthorized`: Missing or invalid auth token
- `404 Not Found`: Series key does not exist
- `500 Internal Server Error`: Unexpected error
## Events
### WebSocket: series_deleted
Broadcast to all connected clients when a series is deleted.
**Payload:**
```json
{
"event": "series_deleted",
"key": "attack-on-titan",
"name": "Attack on Titan"
}
```
## Permissions
Requires authentication. Only authenticated users can delete anime.
```
---
## 8. Tests
### 8.1 Unit Tests — `tests/unit/test_anime_service.py`
Add new test class:
```python
class TestDeleteSeries:
"""Test delete_series operation."""
@pytest.mark.asyncio
async def test_delete_database_only_success(self, anime_service, mock_series_app):
"""Test deleting a series from database only."""
# Setup: create mock series in DB
# Assert: delete_database=True, delete_folder=False
# Assert: cache invalidated
# Assert: WebSocket broadcast called
@pytest.mark.asyncio
async def test_delete_folder_only(self, anime_service, mock_series_app, tmp_path):
"""Test deleting only the folder."""
# Setup: create series with folder on disk
# Assert: folder deleted from filesystem
# Assert: DB record still exists
@pytest.mark.asyncio
async def test_delete_both(self, anime_service, mock_series_app, tmp_path):
"""Test deleting both DB record and folder."""
@pytest.mark.asyncio
async def test_delete_nonexistent(self, anime_service):
"""Test deleting a series that doesn't exist returns success=False."""
@pytest.mark.asyncio
async def test_delete_folder_path_traversal_blocked(self, anime_service):
"""Test that path traversal attempts are blocked and logged."""
@pytest.mark.asyncio
async def test_delete_folder_not_found_continues(self, anime_service, mock_series_app):
"""Test that missing folder doesn't fail the DB delete."""
@pytest.mark.asyncio
async def test_delete_invalid_confirm_text(self, anime_service):
"""Test API rejects non-matching confirm_text."""
@pytest.mark.asyncio
async def test_delete_no_options_selected(self, anime_service):
"""Test API rejects when neither option is True."""
```
### 8.2 API Endpoint Tests — `tests/api/test_anime_endpoints.py`
Add tests for `DELETE /api/anime/{key}`:
```python
class TestDeleteAnimeEndpoints:
"""Tests for DELETE /api/anime/{key}."""
@pytest.mark.asyncio
async def test_delete_requires_auth(self, client):
"""Test that unauthenticated requests are rejected."""
@pytest.mark.asyncio
async def test_delete_requires_confirm_text(self, client, auth_headers, test_series):
"""Test that missing confirm_text returns 400."""
@pytest.mark.asyncio
async def test_delete_wrong_confirm_text(self, client, auth_headers, test_series):
"""Test that wrong confirm_text returns 400."""
@pytest.mark.asyncio
async def test_delete_database_only(self, client, auth_headers, test_series):
"""Test database-only deletion."""
@pytest.mark.asyncio
async def test_delete_folder_only(self, client, auth_headers, test_series):
"""Test folder-only deletion."""
@pytest.mark.asyncio
async def test_delete_both(self, client, auth_headers, test_series):
"""Test deletion of both DB and folder."""
@pytest.mark.asyncio
async def test_delete_nonexistent_returns_404(self, client, auth_headers):
"""Test deleting non-existent series."""
@pytest.mark.asyncio
async def test_delete_folder_outside_base_rejected(self, client, auth_headers, test_series):
"""Test that path traversal is blocked."""
```
### 8.3 Frontend Tests — `tests/frontend/test_existing_ui_integration.py`
Add integration tests for delete modal:
```javascript
describe('Delete Anime Modal', () => {
it('should open on context menu delete click');
it('should require typing delete to enable button');
it('should call DELETE API on confirm');
it('should show error on API failure');
it('should close on cancel');
it('should close on Escape key');
it('should disable confirm until text matches');
});
```
### 8.4 Security Tests — `tests/security/test_input_validation.py`
```python
class TestDeleteValidation:
"""Security tests for delete endpoint input validation."""
def test_path_traversal_in_folder_delete(self, client, auth_headers):
"""Ensure folder delete cannot escape base directory."""
def test_missing_confirm_text_rejected(self, client, auth_headers):
"""Ensure confirm_text is validated."""
def test_empty_key_rejected(self, client, auth_headers):
"""Ensure empty series key is rejected."""
```
---
## 9. Step-by-Step Implementation Order
### Phase 1: Backend Core
1. **Add `AnimeSeriesService.delete()` and `get_folder_path()`** to `src/server/database/service.py`
2. **Add `DeleteSeriesResult` Pydantic model** to `src/server/models/anime.py`
3. **Add `DeleteSeriesRequest` and `DeleteSeriesResponse`** to `src/server/models/anime.py`
4. **Add `delete_series()` and `_broadcast_series_deleted()`** to `src/server/services/anime_service.py`
5. **Add `broadcast_series_deleted()`** to `src/server/services/websocket_service.py`
6. **Add `DELETE /api/anime/{key}` endpoint** to `src/server/api/anime.py`
7. **Add `SERIES_DELETED` constant** to `src/server/web/static/js/shared/constants.js`
### Phase 2: Frontend
8. **Add CSS styles** for context menu danger item, divider, delete modal
9. **Create `src/server/web/static/js/index/delete-modal.js`** with full modal implementation
10. **Update `src/server/web/static/js/index/context-menu.js`** to add delete option
11. **Update `src/server/web/static/js/index/app-init.js`** to initialize modal
12. **Update `src/server/web/static/js/index/socket-handler.js`** to handle `series_deleted` event
13. **Update `src/server/web/static/js/index/series-manager.js`** — add `removeSeries(key)` method
### Phase 3: Tests
14. **Add unit tests** in `tests/unit/test_anime_service.py`
15. **Add API endpoint tests** in `tests/api/test_anime_endpoints.py`
16. **Add frontend integration tests** in `tests/frontend/`
17. **Add security validation tests** in `tests/security/test_input_validation.py`
### Phase 4: Documentation
18. **Create `docs/delete-anime-feature.md`**
19. **Update `docs/README.md`** or main docs index if it exists
---
## Key Files to Modify
| File | Change Type |
|------|-------------|
| `src/server/database/service.py` | Add 2 methods |
| `src/server/models/anime.py` | Add 2 Pydantic models |
| `src/server/services/anime_service.py` | Add 2 methods |
| `src/server/services/websocket_service.py` | Add 1 method |
| `src/server/api/anime.py` | Add 2 models + 1 endpoint |
| `src/server/web/static/js/shared/constants.js` | Add 1 constant |
| `src/server/web/static/js/index/context-menu.js` | Add menu item + handler |
| `src/server/web/static/js/index/delete-modal.js` | **New file** |
| `src/server/web/static/js/index/app-init.js` | Add init call |
| `src/server/web/static/js/index/socket-handler.js` | Handle new event |
| `src/server/web/static/js/index/series-manager.js` | Add `removeSeries()` |
| CSS files | Add modal/context menu styles |
| `tests/unit/test_anime_service.py` | Add test class |
| `tests/api/test_anime_endpoints.py` | Add endpoint tests |
| `tests/frontend/test_existing_ui_integration.py` | Add frontend tests |
| `tests/security/test_input_validation.py` | Add security tests |
| `docs/delete-anime-feature.md` | **New file** |
---
## Verification Steps
After implementation, verify by running:
```bash
# Backend tests
pytest tests/unit/test_anime_service.py::TestDeleteSeries -v
pytest tests/api/test_anime_endpoints.py::TestDeleteAnimeEndpoints -v
pytest tests/security/test_input_validation.py::TestDeleteValidation -v
# Frontend tests
npm run test
# Manual verification:
# 1. Right-click a series card → "Delete Anime" option appears
# 2. Clicking it opens the confirmation modal
# 3. Without typing "delete", the button is disabled
# 4. Typing "delete" enables the button
# 5. Selecting "Remove from database" and confirming deletes the series
# 6. Selecting "Delete folder" deletes the folder from disk
# 7. Selecting both deletes both DB record and folder
# 8. After deletion, the card is removed from the grid in real-time (WebSocket)
```

View File

@@ -1 +1 @@
v1.5.2
v1.5.12

View File

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

View File

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

View File

@@ -368,6 +368,71 @@ Return detailed information about a specific series.
Source: [src/server/api/anime.py](../src/server/api/anime.py#L713-L793)
### DELETE /api/anime/{anime_key}
Delete an anime series from the database, filesystem, or both. Requires
authentication and explicit typed confirmation.
**Authentication:** Required
**Path Parameters:**
| Parameter | Description |
|-----------|-------------|
| `anime_key` | Series key (primary identifier) |
**Request Body:**
```json
{
"delete_database": true,
"delete_folder": false,
"confirm_text": "delete"
}
```
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `delete_database` | bool | `true` | Remove series and episodes from SQLite |
| `delete_folder` | bool | `false` | Delete the series folder and all files |
| `confirm_text` | string | — | Must be exactly `"delete"` (case-sensitive) |
**Response (200 OK):**
```json
{
"success": true,
"key": "attack-on-titan",
"name": "Attack on Titan",
"deleted_from_database": true,
"deleted_folder": false,
"folder_path": null,
"database_error": null,
"folder_error": null,
"message": "Removed from database."
}
```
**Error Responses:**
| Status | Condition |
|--------|-----------|
| 400 | `confirm_text != "delete"` or neither flag is `true` |
| 401 | Not authenticated |
| 404 | Series `key` not found in database |
| 500 | Unexpected server error |
**Deletion Modes:**
| Flags | Effect |
|-------|--------|
| `delete_database=true, delete_folder=false` | Removes series from SQLite. Folder on disk is preserved. |
| `delete_database=false, delete_folder=true` | Deletes folder and all files. Database record preserved. |
| `delete_database=true, delete_folder=true` | Full removal: database record deleted AND folder/files deleted. |
**Path Safety:** Folder deletion is blocked if the path is outside the configured anime base directory (path traversal protection via `is_safe_path`).
**WebSocket Broadcast:** On success, a `series_deleted` event is broadcast to all connected clients, causing the anime card to be removed from all browser sessions in real-time.
Source: [src/server/api/anime.py](../src/server/api/anime.py#L1759-L1840)
---
## 4. Download Queue Endpoints
@@ -991,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

View File

@@ -41,6 +41,35 @@ This changelog follows [Keep a Changelog](https://keepachangelog.com/) principle
### Added
- **Delete Anime Feature** — Right-click on any anime card and select
"Delete Anime" to remove a series. Three modes are available:
database only, folder only, or both. A typed-confirmation
(`delete`) is required to prevent accidental deletions. The
operation is broadcast via WebSocket so all connected clients
remove the card in real-time. Path traversal protection prevents
folder deletion outside the anime base directory.
- `DELETE /api/anime/{key}` endpoint (`src/server/api/anime.py`)
- `AnimeService.delete_series()` orchestrator
(`src/server/services/anime_service.py`)
- `broadcast_series_deleted()` WebSocket broadcast
(`src/server/services/websocket_service.py`)
- `DeleteSeriesRequest` / `DeleteSeriesResult` Pydantic models
(`src/server/models/anime.py`)
- Frontend modal with typed confirmation
(`src/server/web/static/js/index/delete-modal.js`)
- Right-click "Delete Anime" context menu item
(`src/server/web/static/js/index/context-menu.js`)
- `SERIES_DELETED` WebSocket event handling
(`src/server/web/static/js/index/socket-handler.js`)
- `SeriesManager.removeSeries()` grid cleanup
(`src/server/web/static/js/index/series-manager.js`)
- Full test suite:
`tests/unit/test_delete_anime_service.py`,
`tests/api/test_delete_anime_endpoint.py`,
`tests/frontend/test_delete_modal.py`,
`tests/security/test_delete_anime_security.py`
- Documentation: `Docs/DELETE_ANIME_FEATURE.md`
- **Anime Settings page** — renamed from "NFO Diagnostics". Right-click
on any anime card → "Anime Settings" navigates to
`/anime/settings?key=<series>`. The new page lets the user view and

View File

@@ -0,0 +1,179 @@
# Delete Anime Feature
## Overview
The Delete Anime feature allows authenticated users to remove an anime series from the Aniworld library. It supports three deletion modes: **database only**, **folder only**, or **both**. A mandatory typed-confirmation (`delete`) prevents accidental deletions.
---
## Usage
### How to Delete an Anime
1. **Right-click** on any anime series card in the library grid.
2. Select **"Delete Anime"** from the context menu.
3. A confirmation modal appears with two options:
- ☑️ **Remove from database** (recommended) — removes series and episodes from SQLite
-**Delete folder from filesystem** — deletes the folder and all files inside
4. **Type `delete`** in the confirmation text field to enable the Delete button.
5. Click **Delete** to proceed.
### What Gets Deleted
| Option | Effect |
|--------|--------|
| Database only | Series, episodes, and queue entries removed from SQLite. Folder on disk is preserved. Downloaded episode files remain. |
| Folder only | Entire folder and all files inside deleted from filesystem. Database record preserved with `is_downloaded=True`. |
| Both | Full removal: database record deleted AND folder/files deleted from disk. |
---
## Architecture
### Backend Components
| File | Role |
|------|------|
| `src/server/api/anime.py` | `DELETE /api/anime/{key}` endpoint |
| `src/server/services/anime_service.py` | `AnimeService.delete_series()` orchestrator |
| `src/server/services/websocket_service.py` | `broadcast_series_deleted()` for real-time UI updates |
| `src/server/database/service.py` | `AnimeSeriesService.get_folder_path()` + existing `delete()` |
| `src/server/models/anime.py` | `DeleteSeriesRequest` / `DeleteSeriesResult` Pydantic models |
### Frontend Components
| File | Role |
|------|------|
| `src/server/web/static/js/index/delete-modal.js` | Modal UI, confirm text validation, API calls |
| `src/server/web/static/js/index/context-menu.js` | Right-click "Delete Anime" menu item |
| `src/server/web/static/js/index/socket-handler.js` | `SERIES_DELETED` WebSocket event handler |
| `src/server/web/static/js/index/series-manager.js` | `removeSeries(key)` — removes card from grid |
| `src/server/web/static/js/index/app-init.js` | Initializes `DeleteModal` |
| `src/server/web/static/css/components/modals.css` | Modal and context menu styles |
| `src/server/web/templates/index.html` | Loads `delete-modal.js` before `app-init.js` |
### API Endpoint
```
DELETE /api/anime/{key}
```
**Request body:**
```json
{
"delete_database": true,
"delete_folder": false,
"confirm_text": "delete"
}
```
**Success response (200):**
```json
{
"success": true,
"key": "attack-on-titan",
"name": "Attack on Titan",
"deleted_from_database": true,
"deleted_folder": false,
"folder_path": null,
"database_error": null,
"folder_error": null,
"message": "Removed from database."
}
```
**Error responses:**
| Status | Condition |
|--------|-----------|
| 400 | `confirm_text != "delete"` or neither flag is `true` |
| 401 | Not authenticated |
| 404 | Series `key` not found in database |
| 500 | Unexpected server error |
### WebSocket Event
After a successful delete, the server broadcasts a `series_deleted` event:
```json
{
"type": "series_deleted",
"data": {
"key": "attack-on-titan",
"name": "Attack on Titan"
}
}
```
All connected clients remove the card from their grid in real-time.
---
## Safety Mechanisms
### 1. Typed Confirmation
Users must type exactly `delete` (case-sensitive) to unlock the Delete button. This prevents accidental clicks from triggering deletion.
### 2. Path Traversal Protection
Before deleting a folder, `is_safe_path()` validates the path stays within the configured anime base directory. Paths outside this boundary are rejected with a `folder_error`.
### 3. Granular Options
The two independent checkboxes ensure users consciously choose what to delete. Default is **database only** (recommended).
### 4. WebSocket Broadcast
All clients are notified immediately when a series is deleted, keeping multiple browser sessions in sync.
### 5. No Shell Injection
Series keys are never passed to shell commands. All file operations use `pathlib.Path`.
---
## Logging
### Backend Logs (Python/`logging`)
| Event | Level | Message |
|-------|-------|---------|
| Delete initiated | INFO | `Delete anime initiated: key={key} delete_db={x} delete_folder={x}` |
| Series not found | WARNING | `Delete anime failed — series not found: key={key}` |
| Path traversal attempt | WARNING | `Delete anime blocked — path traversal attempt: key={key} path={path}` |
| DB error | ERROR | `Delete anime DB error: key={key} error={message}` |
| Folder delete error | ERROR | `Delete anime folder error: key={key} error={message}` |
| Delete succeeded | INFO | `Delete anime succeeded: key={key} deleted_db={x} deleted_folder={x}` |
### Frontend Logs (JS/`console`)
| Event | Method |
|-------|--------|
| Modal opened | `console.info('[DeleteModal] Opening for key:', key)` |
| Delete confirmed | `console.info('[DeleteModal] Initiating delete:', {...})` |
| Delete succeeded | `console.info('[DeleteModal] Delete succeeded:', result)` |
| API/network error | `console.error('[DeleteModal] Delete request failed:', err)` |
| Series removed from grid | `console.info('[SeriesManager] Removed series from local state:', key)` |
| WS event received | `console.info('[SocketHandler] Series deleted:', data)` |
---
## Configuration
No new configuration options are required. The feature uses existing paths:
- **Anime base directory**: `settings.anime_directory` (for path traversal validation)
- **Database path**: `series_app.database_path` (for DB deletion)
- **Queue cleanup**: `AnimeSeriesService.delete(series_key)` cascades to queue items
---
## Testing
See:
- `tests/unit/test_delete_anime_service.py` — unit tests for `AnimeService.delete_series()`
- `tests/api/test_delete_anime_endpoint.py` — API endpoint tests including auth, validation, error cases
- `tests/frontend/test_delete_modal.py` — frontend modal logic and DOM validation tests
- `tests/security/test_delete_anime_security.py` — security tests for path traversal, XSS, auth bypass
---
## Changelog
| Date | Change |
|------|--------|
| 2026-08-16 | Feature added. `DELETE /api/anime/{key}`, right-click context menu, typed-confirmation modal, WebSocket sync. |

View File

@@ -70,6 +70,7 @@ The application now features a comprehensive configuration system that allows us
- **Library Scanning**: Automated scanning for missing episodes with database persistence
- **Episode Tracking**: Missing episodes tracked in database, automatically updated during scans
- **NFO Status Indicators**: Visual badges showing NFO and media file status for each series
- **Delete Anime**: Right-click any anime card → "Delete Anime" to remove a series from the database, filesystem, or both. Type `delete` in the confirmation field to proceed. See [Delete Anime Feature](./DELETE_ANIME_FEATURE.md) for details.
## NFO Metadata Management

View File

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

View File

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

View File

@@ -281,10 +281,13 @@ class SerieScanner:
async def _sync_episodes_to_db(
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

View File

@@ -22,6 +22,8 @@ from src.server.models.anime import (
AnimeSettingsRegenerateNfoResponse,
AnimeSettingsResponse,
AnimeSettingsUpdateRequest,
DeleteSeriesRequest,
DeleteSeriesResult,
TMDBSearchResult,
)
from src.server.services.anime_service import AnimeService, AnimeServiceError
@@ -1648,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
@@ -1725,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
@@ -1755,3 +1745,83 @@ async def regenerate_anime_nfo(
nfo_path=db_series.nfo_path,
repaired_tags=repaired_tags,
)
@router.delete(
"/{anime_key}",
response_model=DeleteSeriesResult,
)
async def delete_anime(
anime_key: str,
request: DeleteSeriesRequest,
_auth: dict = Depends(require_auth),
anime_service: AnimeService = Depends(get_anime_service),
) -> DeleteSeriesResult:
"""Delete an anime series from database, filesystem, or both.
Requires typing exactly 'delete' in the confirm_text field to prevent
accidental deletions. Users can choose to remove the series from the
database only, delete the folder only, or both.
Args:
anime_key: Series key from URL path (primary identifier)
request: DeleteSeriesRequest with delete options and confirmation
_auth: Ensures the caller is authenticated
anime_service: AnimeService dependency
Returns:
DeleteSeriesResult with outcome details
Raises:
HTTPException(400): confirm_text != "delete" or no options selected
HTTPException(404): Series not found
HTTPException(500): Unexpected error
"""
# Validate confirm_text
if request.confirm_text != "delete":
logger.warning(
"Delete anime rejected - invalid confirm_text: key=%s",
anime_key,
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"Confirmation text must be exactly 'delete'. "
f"Got '{request.confirm_text}'."
),
)
# Validate at least one option is selected
if not request.delete_database and not request.delete_folder:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="At least one of delete_database or delete_folder must be True.",
)
try:
result = await anime_service.delete_series(
key=anime_key,
delete_database=request.delete_database,
delete_folder=request.delete_folder,
)
if not result.success and not result.deleted_from_database:
# This means series was not found (key="" was passed as name)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=result.message,
)
return result
except HTTPException:
raise
except Exception as exc:
logger.error(
"Delete anime failed: key=%s error=%s",
anime_key, str(exc),
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Delete failed: {exc}",
) from exc

View File

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

View File

@@ -121,20 +121,40 @@ 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 [
anime for anime in self.keyDict.values()
if anime.episodeDict
if getattr(anime, 'episodeDict', None)
]
def get_missing_episodes(self) -> List[AnimeSeries]:

View File

@@ -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,20 +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
@@ -288,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

View File

@@ -210,6 +210,25 @@ class AnimeSeriesService:
)
return result.scalar_one_or_none()
@staticmethod
async def get_folder_path(
db: AsyncSession,
series_key: str,
) -> Optional[str]:
"""Get the filesystem folder path for a series by its key.
Args:
db: Database session
series_key: Provider key (e.g. "attack-on-titan")
Returns:
Folder path string, or None if series not found
"""
result = await db.execute(
select(AnimeSeries.folder).where(AnimeSeries.key == series_key)
)
return result.scalar_one_or_none()
@staticmethod
async def get_all(
db: AsyncSession,

View File

@@ -295,3 +295,55 @@ class AnimeSettingsRegenerateNfoResponse(BaseModel):
default_factory=list,
description="Tags that were missing before regeneration",
)
class DeleteSeriesRequest(BaseModel):
"""Request payload for DELETE /api/anime/{key}.
Requires typing exactly 'delete' in confirm_text to prevent accidental deletions.
"""
delete_database: bool = Field(
default=True,
description="Whether to remove the series from the database (default: True)"
)
delete_folder: bool = Field(
default=False,
description="Whether to delete the series folder from filesystem (default: False)"
)
confirm_text: str = Field(
...,
description="Must be exactly 'delete' to confirm the operation"
)
class DeleteSeriesResult(BaseModel):
"""Result of a delete operation on a series.
Tracks what was successfully deleted and any errors encountered.
"""
success: bool = Field(..., description="Whether the operation succeeded")
key: str = Field(..., description="Series key that was deleted")
name: str = Field(..., description="Series name at time of deletion")
deleted_from_database: bool = Field(
default=False,
description="Whether the series was removed from the database"
)
deleted_folder: bool = Field(
default=False,
description="Whether the folder was deleted from filesystem"
)
folder_path: Optional[str] = Field(
None,
description="Path to the folder that was (or would be) deleted"
)
database_error: Optional[str] = Field(
None,
description="Error message if database deletion failed"
)
folder_error: Optional[str] = Field(
None,
description="Error message if folder deletion failed"
)
message: str = Field(..., description="Human-readable outcome message")

View File

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

View File

@@ -5,6 +5,7 @@ import logging
import os
import re
import shutil
import time
import threading
from pathlib import Path
from urllib.parse import quote
@@ -383,6 +384,12 @@ class AniworldLoader(Loader):
"Direct stream download starting (type=%s)",
content_type
)
total_size = int(response.headers.get(
"Content-Length", 0
))
received = 0
last_emit = 0
start_time = time.monotonic()
with open(output_path, "wb") as fh:
for chunk in response.iter_content(chunk_size=1024 * 1024):
if self._cancel_flag.is_set():
@@ -391,7 +398,33 @@ class AniworldLoader(Loader):
)
return False
if chunk:
received += len(chunk)
fh.write(chunk)
# Emit progress events at ~1% intervals
if total_size > 0:
pct = (received / total_size) * 100
if pct - last_emit >= 1.0 or received == total_size:
elapsed = time.monotonic() - start_time
speed_bps = (
received / elapsed
if elapsed > 0 else 0
)
eta = (
int((total_size - received) / speed_bps)
if speed_bps > 0 else None
)
self.events.download_progress({
"downloaded_bytes": received,
"total_bytes": total_size,
"speed": speed_bps,
"eta": eta,
"status": (
"finished"
if received >= total_size
else "downloading"
),
})
last_emit = pct
return True
except requests.RequestException as exc:
logger.warning("Direct stream download failed: %s", exc)

View 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.
@@ -1634,6 +1741,445 @@ class AnimeService:
logger.error("Failed to get NFO statistics: %s", str(exc))
raise AnimeServiceError("NFO statistics query failed") from exc
async def delete_series(
self,
key: str,
delete_database: bool = True,
delete_folder: bool = False,
) -> "DeleteSeriesResult":
"""Delete an anime series from database, filesystem, or both.
Args:
key: Series key (primary identifier)
delete_database: If True, remove from database (default True)
delete_folder: If True, remove folder from filesystem (default False)
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(
"Delete series requested: key=%s delete_database=%s delete_folder=%s",
key, delete_database, delete_folder,
)
# Guard: at least one deletion mode must be selected
if not delete_database and not delete_folder:
logger.warning(
"Delete series rejected - no options selected: key=%s", key
)
return DeleteSeriesResult(
success=False,
key=key,
name="",
folder_path=None,
deleted_from_database=False,
deleted_folder=False,
database_error=None,
folder_error=None,
message="At least one of delete_database or delete_folder must be True.",
)
# 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 - 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,
name="",
folder_path=None,
deleted_from_database=False,
deleted_folder=False,
database_error=None,
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
result = DeleteSeriesResult(
success=True,
key=key,
name=series_name,
folder_path=folder_path,
message="",
)
# --- 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:
deleted = await AnimeSeriesService.delete(db, series_id)
if deleted:
logger.info(
"Deleted series from database: key=%s name=%s id=%d",
key, series_name, series_id,
)
result.deleted_from_database = True
else:
# Already gone is treated as success
result.deleted_from_database = True
except Exception as exc:
logger.error(
"Failed to delete series from database: key=%s error=%s",
key, str(exc),
)
result.database_error = str(exc)
result.success = False
# Invalidate list cache
try:
self._cached_list_missing.cache_clear()
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)
except Exception as exc:
logger.warning(
"Failed to broadcast series_deleted for %s: %s",
key, exc,
)
# Broadcast the broader series_list_changed event so any
# connected client that missed the series_deleted message
# (or whose local state drifted) can re-sync by re-fetching
# /api/anime. This is the durable fix for the
# "deleted but still listed" bug.
try:
await self._broadcast_series_list_changed(reason="deleted")
except Exception as exc:
logger.warning(
"Failed to broadcast series_list_changed after delete: "
"key=%s error=%s",
key, exc,
)
# --- Build message ---
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")
if result.deleted_folder and not result.folder_error:
parts.append("folder deleted from filesystem")
if result.database_error:
parts.append(f"database error: {result.database_error}")
if result.folder_error:
parts.append(f"folder error: {result.folder_error}")
if parts:
result.message = "; ".join(parts)
else:
result.message = "No action taken."
async def _broadcast_series_deleted(self, key: str, name: str) -> None:
"""Broadcast series_deleted event via WebSocket."""
try:
await self._websocket_service.broadcast_series_deleted(
key=key,
name=name,
)
logger.info(
"series_deleted broadcast sent: key=%s name=%s",
key, name,
)
except Exception as exc:
logger.warning(
"Failed to broadcast series_deleted: key=%s error=%s",
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."""

View File

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

View File

@@ -162,17 +162,18 @@ class ImageLoadingService:
for i in range(0, len(series_list), self.BATCH_SIZE):
batch = series_list[i : i + self.BATCH_SIZE]
tasks = [
self.load_series_images(
# Process each series sequentially to avoid concurrent use of the
# same AsyncSession (SQLAlchemy async sessions are not thread-safe
# for concurrent operations). BATCH_SIZE still paces TMDB requests.
results: List[Dict[str, Any] | Exception] = []
for series in batch:
result = await self.load_series_images(
key=series["key"],
folder=series["folder"],
anime_directory=anime_directory,
db=db,
)
for series in batch
]
results = await asyncio.gather(*tasks, return_exceptions=True)
results.append(result)
for series, result in zip(batch, results):
if isinstance(result, Exception):

View File

@@ -651,9 +651,9 @@ class WebSocketService:
async def shutdown(self, timeout: float = 5.0) -> None:
"""Gracefully shutdown the WebSocket service.
Broadcasts shutdown notification and closes all connections.
Args:
timeout: Maximum time (seconds) to wait for shutdown
"""
@@ -661,6 +661,63 @@ class WebSocketService:
await self._manager.shutdown(timeout=timeout)
logger.info("WebSocket service shutdown complete")
async def broadcast_series_deleted(
self,
key: str,
name: str,
) -> None:
"""Broadcast a series_deleted event to all connected clients.
Notifies clients that a series has been deleted so they can
remove it from their UI in real-time.
Args:
key: Series key that was deleted (primary identifier)
name: Series name for display purposes
"""
message = {
"type": "series_deleted",
"timestamp": datetime.now(timezone.utc).isoformat(),
"data": {
"key": key,
"name": name,
},
}
await self._manager.broadcast(message)
logger.info(
"Broadcast series_deleted key=%s name=%s",
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

View File

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

View File

@@ -311,6 +311,24 @@
color: var(--color-text-secondary);
}
.context-menu-item.danger {
color: var(--color-error, #e74c3c);
}
.context-menu-item.danger i {
color: var(--color-error, #e74c3c);
}
.context-menu-item.danger:hover {
background-color: rgba(231, 76, 60, 0.1);
}
.context-menu-divider {
height: 1px;
background: var(--color-border);
margin: var(--spacing-xs) 0;
}
/* ============================================================================
NFO Diagnostics
============================================================================ */
@@ -415,4 +433,125 @@
flex-shrink: 0;
}
/* ============================================================================
Delete Anime Modal
============================================================================ */
#delete-modal .modal-content {
max-width: 480px;
}
.delete-modal-section {
margin-bottom: var(--spacing-md);
}
.delete-modal-series-info {
background: var(--color-background-subtle);
border-radius: var(--border-radius);
padding: var(--spacing-md);
margin-bottom: var(--spacing-md);
}
.delete-modal-series-info p {
margin: 0 0 var(--spacing-xs) 0;
font-size: var(--font-size-body);
}
.delete-modal-series-info p:last-child {
margin-bottom: 0;
}
.delete-modal-series-name {
font-weight: 600;
color: var(--color-text-primary);
}
.delete-modal-series-key {
font-family: 'Consolas', 'Monaco', monospace;
font-size: var(--font-size-caption);
color: var(--color-text-tertiary);
}
.delete-modal-options {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-md);
}
.delete-modal-options label {
display: flex;
align-items: flex-start;
gap: var(--spacing-sm);
cursor: pointer;
font-size: var(--font-size-body);
}
.delete-modal-options input[type="checkbox"] {
margin-top: 3px;
accent-color: var(--color-accent);
}
.delete-modal-warning {
color: var(--color-error, #e74c3c);
font-size: var(--font-size-caption);
margin-top: var(--spacing-xs);
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.delete-modal-confirm-label {
font-size: var(--font-size-body);
color: var(--color-text-secondary);
margin-bottom: var(--spacing-xs);
}
.delete-modal-confirm-label strong {
color: var(--color-text-primary);
}
#delete-confirm-input {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
font-size: var(--font-size-body);
background: var(--color-background);
color: var(--color-text-primary);
transition: border-color 0.15s ease;
}
#delete-confirm-input:focus {
outline: none;
border-color: var(--color-accent);
}
#delete-confirm-input.matched {
border-color: var(--color-success);
background: rgba(46, 204, 113, 0.05);
}
#delete-error {
margin-top: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
background: rgba(231, 76, 60, 0.1);
border: 1px solid var(--color-error, #e74c3c);
border-radius: var(--border-radius);
color: var(--color-error, #e74c3c);
font-size: var(--font-size-caption);
}
.delete-modal-actions {
display: flex;
justify-content: flex-end;
gap: var(--spacing-sm);
margin-top: var(--spacing-lg);
}
#delete-confirm-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}

View File

@@ -46,6 +46,7 @@ AniWorld.IndexApp = (function() {
AniWorld.ScanManager.init();
AniWorld.ConfigManager.init();
AniWorld.ContextMenu.init();
AniWorld.DeleteModal.init();
// Bind global events
bindGlobalEvents();

View File

@@ -71,6 +71,11 @@ AniWorld.ContextMenu = (function() {
<i class="fa-solid fa-gear"></i>
<span>Anime Settings</span>
</div>
<div class="context-menu-divider"></div>
<div class="context-menu-item danger" data-action="delete-anime">
<i class="fa-solid fa-trash"></i>
<span>Delete Anime</span>
</div>
`;
document.body.appendChild(menuElement);
@@ -104,6 +109,17 @@ AniWorld.ContextMenu = (function() {
// Navigate to anime settings page with this series selected
window.location.href = '/anime/settings?key=' + encodeURIComponent(key);
});
// Delete Anime - opens the confirmation modal
menuElement.querySelector('[data-action="delete-anime"]').addEventListener('click', function() {
const key = currentSeriesKey;
hide();
if (AniWorld.DeleteModal && AniWorld.DeleteModal.show) {
AniWorld.DeleteModal.show(key);
} else {
console.error('[ContextMenu] DeleteModal not found on AniWorld');
}
});
}
/**

View File

@@ -0,0 +1,354 @@
/**
* AniWorld - Delete Anime Modal Module
*
* Handles the delete confirmation modal for anime series.
* Users must type "delete" to enable the confirm button.
*
* Dependencies: constants.js, api-client.js, ui-utils.js
*/
var AniWorld = window.AniWorld || {};
AniWorld.DeleteModal = (function() {
'use strict';
const API = '/api/anime';
let currentKey = null;
let currentSeriesName = null;
let modalElement = null;
let confirmBtn = null;
let confirmInput = null;
let deleteDbCheckbox = null;
let deleteFolderCheckbox = null;
let errorElement = null;
let isSubmitting = false;
/**
* Initialize the modal — inject HTML into body if not already present.
*/
function init() {
injectModalHTML();
cacheElements();
bindEvents();
console.info('[DeleteModal] initialized');
}
/**
* Build the modal HTML once and append to document.body.
*/
function injectModalHTML() {
if (document.getElementById('delete-modal')) return;
var div = document.createElement('div');
div.id = 'delete-modal';
div.className = 'modal hidden';
div.innerHTML =
'<div class="modal-overlay"></div>' +
'<div class="modal-content">' +
'<div class="modal-header">' +
'<h3>Delete Anime</h3>' +
'</div>' +
'<div class="modal-body">' +
'<div class="delete-modal-series-info">' +
'<p class="delete-modal-series-name" id="delete-modal-series-name"></p>' +
'<p class="delete-modal-series-key" id="delete-modal-series-key"></p>' +
'</div>' +
'<div class="delete-modal-options">' +
'<label>' +
'<input type="checkbox" id="delete-db-checkbox" checked> ' +
'Remove from database (recommended)' +
'</label>' +
'<label>' +
'<input type="checkbox" id="delete-folder-checkbox"> ' +
'Delete folder from filesystem' +
'</label>' +
'<p class="delete-modal-warning" id="delete-folder-warning" style="display:none;">' +
'<i class="fas fa-exclamation-triangle"></i> ' +
'This will permanently delete the folder and ALL files inside it!' +
'</p>' +
'</div>' +
'<label class="delete-modal-confirm-label" for="delete-confirm-input">' +
'Type <strong>delete</strong> to confirm:' +
'</label>' +
'<input type="text" id="delete-confirm-input" ' +
'placeholder="delete" autocomplete="off" spellcheck="false">' +
'<div id="delete-error" class="hidden"></div>' +
'</div>' +
'<div class="modal-footer delete-modal-actions">' +
'<button class="btn" id="delete-cancel-btn">Cancel</button>' +
'<button class="btn btn-danger" id="delete-confirm-btn" disabled>Delete</button>' +
'</div>' +
'</div>';
document.body.appendChild(div);
}
/**
* Cache DOM element references.
*/
function cacheElements() {
modalElement = document.getElementById('delete-modal');
confirmBtn = document.getElementById('delete-confirm-btn');
confirmInput = document.getElementById('delete-confirm-input');
deleteDbCheckbox = document.getElementById('delete-db-checkbox');
deleteFolderCheckbox = document.getElementById('delete-folder-checkbox');
errorElement = document.getElementById('delete-error');
}
/**
* Bind event listeners on the modal.
*/
function bindEvents() {
// Guard against missing modal
if (!modalElement) return;
// Cancel button
var cancelBtn = document.getElementById('delete-cancel-btn');
if (cancelBtn) cancelBtn.addEventListener('click', hide);
// Close on backdrop click
var overlay = modalElement.querySelector('.modal-overlay');
if (overlay) overlay.addEventListener('click', hide);
// Escape key to close
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && !isSubmitting && modalElement && !modalElement.classList.contains('hidden')) {
hide();
}
});
// Folder checkbox toggle — show/hide warning
if (deleteFolderCheckbox) {
deleteFolderCheckbox.addEventListener('change', function() {
var warning = document.getElementById('delete-folder-warning');
if (warning) {
warning.style.display = deleteFolderCheckbox.checked ? 'flex' : 'none';
}
});
}
// Confirm input — validate and update button state
if (confirmInput) {
confirmInput.addEventListener('input', function() {
var value = confirmInput.value;
var isMatch = value === 'delete';
if (confirmBtn) confirmBtn.disabled = !isMatch || isSubmitting;
confirmInput.classList.toggle('matched', isMatch);
});
}
// Confirm button
if (confirmBtn) confirmBtn.addEventListener('click', handleConfirm);
// Click outside modal content to close
modalElement.addEventListener('click', function(e) {
if (e.target === modalElement) {
hide();
}
});
}
/**
* Show the delete modal for a given series.
* @param {string} key - Series key
*/
function show(key) {
console.info('[DeleteModal] Opening for key:', key);
// Ensure elements are cached (in case DOM was replaced)
cacheElements();
// Guard against missing elements
if (!modalElement || !confirmInput || !confirmBtn) {
console.error('[DeleteModal] Modal elements not found in DOM. Re-injecting.');
injectModalHTML();
cacheElements();
if (!modalElement) {
console.error('[DeleteModal] Failed to create modal element.');
return;
}
}
// Get series info from SeriesManager if available
var seriesData = null;
if (AniWorld.SeriesManager && AniWorld.SeriesManager.findByKey) {
seriesData = AniWorld.SeriesManager.findByKey(key);
}
currentKey = key;
currentSeriesName = seriesData ? (seriesData.name || key) : key;
// Populate modal — guard against missing elements
var seriesNameEl = document.getElementById('delete-modal-series-name');
var seriesKeyEl = document.getElementById('delete-modal-series-key');
var folderWarningEl = document.getElementById('delete-folder-warning');
if (seriesNameEl) seriesNameEl.textContent = currentSeriesName;
if (seriesKeyEl) seriesKeyEl.textContent = 'Key: ' + key;
// Reset state
confirmInput.value = '';
confirmInput.classList.remove('matched');
confirmBtn.disabled = true;
isSubmitting = false;
if (errorElement) {
errorElement.classList.add('hidden');
errorElement.textContent = '';
}
if (deleteDbCheckbox) deleteDbCheckbox.checked = true;
if (deleteFolderCheckbox) deleteFolderCheckbox.checked = false;
if (folderWarningEl) folderWarningEl.style.display = 'none';
// Show modal
modalElement.classList.remove('hidden');
confirmInput.focus();
}
/**
* Hide the modal and reset state.
*/
function hide() {
if (isSubmitting) return; // Don't close while submitting
modalElement.classList.add('hidden');
currentKey = null;
currentSeriesName = null;
}
/**
* Handle confirm button click.
*/
async function handleConfirm() {
if (confirmBtn.disabled || isSubmitting) return;
var deleteDb = deleteDbCheckbox.checked;
var deleteFolder = deleteFolderCheckbox.checked;
var confirmText = confirmInput.value.trim();
// Validate confirm text
if (confirmText !== 'delete') {
showError('You must type exactly "delete" to confirm.');
return;
}
// Validate at least one option selected
if (!deleteDb && !deleteFolder) {
showError('Please select at least one delete option.');
return;
}
isSubmitting = true;
confirmBtn.disabled = true;
confirmBtn.textContent = 'Deleting...';
errorElement.classList.add('hidden');
console.info('[DeleteModal] Initiating delete:', {
key: currentKey,
delete_database: deleteDb,
delete_folder: deleteFolder
});
try {
var response = await AniWorld.ApiClient.request(
API + '/' + encodeURIComponent(currentKey),
{
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
delete_database: deleteDb,
delete_folder: deleteFolder,
confirm_text: confirmText
})
}
);
if (!response) {
// Auth failure already redirected
hide();
return;
}
if (response.status === 401) {
AniWorld.Auth.removeToken();
window.location.href = '/login';
hide();
return;
}
if (response.status === 400) {
var body = await response.json().catch(function() { return {}; });
showError(body.detail || 'Invalid request: ' + response.status);
isSubmitting = false;
confirmBtn.textContent = 'Delete';
confirmBtn.disabled = false;
return;
}
if (response.status === 404) {
showError('Series not found: ' + currentKey);
isSubmitting = false;
confirmBtn.textContent = 'Delete';
return;
}
if (!response.ok) {
var text = await response.text();
showError('Delete failed: HTTP ' + response.status + ' ' + text);
isSubmitting = false;
confirmBtn.textContent = 'Delete';
confirmBtn.disabled = false;
return;
}
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');
if (result.deleted_folder) msgParts.push('folder deleted from filesystem');
if (result.database_error) msgParts.push('DB error: ' + result.database_error);
if (result.folder_error) msgParts.push('Folder error: ' + result.folder_error);
var successMsg = msgParts.length > 0
? msgParts.join('; ')
: '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(deletedKey);
}
} catch (err) {
console.error('[DeleteModal] Delete request failed:', err);
showError('Network error: ' + (err && err.message ? err.message : String(err)));
isSubmitting = false;
confirmBtn.textContent = 'Delete';
confirmBtn.disabled = false;
}
}
/**
* Show an error message in the modal.
* @param {string} msg
*/
function showError(msg) {
errorElement.textContent = msg;
errorElement.classList.remove('hidden');
}
// Public API
return {
init: init,
show: show,
hide: hide
};
})();

View File

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

View File

@@ -549,6 +549,52 @@ AniWorld.SeriesManager = (function() {
renderSeries();
}
/**
* Remove a series from the local data arrays and re-render the grid.
* Called after a successful delete or when receiving series_deleted WS event.
* @param {string} key - Series key to remove
*/
function removeSeries(key) {
if (!key) return;
var removedFromData = false;
var removedFromFiltered = false;
if (seriesData) {
var dataIdx = seriesData.findIndex(function(s) { return s.key === key; });
if (dataIdx >= 0) {
seriesData.splice(dataIdx, 1);
removedFromData = true;
}
}
if (filteredSeriesData) {
var filteredIdx = filteredSeriesData.findIndex(function(s) { return s.key === key; });
if (filteredIdx >= 0) {
filteredSeriesData.splice(filteredIdx, 1);
removedFromFiltered = true;
}
}
if (removedFromData || removedFromFiltered) {
console.info('[SeriesManager] Removed series from local state:', key);
renderSeries();
} else {
console.warn('[SeriesManager] Series not found in local state:', key);
}
}
/**
* 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,
@@ -560,6 +606,8 @@ AniWorld.SeriesManager = (function() {
findByKey: findByKey,
updateSeriesLoadingStatus: updateSeriesLoadingStatus,
updateSingleSeries: updateSingleSeries,
updateSeriesKey: updateSeriesKey
updateSeriesKey: updateSeriesKey,
removeSeries: removeSeries,
reloadSeries: reloadSeries
};
})();

View File

@@ -136,13 +136,16 @@ AniWorld.IndexSocketHandler = (function() {
// Series events
socket.on(WS_EVENTS.SERIES_UPDATED, function(data) {
console.log('Series updated:', data);
// Use the data directly to update the series instead of full refresh
if (data && data.data && AniWorld.SeriesManager && AniWorld.SeriesManager.updateSingleSeries) {
AniWorld.SeriesManager.updateSingleSeries(data.data);
// NOTE: websocket-client.js strips the outer {type, data, ...} wrapper
// before emitting, so `data` here is the inner series data object
// (e.g. {key, name, missing_episodes, ...}) — NOT {type, data, ...}.
// AniWorld.SeriesManager.updateSingleSeries() expects this flat object.
if (data && data.key && AniWorld.SeriesManager && AniWorld.SeriesManager.updateSingleSeries) {
AniWorld.SeriesManager.updateSingleSeries(data);
} else {
// Fallback to full reload if data is incomplete
console.warn('Incomplete series update data, falling back to full reload');
console.warn('Incomplete series update data, falling back to full reload', data);
if (AniWorld.SeriesManager && AniWorld.SeriesManager.loadSeries) {
AniWorld.SeriesManager.loadSeries();
}
@@ -157,6 +160,26 @@ AniWorld.IndexSocketHandler = (function() {
}
});
// Series deleted event — remove the card from the UI
socket.on(WS_EVENTS.SERIES_DELETED, function(data) {
console.info('[SocketHandler] Series deleted:', data);
if (data && data.key && AniWorld.SeriesManager && AniWorld.SeriesManager.removeSeries) {
AniWorld.SeriesManager.removeSeries(data.key);
AniWorld.UI.showToast('Series deleted: ' + (data.name || data.key), 'success');
}
});
// 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;

View File

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

View File

@@ -104,6 +104,11 @@ AniWorld.Constants = (function() {
// Series events
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',

View File

@@ -693,6 +693,7 @@
<script src="/static/js/index/nfo-config.js?v={{ static_v }}"></script>
<script src="/static/js/index/config-manager.js?v={{ static_v }}"></script>
<script src="/static/js/index/socket-handler.js?v={{ static_v }}"></script>
<script src="/static/js/index/delete-modal.js?v={{ static_v }}"></script>
<script src="/static/js/index/app-init.js?v={{ static_v }}"></script>
</body>

View File

@@ -0,0 +1,400 @@
"""Tests for DELETE /api/anime/{key} endpoint."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from httpx import ASGITransport, AsyncClient
from src.server.api import anime as anime_module
from src.server.fastapi_app import app
from src.server.services.auth_service import auth_service
class FakeSerie:
"""Mock Serie object for testing."""
def __init__(self, key, name, folder, episodeDict=None):
self.key = key
self.name = name
self.folder = folder
self.episodeDict = episodeDict or {}
self.site = "aniworld.to"
class FakeSeriesApp:
"""Mock SeriesApp for testing."""
def __init__(self):
self.list = self
self.serie_scanner = MagicMock()
self.directory = "/tmp/fake_anime"
self.keyDict = {}
self._items = [
FakeSerie("test-show-key", "Test Show", "Test Show (2023)", {1: [1, 2]}),
]
for item in self._items:
self.keyDict[item.key] = item
def GetList(self):
return self._items
@pytest.fixture
async def authenticated_client():
"""Return an async HTTP client authenticated with a valid token."""
if not auth_service.is_configured():
auth_service.setup_master_password("TestPass123!")
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as ac:
r = await ac.post("/api/auth/login", json={"password": "TestPass123!"})
assert r.status_code == 200, f"Login failed: {r.status_code} {r.text}"
token = r.json()["access_token"]
ac.headers["Authorization"] = f"Bearer {token}"
yield ac
@pytest.fixture
def mock_series_app():
"""Create a mock SeriesApp for dependency injection."""
return FakeSeriesApp()
class TestDeleteAnimeEndpoint:
"""Tests for DELETE /api/anime/{key}."""
@pytest.mark.asyncio
async def test_delete_requires_authentication(self):
"""DELETE without token returns 401."""
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as ac:
r = await ac.request(
"DELETE",
"/api/anime/test-key",
json={"delete_database": True, "delete_folder": False, "confirm_text": "delete"},
)
assert r.status_code == 401
@pytest.mark.asyncio
async def test_delete_invalid_confirm_text(self, authenticated_client):
"""DELETE with wrong confirm_text returns 400."""
with patch('src.server.api.anime.get_anime_service') as mock_get_svc:
mock_svc = MagicMock()
mock_get_svc.return_value = mock_svc
r = await authenticated_client.request(
"DELETE",
"/api/anime/test-key",
json={
"delete_database": True,
"delete_folder": False,
"confirm_text": "DELETE", # Wrong case
},
)
assert r.status_code == 400
assert "delete" in r.json()["detail"].lower()
@pytest.mark.asyncio
async def test_delete_invalid_confirm_text_empty(self, authenticated_client):
"""DELETE with empty confirm_text returns 400."""
with patch('src.server.api.anime.get_anime_service') as mock_get_svc:
mock_svc = MagicMock()
mock_get_svc.return_value = mock_svc
r = await authenticated_client.request(
"DELETE",
"/api/anime/test-key",
json={
"delete_database": True,
"delete_folder": False,
"confirm_text": "",
},
)
assert r.status_code == 400
@pytest.mark.asyncio
async def test_delete_no_option_selected(self, authenticated_client):
"""DELETE with both flags False returns 400."""
with patch('src.server.api.anime.get_anime_service') as mock_get_svc:
mock_svc = MagicMock()
mock_get_svc.return_value = mock_svc
r = await authenticated_client.request(
"DELETE",
"/api/anime/test-key",
json={
"delete_database": False,
"delete_folder": False,
"confirm_text": "delete",
},
)
assert r.status_code == 400
assert "at least one" in r.json()["detail"].lower()
@pytest.mark.asyncio
async def test_delete_database_only_success(self, authenticated_client):
"""DELETE with delete_database=True succeeds and returns result."""
from src.server.models.anime import DeleteSeriesResult
mock_result = DeleteSeriesResult(
success=True,
key="test-show-key",
name="Test Show",
deleted_from_database=True,
deleted_folder=False,
folder_path=None,
database_error=None,
folder_error=None,
message="Removed from database.",
)
with patch('src.server.api.anime.get_anime_service') as mock_get_svc:
mock_svc = MagicMock()
mock_svc.delete_series = AsyncMock(return_value=mock_result)
mock_get_svc.return_value = mock_svc
r = await authenticated_client.request(
"DELETE",
"/api/anime/test-show-key",
json={
"delete_database": True,
"delete_folder": False,
"confirm_text": "delete",
},
)
assert r.status_code == 200
data = r.json()
assert data["success"] is True
assert data["key"] == "test-show-key"
assert data["deleted_from_database"] is True
assert data["deleted_folder"] is False
@pytest.mark.asyncio
async def test_delete_folder_only_success(self, authenticated_client):
"""DELETE with delete_folder=True succeeds."""
from src.server.models.anime import DeleteSeriesResult
mock_result = DeleteSeriesResult(
success=True,
key="test-show-key",
name="Test Show",
deleted_from_database=False,
deleted_folder=True,
folder_path="/anime/Test Show",
database_error=None,
folder_error=None,
message="Folder deleted.",
)
with patch('src.server.api.anime.get_anime_service') as mock_get_svc:
mock_svc = MagicMock()
mock_svc.delete_series = AsyncMock(return_value=mock_result)
mock_get_svc.return_value = mock_svc
r = await authenticated_client.request(
"DELETE",
"/api/anime/test-show-key",
json={
"delete_database": False,
"delete_folder": True,
"confirm_text": "delete",
},
)
assert r.status_code == 200
data = r.json()
assert data["deleted_folder"] is True
assert data["deleted_from_database"] is False
@pytest.mark.asyncio
async def test_delete_both_success(self, authenticated_client):
"""DELETE with both flags True succeeds."""
from src.server.models.anime import DeleteSeriesResult
mock_result = DeleteSeriesResult(
success=True,
key="test-show-key",
name="Test Show",
deleted_from_database=True,
deleted_folder=True,
folder_path="/anime/Test Show",
database_error=None,
folder_error=None,
message="Removed from database and folder deleted.",
)
with patch('src.server.api.anime.get_anime_service') as mock_get_svc:
mock_svc = MagicMock()
mock_svc.delete_series = AsyncMock(return_value=mock_result)
mock_get_svc.return_value = mock_svc
r = await authenticated_client.request(
"DELETE",
"/api/anime/test-show-key",
json={
"delete_database": True,
"delete_folder": True,
"confirm_text": "delete",
},
)
assert r.status_code == 200
data = r.json()
assert data["deleted_from_database"] is True
assert data["deleted_folder"] is True
@pytest.mark.asyncio
async def test_delete_series_not_found(self, authenticated_client):
"""DELETE with unknown key returns 404."""
from src.server.models.anime import DeleteSeriesResult
mock_result = DeleteSeriesResult(
success=False,
key="nonexistent-key",
name="",
deleted_from_database=False,
deleted_folder=False,
folder_path=None,
database_error=None,
folder_error=None,
message="Series not found.",
)
with patch('src.server.api.anime.get_anime_service') as mock_get_svc:
mock_svc = MagicMock()
mock_svc.delete_series = AsyncMock(return_value=mock_result)
mock_get_svc.return_value = mock_svc
r = await authenticated_client.request(
"DELETE",
"/api/anime/nonexistent-key",
json={
"delete_database": True,
"delete_folder": False,
"confirm_text": "delete",
},
)
assert r.status_code == 404
assert "not found" in r.json()["detail"].lower()
@pytest.mark.asyncio
async def test_delete_service_error_returns_500(self, authenticated_client):
"""DELETE when service raises unexpected error returns 500."""
with patch('src.server.api.anime.get_anime_service') as mock_get_svc:
mock_svc = MagicMock()
mock_svc.delete_series = AsyncMock(side_effect=RuntimeError("Unexpected error"))
mock_get_svc.return_value = mock_svc
r = await authenticated_client.request(
"DELETE",
"/api/anime/test-show-key",
json={
"delete_database": True,
"delete_folder": False,
"confirm_text": "delete",
},
)
assert r.status_code == 500
assert "delete failed" in r.json()["detail"].lower()
@pytest.mark.asyncio
async def test_delete_body_confirm_text_required(self, authenticated_client):
"""DELETE body must contain confirm_text field."""
with patch('src.server.api.anime.get_anime_service') as mock_get_svc:
mock_svc = MagicMock()
mock_get_svc.return_value = mock_svc
# Missing confirm_text entirely
r = await authenticated_client.request(
"DELETE",
"/api/anime/test-show-key",
json={
"delete_database": True,
"delete_folder": False,
},
)
# Pydantic validation error
assert r.status_code in (400, 422)
@pytest.mark.asyncio
async def test_delete_confirm_text_too_long(self, authenticated_client):
"""DELETE with extremely long confirm_text is rejected."""
with patch('src.server.api.anime.get_anime_service') as mock_get_svc:
mock_svc = MagicMock()
mock_get_svc.return_value = mock_svc
r = await authenticated_client.request(
"DELETE",
"/api/anime/test-show-key",
json={
"delete_database": True,
"delete_folder": False,
"confirm_text": "delete" + "x" * 10000,
},
)
# Should be rejected as invalid confirm_text
assert r.status_code in (400, 422)
@pytest.mark.asyncio
async def test_delete_confirm_text_with_whitespace_rejected(self, authenticated_client):
"""DELETE with whitespace-padded confirm_text is rejected."""
with patch('src.server.api.anime.get_anime_service') as mock_get_svc:
mock_svc = MagicMock()
mock_get_svc.return_value = mock_svc
r = await authenticated_client.request(
"DELETE",
"/api/anime/test-show-key",
json={
"delete_database": True,
"delete_folder": False,
"confirm_text": " delete ", # Has spaces
},
)
assert r.status_code == 400
assert "delete" in r.json()["detail"].lower()
@pytest.mark.asyncio
async def test_delete_confirm_text_with_newline_rejected(self, authenticated_client):
"""DELETE with newline in confirm_text is rejected."""
with patch('src.server.api.anime.get_anime_service') as mock_get_svc:
mock_svc = MagicMock()
mock_get_svc.return_value = mock_svc
r = await authenticated_client.request(
"DELETE",
"/api/anime/test-show-key",
json={
"delete_database": True,
"delete_folder": False,
"confirm_text": "delet\ne", # Has newline
},
)
assert r.status_code == 400
@pytest.mark.asyncio
async def test_delete_path_traversal_in_key_rejected(self, authenticated_client):
"""DELETE with path traversal key returns 400 or 404."""
with patch('src.server.api.anime.get_anime_service') as mock_get_svc:
mock_svc = MagicMock()
mock_svc.delete_series = AsyncMock(side_effect=ValueError("Path traversal detected"))
mock_get_svc.return_value = mock_svc
r = await authenticated_client.request(
"DELETE",
"/api/anime/../../../etc/passwd",
json={
"delete_database": True,
"delete_folder": False,
"confirm_text": "delete",
},
)
# Should either be rejected by the service or return an error
assert r.status_code in (400, 404, 422)

View File

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

View File

@@ -0,0 +1,465 @@
"""
Frontend unit tests for delete-modal.js.
Tests the DeleteModal JavaScript module logic in isolation.
Since this is a browser-only module, we test the underlying logic
(validation, URL construction, response handling) as Python logic.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# Module-level mock classes (shared across tests)
class MockUI:
"""Mock UI module."""
showToast_called = []
@staticmethod
def showToast(msg, level):
MockUI.showToast_called.append((msg, level))
class MockResponse:
"""Simulates httpx AsyncClient response used by delete-modal.js."""
def __init__(self, status_code, json_data=None):
self._status = status_code
self._json = json_data
@property
def ok(self):
return 200 <= self._status < 300
@property
def status(self):
return self._status
async def json(self):
return self._json
class MockApiClient:
"""Mock ApiClient that simulates delete-modal.js API calls."""
last_request = None
@classmethod
async def request(cls, url, options=None):
cls.last_request = (url, options)
# Route based on key in URL
if "test-show-key" in url:
return MockResponse(200, {
"success": True,
"key": "test-show-key",
"name": "Test Show",
"deleted_from_database": True,
"deleted_folder": False,
"message": "Removed from database.",
})
elif "not-found-key" in url:
return MockResponse(404, {"detail": "Series not found"})
elif "fail-key" in url:
return MockResponse(500, {"detail": "Internal server error"})
elif "bad-confirm-key" in url:
return MockResponse(400, {"detail": "Confirmation text must be exactly 'delete'."})
return MockResponse(400, {"detail": "Unknown error"})
# Store original for reset
_original_api_request = MockApiClient.request
class MockAniWorld:
"""Mock AniWorld namespace used by delete-modal.js."""
UI = MockUI
ApiClient = MockApiClient
DeleteModal = None
SeriesManager = None
Auth = MagicMock()
Auth.removeToken = MagicMock()
@pytest.fixture(autouse=True)
def reset_mock_aniworld():
"""Reset mock state before each test to prevent pollution."""
MockUI.showToast_called = []
MockApiClient.last_request = None
# Restore both MockApiClient.request AND MockAniWorld.ApiClient.request
# (tests may set either one directly)
MockApiClient.request = _original_api_request
MockAniWorld.ApiClient = MockApiClient
MockAniWorld.SeriesManager = None
MockAniWorld.Auth = MagicMock()
MockAniWorld.Auth.removeToken = MagicMock()
yield
class TestDeleteModalValidation:
"""Tests for the confirm-text validation logic."""
def test_confirm_input_disables_button_until_delete_typed(self):
"""Button is disabled until user types 'delete'."""
confirm_input = {"value": "", "classList": {"toggle": MagicMock()}}
confirm_btn = {"disabled": False}
# Initially empty - button should be disabled
is_match = confirm_input["value"] == "delete"
confirm_btn["disabled"] = not is_match
assert confirm_btn["disabled"] is True
# User types 'del'
confirm_input["value"] = "del"
is_match = confirm_input["value"] == "delete"
confirm_btn["disabled"] = not is_match
assert confirm_btn["disabled"] is True
# User types 'delete'
confirm_input["value"] = "delete"
is_match = confirm_input["value"] == "delete"
confirm_btn["disabled"] = not is_match
assert confirm_btn["disabled"] is False
def test_confirm_input_matched_class_toggles(self):
"""Input gets 'matched' CSS class when value is 'delete'."""
matched_states = []
for value in ["", "del", "delete", "Delete", "delete "]:
is_match = value == "delete"
matched_states.append(is_match)
assert matched_states == [False, False, True, False, False]
def test_folder_checkbox_shows_warning_when_checked(self):
"""Folder warning appears when delete-folder checkbox is checked."""
warning_shown = []
for is_checked in [False, True, False]:
warning_shown.append(is_checked)
assert warning_shown[0] is False
assert warning_shown[1] is True
assert warning_shown[2] is False
def test_at_least_one_option_required_validation(self):
"""Modal should reject when neither checkbox is selected."""
delete_db = False
delete_folder = False
is_valid = delete_db or delete_folder
assert is_valid is False
delete_db = True
is_valid = delete_db or delete_folder
assert is_valid is True
def test_confirm_text_exact_match_required(self):
"""confirmText must be exactly 'delete' (case-sensitive)."""
test_cases = [
("delete", True),
("DELETE", False),
("Delete", False),
(" delete", False),
("delete ", False),
(" delete ", False),
("deletex", False),
("", False),
]
for text, expected in test_cases:
result = text == "delete"
assert result is expected, f"'{text}' should be {expected}"
def test_delete_api_url_construction(self):
"""DELETE request is sent to /api/anime/{key}."""
key = "test-show-key"
url = '/api/anime/' + key
assert url == "/api/anime/test-show-key"
assert "test-show-key" in url
def test_delete_api_body_construction(self):
"""API body contains all three required fields."""
delete_database = True
delete_folder = False
confirm_text = "delete"
body = {
"delete_database": delete_database,
"delete_folder": delete_folder,
"confirm_text": confirm_text
}
assert body["delete_database"] is True
assert body["delete_folder"] is False
assert body["confirm_text"] == "delete"
class TestDeleteModalAPI:
"""Tests for the delete modal API interaction logic."""
@pytest.mark.asyncio
async def test_api_called_with_correct_url_and_method(self):
"""DELETE request is sent to correct endpoint."""
url = "/api/anime/test-show-key"
options = {
"method": "DELETE",
"headers": {"Content-Type": "application/json"},
"body": '{"delete_database": true, "delete_folder": false, "confirm_text": "delete"}'
}
response = await MockAniWorld.ApiClient.request(url, options)
assert response.status == 200
@pytest.mark.asyncio
async def test_api_returns_404_shows_not_found_error(self):
"""API 404 response returns 'not found' detail."""
response = await MockAniWorld.ApiClient.request(
"/api/anime/not-found-key",
{"method": "DELETE", "body": "{}"}
)
assert response.status == 404
data = await response.json()
assert "not found" in data["detail"].lower()
@pytest.mark.asyncio
async def test_api_returns_400_shows_validation_error(self):
"""API 400 response contains validation error detail."""
response = await MockAniWorld.ApiClient.request(
"/api/anime/bad-confirm-key",
{"method": "DELETE"}
)
assert response.status == 400
data = await response.json()
assert "delete" in data["detail"].lower()
@pytest.mark.asyncio
async def test_api_network_error_raises_exception(self):
"""Network failure raises an exception."""
MockAniWorld.ApiClient.request = AsyncMock(
side_effect=Exception("Network connection failed")
)
with pytest.raises(Exception) as exc_info:
await MockAniWorld.ApiClient.request("/api/anime/test", {})
assert "network" in str(exc_info.value).lower() or "failed" in str(exc_info.value).lower()
@pytest.mark.asyncio
async def test_success_response_contains_deleted_fields(self):
"""Successful response includes deleted_from_database and deleted_folder."""
response = await MockAniWorld.ApiClient.request(
"/api/anime/test-show-key",
{"method": "DELETE"}
)
data = await response.json()
assert "success" in data
assert "deleted_from_database" in data
assert "deleted_folder" in data
class TestDeleteModalSeriesManagerIntegration:
"""Tests for SeriesManager.removeSeries integration."""
def test_remove_series_called_after_success(self):
"""After successful delete, removeSeries(key) is called."""
key = "test-show-key"
remove_called_with = []
class MockSeriesManager:
@staticmethod
def removeSeries(k):
remove_called_with.append(k)
MockAniWorld.SeriesManager = MockSeriesManager
# Simulate: after successful API response
result = {"success": True, "key": key, "name": "Test Show"}
if result["success"] and MockAniWorld.SeriesManager:
MockAniWorld.SeriesManager.removeSeries(result["key"])
assert remove_called_with == [key]
def test_remove_series_not_called_on_failure(self):
"""removeSeries is NOT called when API returns error."""
remove_called_with = []
class MockSeriesManager:
@staticmethod
def removeSeries(k):
remove_called_with.append(k)
MockAniWorld.SeriesManager = MockSeriesManager
# Simulate: API returns error
result = {"success": False, "key": "test-show-key", "message": "Not found"}
if result["success"] and MockAniWorld.SeriesManager:
MockAniWorld.SeriesManager.removeSeries(result["key"])
assert remove_called_with == []
def test_ws_event_broadcast_triggers_remove(self):
"""WebSocket series_deleted event triggers removeSeries."""
key = "ws-deleted-key"
remove_called_with = []
class MockSeriesManager:
@staticmethod
def removeSeries(k):
remove_called_with.append(k)
MockAniWorld.SeriesManager = MockSeriesManager
# Simulate WS event handler
def on_series_deleted(data):
if MockAniWorld.SeriesManager and MockAniWorld.SeriesManager.removeSeries:
MockAniWorld.SeriesManager.removeSeries(data["key"])
on_series_deleted({"key": key})
assert remove_called_with == [key]
class TestDeleteModalModalCloseAfterSuccess:
"""Regression tests for the bug where the modal stayed visible after a
successful delete because isSubmitting was never reset on the success path.
Source-level tests (the JS is not run in pytest): they assert that the
handleConfirm success branch (a) clears isSubmitting before hide(), and
(b) removes the card via a key captured before hide() nulls currentKey.
"""
@staticmethod
def _read_source():
import os
path = os.path.join(
os.path.dirname(__file__),
'..', '..',
'src', 'server', 'web', 'static', 'js', 'index', 'delete-modal.js'
)
with open(path, 'r') as f:
return f.read()
def test_isSubmitting_reset_on_success_path(self):
"""isSubmitting must be reset to false after a successful delete,
otherwise hide()'s early-return guard keeps the modal visible."""
src = self._read_source()
# Locate the success branch: it begins with "Delete succeeded:" log
success_idx = src.find("Delete succeeded:")
assert success_idx > 0, "Could not find Delete succeeded log line"
# Everything between the success log and the catch block belongs to
# the success path.
catch_idx = src.find("} catch (err)", success_idx)
assert catch_idx > 0, "Could not find catch block after success path"
success_branch = src[success_idx:catch_idx]
# The flag must be reset in this branch...
assert "isSubmitting = false" in success_branch, (
"isSubmitting is never reset on the success path — "
"this is the bug that left the modal visible with 'Deleting...'"
)
# ...BEFORE hide() is called.
reset_pos = success_branch.find("isSubmitting = false")
hide_pos = success_branch.find("hide();")
assert reset_pos > 0 and hide_pos > 0, (
"Could not locate isSubmitting reset or hide() call"
)
assert reset_pos < hide_pos, (
"isSubmitting must be cleared BEFORE hide() — otherwise hide()'s "
"guard (`if (isSubmitting) return`) bails out and the modal stays"
)
def test_modal_hidden_class_added_on_success(self):
"""After the success-path cleanup, hide() must run and apply the
'hidden' class. We verify by checking hide() is reached after the
isSubmitting reset (covered above) and that the reset precedes the
removal of the card from the grid."""
src = self._read_source()
success_idx = src.find("Delete succeeded:")
catch_idx = src.find("} catch (err)", success_idx)
success_branch = src[success_idx:catch_idx]
reset_pos = success_branch.find("isSubmitting = false")
hide_pos = success_branch.find("hide();")
remove_pos = success_branch.find("removeSeries(")
assert 0 < reset_pos < hide_pos < remove_pos, (
"Order on success path must be: "
"isSubmitting reset -> hide() -> removeSeries()"
)
def test_removeSeries_uses_captured_key_not_live_currentKey(self):
"""hide() nulls currentKey on line ~211. If removeSeries reads the
live currentKey AFTER hide(), it gets null and silently no-ops. The
fix captures the key into a local before hide() runs."""
src = self._read_source()
success_idx = src.find("Delete succeeded:")
catch_idx = src.find("} catch (err)", success_idx)
success_branch = src[success_idx:catch_idx]
# There must be a local capture of the key before hide().
assert "var deletedKey = currentKey;" in success_branch, (
"Success path must capture currentKey into a local before "
"hide() nulls it — otherwise removeSeries(currentKey) would be "
"a silent no-op."
)
# The removeSeries call must reference the captured local, not
# currentKey directly.
capture_pos = success_branch.find("var deletedKey = currentKey;")
remove_pos = success_branch.find("removeSeries(deletedKey)")
assert capture_pos > 0 and remove_pos > 0, (
"removeSeries must be called with the captured deletedKey"
)
assert capture_pos < remove_pos, (
"Capture must happen BEFORE removeSeries reads it"
)
def test_confirm_button_text_reset_on_success(self):
"""The button text is changed to 'Deleting...' during submit and must
be reverted to 'Delete' so the modal is in a clean state if reopened."""
src = self._read_source()
success_idx = src.find("Delete succeeded:")
catch_idx = src.find("} catch (err)", success_idx)
success_branch = src[success_idx:catch_idx]
assert "confirmBtn.textContent = 'Delete'" in success_branch, (
"Confirm button text must be reset to 'Delete' on the success path"
)
class TestDeleteModalConstants:
"""Tests for SERIES_DELETED WebSocket event constant."""
def test_series_deleted_constant_referenced_in_constants_js(self):
"""WS_EVENTS.SERIES_DELETED constant exists in constants.js."""
import os
constants_path = os.path.join(
os.path.dirname(__file__),
'..', '..',
'src', 'server', 'web', 'static', 'js', 'shared', 'constants.js'
)
with open(constants_path, 'r') as f:
content = f.read()
assert 'SERIES_DELETED' in content
assert "SERIES_DELETED: 'series_deleted'" in content
def test_series_deleted_constant_referenced_in_socket_handler(self):
"""WS_EVENTS.SERIES_DELETED is handled in socket-handler.js."""
import os
handler_path = os.path.join(
os.path.dirname(__file__),
'..', '..',
'src', 'server', 'web', 'static', 'js', 'index', 'socket-handler.js'
)
with open(handler_path, 'r') as f:
content = f.read()
assert 'SERIES_DELETED' in content
assert 'removeSeries' in content

View File

@@ -375,6 +375,83 @@ describe('AnimeSettingsManager', () => {
});
});
// -------------------------------------------------------------------
// viewNfoContent()
// -------------------------------------------------------------------
describe('viewNfoContent()', () => {
beforeEach(async () => {
// Seed currentKey via loadSeries so viewNfoContent has a key.
delete window.location;
window.location = { search: '?key=a', href: 'http://x/?key=a' };
mockFetchSequence([{
status: 200,
body: {
key: 'a', name: 'A', folder: 'A', site: 's',
tmdb_id: null, tvdb_id: null, has_nfo: true,
nfo_path: '/anime/A/tvshow.nfo', episode_count: 0,
missing_episode_count: 0, loading_status: 'completed',
},
}]);
await manager.loadSeries('a');
});
it('fetches /api/nfo/{key}/content with auth header', async () => {
mockFetchSequence([{
status: 200,
body: {
key: 'a',
folder: 'A',
content: '<tvshow><title>A</title></tvshow>',
file_size: 30,
last_modified: '2026-06-01T00:00:00',
},
}]);
await manager.viewNfoContent();
const [url, opts] = global.fetch.mock.calls[0];
expect(url).toBe('/api/nfo/a/content');
expect(opts.method).toBe('GET');
expect(opts.headers.Authorization).toBe('Bearer fake-jwt-token');
});
it('writes the content into the #nfo-content <pre> and unhides it',
async () => {
mockFetchSequence([{
status: 200,
body: {
key: 'a',
folder: 'A',
content: '<tvshow><title>A</title></tvshow>',
file_size: 30,
last_modified: '2026-06-01T00:00:00',
},
}]);
const pre = document.getElementById('nfo-content');
expect(pre.classList.contains('hidden')).toBe(true);
await manager.viewNfoContent();
expect(pre.textContent).toBe(
'<tvshow><title>A</title></tvshow>'
);
expect(pre.classList.contains('hidden')).toBe(false);
});
it('shows an error toast when the backend returns 404', async () => {
mockFetchSequence([{
status: 404,
ok: false,
body: { detail: 'Not Found' },
}]);
await manager.viewNfoContent();
expect(global.AniWorld.UI.showToast).toHaveBeenCalledWith(
expect.stringContaining('NFO'),
'error'
);
});
});
// -------------------------------------------------------------------
// validateField()
// -------------------------------------------------------------------
@@ -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');

View File

@@ -0,0 +1,190 @@
"""
Security tests for delete anime feature.
Tests path traversal protection, confirm_text validation, and
other security controls around the delete anime feature.
"""
import os
import pytest
class TestDeleteAnimeSecurity:
"""Security tests for the delete anime feature."""
@pytest.fixture
def anime_service_code(self):
"""Read the anime_service.py source code for security checks."""
path = os.path.join(
os.path.dirname(__file__), '..', '..',
'src', 'server', 'services', 'anime_service.py'
)
with open(path, 'r') as f:
return f.read()
@pytest.fixture
def delete_modal_code(self):
"""Read the delete-modal.js source code for security checks."""
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_delete_series_uses_is_safe_path(self, anime_service_code):
"""delete_series uses is_safe_path before deleting folders."""
assert 'is_safe_path' in anime_service_code
assert 'folder_path' in anime_service_code
def test_delete_series_checks_anime_base_directory(self, anime_service_code):
"""delete_series validates paths against the anime base directory."""
# Should reference the anime directory for path comparison
assert 'anime_base_dir' in anime_service_code or 'directory_to_search' in anime_service_code
def test_delete_series_no_hardcoded_paths(self, anime_service_code):
"""delete_series has no hardcoded dangerous paths."""
dangerous = ['/etc/passwd', '/root/.ssh', 'C:\\Windows\\System32']
for path in dangerous:
assert path not in anime_service_code
def test_delete_modal_encodes_key_in_url(self, delete_modal_code):
"""delete-modal.js encodes the series key in the API URL."""
# Should use encodeURIComponent or similar for the key
assert 'encodeURIComponent' in delete_modal_code
def test_delete_modal_no_inner_html_with_user_data(self, delete_modal_code):
"""delete-modal.js does not use innerHTML with unsanitized user data."""
# innerHTML should not be used with direct variable interpolation
# that could allow XSS
lines = delete_modal_code.split('\n')
dangerous_lines = [
line for line in lines
if 'innerHTML' in line and 'currentSeriesName' in line
and 'escapeHtml' not in line
]
assert len(dangerous_lines) == 0, \
"innerHTML used with currentSeriesName without escapeHtml"
def test_delete_modal_uses_textContent_for_user_visible_text(self, delete_modal_code):
"""User-visible text in modal uses safe DOM methods."""
# Should use textContent or similar instead of innerHTML for data
# This is implicit in using template literals with ${} - but check no obvious XSS
assert '<script>' not in delete_modal_code.lower()
assert 'onclick=' not in delete_modal_code.lower()
def test_delete_modal_confirm_text_not_used_in_url(self, delete_modal_code):
"""confirm_text is only used in JSON body, never in URLs."""
lines = delete_modal_code.split('\n')
for line in lines:
if 'confirm_text' in line:
# confirm_text should only appear in JSON body serialization
assert 'URL' not in line and 'url' not in line or 'body' in line
def test_delete_modal_has_error_display_element(self, delete_modal_code):
"""Modal has a dedicated error display element (not using alert())."""
assert 'delete-error' in delete_modal_code
assert 'showToast' in delete_modal_code # Uses toast, not alert()
def test_delete_endpoint_validates_confirm_text_exactly(self):
"""The API validates confirm_text is exactly 'delete'."""
# This is enforced in the endpoint code - check the endpoint exists
from src.server.api.anime import router
routes = [r for r in router.routes]
assert len(routes) > 0 # Router has routes
def test_delete_modal_handles_401_without_data_leak(self, delete_modal_code):
"""401 response triggers logout redirect, no data exposure."""
assert 'removeToken' in delete_modal_code
assert "window.location.href = '/login'" in delete_modal_code
def test_delete_modal_no_credentials_in_url(self, delete_modal_code):
"""No credentials or tokens appear in the API URL."""
lines = delete_modal_code.split('\n')
for line in lines:
if 'api/' in line.lower():
# URL should not contain token/password
assert 'token=' not in line.lower()
assert 'password=' not in line.lower()
def test_delete_result_message_is_user_facing_only(self, delete_modal_code):
"""Success/error messages shown to user do not expose internal paths."""
# The toast should show only the message from the API, not raw folder paths
assert 'showToast' in delete_modal_code
def test_delete_confirm_text_min_length_enforced(self):
"""confirm_text must be exactly 'delete' — enforced at API endpoint level, not model.
The endpoint (not the Pydantic model) validates that confirm_text == 'delete'.
The model itself accepts any string; validation is done in anime.py.
"""
from src.server.models.anime import DeleteSeriesRequest
# Model accepts any string — validation is in the API endpoint
# where confirm_text is checked against the literal 'delete'
assert DeleteSeriesRequest(
delete_database=True,
delete_folder=False,
confirm_text="del" # Accepted by model
)
# The API endpoint will reject this
def test_delete_confirm_text_max_length_reasonable(self):
"""confirm_text has a reasonable max length to prevent DoS."""
from src.server.models.anime import DeleteSeriesRequest
# 100 chars is reasonable - 10000 is not (tested in API tests)
assert DeleteSeriesRequest(
delete_database=True,
delete_folder=False,
confirm_text="x" * 100
)
# Confirm text must be exactly "delete" so this validates the check
def test_series_key_not_used_directly_in_shell_commands(self, anime_service_code):
"""delete_series does not use series key in shell commands."""
dangerous_patterns = ['os.system', 'subprocess.call', 'subprocess.run',
'subprocess.Popen', 'eval(', 'exec(']
for pattern in dangerous_patterns:
assert pattern not in anime_service_code, \
f"Potentially dangerous pattern '{pattern}' found"
def test_delete_modal_loads_before_context_menu_handler(self):
"""delete-modal.js is loaded before app-init.js in the template."""
import os
path = os.path.join(
os.path.dirname(__file__), '..', '..',
'src', 'server', 'web', 'templates', 'index.html'
)
with open(path, 'r') as f:
content = f.read()
delete_pos = content.find('delete-modal.js')
app_init_pos = content.find('app-init.js')
assert delete_pos != -1, "delete-modal.js not found in template"
assert app_init_pos != -1, "app-init.js not found in template"
assert delete_pos < app_init_pos, \
"delete-modal.js must be loaded before app-init.js"
def test_delete_modal_init_in_app_init(self, delete_modal_code):
"""delete-modal.js is initialized in app-init.js."""
import os
path = os.path.join(
os.path.dirname(__file__), '..', '..',
'src', 'server', 'web', 'static', 'js', 'index', 'app-init.js'
)
with open(path, 'r') as f:
content = f.read()
assert 'DeleteModal.init' in content
def test_context_menu_has_delete_action(self):
"""context-menu.js includes the delete-anime action."""
import os
path = os.path.join(
os.path.dirname(__file__), '..', '..',
'src', 'server', 'web', 'static', 'js', 'index', 'context-menu.js'
)
with open(path, 'r') as f:
content = f.read()
assert 'delete-anime' in content
assert 'DeleteModal.show' in content

View File

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

View File

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

View File

@@ -308,6 +308,185 @@ class TestAnimeSeries:
assert len(with_tmdb) == 2
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."""

View File

@@ -0,0 +1,875 @@
"""Unit tests for AnimeService.delete_series()."""
from __future__ import annotations
import os
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src.server.services.anime_service import AnimeService, AnimeServiceError
from src.server.services.progress_service import ProgressService
@pytest.fixture
def mock_series_app(tmp_path):
"""Create a mock SeriesApp instance."""
mock_instance = MagicMock()
mock_instance.directory_to_search = str(tmp_path)
mock_instance.series_list = []
mock_instance.search = AsyncMock(return_value=[])
mock_instance.rescan = AsyncMock()
mock_instance.download = AsyncMock(return_value=True)
mock_instance.download_status = None
mock_instance.scan_status = None
return mock_instance
@pytest.fixture
def mock_progress_service():
"""Create a mock ProgressService instance."""
service = MagicMock(spec=ProgressService)
service.start_progress = AsyncMock()
service.update_progress = AsyncMock()
service.complete_progress = AsyncMock()
service.fail_progress = AsyncMock()
return service
@pytest.fixture
def anime_service(tmp_path, mock_series_app, mock_progress_service):
"""Create an AnimeService instance for testing."""
return AnimeService(
series_app=mock_series_app,
progress_service=mock_progress_service,
)
# ---------------------------------------------------------------------------
# Shared DB mock helpers — used inside each test's patch context
# ---------------------------------------------------------------------------
def _make_db_ctx(mock_session: AsyncMock) -> AsyncMock:
"""Build a mock async DB context manager."""
mock_ctx = AsyncMock()
mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
mock_ctx.__aexit__ = AsyncMock(return_value=None)
return mock_ctx
# ---------------------------------------------------------------------------
# Tests — patch lives INSIDE each test method, not in a fixture
# ---------------------------------------------------------------------------
class TestDeleteSeriesService:
"""Test AnimeService.delete_series()."""
# ------------------------------------------------------------------
# delete_database=True, delete_folder=False
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_delete_db_only_success(self, anime_service):
"""delete_series with delete_database=True removes series from DB only."""
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 = 42
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="test-key",
delete_database=True,
delete_folder=False,
)
assert result.success is True
assert result.key == "test-key"
assert result.deleted_from_database is True
assert result.deleted_folder is False
assert result.database_error is None
@pytest.mark.asyncio
async def test_delete_db_only_calls_delete_on_series(self, anime_service):
"""DB delete calls AnimeSeriesService.delete(session, series_id)."""
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 = 99
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,
) as mock_delete:
await anime_service.delete_series(
key="test-key",
delete_database=True,
delete_folder=False,
)
mock_delete.assert_called_once()
call_args = mock_delete.call_args
# First positional arg should be the session
assert call_args[0][0] is mock_session
# Second positional arg should be the series id
assert call_args[0][1] == 99
# ------------------------------------------------------------------
# delete_database=False, delete_folder=True
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_delete_folder_only_success(self, anime_service, tmp_path):
"""delete_series with delete_folder=True deletes folder without touching DB."""
series_folder = tmp_path / "Test Series (2023)"
series_folder.mkdir()
(series_folder / "episode1.mkv").write_text("fake video")
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 = str(series_folder)
mock_series.id = 1
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="test-key",
delete_database=False,
delete_folder=True,
)
assert result.success is True
assert result.deleted_from_database is False
assert result.deleted_folder is True
assert not series_folder.exists() # Folder actually deleted
@pytest.mark.asyncio
async def test_delete_folder_only_no_db_delete_called(self, anime_service, tmp_path):
"""Folder-only delete never calls AnimeSeriesService.delete()."""
series_folder = tmp_path / "Another Series"
series_folder.mkdir()
mock_session = AsyncMock()
mock_ctx = _make_db_ctx(mock_session)
mock_series = MagicMock()
mock_series.key = "test-key"
mock_series.name = "Another Series"
mock_series.folder = str(series_folder)
mock_series.id = 1
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,
) as mock_delete:
await anime_service.delete_series(
key="test-key",
delete_database=False,
delete_folder=True,
)
mock_delete.assert_not_called()
# ------------------------------------------------------------------
# delete_database=True, delete_folder=True
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_delete_both_success(self, anime_service, tmp_path):
"""delete_series with both flags removes from DB and deletes folder."""
series_folder = tmp_path / "Test Series (2023)"
series_folder.mkdir()
(series_folder / "episode1.mkv").write_text("fake video")
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 = str(series_folder)
mock_series.id = 42
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="test-key",
delete_database=True,
delete_folder=True,
)
assert result.success is True
assert result.deleted_from_database is True
assert result.deleted_folder is True
assert result.database_error is None
assert result.folder_error is None
assert not series_folder.exists()
# ------------------------------------------------------------------
# Series not found
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_delete_series_not_found(self, anime_service):
"""delete_series returns failure when series doesn't exist."""
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=True,
delete_folder=False,
)
assert result.success is False
assert result.deleted_from_database is False
assert "not found" in result.message.lower()
# ------------------------------------------------------------------
# Path traversal protection
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_delete_series_path_traversal_blocked(self, anime_service, tmp_path):
"""delete_series refuses to delete folder outside anime base directory."""
# Create a safe folder at tmp_path, use parent as dangerous target
safe_base = tmp_path / "anime_root"
safe_base.mkdir()
dangerous_target = tmp_path.parent # parent of anime root
anime_service._directory = str(safe_base)
mock_session = AsyncMock()
mock_ctx = _make_db_ctx(mock_session)
mock_series = MagicMock()
mock_series.key = "test-key"
mock_series.name = "Test"
mock_series.folder = str(dangerous_target) # outside safe_base
mock_series.id = 1
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="test-key",
delete_database=False,
delete_folder=True,
)
# Folder should NOT be deleted
assert result.deleted_folder is False
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
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_delete_series_folder_delete_error(self, anime_service, tmp_path):
"""delete_series handles folder deletion errors gracefully."""
series_folder = tmp_path / "Test Series"
series_folder.mkdir()
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 = str(series_folder)
mock_series.id = 1
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(
"shutil.rmtree",
side_effect=OSError("Permission denied"),
):
result = await anime_service.delete_series(
key="test-key",
delete_database=False,
delete_folder=True,
)
assert result.deleted_folder is False
assert result.folder_error is not None
@pytest.mark.asyncio
async def test_delete_series_db_error_still_reports_folder(
self, anime_service, tmp_path
):
"""Even if DB delete fails, folder deletion result is still reported."""
series_folder = tmp_path / "Test Series"
series_folder.mkdir()
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 = str(series_folder)
mock_series.id = 1
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=Exception("DB connection lost"),
):
result = await anime_service.delete_series(
key="test-key",
delete_database=True,
delete_folder=True,
)
# DB failed but folder still deleted
assert result.deleted_from_database is False
assert result.database_error is not None
assert result.deleted_folder is True
assert not series_folder.exists()
# ------------------------------------------------------------------
# Edge cases
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_delete_series_folder_none_when_no_folder(
self, anime_service
):
"""delete_series with folder=True but no folder path skips folder delete."""
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 = None
mock_series.id = 1
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="test-key",
delete_database=True,
delete_folder=True,
)
assert result.success is True
assert result.deleted_from_database is True
assert result.deleted_folder is False
assert result.folder_error is None # No folder → no error
@pytest.mark.asyncio
async def test_delete_series_neither_flag_set(self, anime_service):
"""delete_series returns failure when neither flag is True.
Needs DB patches so get_db_session doesn't raise RuntimeError,
even though this test hits the early-return guard before any DB use.
"""
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="any-key",
delete_database=False,
delete_folder=False,
)
assert result.success is False
assert "at least one" in result.message.lower()
# ------------------------------------------------------------------
# WebSocket broadcast
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_delete_series_websocket_broadcast(self, anime_service):
"""delete_series broadcasts series_deleted WebSocket event."""
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 = 42
anime_service._websocket_service = MagicMock()
anime_service._websocket_service.broadcast_series_deleted = 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_deleted.assert_called_once_with(
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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

3
uv.lock generated Normal file
View File

@@ -0,0 +1,3 @@
version = 1
revision = 3
requires-python = ">=3.12"