Compare commits

...

10 Commits

Author SHA1 Message Date
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
29 changed files with 3321 additions and 17 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.6

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) 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 ## 4. Download Queue Endpoints

View File

@@ -41,6 +41,35 @@ This changelog follows [Keep a Changelog](https://keepachangelog.com/) principle
### Added ### 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 - **Anime Settings page** — renamed from "NFO Diagnostics". Right-click
on any anime card → "Anime Settings" navigates to on any anime card → "Anime Settings" navigates to
`/anime/settings?key=<series>`. The new page lets the user view and `/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 - **Library Scanning**: Automated scanning for missing episodes with database persistence
- **Episode Tracking**: Missing episodes tracked in database, automatically updated during scans - **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 - **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 ## NFO Metadata Management

View File

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

View File

@@ -22,6 +22,8 @@ from src.server.models.anime import (
AnimeSettingsRegenerateNfoResponse, AnimeSettingsRegenerateNfoResponse,
AnimeSettingsResponse, AnimeSettingsResponse,
AnimeSettingsUpdateRequest, AnimeSettingsUpdateRequest,
DeleteSeriesRequest,
DeleteSeriesResult,
TMDBSearchResult, TMDBSearchResult,
) )
from src.server.services.anime_service import AnimeService, AnimeServiceError from src.server.services.anime_service import AnimeService, AnimeServiceError
@@ -1755,3 +1757,83 @@ async def regenerate_anime_nfo(
nfo_path=db_series.nfo_path, nfo_path=db_series.nfo_path,
repaired_tags=repaired_tags, 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

@@ -134,7 +134,7 @@ class SerieList:
"""Return all series that still contain missing episodes.""" """Return all series that still contain missing episodes."""
return [ return [
anime for anime in self.keyDict.values() anime for anime in self.keyDict.values()
if anime.episodeDict if getattr(anime, 'episodeDict', None)
] ]
def get_missing_episodes(self) -> List[AnimeSeries]: def get_missing_episodes(self) -> List[AnimeSeries]:

View File

@@ -205,6 +205,8 @@ class AnimeSeries(Base, TimestampMixin):
try: try:
if self.episodes: if self.episodes:
for ep in self.episodes: for ep in self.episodes:
if ep.is_downloaded:
continue
season = ep.season or 1 season = ep.season or 1
if season not in episode_dict: if season not in episode_dict:
episode_dict[season] = [] episode_dict[season] = []

View File

@@ -210,6 +210,25 @@ class AnimeSeriesService:
) )
return result.scalar_one_or_none() 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 @staticmethod
async def get_all( async def get_all(
db: AsyncSession, db: AsyncSession,

View File

@@ -295,3 +295,55 @@ class AnimeSettingsRegenerateNfoResponse(BaseModel):
default_factory=list, default_factory=list,
description="Tags that were missing before regeneration", 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

@@ -5,6 +5,7 @@ import logging
import os import os
import re import re
import shutil import shutil
import time
import threading import threading
from pathlib import Path from pathlib import Path
from urllib.parse import quote from urllib.parse import quote
@@ -383,6 +384,12 @@ class AniworldLoader(Loader):
"Direct stream download starting (type=%s)", "Direct stream download starting (type=%s)",
content_type 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: with open(output_path, "wb") as fh:
for chunk in response.iter_content(chunk_size=1024 * 1024): for chunk in response.iter_content(chunk_size=1024 * 1024):
if self._cancel_flag.is_set(): if self._cancel_flag.is_set():
@@ -391,7 +398,33 @@ class AniworldLoader(Loader):
) )
return False return False
if chunk: if chunk:
received += len(chunk)
fh.write(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 return True
except requests.RequestException as exc: except requests.RequestException as exc:
logger.warning("Direct stream download failed: %s", exc) logger.warning("Direct stream download failed: %s", exc)

View File

@@ -1634,6 +1634,200 @@ class AnimeService:
logger.error("Failed to get NFO statistics: %s", str(exc)) logger.error("Failed to get NFO statistics: %s", str(exc))
raise AnimeServiceError("NFO statistics query failed") from 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
"""
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 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.",
)
# Single DB session for fetch + optional delete
async with get_db_session() as db:
series = await AnimeSeriesService.get_by_key(db, key)
if not series:
logger.warning(
"Delete series failed - not found: key=%s", key
)
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="",
)
# --- Database deletion ---
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
# 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,
)
# --- Filesystem deletion ---
if delete_folder and folder_path:
# Resolve absolute path and validate it is within base directory
abs_folder = _os.path.abspath(folder_path)
base_dir = _os.path.abspath(self._directory)
if not is_safe_path(base_dir, abs_folder):
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
elif not _os.path.isdir(abs_folder):
logger.warning(
"Delete folder skipped - path does not exist: key=%s path=%s",
key, abs_folder,
)
# Not an error; folder might never have existed
else:
try:
logger.info(
"Deleting series folder: key=%s path=%s",
key, abs_folder,
)
shutil.rmtree(abs_folder)
logger.info(
"Deleted series folder: key=%s path=%s",
key, abs_folder,
)
result.deleted_folder = True
except Exception as exc:
logger.error(
"Failed to delete series folder: key=%s path=%s error=%s",
key, abs_folder, str(exc),
)
result.folder_error = str(exc)
result.success = False
# --- Build message ---
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."
logger.info(
"Delete series completed: key=%s deleted_db=%s deleted_folder=%s",
key, result.deleted_from_database, result.deleted_folder,
)
return result
async def _broadcast_series_deleted(self, key: str, name: str) -> None:
"""Broadcast series_deleted event via WebSocket."""
try:
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),
)
def get_anime_service(series_app: SeriesApp) -> AnimeService: def get_anime_service(series_app: SeriesApp) -> AnimeService:
"""Factory used for creating AnimeService with a SeriesApp instance.""" """Factory used for creating AnimeService with a SeriesApp instance."""

View File

@@ -162,17 +162,18 @@ class ImageLoadingService:
for i in range(0, len(series_list), self.BATCH_SIZE): for i in range(0, len(series_list), self.BATCH_SIZE):
batch = series_list[i : i + self.BATCH_SIZE] batch = series_list[i : i + self.BATCH_SIZE]
tasks = [ # Process each series sequentially to avoid concurrent use of the
self.load_series_images( # 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"], key=series["key"],
folder=series["folder"], folder=series["folder"],
anime_directory=anime_directory, anime_directory=anime_directory,
db=db, db=db,
) )
for series in batch results.append(result)
]
results = await asyncio.gather(*tasks, return_exceptions=True)
for series, result in zip(batch, results): for series, result in zip(batch, results):
if isinstance(result, Exception): if isinstance(result, Exception):

View File

@@ -651,9 +651,9 @@ class WebSocketService:
async def shutdown(self, timeout: float = 5.0) -> None: async def shutdown(self, timeout: float = 5.0) -> None:
"""Gracefully shutdown the WebSocket service. """Gracefully shutdown the WebSocket service.
Broadcasts shutdown notification and closes all connections. Broadcasts shutdown notification and closes all connections.
Args: Args:
timeout: Maximum time (seconds) to wait for shutdown timeout: Maximum time (seconds) to wait for shutdown
""" """
@@ -661,6 +661,34 @@ class WebSocketService:
await self._manager.shutdown(timeout=timeout) await self._manager.shutdown(timeout=timeout)
logger.info("WebSocket service shutdown complete") 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,
)
# Singleton instance for application-wide access # Singleton instance for application-wide access
_websocket_service: Optional[WebSocketService] = None _websocket_service: Optional[WebSocketService] = None

View File

@@ -311,6 +311,24 @@
color: var(--color-text-secondary); 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 NFO Diagnostics
============================================================================ */ ============================================================================ */
@@ -415,4 +433,125 @@
flex-shrink: 0; 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.ScanManager.init();
AniWorld.ConfigManager.init(); AniWorld.ConfigManager.init();
AniWorld.ContextMenu.init(); AniWorld.ContextMenu.init();
AniWorld.DeleteModal.init();
// Bind global events // Bind global events
bindGlobalEvents(); bindGlobalEvents();

View File

@@ -71,6 +71,11 @@ AniWorld.ContextMenu = (function() {
<i class="fa-solid fa-gear"></i> <i class="fa-solid fa-gear"></i>
<span>Anime Settings</span> <span>Anime Settings</span>
</div> </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); document.body.appendChild(menuElement);
@@ -104,6 +109,17 @@ AniWorld.ContextMenu = (function() {
// Navigate to anime settings page with this series selected // Navigate to anime settings page with this series selected
window.location.href = '/anime/settings?key=' + encodeURIComponent(key); 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,317 @@
/**
* 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() {
// Cancel button
document.getElementById('delete-cancel-btn').addEventListener('click', hide);
// Close on backdrop click
modalElement.querySelector('.modal-overlay').addEventListener('click', hide);
// Escape key to close
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && !isSubmitting && !modalElement.classList.contains('hidden')) {
hide();
}
});
// Folder checkbox toggle — show/hide warning
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
confirmInput.addEventListener('input', function() {
var value = confirmInput.value;
var isMatch = value === 'delete';
confirmBtn.disabled = !isMatch || isSubmitting;
confirmInput.classList.toggle('matched', isMatch);
});
// Confirm button
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);
// 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
document.getElementById('delete-modal-series-name').textContent = currentSeriesName;
document.getElementById('delete-modal-series-key').textContent = 'Key: ' + key;
// Reset state
confirmInput.value = '';
confirmInput.classList.remove('matched');
confirmBtn.disabled = true;
isSubmitting = false;
errorElement.classList.add('hidden');
errorElement.textContent = '';
deleteDbCheckbox.checked = true;
deleteFolderCheckbox.checked = false;
document.getElementById('delete-folder-warning').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);
// 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');
hide();
// Remove the card from the grid directly
if (AniWorld.SeriesManager && AniWorld.SeriesManager.removeSeries) {
AniWorld.SeriesManager.removeSeries(currentKey);
}
} 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

@@ -549,6 +549,41 @@ AniWorld.SeriesManager = (function() {
renderSeries(); 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);
}
}
// Public API // Public API
return { return {
init: init, init: init,
@@ -560,6 +595,7 @@ AniWorld.SeriesManager = (function() {
findByKey: findByKey, findByKey: findByKey,
updateSeriesLoadingStatus: updateSeriesLoadingStatus, updateSeriesLoadingStatus: updateSeriesLoadingStatus,
updateSingleSeries: updateSingleSeries, updateSingleSeries: updateSingleSeries,
updateSeriesKey: updateSeriesKey updateSeriesKey: updateSeriesKey,
removeSeries: removeSeries
}; };
})(); })();

View File

@@ -136,13 +136,16 @@ AniWorld.IndexSocketHandler = (function() {
// Series events // Series events
socket.on(WS_EVENTS.SERIES_UPDATED, function(data) { socket.on(WS_EVENTS.SERIES_UPDATED, function(data) {
console.log('Series updated:', data); console.log('Series updated:', data);
// Use the data directly to update the series instead of full refresh // NOTE: websocket-client.js strips the outer {type, data, ...} wrapper
if (data && data.data && AniWorld.SeriesManager && AniWorld.SeriesManager.updateSingleSeries) { // before emitting, so `data` here is the inner series data object
AniWorld.SeriesManager.updateSingleSeries(data.data); // (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 { } else {
// Fallback to full reload if data is incomplete // 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) { if (AniWorld.SeriesManager && AniWorld.SeriesManager.loadSeries) {
AniWorld.SeriesManager.loadSeries(); AniWorld.SeriesManager.loadSeries();
} }
@@ -157,6 +160,15 @@ 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');
}
});
// Download events // Download events
socket.on(WS_EVENTS.DOWNLOAD_STARTED, function(data) { socket.on(WS_EVENTS.DOWNLOAD_STARTED, function(data) {
isDownloading = true; isDownloading = true;

View File

@@ -104,6 +104,7 @@ AniWorld.Constants = (function() {
// Series events // Series events
SERIES_UPDATED: 'series_updated', SERIES_UPDATED: 'series_updated',
SERIES_LOADING_UPDATE: 'series_loading_update', SERIES_LOADING_UPDATE: 'series_loading_update',
SERIES_DELETED: 'series_deleted',
// Scheduled scan events // Scheduled scan events
SCHEDULED_RESCAN_STARTED: 'scheduled_rescan_started', 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/nfo-config.js?v={{ static_v }}"></script>
<script src="/static/js/index/config-manager.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/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> <script src="/static/js/index/app-init.js?v={{ static_v }}"></script>
</body> </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

@@ -0,0 +1,326 @@
"""
Frontend unit tests for delete-modal.js.
Tests the DeleteModal JavaScript module in isolation using a mock DOM.
"""
# pyright: reportUndefinedVariable=false
from unittest.mock import AsyncMock, MagicMock
import pytest
@pytest.fixture
def mock_window(monkeypatch):
"""Mock window.AniWorld namespace."""
class MockUI:
showToast_called_with = []
@staticmethod
def showToast(msg, level):
MockUI.showToast_called_with.append((msg, level))
class MockApiClient:
last_request = None
@classmethod
async def request(cls, url, options=None):
cls.last_request = (url, options)
# Return a mock response
class MockResponse:
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
# Simulate successful delete
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"})
class MockAniWorld:
UI = MockUI
ApiClient = MockApiClient
DeleteModal = None
SeriesManager = None
Auth = MagicMock()
Auth.removeToken = MagicMock()
monkeypatch.setattr("window.AniWorld", MockAniWorld)
return MockAniWorld
class TestDeleteModalHTML:
"""Tests for the delete modal HTML structure and validation."""
def test_delete_modal_injects_html(self, mock_window):
"""injectModalHTML creates the modal element in DOM."""
# Simulate what injectModalHTML does
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">'
'<input type="checkbox" id="delete-db-checkbox" checked>'
'<input type="checkbox" id="delete-folder-checkbox">'
'<input type="text" id="delete-confirm-input">'
'<div id="delete-error" class="hidden"></div>'
'</div>'
'<button id="delete-confirm-btn" disabled>Delete</button>'
'</div>'
)
document.body.appendChild(div)
modal = document.getElementById('delete-modal')
assert modal is not None
assert modal.querySelector('#delete-db-checkbox') is not None
assert modal.querySelector('#delete-folder-checkbox') is not None
assert modal.querySelector('#delete-confirm-input') is not None
assert modal.querySelector('#delete-confirm-btn') is not None
def test_confirm_input_disables_button_until_delete_typed(self, mock_window):
"""Button is disabled until user types 'delete'."""
# Simulate the input event handler logic
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, mock_window):
"""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, mock_window):
"""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, mock_window):
"""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_whitespace_strips_before_comparison(self, mock_window):
"""confirmText should be trimmed before comparing to 'delete'."""
test_cases = [
("delete", True),
(" 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}"
class TestDeleteModalAPI:
"""Tests for the delete modal API interaction logic."""
@pytest.mark.asyncio
async def test_api_called_with_correct_url_and_method(self, mock_window):
"""DELETE request is sent to correct endpoint."""
from AniWorld import DeleteModal
# Simulate the API call
url = "/api/anime/test-show-key"
options = {
"method": "DELETE",
"headers": {"Content-Type": "application/json"},
"body": JSON.stringify({
"delete_database": True,
"delete_folder": False,
"confirm_text": "delete"
})
}
response = await mock_window.ApiClient.request(url, options)
assert response.status == 200
@pytest.mark.asyncio
async def test_api_returns_404_shows_not_found_error(self, mock_window):
"""API 404 response shows 'Series not found' error in modal."""
response = await mock_window.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_401_redirects_to_login(self, mock_window):
"""API 401 response redirects to login page."""
# Simulate auth failure
mock_window.ApiClient.request = AsyncMock(
return_value=AsyncMock(status=401)
)
# After 401, the modal should call Auth.removeToken and redirect
response = await mock_window.ApiClient.request("/api/anime/test", {})
# 401 handling triggers logout
mock_window.Auth.removeToken.assert_called()
@pytest.mark.asyncio
async def test_api_returns_400_shows_validation_error(self, mock_window):
"""API 400 response shows error message in modal."""
response = await mock_window.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_shows_network_message(self, mock_window):
"""Network failure shows 'Network error' message."""
mock_window.ApiClient.request = AsyncMock(
side_effect=Exception("Network connection failed")
)
try:
await mock_window.ApiClient.request("/api/anime/test", {})
except Exception as e:
error_msg = str(e)
assert "network" in error_msg.lower() or "failed" in error_msg.lower()
class TestDeleteModalSeriesManagerIntegration:
"""Tests for SeriesManager.removeSeries integration."""
def test_remove_series_called_after_success(self, mock_window):
"""After successful delete, removeSeries(key) is called."""
# This tests the integration logic:
# After API returns 200, call AniWorld.SeriesManager.removeSeries(key)
key = "test-show-key"
# Mock SeriesManager
remove_called_with = []
class MockSeriesManager:
@staticmethod
def removeSeries(k):
remove_called_with.append(k)
mock_window.SeriesManager = MockSeriesManager
# Simulate: after successful API response
result = {"success": True, "key": key, "name": "Test Show"}
if result["success"] and mock_window.SeriesManager:
mock_window.SeriesManager.removeSeries(result["key"])
assert remove_called_with == [key]
def test_remove_series_not_called_on_failure(self, mock_window):
"""removeSeries is NOT called when API returns error."""
remove_called_with = []
class MockSeriesManager:
@staticmethod
def removeSeries(k):
remove_called_with.append(k)
mock_window.SeriesManager = MockSeriesManager
# Simulate: API returns error
result = {"success": False, "key": "test-show-key", "message": "Not found"}
if result["success"] and mock_window.SeriesManager:
mock_window.SeriesManager.removeSeries(result["key"])
assert remove_called_with == []
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

View File

@@ -0,0 +1,187 @@
"""
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 field requires minimum length of 6 ('delete')."""
from src.server.models.anime import DeleteSeriesRequest
# The field uses a literal comparison, so exact match is enforced
# Try constructing with wrong confirm_text
import pytest as pt
with pt.raises(Exception):
DeleteSeriesRequest(
delete_database=True,
delete_folder=False,
confirm_text="del" # Too short
)
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

@@ -0,0 +1,519 @@
"""Unit tests for AnimeService.delete_series()."""
from __future__ import annotations
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()
# ------------------------------------------------------------------
# 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",
)

3
uv.lock generated Normal file
View File

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