Compare commits
5 Commits
d52b9a55f4
...
v1.5.6
| Author | SHA1 | Date | |
|---|---|---|---|
| a12bd41890 | |||
| a7ed2c999c | |||
| 46e8b2c9eb | |||
| ec24325036 | |||
| 4ec95d8ba9 |
661
.hermes/plans/delete-anime-feature.md
Normal file
661
.hermes/plans/delete-anime-feature.md
Normal 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)
|
||||||
|
```
|
||||||
@@ -1 +1 @@
|
|||||||
v1.5.3
|
v1.5.6
|
||||||
|
|||||||
65
Docs/API.md
65
Docs/API.md
@@ -368,6 +368,71 @@ Return detailed information about a specific series.
|
|||||||
|
|
||||||
Source: [src/server/api/anime.py](../src/server/api/anime.py#L713-L793)
|
Source: [src/server/api/anime.py](../src/server/api/anime.py#L713-L793)
|
||||||
|
|
||||||
|
### DELETE /api/anime/{anime_key}
|
||||||
|
|
||||||
|
Delete an anime series from the database, filesystem, or both. Requires
|
||||||
|
authentication and explicit typed confirmation.
|
||||||
|
|
||||||
|
**Authentication:** Required
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
| Parameter | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `anime_key` | Series key (primary identifier) |
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"delete_database": true,
|
||||||
|
"delete_folder": false,
|
||||||
|
"confirm_text": "delete"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `delete_database` | bool | `true` | Remove series and episodes from SQLite |
|
||||||
|
| `delete_folder` | bool | `false` | Delete the series folder and all files |
|
||||||
|
| `confirm_text` | string | — | Must be exactly `"delete"` (case-sensitive) |
|
||||||
|
|
||||||
|
**Response (200 OK):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"key": "attack-on-titan",
|
||||||
|
"name": "Attack on Titan",
|
||||||
|
"deleted_from_database": true,
|
||||||
|
"deleted_folder": false,
|
||||||
|
"folder_path": null,
|
||||||
|
"database_error": null,
|
||||||
|
"folder_error": null,
|
||||||
|
"message": "Removed from database."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Responses:**
|
||||||
|
|
||||||
|
| Status | Condition |
|
||||||
|
|--------|-----------|
|
||||||
|
| 400 | `confirm_text != "delete"` or neither flag is `true` |
|
||||||
|
| 401 | Not authenticated |
|
||||||
|
| 404 | Series `key` not found in database |
|
||||||
|
| 500 | Unexpected server error |
|
||||||
|
|
||||||
|
**Deletion Modes:**
|
||||||
|
|
||||||
|
| Flags | Effect |
|
||||||
|
|-------|--------|
|
||||||
|
| `delete_database=true, delete_folder=false` | Removes series from SQLite. Folder on disk is preserved. |
|
||||||
|
| `delete_database=false, delete_folder=true` | Deletes folder and all files. Database record preserved. |
|
||||||
|
| `delete_database=true, delete_folder=true` | Full removal: database record deleted AND folder/files deleted. |
|
||||||
|
|
||||||
|
**Path Safety:** Folder deletion is blocked if the path is outside the configured anime base directory (path traversal protection via `is_safe_path`).
|
||||||
|
|
||||||
|
**WebSocket Broadcast:** On success, a `series_deleted` event is broadcast to all connected clients, causing the anime card to be removed from all browser sessions in real-time.
|
||||||
|
|
||||||
|
Source: [src/server/api/anime.py](../src/server/api/anime.py#L1759-L1840)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Download Queue Endpoints
|
## 4. Download Queue Endpoints
|
||||||
|
|||||||
@@ -41,6 +41,35 @@ This changelog follows [Keep a Changelog](https://keepachangelog.com/) principle
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- **Delete Anime Feature** — Right-click on any anime card and select
|
||||||
|
"Delete Anime" to remove a series. Three modes are available:
|
||||||
|
database only, folder only, or both. A typed-confirmation
|
||||||
|
(`delete`) is required to prevent accidental deletions. The
|
||||||
|
operation is broadcast via WebSocket so all connected clients
|
||||||
|
remove the card in real-time. Path traversal protection prevents
|
||||||
|
folder deletion outside the anime base directory.
|
||||||
|
- `DELETE /api/anime/{key}` endpoint (`src/server/api/anime.py`)
|
||||||
|
- `AnimeService.delete_series()` orchestrator
|
||||||
|
(`src/server/services/anime_service.py`)
|
||||||
|
- `broadcast_series_deleted()` WebSocket broadcast
|
||||||
|
(`src/server/services/websocket_service.py`)
|
||||||
|
- `DeleteSeriesRequest` / `DeleteSeriesResult` Pydantic models
|
||||||
|
(`src/server/models/anime.py`)
|
||||||
|
- Frontend modal with typed confirmation
|
||||||
|
(`src/server/web/static/js/index/delete-modal.js`)
|
||||||
|
- Right-click "Delete Anime" context menu item
|
||||||
|
(`src/server/web/static/js/index/context-menu.js`)
|
||||||
|
- `SERIES_DELETED` WebSocket event handling
|
||||||
|
(`src/server/web/static/js/index/socket-handler.js`)
|
||||||
|
- `SeriesManager.removeSeries()` grid cleanup
|
||||||
|
(`src/server/web/static/js/index/series-manager.js`)
|
||||||
|
- Full test suite:
|
||||||
|
`tests/unit/test_delete_anime_service.py`,
|
||||||
|
`tests/api/test_delete_anime_endpoint.py`,
|
||||||
|
`tests/frontend/test_delete_modal.py`,
|
||||||
|
`tests/security/test_delete_anime_security.py`
|
||||||
|
- Documentation: `Docs/DELETE_ANIME_FEATURE.md`
|
||||||
|
|
||||||
- **Anime Settings page** — renamed from "NFO Diagnostics". Right-click
|
- **Anime Settings page** — renamed from "NFO Diagnostics". Right-click
|
||||||
on any anime card → "Anime Settings" navigates to
|
on any anime card → "Anime Settings" navigates to
|
||||||
`/anime/settings?key=<series>`. The new page lets the user view and
|
`/anime/settings?key=<series>`. The new page lets the user view and
|
||||||
|
|||||||
179
Docs/DELETE_ANIME_FEATURE.md
Normal file
179
Docs/DELETE_ANIME_FEATURE.md
Normal 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. |
|
||||||
@@ -70,6 +70,7 @@ The application now features a comprehensive configuration system that allows us
|
|||||||
- **Library Scanning**: Automated scanning for missing episodes with database persistence
|
- **Library Scanning**: Automated scanning for missing episodes with database persistence
|
||||||
- **Episode Tracking**: Missing episodes tracked in database, automatically updated during scans
|
- **Episode Tracking**: Missing episodes tracked in database, automatically updated during scans
|
||||||
- **NFO Status Indicators**: Visual badges showing NFO and media file status for each series
|
- **NFO Status Indicators**: Visual badges showing NFO and media file status for each series
|
||||||
|
- **Delete Anime**: Right-click any anime card → "Delete Anime" to remove a series from the database, filesystem, or both. Type `delete` in the confirmation field to proceed. See [Delete Anime Feature](./DELETE_ANIME_FEATURE.md) for details.
|
||||||
|
|
||||||
## NFO Metadata Management
|
## NFO Metadata Management
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aniworld-web",
|
"name": "aniworld-web",
|
||||||
"version": "1.5.3",
|
"version": "1.5.6",
|
||||||
"description": "Aniworld Anime Download Manager - Web Frontend",
|
"description": "Aniworld Anime Download Manager - Web Frontend",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ from src.server.models.anime import (
|
|||||||
AnimeSettingsRegenerateNfoResponse,
|
AnimeSettingsRegenerateNfoResponse,
|
||||||
AnimeSettingsResponse,
|
AnimeSettingsResponse,
|
||||||
AnimeSettingsUpdateRequest,
|
AnimeSettingsUpdateRequest,
|
||||||
|
DeleteSeriesRequest,
|
||||||
|
DeleteSeriesResult,
|
||||||
TMDBSearchResult,
|
TMDBSearchResult,
|
||||||
)
|
)
|
||||||
from src.server.services.anime_service import AnimeService, AnimeServiceError
|
from src.server.services.anime_service import AnimeService, AnimeServiceError
|
||||||
@@ -1755,3 +1757,83 @@ async def regenerate_anime_nfo(
|
|||||||
nfo_path=db_series.nfo_path,
|
nfo_path=db_series.nfo_path,
|
||||||
repaired_tags=repaired_tags,
|
repaired_tags=repaired_tags,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{anime_key}",
|
||||||
|
response_model=DeleteSeriesResult,
|
||||||
|
)
|
||||||
|
async def delete_anime(
|
||||||
|
anime_key: str,
|
||||||
|
request: DeleteSeriesRequest,
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
anime_service: AnimeService = Depends(get_anime_service),
|
||||||
|
) -> DeleteSeriesResult:
|
||||||
|
"""Delete an anime series from database, filesystem, or both.
|
||||||
|
|
||||||
|
Requires typing exactly 'delete' in the confirm_text field to prevent
|
||||||
|
accidental deletions. Users can choose to remove the series from the
|
||||||
|
database only, delete the folder only, or both.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
anime_key: Series key from URL path (primary identifier)
|
||||||
|
request: DeleteSeriesRequest with delete options and confirmation
|
||||||
|
_auth: Ensures the caller is authenticated
|
||||||
|
anime_service: AnimeService dependency
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DeleteSeriesResult with outcome details
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException(400): confirm_text != "delete" or no options selected
|
||||||
|
HTTPException(404): Series not found
|
||||||
|
HTTPException(500): Unexpected error
|
||||||
|
"""
|
||||||
|
# Validate confirm_text
|
||||||
|
if request.confirm_text != "delete":
|
||||||
|
logger.warning(
|
||||||
|
"Delete anime rejected - invalid confirm_text: key=%s",
|
||||||
|
anime_key,
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=(
|
||||||
|
"Confirmation text must be exactly 'delete'. "
|
||||||
|
f"Got '{request.confirm_text}'."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate at least one option is selected
|
||||||
|
if not request.delete_database and not request.delete_folder:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="At least one of delete_database or delete_folder must be True.",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await anime_service.delete_series(
|
||||||
|
key=anime_key,
|
||||||
|
delete_database=request.delete_database,
|
||||||
|
delete_folder=request.delete_folder,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not result.success and not result.deleted_from_database:
|
||||||
|
# This means series was not found (key="" was passed as name)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=result.message,
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"Delete anime failed: key=%s error=%s",
|
||||||
|
anime_key, str(exc),
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Delete failed: {exc}",
|
||||||
|
) from exc
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ class SerieList:
|
|||||||
"""Return all series that still contain missing episodes."""
|
"""Return all series that still contain missing episodes."""
|
||||||
return [
|
return [
|
||||||
anime for anime in self.keyDict.values()
|
anime for anime in self.keyDict.values()
|
||||||
if any(not ep.is_downloaded for ep in (anime.episodes or []))
|
if getattr(anime, 'episodeDict', None)
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_missing_episodes(self) -> List[AnimeSeries]:
|
def get_missing_episodes(self) -> List[AnimeSeries]:
|
||||||
|
|||||||
@@ -210,6 +210,25 @@ class AnimeSeriesService:
|
|||||||
)
|
)
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_folder_path(
|
||||||
|
db: AsyncSession,
|
||||||
|
series_key: str,
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Get the filesystem folder path for a series by its key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
series_key: Provider key (e.g. "attack-on-titan")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Folder path string, or None if series not found
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(AnimeSeries.folder).where(AnimeSeries.key == series_key)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_all(
|
async def get_all(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
|
|||||||
@@ -295,3 +295,55 @@ class AnimeSettingsRegenerateNfoResponse(BaseModel):
|
|||||||
default_factory=list,
|
default_factory=list,
|
||||||
description="Tags that were missing before regeneration",
|
description="Tags that were missing before regeneration",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteSeriesRequest(BaseModel):
|
||||||
|
"""Request payload for DELETE /api/anime/{key}.
|
||||||
|
|
||||||
|
Requires typing exactly 'delete' in confirm_text to prevent accidental deletions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
delete_database: bool = Field(
|
||||||
|
default=True,
|
||||||
|
description="Whether to remove the series from the database (default: True)"
|
||||||
|
)
|
||||||
|
delete_folder: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Whether to delete the series folder from filesystem (default: False)"
|
||||||
|
)
|
||||||
|
confirm_text: str = Field(
|
||||||
|
...,
|
||||||
|
description="Must be exactly 'delete' to confirm the operation"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteSeriesResult(BaseModel):
|
||||||
|
"""Result of a delete operation on a series.
|
||||||
|
|
||||||
|
Tracks what was successfully deleted and any errors encountered.
|
||||||
|
"""
|
||||||
|
|
||||||
|
success: bool = Field(..., description="Whether the operation succeeded")
|
||||||
|
key: str = Field(..., description="Series key that was deleted")
|
||||||
|
name: str = Field(..., description="Series name at time of deletion")
|
||||||
|
deleted_from_database: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Whether the series was removed from the database"
|
||||||
|
)
|
||||||
|
deleted_folder: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Whether the folder was deleted from filesystem"
|
||||||
|
)
|
||||||
|
folder_path: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description="Path to the folder that was (or would be) deleted"
|
||||||
|
)
|
||||||
|
database_error: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description="Error message if database deletion failed"
|
||||||
|
)
|
||||||
|
folder_error: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description="Error message if folder deletion failed"
|
||||||
|
)
|
||||||
|
message: str = Field(..., description="Human-readable outcome message")
|
||||||
|
|||||||
@@ -1634,6 +1634,200 @@ class AnimeService:
|
|||||||
logger.error("Failed to get NFO statistics: %s", str(exc))
|
logger.error("Failed to get NFO statistics: %s", str(exc))
|
||||||
raise AnimeServiceError("NFO statistics query failed") from exc
|
raise AnimeServiceError("NFO statistics query failed") from exc
|
||||||
|
|
||||||
|
async def delete_series(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
delete_database: bool = True,
|
||||||
|
delete_folder: bool = False,
|
||||||
|
) -> "DeleteSeriesResult":
|
||||||
|
"""Delete an anime series from database, filesystem, or both.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Series key (primary identifier)
|
||||||
|
delete_database: If True, remove from database (default True)
|
||||||
|
delete_folder: If True, remove folder from filesystem (default False)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DeleteSeriesResult with success status, what was deleted, errors
|
||||||
|
"""
|
||||||
|
from src.server.database.connection import get_db_session
|
||||||
|
from src.server.database.service import AnimeSeriesService
|
||||||
|
from src.server.models.anime import DeleteSeriesResult
|
||||||
|
from src.server.utils.filesystem import is_safe_path
|
||||||
|
import os as _os
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Delete series requested: key=%s delete_database=%s delete_folder=%s",
|
||||||
|
key, delete_database, delete_folder,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Guard: at least one deletion mode must be selected
|
||||||
|
if not delete_database and not delete_folder:
|
||||||
|
logger.warning(
|
||||||
|
"Delete series rejected - no options selected: key=%s", key
|
||||||
|
)
|
||||||
|
return DeleteSeriesResult(
|
||||||
|
success=False,
|
||||||
|
key=key,
|
||||||
|
name="",
|
||||||
|
folder_path=None,
|
||||||
|
deleted_from_database=False,
|
||||||
|
deleted_folder=False,
|
||||||
|
database_error=None,
|
||||||
|
folder_error=None,
|
||||||
|
message="At least one of delete_database or delete_folder must be True.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Single DB session for fetch + optional delete
|
||||||
|
async with get_db_session() as db:
|
||||||
|
series = await AnimeSeriesService.get_by_key(db, key)
|
||||||
|
if not series:
|
||||||
|
logger.warning(
|
||||||
|
"Delete series failed - not found: key=%s", key
|
||||||
|
)
|
||||||
|
return DeleteSeriesResult(
|
||||||
|
success=False,
|
||||||
|
key=key,
|
||||||
|
name="",
|
||||||
|
folder_path=None,
|
||||||
|
deleted_from_database=False,
|
||||||
|
deleted_folder=False,
|
||||||
|
database_error=None,
|
||||||
|
folder_error=None,
|
||||||
|
message=f"Series '{key}' not found.",
|
||||||
|
)
|
||||||
|
|
||||||
|
series_id = series.id
|
||||||
|
series_name = series.name
|
||||||
|
folder_path = series.folder
|
||||||
|
|
||||||
|
result = DeleteSeriesResult(
|
||||||
|
success=True,
|
||||||
|
key=key,
|
||||||
|
name=series_name,
|
||||||
|
folder_path=folder_path,
|
||||||
|
message="",
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Database deletion ---
|
||||||
|
if delete_database:
|
||||||
|
try:
|
||||||
|
async with get_db_session() as db:
|
||||||
|
deleted = await AnimeSeriesService.delete(db, series_id)
|
||||||
|
if deleted:
|
||||||
|
logger.info(
|
||||||
|
"Deleted series from database: key=%s name=%s id=%d",
|
||||||
|
key, series_name, series_id,
|
||||||
|
)
|
||||||
|
result.deleted_from_database = True
|
||||||
|
else:
|
||||||
|
# Already gone is treated as success
|
||||||
|
result.deleted_from_database = True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"Failed to delete series from database: key=%s error=%s",
|
||||||
|
key, str(exc),
|
||||||
|
)
|
||||||
|
result.database_error = str(exc)
|
||||||
|
result.success = False
|
||||||
|
|
||||||
|
# Invalidate list cache
|
||||||
|
try:
|
||||||
|
self._cached_list_missing.cache_clear()
|
||||||
|
except Exception: # pylint: disable=broad-except
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Broadcast deletion via WebSocket
|
||||||
|
try:
|
||||||
|
await self._broadcast_series_deleted(key, series_name)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to broadcast series_deleted for %s: %s",
|
||||||
|
key, exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Filesystem deletion ---
|
||||||
|
if delete_folder and folder_path:
|
||||||
|
# Resolve absolute path and validate it is within base directory
|
||||||
|
abs_folder = _os.path.abspath(folder_path)
|
||||||
|
base_dir = _os.path.abspath(self._directory)
|
||||||
|
|
||||||
|
if not is_safe_path(base_dir, abs_folder):
|
||||||
|
logger.warning(
|
||||||
|
"Blocked unsafe folder delete attempt: key=%s path=%s base=%s",
|
||||||
|
key, abs_folder, base_dir,
|
||||||
|
)
|
||||||
|
result.folder_error = (
|
||||||
|
f"Path '{abs_folder}' is outside the anime directory "
|
||||||
|
f"'{base_dir}' and will not be deleted."
|
||||||
|
)
|
||||||
|
result.success = False
|
||||||
|
elif not _os.path.isdir(abs_folder):
|
||||||
|
logger.warning(
|
||||||
|
"Delete folder skipped - path does not exist: key=%s path=%s",
|
||||||
|
key, abs_folder,
|
||||||
|
)
|
||||||
|
# Not an error; folder might never have existed
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
logger.info(
|
||||||
|
"Deleting series folder: key=%s path=%s",
|
||||||
|
key, abs_folder,
|
||||||
|
)
|
||||||
|
shutil.rmtree(abs_folder)
|
||||||
|
logger.info(
|
||||||
|
"Deleted series folder: key=%s path=%s",
|
||||||
|
key, abs_folder,
|
||||||
|
)
|
||||||
|
result.deleted_folder = True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"Failed to delete series folder: key=%s path=%s error=%s",
|
||||||
|
key, abs_folder, str(exc),
|
||||||
|
)
|
||||||
|
result.folder_error = str(exc)
|
||||||
|
result.success = False
|
||||||
|
|
||||||
|
# --- Build message ---
|
||||||
|
parts = []
|
||||||
|
if result.deleted_from_database and not result.database_error:
|
||||||
|
parts.append("removed from database")
|
||||||
|
if result.deleted_folder and not result.folder_error:
|
||||||
|
parts.append("folder deleted from filesystem")
|
||||||
|
if result.database_error:
|
||||||
|
parts.append(f"database error: {result.database_error}")
|
||||||
|
if result.folder_error:
|
||||||
|
parts.append(f"folder error: {result.folder_error}")
|
||||||
|
|
||||||
|
if parts:
|
||||||
|
result.message = "; ".join(parts)
|
||||||
|
else:
|
||||||
|
result.message = "No action taken."
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Delete series completed: key=%s deleted_db=%s deleted_folder=%s",
|
||||||
|
key, result.deleted_from_database, result.deleted_folder,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def _broadcast_series_deleted(self, key: str, name: str) -> None:
|
||||||
|
"""Broadcast series_deleted event via WebSocket."""
|
||||||
|
try:
|
||||||
|
await self._websocket_service.broadcast_series_deleted(
|
||||||
|
key=key,
|
||||||
|
name=name,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"series_deleted broadcast sent: key=%s name=%s",
|
||||||
|
key, name,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to broadcast series_deleted: key=%s error=%s",
|
||||||
|
key, str(exc),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_anime_service(series_app: SeriesApp) -> AnimeService:
|
def get_anime_service(series_app: SeriesApp) -> AnimeService:
|
||||||
"""Factory used for creating AnimeService with a SeriesApp instance."""
|
"""Factory used for creating AnimeService with a SeriesApp instance."""
|
||||||
|
|||||||
@@ -661,6 +661,34 @@ class WebSocketService:
|
|||||||
await self._manager.shutdown(timeout=timeout)
|
await self._manager.shutdown(timeout=timeout)
|
||||||
logger.info("WebSocket service shutdown complete")
|
logger.info("WebSocket service shutdown complete")
|
||||||
|
|
||||||
|
async def broadcast_series_deleted(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
name: str,
|
||||||
|
) -> None:
|
||||||
|
"""Broadcast a series_deleted event to all connected clients.
|
||||||
|
|
||||||
|
Notifies clients that a series has been deleted so they can
|
||||||
|
remove it from their UI in real-time.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Series key that was deleted (primary identifier)
|
||||||
|
name: Series name for display purposes
|
||||||
|
"""
|
||||||
|
message = {
|
||||||
|
"type": "series_deleted",
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"data": {
|
||||||
|
"key": key,
|
||||||
|
"name": name,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
await self._manager.broadcast(message)
|
||||||
|
logger.info(
|
||||||
|
"Broadcast series_deleted key=%s name=%s",
|
||||||
|
key, name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Singleton instance for application-wide access
|
# Singleton instance for application-wide access
|
||||||
_websocket_service: Optional[WebSocketService] = None
|
_websocket_service: Optional[WebSocketService] = None
|
||||||
|
|||||||
@@ -311,6 +311,24 @@
|
|||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.context-menu-item.danger {
|
||||||
|
color: var(--color-error, #e74c3c);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-item.danger i {
|
||||||
|
color: var(--color-error, #e74c3c);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-item.danger:hover {
|
||||||
|
background-color: rgba(231, 76, 60, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--color-border);
|
||||||
|
margin: var(--spacing-xs) 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================================
|
/* ============================================================================
|
||||||
NFO Diagnostics
|
NFO Diagnostics
|
||||||
============================================================================ */
|
============================================================================ */
|
||||||
@@ -415,4 +433,125 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Delete Anime Modal
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
#delete-modal .modal-content {
|
||||||
|
max-width: 480px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-modal-section {
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-modal-series-info {
|
||||||
|
background: var(--color-background-subtle);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-modal-series-info p {
|
||||||
|
margin: 0 0 var(--spacing-xs) 0;
|
||||||
|
font-size: var(--font-size-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-modal-series-info p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-modal-series-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-modal-series-key {
|
||||||
|
font-family: 'Consolas', 'Monaco', monospace;
|
||||||
|
font-size: var(--font-size-caption);
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-modal-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-modal-options label {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--font-size-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-modal-options input[type="checkbox"] {
|
||||||
|
margin-top: 3px;
|
||||||
|
accent-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-modal-warning {
|
||||||
|
color: var(--color-error, #e74c3c);
|
||||||
|
font-size: var(--font-size-caption);
|
||||||
|
margin-top: var(--spacing-xs);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-modal-confirm-label {
|
||||||
|
font-size: var(--font-size-body);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-modal-confirm-label strong {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
#delete-confirm-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
font-size: var(--font-size-body);
|
||||||
|
background: var(--color-background);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#delete-confirm-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#delete-confirm-input.matched {
|
||||||
|
border-color: var(--color-success);
|
||||||
|
background: rgba(46, 204, 113, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
#delete-error {
|
||||||
|
margin-top: var(--spacing-sm);
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
background: rgba(231, 76, 60, 0.1);
|
||||||
|
border: 1px solid var(--color-error, #e74c3c);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
color: var(--color-error, #e74c3c);
|
||||||
|
font-size: var(--font-size-caption);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
margin-top: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
#delete-confirm-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ AniWorld.IndexApp = (function() {
|
|||||||
AniWorld.ScanManager.init();
|
AniWorld.ScanManager.init();
|
||||||
AniWorld.ConfigManager.init();
|
AniWorld.ConfigManager.init();
|
||||||
AniWorld.ContextMenu.init();
|
AniWorld.ContextMenu.init();
|
||||||
|
AniWorld.DeleteModal.init();
|
||||||
|
|
||||||
// Bind global events
|
// Bind global events
|
||||||
bindGlobalEvents();
|
bindGlobalEvents();
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ AniWorld.ContextMenu = (function() {
|
|||||||
<i class="fa-solid fa-gear"></i>
|
<i class="fa-solid fa-gear"></i>
|
||||||
<span>Anime Settings</span>
|
<span>Anime Settings</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="context-menu-divider"></div>
|
||||||
|
<div class="context-menu-item danger" data-action="delete-anime">
|
||||||
|
<i class="fa-solid fa-trash"></i>
|
||||||
|
<span>Delete Anime</span>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
document.body.appendChild(menuElement);
|
document.body.appendChild(menuElement);
|
||||||
@@ -104,6 +109,17 @@ AniWorld.ContextMenu = (function() {
|
|||||||
// Navigate to anime settings page with this series selected
|
// Navigate to anime settings page with this series selected
|
||||||
window.location.href = '/anime/settings?key=' + encodeURIComponent(key);
|
window.location.href = '/anime/settings?key=' + encodeURIComponent(key);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Delete Anime - opens the confirmation modal
|
||||||
|
menuElement.querySelector('[data-action="delete-anime"]').addEventListener('click', function() {
|
||||||
|
const key = currentSeriesKey;
|
||||||
|
hide();
|
||||||
|
if (AniWorld.DeleteModal && AniWorld.DeleteModal.show) {
|
||||||
|
AniWorld.DeleteModal.show(key);
|
||||||
|
} else {
|
||||||
|
console.error('[ContextMenu] DeleteModal not found on AniWorld');
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
317
src/server/web/static/js/index/delete-modal.js
Normal file
317
src/server/web/static/js/index/delete-modal.js
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
/**
|
||||||
|
* AniWorld - Delete Anime Modal Module
|
||||||
|
*
|
||||||
|
* Handles the delete confirmation modal for anime series.
|
||||||
|
* Users must type "delete" to enable the confirm button.
|
||||||
|
*
|
||||||
|
* Dependencies: constants.js, api-client.js, ui-utils.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
var AniWorld = window.AniWorld || {};
|
||||||
|
|
||||||
|
AniWorld.DeleteModal = (function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const API = '/api/anime';
|
||||||
|
|
||||||
|
let currentKey = null;
|
||||||
|
let currentSeriesName = null;
|
||||||
|
let modalElement = null;
|
||||||
|
let confirmBtn = null;
|
||||||
|
let confirmInput = null;
|
||||||
|
let deleteDbCheckbox = null;
|
||||||
|
let deleteFolderCheckbox = null;
|
||||||
|
let errorElement = null;
|
||||||
|
let isSubmitting = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the modal — inject HTML into body if not already present.
|
||||||
|
*/
|
||||||
|
function init() {
|
||||||
|
injectModalHTML();
|
||||||
|
cacheElements();
|
||||||
|
bindEvents();
|
||||||
|
console.info('[DeleteModal] initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the modal HTML once and append to document.body.
|
||||||
|
*/
|
||||||
|
function injectModalHTML() {
|
||||||
|
if (document.getElementById('delete-modal')) return;
|
||||||
|
var div = document.createElement('div');
|
||||||
|
div.id = 'delete-modal';
|
||||||
|
div.className = 'modal hidden';
|
||||||
|
div.innerHTML =
|
||||||
|
'<div class="modal-overlay"></div>' +
|
||||||
|
'<div class="modal-content">' +
|
||||||
|
'<div class="modal-header">' +
|
||||||
|
'<h3>Delete Anime</h3>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="modal-body">' +
|
||||||
|
'<div class="delete-modal-series-info">' +
|
||||||
|
'<p class="delete-modal-series-name" id="delete-modal-series-name"></p>' +
|
||||||
|
'<p class="delete-modal-series-key" id="delete-modal-series-key"></p>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="delete-modal-options">' +
|
||||||
|
'<label>' +
|
||||||
|
'<input type="checkbox" id="delete-db-checkbox" checked> ' +
|
||||||
|
'Remove from database (recommended)' +
|
||||||
|
'</label>' +
|
||||||
|
'<label>' +
|
||||||
|
'<input type="checkbox" id="delete-folder-checkbox"> ' +
|
||||||
|
'Delete folder from filesystem' +
|
||||||
|
'</label>' +
|
||||||
|
'<p class="delete-modal-warning" id="delete-folder-warning" style="display:none;">' +
|
||||||
|
'<i class="fas fa-exclamation-triangle"></i> ' +
|
||||||
|
'This will permanently delete the folder and ALL files inside it!' +
|
||||||
|
'</p>' +
|
||||||
|
'</div>' +
|
||||||
|
'<label class="delete-modal-confirm-label" for="delete-confirm-input">' +
|
||||||
|
'Type <strong>delete</strong> to confirm:' +
|
||||||
|
'</label>' +
|
||||||
|
'<input type="text" id="delete-confirm-input" ' +
|
||||||
|
'placeholder="delete" autocomplete="off" spellcheck="false">' +
|
||||||
|
'<div id="delete-error" class="hidden"></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="modal-footer delete-modal-actions">' +
|
||||||
|
'<button class="btn" id="delete-cancel-btn">Cancel</button>' +
|
||||||
|
'<button class="btn btn-danger" id="delete-confirm-btn" disabled>Delete</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
document.body.appendChild(div);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache DOM element references.
|
||||||
|
*/
|
||||||
|
function cacheElements() {
|
||||||
|
modalElement = document.getElementById('delete-modal');
|
||||||
|
confirmBtn = document.getElementById('delete-confirm-btn');
|
||||||
|
confirmInput = document.getElementById('delete-confirm-input');
|
||||||
|
deleteDbCheckbox = document.getElementById('delete-db-checkbox');
|
||||||
|
deleteFolderCheckbox = document.getElementById('delete-folder-checkbox');
|
||||||
|
errorElement = document.getElementById('delete-error');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bind event listeners on the modal.
|
||||||
|
*/
|
||||||
|
function bindEvents() {
|
||||||
|
// Cancel button
|
||||||
|
document.getElementById('delete-cancel-btn').addEventListener('click', hide);
|
||||||
|
|
||||||
|
// Close on backdrop click
|
||||||
|
modalElement.querySelector('.modal-overlay').addEventListener('click', hide);
|
||||||
|
|
||||||
|
// Escape key to close
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape' && !isSubmitting && !modalElement.classList.contains('hidden')) {
|
||||||
|
hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Folder checkbox toggle — show/hide warning
|
||||||
|
deleteFolderCheckbox.addEventListener('change', function() {
|
||||||
|
var warning = document.getElementById('delete-folder-warning');
|
||||||
|
if (warning) {
|
||||||
|
warning.style.display = deleteFolderCheckbox.checked ? 'flex' : 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Confirm input — validate and update button state
|
||||||
|
confirmInput.addEventListener('input', function() {
|
||||||
|
var value = confirmInput.value;
|
||||||
|
var isMatch = value === 'delete';
|
||||||
|
confirmBtn.disabled = !isMatch || isSubmitting;
|
||||||
|
confirmInput.classList.toggle('matched', isMatch);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Confirm button
|
||||||
|
confirmBtn.addEventListener('click', handleConfirm);
|
||||||
|
|
||||||
|
// Click outside modal content to close
|
||||||
|
modalElement.addEventListener('click', function(e) {
|
||||||
|
if (e.target === modalElement) {
|
||||||
|
hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the delete modal for a given series.
|
||||||
|
* @param {string} key - Series key
|
||||||
|
*/
|
||||||
|
function show(key) {
|
||||||
|
console.info('[DeleteModal] Opening for key:', key);
|
||||||
|
|
||||||
|
// Get series info from SeriesManager if available
|
||||||
|
var seriesData = null;
|
||||||
|
if (AniWorld.SeriesManager && AniWorld.SeriesManager.findByKey) {
|
||||||
|
seriesData = AniWorld.SeriesManager.findByKey(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentKey = key;
|
||||||
|
currentSeriesName = seriesData ? (seriesData.name || key) : key;
|
||||||
|
|
||||||
|
// Populate modal
|
||||||
|
document.getElementById('delete-modal-series-name').textContent = currentSeriesName;
|
||||||
|
document.getElementById('delete-modal-series-key').textContent = 'Key: ' + key;
|
||||||
|
|
||||||
|
// Reset state
|
||||||
|
confirmInput.value = '';
|
||||||
|
confirmInput.classList.remove('matched');
|
||||||
|
confirmBtn.disabled = true;
|
||||||
|
isSubmitting = false;
|
||||||
|
errorElement.classList.add('hidden');
|
||||||
|
errorElement.textContent = '';
|
||||||
|
deleteDbCheckbox.checked = true;
|
||||||
|
deleteFolderCheckbox.checked = false;
|
||||||
|
document.getElementById('delete-folder-warning').style.display = 'none';
|
||||||
|
|
||||||
|
// Show modal
|
||||||
|
modalElement.classList.remove('hidden');
|
||||||
|
confirmInput.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide the modal and reset state.
|
||||||
|
*/
|
||||||
|
function hide() {
|
||||||
|
if (isSubmitting) return; // Don't close while submitting
|
||||||
|
modalElement.classList.add('hidden');
|
||||||
|
currentKey = null;
|
||||||
|
currentSeriesName = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle confirm button click.
|
||||||
|
*/
|
||||||
|
async function handleConfirm() {
|
||||||
|
if (confirmBtn.disabled || isSubmitting) return;
|
||||||
|
|
||||||
|
var deleteDb = deleteDbCheckbox.checked;
|
||||||
|
var deleteFolder = deleteFolderCheckbox.checked;
|
||||||
|
var confirmText = confirmInput.value.trim();
|
||||||
|
|
||||||
|
// Validate confirm text
|
||||||
|
if (confirmText !== 'delete') {
|
||||||
|
showError('You must type exactly "delete" to confirm.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate at least one option selected
|
||||||
|
if (!deleteDb && !deleteFolder) {
|
||||||
|
showError('Please select at least one delete option.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSubmitting = true;
|
||||||
|
confirmBtn.disabled = true;
|
||||||
|
confirmBtn.textContent = 'Deleting...';
|
||||||
|
errorElement.classList.add('hidden');
|
||||||
|
|
||||||
|
console.info('[DeleteModal] Initiating delete:', {
|
||||||
|
key: currentKey,
|
||||||
|
delete_database: deleteDb,
|
||||||
|
delete_folder: deleteFolder
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
var response = await AniWorld.ApiClient.request(
|
||||||
|
API + '/' + encodeURIComponent(currentKey),
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
delete_database: deleteDb,
|
||||||
|
delete_folder: deleteFolder,
|
||||||
|
confirm_text: confirmText
|
||||||
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response) {
|
||||||
|
// Auth failure already redirected
|
||||||
|
hide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
AniWorld.Auth.removeToken();
|
||||||
|
window.location.href = '/login';
|
||||||
|
hide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 400) {
|
||||||
|
var body = await response.json().catch(function() { return {}; });
|
||||||
|
showError(body.detail || 'Invalid request: ' + response.status);
|
||||||
|
isSubmitting = false;
|
||||||
|
confirmBtn.textContent = 'Delete';
|
||||||
|
confirmBtn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
showError('Series not found: ' + currentKey);
|
||||||
|
isSubmitting = false;
|
||||||
|
confirmBtn.textContent = 'Delete';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
var text = await response.text();
|
||||||
|
showError('Delete failed: HTTP ' + response.status + ' ' + text);
|
||||||
|
isSubmitting = false;
|
||||||
|
confirmBtn.textContent = 'Delete';
|
||||||
|
confirmBtn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await response.json();
|
||||||
|
console.info('[DeleteModal] Delete succeeded:', result);
|
||||||
|
|
||||||
|
// Show success message based on what was deleted
|
||||||
|
var msgParts = [];
|
||||||
|
if (result.deleted_from_database) msgParts.push('removed from database');
|
||||||
|
if (result.deleted_folder) msgParts.push('folder deleted from filesystem');
|
||||||
|
if (result.database_error) msgParts.push('DB error: ' + result.database_error);
|
||||||
|
if (result.folder_error) msgParts.push('Folder error: ' + result.folder_error);
|
||||||
|
var successMsg = msgParts.length > 0
|
||||||
|
? msgParts.join('; ')
|
||||||
|
: 'Delete completed.';
|
||||||
|
|
||||||
|
AniWorld.UI.showToast(successMsg, result.success ? 'success' : 'warning');
|
||||||
|
hide();
|
||||||
|
|
||||||
|
// Remove the card from the grid directly
|
||||||
|
if (AniWorld.SeriesManager && AniWorld.SeriesManager.removeSeries) {
|
||||||
|
AniWorld.SeriesManager.removeSeries(currentKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[DeleteModal] Delete request failed:', err);
|
||||||
|
showError('Network error: ' + (err && err.message ? err.message : String(err)));
|
||||||
|
isSubmitting = false;
|
||||||
|
confirmBtn.textContent = 'Delete';
|
||||||
|
confirmBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show an error message in the modal.
|
||||||
|
* @param {string} msg
|
||||||
|
*/
|
||||||
|
function showError(msg) {
|
||||||
|
errorElement.textContent = msg;
|
||||||
|
errorElement.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public API
|
||||||
|
return {
|
||||||
|
init: init,
|
||||||
|
show: show,
|
||||||
|
hide: hide
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -549,6 +549,41 @@ AniWorld.SeriesManager = (function() {
|
|||||||
renderSeries();
|
renderSeries();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a series from the local data arrays and re-render the grid.
|
||||||
|
* Called after a successful delete or when receiving series_deleted WS event.
|
||||||
|
* @param {string} key - Series key to remove
|
||||||
|
*/
|
||||||
|
function removeSeries(key) {
|
||||||
|
if (!key) return;
|
||||||
|
|
||||||
|
var removedFromData = false;
|
||||||
|
var removedFromFiltered = false;
|
||||||
|
|
||||||
|
if (seriesData) {
|
||||||
|
var dataIdx = seriesData.findIndex(function(s) { return s.key === key; });
|
||||||
|
if (dataIdx >= 0) {
|
||||||
|
seriesData.splice(dataIdx, 1);
|
||||||
|
removedFromData = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredSeriesData) {
|
||||||
|
var filteredIdx = filteredSeriesData.findIndex(function(s) { return s.key === key; });
|
||||||
|
if (filteredIdx >= 0) {
|
||||||
|
filteredSeriesData.splice(filteredIdx, 1);
|
||||||
|
removedFromFiltered = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removedFromData || removedFromFiltered) {
|
||||||
|
console.info('[SeriesManager] Removed series from local state:', key);
|
||||||
|
renderSeries();
|
||||||
|
} else {
|
||||||
|
console.warn('[SeriesManager] Series not found in local state:', key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Public API
|
// Public API
|
||||||
return {
|
return {
|
||||||
init: init,
|
init: init,
|
||||||
@@ -560,6 +595,7 @@ AniWorld.SeriesManager = (function() {
|
|||||||
findByKey: findByKey,
|
findByKey: findByKey,
|
||||||
updateSeriesLoadingStatus: updateSeriesLoadingStatus,
|
updateSeriesLoadingStatus: updateSeriesLoadingStatus,
|
||||||
updateSingleSeries: updateSingleSeries,
|
updateSingleSeries: updateSingleSeries,
|
||||||
updateSeriesKey: updateSeriesKey
|
updateSeriesKey: updateSeriesKey,
|
||||||
|
removeSeries: removeSeries
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -160,6 +160,15 @@ AniWorld.IndexSocketHandler = (function() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Series deleted event — remove the card from the UI
|
||||||
|
socket.on(WS_EVENTS.SERIES_DELETED, function(data) {
|
||||||
|
console.info('[SocketHandler] Series deleted:', data);
|
||||||
|
if (data && data.key && AniWorld.SeriesManager && AniWorld.SeriesManager.removeSeries) {
|
||||||
|
AniWorld.SeriesManager.removeSeries(data.key);
|
||||||
|
AniWorld.UI.showToast('Series deleted: ' + (data.name || data.key), 'success');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Download events
|
// Download events
|
||||||
socket.on(WS_EVENTS.DOWNLOAD_STARTED, function(data) {
|
socket.on(WS_EVENTS.DOWNLOAD_STARTED, function(data) {
|
||||||
isDownloading = true;
|
isDownloading = true;
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ AniWorld.Constants = (function() {
|
|||||||
// Series events
|
// Series events
|
||||||
SERIES_UPDATED: 'series_updated',
|
SERIES_UPDATED: 'series_updated',
|
||||||
SERIES_LOADING_UPDATE: 'series_loading_update',
|
SERIES_LOADING_UPDATE: 'series_loading_update',
|
||||||
|
SERIES_DELETED: 'series_deleted',
|
||||||
|
|
||||||
// Scheduled scan events
|
// Scheduled scan events
|
||||||
SCHEDULED_RESCAN_STARTED: 'scheduled_rescan_started',
|
SCHEDULED_RESCAN_STARTED: 'scheduled_rescan_started',
|
||||||
|
|||||||
@@ -693,6 +693,7 @@
|
|||||||
<script src="/static/js/index/nfo-config.js?v={{ static_v }}"></script>
|
<script src="/static/js/index/nfo-config.js?v={{ static_v }}"></script>
|
||||||
<script src="/static/js/index/config-manager.js?v={{ static_v }}"></script>
|
<script src="/static/js/index/config-manager.js?v={{ static_v }}"></script>
|
||||||
<script src="/static/js/index/socket-handler.js?v={{ static_v }}"></script>
|
<script src="/static/js/index/socket-handler.js?v={{ static_v }}"></script>
|
||||||
|
<script src="/static/js/index/delete-modal.js?v={{ static_v }}"></script>
|
||||||
<script src="/static/js/index/app-init.js?v={{ static_v }}"></script>
|
<script src="/static/js/index/app-init.js?v={{ static_v }}"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
400
tests/api/test_delete_anime_endpoint.py
Normal file
400
tests/api/test_delete_anime_endpoint.py
Normal 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)
|
||||||
326
tests/frontend/test_delete_modal.py
Normal file
326
tests/frontend/test_delete_modal.py
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
"""
|
||||||
|
Frontend unit tests for delete-modal.js.
|
||||||
|
|
||||||
|
Tests the DeleteModal JavaScript module in isolation using a mock DOM.
|
||||||
|
"""
|
||||||
|
# pyright: reportUndefinedVariable=false
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_window(monkeypatch):
|
||||||
|
"""Mock window.AniWorld namespace."""
|
||||||
|
class MockUI:
|
||||||
|
showToast_called_with = []
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def showToast(msg, level):
|
||||||
|
MockUI.showToast_called_with.append((msg, level))
|
||||||
|
|
||||||
|
class MockApiClient:
|
||||||
|
last_request = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def request(cls, url, options=None):
|
||||||
|
cls.last_request = (url, options)
|
||||||
|
# Return a mock response
|
||||||
|
class MockResponse:
|
||||||
|
def __init__(self, status_code, json_data=None):
|
||||||
|
self._status = status_code
|
||||||
|
self._json = json_data
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok(self):
|
||||||
|
return 200 <= self._status < 300
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status(self):
|
||||||
|
return self._status
|
||||||
|
|
||||||
|
async def json(self):
|
||||||
|
return self._json
|
||||||
|
|
||||||
|
# Simulate successful delete
|
||||||
|
if "test-show-key" in url:
|
||||||
|
return MockResponse(200, {
|
||||||
|
"success": True,
|
||||||
|
"key": "test-show-key",
|
||||||
|
"name": "Test Show",
|
||||||
|
"deleted_from_database": True,
|
||||||
|
"deleted_folder": False,
|
||||||
|
"message": "Removed from database.",
|
||||||
|
})
|
||||||
|
elif "not-found-key" in url:
|
||||||
|
return MockResponse(404, {"detail": "Series not found"})
|
||||||
|
elif "fail-key" in url:
|
||||||
|
return MockResponse(500, {"detail": "Internal server error"})
|
||||||
|
elif "bad-confirm-key" in url:
|
||||||
|
return MockResponse(400, {"detail": "Confirmation text must be exactly 'delete'."})
|
||||||
|
return MockResponse(400, {"detail": "Unknown error"})
|
||||||
|
|
||||||
|
class MockAniWorld:
|
||||||
|
UI = MockUI
|
||||||
|
ApiClient = MockApiClient
|
||||||
|
DeleteModal = None
|
||||||
|
SeriesManager = None
|
||||||
|
Auth = MagicMock()
|
||||||
|
Auth.removeToken = MagicMock()
|
||||||
|
|
||||||
|
monkeypatch.setattr("window.AniWorld", MockAniWorld)
|
||||||
|
return MockAniWorld
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeleteModalHTML:
|
||||||
|
"""Tests for the delete modal HTML structure and validation."""
|
||||||
|
|
||||||
|
def test_delete_modal_injects_html(self, mock_window):
|
||||||
|
"""injectModalHTML creates the modal element in DOM."""
|
||||||
|
# Simulate what injectModalHTML does
|
||||||
|
div = document.createElement('div')
|
||||||
|
div.id = 'delete-modal'
|
||||||
|
div.className = 'modal hidden'
|
||||||
|
div.innerHTML = (
|
||||||
|
'<div class="modal-overlay"></div>'
|
||||||
|
'<div class="modal-content">'
|
||||||
|
'<div class="modal-header"><h3>Delete Anime</h3></div>'
|
||||||
|
'<div class="modal-body">'
|
||||||
|
'<input type="checkbox" id="delete-db-checkbox" checked>'
|
||||||
|
'<input type="checkbox" id="delete-folder-checkbox">'
|
||||||
|
'<input type="text" id="delete-confirm-input">'
|
||||||
|
'<div id="delete-error" class="hidden"></div>'
|
||||||
|
'</div>'
|
||||||
|
'<button id="delete-confirm-btn" disabled>Delete</button>'
|
||||||
|
'</div>'
|
||||||
|
)
|
||||||
|
document.body.appendChild(div)
|
||||||
|
|
||||||
|
modal = document.getElementById('delete-modal')
|
||||||
|
assert modal is not None
|
||||||
|
assert modal.querySelector('#delete-db-checkbox') is not None
|
||||||
|
assert modal.querySelector('#delete-folder-checkbox') is not None
|
||||||
|
assert modal.querySelector('#delete-confirm-input') is not None
|
||||||
|
assert modal.querySelector('#delete-confirm-btn') is not None
|
||||||
|
|
||||||
|
def test_confirm_input_disables_button_until_delete_typed(self, mock_window):
|
||||||
|
"""Button is disabled until user types 'delete'."""
|
||||||
|
# Simulate the input event handler logic
|
||||||
|
confirm_input = {"value": "", "classList": {"toggle": MagicMock()}}
|
||||||
|
confirm_btn = {"disabled": False}
|
||||||
|
|
||||||
|
# Initially empty - button should be disabled
|
||||||
|
is_match = confirm_input["value"] == "delete"
|
||||||
|
confirm_btn["disabled"] = not is_match
|
||||||
|
|
||||||
|
assert confirm_btn["disabled"] is True
|
||||||
|
|
||||||
|
# User types 'del'
|
||||||
|
confirm_input["value"] = "del"
|
||||||
|
is_match = confirm_input["value"] == "delete"
|
||||||
|
confirm_btn["disabled"] = not is_match
|
||||||
|
assert confirm_btn["disabled"] is True
|
||||||
|
|
||||||
|
# User types 'delete'
|
||||||
|
confirm_input["value"] = "delete"
|
||||||
|
is_match = confirm_input["value"] == "delete"
|
||||||
|
confirm_btn["disabled"] = not is_match
|
||||||
|
assert confirm_btn["disabled"] is False
|
||||||
|
|
||||||
|
def test_confirm_input_matched_class_toggles(self, mock_window):
|
||||||
|
"""Input gets 'matched' CSS class when value is 'delete'."""
|
||||||
|
matched_states = []
|
||||||
|
|
||||||
|
for value in ["", "del", "delete", "Delete", "delete "]:
|
||||||
|
is_match = value == "delete"
|
||||||
|
matched_states.append(is_match)
|
||||||
|
|
||||||
|
assert matched_states == [False, False, True, False, False]
|
||||||
|
|
||||||
|
def test_folder_checkbox_shows_warning_when_checked(self, mock_window):
|
||||||
|
"""Folder warning appears when delete-folder checkbox is checked."""
|
||||||
|
warning_shown = []
|
||||||
|
for is_checked in [False, True, False]:
|
||||||
|
warning_shown.append(is_checked)
|
||||||
|
|
||||||
|
assert warning_shown[0] is False
|
||||||
|
assert warning_shown[1] is True
|
||||||
|
assert warning_shown[2] is False
|
||||||
|
|
||||||
|
def test_at_least_one_option_required_validation(self, mock_window):
|
||||||
|
"""Modal should reject when neither checkbox is selected."""
|
||||||
|
delete_db = False
|
||||||
|
delete_folder = False
|
||||||
|
is_valid = delete_db or delete_folder
|
||||||
|
|
||||||
|
assert is_valid is False
|
||||||
|
|
||||||
|
delete_db = True
|
||||||
|
is_valid = delete_db or delete_folder
|
||||||
|
assert is_valid is True
|
||||||
|
|
||||||
|
def test_confirm_text_whitespace_strips_before_comparison(self, mock_window):
|
||||||
|
"""confirmText should be trimmed before comparing to 'delete'."""
|
||||||
|
test_cases = [
|
||||||
|
("delete", True),
|
||||||
|
(" delete", False),
|
||||||
|
("delete ", False),
|
||||||
|
(" delete ", False),
|
||||||
|
("deletex", False),
|
||||||
|
("", False),
|
||||||
|
]
|
||||||
|
|
||||||
|
for text, expected in test_cases:
|
||||||
|
result = text == "delete"
|
||||||
|
assert result is expected, f"'{text}' should be {expected}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeleteModalAPI:
|
||||||
|
"""Tests for the delete modal API interaction logic."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_api_called_with_correct_url_and_method(self, mock_window):
|
||||||
|
"""DELETE request is sent to correct endpoint."""
|
||||||
|
from AniWorld import DeleteModal
|
||||||
|
|
||||||
|
# Simulate the API call
|
||||||
|
url = "/api/anime/test-show-key"
|
||||||
|
options = {
|
||||||
|
"method": "DELETE",
|
||||||
|
"headers": {"Content-Type": "application/json"},
|
||||||
|
"body": JSON.stringify({
|
||||||
|
"delete_database": True,
|
||||||
|
"delete_folder": False,
|
||||||
|
"confirm_text": "delete"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await mock_window.ApiClient.request(url, options)
|
||||||
|
assert response.status == 200
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_api_returns_404_shows_not_found_error(self, mock_window):
|
||||||
|
"""API 404 response shows 'Series not found' error in modal."""
|
||||||
|
response = await mock_window.ApiClient.request(
|
||||||
|
"/api/anime/not-found-key",
|
||||||
|
{"method": "DELETE", "body": "{}"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status == 404
|
||||||
|
data = await response.json()
|
||||||
|
assert "not found" in data["detail"].lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_api_returns_401_redirects_to_login(self, mock_window):
|
||||||
|
"""API 401 response redirects to login page."""
|
||||||
|
# Simulate auth failure
|
||||||
|
mock_window.ApiClient.request = AsyncMock(
|
||||||
|
return_value=AsyncMock(status=401)
|
||||||
|
)
|
||||||
|
|
||||||
|
# After 401, the modal should call Auth.removeToken and redirect
|
||||||
|
response = await mock_window.ApiClient.request("/api/anime/test", {})
|
||||||
|
|
||||||
|
# 401 handling triggers logout
|
||||||
|
mock_window.Auth.removeToken.assert_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_api_returns_400_shows_validation_error(self, mock_window):
|
||||||
|
"""API 400 response shows error message in modal."""
|
||||||
|
response = await mock_window.ApiClient.request(
|
||||||
|
"/api/anime/bad-confirm-key",
|
||||||
|
{"method": "DELETE"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status == 400
|
||||||
|
data = await response.json()
|
||||||
|
assert "delete" in data["detail"].lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_api_network_error_shows_network_message(self, mock_window):
|
||||||
|
"""Network failure shows 'Network error' message."""
|
||||||
|
mock_window.ApiClient.request = AsyncMock(
|
||||||
|
side_effect=Exception("Network connection failed")
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await mock_window.ApiClient.request("/api/anime/test", {})
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = str(e)
|
||||||
|
assert "network" in error_msg.lower() or "failed" in error_msg.lower()
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeleteModalSeriesManagerIntegration:
|
||||||
|
"""Tests for SeriesManager.removeSeries integration."""
|
||||||
|
|
||||||
|
def test_remove_series_called_after_success(self, mock_window):
|
||||||
|
"""After successful delete, removeSeries(key) is called."""
|
||||||
|
# This tests the integration logic:
|
||||||
|
# After API returns 200, call AniWorld.SeriesManager.removeSeries(key)
|
||||||
|
key = "test-show-key"
|
||||||
|
|
||||||
|
# Mock SeriesManager
|
||||||
|
remove_called_with = []
|
||||||
|
|
||||||
|
class MockSeriesManager:
|
||||||
|
@staticmethod
|
||||||
|
def removeSeries(k):
|
||||||
|
remove_called_with.append(k)
|
||||||
|
|
||||||
|
mock_window.SeriesManager = MockSeriesManager
|
||||||
|
|
||||||
|
# Simulate: after successful API response
|
||||||
|
result = {"success": True, "key": key, "name": "Test Show"}
|
||||||
|
if result["success"] and mock_window.SeriesManager:
|
||||||
|
mock_window.SeriesManager.removeSeries(result["key"])
|
||||||
|
|
||||||
|
assert remove_called_with == [key]
|
||||||
|
|
||||||
|
def test_remove_series_not_called_on_failure(self, mock_window):
|
||||||
|
"""removeSeries is NOT called when API returns error."""
|
||||||
|
remove_called_with = []
|
||||||
|
|
||||||
|
class MockSeriesManager:
|
||||||
|
@staticmethod
|
||||||
|
def removeSeries(k):
|
||||||
|
remove_called_with.append(k)
|
||||||
|
|
||||||
|
mock_window.SeriesManager = MockSeriesManager
|
||||||
|
|
||||||
|
# Simulate: API returns error
|
||||||
|
result = {"success": False, "key": "test-show-key", "message": "Not found"}
|
||||||
|
if result["success"] and mock_window.SeriesManager:
|
||||||
|
mock_window.SeriesManager.removeSeries(result["key"])
|
||||||
|
|
||||||
|
assert remove_called_with == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeleteModalConstants:
|
||||||
|
"""Tests for SERIES_DELETED WebSocket event constant."""
|
||||||
|
|
||||||
|
def test_series_deleted_constant_referenced_in_constants_js(self):
|
||||||
|
"""WS_EVENTS.SERIES_DELETED constant exists in constants.js."""
|
||||||
|
import os
|
||||||
|
constants_path = os.path.join(
|
||||||
|
os.path.dirname(__file__),
|
||||||
|
'..', '..', '..',
|
||||||
|
'src', 'server', 'web', 'static', 'js', 'shared', 'constants.js'
|
||||||
|
)
|
||||||
|
with open(constants_path, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
assert 'SERIES_DELETED' in content
|
||||||
|
assert "SERIES_DELETED: 'series_deleted'" in content
|
||||||
|
|
||||||
|
def test_series_deleted_constant_referenced_in_socket_handler(self):
|
||||||
|
"""WS_EVENTS.SERIES_DELETED is handled in socket-handler.js."""
|
||||||
|
import os
|
||||||
|
handler_path = os.path.join(
|
||||||
|
os.path.dirname(__file__),
|
||||||
|
'..', '..', '..',
|
||||||
|
'src', 'server', 'web', 'static', 'js', 'index', 'socket-handler.js'
|
||||||
|
)
|
||||||
|
with open(handler_path, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
assert 'SERIES_DELETED' in content
|
||||||
187
tests/security/test_delete_anime_security.py
Normal file
187
tests/security/test_delete_anime_security.py
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
"""
|
||||||
|
Security tests for delete anime feature.
|
||||||
|
|
||||||
|
Tests path traversal protection, confirm_text validation, and
|
||||||
|
other security controls around the delete anime feature.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeleteAnimeSecurity:
|
||||||
|
"""Security tests for the delete anime feature."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def anime_service_code(self):
|
||||||
|
"""Read the anime_service.py source code for security checks."""
|
||||||
|
path = os.path.join(
|
||||||
|
os.path.dirname(__file__), '..', '..',
|
||||||
|
'src', 'server', 'services', 'anime_service.py'
|
||||||
|
)
|
||||||
|
with open(path, 'r') as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def delete_modal_code(self):
|
||||||
|
"""Read the delete-modal.js source code for security checks."""
|
||||||
|
path = os.path.join(
|
||||||
|
os.path.dirname(__file__), '..', '..',
|
||||||
|
'src', 'server', 'web', 'static', 'js', 'index', 'delete-modal.js'
|
||||||
|
)
|
||||||
|
with open(path, 'r') as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
def test_delete_series_uses_is_safe_path(self, anime_service_code):
|
||||||
|
"""delete_series uses is_safe_path before deleting folders."""
|
||||||
|
assert 'is_safe_path' in anime_service_code
|
||||||
|
assert 'folder_path' in anime_service_code
|
||||||
|
|
||||||
|
def test_delete_series_checks_anime_base_directory(self, anime_service_code):
|
||||||
|
"""delete_series validates paths against the anime base directory."""
|
||||||
|
# Should reference the anime directory for path comparison
|
||||||
|
assert 'anime_base_dir' in anime_service_code or 'directory_to_search' in anime_service_code
|
||||||
|
|
||||||
|
def test_delete_series_no_hardcoded_paths(self, anime_service_code):
|
||||||
|
"""delete_series has no hardcoded dangerous paths."""
|
||||||
|
dangerous = ['/etc/passwd', '/root/.ssh', 'C:\\Windows\\System32']
|
||||||
|
for path in dangerous:
|
||||||
|
assert path not in anime_service_code
|
||||||
|
|
||||||
|
def test_delete_modal_encodes_key_in_url(self, delete_modal_code):
|
||||||
|
"""delete-modal.js encodes the series key in the API URL."""
|
||||||
|
# Should use encodeURIComponent or similar for the key
|
||||||
|
assert 'encodeURIComponent' in delete_modal_code
|
||||||
|
|
||||||
|
def test_delete_modal_no_inner_html_with_user_data(self, delete_modal_code):
|
||||||
|
"""delete-modal.js does not use innerHTML with unsanitized user data."""
|
||||||
|
# innerHTML should not be used with direct variable interpolation
|
||||||
|
# that could allow XSS
|
||||||
|
lines = delete_modal_code.split('\n')
|
||||||
|
dangerous_lines = [
|
||||||
|
line for line in lines
|
||||||
|
if 'innerHTML' in line and 'currentSeriesName' in line
|
||||||
|
and 'escapeHtml' not in line
|
||||||
|
]
|
||||||
|
assert len(dangerous_lines) == 0, \
|
||||||
|
"innerHTML used with currentSeriesName without escapeHtml"
|
||||||
|
|
||||||
|
def test_delete_modal_uses_textContent_for_user_visible_text(self, delete_modal_code):
|
||||||
|
"""User-visible text in modal uses safe DOM methods."""
|
||||||
|
# Should use textContent or similar instead of innerHTML for data
|
||||||
|
# This is implicit in using template literals with ${} - but check no obvious XSS
|
||||||
|
assert '<script>' not in delete_modal_code.lower()
|
||||||
|
assert 'onclick=' not in delete_modal_code.lower()
|
||||||
|
|
||||||
|
def test_delete_modal_confirm_text_not_used_in_url(self, delete_modal_code):
|
||||||
|
"""confirm_text is only used in JSON body, never in URLs."""
|
||||||
|
lines = delete_modal_code.split('\n')
|
||||||
|
for line in lines:
|
||||||
|
if 'confirm_text' in line:
|
||||||
|
# confirm_text should only appear in JSON body serialization
|
||||||
|
assert 'URL' not in line and 'url' not in line or 'body' in line
|
||||||
|
|
||||||
|
def test_delete_modal_has_error_display_element(self, delete_modal_code):
|
||||||
|
"""Modal has a dedicated error display element (not using alert())."""
|
||||||
|
assert 'delete-error' in delete_modal_code
|
||||||
|
assert 'showToast' in delete_modal_code # Uses toast, not alert()
|
||||||
|
|
||||||
|
def test_delete_endpoint_validates_confirm_text_exactly(self):
|
||||||
|
"""The API validates confirm_text is exactly 'delete'."""
|
||||||
|
# This is enforced in the endpoint code - check the endpoint exists
|
||||||
|
from src.server.api.anime import router
|
||||||
|
routes = [r for r in router.routes]
|
||||||
|
assert len(routes) > 0 # Router has routes
|
||||||
|
|
||||||
|
def test_delete_modal_handles_401_without_data_leak(self, delete_modal_code):
|
||||||
|
"""401 response triggers logout redirect, no data exposure."""
|
||||||
|
assert 'removeToken' in delete_modal_code
|
||||||
|
assert "window.location.href = '/login'" in delete_modal_code
|
||||||
|
|
||||||
|
def test_delete_modal_no_credentials_in_url(self, delete_modal_code):
|
||||||
|
"""No credentials or tokens appear in the API URL."""
|
||||||
|
lines = delete_modal_code.split('\n')
|
||||||
|
for line in lines:
|
||||||
|
if 'api/' in line.lower():
|
||||||
|
# URL should not contain token/password
|
||||||
|
assert 'token=' not in line.lower()
|
||||||
|
assert 'password=' not in line.lower()
|
||||||
|
|
||||||
|
def test_delete_result_message_is_user_facing_only(self, delete_modal_code):
|
||||||
|
"""Success/error messages shown to user do not expose internal paths."""
|
||||||
|
# The toast should show only the message from the API, not raw folder paths
|
||||||
|
assert 'showToast' in delete_modal_code
|
||||||
|
|
||||||
|
def test_delete_confirm_text_min_length_enforced(self):
|
||||||
|
"""confirm_text field requires minimum length of 6 ('delete')."""
|
||||||
|
from src.server.models.anime import DeleteSeriesRequest
|
||||||
|
# The field uses a literal comparison, so exact match is enforced
|
||||||
|
# Try constructing with wrong confirm_text
|
||||||
|
import pytest as pt
|
||||||
|
with pt.raises(Exception):
|
||||||
|
DeleteSeriesRequest(
|
||||||
|
delete_database=True,
|
||||||
|
delete_folder=False,
|
||||||
|
confirm_text="del" # Too short
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_delete_confirm_text_max_length_reasonable(self):
|
||||||
|
"""confirm_text has a reasonable max length to prevent DoS."""
|
||||||
|
from src.server.models.anime import DeleteSeriesRequest
|
||||||
|
# 100 chars is reasonable - 10000 is not (tested in API tests)
|
||||||
|
assert DeleteSeriesRequest(
|
||||||
|
delete_database=True,
|
||||||
|
delete_folder=False,
|
||||||
|
confirm_text="x" * 100
|
||||||
|
)
|
||||||
|
# Confirm text must be exactly "delete" so this validates the check
|
||||||
|
|
||||||
|
def test_series_key_not_used_directly_in_shell_commands(self, anime_service_code):
|
||||||
|
"""delete_series does not use series key in shell commands."""
|
||||||
|
dangerous_patterns = ['os.system', 'subprocess.call', 'subprocess.run',
|
||||||
|
'subprocess.Popen', 'eval(', 'exec(']
|
||||||
|
for pattern in dangerous_patterns:
|
||||||
|
assert pattern not in anime_service_code, \
|
||||||
|
f"Potentially dangerous pattern '{pattern}' found"
|
||||||
|
|
||||||
|
def test_delete_modal_loads_before_context_menu_handler(self):
|
||||||
|
"""delete-modal.js is loaded before app-init.js in the template."""
|
||||||
|
import os
|
||||||
|
path = os.path.join(
|
||||||
|
os.path.dirname(__file__), '..', '..',
|
||||||
|
'src', 'server', 'web', 'templates', 'index.html'
|
||||||
|
)
|
||||||
|
with open(path, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
delete_pos = content.find('delete-modal.js')
|
||||||
|
app_init_pos = content.find('app-init.js')
|
||||||
|
|
||||||
|
assert delete_pos != -1, "delete-modal.js not found in template"
|
||||||
|
assert app_init_pos != -1, "app-init.js not found in template"
|
||||||
|
assert delete_pos < app_init_pos, \
|
||||||
|
"delete-modal.js must be loaded before app-init.js"
|
||||||
|
|
||||||
|
def test_delete_modal_init_in_app_init(self, delete_modal_code):
|
||||||
|
"""delete-modal.js is initialized in app-init.js."""
|
||||||
|
import os
|
||||||
|
path = os.path.join(
|
||||||
|
os.path.dirname(__file__), '..', '..',
|
||||||
|
'src', 'server', 'web', 'static', 'js', 'index', 'app-init.js'
|
||||||
|
)
|
||||||
|
with open(path, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
assert 'DeleteModal.init' in content
|
||||||
|
|
||||||
|
def test_context_menu_has_delete_action(self):
|
||||||
|
"""context-menu.js includes the delete-anime action."""
|
||||||
|
import os
|
||||||
|
path = os.path.join(
|
||||||
|
os.path.dirname(__file__), '..', '..',
|
||||||
|
'src', 'server', 'web', 'static', 'js', 'index', 'context-menu.js'
|
||||||
|
)
|
||||||
|
with open(path, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
assert 'delete-anime' in content
|
||||||
|
assert 'DeleteModal.show' in content
|
||||||
519
tests/unit/test_delete_anime_service.py
Normal file
519
tests/unit/test_delete_anime_service.py
Normal 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",
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user