22 KiB
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
@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
@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
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):
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:
- Log start —
logger.info("Delete series requested key=%s db=%s folder=%s", key, delete_database, delete_folder) - Fetch series from DB to get
id,folder,name - If
delete_database=True: a. CallAnimeSeriesService.delete(db, series_id)b. Log success/failure c. Invalidate_cached_list_missingLRU cache d. Broadcastseries_deletedWebSocket event - If
delete_folder=True: a. Validate folder path withis_safe_path(self._directory, folder_path)— reject if outside base directory b. Useshutil.rmtree(folder_path)to delete the folder c. Log success/failure - Log completion —
logger.info("Delete series completed key=%s results=%s", key, result) - 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
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
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
@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_textmust be exactly"delete"(case-insensitive? No — exact match to be strict)- If
delete_folder=Truebut folder doesn't exist → log warning, continue with DB delete - If neither
delete_databasenordelete_folderis 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
// 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 populatedhide()— 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):
<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:
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:
if (AniWorld.DeleteModal) {
AniWorld.DeleteModal.init();
}
5. WebSocket Event
5.1 src/server/services/websocket_service.py — Add broadcast_series_deleted
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:
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
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
# 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):
{
"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 selected401 Unauthorized: Missing or invalid auth token404 Not Found: Series key does not exist500 Internal Server Error: Unexpected error
Events
WebSocket: series_deleted
Broadcast to all connected clients when a series is deleted.
Payload:
{
"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}:
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:
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
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
- Add
AnimeSeriesService.delete()andget_folder_path()tosrc/server/database/service.py - Add
DeleteSeriesResultPydantic model tosrc/server/models/anime.py - Add
DeleteSeriesRequestandDeleteSeriesResponsetosrc/server/models/anime.py - Add
delete_series()and_broadcast_series_deleted()tosrc/server/services/anime_service.py - Add
broadcast_series_deleted()tosrc/server/services/websocket_service.py - Add
DELETE /api/anime/{key}endpoint tosrc/server/api/anime.py - Add
SERIES_DELETEDconstant tosrc/server/web/static/js/shared/constants.js
Phase 2: Frontend
- Add CSS styles for context menu danger item, divider, delete modal
- Create
src/server/web/static/js/index/delete-modal.jswith full modal implementation - Update
src/server/web/static/js/index/context-menu.jsto add delete option - Update
src/server/web/static/js/index/app-init.jsto initialize modal - Update
src/server/web/static/js/index/socket-handler.jsto handleseries_deletedevent - Update
src/server/web/static/js/index/series-manager.js— addremoveSeries(key)method
Phase 3: Tests
- Add unit tests in
tests/unit/test_anime_service.py - Add API endpoint tests in
tests/api/test_anime_endpoints.py - Add frontend integration tests in
tests/frontend/ - Add security validation tests in
tests/security/test_input_validation.py
Phase 4: Documentation
- Create
docs/delete-anime-feature.md - Update
docs/README.mdor 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:
# 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)