# 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 ``` **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) ```