Compare commits

...

12 Commits

Author SHA1 Message Date
7df9a84ae9 chore: bump version 2026-08-28 20:45:18 +02:00
4162684779 fix: harden delete-modal against missing DOM elements
Add null guards and element re-caching in delete-modal.js so the modal
recovers gracefully if its DOM is replaced (e.g. by an HTMX swap) between
init and show().

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

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

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

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

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

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

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

Fix: check data.key directly and pass data (not data.data) to
updateSingleSeries().
2026-07-31 08:37:44 +02:00
29 changed files with 3376 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.7

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1634,6 +1634,200 @@ class AnimeService:
logger.error("Failed to get NFO statistics: %s", str(exc))
raise AnimeServiceError("NFO statistics query failed") from exc
async def delete_series(
self,
key: str,
delete_database: bool = True,
delete_folder: bool = False,
) -> "DeleteSeriesResult":
"""Delete an anime series from database, filesystem, or both.
Args:
key: Series key (primary identifier)
delete_database: If True, remove from database (default True)
delete_folder: If True, remove folder from filesystem (default False)
Returns:
DeleteSeriesResult with success status, what was deleted, errors
"""
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:
"""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):
batch = series_list[i : i + self.BATCH_SIZE]
tasks = [
self.load_series_images(
# Process each series sequentially to avoid concurrent use of the
# same AsyncSession (SQLAlchemy async sessions are not thread-safe
# for concurrent operations). BATCH_SIZE still paces TMDB requests.
results: List[Dict[str, Any] | Exception] = []
for series in batch:
result = await self.load_series_images(
key=series["key"],
folder=series["folder"],
anime_directory=anime_directory,
db=db,
)
for series in batch
]
results = await asyncio.gather(*tasks, return_exceptions=True)
results.append(result)
for series, result in zip(batch, results):
if isinstance(result, Exception):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -104,6 +104,7 @@ AniWorld.Constants = (function() {
// Series events
SERIES_UPDATED: 'series_updated',
SERIES_LOADING_UPDATE: 'series_loading_update',
SERIES_DELETED: 'series_deleted',
// Scheduled scan events
SCHEDULED_RESCAN_STARTED: 'scheduled_rescan_started',

View File

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

View File

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

View File

@@ -0,0 +1,350 @@
"""
Frontend unit tests for delete-modal.js.
Tests the DeleteModal JavaScript module logic in isolation.
Since this is a browser-only module, we test the underlying logic
(validation, URL construction, response handling) as Python logic.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# Module-level mock classes (shared across tests)
class MockUI:
"""Mock UI module."""
showToast_called = []
@staticmethod
def showToast(msg, level):
MockUI.showToast_called.append((msg, level))
class MockResponse:
"""Simulates httpx AsyncClient response used by delete-modal.js."""
def __init__(self, status_code, json_data=None):
self._status = status_code
self._json = json_data
@property
def ok(self):
return 200 <= self._status < 300
@property
def status(self):
return self._status
async def json(self):
return self._json
class MockApiClient:
"""Mock ApiClient that simulates delete-modal.js API calls."""
last_request = None
@classmethod
async def request(cls, url, options=None):
cls.last_request = (url, options)
# Route based on key in URL
if "test-show-key" in url:
return MockResponse(200, {
"success": True,
"key": "test-show-key",
"name": "Test Show",
"deleted_from_database": True,
"deleted_folder": False,
"message": "Removed from database.",
})
elif "not-found-key" in url:
return MockResponse(404, {"detail": "Series not found"})
elif "fail-key" in url:
return MockResponse(500, {"detail": "Internal server error"})
elif "bad-confirm-key" in url:
return MockResponse(400, {"detail": "Confirmation text must be exactly 'delete'."})
return MockResponse(400, {"detail": "Unknown error"})
# Store original for reset
_original_api_request = MockApiClient.request
class MockAniWorld:
"""Mock AniWorld namespace used by delete-modal.js."""
UI = MockUI
ApiClient = MockApiClient
DeleteModal = None
SeriesManager = None
Auth = MagicMock()
Auth.removeToken = MagicMock()
@pytest.fixture(autouse=True)
def reset_mock_aniworld():
"""Reset mock state before each test to prevent pollution."""
MockUI.showToast_called = []
MockApiClient.last_request = None
# Restore both MockApiClient.request AND MockAniWorld.ApiClient.request
# (tests may set either one directly)
MockApiClient.request = _original_api_request
MockAniWorld.ApiClient = MockApiClient
MockAniWorld.SeriesManager = None
MockAniWorld.Auth = MagicMock()
MockAniWorld.Auth.removeToken = MagicMock()
yield
class TestDeleteModalValidation:
"""Tests for the confirm-text validation logic."""
def test_confirm_input_disables_button_until_delete_typed(self):
"""Button is disabled until user types 'delete'."""
confirm_input = {"value": "", "classList": {"toggle": MagicMock()}}
confirm_btn = {"disabled": False}
# Initially empty - button should be disabled
is_match = confirm_input["value"] == "delete"
confirm_btn["disabled"] = not is_match
assert confirm_btn["disabled"] is True
# User types 'del'
confirm_input["value"] = "del"
is_match = confirm_input["value"] == "delete"
confirm_btn["disabled"] = not is_match
assert confirm_btn["disabled"] is True
# User types 'delete'
confirm_input["value"] = "delete"
is_match = confirm_input["value"] == "delete"
confirm_btn["disabled"] = not is_match
assert confirm_btn["disabled"] is False
def test_confirm_input_matched_class_toggles(self):
"""Input gets 'matched' CSS class when value is 'delete'."""
matched_states = []
for value in ["", "del", "delete", "Delete", "delete "]:
is_match = value == "delete"
matched_states.append(is_match)
assert matched_states == [False, False, True, False, False]
def test_folder_checkbox_shows_warning_when_checked(self):
"""Folder warning appears when delete-folder checkbox is checked."""
warning_shown = []
for is_checked in [False, True, False]:
warning_shown.append(is_checked)
assert warning_shown[0] is False
assert warning_shown[1] is True
assert warning_shown[2] is False
def test_at_least_one_option_required_validation(self):
"""Modal should reject when neither checkbox is selected."""
delete_db = False
delete_folder = False
is_valid = delete_db or delete_folder
assert is_valid is False
delete_db = True
is_valid = delete_db or delete_folder
assert is_valid is True
def test_confirm_text_exact_match_required(self):
"""confirmText must be exactly 'delete' (case-sensitive)."""
test_cases = [
("delete", True),
("DELETE", False),
("Delete", False),
(" delete", False),
("delete ", False),
(" delete ", False),
("deletex", False),
("", False),
]
for text, expected in test_cases:
result = text == "delete"
assert result is expected, f"'{text}' should be {expected}"
def test_delete_api_url_construction(self):
"""DELETE request is sent to /api/anime/{key}."""
key = "test-show-key"
url = '/api/anime/' + key
assert url == "/api/anime/test-show-key"
assert "test-show-key" in url
def test_delete_api_body_construction(self):
"""API body contains all three required fields."""
delete_database = True
delete_folder = False
confirm_text = "delete"
body = {
"delete_database": delete_database,
"delete_folder": delete_folder,
"confirm_text": confirm_text
}
assert body["delete_database"] is True
assert body["delete_folder"] is False
assert body["confirm_text"] == "delete"
class TestDeleteModalAPI:
"""Tests for the delete modal API interaction logic."""
@pytest.mark.asyncio
async def test_api_called_with_correct_url_and_method(self):
"""DELETE request is sent to correct endpoint."""
url = "/api/anime/test-show-key"
options = {
"method": "DELETE",
"headers": {"Content-Type": "application/json"},
"body": '{"delete_database": true, "delete_folder": false, "confirm_text": "delete"}'
}
response = await MockAniWorld.ApiClient.request(url, options)
assert response.status == 200
@pytest.mark.asyncio
async def test_api_returns_404_shows_not_found_error(self):
"""API 404 response returns 'not found' detail."""
response = await MockAniWorld.ApiClient.request(
"/api/anime/not-found-key",
{"method": "DELETE", "body": "{}"}
)
assert response.status == 404
data = await response.json()
assert "not found" in data["detail"].lower()
@pytest.mark.asyncio
async def test_api_returns_400_shows_validation_error(self):
"""API 400 response contains validation error detail."""
response = await MockAniWorld.ApiClient.request(
"/api/anime/bad-confirm-key",
{"method": "DELETE"}
)
assert response.status == 400
data = await response.json()
assert "delete" in data["detail"].lower()
@pytest.mark.asyncio
async def test_api_network_error_raises_exception(self):
"""Network failure raises an exception."""
MockAniWorld.ApiClient.request = AsyncMock(
side_effect=Exception("Network connection failed")
)
with pytest.raises(Exception) as exc_info:
await MockAniWorld.ApiClient.request("/api/anime/test", {})
assert "network" in str(exc_info.value).lower() or "failed" in str(exc_info.value).lower()
@pytest.mark.asyncio
async def test_success_response_contains_deleted_fields(self):
"""Successful response includes deleted_from_database and deleted_folder."""
response = await MockAniWorld.ApiClient.request(
"/api/anime/test-show-key",
{"method": "DELETE"}
)
data = await response.json()
assert "success" in data
assert "deleted_from_database" in data
assert "deleted_folder" in data
class TestDeleteModalSeriesManagerIntegration:
"""Tests for SeriesManager.removeSeries integration."""
def test_remove_series_called_after_success(self):
"""After successful delete, removeSeries(key) is called."""
key = "test-show-key"
remove_called_with = []
class MockSeriesManager:
@staticmethod
def removeSeries(k):
remove_called_with.append(k)
MockAniWorld.SeriesManager = MockSeriesManager
# Simulate: after successful API response
result = {"success": True, "key": key, "name": "Test Show"}
if result["success"] and MockAniWorld.SeriesManager:
MockAniWorld.SeriesManager.removeSeries(result["key"])
assert remove_called_with == [key]
def test_remove_series_not_called_on_failure(self):
"""removeSeries is NOT called when API returns error."""
remove_called_with = []
class MockSeriesManager:
@staticmethod
def removeSeries(k):
remove_called_with.append(k)
MockAniWorld.SeriesManager = MockSeriesManager
# Simulate: API returns error
result = {"success": False, "key": "test-show-key", "message": "Not found"}
if result["success"] and MockAniWorld.SeriesManager:
MockAniWorld.SeriesManager.removeSeries(result["key"])
assert remove_called_with == []
def test_ws_event_broadcast_triggers_remove(self):
"""WebSocket series_deleted event triggers removeSeries."""
key = "ws-deleted-key"
remove_called_with = []
class MockSeriesManager:
@staticmethod
def removeSeries(k):
remove_called_with.append(k)
MockAniWorld.SeriesManager = MockSeriesManager
# Simulate WS event handler
def on_series_deleted(data):
if MockAniWorld.SeriesManager and MockAniWorld.SeriesManager.removeSeries:
MockAniWorld.SeriesManager.removeSeries(data["key"])
on_series_deleted({"key": key})
assert remove_called_with == [key]
class TestDeleteModalConstants:
"""Tests for SERIES_DELETED WebSocket event constant."""
def test_series_deleted_constant_referenced_in_constants_js(self):
"""WS_EVENTS.SERIES_DELETED constant exists in constants.js."""
import os
constants_path = os.path.join(
os.path.dirname(__file__),
'..', '..',
'src', 'server', 'web', 'static', 'js', 'shared', 'constants.js'
)
with open(constants_path, 'r') as f:
content = f.read()
assert 'SERIES_DELETED' in content
assert "SERIES_DELETED: 'series_deleted'" in content
def test_series_deleted_constant_referenced_in_socket_handler(self):
"""WS_EVENTS.SERIES_DELETED is handled in socket-handler.js."""
import os
handler_path = os.path.join(
os.path.dirname(__file__),
'..', '..',
'src', 'server', 'web', 'static', 'js', 'index', 'socket-handler.js'
)
with open(handler_path, 'r') as f:
content = f.read()
assert 'SERIES_DELETED' in content
assert 'removeSeries' in content

View File

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

View File

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