diff --git a/.hermes/plans/delete-anime-feature.md b/.hermes/plans/delete-anime-feature.md
new file mode 100644
index 0000000..6046a9f
--- /dev/null
+++ b/.hermes/plans/delete-anime-feature.md
@@ -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 = `
+
+
+ Anime Settings
+
+
+
+
+ Delete Anime
+
+`;
+
+// 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
+
+
+
+
Delete Anime
+
+
+
+
+
+
This will permanently delete the folder and all its files!
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+**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)
+```
diff --git a/Docs/API.md b/Docs/API.md
index d686579..553822e 100644
--- a/Docs/API.md
+++ b/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)
+### 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
diff --git a/Docs/CHANGELOG.md b/Docs/CHANGELOG.md
index 526baa7..908f7a7 100644
--- a/Docs/CHANGELOG.md
+++ b/Docs/CHANGELOG.md
@@ -41,6 +41,35 @@ This changelog follows [Keep a Changelog](https://keepachangelog.com/) principle
### Added
+- **Delete Anime Feature** — Right-click on any anime card and select
+ "Delete Anime" to remove a series. Three modes are available:
+ database only, folder only, or both. A typed-confirmation
+ (`delete`) is required to prevent accidental deletions. The
+ operation is broadcast via WebSocket so all connected clients
+ remove the card in real-time. Path traversal protection prevents
+ folder deletion outside the anime base directory.
+ - `DELETE /api/anime/{key}` endpoint (`src/server/api/anime.py`)
+ - `AnimeService.delete_series()` orchestrator
+ (`src/server/services/anime_service.py`)
+ - `broadcast_series_deleted()` WebSocket broadcast
+ (`src/server/services/websocket_service.py`)
+ - `DeleteSeriesRequest` / `DeleteSeriesResult` Pydantic models
+ (`src/server/models/anime.py`)
+ - Frontend modal with typed confirmation
+ (`src/server/web/static/js/index/delete-modal.js`)
+ - Right-click "Delete Anime" context menu item
+ (`src/server/web/static/js/index/context-menu.js`)
+ - `SERIES_DELETED` WebSocket event handling
+ (`src/server/web/static/js/index/socket-handler.js`)
+ - `SeriesManager.removeSeries()` grid cleanup
+ (`src/server/web/static/js/index/series-manager.js`)
+ - Full test suite:
+ `tests/unit/test_delete_anime_service.py`,
+ `tests/api/test_delete_anime_endpoint.py`,
+ `tests/frontend/test_delete_modal.py`,
+ `tests/security/test_delete_anime_security.py`
+ - Documentation: `Docs/DELETE_ANIME_FEATURE.md`
+
- **Anime Settings page** — renamed from "NFO Diagnostics". Right-click
on any anime card → "Anime Settings" navigates to
`/anime/settings?key=`. The new page lets the user view and
diff --git a/Docs/DELETE_ANIME_FEATURE.md b/Docs/DELETE_ANIME_FEATURE.md
new file mode 100644
index 0000000..c048f8a
--- /dev/null
+++ b/Docs/DELETE_ANIME_FEATURE.md
@@ -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. |
diff --git a/Docs/features.md b/Docs/features.md
index c389b9d..f1bfcb4 100644
--- a/Docs/features.md
+++ b/Docs/features.md
@@ -70,6 +70,7 @@ The application now features a comprehensive configuration system that allows us
- **Library Scanning**: Automated scanning for missing episodes with database persistence
- **Episode Tracking**: Missing episodes tracked in database, automatically updated during scans
- **NFO Status Indicators**: Visual badges showing NFO and media file status for each series
+- **Delete Anime**: Right-click any anime card → "Delete Anime" to remove a series from the database, filesystem, or both. Type `delete` in the confirmation field to proceed. See [Delete Anime Feature](./DELETE_ANIME_FEATURE.md) for details.
## NFO Metadata Management
diff --git a/src/server/api/anime.py b/src/server/api/anime.py
index 9274424..6e66b6c 100644
--- a/src/server/api/anime.py
+++ b/src/server/api/anime.py
@@ -22,6 +22,8 @@ from src.server.models.anime import (
AnimeSettingsRegenerateNfoResponse,
AnimeSettingsResponse,
AnimeSettingsUpdateRequest,
+ DeleteSeriesRequest,
+ DeleteSeriesResult,
TMDBSearchResult,
)
from src.server.services.anime_service import AnimeService, AnimeServiceError
@@ -1755,3 +1757,83 @@ async def regenerate_anime_nfo(
nfo_path=db_series.nfo_path,
repaired_tags=repaired_tags,
)
+
+
+@router.delete(
+ "/{anime_key}",
+ response_model=DeleteSeriesResult,
+)
+async def delete_anime(
+ anime_key: str,
+ request: DeleteSeriesRequest,
+ _auth: dict = Depends(require_auth),
+ anime_service: AnimeService = Depends(get_anime_service),
+) -> DeleteSeriesResult:
+ """Delete an anime series from database, filesystem, or both.
+
+ Requires typing exactly 'delete' in the confirm_text field to prevent
+ accidental deletions. Users can choose to remove the series from the
+ database only, delete the folder only, or both.
+
+ Args:
+ anime_key: Series key from URL path (primary identifier)
+ request: DeleteSeriesRequest with delete options and confirmation
+ _auth: Ensures the caller is authenticated
+ anime_service: AnimeService dependency
+
+ Returns:
+ DeleteSeriesResult with outcome details
+
+ Raises:
+ HTTPException(400): confirm_text != "delete" or no options selected
+ HTTPException(404): Series not found
+ HTTPException(500): Unexpected error
+ """
+ # Validate confirm_text
+ if request.confirm_text != "delete":
+ logger.warning(
+ "Delete anime rejected - invalid confirm_text: key=%s",
+ anime_key,
+ )
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=(
+ "Confirmation text must be exactly 'delete'. "
+ f"Got '{request.confirm_text}'."
+ ),
+ )
+
+ # Validate at least one option is selected
+ if not request.delete_database and not request.delete_folder:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="At least one of delete_database or delete_folder must be True.",
+ )
+
+ try:
+ result = await anime_service.delete_series(
+ key=anime_key,
+ delete_database=request.delete_database,
+ delete_folder=request.delete_folder,
+ )
+
+ if not result.success and not result.deleted_from_database:
+ # This means series was not found (key="" was passed as name)
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=result.message,
+ )
+
+ return result
+
+ except HTTPException:
+ raise
+ except Exception as exc:
+ logger.error(
+ "Delete anime failed: key=%s error=%s",
+ anime_key, str(exc),
+ )
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=f"Delete failed: {exc}",
+ ) from exc
diff --git a/src/server/database/service.py b/src/server/database/service.py
index 8e9a171..1909886 100644
--- a/src/server/database/service.py
+++ b/src/server/database/service.py
@@ -210,6 +210,25 @@ class AnimeSeriesService:
)
return result.scalar_one_or_none()
+ @staticmethod
+ async def get_folder_path(
+ db: AsyncSession,
+ series_key: str,
+ ) -> Optional[str]:
+ """Get the filesystem folder path for a series by its key.
+
+ Args:
+ db: Database session
+ series_key: Provider key (e.g. "attack-on-titan")
+
+ Returns:
+ Folder path string, or None if series not found
+ """
+ result = await db.execute(
+ select(AnimeSeries.folder).where(AnimeSeries.key == series_key)
+ )
+ return result.scalar_one_or_none()
+
@staticmethod
async def get_all(
db: AsyncSession,
diff --git a/src/server/models/anime.py b/src/server/models/anime.py
index c497672..152975e 100644
--- a/src/server/models/anime.py
+++ b/src/server/models/anime.py
@@ -295,3 +295,55 @@ class AnimeSettingsRegenerateNfoResponse(BaseModel):
default_factory=list,
description="Tags that were missing before regeneration",
)
+
+
+class DeleteSeriesRequest(BaseModel):
+ """Request payload for DELETE /api/anime/{key}.
+
+ Requires typing exactly 'delete' in confirm_text to prevent accidental deletions.
+ """
+
+ delete_database: bool = Field(
+ default=True,
+ description="Whether to remove the series from the database (default: True)"
+ )
+ delete_folder: bool = Field(
+ default=False,
+ description="Whether to delete the series folder from filesystem (default: False)"
+ )
+ confirm_text: str = Field(
+ ...,
+ description="Must be exactly 'delete' to confirm the operation"
+ )
+
+
+class DeleteSeriesResult(BaseModel):
+ """Result of a delete operation on a series.
+
+ Tracks what was successfully deleted and any errors encountered.
+ """
+
+ success: bool = Field(..., description="Whether the operation succeeded")
+ key: str = Field(..., description="Series key that was deleted")
+ name: str = Field(..., description="Series name at time of deletion")
+ deleted_from_database: bool = Field(
+ default=False,
+ description="Whether the series was removed from the database"
+ )
+ deleted_folder: bool = Field(
+ default=False,
+ description="Whether the folder was deleted from filesystem"
+ )
+ folder_path: Optional[str] = Field(
+ None,
+ description="Path to the folder that was (or would be) deleted"
+ )
+ database_error: Optional[str] = Field(
+ None,
+ description="Error message if database deletion failed"
+ )
+ folder_error: Optional[str] = Field(
+ None,
+ description="Error message if folder deletion failed"
+ )
+ message: str = Field(..., description="Human-readable outcome message")
diff --git a/src/server/services/anime_service.py b/src/server/services/anime_service.py
index 7974bce..9f76cdc 100644
--- a/src/server/services/anime_service.py
+++ b/src/server/services/anime_service.py
@@ -1634,6 +1634,200 @@ class AnimeService:
logger.error("Failed to get NFO statistics: %s", str(exc))
raise AnimeServiceError("NFO statistics query failed") from exc
+ async def delete_series(
+ self,
+ key: str,
+ delete_database: bool = True,
+ delete_folder: bool = False,
+ ) -> "DeleteSeriesResult":
+ """Delete an anime series from database, filesystem, or both.
+
+ Args:
+ key: Series key (primary identifier)
+ delete_database: If True, remove from database (default True)
+ delete_folder: If True, remove folder from filesystem (default False)
+
+ Returns:
+ DeleteSeriesResult with success status, what was deleted, errors
+ """
+ from src.server.database.connection import get_db_session
+ from src.server.database.service import AnimeSeriesService
+ from src.server.models.anime import DeleteSeriesResult
+ from src.server.utils.filesystem import is_safe_path
+ import os as _os
+ import shutil
+
+ logger.info(
+ "Delete series requested: key=%s delete_database=%s delete_folder=%s",
+ key, delete_database, delete_folder,
+ )
+
+ # Guard: at least one deletion mode must be selected
+ if not delete_database and not delete_folder:
+ logger.warning(
+ "Delete series rejected - no options selected: key=%s", key
+ )
+ return DeleteSeriesResult(
+ success=False,
+ key=key,
+ name="",
+ folder_path=None,
+ deleted_from_database=False,
+ deleted_folder=False,
+ database_error=None,
+ folder_error=None,
+ message="At least one of delete_database or delete_folder must be True.",
+ )
+
+ # Single DB session for fetch + optional delete
+ async with get_db_session() as db:
+ series = await AnimeSeriesService.get_by_key(db, key)
+ if not series:
+ logger.warning(
+ "Delete series failed - not found: key=%s", key
+ )
+ return DeleteSeriesResult(
+ success=False,
+ key=key,
+ name="",
+ folder_path=None,
+ deleted_from_database=False,
+ deleted_folder=False,
+ database_error=None,
+ folder_error=None,
+ message=f"Series '{key}' not found.",
+ )
+
+ series_id = series.id
+ series_name = series.name
+ folder_path = series.folder
+
+ result = DeleteSeriesResult(
+ success=True,
+ key=key,
+ name=series_name,
+ folder_path=folder_path,
+ message="",
+ )
+
+ # --- Database deletion ---
+ if delete_database:
+ try:
+ async with get_db_session() as db:
+ deleted = await AnimeSeriesService.delete(db, series_id)
+ if deleted:
+ logger.info(
+ "Deleted series from database: key=%s name=%s id=%d",
+ key, series_name, series_id,
+ )
+ result.deleted_from_database = True
+ else:
+ # Already gone is treated as success
+ result.deleted_from_database = True
+ except Exception as exc:
+ logger.error(
+ "Failed to delete series from database: key=%s error=%s",
+ key, str(exc),
+ )
+ result.database_error = str(exc)
+ result.success = False
+
+ # Invalidate list cache
+ try:
+ self._cached_list_missing.cache_clear()
+ except Exception: # pylint: disable=broad-except
+ pass
+
+ # Broadcast deletion via WebSocket
+ try:
+ await self._broadcast_series_deleted(key, series_name)
+ except Exception as exc:
+ logger.warning(
+ "Failed to broadcast series_deleted for %s: %s",
+ key, exc,
+ )
+
+ # --- Filesystem deletion ---
+ if delete_folder and folder_path:
+ # Resolve absolute path and validate it is within base directory
+ abs_folder = _os.path.abspath(folder_path)
+ base_dir = _os.path.abspath(self._directory)
+
+ if not is_safe_path(base_dir, abs_folder):
+ logger.warning(
+ "Blocked unsafe folder delete attempt: key=%s path=%s base=%s",
+ key, abs_folder, base_dir,
+ )
+ result.folder_error = (
+ f"Path '{abs_folder}' is outside the anime directory "
+ f"'{base_dir}' and will not be deleted."
+ )
+ result.success = False
+ elif not _os.path.isdir(abs_folder):
+ logger.warning(
+ "Delete folder skipped - path does not exist: key=%s path=%s",
+ key, abs_folder,
+ )
+ # Not an error; folder might never have existed
+ else:
+ try:
+ logger.info(
+ "Deleting series folder: key=%s path=%s",
+ key, abs_folder,
+ )
+ shutil.rmtree(abs_folder)
+ logger.info(
+ "Deleted series folder: key=%s path=%s",
+ key, abs_folder,
+ )
+ result.deleted_folder = True
+ except Exception as exc:
+ logger.error(
+ "Failed to delete series folder: key=%s path=%s error=%s",
+ key, abs_folder, str(exc),
+ )
+ result.folder_error = str(exc)
+ result.success = False
+
+ # --- Build message ---
+ parts = []
+ if result.deleted_from_database and not result.database_error:
+ parts.append("removed from database")
+ if result.deleted_folder and not result.folder_error:
+ parts.append("folder deleted from filesystem")
+ if result.database_error:
+ parts.append(f"database error: {result.database_error}")
+ if result.folder_error:
+ parts.append(f"folder error: {result.folder_error}")
+
+ if parts:
+ result.message = "; ".join(parts)
+ else:
+ result.message = "No action taken."
+
+ logger.info(
+ "Delete series completed: key=%s deleted_db=%s deleted_folder=%s",
+ key, result.deleted_from_database, result.deleted_folder,
+ )
+ return result
+
+ async def _broadcast_series_deleted(self, key: str, name: str) -> None:
+ """Broadcast series_deleted event via WebSocket."""
+ try:
+ await self._websocket_service.broadcast_series_deleted(
+ key=key,
+ name=name,
+ )
+ logger.info(
+ "series_deleted broadcast sent: key=%s name=%s",
+ key, name,
+ )
+ except Exception as exc:
+ logger.warning(
+ "Failed to broadcast series_deleted: key=%s error=%s",
+ key, str(exc),
+ )
+
def get_anime_service(series_app: SeriesApp) -> AnimeService:
"""Factory used for creating AnimeService with a SeriesApp instance."""
diff --git a/src/server/services/websocket_service.py b/src/server/services/websocket_service.py
index 877707d..9bd26ec 100644
--- a/src/server/services/websocket_service.py
+++ b/src/server/services/websocket_service.py
@@ -651,9 +651,9 @@ class WebSocketService:
async def shutdown(self, timeout: float = 5.0) -> None:
"""Gracefully shutdown the WebSocket service.
-
+
Broadcasts shutdown notification and closes all connections.
-
+
Args:
timeout: Maximum time (seconds) to wait for shutdown
"""
@@ -661,6 +661,34 @@ class WebSocketService:
await self._manager.shutdown(timeout=timeout)
logger.info("WebSocket service shutdown complete")
+ async def broadcast_series_deleted(
+ self,
+ key: str,
+ name: str,
+ ) -> None:
+ """Broadcast a series_deleted event to all connected clients.
+
+ Notifies clients that a series has been deleted so they can
+ remove it from their UI in real-time.
+
+ Args:
+ key: Series key that was deleted (primary identifier)
+ name: Series name for display purposes
+ """
+ message = {
+ "type": "series_deleted",
+ "timestamp": datetime.now(timezone.utc).isoformat(),
+ "data": {
+ "key": key,
+ "name": name,
+ },
+ }
+ await self._manager.broadcast(message)
+ logger.info(
+ "Broadcast series_deleted key=%s name=%s",
+ key, name,
+ )
+
# Singleton instance for application-wide access
_websocket_service: Optional[WebSocketService] = None
diff --git a/src/server/web/static/css/components/modals.css b/src/server/web/static/css/components/modals.css
index fbbdd8c..6eb046e 100644
--- a/src/server/web/static/css/components/modals.css
+++ b/src/server/web/static/css/components/modals.css
@@ -311,6 +311,24 @@
color: var(--color-text-secondary);
}
+.context-menu-item.danger {
+ color: var(--color-error, #e74c3c);
+}
+
+.context-menu-item.danger i {
+ color: var(--color-error, #e74c3c);
+}
+
+.context-menu-item.danger:hover {
+ background-color: rgba(231, 76, 60, 0.1);
+}
+
+.context-menu-divider {
+ height: 1px;
+ background: var(--color-border);
+ margin: var(--spacing-xs) 0;
+}
+
/* ============================================================================
NFO Diagnostics
============================================================================ */
@@ -415,4 +433,125 @@
flex-shrink: 0;
}
+/* ============================================================================
+ Delete Anime Modal
+ ============================================================================ */
+
+#delete-modal .modal-content {
+ max-width: 480px;
+}
+
+.delete-modal-section {
+ margin-bottom: var(--spacing-md);
+}
+
+.delete-modal-series-info {
+ background: var(--color-background-subtle);
+ border-radius: var(--border-radius);
+ padding: var(--spacing-md);
+ margin-bottom: var(--spacing-md);
+}
+
+.delete-modal-series-info p {
+ margin: 0 0 var(--spacing-xs) 0;
+ font-size: var(--font-size-body);
+}
+
+.delete-modal-series-info p:last-child {
+ margin-bottom: 0;
+}
+
+.delete-modal-series-name {
+ font-weight: 600;
+ color: var(--color-text-primary);
+}
+
+.delete-modal-series-key {
+ font-family: 'Consolas', 'Monaco', monospace;
+ font-size: var(--font-size-caption);
+ color: var(--color-text-tertiary);
+}
+
+.delete-modal-options {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-sm);
+ margin-bottom: var(--spacing-md);
+}
+
+.delete-modal-options label {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--spacing-sm);
+ cursor: pointer;
+ font-size: var(--font-size-body);
+}
+
+.delete-modal-options input[type="checkbox"] {
+ margin-top: 3px;
+ accent-color: var(--color-accent);
+}
+
+.delete-modal-warning {
+ color: var(--color-error, #e74c3c);
+ font-size: var(--font-size-caption);
+ margin-top: var(--spacing-xs);
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-xs);
+}
+
+.delete-modal-confirm-label {
+ font-size: var(--font-size-body);
+ color: var(--color-text-secondary);
+ margin-bottom: var(--spacing-xs);
+}
+
+.delete-modal-confirm-label strong {
+ color: var(--color-text-primary);
+}
+
+#delete-confirm-input {
+ width: 100%;
+ padding: var(--spacing-sm) var(--spacing-md);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius);
+ font-size: var(--font-size-body);
+ background: var(--color-background);
+ color: var(--color-text-primary);
+ transition: border-color 0.15s ease;
+}
+
+#delete-confirm-input:focus {
+ outline: none;
+ border-color: var(--color-accent);
+}
+
+#delete-confirm-input.matched {
+ border-color: var(--color-success);
+ background: rgba(46, 204, 113, 0.05);
+}
+
+#delete-error {
+ margin-top: var(--spacing-sm);
+ padding: var(--spacing-sm) var(--spacing-md);
+ background: rgba(231, 76, 60, 0.1);
+ border: 1px solid var(--color-error, #e74c3c);
+ border-radius: var(--border-radius);
+ color: var(--color-error, #e74c3c);
+ font-size: var(--font-size-caption);
+}
+
+.delete-modal-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: var(--spacing-sm);
+ margin-top: var(--spacing-lg);
+}
+
+#delete-confirm-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
diff --git a/src/server/web/static/js/index/app-init.js b/src/server/web/static/js/index/app-init.js
index 3387f02..c4a37a6 100644
--- a/src/server/web/static/js/index/app-init.js
+++ b/src/server/web/static/js/index/app-init.js
@@ -46,6 +46,7 @@ AniWorld.IndexApp = (function() {
AniWorld.ScanManager.init();
AniWorld.ConfigManager.init();
AniWorld.ContextMenu.init();
+ AniWorld.DeleteModal.init();
// Bind global events
bindGlobalEvents();
diff --git a/src/server/web/static/js/index/context-menu.js b/src/server/web/static/js/index/context-menu.js
index 9cb8682..f657d41 100644
--- a/src/server/web/static/js/index/context-menu.js
+++ b/src/server/web/static/js/index/context-menu.js
@@ -71,6 +71,11 @@ AniWorld.ContextMenu = (function() {
Anime Settings
+
+
+
+ Delete Anime
+
`;
document.body.appendChild(menuElement);
@@ -104,6 +109,17 @@ AniWorld.ContextMenu = (function() {
// Navigate to anime settings page with this series selected
window.location.href = '/anime/settings?key=' + encodeURIComponent(key);
});
+
+ // Delete Anime - opens the confirmation modal
+ menuElement.querySelector('[data-action="delete-anime"]').addEventListener('click', function() {
+ const key = currentSeriesKey;
+ hide();
+ if (AniWorld.DeleteModal && AniWorld.DeleteModal.show) {
+ AniWorld.DeleteModal.show(key);
+ } else {
+ console.error('[ContextMenu] DeleteModal not found on AniWorld');
+ }
+ });
}
/**
diff --git a/src/server/web/static/js/index/delete-modal.js b/src/server/web/static/js/index/delete-modal.js
new file mode 100644
index 0000000..91675ba
--- /dev/null
+++ b/src/server/web/static/js/index/delete-modal.js
@@ -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 =
+ '' +
+ '
' +
+ '
' +
+ '
Delete Anime
' +
+ '
' +
+ '
' +
+ '
' +
+ '' +
+ '' +
+ '
' +
+ '
' +
+ '' +
+ '' +
+ '
' +
+ ' ' +
+ 'This will permanently delete the folder and ALL files inside it!' +
+ '
' +
+ '
' +
+ '' +
+ '' +
+ '' +
+ '
' +
+ '' +
+ '
';
+ 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
+ };
+})();
diff --git a/src/server/web/static/js/index/series-manager.js b/src/server/web/static/js/index/series-manager.js
index 294b9f4..fadfeb6 100644
--- a/src/server/web/static/js/index/series-manager.js
+++ b/src/server/web/static/js/index/series-manager.js
@@ -549,6 +549,41 @@ AniWorld.SeriesManager = (function() {
renderSeries();
}
+ /**
+ * Remove a series from the local data arrays and re-render the grid.
+ * Called after a successful delete or when receiving series_deleted WS event.
+ * @param {string} key - Series key to remove
+ */
+ function removeSeries(key) {
+ if (!key) return;
+
+ var removedFromData = false;
+ var removedFromFiltered = false;
+
+ if (seriesData) {
+ var dataIdx = seriesData.findIndex(function(s) { return s.key === key; });
+ if (dataIdx >= 0) {
+ seriesData.splice(dataIdx, 1);
+ removedFromData = true;
+ }
+ }
+
+ if (filteredSeriesData) {
+ var filteredIdx = filteredSeriesData.findIndex(function(s) { return s.key === key; });
+ if (filteredIdx >= 0) {
+ filteredSeriesData.splice(filteredIdx, 1);
+ removedFromFiltered = true;
+ }
+ }
+
+ if (removedFromData || removedFromFiltered) {
+ console.info('[SeriesManager] Removed series from local state:', key);
+ renderSeries();
+ } else {
+ console.warn('[SeriesManager] Series not found in local state:', key);
+ }
+ }
+
// Public API
return {
init: init,
@@ -560,6 +595,7 @@ AniWorld.SeriesManager = (function() {
findByKey: findByKey,
updateSeriesLoadingStatus: updateSeriesLoadingStatus,
updateSingleSeries: updateSingleSeries,
- updateSeriesKey: updateSeriesKey
+ updateSeriesKey: updateSeriesKey,
+ removeSeries: removeSeries
};
})();
diff --git a/src/server/web/static/js/index/socket-handler.js b/src/server/web/static/js/index/socket-handler.js
index 96a294a..2cd03dd 100644
--- a/src/server/web/static/js/index/socket-handler.js
+++ b/src/server/web/static/js/index/socket-handler.js
@@ -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
socket.on(WS_EVENTS.DOWNLOAD_STARTED, function(data) {
isDownloading = true;
diff --git a/src/server/web/static/js/shared/constants.js b/src/server/web/static/js/shared/constants.js
index 1e36ffd..aeb3da6 100644
--- a/src/server/web/static/js/shared/constants.js
+++ b/src/server/web/static/js/shared/constants.js
@@ -104,6 +104,7 @@ AniWorld.Constants = (function() {
// Series events
SERIES_UPDATED: 'series_updated',
SERIES_LOADING_UPDATE: 'series_loading_update',
+ SERIES_DELETED: 'series_deleted',
// Scheduled scan events
SCHEDULED_RESCAN_STARTED: 'scheduled_rescan_started',
diff --git a/src/server/web/templates/index.html b/src/server/web/templates/index.html
index d90f95b..aaed480 100644
--- a/src/server/web/templates/index.html
+++ b/src/server/web/templates/index.html
@@ -693,6 +693,7 @@
+