Files
Aniworld/.hermes/plans/delete-anime-feature.md
2026-08-16 19:53:38 +02:00

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:

  1. Log startlogger.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 completionlogger.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

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_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

// In the show() function, add a divider and delete option:
menuElement.innerHTML = `
    <div class="context-menu-item" data-action="anime-settings">
        <i class="fa-solid fa-gear"></i>
        <span>Anime Settings</span>
    </div>
    <div class="context-menu-divider"></div>
    <div class="context-menu-item danger" data-action="delete-anime">
        <i class="fa-solid fa-trash"></i>
        <span>Delete Anime</span>
    </div>
`;

// Handler for delete-anime:
menuElement.querySelector('[data-action="delete-anime"]').addEventListener('click', function() {
    const key = currentSeriesKey;
    hide();
    AniWorld.DeleteModal && AniWorld.DeleteModal.show(key);
});

4.2 Create src/server/web/static/js/index/delete-modal.js

New module for the confirmation modal. Module structure mirrors anime-settings.js.

Features:

  • show(key) — opens modal with series info populated
  • hide() — closes and resets modal
  • Shows series name and key being deleted
  • Three checkboxes: ☐ Remove from database (default checked), ☐ Delete folder (default unchecked)
  • Confirmation text field: user must type exactly delete
  • Delete button: disabled until confirmation text matches
  • Error display area
  • Keyboard: Escape closes, Enter submits if valid

Modal HTML structure (inline in JS, no new HTML file needed):

<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 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:

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

  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

  1. Add CSS styles for context menu danger item, divider, delete modal
  2. Create src/server/web/static/js/index/delete-modal.js with full modal implementation
  3. Update src/server/web/static/js/index/context-menu.js to add delete option
  4. Update src/server/web/static/js/index/app-init.js to initialize modal
  5. Update src/server/web/static/js/index/socket-handler.js to handle series_deleted event
  6. Update src/server/web/static/js/index/series-manager.js — add removeSeries(key) method

Phase 3: Tests

  1. Add unit tests in tests/unit/test_anime_service.py
  2. Add API endpoint tests in tests/api/test_anime_endpoints.py
  3. Add frontend integration tests in tests/frontend/
  4. Add security validation tests in tests/security/test_input_validation.py

Phase 4: Documentation

  1. Create docs/delete-anime-feature.md
  2. 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:

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