Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db5f5edf2d | |||
| 35a733d36f | |||
| 62b4ca5ffc | |||
| 8887f9a7cb | |||
|
|
2299cf788b | ||
|
|
9f52ea03fb | ||
| ff526e08ea | |||
| b8892b4737 | |||
| 16977d6227 | |||
| 7538ea8608 | |||
| 7da7668787 | |||
| 0f872276dd | |||
| 818e621288 | |||
| 14f12e55e7 | |||
| 7df9a84ae9 | |||
| 4162684779 | |||
| a12bd41890 | |||
| a7ed2c999c |
661
.hermes/plans/delete-anime-feature.md
Normal file
661
.hermes/plans/delete-anime-feature.md
Normal file
@@ -0,0 +1,661 @@
|
||||
# Plan: Delete Anime Feature
|
||||
|
||||
## Feature Summary
|
||||
Add a right-click context menu option on anime series cards to delete an anime. Provides three deletion modes: database-only, folder-only, or both. Requires the user to type "delete" in a confirmation field.
|
||||
|
||||
---
|
||||
|
||||
## 1. Backend — Database Layer
|
||||
|
||||
### 1.1 `src/server/database/service.py` — Add `delete` method to `AnimeSeriesService`
|
||||
|
||||
```python
|
||||
@staticmethod
|
||||
async def delete(db: AsyncSession, series_id: int) -> bool:
|
||||
"""Hard-delete an anime series and all its episodes/queue items.
|
||||
|
||||
Uses cascade delete configured on the AnimeSeries model
|
||||
(cascade="all, delete-orphan" on episodes and download_items).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
series_id: Primary key (id) of the AnimeSeries to delete
|
||||
|
||||
Returns:
|
||||
True if a row was deleted, False if not found
|
||||
|
||||
Raises:
|
||||
AnimeServiceError: On database errors
|
||||
"""
|
||||
```
|
||||
|
||||
**Implementation notes:**
|
||||
- Uses `select(delete(...).where(...))` pattern matching existing codebase style
|
||||
- Logs series key + name before deletion for audit trail
|
||||
- Returns `True`/`False` (not an exception) when series not found — caller decides response code
|
||||
- Wraps in try/except, logs error, re-raises as `AnimeServiceError`
|
||||
|
||||
### 1.2 `src/server/database/service.py` — Add `get_folder_path` helper to `AnimeSeriesService`
|
||||
|
||||
```python
|
||||
@staticmethod
|
||||
async def get_folder_path(db: AsyncSession, series_key: str) -> str | None:
|
||||
"""Get the filesystem folder path for a series by its key.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
series_key: Provider key (e.g. "attack-on-titan")
|
||||
|
||||
Returns:
|
||||
Folder path string, or None if series not found
|
||||
"""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Backend — AnimeService
|
||||
|
||||
### 2.1 `src/server/services/anime_service.py` — Add `delete_series` method
|
||||
|
||||
```python
|
||||
async def delete_series(
|
||||
self,
|
||||
key: str,
|
||||
delete_database: bool = True,
|
||||
delete_folder: bool = False,
|
||||
) -> DeleteSeriesResult:
|
||||
"""Delete an anime series from DB, filesystem, or both.
|
||||
|
||||
Args:
|
||||
key: Series key (primary identifier)
|
||||
delete_database: If True, remove from database (default True)
|
||||
delete_folder: If True, remove folder from filesystem (default False)
|
||||
|
||||
Returns:
|
||||
DeleteSeriesResult with success status, what was deleted, errors
|
||||
|
||||
Raises:
|
||||
AnimeServiceError: On critical failures
|
||||
"""
|
||||
```
|
||||
|
||||
**`DeleteSeriesResult` Pydantic model (add to `src/server/models/anime.py`):**
|
||||
|
||||
```python
|
||||
class DeleteSeriesResult(BaseModel):
|
||||
success: bool
|
||||
key: str
|
||||
name: str
|
||||
deleted_from_database: bool
|
||||
deleted_folder: bool
|
||||
folder_path: str | None
|
||||
database_error: str | None
|
||||
folder_error: str | None
|
||||
message: str
|
||||
```
|
||||
|
||||
**Step-by-step inside `delete_series`:**
|
||||
|
||||
1. **Log start** — `logger.info("Delete series requested key=%s db=%s folder=%s", key, delete_database, delete_folder)`
|
||||
2. **Fetch series from DB** to get `id`, `folder`, `name`
|
||||
3. **If `delete_database=True`**:
|
||||
a. Call `AnimeSeriesService.delete(db, series_id)`
|
||||
b. Log success/failure
|
||||
c. Invalidate `_cached_list_missing` LRU cache
|
||||
d. Broadcast `series_deleted` WebSocket event
|
||||
4. **If `delete_folder=True`**:
|
||||
a. Validate folder path with `is_safe_path(self._directory, folder_path)` — reject if outside base directory
|
||||
b. Use `shutil.rmtree(folder_path)` to delete the folder
|
||||
c. Log success/failure
|
||||
5. **Log completion** — `logger.info("Delete series completed key=%s results=%s", key, result)`
|
||||
6. Return `DeleteSeriesResult`
|
||||
|
||||
**Error handling:**
|
||||
- DB errors during folder delete → log but don't fail the whole operation
|
||||
- Folder delete errors → log, attach to result, don't rollback DB delete
|
||||
- Series not found → return `DeleteSeriesResult(success=False, message="Series not found")`
|
||||
|
||||
### 2.2 `src/server/services/anime_service.py` — Add `broadcast_series_deleted`
|
||||
|
||||
```python
|
||||
async def _broadcast_series_deleted(self, key: str, name: str) -> None:
|
||||
"""Broadcast series_deleted event via WebSocket."""
|
||||
```
|
||||
|
||||
Mirrors existing `_broadcast_series_updated` pattern.
|
||||
|
||||
---
|
||||
|
||||
## 3. Backend — API Layer
|
||||
|
||||
### 3.1 `src/server/api/anime.py` — Add request/response models
|
||||
|
||||
```python
|
||||
class DeleteSeriesRequest(BaseModel):
|
||||
"""Request payload for DELETE /api/anime/{key}."""
|
||||
delete_database: bool = Field(
|
||||
default=True,
|
||||
description="Whether to remove the series from the database"
|
||||
)
|
||||
delete_folder: bool = Field(
|
||||
default=False,
|
||||
description="Whether to delete the series folder from filesystem"
|
||||
)
|
||||
confirm_text: str = Field(
|
||||
...,
|
||||
description="Must be exactly 'delete' to confirm"
|
||||
)
|
||||
|
||||
class DeleteSeriesResponse(BaseModel):
|
||||
"""Response payload for DELETE /api/anime/{key}."""
|
||||
success: bool
|
||||
key: str
|
||||
name: str
|
||||
deleted_from_database: bool
|
||||
deleted_folder: bool
|
||||
folder_path: str | None
|
||||
database_error: str | None
|
||||
folder_error: str | None
|
||||
message: str
|
||||
```
|
||||
|
||||
### 3.2 `src/server/api/anime.py` — Add DELETE endpoint
|
||||
|
||||
```python
|
||||
@router.delete("/{key}", response_model=DeleteSeriesResponse)
|
||||
async def delete_anime(
|
||||
key: str,
|
||||
request: DeleteSeriesRequest,
|
||||
_auth: dict = Depends(require_auth),
|
||||
anime_service: AnimeService = Depends(get_anime_service),
|
||||
) -> DeleteSeriesResponse:
|
||||
"""Delete an anime series from database, filesystem, or both.
|
||||
|
||||
Requires typing 'delete' in the confirm_text field to prevent accidents.
|
||||
|
||||
Args:
|
||||
key: Series key (from URL path)
|
||||
request: DeleteSeriesRequest with options and confirmation
|
||||
_auth: Ensures the caller is authenticated
|
||||
anime_service: AnimeService instance
|
||||
|
||||
Returns:
|
||||
DeleteSeriesResponse with outcome details
|
||||
|
||||
Raises:
|
||||
HTTPException(400): If confirm_text is not exactly 'delete'
|
||||
HTTPException(404): If series not found
|
||||
HTTPException(500): On unexpected errors
|
||||
"""
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
- `confirm_text` must be exactly `"delete"` (case-insensitive? No — exact match to be strict)
|
||||
- If `delete_folder=True` but folder doesn't exist → log warning, continue with DB delete
|
||||
- If neither `delete_database` nor `delete_folder` is True → return 400
|
||||
|
||||
### 3.3 Update constants — No changes needed to `constants.js` for the endpoint path
|
||||
|
||||
The existing `API.ANIME_LIST` pattern is `/api/anime` — DELETE `/api/anime/{key}` follows REST conventions.
|
||||
|
||||
---
|
||||
|
||||
## 4. Frontend — Context Menu
|
||||
|
||||
### 4.1 `src/server/web/static/js/index/context-menu.js` — Add delete option
|
||||
|
||||
```javascript
|
||||
// In the show() function, add a divider and delete option:
|
||||
menuElement.innerHTML = `
|
||||
<div class="context-menu-item" data-action="anime-settings">
|
||||
<i class="fa-solid fa-gear"></i>
|
||||
<span>Anime Settings</span>
|
||||
</div>
|
||||
<div class="context-menu-divider"></div>
|
||||
<div class="context-menu-item danger" data-action="delete-anime">
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
<span>Delete Anime</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Handler for delete-anime:
|
||||
menuElement.querySelector('[data-action="delete-anime"]').addEventListener('click', function() {
|
||||
const key = currentSeriesKey;
|
||||
hide();
|
||||
AniWorld.DeleteModal && AniWorld.DeleteModal.show(key);
|
||||
});
|
||||
```
|
||||
|
||||
### 4.2 Create `src/server/web/static/js/index/delete-modal.js`
|
||||
|
||||
New module for the confirmation modal. Module structure mirrors `anime-settings.js`.
|
||||
|
||||
**Features:**
|
||||
- `show(key)` — opens modal with series info populated
|
||||
- `hide()` — closes and resets modal
|
||||
- Shows series name and key being deleted
|
||||
- Three checkboxes: `☐ Remove from database` (default checked), `☐ Delete folder` (default unchecked)
|
||||
- Confirmation text field: user must type exactly `delete`
|
||||
- Delete button: disabled until confirmation text matches
|
||||
- Error display area
|
||||
- Keyboard: Escape closes, Enter submits if valid
|
||||
|
||||
**Modal HTML structure** (inline in JS, no new HTML file needed):
|
||||
|
||||
```html
|
||||
<div id="delete-modal" class="modal hidden">
|
||||
<div class="modal-backdrop"></div>
|
||||
<div class="modal-content">
|
||||
<h2>Delete Anime</h2>
|
||||
<p id="delete-modal-series-name"></p>
|
||||
<p id="delete-modal-series-key"></p>
|
||||
|
||||
<label>
|
||||
<input type="checkbox" id="delete-db-checkbox" checked>
|
||||
Remove from database (recommended)
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" id="delete-folder-checkbox">
|
||||
Delete folder from filesystem
|
||||
</label>
|
||||
<p class="warning">This will permanently delete the folder and all its files!</p>
|
||||
|
||||
<label for="delete-confirm-input">
|
||||
Type <strong>delete</strong> to confirm:
|
||||
</label>
|
||||
<input type="text" id="delete-confirm-input" placeholder="delete">
|
||||
|
||||
<div id="delete-error" class="error-message hidden"></div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button id="delete-cancel-btn">Cancel</button>
|
||||
<button id="delete-confirm-btn" disabled>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**CSS** — add to existing CSS files:
|
||||
- `.context-menu-item.danger { color: var(--color-danger, #dc3545); }`
|
||||
- `.context-menu-divider { height: 1px; background: var(--color-border); margin: 4px 0; }`
|
||||
- `.warning { color: var(--color-warning, #ffc107); font-size: 0.875em; }`
|
||||
- Modal styles (`.modal`, `.modal-backdrop`, `.modal-content`) — reuse existing modal CSS if present, or add new
|
||||
|
||||
**API call on confirm:**
|
||||
```javascript
|
||||
const response = await AniWorld.ApiClient.request(`/api/anime/${encodeURIComponent(key)}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
delete_database: document.getElementById('delete-db-checkbox').checked,
|
||||
delete_folder: document.getElementById('delete-folder-checkbox').checked,
|
||||
confirm_text: document.getElementById('delete-confirm-input').value
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
**On success:**
|
||||
- Show success toast
|
||||
- Close modal
|
||||
- Reload series grid (or remove card from DOM directly)
|
||||
|
||||
**On error:**
|
||||
- Show error message in modal
|
||||
|
||||
### 4.3 `src/server/web/static/js/index/app-init.js` — Initialize delete modal
|
||||
|
||||
Add initialization call:
|
||||
```javascript
|
||||
if (AniWorld.DeleteModal) {
|
||||
AniWorld.DeleteModal.init();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. WebSocket Event
|
||||
|
||||
### 5.1 `src/server/services/websocket_service.py` — Add `broadcast_series_deleted`
|
||||
|
||||
```python
|
||||
async def broadcast_series_deleted(self, key: str, name: str) -> None:
|
||||
"""Broadcast a series_deleted event.
|
||||
|
||||
Args:
|
||||
key: Series key that was deleted
|
||||
name: Series name for display
|
||||
"""
|
||||
```
|
||||
|
||||
### 5.2 `src/server/web/static/js/index/socket-handler.js` — Handle `series_deleted`
|
||||
|
||||
Handle the new WebSocket event to remove the deleted card from the UI in real-time:
|
||||
|
||||
```javascript
|
||||
case AniWorld.Constants.WS_EVENTS.SERIES_DELETED:
|
||||
if (AniWorld.SeriesManager) {
|
||||
AniWorld.SeriesManager.removeSeries(data.key);
|
||||
}
|
||||
break;
|
||||
```
|
||||
|
||||
### 5.3 `src/server/web/static/js/shared/constants.js` — Add event constant
|
||||
|
||||
```javascript
|
||||
SERIES_DELETED: 'series_deleted',
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Logging
|
||||
|
||||
### 6.1 Backend logging points (all use Python `logging.getLogger(__name__)`):
|
||||
|
||||
| Event | Level | Message format |
|
||||
|-------|-------|----------------|
|
||||
| Delete requested | `info` | `"Delete series requested: key=%s delete_database=%s delete_folder=%s"` |
|
||||
| Series not found | `warning` | `"Delete series failed - not found: key=%s"` |
|
||||
| DB delete success | `info` | `"Deleted series from database: key=%s name=%s id=%d"` |
|
||||
| DB delete failure | `error` | `"Failed to delete series from database: key=%s error=%s"` |
|
||||
| Folder delete start | `info` | `"Deleting series folder: key=%s path=%s"` |
|
||||
| Folder delete success | `info` | `"Deleted series folder: key=%s path=%s"` |
|
||||
| Folder delete failure | `error` | `"Failed to delete series folder: key=%s path=%s error=%s"` |
|
||||
| Path traversal blocked | `warning` | `"Blocked unsafe folder delete attempt: key=%s path=%s base=%s"` |
|
||||
| Delete completed | `info` | `"Delete series completed: key=%s db=%s folder=%s"` |
|
||||
|
||||
### 6.2 Frontend logging points (console.log / console.error):
|
||||
|
||||
| Event | Level |
|
||||
|-------|-------|
|
||||
| Delete modal opened | `console.info` |
|
||||
| Delete API call initiated | `console.info` |
|
||||
| Delete success | `console.info` |
|
||||
| Delete API error | `console.error` |
|
||||
| Validation failure (confirm_text) | `console.warn` |
|
||||
|
||||
---
|
||||
|
||||
## 7. Documentation
|
||||
|
||||
### 7.1 Create `docs/delete-anime-feature.md`
|
||||
|
||||
```markdown
|
||||
# Delete Anime Feature
|
||||
|
||||
## Overview
|
||||
Allows authenticated users to delete an anime series from the database,
|
||||
filesystem, or both via the right-click context menu on series cards.
|
||||
|
||||
## Safety Mechanisms
|
||||
|
||||
### Confirmation Required
|
||||
Users must type exactly `delete` in a confirmation field before deletion
|
||||
proceeds. This prevents accidental clicks.
|
||||
|
||||
### Selective Deletion
|
||||
Two independent options:
|
||||
- **Remove from database**: Removes the series and all its episodes/queue
|
||||
items from the SQLite database. The folder on disk is preserved.
|
||||
- **Delete folder**: Removes the series folder and ALL files inside it
|
||||
from the filesystem. This cannot be undone.
|
||||
|
||||
### Path Traversal Protection
|
||||
Folder deletion validates the target path is within the configured
|
||||
`directory_to_search` base directory before attempting deletion.
|
||||
|
||||
## API
|
||||
|
||||
### DELETE /api/anime/{key}
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{
|
||||
"delete_database": true,
|
||||
"delete_folder": false,
|
||||
"confirm_text": "delete"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"key": "attack-on-titan",
|
||||
"name": "Attack on Titan",
|
||||
"deleted_from_database": true,
|
||||
"deleted_folder": false,
|
||||
"folder_path": "/anime/Attack on Titan (2013)",
|
||||
"database_error": null,
|
||||
"folder_error": null,
|
||||
"message": "Series deleted from database successfully."
|
||||
}
|
||||
```
|
||||
|
||||
**Error responses:**
|
||||
- `400 Bad Request`: confirm_text != "delete", or neither delete option selected
|
||||
- `401 Unauthorized`: Missing or invalid auth token
|
||||
- `404 Not Found`: Series key does not exist
|
||||
- `500 Internal Server Error`: Unexpected error
|
||||
|
||||
## Events
|
||||
|
||||
### WebSocket: series_deleted
|
||||
Broadcast to all connected clients when a series is deleted.
|
||||
|
||||
**Payload:**
|
||||
```json
|
||||
{
|
||||
"event": "series_deleted",
|
||||
"key": "attack-on-titan",
|
||||
"name": "Attack on Titan"
|
||||
}
|
||||
```
|
||||
|
||||
## Permissions
|
||||
Requires authentication. Only authenticated users can delete anime.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Tests
|
||||
|
||||
### 8.1 Unit Tests — `tests/unit/test_anime_service.py`
|
||||
|
||||
Add new test class:
|
||||
|
||||
```python
|
||||
class TestDeleteSeries:
|
||||
"""Test delete_series operation."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_database_only_success(self, anime_service, mock_series_app):
|
||||
"""Test deleting a series from database only."""
|
||||
# Setup: create mock series in DB
|
||||
# Assert: delete_database=True, delete_folder=False
|
||||
# Assert: cache invalidated
|
||||
# Assert: WebSocket broadcast called
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_folder_only(self, anime_service, mock_series_app, tmp_path):
|
||||
"""Test deleting only the folder."""
|
||||
# Setup: create series with folder on disk
|
||||
# Assert: folder deleted from filesystem
|
||||
# Assert: DB record still exists
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_both(self, anime_service, mock_series_app, tmp_path):
|
||||
"""Test deleting both DB record and folder."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_nonexistent(self, anime_service):
|
||||
"""Test deleting a series that doesn't exist returns success=False."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_folder_path_traversal_blocked(self, anime_service):
|
||||
"""Test that path traversal attempts are blocked and logged."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_folder_not_found_continues(self, anime_service, mock_series_app):
|
||||
"""Test that missing folder doesn't fail the DB delete."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_invalid_confirm_text(self, anime_service):
|
||||
"""Test API rejects non-matching confirm_text."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_no_options_selected(self, anime_service):
|
||||
"""Test API rejects when neither option is True."""
|
||||
```
|
||||
|
||||
### 8.2 API Endpoint Tests — `tests/api/test_anime_endpoints.py`
|
||||
|
||||
Add tests for `DELETE /api/anime/{key}`:
|
||||
|
||||
```python
|
||||
class TestDeleteAnimeEndpoints:
|
||||
"""Tests for DELETE /api/anime/{key}."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_requires_auth(self, client):
|
||||
"""Test that unauthenticated requests are rejected."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_requires_confirm_text(self, client, auth_headers, test_series):
|
||||
"""Test that missing confirm_text returns 400."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_wrong_confirm_text(self, client, auth_headers, test_series):
|
||||
"""Test that wrong confirm_text returns 400."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_database_only(self, client, auth_headers, test_series):
|
||||
"""Test database-only deletion."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_folder_only(self, client, auth_headers, test_series):
|
||||
"""Test folder-only deletion."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_both(self, client, auth_headers, test_series):
|
||||
"""Test deletion of both DB and folder."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_nonexistent_returns_404(self, client, auth_headers):
|
||||
"""Test deleting non-existent series."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_folder_outside_base_rejected(self, client, auth_headers, test_series):
|
||||
"""Test that path traversal is blocked."""
|
||||
```
|
||||
|
||||
### 8.3 Frontend Tests — `tests/frontend/test_existing_ui_integration.py`
|
||||
|
||||
Add integration tests for delete modal:
|
||||
|
||||
```javascript
|
||||
describe('Delete Anime Modal', () => {
|
||||
it('should open on context menu delete click');
|
||||
it('should require typing delete to enable button');
|
||||
it('should call DELETE API on confirm');
|
||||
it('should show error on API failure');
|
||||
it('should close on cancel');
|
||||
it('should close on Escape key');
|
||||
it('should disable confirm until text matches');
|
||||
});
|
||||
```
|
||||
|
||||
### 8.4 Security Tests — `tests/security/test_input_validation.py`
|
||||
|
||||
```python
|
||||
class TestDeleteValidation:
|
||||
"""Security tests for delete endpoint input validation."""
|
||||
|
||||
def test_path_traversal_in_folder_delete(self, client, auth_headers):
|
||||
"""Ensure folder delete cannot escape base directory."""
|
||||
|
||||
def test_missing_confirm_text_rejected(self, client, auth_headers):
|
||||
"""Ensure confirm_text is validated."""
|
||||
|
||||
def test_empty_key_rejected(self, client, auth_headers):
|
||||
"""Ensure empty series key is rejected."""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Step-by-Step Implementation Order
|
||||
|
||||
### Phase 1: Backend Core
|
||||
1. **Add `AnimeSeriesService.delete()` and `get_folder_path()`** to `src/server/database/service.py`
|
||||
2. **Add `DeleteSeriesResult` Pydantic model** to `src/server/models/anime.py`
|
||||
3. **Add `DeleteSeriesRequest` and `DeleteSeriesResponse`** to `src/server/models/anime.py`
|
||||
4. **Add `delete_series()` and `_broadcast_series_deleted()`** to `src/server/services/anime_service.py`
|
||||
5. **Add `broadcast_series_deleted()`** to `src/server/services/websocket_service.py`
|
||||
6. **Add `DELETE /api/anime/{key}` endpoint** to `src/server/api/anime.py`
|
||||
7. **Add `SERIES_DELETED` constant** to `src/server/web/static/js/shared/constants.js`
|
||||
|
||||
### Phase 2: Frontend
|
||||
8. **Add CSS styles** for context menu danger item, divider, delete modal
|
||||
9. **Create `src/server/web/static/js/index/delete-modal.js`** with full modal implementation
|
||||
10. **Update `src/server/web/static/js/index/context-menu.js`** to add delete option
|
||||
11. **Update `src/server/web/static/js/index/app-init.js`** to initialize modal
|
||||
12. **Update `src/server/web/static/js/index/socket-handler.js`** to handle `series_deleted` event
|
||||
13. **Update `src/server/web/static/js/index/series-manager.js`** — add `removeSeries(key)` method
|
||||
|
||||
### Phase 3: Tests
|
||||
14. **Add unit tests** in `tests/unit/test_anime_service.py`
|
||||
15. **Add API endpoint tests** in `tests/api/test_anime_endpoints.py`
|
||||
16. **Add frontend integration tests** in `tests/frontend/`
|
||||
17. **Add security validation tests** in `tests/security/test_input_validation.py`
|
||||
|
||||
### Phase 4: Documentation
|
||||
18. **Create `docs/delete-anime-feature.md`**
|
||||
19. **Update `docs/README.md`** or main docs index if it exists
|
||||
|
||||
---
|
||||
|
||||
## Key Files to Modify
|
||||
|
||||
| File | Change Type |
|
||||
|------|-------------|
|
||||
| `src/server/database/service.py` | Add 2 methods |
|
||||
| `src/server/models/anime.py` | Add 2 Pydantic models |
|
||||
| `src/server/services/anime_service.py` | Add 2 methods |
|
||||
| `src/server/services/websocket_service.py` | Add 1 method |
|
||||
| `src/server/api/anime.py` | Add 2 models + 1 endpoint |
|
||||
| `src/server/web/static/js/shared/constants.js` | Add 1 constant |
|
||||
| `src/server/web/static/js/index/context-menu.js` | Add menu item + handler |
|
||||
| `src/server/web/static/js/index/delete-modal.js` | **New file** |
|
||||
| `src/server/web/static/js/index/app-init.js` | Add init call |
|
||||
| `src/server/web/static/js/index/socket-handler.js` | Handle new event |
|
||||
| `src/server/web/static/js/index/series-manager.js` | Add `removeSeries()` |
|
||||
| CSS files | Add modal/context menu styles |
|
||||
| `tests/unit/test_anime_service.py` | Add test class |
|
||||
| `tests/api/test_anime_endpoints.py` | Add endpoint tests |
|
||||
| `tests/frontend/test_existing_ui_integration.py` | Add frontend tests |
|
||||
| `tests/security/test_input_validation.py` | Add security tests |
|
||||
| `docs/delete-anime-feature.md` | **New file** |
|
||||
|
||||
---
|
||||
|
||||
## Verification Steps
|
||||
|
||||
After implementation, verify by running:
|
||||
```bash
|
||||
# Backend tests
|
||||
pytest tests/unit/test_anime_service.py::TestDeleteSeries -v
|
||||
pytest tests/api/test_anime_endpoints.py::TestDeleteAnimeEndpoints -v
|
||||
pytest tests/security/test_input_validation.py::TestDeleteValidation -v
|
||||
|
||||
# Frontend tests
|
||||
npm run test
|
||||
|
||||
# Manual verification:
|
||||
# 1. Right-click a series card → "Delete Anime" option appears
|
||||
# 2. Clicking it opens the confirmation modal
|
||||
# 3. Without typing "delete", the button is disabled
|
||||
# 4. Typing "delete" enables the button
|
||||
# 5. Selecting "Remove from database" and confirming deletes the series
|
||||
# 6. Selecting "Delete folder" deletes the folder from disk
|
||||
# 7. Selecting both deletes both DB record and folder
|
||||
# 8. After deletion, the card is removed from the grid in real-time (WebSocket)
|
||||
```
|
||||
@@ -1 +1 @@
|
||||
v1.5.5
|
||||
v1.5.11
|
||||
|
||||
@@ -59,9 +59,26 @@ else
|
||||
err "Neither podman nor docker is installed."
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# -------------------------------------------------------------------
|
||||
# Refuse to run from inside a confined snap sandbox (e.g. the VS Code
|
||||
# integrated terminal). When the script is launched from such a
|
||||
# sandbox, $HOME points under /home/$USER/snap/code/<rev>/ and podman
|
||||
# stores its DB under that path. If a snap revision bump happens mid
|
||||
# session, the next podman invocation finds a stale static-dir pointer
|
||||
# and aborts with a confusing "database static dir ... does not match"
|
||||
# error. Re-run the script from a regular host shell instead.
|
||||
# -------------------------------------------------------------------
|
||||
case "${HOME:-}" in
|
||||
/home/*/snap/*)
|
||||
err "Refusing to run inside a snap-sandboxed HOME (${HOME}). \
|
||||
Re-run from a regular host terminal (e.g. gnome-terminal, konsole) \
|
||||
so podman uses a stable storage path."
|
||||
;;
|
||||
esac
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Pre-flight checks
|
||||
# ---------------------------------------------------------------------------
|
||||
# -------------------------------------------------------------------
|
||||
echo "============================================"
|
||||
echo " AniWorld — Build & Push"
|
||||
echo " Engine : ${ENGINE}"
|
||||
|
||||
@@ -85,7 +85,20 @@ echo "Version file updated → ${VERSION_FILE}"
|
||||
FRONT_VERSION="${NEW_TAG#v}"
|
||||
FRONT_PKG="${SCRIPT_DIR}/../package.json"
|
||||
if [[ -f "${FRONT_PKG}" ]]; then
|
||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${FRONT_VERSION}\"/" "${FRONT_PKG}"
|
||||
# Use a python one-liner for portable, safe JSON editing. The previous
|
||||
# `sed -i` used single-quoted bash strings, which prevented
|
||||
# ${FRONT_VERSION} from being interpolated and silently rewrote the file
|
||||
# to the literal string "${FRONT_VERSION}".
|
||||
python3 - "$FRONT_PKG" "$FRONT_VERSION" <<'PY'
|
||||
import json, sys
|
||||
path, new_version = sys.argv[1], sys.argv[2]
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
data["version"] = new_version
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
json.dump(data, fh, indent=2)
|
||||
fh.write("\n")
|
||||
PY
|
||||
echo "package.json version updated → ${FRONT_VERSION}"
|
||||
else
|
||||
echo "Warning: package.json not found, skipping package.json version sync" >&2
|
||||
@@ -94,13 +107,63 @@ fi
|
||||
# Keep root pyproject.toml in sync.
|
||||
BACKEND_PYPROJECT="${SCRIPT_DIR}/../pyproject.toml"
|
||||
if [[ -f "${BACKEND_PYPROJECT}" ]]; then
|
||||
# Update version under [project] section if present
|
||||
if grep -q '^\[project\]' "${BACKEND_PYPROJECT}"; then
|
||||
sed -i "/^\[project\]/,/^\[/ s/^version = \".*\"/version = \"${FRONT_VERSION}\"/" "${BACKEND_PYPROJECT}"
|
||||
else
|
||||
sed -i "s/^version = \".*\"/version = \"${FRONT_VERSION}\"/" "${BACKEND_PYPROJECT}"
|
||||
fi
|
||||
# Use python instead of sed: the previous `sed -i` used double-quoted
|
||||
# patterns whose `&` and `\` characters would have to be escaped, and
|
||||
# more importantly it could silently do nothing if the [project] section
|
||||
# was missing. python reads/writes the file as a string, preserving
|
||||
# the existing format, and reports whether anything changed.
|
||||
if FRONT_VERSION="$FRONT_VERSION" BACKEND_PYPROJECT="$BACKEND_PYPROJECT" python3 <<'PY'
|
||||
import os, re, sys
|
||||
|
||||
path = os.environ["BACKEND_PYPROJECT"]
|
||||
new_version = os.environ["FRONT_VERSION"]
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
text = fh.read()
|
||||
|
||||
# If there is a [project] table, update only the `version = "..."` line
|
||||
# inside it; otherwise update the first top-level `version = "..."` line.
|
||||
project_match = re.search(r"^\[project\]\s*$", text, re.MULTILINE)
|
||||
if project_match:
|
||||
start = project_match.end()
|
||||
end = re.search(r"^\[", text[start:], re.MULTILINE)
|
||||
section_end = start + end.start() if end else len(text)
|
||||
section = text[start:section_end]
|
||||
new_section, n = re.subn(
|
||||
r'^version = ".*"$',
|
||||
f'version = "{new_version}"',
|
||||
section,
|
||||
count=1,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
if n == 0:
|
||||
print(
|
||||
f"Warning: no `version = ...` line found under [project] in {path}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
text = text[:start] + new_section + text[section_end:]
|
||||
else:
|
||||
new_text, n = re.subn(
|
||||
r'^version = ".*"$',
|
||||
f'version = "{new_version}"',
|
||||
text,
|
||||
count=1,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
if n == 0:
|
||||
print(
|
||||
f"Warning: no `version = ...` line found in {path}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
text = new_text
|
||||
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write(text)
|
||||
PY
|
||||
then
|
||||
echo "pyproject.toml version updated → ${FRONT_VERSION}"
|
||||
fi
|
||||
else
|
||||
echo "Warning: pyproject.toml not found, skipping pyproject.toml version sync" >&2
|
||||
fi
|
||||
|
||||
90
Docs/API.md
90
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
|
||||
@@ -991,34 +1056,39 @@ Update existing NFO file with fresh TMDB data.
|
||||
|
||||
Source: [src/server/api/nfo.py](../src/server/api/nfo.py#L243-L325)
|
||||
|
||||
### GET /api/nfo/{serie_id}/content
|
||||
### GET /api/nfo/{key}/content
|
||||
|
||||
Get NFO file XML content for a series.
|
||||
Read the raw `tvshow.nfo` XML for a series. Used by the Anime Settings
|
||||
page's "View NFO XML" button to render the on-disk NFO in a `<pre>` block.
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
**Path Parameters:**
|
||||
|
||||
- `serie_id` (string): Series identifier
|
||||
- `key` (string): Series unique key (e.g., `attack-on-titan`)
|
||||
|
||||
**Response (200 OK):**
|
||||
**Response (200 OK):** [`NfoContentResponse`](../src/server/models/nfo.py)
|
||||
|
||||
```json
|
||||
{
|
||||
"serie_id": "one-piece",
|
||||
"serie_folder": "One Piece (1999)",
|
||||
"key": "attack-on-titan",
|
||||
"folder": "Attack on Titan (2013)",
|
||||
"content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<tvshow>...</tvshow>",
|
||||
"file_size": 2048,
|
||||
"last_modified": "2026-01-15T10:30:00"
|
||||
"last_modified": "2026-09-04T17:42:13"
|
||||
}
|
||||
```
|
||||
|
||||
**Errors:**
|
||||
|
||||
- `401 Unauthorized` - Not authenticated
|
||||
- `404 Not Found` - Series or NFO not found
|
||||
- `400 Bad Request` — Series has no folder configured.
|
||||
- `401 Unauthorized` — Not authenticated.
|
||||
- `404 Not Found` — Series with the given key does not exist, or its
|
||||
`tvshow.nfo` is missing on disk.
|
||||
- `500 Internal Server Error` — Failed to read the NFO file from disk.
|
||||
- `503 Service Unavailable` — `settings.anime_directory` is not configured.
|
||||
|
||||
Source: [src/server/api/nfo.py](../src/server/api/nfo.py#L328-L397)
|
||||
Source: [src/server/api/nfo.py](../src/server/api/nfo.py#L411-L477)
|
||||
|
||||
### GET /api/nfo/{serie_id}/media/status
|
||||
|
||||
|
||||
@@ -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=<series>`. The new page lets the user view and
|
||||
|
||||
179
Docs/DELETE_ANIME_FEATURE.md
Normal file
179
Docs/DELETE_ANIME_FEATURE.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# Delete Anime Feature
|
||||
|
||||
## Overview
|
||||
|
||||
The Delete Anime feature allows authenticated users to remove an anime series from the Aniworld library. It supports three deletion modes: **database only**, **folder only**, or **both**. A mandatory typed-confirmation (`delete`) prevents accidental deletions.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
### How to Delete an Anime
|
||||
|
||||
1. **Right-click** on any anime series card in the library grid.
|
||||
2. Select **"Delete Anime"** from the context menu.
|
||||
3. A confirmation modal appears with two options:
|
||||
- ☑️ **Remove from database** (recommended) — removes series and episodes from SQLite
|
||||
- ☐ **Delete folder from filesystem** — deletes the folder and all files inside
|
||||
4. **Type `delete`** in the confirmation text field to enable the Delete button.
|
||||
5. Click **Delete** to proceed.
|
||||
|
||||
### What Gets Deleted
|
||||
|
||||
| Option | Effect |
|
||||
|--------|--------|
|
||||
| Database only | Series, episodes, and queue entries removed from SQLite. Folder on disk is preserved. Downloaded episode files remain. |
|
||||
| Folder only | Entire folder and all files inside deleted from filesystem. Database record preserved with `is_downloaded=True`. |
|
||||
| Both | Full removal: database record deleted AND folder/files deleted from disk. |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend Components
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `src/server/api/anime.py` | `DELETE /api/anime/{key}` endpoint |
|
||||
| `src/server/services/anime_service.py` | `AnimeService.delete_series()` orchestrator |
|
||||
| `src/server/services/websocket_service.py` | `broadcast_series_deleted()` for real-time UI updates |
|
||||
| `src/server/database/service.py` | `AnimeSeriesService.get_folder_path()` + existing `delete()` |
|
||||
| `src/server/models/anime.py` | `DeleteSeriesRequest` / `DeleteSeriesResult` Pydantic models |
|
||||
|
||||
### Frontend Components
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `src/server/web/static/js/index/delete-modal.js` | Modal UI, confirm text validation, API calls |
|
||||
| `src/server/web/static/js/index/context-menu.js` | Right-click "Delete Anime" menu item |
|
||||
| `src/server/web/static/js/index/socket-handler.js` | `SERIES_DELETED` WebSocket event handler |
|
||||
| `src/server/web/static/js/index/series-manager.js` | `removeSeries(key)` — removes card from grid |
|
||||
| `src/server/web/static/js/index/app-init.js` | Initializes `DeleteModal` |
|
||||
| `src/server/web/static/css/components/modals.css` | Modal and context menu styles |
|
||||
| `src/server/web/templates/index.html` | Loads `delete-modal.js` before `app-init.js` |
|
||||
|
||||
### API Endpoint
|
||||
|
||||
```
|
||||
DELETE /api/anime/{key}
|
||||
```
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{
|
||||
"delete_database": true,
|
||||
"delete_folder": false,
|
||||
"confirm_text": "delete"
|
||||
}
|
||||
```
|
||||
|
||||
**Success response (200):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"key": "attack-on-titan",
|
||||
"name": "Attack on Titan",
|
||||
"deleted_from_database": true,
|
||||
"deleted_folder": false,
|
||||
"folder_path": null,
|
||||
"database_error": null,
|
||||
"folder_error": null,
|
||||
"message": "Removed from database."
|
||||
}
|
||||
```
|
||||
|
||||
**Error responses:**
|
||||
| Status | Condition |
|
||||
|--------|-----------|
|
||||
| 400 | `confirm_text != "delete"` or neither flag is `true` |
|
||||
| 401 | Not authenticated |
|
||||
| 404 | Series `key` not found in database |
|
||||
| 500 | Unexpected server error |
|
||||
|
||||
### WebSocket Event
|
||||
|
||||
After a successful delete, the server broadcasts a `series_deleted` event:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "series_deleted",
|
||||
"data": {
|
||||
"key": "attack-on-titan",
|
||||
"name": "Attack on Titan"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
All connected clients remove the card from their grid in real-time.
|
||||
|
||||
---
|
||||
|
||||
## Safety Mechanisms
|
||||
|
||||
### 1. Typed Confirmation
|
||||
Users must type exactly `delete` (case-sensitive) to unlock the Delete button. This prevents accidental clicks from triggering deletion.
|
||||
|
||||
### 2. Path Traversal Protection
|
||||
Before deleting a folder, `is_safe_path()` validates the path stays within the configured anime base directory. Paths outside this boundary are rejected with a `folder_error`.
|
||||
|
||||
### 3. Granular Options
|
||||
The two independent checkboxes ensure users consciously choose what to delete. Default is **database only** (recommended).
|
||||
|
||||
### 4. WebSocket Broadcast
|
||||
All clients are notified immediately when a series is deleted, keeping multiple browser sessions in sync.
|
||||
|
||||
### 5. No Shell Injection
|
||||
Series keys are never passed to shell commands. All file operations use `pathlib.Path`.
|
||||
|
||||
---
|
||||
|
||||
## Logging
|
||||
|
||||
### Backend Logs (Python/`logging`)
|
||||
|
||||
| Event | Level | Message |
|
||||
|-------|-------|---------|
|
||||
| Delete initiated | INFO | `Delete anime initiated: key={key} delete_db={x} delete_folder={x}` |
|
||||
| Series not found | WARNING | `Delete anime failed — series not found: key={key}` |
|
||||
| Path traversal attempt | WARNING | `Delete anime blocked — path traversal attempt: key={key} path={path}` |
|
||||
| DB error | ERROR | `Delete anime DB error: key={key} error={message}` |
|
||||
| Folder delete error | ERROR | `Delete anime folder error: key={key} error={message}` |
|
||||
| Delete succeeded | INFO | `Delete anime succeeded: key={key} deleted_db={x} deleted_folder={x}` |
|
||||
|
||||
### Frontend Logs (JS/`console`)
|
||||
|
||||
| Event | Method |
|
||||
|-------|--------|
|
||||
| Modal opened | `console.info('[DeleteModal] Opening for key:', key)` |
|
||||
| Delete confirmed | `console.info('[DeleteModal] Initiating delete:', {...})` |
|
||||
| Delete succeeded | `console.info('[DeleteModal] Delete succeeded:', result)` |
|
||||
| API/network error | `console.error('[DeleteModal] Delete request failed:', err)` |
|
||||
| Series removed from grid | `console.info('[SeriesManager] Removed series from local state:', key)` |
|
||||
| WS event received | `console.info('[SocketHandler] Series deleted:', data)` |
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
No new configuration options are required. The feature uses existing paths:
|
||||
- **Anime base directory**: `settings.anime_directory` (for path traversal validation)
|
||||
- **Database path**: `series_app.database_path` (for DB deletion)
|
||||
- **Queue cleanup**: `AnimeSeriesService.delete(series_key)` cascades to queue items
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
See:
|
||||
- `tests/unit/test_delete_anime_service.py` — unit tests for `AnimeService.delete_series()`
|
||||
- `tests/api/test_delete_anime_endpoint.py` — API endpoint tests including auth, validation, error cases
|
||||
- `tests/frontend/test_delete_modal.py` — frontend modal logic and DOM validation tests
|
||||
- `tests/security/test_delete_anime_security.py` — security tests for path traversal, XSS, auth bypass
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2026-08-16 | Feature added. `DELETE /api/anime/{key}`, right-click context menu, typed-confirmation modal, WebSocket sync. |
|
||||
@@ -70,6 +70,7 @@ The application now features a comprehensive configuration system that allows us
|
||||
- **Library Scanning**: Automated scanning for missing episodes with database persistence
|
||||
- **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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aniworld-web",
|
||||
"version": "1.5.5",
|
||||
"version": "1.5.11",
|
||||
"description": "Aniworld Anime Download Manager - Web Frontend",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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
|
||||
@@ -1648,17 +1650,11 @@ async def update_anime_settings(
|
||||
# Lazy-import to avoid heavy deps when not used
|
||||
from src.server.api.nfo import _create_or_update_nfo
|
||||
|
||||
series_data = {
|
||||
"key": anime_key,
|
||||
"name": db_series.name,
|
||||
"folder": db_series.folder,
|
||||
"tmdb_id": db_series.tmdb_id,
|
||||
}
|
||||
await _create_or_update_nfo(
|
||||
key=anime_key,
|
||||
folder=db_series.folder,
|
||||
tmdb_id=db_series.tmdb_id,
|
||||
series_data=series_data,
|
||||
anime_service=anime_service,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -1725,17 +1721,11 @@ async def regenerate_anime_nfo(
|
||||
try:
|
||||
from src.server.api.nfo import _create_or_update_nfo
|
||||
|
||||
series_data = {
|
||||
"key": anime_key,
|
||||
"name": db_series.name,
|
||||
"folder": db_series.folder,
|
||||
"tmdb_id": tmdb_id,
|
||||
}
|
||||
repaired_tags = await _create_or_update_nfo(
|
||||
key=anime_key,
|
||||
folder=db_series.folder,
|
||||
tmdb_id=tmdb_id,
|
||||
series_data=series_data,
|
||||
anime_service=anime_service,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -1755,3 +1745,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
|
||||
|
||||
@@ -4,6 +4,7 @@ Provides endpoints for NFO settings, repair, and validation for anime series.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
@@ -11,6 +12,7 @@ from pydantic import BaseModel
|
||||
|
||||
from src.config.settings import settings
|
||||
from src.server.models.nfo import (
|
||||
NfoContentResponse,
|
||||
NfoRepairResponse,
|
||||
NfoSeriesSettings,
|
||||
NfoSettingsResponse,
|
||||
@@ -241,7 +243,6 @@ async def repair_nfo_settings(
|
||||
key=key,
|
||||
folder=folder,
|
||||
tmdb_id=tmdb_id,
|
||||
series_data=series_data,
|
||||
anime_service=anime_service,
|
||||
)
|
||||
except Exception as exc:
|
||||
@@ -285,7 +286,6 @@ async def _create_or_update_nfo(
|
||||
key: str,
|
||||
folder: str,
|
||||
tmdb_id: int,
|
||||
series_data: dict,
|
||||
anime_service: AnimeService,
|
||||
) -> List[str]:
|
||||
"""Create or update NFO file for a series.
|
||||
@@ -406,6 +406,74 @@ async def validate_nfo(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{key}/content", response_model=NfoContentResponse)
|
||||
async def get_nfo_content(
|
||||
key: str,
|
||||
_auth: dict = Depends(require_auth),
|
||||
anime_service: AnimeService = Depends(get_anime_service),
|
||||
) -> NfoContentResponse:
|
||||
"""Read and return the raw tvshow.nfo XML for a series.
|
||||
|
||||
Used by the Anime Settings page's "View NFO XML" button to display the
|
||||
on-disk NFO contents inside a ``<pre>`` block. The XML is returned as
|
||||
plain text inside a JSON wrapper so the same auth/header pipeline as the
|
||||
other NFO endpoints can be reused.
|
||||
|
||||
Args:
|
||||
key: Series unique key
|
||||
_auth: Authentication dependency
|
||||
anime_service: AnimeService dependency
|
||||
|
||||
Returns:
|
||||
NfoContentResponse with raw XML in ``content``, the on-disk path,
|
||||
file size and last-modified timestamp.
|
||||
|
||||
Raises:
|
||||
HTTPException 404: If the series or its tvshow.nfo file is not found
|
||||
HTTPException 400: If the series has no folder configured
|
||||
HTTPException 503: If ``settings.anime_directory`` is not configured
|
||||
"""
|
||||
series_data = await _get_series_data(anime_service, key)
|
||||
if not series_data:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Series not found: {key}",
|
||||
)
|
||||
|
||||
folder = series_data.get("folder", "")
|
||||
if not folder:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Series has no folder configured: {key}",
|
||||
)
|
||||
|
||||
nfo_path = _get_nfo_path(folder)
|
||||
if not os.path.isfile(nfo_path):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"No tvshow.nfo file found for series '{key}'",
|
||||
)
|
||||
|
||||
try:
|
||||
stat = os.stat(nfo_path)
|
||||
with open(nfo_path, "r", encoding="utf-8") as f:
|
||||
xml_text = f.read()
|
||||
except OSError as exc:
|
||||
logger.error("Failed to read NFO file %s: %s", nfo_path, exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to read NFO file: {exc}",
|
||||
) from exc
|
||||
|
||||
return NfoContentResponse(
|
||||
key=key,
|
||||
folder=folder,
|
||||
content=xml_text,
|
||||
file_size=stat.st_size,
|
||||
last_modified=datetime.fromtimestamp(stat.st_mtime),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/needs-repair", response_model=NfoNeedsRepairListResponse)
|
||||
async def get_series_needing_repair(
|
||||
_auth: dict = Depends(require_auth),
|
||||
@@ -522,7 +590,6 @@ async def batch_repair_nfo(
|
||||
key=key,
|
||||
folder=folder,
|
||||
tmdb_id=tmdb_id,
|
||||
series_data=series_data,
|
||||
anime_service=anime_service,
|
||||
)
|
||||
results["success"] += 1
|
||||
|
||||
@@ -130,6 +130,26 @@ class SerieList:
|
||||
"""
|
||||
return key in self.keyDict
|
||||
|
||||
def remove(self, key: str) -> bool:
|
||||
"""Remove a series from the in-memory cache.
|
||||
|
||||
Used by the delete flow to keep the in-memory cache in sync with
|
||||
the database after a row is deleted. Returns True if an entry
|
||||
was present and removed, False if the key was not in the cache
|
||||
(treating "already gone" as a no-op rather than an error).
|
||||
|
||||
Args:
|
||||
key: The unique provider identifier for the series
|
||||
|
||||
Returns:
|
||||
True if a series was removed, False if it was not cached.
|
||||
"""
|
||||
if key in self.keyDict:
|
||||
del self.keyDict[key]
|
||||
logger.debug("Removed series from in-memory cache: key=%s", key)
|
||||
return True
|
||||
return False
|
||||
|
||||
def GetMissingEpisode(self) -> List[AnimeSeries]:
|
||||
"""Return all series that still contain missing episodes."""
|
||||
return [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -393,5 +393,29 @@ class NfoRepairResponse(BaseModel):
|
||||
message: str = Field(..., description="Human-readable result message")
|
||||
repaired_tags: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="Tags that were missing before repair"
|
||||
description="Tags that were missing before repair",
|
||||
)
|
||||
|
||||
|
||||
class NfoContentResponse(BaseModel):
|
||||
"""Response containing the raw contents of a series' tvshow.nfo.
|
||||
|
||||
Returned by ``GET /api/nfo/{key}/content`` so the Anime Settings page
|
||||
can render the XML for the user without exposing the on-disk path to
|
||||
the client (only the resolved path is included for display).
|
||||
|
||||
Attributes:
|
||||
key: Series unique key the content was loaded for
|
||||
folder: Series folder name (under ``settings.anime_directory``)
|
||||
content: Raw XML text of tvshow.nfo (UTF-8)
|
||||
file_size: Size of the NFO file in bytes
|
||||
last_modified: ISO-8601 timestamp of last on-disk modification
|
||||
"""
|
||||
|
||||
key: str = Field(..., description="Series unique key")
|
||||
folder: str = Field(..., description="Series folder name")
|
||||
content: str = Field(..., description="Raw XML content of tvshow.nfo")
|
||||
file_size: int = Field(..., description="NFO file size in bytes")
|
||||
last_modified: datetime = Field(
|
||||
..., description="Last modification time of the NFO file"
|
||||
)
|
||||
|
||||
@@ -1244,8 +1244,7 @@ class AnimeService:
|
||||
Returns:
|
||||
True if rename was performed, False if no rename needed or failed
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
if current_folder == target_folder:
|
||||
logger.debug(
|
||||
@@ -1254,8 +1253,9 @@ class AnimeService:
|
||||
)
|
||||
return False
|
||||
|
||||
current_path = self._directory / current_folder
|
||||
target_path = self._directory / target_folder
|
||||
base_dir = Path(self._directory)
|
||||
current_path = base_dir / current_folder
|
||||
target_path = base_dir / target_folder
|
||||
|
||||
if not current_path.exists():
|
||||
logger.debug(
|
||||
@@ -1265,15 +1265,54 @@ class AnimeService:
|
||||
return False
|
||||
|
||||
if target_path.exists():
|
||||
logger.warning(
|
||||
"Cannot rename folder for %s: target path already exists: %s",
|
||||
key,
|
||||
target_path
|
||||
# Target already exists — merge source into target instead of
|
||||
# bailing. Without this, a bare folder ('Naruto') next to the
|
||||
# year-suffixed one ('Naruto (2019)') would orphan the bare
|
||||
# folder forever, producing the "series added twice" symptom.
|
||||
try:
|
||||
summary = self._merge_folder_into_target(
|
||||
str(current_path), str(target_path)
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to merge %s -> %s for %s: %s",
|
||||
current_folder, target_folder, key, exc,
|
||||
)
|
||||
return False
|
||||
logger.info(
|
||||
"Merged folder %s -> %s for series %s (moved=%d skipped=%d removed_source=%s)",
|
||||
current_folder, target_folder, key,
|
||||
summary["moved"], summary["skipped"], summary["removed_source"],
|
||||
)
|
||||
|
||||
# Update in-memory cache
|
||||
if key in self._app.list.keyDict:
|
||||
self._app.list.keyDict[key].folder = target_folder
|
||||
logger.debug(
|
||||
"Updated in-memory cache folder for %s: %s",
|
||||
key, target_folder
|
||||
)
|
||||
|
||||
# Update database if session provided
|
||||
if db is not None:
|
||||
from src.server.database.service import AnimeSeriesService
|
||||
|
||||
# Look up series by key to get database ID
|
||||
series = await AnimeSeriesService.get_by_key(db, key)
|
||||
if series:
|
||||
await AnimeSeriesService.update(
|
||||
db, series_id=series.id, folder=target_folder
|
||||
)
|
||||
logger.debug(
|
||||
"Updated DB folder for %s: %s",
|
||||
key, target_folder
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
try:
|
||||
# Rename folder on disk
|
||||
import shutil
|
||||
shutil.move(str(current_path), str(target_path))
|
||||
logger.info(
|
||||
"Renamed folder for %s: %s -> %s",
|
||||
@@ -1317,6 +1356,83 @@ class AnimeService:
|
||||
)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _merge_folder_into_target(source: str, target: str) -> dict:
|
||||
"""Merge a source folder's contents into an existing target folder.
|
||||
|
||||
Walks the source tree and moves every file into the matching path
|
||||
under the target. When a destination file already exists, the
|
||||
source copy is removed (the target version wins; we don't keep
|
||||
duplicates). When the source tree is fully consumed, the
|
||||
(now-empty) source directory is removed.
|
||||
|
||||
Both paths must be absolute and ``target`` must already exist on
|
||||
disk.
|
||||
|
||||
Returns a summary dict with ``moved`` (file count), ``skipped``
|
||||
(file count where target already had a copy), and
|
||||
``removed_source`` (bool).
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
|
||||
if not os.path.isdir(source):
|
||||
return {"moved": 0, "skipped": 0, "removed_source": False}
|
||||
if not os.path.isdir(target):
|
||||
raise ValueError(f"target does not exist: {target}")
|
||||
|
||||
moved = 0
|
||||
skipped = 0
|
||||
for root, _dirs, files in os.walk(source):
|
||||
rel_root = os.path.relpath(root, source)
|
||||
dest_root = (
|
||||
target if rel_root == "."
|
||||
else os.path.join(target, rel_root)
|
||||
)
|
||||
os.makedirs(dest_root, exist_ok=True)
|
||||
for name in files:
|
||||
src_file = os.path.join(root, name)
|
||||
dest_file = os.path.join(dest_root, name)
|
||||
if os.path.exists(dest_file):
|
||||
# Target wins — never overwrite existing content.
|
||||
# Remove the orphaned source copy so cleanup below
|
||||
# can rmdir it.
|
||||
try:
|
||||
os.remove(src_file)
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"merge: could not remove duplicate %s: %s",
|
||||
src_file, exc,
|
||||
)
|
||||
skipped += 1
|
||||
logger.warning(
|
||||
"merge: skipping %s (target already has %s)",
|
||||
src_file, dest_file,
|
||||
)
|
||||
continue
|
||||
shutil.move(src_file, dest_file)
|
||||
moved += 1
|
||||
|
||||
# Try to remove the (now empty) source tree. Walk bottom-up so
|
||||
# leaf directories are removed before their parents.
|
||||
removed_source = False
|
||||
for root, dirs, files in os.walk(source, topdown=False):
|
||||
for d in dirs:
|
||||
try:
|
||||
os.rmdir(os.path.join(root, d))
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.rmdir(source)
|
||||
removed_source = True
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"merge: could not remove source directory %s: %s",
|
||||
source, exc,
|
||||
)
|
||||
|
||||
return {"moved": moved, "skipped": skipped, "removed_source": removed_source}
|
||||
|
||||
async def contains_in_db(self, key: str, db) -> bool:
|
||||
"""
|
||||
Check if a series with the given key exists in the database.
|
||||
@@ -1634,6 +1750,445 @@ 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
|
||||
|
||||
Deletion order: filesystem first, database second.
|
||||
|
||||
This order matters: if the folder delete fails (e.g. permission
|
||||
error, path outside the configured anime directory), the database
|
||||
row is preserved so the user can retry the delete once the
|
||||
underlying issue is resolved. If we deleted the database row
|
||||
first, an orphan folder would be left on disk with no way to
|
||||
clean it up through the normal delete flow.
|
||||
|
||||
Orphan folder recovery: when ``delete_folder=True`` is requested
|
||||
for a series whose database row no longer exists, the configured
|
||||
anime directory is scanned for a folder that uniquely matches
|
||||
the key. This recovers the case where a previous delete with
|
||||
``delete_database=True`` succeeded but ``delete_folder=True``
|
||||
silently failed, leaving the folder on disk.
|
||||
"""
|
||||
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 re
|
||||
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.",
|
||||
)
|
||||
|
||||
# Look up the series in the DB to get its folder path
|
||||
series = None
|
||||
async with get_db_session() as db:
|
||||
series = await AnimeSeriesService.get_by_key(db, key)
|
||||
|
||||
if not series:
|
||||
logger.warning(
|
||||
"Delete series - row not found in DB: key=%s delete_folder=%s",
|
||||
key, delete_folder,
|
||||
)
|
||||
# Recovery path: if the user wants to delete the folder but the
|
||||
# DB row is already gone (e.g. orphaned by a previous partial
|
||||
# delete), scan the configured anime directory for a folder
|
||||
# that uniquely matches this key and delete it.
|
||||
if delete_folder:
|
||||
folder_path = self._find_orphan_folder_for_key(key)
|
||||
if folder_path:
|
||||
logger.info(
|
||||
"Orphan folder recovery: key=%s matched folder=%s",
|
||||
key, folder_path,
|
||||
)
|
||||
result = DeleteSeriesResult(
|
||||
success=True,
|
||||
key=key,
|
||||
name="",
|
||||
folder_path=folder_path,
|
||||
message="",
|
||||
)
|
||||
self._delete_folder_at_path(folder_path, key, result)
|
||||
# No DB row to delete; build message and return
|
||||
self._build_delete_message(result)
|
||||
logger.info(
|
||||
"Delete series completed (orphan recovery): key=%s "
|
||||
"deleted_folder=%s folder_error=%s",
|
||||
key, result.deleted_folder, result.folder_error,
|
||||
)
|
||||
return result
|
||||
return DeleteSeriesResult(
|
||||
success=False,
|
||||
key=key,
|
||||
name="",
|
||||
folder_path=None,
|
||||
deleted_from_database=False,
|
||||
deleted_folder=False,
|
||||
database_error=None,
|
||||
folder_error=(
|
||||
f"Series '{key}' not found in database, and no folder "
|
||||
"matching this key was found in the anime directory. "
|
||||
"Nothing to delete."
|
||||
),
|
||||
message=(
|
||||
f"Series '{key}' not found. If the folder on disk is "
|
||||
"still required to be removed, please specify its "
|
||||
"exact name on the filesystem."
|
||||
),
|
||||
)
|
||||
# No row, no folder requested — nothing to do
|
||||
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="",
|
||||
)
|
||||
|
||||
# --- Filesystem deletion (do FIRST so a failure preserves the DB row) ---
|
||||
if delete_folder and folder_path:
|
||||
self._delete_folder_at_path(folder_path, key, result)
|
||||
# If folder delete was requested but failed, abort before
|
||||
# removing the DB row so the user can retry.
|
||||
if not result.deleted_folder and result.folder_error:
|
||||
result.success = False
|
||||
self._build_delete_message(result)
|
||||
logger.warning(
|
||||
"Delete series aborted - folder delete failed; DB row preserved: "
|
||||
"key=%s folder_error=%s",
|
||||
key, result.folder_error,
|
||||
)
|
||||
return result
|
||||
|
||||
# --- Database deletion (do AFTER folder delete) ---
|
||||
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
|
||||
|
||||
# Evict from in-memory SerieList.keyDict so the next
|
||||
# GET /api/anime (which reads via SeriesApp.list.GetList())
|
||||
# does not return the just-deleted series.
|
||||
try:
|
||||
list_obj = getattr(self._app, "list", None)
|
||||
if list_obj is not None:
|
||||
# Prefer the explicit remove() when available.
|
||||
if hasattr(list_obj, "remove"):
|
||||
removed = list_obj.remove(key)
|
||||
else:
|
||||
# Fallback: mutate the underlying keyDict dict
|
||||
# directly (mirrors how add_to_db() writes).
|
||||
key_dict = getattr(list_obj, "keyDict", None)
|
||||
removed = (
|
||||
key_dict is not None
|
||||
and key_dict.pop(key, None) is not None
|
||||
)
|
||||
if removed:
|
||||
logger.info(
|
||||
"Evicted series from in-memory cache: key=%s",
|
||||
key,
|
||||
)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
logger.warning(
|
||||
"Failed to evict series from in-memory cache: "
|
||||
"key=%s error=%s",
|
||||
key, exc,
|
||||
)
|
||||
|
||||
# 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,
|
||||
)
|
||||
|
||||
# Broadcast the broader series_list_changed event so any
|
||||
# connected client that missed the series_deleted message
|
||||
# (or whose local state drifted) can re-sync by re-fetching
|
||||
# /api/anime. This is the durable fix for the
|
||||
# "deleted but still listed" bug.
|
||||
try:
|
||||
await self._broadcast_series_list_changed(reason="deleted")
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to broadcast series_list_changed after delete: "
|
||||
"key=%s error=%s",
|
||||
key, exc,
|
||||
)
|
||||
|
||||
# --- Build message ---
|
||||
self._build_delete_message(result)
|
||||
|
||||
logger.info(
|
||||
"Delete series completed: key=%s deleted_db=%s deleted_folder=%s",
|
||||
key, result.deleted_from_database, result.deleted_folder,
|
||||
)
|
||||
return result
|
||||
|
||||
def _delete_folder_at_path(self, folder_path, key, result):
|
||||
"""Resolve ``folder_path`` against the configured anime directory
|
||||
and attempt to remove it. Updates ``result`` in place.
|
||||
|
||||
Resolves relative paths against ``self._directory`` so the safety
|
||||
check operates on the real intended target (the process's current
|
||||
working directory is not used as the base; in containers CWD may
|
||||
differ from the anime directory, e.g. /app vs /data).
|
||||
"""
|
||||
import os as _os
|
||||
import shutil
|
||||
|
||||
# Resolve absolute path and validate it is within base directory.
|
||||
#
|
||||
# Important: `folder_path` stored in the database is the relative
|
||||
# folder name (e.g. "Beyblade Burst (2016)"), not an absolute path.
|
||||
# If we feed a relative path to os.path.abspath() it gets joined
|
||||
# against the process's current working directory — which may be
|
||||
# /app inside the container while the anime directory is /data,
|
||||
# producing e.g. "/app/Beyblade Burst (2016)" and tripping the
|
||||
# safe-path check below for what is actually a valid deletion.
|
||||
# Resolve relative paths against the configured anime directory
|
||||
# so the safety check operates on the real intended target.
|
||||
from src.server.utils.filesystem import is_safe_path
|
||||
|
||||
base_dir = _os.path.abspath(self._directory)
|
||||
if _os.path.isabs(folder_path):
|
||||
abs_folder = _os.path.abspath(folder_path)
|
||||
else:
|
||||
abs_folder = _os.path.abspath(_os.path.join(base_dir, folder_path))
|
||||
|
||||
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
|
||||
return
|
||||
if 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
|
||||
return
|
||||
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
|
||||
|
||||
def _find_orphan_folder_for_key(self, key: str):
|
||||
"""Locate a folder under ``self._directory`` that uniquely matches
|
||||
the given series ``key``.
|
||||
|
||||
Used as a recovery path when the DB row is gone but the on-disk
|
||||
folder still exists (orphaned by a previous partial delete).
|
||||
|
||||
Matching strategy: for each immediate subdirectory of the anime
|
||||
directory, strip a trailing ``(YYYY)`` year suffix if present and
|
||||
then compare the normalized form (lowercased, non-alphanumerics
|
||||
removed, key's hyphens treated as separators) against the key.
|
||||
Returns the folder name (relative to the anime directory) of the
|
||||
unique match, or ``None`` if zero or multiple folders match.
|
||||
|
||||
Returns:
|
||||
The matching relative folder name, or None when no unique
|
||||
match exists. Returning None is the safe default — it forces
|
||||
the caller to surface an explicit error rather than risk
|
||||
deleting the wrong folder.
|
||||
"""
|
||||
import os as _os
|
||||
import re
|
||||
|
||||
if not self._directory or not _os.path.isdir(self._directory):
|
||||
return None
|
||||
|
||||
def _normalize(value: str) -> str:
|
||||
# Drop an optional trailing "(YYYY)" or "(YYYY)"-with-content
|
||||
# suffix the user might have added for disambiguation. We only
|
||||
# strip a single trailing parenthesised group to avoid eating
|
||||
# legitimate parts of the title.
|
||||
value = re.sub(r"\s*\([^)]*\)\s*$", "", value or "")
|
||||
# Lowercase, replace hyphens/underscores with empty so they
|
||||
# line up with the way the key is constructed.
|
||||
lowered = value.lower().replace("-", "").replace("_", "")
|
||||
# Keep only alphanumerics (which preserves CJK characters
|
||||
# because \w in unicode mode includes them; using explicit
|
||||
# alphanumerics is safer cross-platform).
|
||||
return re.sub(r"[^0-9a-z\u00C0-\uFFFF]", "", lowered)
|
||||
|
||||
target = _normalize(key)
|
||||
if not target:
|
||||
return None
|
||||
|
||||
candidates = []
|
||||
try:
|
||||
entries = _os.listdir(self._directory)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
for entry in entries:
|
||||
full = _os.path.join(self._directory, entry)
|
||||
if not _os.path.isdir(full):
|
||||
continue
|
||||
if _normalize(entry) == target:
|
||||
candidates.append(entry)
|
||||
|
||||
if len(candidates) == 1:
|
||||
return candidates[0]
|
||||
if len(candidates) > 1:
|
||||
logger.warning(
|
||||
"Orphan folder recovery: ambiguous match for key=%s "
|
||||
"found %d candidate folders: %s",
|
||||
key, len(candidates), candidates,
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _build_delete_message(result) -> None:
|
||||
"""Assemble the human-readable ``result.message`` from flags/errors."""
|
||||
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."
|
||||
|
||||
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),
|
||||
)
|
||||
|
||||
async def _broadcast_series_list_changed(self, reason: str = "updated") -> None:
|
||||
"""Broadcast series_list_changed event via WebSocket.
|
||||
|
||||
Fires whenever the membership of the series list changes
|
||||
(delete, bulk import, rescan completion, …). The front-end
|
||||
can use this as a hint to re-fetch /api/anime so its local
|
||||
state cannot drift from the server's in-memory cache.
|
||||
"""
|
||||
try:
|
||||
await self._websocket_service.broadcast_series_list_changed(
|
||||
reason=reason,
|
||||
)
|
||||
logger.info(
|
||||
"series_list_changed broadcast sent: reason=%s",
|
||||
reason,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to broadcast series_list_changed: reason=%s error=%s",
|
||||
reason, str(exc),
|
||||
)
|
||||
|
||||
|
||||
def get_anime_service(series_app: SeriesApp) -> AnimeService:
|
||||
"""Factory used for creating AnimeService with a SeriesApp instance."""
|
||||
|
||||
@@ -119,6 +119,79 @@ class FolderNamingService:
|
||||
|
||||
return await self._execute_rename(series, folder, target_folder)
|
||||
|
||||
@staticmethod
|
||||
def _merge_folder_into_target(source: str, target: str) -> dict:
|
||||
"""Merge a source folder's contents into an existing target folder.
|
||||
|
||||
Walks the source tree and moves every file into the matching path under
|
||||
the target. When a destination file already exists, the source copy is
|
||||
removed (the target version wins; we don't keep duplicates). When the
|
||||
source tree is fully consumed, the (now-empty) source directory is
|
||||
removed.
|
||||
|
||||
Both paths must be absolute and ``target`` must already exist on disk.
|
||||
|
||||
Returns a summary dict with ``moved`` (file count), ``skipped`` (file
|
||||
count where target already had a copy), and ``removed_source`` (bool).
|
||||
Caller is responsible for any DB / cache updates that depend on the
|
||||
outcome.
|
||||
"""
|
||||
if not os.path.isdir(source):
|
||||
return {"moved": 0, "skipped": 0, "removed_source": False}
|
||||
if not os.path.isdir(target):
|
||||
raise ValueError(f"target does not exist: {target}")
|
||||
|
||||
moved = 0
|
||||
skipped = 0
|
||||
for root, _dirs, files in os.walk(source):
|
||||
rel_root = os.path.relpath(root, source)
|
||||
dest_root = (
|
||||
target if rel_root == "."
|
||||
else os.path.join(target, rel_root)
|
||||
)
|
||||
os.makedirs(dest_root, exist_ok=True)
|
||||
for name in files:
|
||||
src_file = os.path.join(root, name)
|
||||
dest_file = os.path.join(dest_root, name)
|
||||
if os.path.exists(dest_file):
|
||||
# Target wins — never overwrite existing content. Remove
|
||||
# the orphaned source copy so cleanup below can rmdir it.
|
||||
try:
|
||||
os.remove(src_file)
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"merge: could not remove duplicate %s: %s",
|
||||
src_file, exc,
|
||||
)
|
||||
skipped += 1
|
||||
logger.warning(
|
||||
"merge: skipping %s (target already has %s)",
|
||||
src_file, dest_file,
|
||||
)
|
||||
continue
|
||||
shutil.move(src_file, dest_file)
|
||||
moved += 1
|
||||
|
||||
# Try to remove the (now empty) source tree. Walk bottom-up so leaf
|
||||
# directories are removed before their parents.
|
||||
removed_source = False
|
||||
for root, dirs, files in os.walk(source, topdown=False):
|
||||
for d in dirs:
|
||||
try:
|
||||
os.rmdir(os.path.join(root, d))
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.rmdir(source)
|
||||
removed_source = True
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"merge: could not remove source directory %s: %s",
|
||||
source, exc,
|
||||
)
|
||||
|
||||
return {"moved": moved, "skipped": skipped, "removed_source": removed_source}
|
||||
|
||||
async def _execute_rename(self, series, old_folder: str, target_folder: str) -> FolderRenameResult:
|
||||
key = series.key
|
||||
|
||||
@@ -132,16 +205,72 @@ class FolderNamingService:
|
||||
if not os.path.isdir(old_path):
|
||||
return FolderRenameResult(key=key, old_folder=old_folder, new_folder=None, success=False, skipped=False, reason="source folder does not exist on disk")
|
||||
|
||||
# If the target already exists, merge source into it instead of bailing.
|
||||
# A bare folder ('Naruto') sitting next to the year-suffixed one
|
||||
# ('Naruto (2019)') is how we get a series "added twice". Merging
|
||||
# makes the rename succeed and removes the orphan folder.
|
||||
if os.path.isdir(target_path):
|
||||
return FolderRenameResult(key=key, old_folder=old_folder, new_folder=None, success=False, skipped=False, reason="target folder already exists on disk")
|
||||
try:
|
||||
summary = self._merge_folder_into_target(old_path, target_path)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to merge %s -> %s for %s: %s",
|
||||
old_folder, target_folder, key, exc,
|
||||
)
|
||||
return FolderRenameResult(
|
||||
key=key, old_folder=old_folder, new_folder=None,
|
||||
success=False, skipped=False,
|
||||
reason=f"merge failed: {exc}",
|
||||
)
|
||||
logger.info(
|
||||
"Merged folder %s -> %s for series %s (moved=%d skipped=%d removed_source=%s)",
|
||||
old_folder, target_folder, key,
|
||||
summary["moved"], summary["skipped"], summary["removed_source"],
|
||||
)
|
||||
|
||||
# Update in-memory cache (best-effort)
|
||||
try:
|
||||
from src.server.utils.dependencies import get_series_app
|
||||
series_app = get_series_app()
|
||||
if key in series_app.list.keyDict:
|
||||
series_app.list.keyDict[key].folder = target_folder
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to update in-memory cache for %s: %s", key, exc)
|
||||
|
||||
# Update database
|
||||
async with _get_db_session() as db:
|
||||
db_series = await AnimeSeriesService.get_by_key(db, key)
|
||||
if db_series:
|
||||
await AnimeSeriesService.update(db, series_id=db_series.id, folder=target_folder)
|
||||
logger.debug("Updated DB folder for %s to %s", key, target_folder)
|
||||
|
||||
# If source couldn't be removed (still had unexpected files) the
|
||||
# state is worse than the original orphan, so surface that as a
|
||||
# warning in the result while still reporting success.
|
||||
note = None
|
||||
if not summary["removed_source"]:
|
||||
note = (
|
||||
f"merged (moved={summary['moved']}, skipped={summary['skipped']}) "
|
||||
f"but source folder could not be removed"
|
||||
)
|
||||
elif summary["skipped"]:
|
||||
note = (
|
||||
f"merged (moved={summary['moved']}, "
|
||||
f"kept target copies for {summary['skipped']} file(s))"
|
||||
)
|
||||
return FolderRenameResult(
|
||||
key=key, old_folder=old_folder, new_folder=target_folder,
|
||||
success=True, skipped=False, reason=note,
|
||||
)
|
||||
|
||||
# Target doesn't exist — plain rename.
|
||||
try:
|
||||
shutil.move(old_path, target_path)
|
||||
logger.info("Renamed folder %s -> %s for series %s", old_folder, target_folder, key)
|
||||
|
||||
# Update in-memory cache
|
||||
try:
|
||||
from src.server.SeriesApp import get_series_app
|
||||
from src.server.utils.dependencies import get_series_app
|
||||
series_app = get_series_app()
|
||||
if key in series_app.list.keyDict:
|
||||
series_app.list.keyDict[key].folder = target_folder
|
||||
|
||||
@@ -661,6 +661,63 @@ 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,
|
||||
)
|
||||
|
||||
async def broadcast_series_list_changed(
|
||||
self,
|
||||
reason: str = "updated",
|
||||
) -> None:
|
||||
"""Broadcast a series_list_changed event to all connected clients.
|
||||
|
||||
Fires whenever the membership of the series list changes
|
||||
(delete, bulk import, rescan completion, …). Clients use this
|
||||
as a hint to re-fetch /api/anime so their local state cannot
|
||||
drift from the server's in-memory cache.
|
||||
|
||||
Args:
|
||||
reason: Short string describing why the list changed
|
||||
(e.g. "deleted", "imported", "rescanned"). Forwarded
|
||||
to the client for logging/debugging only.
|
||||
"""
|
||||
message = {
|
||||
"type": "series_list_changed",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"data": {
|
||||
"reason": reason,
|
||||
},
|
||||
}
|
||||
await self._manager.broadcast(message)
|
||||
logger.info(
|
||||
"Broadcast series_list_changed reason=%s",
|
||||
reason,
|
||||
)
|
||||
|
||||
|
||||
# Singleton instance for application-wide access
|
||||
_websocket_service: Optional[WebSocketService] = None
|
||||
|
||||
@@ -117,9 +117,23 @@ def is_safe_path(base_path: str, target_path: str) -> bool:
|
||||
Prevents path traversal attacks by ensuring the target path
|
||||
is actually within the base path after resolution.
|
||||
|
||||
Note on relative paths: a relative ``target_path`` is interpreted
|
||||
as relative to ``base_path``, *not* to the process's current
|
||||
working directory. This mirrors how callers use this helper:
|
||||
they pass a configured base directory and a folder name stored
|
||||
alongside it (e.g. the series ``folder`` column in the database
|
||||
holds a relative name like ``"Beyblade Burst (2016)"``, and the
|
||||
anime directory is configured separately). Without this, a
|
||||
relative target would be resolved against the process CWD —
|
||||
which can differ from ``base_path`` (the FastAPI app runs with
|
||||
CWD=/app while the anime directory is mounted at /data), and
|
||||
the helper would incorrectly reject the path as outside the
|
||||
base. Absolute ``target_path`` values are validated against
|
||||
``base_path`` directly.
|
||||
|
||||
Args:
|
||||
base_path: The base directory that should contain the target
|
||||
target_path: The path to validate
|
||||
target_path: The path to validate (absolute, or relative to base_path)
|
||||
|
||||
Returns:
|
||||
bool: True if target_path is safely within base_path
|
||||
@@ -127,18 +141,26 @@ def is_safe_path(base_path: str, target_path: str) -> bool:
|
||||
Example:
|
||||
>>> is_safe_path("/anime", "/anime/Attack on Titan")
|
||||
True
|
||||
>>> is_safe_path("/anime", "Attack on Titan") # relative -> /anime/Attack on Titan
|
||||
True
|
||||
>>> is_safe_path("/anime", "/anime/../etc/passwd")
|
||||
False
|
||||
"""
|
||||
# Resolve to absolute paths
|
||||
# Resolve base to an absolute path
|
||||
base_resolved = os.path.abspath(base_path)
|
||||
|
||||
# Resolve target relative to the base (not the process CWD) when it is
|
||||
# supplied as a relative path. Absolute targets are validated as-is.
|
||||
if os.path.isabs(target_path):
|
||||
target_resolved = os.path.abspath(target_path)
|
||||
else:
|
||||
target_resolved = os.path.abspath(os.path.join(base_resolved, target_path))
|
||||
|
||||
# Check that target starts with base (with trailing separator)
|
||||
base_with_sep = base_resolved + os.sep
|
||||
return (
|
||||
target_resolved == base_resolved or
|
||||
target_resolved.startswith(base_with_sep)
|
||||
target_resolved == base_resolved
|
||||
or target_resolved.startswith(base_with_sep)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ AniWorld.IndexApp = (function() {
|
||||
AniWorld.ScanManager.init();
|
||||
AniWorld.ConfigManager.init();
|
||||
AniWorld.ContextMenu.init();
|
||||
AniWorld.DeleteModal.init();
|
||||
|
||||
// Bind global events
|
||||
bindGlobalEvents();
|
||||
|
||||
@@ -71,6 +71,11 @@ AniWorld.ContextMenu = (function() {
|
||||
<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>
|
||||
`;
|
||||
|
||||
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');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
354
src/server/web/static/js/index/delete-modal.js
Normal file
354
src/server/web/static/js/index/delete-modal.js
Normal file
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* AniWorld - Delete Anime Modal Module
|
||||
*
|
||||
* Handles the delete confirmation modal for anime series.
|
||||
* Users must type "delete" to enable the confirm button.
|
||||
*
|
||||
* Dependencies: constants.js, api-client.js, ui-utils.js
|
||||
*/
|
||||
|
||||
var AniWorld = window.AniWorld || {};
|
||||
|
||||
AniWorld.DeleteModal = (function() {
|
||||
'use strict';
|
||||
|
||||
const API = '/api/anime';
|
||||
|
||||
let currentKey = null;
|
||||
let currentSeriesName = null;
|
||||
let modalElement = null;
|
||||
let confirmBtn = null;
|
||||
let confirmInput = null;
|
||||
let deleteDbCheckbox = null;
|
||||
let deleteFolderCheckbox = null;
|
||||
let errorElement = null;
|
||||
let isSubmitting = false;
|
||||
|
||||
/**
|
||||
* Initialize the modal — inject HTML into body if not already present.
|
||||
*/
|
||||
function init() {
|
||||
injectModalHTML();
|
||||
cacheElements();
|
||||
bindEvents();
|
||||
console.info('[DeleteModal] initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the modal HTML once and append to document.body.
|
||||
*/
|
||||
function injectModalHTML() {
|
||||
if (document.getElementById('delete-modal')) return;
|
||||
var div = document.createElement('div');
|
||||
div.id = 'delete-modal';
|
||||
div.className = 'modal hidden';
|
||||
div.innerHTML =
|
||||
'<div class="modal-overlay"></div>' +
|
||||
'<div class="modal-content">' +
|
||||
'<div class="modal-header">' +
|
||||
'<h3>Delete Anime</h3>' +
|
||||
'</div>' +
|
||||
'<div class="modal-body">' +
|
||||
'<div class="delete-modal-series-info">' +
|
||||
'<p class="delete-modal-series-name" id="delete-modal-series-name"></p>' +
|
||||
'<p class="delete-modal-series-key" id="delete-modal-series-key"></p>' +
|
||||
'</div>' +
|
||||
'<div class="delete-modal-options">' +
|
||||
'<label>' +
|
||||
'<input type="checkbox" id="delete-db-checkbox" checked> ' +
|
||||
'Remove from database (recommended)' +
|
||||
'</label>' +
|
||||
'<label>' +
|
||||
'<input type="checkbox" id="delete-folder-checkbox"> ' +
|
||||
'Delete folder from filesystem' +
|
||||
'</label>' +
|
||||
'<p class="delete-modal-warning" id="delete-folder-warning" style="display:none;">' +
|
||||
'<i class="fas fa-exclamation-triangle"></i> ' +
|
||||
'This will permanently delete the folder and ALL files inside it!' +
|
||||
'</p>' +
|
||||
'</div>' +
|
||||
'<label class="delete-modal-confirm-label" for="delete-confirm-input">' +
|
||||
'Type <strong>delete</strong> to confirm:' +
|
||||
'</label>' +
|
||||
'<input type="text" id="delete-confirm-input" ' +
|
||||
'placeholder="delete" autocomplete="off" spellcheck="false">' +
|
||||
'<div id="delete-error" class="hidden"></div>' +
|
||||
'</div>' +
|
||||
'<div class="modal-footer delete-modal-actions">' +
|
||||
'<button class="btn" id="delete-cancel-btn">Cancel</button>' +
|
||||
'<button class="btn btn-danger" id="delete-confirm-btn" disabled>Delete</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
document.body.appendChild(div);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache DOM element references.
|
||||
*/
|
||||
function cacheElements() {
|
||||
modalElement = document.getElementById('delete-modal');
|
||||
confirmBtn = document.getElementById('delete-confirm-btn');
|
||||
confirmInput = document.getElementById('delete-confirm-input');
|
||||
deleteDbCheckbox = document.getElementById('delete-db-checkbox');
|
||||
deleteFolderCheckbox = document.getElementById('delete-folder-checkbox');
|
||||
errorElement = document.getElementById('delete-error');
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind event listeners on the modal.
|
||||
*/
|
||||
function bindEvents() {
|
||||
// Guard against missing modal
|
||||
if (!modalElement) return;
|
||||
|
||||
// Cancel button
|
||||
var cancelBtn = document.getElementById('delete-cancel-btn');
|
||||
if (cancelBtn) cancelBtn.addEventListener('click', hide);
|
||||
|
||||
// Close on backdrop click
|
||||
var overlay = modalElement.querySelector('.modal-overlay');
|
||||
if (overlay) overlay.addEventListener('click', hide);
|
||||
|
||||
// Escape key to close
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape' && !isSubmitting && modalElement && !modalElement.classList.contains('hidden')) {
|
||||
hide();
|
||||
}
|
||||
});
|
||||
|
||||
// Folder checkbox toggle — show/hide warning
|
||||
if (deleteFolderCheckbox) {
|
||||
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
|
||||
if (confirmInput) {
|
||||
confirmInput.addEventListener('input', function() {
|
||||
var value = confirmInput.value;
|
||||
var isMatch = value === 'delete';
|
||||
if (confirmBtn) confirmBtn.disabled = !isMatch || isSubmitting;
|
||||
confirmInput.classList.toggle('matched', isMatch);
|
||||
});
|
||||
}
|
||||
|
||||
// Confirm button
|
||||
if (confirmBtn) 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);
|
||||
|
||||
// Ensure elements are cached (in case DOM was replaced)
|
||||
cacheElements();
|
||||
|
||||
// Guard against missing elements
|
||||
if (!modalElement || !confirmInput || !confirmBtn) {
|
||||
console.error('[DeleteModal] Modal elements not found in DOM. Re-injecting.');
|
||||
injectModalHTML();
|
||||
cacheElements();
|
||||
if (!modalElement) {
|
||||
console.error('[DeleteModal] Failed to create modal element.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 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 — guard against missing elements
|
||||
var seriesNameEl = document.getElementById('delete-modal-series-name');
|
||||
var seriesKeyEl = document.getElementById('delete-modal-series-key');
|
||||
var folderWarningEl = document.getElementById('delete-folder-warning');
|
||||
if (seriesNameEl) seriesNameEl.textContent = currentSeriesName;
|
||||
if (seriesKeyEl) seriesKeyEl.textContent = 'Key: ' + key;
|
||||
|
||||
// Reset state
|
||||
confirmInput.value = '';
|
||||
confirmInput.classList.remove('matched');
|
||||
confirmBtn.disabled = true;
|
||||
isSubmitting = false;
|
||||
if (errorElement) {
|
||||
errorElement.classList.add('hidden');
|
||||
errorElement.textContent = '';
|
||||
}
|
||||
if (deleteDbCheckbox) deleteDbCheckbox.checked = true;
|
||||
if (deleteFolderCheckbox) deleteFolderCheckbox.checked = false;
|
||||
if (folderWarningEl) folderWarningEl.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);
|
||||
|
||||
// Capture key before hide() nulls currentKey
|
||||
var deletedKey = currentKey;
|
||||
|
||||
// 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');
|
||||
|
||||
// Close modal and reset submission state together — isSubmitting must
|
||||
// be cleared before hide(), otherwise hide() bails out (early return
|
||||
// on the !isSubmitting guard) and the modal stays visible.
|
||||
isSubmitting = false;
|
||||
if (confirmBtn) confirmBtn.textContent = 'Delete';
|
||||
hide();
|
||||
|
||||
// Remove the card from the grid directly
|
||||
if (AniWorld.SeriesManager && AniWorld.SeriesManager.removeSeries) {
|
||||
AniWorld.SeriesManager.removeSeries(deletedKey);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('[DeleteModal] Delete request failed:', err);
|
||||
showError('Network error: ' + (err && err.message ? err.message : String(err)));
|
||||
isSubmitting = false;
|
||||
confirmBtn.textContent = 'Delete';
|
||||
confirmBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show an error message in the modal.
|
||||
* @param {string} msg
|
||||
*/
|
||||
function showError(msg) {
|
||||
errorElement.textContent = msg;
|
||||
errorElement.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
init: init,
|
||||
show: show,
|
||||
hide: hide
|
||||
};
|
||||
})();
|
||||
@@ -549,6 +549,52 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-fetch the series list from the server. Used as a durable
|
||||
* backstop when receiving the broader series_list_changed WS event
|
||||
* so local state cannot drift from the server's in-memory cache.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function reloadSeries() {
|
||||
console.info('[SeriesManager] Reloading series from server');
|
||||
return loadSeries();
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
init: init,
|
||||
@@ -560,6 +606,8 @@ AniWorld.SeriesManager = (function() {
|
||||
findByKey: findByKey,
|
||||
updateSeriesLoadingStatus: updateSeriesLoadingStatus,
|
||||
updateSingleSeries: updateSingleSeries,
|
||||
updateSeriesKey: updateSeriesKey
|
||||
updateSeriesKey: updateSeriesKey,
|
||||
removeSeries: removeSeries,
|
||||
reloadSeries: reloadSeries
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -160,6 +160,26 @@ 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');
|
||||
}
|
||||
});
|
||||
|
||||
// Series list membership changed (delete, bulk import, rescan, …).
|
||||
// Re-fetch /api/anime so local state cannot drift from the server's
|
||||
// in-memory cache. Acts as a durable backstop when the more specific
|
||||
// series_deleted event does not reach the client.
|
||||
socket.on(WS_EVENTS.SERIES_LIST_CHANGED, function(data) {
|
||||
console.info('[SocketHandler] Series list changed:', data);
|
||||
if (AniWorld.SeriesManager && AniWorld.SeriesManager.reloadSeries) {
|
||||
AniWorld.SeriesManager.reloadSeries();
|
||||
}
|
||||
});
|
||||
|
||||
// Download events
|
||||
socket.on(WS_EVENTS.DOWNLOAD_STARTED, function(data) {
|
||||
isDownloading = true;
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
* - loadSeries(key) : fetch settings for a series key
|
||||
* - saveSettings(opts) : PUT settings, opts.applyToNfo / opts.renameDisk
|
||||
* - regenerateNfo() : POST regenerate-nfo endpoint
|
||||
* - viewNfoContent() : GET raw tvshow.nfo XML into the preview <pre>
|
||||
* - validateField(name, value) : client-side validation, returns error string or null
|
||||
* - populateForm(data) : fill the form from a payload
|
||||
* - showSaveSuccess(msg) : success toast
|
||||
@@ -632,6 +633,7 @@ AniWorld.AnimeSettingsManager = (function () {
|
||||
loadSeries: loadSeries,
|
||||
saveSettings: saveSettings,
|
||||
regenerateNfo: regenerateNfo,
|
||||
viewNfoContent: viewNfoContent,
|
||||
validateField: validateField,
|
||||
populateForm: populateForm,
|
||||
showSaveSuccess: showSaveSuccess,
|
||||
|
||||
@@ -104,6 +104,11 @@ AniWorld.Constants = (function() {
|
||||
// Series events
|
||||
SERIES_UPDATED: 'series_updated',
|
||||
SERIES_LOADING_UPDATE: 'series_loading_update',
|
||||
SERIES_DELETED: 'series_deleted',
|
||||
// Fires when the membership of the series list changes (delete,
|
||||
// bulk import, rescan completion, …). Clients should re-fetch
|
||||
// /api/anime to resync with the server's in-memory cache.
|
||||
SERIES_LIST_CHANGED: 'series_list_changed',
|
||||
|
||||
// Scheduled scan events
|
||||
SCHEDULED_RESCAN_STARTED: 'scheduled_rescan_started',
|
||||
|
||||
@@ -693,6 +693,7 @@
|
||||
<script src="/static/js/index/nfo-config.js?v={{ static_v }}"></script>
|
||||
<script src="/static/js/index/config-manager.js?v={{ static_v }}"></script>
|
||||
<script src="/static/js/index/socket-handler.js?v={{ static_v }}"></script>
|
||||
<script src="/static/js/index/delete-modal.js?v={{ static_v }}"></script>
|
||||
<script src="/static/js/index/app-init.js?v={{ static_v }}"></script>
|
||||
</body>
|
||||
|
||||
|
||||
400
tests/api/test_delete_anime_endpoint.py
Normal file
400
tests/api/test_delete_anime_endpoint.py
Normal file
@@ -0,0 +1,400 @@
|
||||
"""Tests for DELETE /api/anime/{key} endpoint."""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from src.server.api import anime as anime_module
|
||||
from src.server.fastapi_app import app
|
||||
from src.server.services.auth_service import auth_service
|
||||
|
||||
|
||||
class FakeSerie:
|
||||
"""Mock Serie object for testing."""
|
||||
def __init__(self, key, name, folder, episodeDict=None):
|
||||
self.key = key
|
||||
self.name = name
|
||||
self.folder = folder
|
||||
self.episodeDict = episodeDict or {}
|
||||
self.site = "aniworld.to"
|
||||
|
||||
|
||||
class FakeSeriesApp:
|
||||
"""Mock SeriesApp for testing."""
|
||||
def __init__(self):
|
||||
self.list = self
|
||||
self.serie_scanner = MagicMock()
|
||||
self.directory = "/tmp/fake_anime"
|
||||
self.keyDict = {}
|
||||
self._items = [
|
||||
FakeSerie("test-show-key", "Test Show", "Test Show (2023)", {1: [1, 2]}),
|
||||
]
|
||||
for item in self._items:
|
||||
self.keyDict[item.key] = item
|
||||
|
||||
def GetList(self):
|
||||
return self._items
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def authenticated_client():
|
||||
"""Return an async HTTP client authenticated with a valid token."""
|
||||
if not auth_service.is_configured():
|
||||
auth_service.setup_master_password("TestPass123!")
|
||||
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://test"
|
||||
) as ac:
|
||||
r = await ac.post("/api/auth/login", json={"password": "TestPass123!"})
|
||||
assert r.status_code == 200, f"Login failed: {r.status_code} {r.text}"
|
||||
token = r.json()["access_token"]
|
||||
ac.headers["Authorization"] = f"Bearer {token}"
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_series_app():
|
||||
"""Create a mock SeriesApp for dependency injection."""
|
||||
return FakeSeriesApp()
|
||||
|
||||
|
||||
class TestDeleteAnimeEndpoint:
|
||||
"""Tests for DELETE /api/anime/{key}."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_requires_authentication(self):
|
||||
"""DELETE without token returns 401."""
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://test"
|
||||
) as ac:
|
||||
r = await ac.request(
|
||||
"DELETE",
|
||||
"/api/anime/test-key",
|
||||
json={"delete_database": True, "delete_folder": False, "confirm_text": "delete"},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_invalid_confirm_text(self, authenticated_client):
|
||||
"""DELETE with wrong confirm_text returns 400."""
|
||||
with patch('src.server.api.anime.get_anime_service') as mock_get_svc:
|
||||
mock_svc = MagicMock()
|
||||
mock_get_svc.return_value = mock_svc
|
||||
|
||||
r = await authenticated_client.request(
|
||||
"DELETE",
|
||||
"/api/anime/test-key",
|
||||
json={
|
||||
"delete_database": True,
|
||||
"delete_folder": False,
|
||||
"confirm_text": "DELETE", # Wrong case
|
||||
},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "delete" in r.json()["detail"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_invalid_confirm_text_empty(self, authenticated_client):
|
||||
"""DELETE with empty confirm_text returns 400."""
|
||||
with patch('src.server.api.anime.get_anime_service') as mock_get_svc:
|
||||
mock_svc = MagicMock()
|
||||
mock_get_svc.return_value = mock_svc
|
||||
|
||||
r = await authenticated_client.request(
|
||||
"DELETE",
|
||||
"/api/anime/test-key",
|
||||
json={
|
||||
"delete_database": True,
|
||||
"delete_folder": False,
|
||||
"confirm_text": "",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_no_option_selected(self, authenticated_client):
|
||||
"""DELETE with both flags False returns 400."""
|
||||
with patch('src.server.api.anime.get_anime_service') as mock_get_svc:
|
||||
mock_svc = MagicMock()
|
||||
mock_get_svc.return_value = mock_svc
|
||||
|
||||
r = await authenticated_client.request(
|
||||
"DELETE",
|
||||
"/api/anime/test-key",
|
||||
json={
|
||||
"delete_database": False,
|
||||
"delete_folder": False,
|
||||
"confirm_text": "delete",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "at least one" in r.json()["detail"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_database_only_success(self, authenticated_client):
|
||||
"""DELETE with delete_database=True succeeds and returns result."""
|
||||
from src.server.models.anime import DeleteSeriesResult
|
||||
|
||||
mock_result = DeleteSeriesResult(
|
||||
success=True,
|
||||
key="test-show-key",
|
||||
name="Test Show",
|
||||
deleted_from_database=True,
|
||||
deleted_folder=False,
|
||||
folder_path=None,
|
||||
database_error=None,
|
||||
folder_error=None,
|
||||
message="Removed from database.",
|
||||
)
|
||||
|
||||
with patch('src.server.api.anime.get_anime_service') as mock_get_svc:
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.delete_series = AsyncMock(return_value=mock_result)
|
||||
mock_get_svc.return_value = mock_svc
|
||||
|
||||
r = await authenticated_client.request(
|
||||
"DELETE",
|
||||
"/api/anime/test-show-key",
|
||||
json={
|
||||
"delete_database": True,
|
||||
"delete_folder": False,
|
||||
"confirm_text": "delete",
|
||||
},
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["success"] is True
|
||||
assert data["key"] == "test-show-key"
|
||||
assert data["deleted_from_database"] is True
|
||||
assert data["deleted_folder"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_folder_only_success(self, authenticated_client):
|
||||
"""DELETE with delete_folder=True succeeds."""
|
||||
from src.server.models.anime import DeleteSeriesResult
|
||||
|
||||
mock_result = DeleteSeriesResult(
|
||||
success=True,
|
||||
key="test-show-key",
|
||||
name="Test Show",
|
||||
deleted_from_database=False,
|
||||
deleted_folder=True,
|
||||
folder_path="/anime/Test Show",
|
||||
database_error=None,
|
||||
folder_error=None,
|
||||
message="Folder deleted.",
|
||||
)
|
||||
|
||||
with patch('src.server.api.anime.get_anime_service') as mock_get_svc:
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.delete_series = AsyncMock(return_value=mock_result)
|
||||
mock_get_svc.return_value = mock_svc
|
||||
|
||||
r = await authenticated_client.request(
|
||||
"DELETE",
|
||||
"/api/anime/test-show-key",
|
||||
json={
|
||||
"delete_database": False,
|
||||
"delete_folder": True,
|
||||
"confirm_text": "delete",
|
||||
},
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["deleted_folder"] is True
|
||||
assert data["deleted_from_database"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_both_success(self, authenticated_client):
|
||||
"""DELETE with both flags True succeeds."""
|
||||
from src.server.models.anime import DeleteSeriesResult
|
||||
|
||||
mock_result = DeleteSeriesResult(
|
||||
success=True,
|
||||
key="test-show-key",
|
||||
name="Test Show",
|
||||
deleted_from_database=True,
|
||||
deleted_folder=True,
|
||||
folder_path="/anime/Test Show",
|
||||
database_error=None,
|
||||
folder_error=None,
|
||||
message="Removed from database and folder deleted.",
|
||||
)
|
||||
|
||||
with patch('src.server.api.anime.get_anime_service') as mock_get_svc:
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.delete_series = AsyncMock(return_value=mock_result)
|
||||
mock_get_svc.return_value = mock_svc
|
||||
|
||||
r = await authenticated_client.request(
|
||||
"DELETE",
|
||||
"/api/anime/test-show-key",
|
||||
json={
|
||||
"delete_database": True,
|
||||
"delete_folder": True,
|
||||
"confirm_text": "delete",
|
||||
},
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["deleted_from_database"] is True
|
||||
assert data["deleted_folder"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_series_not_found(self, authenticated_client):
|
||||
"""DELETE with unknown key returns 404."""
|
||||
from src.server.models.anime import DeleteSeriesResult
|
||||
|
||||
mock_result = DeleteSeriesResult(
|
||||
success=False,
|
||||
key="nonexistent-key",
|
||||
name="",
|
||||
deleted_from_database=False,
|
||||
deleted_folder=False,
|
||||
folder_path=None,
|
||||
database_error=None,
|
||||
folder_error=None,
|
||||
message="Series not found.",
|
||||
)
|
||||
|
||||
with patch('src.server.api.anime.get_anime_service') as mock_get_svc:
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.delete_series = AsyncMock(return_value=mock_result)
|
||||
mock_get_svc.return_value = mock_svc
|
||||
|
||||
r = await authenticated_client.request(
|
||||
"DELETE",
|
||||
"/api/anime/nonexistent-key",
|
||||
json={
|
||||
"delete_database": True,
|
||||
"delete_folder": False,
|
||||
"confirm_text": "delete",
|
||||
},
|
||||
)
|
||||
|
||||
assert r.status_code == 404
|
||||
assert "not found" in r.json()["detail"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_service_error_returns_500(self, authenticated_client):
|
||||
"""DELETE when service raises unexpected error returns 500."""
|
||||
with patch('src.server.api.anime.get_anime_service') as mock_get_svc:
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.delete_series = AsyncMock(side_effect=RuntimeError("Unexpected error"))
|
||||
mock_get_svc.return_value = mock_svc
|
||||
|
||||
r = await authenticated_client.request(
|
||||
"DELETE",
|
||||
"/api/anime/test-show-key",
|
||||
json={
|
||||
"delete_database": True,
|
||||
"delete_folder": False,
|
||||
"confirm_text": "delete",
|
||||
},
|
||||
)
|
||||
|
||||
assert r.status_code == 500
|
||||
assert "delete failed" in r.json()["detail"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_body_confirm_text_required(self, authenticated_client):
|
||||
"""DELETE body must contain confirm_text field."""
|
||||
with patch('src.server.api.anime.get_anime_service') as mock_get_svc:
|
||||
mock_svc = MagicMock()
|
||||
mock_get_svc.return_value = mock_svc
|
||||
|
||||
# Missing confirm_text entirely
|
||||
r = await authenticated_client.request(
|
||||
"DELETE",
|
||||
"/api/anime/test-show-key",
|
||||
json={
|
||||
"delete_database": True,
|
||||
"delete_folder": False,
|
||||
},
|
||||
)
|
||||
|
||||
# Pydantic validation error
|
||||
assert r.status_code in (400, 422)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_confirm_text_too_long(self, authenticated_client):
|
||||
"""DELETE with extremely long confirm_text is rejected."""
|
||||
with patch('src.server.api.anime.get_anime_service') as mock_get_svc:
|
||||
mock_svc = MagicMock()
|
||||
mock_get_svc.return_value = mock_svc
|
||||
|
||||
r = await authenticated_client.request(
|
||||
"DELETE",
|
||||
"/api/anime/test-show-key",
|
||||
json={
|
||||
"delete_database": True,
|
||||
"delete_folder": False,
|
||||
"confirm_text": "delete" + "x" * 10000,
|
||||
},
|
||||
)
|
||||
|
||||
# Should be rejected as invalid confirm_text
|
||||
assert r.status_code in (400, 422)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_confirm_text_with_whitespace_rejected(self, authenticated_client):
|
||||
"""DELETE with whitespace-padded confirm_text is rejected."""
|
||||
with patch('src.server.api.anime.get_anime_service') as mock_get_svc:
|
||||
mock_svc = MagicMock()
|
||||
mock_get_svc.return_value = mock_svc
|
||||
|
||||
r = await authenticated_client.request(
|
||||
"DELETE",
|
||||
"/api/anime/test-show-key",
|
||||
json={
|
||||
"delete_database": True,
|
||||
"delete_folder": False,
|
||||
"confirm_text": " delete ", # Has spaces
|
||||
},
|
||||
)
|
||||
|
||||
assert r.status_code == 400
|
||||
assert "delete" in r.json()["detail"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_confirm_text_with_newline_rejected(self, authenticated_client):
|
||||
"""DELETE with newline in confirm_text is rejected."""
|
||||
with patch('src.server.api.anime.get_anime_service') as mock_get_svc:
|
||||
mock_svc = MagicMock()
|
||||
mock_get_svc.return_value = mock_svc
|
||||
|
||||
r = await authenticated_client.request(
|
||||
"DELETE",
|
||||
"/api/anime/test-show-key",
|
||||
json={
|
||||
"delete_database": True,
|
||||
"delete_folder": False,
|
||||
"confirm_text": "delet\ne", # Has newline
|
||||
},
|
||||
)
|
||||
|
||||
assert r.status_code == 400
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_path_traversal_in_key_rejected(self, authenticated_client):
|
||||
"""DELETE with path traversal key returns 400 or 404."""
|
||||
with patch('src.server.api.anime.get_anime_service') as mock_get_svc:
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.delete_series = AsyncMock(side_effect=ValueError("Path traversal detected"))
|
||||
mock_get_svc.return_value = mock_svc
|
||||
|
||||
r = await authenticated_client.request(
|
||||
"DELETE",
|
||||
"/api/anime/../../../etc/passwd",
|
||||
json={
|
||||
"delete_database": True,
|
||||
"delete_folder": False,
|
||||
"confirm_text": "delete",
|
||||
},
|
||||
)
|
||||
|
||||
# Should either be rejected by the service or return an error
|
||||
assert r.status_code in (400, 404, 422)
|
||||
@@ -4,6 +4,7 @@ Covers the live endpoints in src/server/api/nfo.py:
|
||||
- GET /api/nfo/{key}/diagnostics
|
||||
- POST /api/nfo/{key}/repair
|
||||
- GET /api/nfo/{key}/validate
|
||||
- GET /api/nfo/{key}/content (re-introduced — used by Anime Settings 'View NFO XML')
|
||||
- GET /api/nfo/needs-repair
|
||||
- POST /api/nfo/batch/repair
|
||||
|
||||
@@ -12,6 +13,12 @@ Note: Legacy endpoints referenced in v1.x (e.g. /api/nfo/{id}/check,
|
||||
in the codebase — they were replaced by the consolidated diagnostic,
|
||||
repair, validate, needs-repair, batch/repair endpoints and the new
|
||||
Anime Settings page (see tests/api/test_anime_settings_endpoints.py).
|
||||
|
||||
Auth note: tests/conftest.py's autouse ``reset_auth_and_rate_limits``
|
||||
fixture configures the master password with ``TestPass123!`` before every
|
||||
test. The per-file ``reset_auth`` autouse fixture that used to live here
|
||||
was removed because it wiped the conftest's setup and made any test that
|
||||
needed an authenticated client fail with a stale-hash login error.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
@@ -19,16 +26,6 @@ import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from src.server.fastapi_app import app
|
||||
from src.server.services.auth_service import auth_service
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_auth():
|
||||
auth_service._hash = None
|
||||
auth_service._failed.clear()
|
||||
yield
|
||||
auth_service._hash = None
|
||||
auth_service._failed.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -38,19 +35,19 @@ async def client():
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def authenticated_client(client):
|
||||
await client.post(
|
||||
"/api/auth/setup",
|
||||
json={"master_password": "TestPassword123!"},
|
||||
)
|
||||
async def _login(client: AsyncClient) -> str:
|
||||
"""Log in with the master password configured by conftest and
|
||||
return the bearer token. Sets the ``Authorization`` header on the
|
||||
client as a side benefit so the caller can ``await client.get(...)``
|
||||
immediately."""
|
||||
resp = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"password": "TestPassword123!"},
|
||||
json={"password": "TestPass123!"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
token = resp.json()["access_token"]
|
||||
client.headers.update({"Authorization": f"Bearer {token}"})
|
||||
yield client
|
||||
return token
|
||||
|
||||
|
||||
class TestNFOAuthRequirements:
|
||||
@@ -84,6 +81,119 @@ class TestNFOAuthRequirements:
|
||||
)
|
||||
assert resp.status_code in (401, 503)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_content_requires_auth(self, client):
|
||||
"""GET /api/nfo/{key}/content (used by the Anime Settings page
|
||||
'View NFO XML' button) must require authentication."""
|
||||
resp = await client.get("/api/nfo/any-key/content")
|
||||
assert resp.status_code in (401, 503)
|
||||
|
||||
|
||||
class TestNFOContentEndpoint:
|
||||
"""Behavioural tests for GET /api/nfo/{key}/content.
|
||||
|
||||
Covers the success path and the two 404 cases (unknown series,
|
||||
missing tvshow.nfo) the Anime Settings page relies on."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_anime_service(self):
|
||||
"""Replace the FastAPI get_anime_service dependency with a mock.
|
||||
Yields the mock so individual tests can configure ``list_series_with_filters``."""
|
||||
from src.server.utils import dependencies as deps
|
||||
|
||||
service = Mock()
|
||||
service.list_series_with_filters = AsyncMock(return_value=[])
|
||||
app.dependency_overrides[deps.get_anime_service] = lambda: service
|
||||
yield service
|
||||
app.dependency_overrides.pop(deps.get_anime_service, None)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_xml_for_series_with_nfo(
|
||||
self, client, tmp_path, monkeypatch, mock_anime_service
|
||||
):
|
||||
"""Happy path: existing tvshow.nfo is returned verbatim inside
|
||||
the JSON wrapper the JS uses (``data.content``)."""
|
||||
from src.config import settings as settings_module
|
||||
|
||||
# Point settings.anime_directory at a temp dir
|
||||
monkeypatch.setattr(
|
||||
settings_module.settings,
|
||||
"anime_directory",
|
||||
str(tmp_path),
|
||||
raising=False,
|
||||
)
|
||||
|
||||
# Build a fake folder + tvshow.nfo on disk
|
||||
folder = "Naruto (2002)"
|
||||
series_dir = tmp_path / folder
|
||||
series_dir.mkdir()
|
||||
xml = (
|
||||
"<?xml version='1.0' encoding='UTF-8'?>\n"
|
||||
"<tvshow><title>Naruto</title><year>2002</year></tvshow>\n"
|
||||
)
|
||||
(series_dir / "tvshow.nfo").write_text(xml, encoding="utf-8")
|
||||
|
||||
mock_anime_service.list_series_with_filters = AsyncMock(
|
||||
return_value=[{"key": "naruto", "folder": folder}]
|
||||
)
|
||||
|
||||
await _login(client)
|
||||
|
||||
resp = await client.get("/api/nfo/naruto/content")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["key"] == "naruto"
|
||||
assert body["folder"] == folder
|
||||
assert body["content"] == xml
|
||||
assert body["file_size"] == len(xml.encode("utf-8"))
|
||||
assert "last_modified" in body
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_404_when_series_unknown(
|
||||
self, client, tmp_path, monkeypatch, mock_anime_service
|
||||
):
|
||||
from src.config import settings as settings_module
|
||||
|
||||
monkeypatch.setattr(
|
||||
settings_module.settings,
|
||||
"anime_directory",
|
||||
str(tmp_path),
|
||||
raising=False,
|
||||
)
|
||||
mock_anime_service.list_series_with_filters = AsyncMock(return_value=[])
|
||||
|
||||
await _login(client)
|
||||
|
||||
resp = await client.get("/api/nfo/missing/content")
|
||||
assert resp.status_code == 404
|
||||
assert "not found" in resp.json()["detail"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_404_when_nfo_file_missing(
|
||||
self, client, tmp_path, monkeypatch, mock_anime_service
|
||||
):
|
||||
"""Series exists with a configured folder but no tvshow.nfo yet."""
|
||||
from src.config import settings as settings_module
|
||||
|
||||
folder = "Empty"
|
||||
(tmp_path / folder).mkdir()
|
||||
|
||||
monkeypatch.setattr(
|
||||
settings_module.settings,
|
||||
"anime_directory",
|
||||
str(tmp_path),
|
||||
raising=False,
|
||||
)
|
||||
mock_anime_service.list_series_with_filters = AsyncMock(
|
||||
return_value=[{"key": "empty", "folder": folder}]
|
||||
)
|
||||
|
||||
await _login(client)
|
||||
|
||||
resp = await client.get("/api/nfo/empty/content")
|
||||
assert resp.status_code == 404
|
||||
assert "tvshow.nfo" in resp.json()["detail"].lower()
|
||||
|
||||
|
||||
class TestNFOEndpointModels:
|
||||
"""Verify the response models use the renamed classes (regression
|
||||
|
||||
465
tests/frontend/test_delete_modal.py
Normal file
465
tests/frontend/test_delete_modal.py
Normal file
@@ -0,0 +1,465 @@
|
||||
"""
|
||||
Frontend unit tests for delete-modal.js.
|
||||
|
||||
Tests the DeleteModal JavaScript module logic in isolation.
|
||||
Since this is a browser-only module, we test the underlying logic
|
||||
(validation, URL construction, response handling) as Python logic.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# Module-level mock classes (shared across tests)
|
||||
class MockUI:
|
||||
"""Mock UI module."""
|
||||
showToast_called = []
|
||||
|
||||
@staticmethod
|
||||
def showToast(msg, level):
|
||||
MockUI.showToast_called.append((msg, level))
|
||||
|
||||
|
||||
class MockResponse:
|
||||
"""Simulates httpx AsyncClient response used by delete-modal.js."""
|
||||
def __init__(self, status_code, json_data=None):
|
||||
self._status = status_code
|
||||
self._json = json_data
|
||||
|
||||
@property
|
||||
def ok(self):
|
||||
return 200 <= self._status < 300
|
||||
|
||||
@property
|
||||
def status(self):
|
||||
return self._status
|
||||
|
||||
async def json(self):
|
||||
return self._json
|
||||
|
||||
|
||||
class MockApiClient:
|
||||
"""Mock ApiClient that simulates delete-modal.js API calls."""
|
||||
last_request = None
|
||||
|
||||
@classmethod
|
||||
async def request(cls, url, options=None):
|
||||
cls.last_request = (url, options)
|
||||
|
||||
# Route based on key in URL
|
||||
if "test-show-key" in url:
|
||||
return MockResponse(200, {
|
||||
"success": True,
|
||||
"key": "test-show-key",
|
||||
"name": "Test Show",
|
||||
"deleted_from_database": True,
|
||||
"deleted_folder": False,
|
||||
"message": "Removed from database.",
|
||||
})
|
||||
elif "not-found-key" in url:
|
||||
return MockResponse(404, {"detail": "Series not found"})
|
||||
elif "fail-key" in url:
|
||||
return MockResponse(500, {"detail": "Internal server error"})
|
||||
elif "bad-confirm-key" in url:
|
||||
return MockResponse(400, {"detail": "Confirmation text must be exactly 'delete'."})
|
||||
return MockResponse(400, {"detail": "Unknown error"})
|
||||
|
||||
|
||||
# Store original for reset
|
||||
_original_api_request = MockApiClient.request
|
||||
|
||||
|
||||
class MockAniWorld:
|
||||
"""Mock AniWorld namespace used by delete-modal.js."""
|
||||
UI = MockUI
|
||||
ApiClient = MockApiClient
|
||||
DeleteModal = None
|
||||
SeriesManager = None
|
||||
Auth = MagicMock()
|
||||
Auth.removeToken = MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_mock_aniworld():
|
||||
"""Reset mock state before each test to prevent pollution."""
|
||||
MockUI.showToast_called = []
|
||||
MockApiClient.last_request = None
|
||||
# Restore both MockApiClient.request AND MockAniWorld.ApiClient.request
|
||||
# (tests may set either one directly)
|
||||
MockApiClient.request = _original_api_request
|
||||
MockAniWorld.ApiClient = MockApiClient
|
||||
MockAniWorld.SeriesManager = None
|
||||
MockAniWorld.Auth = MagicMock()
|
||||
MockAniWorld.Auth.removeToken = MagicMock()
|
||||
yield
|
||||
|
||||
|
||||
class TestDeleteModalValidation:
|
||||
"""Tests for the confirm-text validation logic."""
|
||||
|
||||
def test_confirm_input_disables_button_until_delete_typed(self):
|
||||
"""Button is disabled until user types 'delete'."""
|
||||
confirm_input = {"value": "", "classList": {"toggle": MagicMock()}}
|
||||
confirm_btn = {"disabled": False}
|
||||
|
||||
# Initially empty - button should be disabled
|
||||
is_match = confirm_input["value"] == "delete"
|
||||
confirm_btn["disabled"] = not is_match
|
||||
assert confirm_btn["disabled"] is True
|
||||
|
||||
# User types 'del'
|
||||
confirm_input["value"] = "del"
|
||||
is_match = confirm_input["value"] == "delete"
|
||||
confirm_btn["disabled"] = not is_match
|
||||
assert confirm_btn["disabled"] is True
|
||||
|
||||
# User types 'delete'
|
||||
confirm_input["value"] = "delete"
|
||||
is_match = confirm_input["value"] == "delete"
|
||||
confirm_btn["disabled"] = not is_match
|
||||
assert confirm_btn["disabled"] is False
|
||||
|
||||
def test_confirm_input_matched_class_toggles(self):
|
||||
"""Input gets 'matched' CSS class when value is 'delete'."""
|
||||
matched_states = []
|
||||
|
||||
for value in ["", "del", "delete", "Delete", "delete "]:
|
||||
is_match = value == "delete"
|
||||
matched_states.append(is_match)
|
||||
|
||||
assert matched_states == [False, False, True, False, False]
|
||||
|
||||
def test_folder_checkbox_shows_warning_when_checked(self):
|
||||
"""Folder warning appears when delete-folder checkbox is checked."""
|
||||
warning_shown = []
|
||||
for is_checked in [False, True, False]:
|
||||
warning_shown.append(is_checked)
|
||||
|
||||
assert warning_shown[0] is False
|
||||
assert warning_shown[1] is True
|
||||
assert warning_shown[2] is False
|
||||
|
||||
def test_at_least_one_option_required_validation(self):
|
||||
"""Modal should reject when neither checkbox is selected."""
|
||||
delete_db = False
|
||||
delete_folder = False
|
||||
is_valid = delete_db or delete_folder
|
||||
assert is_valid is False
|
||||
|
||||
delete_db = True
|
||||
is_valid = delete_db or delete_folder
|
||||
assert is_valid is True
|
||||
|
||||
def test_confirm_text_exact_match_required(self):
|
||||
"""confirmText must be exactly 'delete' (case-sensitive)."""
|
||||
test_cases = [
|
||||
("delete", True),
|
||||
("DELETE", False),
|
||||
("Delete", False),
|
||||
(" delete", False),
|
||||
("delete ", False),
|
||||
(" delete ", False),
|
||||
("deletex", False),
|
||||
("", False),
|
||||
]
|
||||
|
||||
for text, expected in test_cases:
|
||||
result = text == "delete"
|
||||
assert result is expected, f"'{text}' should be {expected}"
|
||||
|
||||
def test_delete_api_url_construction(self):
|
||||
"""DELETE request is sent to /api/anime/{key}."""
|
||||
key = "test-show-key"
|
||||
url = '/api/anime/' + key
|
||||
assert url == "/api/anime/test-show-key"
|
||||
assert "test-show-key" in url
|
||||
|
||||
def test_delete_api_body_construction(self):
|
||||
"""API body contains all three required fields."""
|
||||
delete_database = True
|
||||
delete_folder = False
|
||||
confirm_text = "delete"
|
||||
|
||||
body = {
|
||||
"delete_database": delete_database,
|
||||
"delete_folder": delete_folder,
|
||||
"confirm_text": confirm_text
|
||||
}
|
||||
|
||||
assert body["delete_database"] is True
|
||||
assert body["delete_folder"] is False
|
||||
assert body["confirm_text"] == "delete"
|
||||
|
||||
|
||||
class TestDeleteModalAPI:
|
||||
"""Tests for the delete modal API interaction logic."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_called_with_correct_url_and_method(self):
|
||||
"""DELETE request is sent to correct endpoint."""
|
||||
url = "/api/anime/test-show-key"
|
||||
options = {
|
||||
"method": "DELETE",
|
||||
"headers": {"Content-Type": "application/json"},
|
||||
"body": '{"delete_database": true, "delete_folder": false, "confirm_text": "delete"}'
|
||||
}
|
||||
|
||||
response = await MockAniWorld.ApiClient.request(url, options)
|
||||
assert response.status == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_returns_404_shows_not_found_error(self):
|
||||
"""API 404 response returns 'not found' detail."""
|
||||
response = await MockAniWorld.ApiClient.request(
|
||||
"/api/anime/not-found-key",
|
||||
{"method": "DELETE", "body": "{}"}
|
||||
)
|
||||
|
||||
assert response.status == 404
|
||||
data = await response.json()
|
||||
assert "not found" in data["detail"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_returns_400_shows_validation_error(self):
|
||||
"""API 400 response contains validation error detail."""
|
||||
response = await MockAniWorld.ApiClient.request(
|
||||
"/api/anime/bad-confirm-key",
|
||||
{"method": "DELETE"}
|
||||
)
|
||||
|
||||
assert response.status == 400
|
||||
data = await response.json()
|
||||
assert "delete" in data["detail"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_network_error_raises_exception(self):
|
||||
"""Network failure raises an exception."""
|
||||
MockAniWorld.ApiClient.request = AsyncMock(
|
||||
side_effect=Exception("Network connection failed")
|
||||
)
|
||||
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await MockAniWorld.ApiClient.request("/api/anime/test", {})
|
||||
assert "network" in str(exc_info.value).lower() or "failed" in str(exc_info.value).lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_success_response_contains_deleted_fields(self):
|
||||
"""Successful response includes deleted_from_database and deleted_folder."""
|
||||
response = await MockAniWorld.ApiClient.request(
|
||||
"/api/anime/test-show-key",
|
||||
{"method": "DELETE"}
|
||||
)
|
||||
|
||||
data = await response.json()
|
||||
assert "success" in data
|
||||
assert "deleted_from_database" in data
|
||||
assert "deleted_folder" in data
|
||||
|
||||
|
||||
class TestDeleteModalSeriesManagerIntegration:
|
||||
"""Tests for SeriesManager.removeSeries integration."""
|
||||
|
||||
def test_remove_series_called_after_success(self):
|
||||
"""After successful delete, removeSeries(key) is called."""
|
||||
key = "test-show-key"
|
||||
|
||||
remove_called_with = []
|
||||
|
||||
class MockSeriesManager:
|
||||
@staticmethod
|
||||
def removeSeries(k):
|
||||
remove_called_with.append(k)
|
||||
|
||||
MockAniWorld.SeriesManager = MockSeriesManager
|
||||
|
||||
# Simulate: after successful API response
|
||||
result = {"success": True, "key": key, "name": "Test Show"}
|
||||
if result["success"] and MockAniWorld.SeriesManager:
|
||||
MockAniWorld.SeriesManager.removeSeries(result["key"])
|
||||
|
||||
assert remove_called_with == [key]
|
||||
|
||||
def test_remove_series_not_called_on_failure(self):
|
||||
"""removeSeries is NOT called when API returns error."""
|
||||
remove_called_with = []
|
||||
|
||||
class MockSeriesManager:
|
||||
@staticmethod
|
||||
def removeSeries(k):
|
||||
remove_called_with.append(k)
|
||||
|
||||
MockAniWorld.SeriesManager = MockSeriesManager
|
||||
|
||||
# Simulate: API returns error
|
||||
result = {"success": False, "key": "test-show-key", "message": "Not found"}
|
||||
if result["success"] and MockAniWorld.SeriesManager:
|
||||
MockAniWorld.SeriesManager.removeSeries(result["key"])
|
||||
|
||||
assert remove_called_with == []
|
||||
|
||||
def test_ws_event_broadcast_triggers_remove(self):
|
||||
"""WebSocket series_deleted event triggers removeSeries."""
|
||||
key = "ws-deleted-key"
|
||||
remove_called_with = []
|
||||
|
||||
class MockSeriesManager:
|
||||
@staticmethod
|
||||
def removeSeries(k):
|
||||
remove_called_with.append(k)
|
||||
|
||||
MockAniWorld.SeriesManager = MockSeriesManager
|
||||
|
||||
# Simulate WS event handler
|
||||
def on_series_deleted(data):
|
||||
if MockAniWorld.SeriesManager and MockAniWorld.SeriesManager.removeSeries:
|
||||
MockAniWorld.SeriesManager.removeSeries(data["key"])
|
||||
|
||||
on_series_deleted({"key": key})
|
||||
assert remove_called_with == [key]
|
||||
|
||||
|
||||
class TestDeleteModalModalCloseAfterSuccess:
|
||||
"""Regression tests for the bug where the modal stayed visible after a
|
||||
successful delete because isSubmitting was never reset on the success path.
|
||||
|
||||
Source-level tests (the JS is not run in pytest): they assert that the
|
||||
handleConfirm success branch (a) clears isSubmitting before hide(), and
|
||||
(b) removes the card via a key captured before hide() nulls currentKey.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _read_source():
|
||||
import os
|
||||
path = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
'..', '..',
|
||||
'src', 'server', 'web', 'static', 'js', 'index', 'delete-modal.js'
|
||||
)
|
||||
with open(path, 'r') as f:
|
||||
return f.read()
|
||||
|
||||
def test_isSubmitting_reset_on_success_path(self):
|
||||
"""isSubmitting must be reset to false after a successful delete,
|
||||
otherwise hide()'s early-return guard keeps the modal visible."""
|
||||
src = self._read_source()
|
||||
|
||||
# Locate the success branch: it begins with "Delete succeeded:" log
|
||||
success_idx = src.find("Delete succeeded:")
|
||||
assert success_idx > 0, "Could not find Delete succeeded log line"
|
||||
|
||||
# Everything between the success log and the catch block belongs to
|
||||
# the success path.
|
||||
catch_idx = src.find("} catch (err)", success_idx)
|
||||
assert catch_idx > 0, "Could not find catch block after success path"
|
||||
|
||||
success_branch = src[success_idx:catch_idx]
|
||||
|
||||
# The flag must be reset in this branch...
|
||||
assert "isSubmitting = false" in success_branch, (
|
||||
"isSubmitting is never reset on the success path — "
|
||||
"this is the bug that left the modal visible with 'Deleting...'"
|
||||
)
|
||||
|
||||
# ...BEFORE hide() is called.
|
||||
reset_pos = success_branch.find("isSubmitting = false")
|
||||
hide_pos = success_branch.find("hide();")
|
||||
assert reset_pos > 0 and hide_pos > 0, (
|
||||
"Could not locate isSubmitting reset or hide() call"
|
||||
)
|
||||
assert reset_pos < hide_pos, (
|
||||
"isSubmitting must be cleared BEFORE hide() — otherwise hide()'s "
|
||||
"guard (`if (isSubmitting) return`) bails out and the modal stays"
|
||||
)
|
||||
|
||||
def test_modal_hidden_class_added_on_success(self):
|
||||
"""After the success-path cleanup, hide() must run and apply the
|
||||
'hidden' class. We verify by checking hide() is reached after the
|
||||
isSubmitting reset (covered above) and that the reset precedes the
|
||||
removal of the card from the grid."""
|
||||
src = self._read_source()
|
||||
|
||||
success_idx = src.find("Delete succeeded:")
|
||||
catch_idx = src.find("} catch (err)", success_idx)
|
||||
success_branch = src[success_idx:catch_idx]
|
||||
|
||||
reset_pos = success_branch.find("isSubmitting = false")
|
||||
hide_pos = success_branch.find("hide();")
|
||||
remove_pos = success_branch.find("removeSeries(")
|
||||
|
||||
assert 0 < reset_pos < hide_pos < remove_pos, (
|
||||
"Order on success path must be: "
|
||||
"isSubmitting reset -> hide() -> removeSeries()"
|
||||
)
|
||||
|
||||
def test_removeSeries_uses_captured_key_not_live_currentKey(self):
|
||||
"""hide() nulls currentKey on line ~211. If removeSeries reads the
|
||||
live currentKey AFTER hide(), it gets null and silently no-ops. The
|
||||
fix captures the key into a local before hide() runs."""
|
||||
src = self._read_source()
|
||||
|
||||
success_idx = src.find("Delete succeeded:")
|
||||
catch_idx = src.find("} catch (err)", success_idx)
|
||||
success_branch = src[success_idx:catch_idx]
|
||||
|
||||
# There must be a local capture of the key before hide().
|
||||
assert "var deletedKey = currentKey;" in success_branch, (
|
||||
"Success path must capture currentKey into a local before "
|
||||
"hide() nulls it — otherwise removeSeries(currentKey) would be "
|
||||
"a silent no-op."
|
||||
)
|
||||
|
||||
# The removeSeries call must reference the captured local, not
|
||||
# currentKey directly.
|
||||
capture_pos = success_branch.find("var deletedKey = currentKey;")
|
||||
remove_pos = success_branch.find("removeSeries(deletedKey)")
|
||||
assert capture_pos > 0 and remove_pos > 0, (
|
||||
"removeSeries must be called with the captured deletedKey"
|
||||
)
|
||||
assert capture_pos < remove_pos, (
|
||||
"Capture must happen BEFORE removeSeries reads it"
|
||||
)
|
||||
|
||||
def test_confirm_button_text_reset_on_success(self):
|
||||
"""The button text is changed to 'Deleting...' during submit and must
|
||||
be reverted to 'Delete' so the modal is in a clean state if reopened."""
|
||||
src = self._read_source()
|
||||
|
||||
success_idx = src.find("Delete succeeded:")
|
||||
catch_idx = src.find("} catch (err)", success_idx)
|
||||
success_branch = src[success_idx:catch_idx]
|
||||
|
||||
assert "confirmBtn.textContent = 'Delete'" in success_branch, (
|
||||
"Confirm button text must be reset to 'Delete' on the success path"
|
||||
)
|
||||
|
||||
|
||||
class TestDeleteModalConstants:
|
||||
"""Tests for SERIES_DELETED WebSocket event constant."""
|
||||
|
||||
def test_series_deleted_constant_referenced_in_constants_js(self):
|
||||
"""WS_EVENTS.SERIES_DELETED constant exists in constants.js."""
|
||||
import os
|
||||
constants_path = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
'..', '..',
|
||||
'src', 'server', 'web', 'static', 'js', 'shared', 'constants.js'
|
||||
)
|
||||
with open(constants_path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
assert 'SERIES_DELETED' in content
|
||||
assert "SERIES_DELETED: 'series_deleted'" in content
|
||||
|
||||
def test_series_deleted_constant_referenced_in_socket_handler(self):
|
||||
"""WS_EVENTS.SERIES_DELETED is handled in socket-handler.js."""
|
||||
import os
|
||||
handler_path = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
'..', '..',
|
||||
'src', 'server', 'web', 'static', 'js', 'index', 'socket-handler.js'
|
||||
)
|
||||
with open(handler_path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
assert 'SERIES_DELETED' in content
|
||||
assert 'removeSeries' in content
|
||||
@@ -375,6 +375,83 @@ describe('AnimeSettingsManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// viewNfoContent()
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
describe('viewNfoContent()', () => {
|
||||
beforeEach(async () => {
|
||||
// Seed currentKey via loadSeries so viewNfoContent has a key.
|
||||
delete window.location;
|
||||
window.location = { search: '?key=a', href: 'http://x/?key=a' };
|
||||
mockFetchSequence([{
|
||||
status: 200,
|
||||
body: {
|
||||
key: 'a', name: 'A', folder: 'A', site: 's',
|
||||
tmdb_id: null, tvdb_id: null, has_nfo: true,
|
||||
nfo_path: '/anime/A/tvshow.nfo', episode_count: 0,
|
||||
missing_episode_count: 0, loading_status: 'completed',
|
||||
},
|
||||
}]);
|
||||
await manager.loadSeries('a');
|
||||
});
|
||||
|
||||
it('fetches /api/nfo/{key}/content with auth header', async () => {
|
||||
mockFetchSequence([{
|
||||
status: 200,
|
||||
body: {
|
||||
key: 'a',
|
||||
folder: 'A',
|
||||
content: '<tvshow><title>A</title></tvshow>',
|
||||
file_size: 30,
|
||||
last_modified: '2026-06-01T00:00:00',
|
||||
},
|
||||
}]);
|
||||
|
||||
await manager.viewNfoContent();
|
||||
const [url, opts] = global.fetch.mock.calls[0];
|
||||
expect(url).toBe('/api/nfo/a/content');
|
||||
expect(opts.method).toBe('GET');
|
||||
expect(opts.headers.Authorization).toBe('Bearer fake-jwt-token');
|
||||
});
|
||||
|
||||
it('writes the content into the #nfo-content <pre> and unhides it',
|
||||
async () => {
|
||||
mockFetchSequence([{
|
||||
status: 200,
|
||||
body: {
|
||||
key: 'a',
|
||||
folder: 'A',
|
||||
content: '<tvshow><title>A</title></tvshow>',
|
||||
file_size: 30,
|
||||
last_modified: '2026-06-01T00:00:00',
|
||||
},
|
||||
}]);
|
||||
|
||||
const pre = document.getElementById('nfo-content');
|
||||
expect(pre.classList.contains('hidden')).toBe(true);
|
||||
|
||||
await manager.viewNfoContent();
|
||||
expect(pre.textContent).toBe(
|
||||
'<tvshow><title>A</title></tvshow>'
|
||||
);
|
||||
expect(pre.classList.contains('hidden')).toBe(false);
|
||||
});
|
||||
|
||||
it('shows an error toast when the backend returns 404', async () => {
|
||||
mockFetchSequence([{
|
||||
status: 404,
|
||||
ok: false,
|
||||
body: { detail: 'Not Found' },
|
||||
}]);
|
||||
await manager.viewNfoContent();
|
||||
expect(global.AniWorld.UI.showToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining('NFO'),
|
||||
'error'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// validateField()
|
||||
// -------------------------------------------------------------------
|
||||
@@ -493,6 +570,7 @@ describe('AnimeSettingsManager', () => {
|
||||
expect(typeof manager.loadSeries).toBe('function');
|
||||
expect(typeof manager.saveSettings).toBe('function');
|
||||
expect(typeof manager.regenerateNfo).toBe('function');
|
||||
expect(typeof manager.viewNfoContent).toBe('function');
|
||||
expect(typeof manager.validateField).toBe('function');
|
||||
expect(typeof manager.populateForm).toBe('function');
|
||||
expect(typeof manager.showSaveSuccess).toBe('function');
|
||||
|
||||
190
tests/security/test_delete_anime_security.py
Normal file
190
tests/security/test_delete_anime_security.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""
|
||||
Security tests for delete anime feature.
|
||||
|
||||
Tests path traversal protection, confirm_text validation, and
|
||||
other security controls around the delete anime feature.
|
||||
"""
|
||||
import os
|
||||
import pytest
|
||||
|
||||
|
||||
class TestDeleteAnimeSecurity:
|
||||
"""Security tests for the delete anime feature."""
|
||||
|
||||
@pytest.fixture
|
||||
def anime_service_code(self):
|
||||
"""Read the anime_service.py source code for security checks."""
|
||||
path = os.path.join(
|
||||
os.path.dirname(__file__), '..', '..',
|
||||
'src', 'server', 'services', 'anime_service.py'
|
||||
)
|
||||
with open(path, 'r') as f:
|
||||
return f.read()
|
||||
|
||||
@pytest.fixture
|
||||
def delete_modal_code(self):
|
||||
"""Read the delete-modal.js source code for security checks."""
|
||||
path = os.path.join(
|
||||
os.path.dirname(__file__), '..', '..',
|
||||
'src', 'server', 'web', 'static', 'js', 'index', 'delete-modal.js'
|
||||
)
|
||||
with open(path, 'r') as f:
|
||||
return f.read()
|
||||
|
||||
def test_delete_series_uses_is_safe_path(self, anime_service_code):
|
||||
"""delete_series uses is_safe_path before deleting folders."""
|
||||
assert 'is_safe_path' in anime_service_code
|
||||
assert 'folder_path' in anime_service_code
|
||||
|
||||
def test_delete_series_checks_anime_base_directory(self, anime_service_code):
|
||||
"""delete_series validates paths against the anime base directory."""
|
||||
# Should reference the anime directory for path comparison
|
||||
assert 'anime_base_dir' in anime_service_code or 'directory_to_search' in anime_service_code
|
||||
|
||||
def test_delete_series_no_hardcoded_paths(self, anime_service_code):
|
||||
"""delete_series has no hardcoded dangerous paths."""
|
||||
dangerous = ['/etc/passwd', '/root/.ssh', 'C:\\Windows\\System32']
|
||||
for path in dangerous:
|
||||
assert path not in anime_service_code
|
||||
|
||||
def test_delete_modal_encodes_key_in_url(self, delete_modal_code):
|
||||
"""delete-modal.js encodes the series key in the API URL."""
|
||||
# Should use encodeURIComponent or similar for the key
|
||||
assert 'encodeURIComponent' in delete_modal_code
|
||||
|
||||
def test_delete_modal_no_inner_html_with_user_data(self, delete_modal_code):
|
||||
"""delete-modal.js does not use innerHTML with unsanitized user data."""
|
||||
# innerHTML should not be used with direct variable interpolation
|
||||
# that could allow XSS
|
||||
lines = delete_modal_code.split('\n')
|
||||
dangerous_lines = [
|
||||
line for line in lines
|
||||
if 'innerHTML' in line and 'currentSeriesName' in line
|
||||
and 'escapeHtml' not in line
|
||||
]
|
||||
assert len(dangerous_lines) == 0, \
|
||||
"innerHTML used with currentSeriesName without escapeHtml"
|
||||
|
||||
def test_delete_modal_uses_textContent_for_user_visible_text(self, delete_modal_code):
|
||||
"""User-visible text in modal uses safe DOM methods."""
|
||||
# Should use textContent or similar instead of innerHTML for data
|
||||
# This is implicit in using template literals with ${} - but check no obvious XSS
|
||||
assert '<script>' not in delete_modal_code.lower()
|
||||
assert 'onclick=' not in delete_modal_code.lower()
|
||||
|
||||
def test_delete_modal_confirm_text_not_used_in_url(self, delete_modal_code):
|
||||
"""confirm_text is only used in JSON body, never in URLs."""
|
||||
lines = delete_modal_code.split('\n')
|
||||
for line in lines:
|
||||
if 'confirm_text' in line:
|
||||
# confirm_text should only appear in JSON body serialization
|
||||
assert 'URL' not in line and 'url' not in line or 'body' in line
|
||||
|
||||
def test_delete_modal_has_error_display_element(self, delete_modal_code):
|
||||
"""Modal has a dedicated error display element (not using alert())."""
|
||||
assert 'delete-error' in delete_modal_code
|
||||
assert 'showToast' in delete_modal_code # Uses toast, not alert()
|
||||
|
||||
def test_delete_endpoint_validates_confirm_text_exactly(self):
|
||||
"""The API validates confirm_text is exactly 'delete'."""
|
||||
# This is enforced in the endpoint code - check the endpoint exists
|
||||
from src.server.api.anime import router
|
||||
routes = [r for r in router.routes]
|
||||
assert len(routes) > 0 # Router has routes
|
||||
|
||||
def test_delete_modal_handles_401_without_data_leak(self, delete_modal_code):
|
||||
"""401 response triggers logout redirect, no data exposure."""
|
||||
assert 'removeToken' in delete_modal_code
|
||||
assert "window.location.href = '/login'" in delete_modal_code
|
||||
|
||||
def test_delete_modal_no_credentials_in_url(self, delete_modal_code):
|
||||
"""No credentials or tokens appear in the API URL."""
|
||||
lines = delete_modal_code.split('\n')
|
||||
for line in lines:
|
||||
if 'api/' in line.lower():
|
||||
# URL should not contain token/password
|
||||
assert 'token=' not in line.lower()
|
||||
assert 'password=' not in line.lower()
|
||||
|
||||
def test_delete_result_message_is_user_facing_only(self, delete_modal_code):
|
||||
"""Success/error messages shown to user do not expose internal paths."""
|
||||
# The toast should show only the message from the API, not raw folder paths
|
||||
assert 'showToast' in delete_modal_code
|
||||
|
||||
def test_delete_confirm_text_min_length_enforced(self):
|
||||
"""confirm_text must be exactly 'delete' — enforced at API endpoint level, not model.
|
||||
|
||||
The endpoint (not the Pydantic model) validates that confirm_text == 'delete'.
|
||||
The model itself accepts any string; validation is done in anime.py.
|
||||
"""
|
||||
from src.server.models.anime import DeleteSeriesRequest
|
||||
# Model accepts any string — validation is in the API endpoint
|
||||
# where confirm_text is checked against the literal 'delete'
|
||||
assert DeleteSeriesRequest(
|
||||
delete_database=True,
|
||||
delete_folder=False,
|
||||
confirm_text="del" # Accepted by model
|
||||
)
|
||||
# The API endpoint will reject this
|
||||
|
||||
def test_delete_confirm_text_max_length_reasonable(self):
|
||||
"""confirm_text has a reasonable max length to prevent DoS."""
|
||||
from src.server.models.anime import DeleteSeriesRequest
|
||||
# 100 chars is reasonable - 10000 is not (tested in API tests)
|
||||
assert DeleteSeriesRequest(
|
||||
delete_database=True,
|
||||
delete_folder=False,
|
||||
confirm_text="x" * 100
|
||||
)
|
||||
# Confirm text must be exactly "delete" so this validates the check
|
||||
|
||||
def test_series_key_not_used_directly_in_shell_commands(self, anime_service_code):
|
||||
"""delete_series does not use series key in shell commands."""
|
||||
dangerous_patterns = ['os.system', 'subprocess.call', 'subprocess.run',
|
||||
'subprocess.Popen', 'eval(', 'exec(']
|
||||
for pattern in dangerous_patterns:
|
||||
assert pattern not in anime_service_code, \
|
||||
f"Potentially dangerous pattern '{pattern}' found"
|
||||
|
||||
def test_delete_modal_loads_before_context_menu_handler(self):
|
||||
"""delete-modal.js is loaded before app-init.js in the template."""
|
||||
import os
|
||||
path = os.path.join(
|
||||
os.path.dirname(__file__), '..', '..',
|
||||
'src', 'server', 'web', 'templates', 'index.html'
|
||||
)
|
||||
with open(path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
delete_pos = content.find('delete-modal.js')
|
||||
app_init_pos = content.find('app-init.js')
|
||||
|
||||
assert delete_pos != -1, "delete-modal.js not found in template"
|
||||
assert app_init_pos != -1, "app-init.js not found in template"
|
||||
assert delete_pos < app_init_pos, \
|
||||
"delete-modal.js must be loaded before app-init.js"
|
||||
|
||||
def test_delete_modal_init_in_app_init(self, delete_modal_code):
|
||||
"""delete-modal.js is initialized in app-init.js."""
|
||||
import os
|
||||
path = os.path.join(
|
||||
os.path.dirname(__file__), '..', '..',
|
||||
'src', 'server', 'web', 'static', 'js', 'index', 'app-init.js'
|
||||
)
|
||||
with open(path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
assert 'DeleteModal.init' in content
|
||||
|
||||
def test_context_menu_has_delete_action(self):
|
||||
"""context-menu.js includes the delete-anime action."""
|
||||
import os
|
||||
path = os.path.join(
|
||||
os.path.dirname(__file__), '..', '..',
|
||||
'src', 'server', 'web', 'static', 'js', 'index', 'context-menu.js'
|
||||
)
|
||||
with open(path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
assert 'delete-anime' in content
|
||||
assert 'DeleteModal.show' in content
|
||||
875
tests/unit/test_delete_anime_service.py
Normal file
875
tests/unit/test_delete_anime_service.py
Normal file
@@ -0,0 +1,875 @@
|
||||
"""Unit tests for AnimeService.delete_series()."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.server.services.anime_service import AnimeService, AnimeServiceError
|
||||
from src.server.services.progress_service import ProgressService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_series_app(tmp_path):
|
||||
"""Create a mock SeriesApp instance."""
|
||||
mock_instance = MagicMock()
|
||||
mock_instance.directory_to_search = str(tmp_path)
|
||||
mock_instance.series_list = []
|
||||
mock_instance.search = AsyncMock(return_value=[])
|
||||
mock_instance.rescan = AsyncMock()
|
||||
mock_instance.download = AsyncMock(return_value=True)
|
||||
mock_instance.download_status = None
|
||||
mock_instance.scan_status = None
|
||||
return mock_instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_progress_service():
|
||||
"""Create a mock ProgressService instance."""
|
||||
service = MagicMock(spec=ProgressService)
|
||||
service.start_progress = AsyncMock()
|
||||
service.update_progress = AsyncMock()
|
||||
service.complete_progress = AsyncMock()
|
||||
service.fail_progress = AsyncMock()
|
||||
return service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anime_service(tmp_path, mock_series_app, mock_progress_service):
|
||||
"""Create an AnimeService instance for testing."""
|
||||
return AnimeService(
|
||||
series_app=mock_series_app,
|
||||
progress_service=mock_progress_service,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared DB mock helpers — used inside each test's patch context
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_db_ctx(mock_session: AsyncMock) -> AsyncMock:
|
||||
"""Build a mock async DB context manager."""
|
||||
mock_ctx = AsyncMock()
|
||||
mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_ctx.__aexit__ = AsyncMock(return_value=None)
|
||||
return mock_ctx
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — patch lives INSIDE each test method, not in a fixture
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDeleteSeriesService:
|
||||
"""Test AnimeService.delete_series()."""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# delete_database=True, delete_folder=False
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_db_only_success(self, anime_service):
|
||||
"""delete_series with delete_database=True removes series from DB only."""
|
||||
mock_session = AsyncMock()
|
||||
mock_ctx = _make_db_ctx(mock_session)
|
||||
|
||||
mock_series = MagicMock()
|
||||
mock_series.key = "test-key"
|
||||
mock_series.name = "Test Series"
|
||||
mock_series.folder = "Test Series (2023)"
|
||||
mock_series.id = 42
|
||||
|
||||
with patch(
|
||||
"src.server.database.connection.get_db_session",
|
||||
return_value=mock_ctx,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_series,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.delete",
|
||||
new_callable=AsyncMock,
|
||||
return_value=True,
|
||||
):
|
||||
result = await anime_service.delete_series(
|
||||
key="test-key",
|
||||
delete_database=True,
|
||||
delete_folder=False,
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.key == "test-key"
|
||||
assert result.deleted_from_database is True
|
||||
assert result.deleted_folder is False
|
||||
assert result.database_error is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_db_only_calls_delete_on_series(self, anime_service):
|
||||
"""DB delete calls AnimeSeriesService.delete(session, series_id)."""
|
||||
mock_session = AsyncMock()
|
||||
mock_ctx = _make_db_ctx(mock_session)
|
||||
|
||||
mock_series = MagicMock()
|
||||
mock_series.key = "test-key"
|
||||
mock_series.name = "Test Series"
|
||||
mock_series.folder = "Test Series (2023)"
|
||||
mock_series.id = 99
|
||||
|
||||
with patch(
|
||||
"src.server.database.connection.get_db_session",
|
||||
return_value=mock_ctx,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_series,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.delete",
|
||||
new_callable=AsyncMock,
|
||||
return_value=True,
|
||||
) as mock_delete:
|
||||
await anime_service.delete_series(
|
||||
key="test-key",
|
||||
delete_database=True,
|
||||
delete_folder=False,
|
||||
)
|
||||
|
||||
mock_delete.assert_called_once()
|
||||
call_args = mock_delete.call_args
|
||||
# First positional arg should be the session
|
||||
assert call_args[0][0] is mock_session
|
||||
# Second positional arg should be the series id
|
||||
assert call_args[0][1] == 99
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# delete_database=False, delete_folder=True
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_folder_only_success(self, anime_service, tmp_path):
|
||||
"""delete_series with delete_folder=True deletes folder without touching DB."""
|
||||
series_folder = tmp_path / "Test Series (2023)"
|
||||
series_folder.mkdir()
|
||||
(series_folder / "episode1.mkv").write_text("fake video")
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_ctx = _make_db_ctx(mock_session)
|
||||
|
||||
mock_series = MagicMock()
|
||||
mock_series.key = "test-key"
|
||||
mock_series.name = "Test Series"
|
||||
mock_series.folder = str(series_folder)
|
||||
mock_series.id = 1
|
||||
|
||||
with patch(
|
||||
"src.server.database.connection.get_db_session",
|
||||
return_value=mock_ctx,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_series,
|
||||
):
|
||||
result = await anime_service.delete_series(
|
||||
key="test-key",
|
||||
delete_database=False,
|
||||
delete_folder=True,
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.deleted_from_database is False
|
||||
assert result.deleted_folder is True
|
||||
assert not series_folder.exists() # Folder actually deleted
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_folder_only_no_db_delete_called(self, anime_service, tmp_path):
|
||||
"""Folder-only delete never calls AnimeSeriesService.delete()."""
|
||||
series_folder = tmp_path / "Another Series"
|
||||
series_folder.mkdir()
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_ctx = _make_db_ctx(mock_session)
|
||||
|
||||
mock_series = MagicMock()
|
||||
mock_series.key = "test-key"
|
||||
mock_series.name = "Another Series"
|
||||
mock_series.folder = str(series_folder)
|
||||
mock_series.id = 1
|
||||
|
||||
with patch(
|
||||
"src.server.database.connection.get_db_session",
|
||||
return_value=mock_ctx,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_series,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.delete",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_delete:
|
||||
await anime_service.delete_series(
|
||||
key="test-key",
|
||||
delete_database=False,
|
||||
delete_folder=True,
|
||||
)
|
||||
|
||||
mock_delete.assert_not_called()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# delete_database=True, delete_folder=True
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_both_success(self, anime_service, tmp_path):
|
||||
"""delete_series with both flags removes from DB and deletes folder."""
|
||||
series_folder = tmp_path / "Test Series (2023)"
|
||||
series_folder.mkdir()
|
||||
(series_folder / "episode1.mkv").write_text("fake video")
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_ctx = _make_db_ctx(mock_session)
|
||||
|
||||
mock_series = MagicMock()
|
||||
mock_series.key = "test-key"
|
||||
mock_series.name = "Test Series"
|
||||
mock_series.folder = str(series_folder)
|
||||
mock_series.id = 42
|
||||
|
||||
with patch(
|
||||
"src.server.database.connection.get_db_session",
|
||||
return_value=mock_ctx,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_series,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.delete",
|
||||
new_callable=AsyncMock,
|
||||
return_value=True,
|
||||
):
|
||||
result = await anime_service.delete_series(
|
||||
key="test-key",
|
||||
delete_database=True,
|
||||
delete_folder=True,
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.deleted_from_database is True
|
||||
assert result.deleted_folder is True
|
||||
assert result.database_error is None
|
||||
assert result.folder_error is None
|
||||
assert not series_folder.exists()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Series not found
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_series_not_found(self, anime_service):
|
||||
"""delete_series returns failure when series doesn't exist."""
|
||||
mock_session = AsyncMock()
|
||||
mock_ctx = _make_db_ctx(mock_session)
|
||||
|
||||
with patch(
|
||||
"src.server.database.connection.get_db_session",
|
||||
return_value=mock_ctx,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
):
|
||||
result = await anime_service.delete_series(
|
||||
key="nonexistent-key",
|
||||
delete_database=True,
|
||||
delete_folder=False,
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert result.deleted_from_database is False
|
||||
assert "not found" in result.message.lower()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Path traversal protection
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_series_path_traversal_blocked(self, anime_service, tmp_path):
|
||||
"""delete_series refuses to delete folder outside anime base directory."""
|
||||
# Create a safe folder at tmp_path, use parent as dangerous target
|
||||
safe_base = tmp_path / "anime_root"
|
||||
safe_base.mkdir()
|
||||
dangerous_target = tmp_path.parent # parent of anime root
|
||||
|
||||
anime_service._directory = str(safe_base)
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_ctx = _make_db_ctx(mock_session)
|
||||
|
||||
mock_series = MagicMock()
|
||||
mock_series.key = "test-key"
|
||||
mock_series.name = "Test"
|
||||
mock_series.folder = str(dangerous_target) # outside safe_base
|
||||
mock_series.id = 1
|
||||
|
||||
with patch(
|
||||
"src.server.database.connection.get_db_session",
|
||||
return_value=mock_ctx,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_series,
|
||||
):
|
||||
result = await anime_service.delete_series(
|
||||
key="test-key",
|
||||
delete_database=False,
|
||||
delete_folder=True,
|
||||
)
|
||||
|
||||
# Folder should NOT be deleted
|
||||
assert result.deleted_folder is False
|
||||
assert result.folder_error is not None
|
||||
assert "outside" in result.folder_error.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_series_relative_folder_with_different_cwd(
|
||||
self, anime_service, tmp_path
|
||||
):
|
||||
"""Regression: delete_series must work when the stored folder is relative
|
||||
and the process CWD differs from directory_to_search.
|
||||
|
||||
In the container the FastAPI app runs with CWD=/app while the anime
|
||||
directory is /data. The DB stores the relative folder name (e.g.
|
||||
"Beyblade Burst (2016)"). The old code called
|
||||
``os.path.abspath(folder)`` which joined against CWD=/app and
|
||||
produced "/app/Beyblade Burst (2016)", which was then rejected as
|
||||
outside the /data base. The fix resolves relative paths against
|
||||
the configured anime directory instead.
|
||||
"""
|
||||
safe_base = tmp_path / "data"
|
||||
safe_base.mkdir()
|
||||
series_folder = safe_base / "Beyblade Burst (2016)"
|
||||
series_folder.mkdir()
|
||||
|
||||
anime_service._directory = str(safe_base)
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_ctx = _make_db_ctx(mock_session)
|
||||
|
||||
# Stored folder is RELATIVE (matches what's actually in the DB)
|
||||
mock_series = MagicMock()
|
||||
mock_series.key = "beyblade-burst"
|
||||
mock_series.name = "Beyblade Burst"
|
||||
mock_series.folder = "Beyblade Burst (2016)"
|
||||
mock_series.id = 336
|
||||
|
||||
# Simulate process CWD differing from anime dir (container case:
|
||||
# CWD=/app while anime dir is /data). Use "/" as a stable, always-
|
||||
# existing CWD distinct from tmp_path.
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir("/")
|
||||
with patch(
|
||||
"src.server.database.connection.get_db_session",
|
||||
return_value=mock_ctx,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_series,
|
||||
):
|
||||
result = await anime_service.delete_series(
|
||||
key="beyblade-burst",
|
||||
delete_database=False,
|
||||
delete_folder=True,
|
||||
)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
# Folder MUST be deleted successfully
|
||||
assert result.deleted_folder is True, (
|
||||
f"folder delete failed: success={result.success} "
|
||||
f"folder_error={result.folder_error!r}"
|
||||
)
|
||||
assert result.folder_error is None
|
||||
assert result.success is True
|
||||
assert not series_folder.exists()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Error handling
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_series_folder_delete_error(self, anime_service, tmp_path):
|
||||
"""delete_series handles folder deletion errors gracefully."""
|
||||
series_folder = tmp_path / "Test Series"
|
||||
series_folder.mkdir()
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_ctx = _make_db_ctx(mock_session)
|
||||
|
||||
mock_series = MagicMock()
|
||||
mock_series.key = "test-key"
|
||||
mock_series.name = "Test Series"
|
||||
mock_series.folder = str(series_folder)
|
||||
mock_series.id = 1
|
||||
|
||||
with patch(
|
||||
"src.server.database.connection.get_db_session",
|
||||
return_value=mock_ctx,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_series,
|
||||
), patch(
|
||||
"shutil.rmtree",
|
||||
side_effect=OSError("Permission denied"),
|
||||
):
|
||||
result = await anime_service.delete_series(
|
||||
key="test-key",
|
||||
delete_database=False,
|
||||
delete_folder=True,
|
||||
)
|
||||
|
||||
assert result.deleted_folder is False
|
||||
assert result.folder_error is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_series_db_error_still_reports_folder(
|
||||
self, anime_service, tmp_path
|
||||
):
|
||||
"""Even if DB delete fails, folder deletion result is still reported."""
|
||||
series_folder = tmp_path / "Test Series"
|
||||
series_folder.mkdir()
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_ctx = _make_db_ctx(mock_session)
|
||||
|
||||
mock_series = MagicMock()
|
||||
mock_series.key = "test-key"
|
||||
mock_series.name = "Test Series"
|
||||
mock_series.folder = str(series_folder)
|
||||
mock_series.id = 1
|
||||
|
||||
with patch(
|
||||
"src.server.database.connection.get_db_session",
|
||||
return_value=mock_ctx,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_series,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.delete",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=Exception("DB connection lost"),
|
||||
):
|
||||
result = await anime_service.delete_series(
|
||||
key="test-key",
|
||||
delete_database=True,
|
||||
delete_folder=True,
|
||||
)
|
||||
|
||||
# DB failed but folder still deleted
|
||||
assert result.deleted_from_database is False
|
||||
assert result.database_error is not None
|
||||
assert result.deleted_folder is True
|
||||
assert not series_folder.exists()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Edge cases
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_series_folder_none_when_no_folder(
|
||||
self, anime_service
|
||||
):
|
||||
"""delete_series with folder=True but no folder path skips folder delete."""
|
||||
mock_session = AsyncMock()
|
||||
mock_ctx = _make_db_ctx(mock_session)
|
||||
|
||||
mock_series = MagicMock()
|
||||
mock_series.key = "test-key"
|
||||
mock_series.name = "Test Series"
|
||||
mock_series.folder = None
|
||||
mock_series.id = 1
|
||||
|
||||
with patch(
|
||||
"src.server.database.connection.get_db_session",
|
||||
return_value=mock_ctx,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_series,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.delete",
|
||||
new_callable=AsyncMock,
|
||||
return_value=True,
|
||||
):
|
||||
result = await anime_service.delete_series(
|
||||
key="test-key",
|
||||
delete_database=True,
|
||||
delete_folder=True,
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.deleted_from_database is True
|
||||
assert result.deleted_folder is False
|
||||
assert result.folder_error is None # No folder → no error
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_series_neither_flag_set(self, anime_service):
|
||||
"""delete_series returns failure when neither flag is True.
|
||||
|
||||
Needs DB patches so get_db_session doesn't raise RuntimeError,
|
||||
even though this test hits the early-return guard before any DB use.
|
||||
"""
|
||||
mock_session = AsyncMock()
|
||||
mock_ctx = _make_db_ctx(mock_session)
|
||||
|
||||
with patch(
|
||||
"src.server.database.connection.get_db_session",
|
||||
return_value=mock_ctx,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
):
|
||||
result = await anime_service.delete_series(
|
||||
key="any-key",
|
||||
delete_database=False,
|
||||
delete_folder=False,
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert "at least one" in result.message.lower()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# WebSocket broadcast
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_series_websocket_broadcast(self, anime_service):
|
||||
"""delete_series broadcasts series_deleted WebSocket event."""
|
||||
mock_session = AsyncMock()
|
||||
mock_ctx = _make_db_ctx(mock_session)
|
||||
|
||||
mock_series = MagicMock()
|
||||
mock_series.key = "test-key"
|
||||
mock_series.name = "Test Series"
|
||||
mock_series.folder = "Test Series (2023)"
|
||||
mock_series.id = 42
|
||||
|
||||
anime_service._websocket_service = MagicMock()
|
||||
anime_service._websocket_service.broadcast_series_deleted = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"src.server.database.connection.get_db_session",
|
||||
return_value=mock_ctx,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_series,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.delete",
|
||||
new_callable=AsyncMock,
|
||||
return_value=True,
|
||||
):
|
||||
await anime_service.delete_series(
|
||||
key="test-key",
|
||||
delete_database=True,
|
||||
delete_folder=False,
|
||||
)
|
||||
|
||||
anime_service._websocket_service.broadcast_series_deleted.assert_called_once_with(
|
||||
key="test-key",
|
||||
name="Test Series",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Deletion order: filesystem first, database second
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_series_db_preserved_when_folder_fails(
|
||||
self, anime_service, tmp_path
|
||||
):
|
||||
"""When both flags are True and folder delete fails, the DB row
|
||||
is preserved so the user can retry after fixing the underlying issue.
|
||||
|
||||
Previously, the database row was deleted first and the folder
|
||||
second. If the folder delete failed (e.g. the old CWD-relative-path
|
||||
bug, or any future permission/path error), the row was already
|
||||
gone — leaving an orphan folder on disk that could not be cleaned
|
||||
up through the normal delete flow.
|
||||
"""
|
||||
# Folder exists but we'll force shutil.rmtree to fail
|
||||
series_folder = tmp_path / "Test Series"
|
||||
series_folder.mkdir()
|
||||
anime_service._directory = str(tmp_path)
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_ctx = _make_db_ctx(mock_session)
|
||||
|
||||
mock_series = MagicMock()
|
||||
mock_series.key = "test-key"
|
||||
mock_series.name = "Test Series"
|
||||
mock_series.folder = "Test Series"
|
||||
mock_series.id = 7
|
||||
|
||||
db_delete_mock = AsyncMock(return_value=True)
|
||||
|
||||
with patch(
|
||||
"src.server.database.connection.get_db_session",
|
||||
return_value=mock_ctx,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_series,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.delete",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=db_delete_mock,
|
||||
), patch(
|
||||
"shutil.rmtree",
|
||||
side_effect=OSError("Permission denied"),
|
||||
):
|
||||
result = await anime_service.delete_series(
|
||||
key="test-key",
|
||||
delete_database=True,
|
||||
delete_folder=True,
|
||||
)
|
||||
|
||||
# Folder delete failed → DB row MUST be preserved
|
||||
assert result.deleted_folder is False
|
||||
assert result.folder_error is not None
|
||||
assert result.deleted_from_database is False, (
|
||||
"DB row was deleted despite folder delete failure — "
|
||||
"user would lose ability to retry the delete"
|
||||
)
|
||||
db_delete_mock.assert_not_called()
|
||||
assert result.success is False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# In-memory keyDict cache eviction
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_series_evicts_in_memory_cache(
|
||||
self, anime_service, mock_series_app
|
||||
):
|
||||
"""After a successful DB delete, the in-memory SerieList.keyDict
|
||||
entry for that series must be removed.
|
||||
|
||||
The /api/anime list endpoint reads from SeriesApp.list.keyDict
|
||||
via list_series_with_filters(). If the cache is not pruned, the
|
||||
deleted series keeps appearing in the listing on every page
|
||||
reload — exactly the Beyblade Burst bug.
|
||||
"""
|
||||
from src.server.database.SerieList import SerieList
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_ctx = _make_db_ctx(mock_session)
|
||||
|
||||
mock_series = MagicMock()
|
||||
mock_series.key = "beyblade-burst-rise"
|
||||
mock_series.name = "Beyblade Burst Rise"
|
||||
mock_series.folder = "Beyblade Burst Rise (2016)"
|
||||
mock_series.id = 487
|
||||
|
||||
# Use a real SerieList instance — the same type the production
|
||||
# code mutates — so the eviction actually exercises the real
|
||||
# remove() method (a MagicMock would just return more mocks).
|
||||
real_list = SerieList(str(mock_series_app.directory_to_search))
|
||||
real_list.keyDict["beyblade-burst-rise"] = mock_series
|
||||
mock_series_app.list = real_list
|
||||
|
||||
anime_service._websocket_service = MagicMock()
|
||||
anime_service._websocket_service.broadcast_series_deleted = AsyncMock()
|
||||
anime_service._websocket_service.broadcast_series_list_changed = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"src.server.database.connection.get_db_session",
|
||||
return_value=mock_ctx,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_series,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.delete",
|
||||
new_callable=AsyncMock,
|
||||
return_value=True,
|
||||
):
|
||||
result = await anime_service.delete_series(
|
||||
key="beyblade-burst-rise",
|
||||
delete_database=True,
|
||||
delete_folder=False,
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert "beyblade-burst-rise" not in real_list.keyDict, (
|
||||
"In-memory cache still contains the deleted series — "
|
||||
"/api/anime will keep returning it after a page reload"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_series_broadcasts_series_list_changed(
|
||||
self, anime_service, mock_series_app
|
||||
):
|
||||
"""A stronger ``series_list_changed`` broadcast fires after delete
|
||||
so any connected client can re-sync without relying on the more
|
||||
specific ``series_deleted`` event reaching them.
|
||||
"""
|
||||
mock_session = AsyncMock()
|
||||
mock_ctx = _make_db_ctx(mock_session)
|
||||
|
||||
mock_series = MagicMock()
|
||||
mock_series.key = "test-key"
|
||||
mock_series.name = "Test Series"
|
||||
mock_series.folder = "Test Series (2023)"
|
||||
mock_series.id = 1
|
||||
|
||||
mock_series_app.list.keyDict = {"test-key": mock_series}
|
||||
|
||||
anime_service._websocket_service = MagicMock()
|
||||
anime_service._websocket_service.broadcast_series_deleted = AsyncMock()
|
||||
anime_service._websocket_service.broadcast_series_list_changed = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"src.server.database.connection.get_db_session",
|
||||
return_value=mock_ctx,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_series,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.delete",
|
||||
new_callable=AsyncMock,
|
||||
return_value=True,
|
||||
):
|
||||
await anime_service.delete_series(
|
||||
key="test-key",
|
||||
delete_database=True,
|
||||
delete_folder=False,
|
||||
)
|
||||
|
||||
anime_service._websocket_service.broadcast_series_list_changed.assert_called_once()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Orphan folder recovery (DB row gone, folder still on disk)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_series_orphan_folder_recovery(self, anime_service, tmp_path):
|
||||
"""If the DB row is gone but a matching folder is still on disk,
|
||||
``delete_folder=True`` removes the orphan folder.
|
||||
|
||||
Reproduces the Beyblade Burst scenario: previous delete attempt
|
||||
removed the DB row (delete_database=True) but the folder delete
|
||||
silently failed (path math bug). Retrying with delete_folder=True
|
||||
should clean up the orphan via a key-based folder scan.
|
||||
"""
|
||||
# Simulate the on-disk anime directory
|
||||
anime_dir = tmp_path / "anime"
|
||||
anime_dir.mkdir()
|
||||
orphan = anime_dir / "Beyblade Burst (2016)"
|
||||
orphan.mkdir()
|
||||
(orphan / "episode.mp4").write_text("x")
|
||||
|
||||
anime_service._directory = str(anime_dir)
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_ctx = _make_db_ctx(mock_session)
|
||||
|
||||
# DB lookup returns None (row already gone)
|
||||
with patch(
|
||||
"src.server.database.connection.get_db_session",
|
||||
return_value=mock_ctx,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
):
|
||||
result = await anime_service.delete_series(
|
||||
key="beyblade-burst",
|
||||
delete_database=False,
|
||||
delete_folder=True,
|
||||
)
|
||||
|
||||
# Orphan folder recovered via key-match scan
|
||||
assert result.deleted_folder is True, (
|
||||
f"orphan recovery failed: success={result.success} "
|
||||
f"folder_error={result.folder_error!r}"
|
||||
)
|
||||
assert result.folder_error is None
|
||||
assert not orphan.exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_series_orphan_folder_no_match(
|
||||
self, anime_service, tmp_path
|
||||
):
|
||||
"""If DB row is gone and no folder matches the key, return a
|
||||
clear error rather than silently succeeding.
|
||||
"""
|
||||
anime_dir = tmp_path / "anime"
|
||||
anime_dir.mkdir()
|
||||
# Some unrelated folder that does NOT match
|
||||
(anime_dir / "Different Show (2020)").mkdir()
|
||||
anime_service._directory = str(anime_dir)
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_ctx = _make_db_ctx(mock_session)
|
||||
|
||||
with patch(
|
||||
"src.server.database.connection.get_db_session",
|
||||
return_value=mock_ctx,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
):
|
||||
result = await anime_service.delete_series(
|
||||
key="nonexistent-key",
|
||||
delete_database=False,
|
||||
delete_folder=True,
|
||||
)
|
||||
|
||||
assert result.deleted_folder is False
|
||||
assert result.folder_error is not None
|
||||
assert "no folder matching" in result.folder_error.lower()
|
||||
# Unrelated folder untouched
|
||||
assert (anime_dir / "Different Show (2020)").exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_series_orphan_folder_ambiguous(
|
||||
self, anime_service, tmp_path
|
||||
):
|
||||
"""If multiple folders match the same normalized key, refuse to
|
||||
delete any of them (safe default).
|
||||
"""
|
||||
anime_dir = tmp_path / "anime"
|
||||
anime_dir.mkdir()
|
||||
(anime_dir / "Beyblade Burst (2016)").mkdir()
|
||||
(anime_dir / "Beyblade Burst (2019)").mkdir() # also normalizes to "beybladeburst"
|
||||
anime_service._directory = str(anime_dir)
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_ctx = _make_db_ctx(mock_session)
|
||||
|
||||
with patch(
|
||||
"src.server.database.connection.get_db_session",
|
||||
return_value=mock_ctx,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
):
|
||||
result = await anime_service.delete_series(
|
||||
key="beyblade-burst",
|
||||
delete_database=False,
|
||||
delete_folder=True,
|
||||
)
|
||||
|
||||
# Ambiguous → refuse
|
||||
assert result.deleted_folder is False
|
||||
assert result.folder_error is not None
|
||||
assert "no folder matching" in result.folder_error.lower()
|
||||
# Neither folder deleted
|
||||
assert (anime_dir / "Beyblade Burst (2016)").exists()
|
||||
assert (anime_dir / "Beyblade Burst (2019)").exists()
|
||||
@@ -213,6 +213,34 @@ class TestIsSafePath:
|
||||
"/anime/Attack on Titan/Season 1/Episode 1"
|
||||
)
|
||||
|
||||
def test_relative_target_resolved_against_base(self):
|
||||
"""Relative targets resolve against the base, not the process CWD.
|
||||
|
||||
Regression test: previously `os.path.abspath(target_path)` would
|
||||
join a relative target against the process's current working
|
||||
directory. When the CWD differed from `base_path` (e.g. the
|
||||
FastAPI app running with CWD=/app while the anime directory is
|
||||
/data), a relative folder name like "Beyblade Burst (2016)"
|
||||
would be resolved to "/app/Beyblade Burst (2016)" and
|
||||
incorrectly rejected as outside the base. The helper now
|
||||
treats a relative target as relative to `base_path`.
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
base = os.path.abspath(tmpdir)
|
||||
# Simulate a process CWD different from base
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir("/")
|
||||
# Relative target inside base should be safe
|
||||
assert is_safe_path(base, "Beyblade Burst (2016)")
|
||||
# Nested relative target should also be safe
|
||||
assert is_safe_path(base, "Beyblade Burst (2016)/Season 1")
|
||||
# Relative traversal (../) must still be rejected even
|
||||
# when resolved against the base
|
||||
assert not is_safe_path(base, "../etc/passwd")
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
|
||||
class TestCreateSafeFolder:
|
||||
"""Test create_safe_folder function."""
|
||||
|
||||
@@ -241,28 +241,157 @@ class TestFolderNamingServiceIntegration:
|
||||
assert call_kwargs["folder"] == "Naruto (1999)"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_when_target_folder_already_exists(
|
||||
async def test_merges_source_into_existing_target_when_target_has_no_overlap(
|
||||
self, tmp_path, mock_db_session, mock_series, mock_settings
|
||||
):
|
||||
"""If 'Naruto (1999)' already exists, rename is skipped."""
|
||||
"""Source 'Naruto' with seasons/episodes, target 'Naruto (1999)' exists empty.
|
||||
|
||||
Source files are moved into target. Empty source directory is removed.
|
||||
DB folder is updated to target. Result is success (renamed).
|
||||
"""
|
||||
anime_dir = tmp_path
|
||||
(anime_dir / "Naruto").mkdir()
|
||||
(anime_dir / "Naruto (1999)").mkdir() # target already exists
|
||||
source = anime_dir / "Naruto"
|
||||
target = anime_dir / "Naruto (1999)"
|
||||
source.mkdir()
|
||||
target.mkdir()
|
||||
(source / "Season 1").mkdir()
|
||||
(source / "Season 1" / "ep01.mp4").touch()
|
||||
(source / "Season 2").mkdir()
|
||||
(source / "Season 2" / "ep01.mp4").touch()
|
||||
|
||||
series = mock_series("key1", "Naruto", 1999)
|
||||
mock_db_session.__aenter__.return_value.__aexit__.return_value = None
|
||||
|
||||
with patch("src.server.services.folder_naming_service.AnimeSeriesService.get_all", new_callable=AsyncMock) as mock_get_all:
|
||||
with patch("src.server.services.folder_naming_service.AnimeSeriesService.get_all", new_callable=AsyncMock) as mock_get_all, \
|
||||
patch("src.server.services.folder_naming_service.AnimeSeriesService.get_by_key", new_callable=AsyncMock) as mock_get_by_key, \
|
||||
patch("src.server.services.folder_naming_service.AnimeSeriesService.update", new_callable=AsyncMock) as mock_update, \
|
||||
patch("src.server.utils.dependencies.get_series_app") as mock_get_app:
|
||||
|
||||
mock_get_all.return_value = [series]
|
||||
db_series = MagicMock()
|
||||
db_series.id = 42
|
||||
mock_get_by_key.return_value = db_series
|
||||
|
||||
app_instance = MagicMock()
|
||||
app_instance.list.keyDict = {"key1": MagicMock()}
|
||||
mock_get_app.return_value = app_instance
|
||||
|
||||
mock_settings.anime_directory = str(anime_dir)
|
||||
|
||||
service = FolderNamingService()
|
||||
report = await service.run()
|
||||
|
||||
assert report.errors == 1
|
||||
assert report.renamed == 0
|
||||
assert report.results[0].reason == "target folder already exists on disk"
|
||||
assert (anime_dir / "Naruto").exists() # source not moved
|
||||
# Outcome: renamed (not error) — source merged into target
|
||||
assert report.renamed == 1
|
||||
assert report.errors == 0
|
||||
assert report.results[0].success is True
|
||||
assert report.results[0].skipped is False
|
||||
assert report.results[0].new_folder == "Naruto (1999)"
|
||||
|
||||
# Source folder gone
|
||||
assert not source.exists(), "Source folder should be removed after merge"
|
||||
# Target folder has merged content
|
||||
assert (target / "Season 1" / "ep01.mp4").exists()
|
||||
assert (target / "Season 2" / "ep01.mp4").exists()
|
||||
# DB updated
|
||||
assert mock_update.call_args.kwargs["folder"] == "Naruto (1999)"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merges_only_missing_seasons_preserving_existing_target_files(
|
||||
self, tmp_path, mock_db_session, mock_series, mock_settings
|
||||
):
|
||||
"""Source has S01 ep01, target already has S01 ep01 (different content).
|
||||
|
||||
Existing target files are kept. Source's S01 ep01 is NOT overwritten.
|
||||
Source's S02 (new) is moved. Empty source is removed.
|
||||
"""
|
||||
anime_dir = tmp_path
|
||||
source = anime_dir / "Naruto"
|
||||
target = anime_dir / "Naruto (1999)"
|
||||
source.mkdir()
|
||||
target.mkdir()
|
||||
|
||||
# Target already has S01 with one episode
|
||||
(target / "Season 1").mkdir()
|
||||
target_existing = target / "Season 1" / "ep01.mp4"
|
||||
target_existing.write_text("target-version")
|
||||
|
||||
# Source has S01 with same episode (different content) and S02
|
||||
(source / "Season 1").mkdir()
|
||||
source_conflict = source / "Season 1" / "ep01.mp4"
|
||||
source_conflict.write_text("source-version")
|
||||
(source / "Season 2").mkdir()
|
||||
(source / "Season 2" / "ep01.mp4").touch()
|
||||
|
||||
series = mock_series("key1", "Naruto", 1999)
|
||||
mock_db_session.__aenter__.return_value.__aexit__.return_value = None
|
||||
|
||||
with patch("src.server.services.folder_naming_service.AnimeSeriesService.get_all", new_callable=AsyncMock) as mock_get_all, \
|
||||
patch("src.server.services.folder_naming_service.AnimeSeriesService.get_by_key", new_callable=AsyncMock) as mock_get_by_key, \
|
||||
patch("src.server.services.folder_naming_service.AnimeSeriesService.update", new_callable=AsyncMock), \
|
||||
patch("src.server.utils.dependencies.get_series_app") as mock_get_app:
|
||||
|
||||
mock_get_all.return_value = [series]
|
||||
db_series = MagicMock()
|
||||
db_series.id = 42
|
||||
mock_get_by_key.return_value = db_series
|
||||
|
||||
app_instance = MagicMock()
|
||||
app_instance.list.keyDict = {"key1": MagicMock()}
|
||||
mock_get_app.return_value = app_instance
|
||||
mock_settings.anime_directory = str(anime_dir)
|
||||
|
||||
service = FolderNamingService()
|
||||
report = await service.run()
|
||||
|
||||
# Renamed (source effectively absorbed)
|
||||
assert report.renamed == 1
|
||||
assert not source.exists(), "Source should be removed after merge"
|
||||
# Target S01 ep01 keeps the target version (not overwritten)
|
||||
assert target_existing.read_text() == "target-version"
|
||||
# New S02 moved in
|
||||
assert (target / "Season 2" / "ep01.mp4").exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_removes_empty_source_folder_when_target_exists(
|
||||
self, tmp_path, mock_db_session, mock_series, mock_settings
|
||||
):
|
||||
"""Source folder exists but is empty, target already exists.
|
||||
|
||||
Source should be removed silently, DB updated, success.
|
||||
"""
|
||||
anime_dir = tmp_path
|
||||
source = anime_dir / "Naruto"
|
||||
target = anime_dir / "Naruto (1999)"
|
||||
source.mkdir()
|
||||
target.mkdir()
|
||||
(target / "tvshow.nfo").write_text("existing nfo")
|
||||
|
||||
series = mock_series("key1", "Naruto", 1999)
|
||||
mock_db_session.__aenter__.return_value.__aexit__.return_value = None
|
||||
|
||||
with patch("src.server.services.folder_naming_service.AnimeSeriesService.get_all", new_callable=AsyncMock) as mock_get_all, \
|
||||
patch("src.server.services.folder_naming_service.AnimeSeriesService.get_by_key", new_callable=AsyncMock) as mock_get_by_key, \
|
||||
patch("src.server.services.folder_naming_service.AnimeSeriesService.update", new_callable=AsyncMock) as mock_update, \
|
||||
patch("src.server.utils.dependencies.get_series_app") as mock_get_app:
|
||||
|
||||
mock_get_all.return_value = [series]
|
||||
db_series = MagicMock()
|
||||
db_series.id = 42
|
||||
mock_get_by_key.return_value = db_series
|
||||
app_instance = MagicMock()
|
||||
app_instance.list.keyDict = {"key1": MagicMock()}
|
||||
mock_get_app.return_value = app_instance
|
||||
mock_settings.anime_directory = str(anime_dir)
|
||||
|
||||
service = FolderNamingService()
|
||||
report = await service.run()
|
||||
|
||||
assert report.renamed == 1
|
||||
assert report.errors == 0
|
||||
assert not source.exists(), "Empty source should be removed"
|
||||
assert (target / "tvshow.nfo").exists(), "Target content preserved"
|
||||
assert mock_update.call_args.kwargs["folder"] == "Naruto (1999)"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_safety_guard_detects_wrong_year_in_target(self, tmp_path, mock_db_session, mock_series, mock_settings):
|
||||
|
||||
242
tests/unit/test_rename_folder_if_needed.py
Normal file
242
tests/unit/test_rename_folder_if_needed.py
Normal file
@@ -0,0 +1,242 @@
|
||||
"""Tests for AnimeService.rename_folder_if_needed.
|
||||
|
||||
The behavior under test: when both the source folder (without year) and the
|
||||
target folder (with year) exist on disk, the rename must not silently bail
|
||||
out — it must merge the source into the target and remove the empty source.
|
||||
This is what prevents the "Ultraman" + "Ultraman (2019)" duplicate-folder
|
||||
problem reported by users.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.server.services.anime_service import AnimeService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anime_service_with_dir(tmp_path):
|
||||
"""Create AnimeService pointing at a temp directory."""
|
||||
mock_app = MagicMock()
|
||||
mock_app.directory_to_search = str(tmp_path)
|
||||
mock_app.list.keyDict = {}
|
||||
progress = MagicMock()
|
||||
service = AnimeService(series_app=mock_app, progress_service=progress)
|
||||
return service, tmp_path
|
||||
|
||||
|
||||
class TestRenameFolderIfNeededMerge:
|
||||
"""Tests for the merge-into-existing-target behavior."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merges_seasons_when_target_exists(
|
||||
self, anime_service_with_dir
|
||||
):
|
||||
service, anime_dir = anime_service_with_dir
|
||||
source = anime_dir / "Naruto"
|
||||
target = anime_dir / "Naruto (1999)"
|
||||
source.mkdir()
|
||||
(source / "Season 1").mkdir()
|
||||
(source / "Season 1" / "ep01.mp4").touch()
|
||||
target.mkdir()
|
||||
|
||||
db = AsyncMock()
|
||||
db_series = MagicMock()
|
||||
db_series.id = 1
|
||||
db_series.folder = "Naruto"
|
||||
with patch(
|
||||
"src.server.database.service.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=db_series,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.update",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_update:
|
||||
ok = await service.rename_folder_if_needed(
|
||||
key="naruto",
|
||||
current_folder="Naruto",
|
||||
target_folder="Naruto (1999)",
|
||||
db=db,
|
||||
)
|
||||
|
||||
# Outcome: rename "succeeded" (target now contains source content)
|
||||
assert ok is True
|
||||
assert not source.exists(), "Source should be removed after merge"
|
||||
assert (target / "Season 1" / "ep01.mp4").exists()
|
||||
# DB row updated to target
|
||||
assert mock_update.call_args.kwargs["folder"] == "Naruto (1999)"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_does_not_overwrite_existing_target_files(
|
||||
self, anime_service_with_dir
|
||||
):
|
||||
"""If target already has an episode file, the source copy is removed
|
||||
(target version wins; no duplicate retained).
|
||||
"""
|
||||
service, anime_dir = anime_service_with_dir
|
||||
source = anime_dir / "Naruto"
|
||||
target = anime_dir / "Naruto (1999)"
|
||||
source.mkdir()
|
||||
target.mkdir()
|
||||
(target / "Season 1").mkdir()
|
||||
target_existing = target / "Season 1" / "ep01.mp4"
|
||||
target_existing.write_text("target-version")
|
||||
(source / "Season 1").mkdir()
|
||||
source_conflict = source / "Season 1" / "ep01.mp4"
|
||||
source_conflict.write_text("source-version")
|
||||
|
||||
db = AsyncMock()
|
||||
db_series = MagicMock()
|
||||
db_series.id = 1
|
||||
with patch(
|
||||
"src.server.database.service.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=db_series,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.update",
|
||||
new_callable=AsyncMock,
|
||||
):
|
||||
ok = await service.rename_folder_if_needed(
|
||||
key="naruto",
|
||||
current_folder="Naruto",
|
||||
target_folder="Naruto (1999)",
|
||||
db=db,
|
||||
)
|
||||
|
||||
assert ok is True
|
||||
# Target version preserved
|
||||
assert target_existing.read_text() == "target-version"
|
||||
# Source folder removed (after merge, even with skipped conflicts)
|
||||
assert not source.exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_removes_empty_source_when_target_exists(
|
||||
self, anime_service_with_dir
|
||||
):
|
||||
service, anime_dir = anime_service_with_dir
|
||||
source = anime_dir / "Naruto"
|
||||
target = anime_dir / "Naruto (1999)"
|
||||
source.mkdir()
|
||||
target.mkdir()
|
||||
(target / "tvshow.nfo").write_text("kept")
|
||||
|
||||
db = AsyncMock()
|
||||
db_series = MagicMock()
|
||||
db_series.id = 1
|
||||
with patch(
|
||||
"src.server.database.service.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=db_series,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.update",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_update:
|
||||
ok = await service.rename_folder_if_needed(
|
||||
key="naruto",
|
||||
current_folder="Naruto",
|
||||
target_folder="Naruto (1999)",
|
||||
db=db,
|
||||
)
|
||||
|
||||
assert ok is True
|
||||
assert not source.exists()
|
||||
assert (target / "tvshow.nfo").read_text() == "kept"
|
||||
assert mock_update.call_args.kwargs["folder"] == "Naruto (1999)"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_simple_rename_when_target_does_not_exist(
|
||||
self, anime_service_with_dir
|
||||
):
|
||||
"""Regression: plain rename (no merge needed) still works."""
|
||||
service, anime_dir = anime_service_with_dir
|
||||
source = anime_dir / "Naruto"
|
||||
source.mkdir()
|
||||
(source / "Season 1").mkdir()
|
||||
(source / "Season 1" / "ep01.mp4").touch()
|
||||
|
||||
db = AsyncMock()
|
||||
db_series = MagicMock()
|
||||
db_series.id = 1
|
||||
with patch(
|
||||
"src.server.database.service.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=db_series,
|
||||
), patch(
|
||||
"src.server.database.service.AnimeSeriesService.update",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_update:
|
||||
ok = await service.rename_folder_if_needed(
|
||||
key="naruto",
|
||||
current_folder="Naruto",
|
||||
target_folder="Naruto (1999)",
|
||||
db=db,
|
||||
)
|
||||
|
||||
assert ok is True
|
||||
assert not source.exists()
|
||||
assert (anime_dir / "Naruto (1999)" / "Season 1" / "ep01.mp4").exists()
|
||||
assert mock_update.call_args.kwargs["folder"] == "Naruto (1999)"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_op_when_source_and_target_same(
|
||||
self, anime_service_with_dir
|
||||
):
|
||||
"""Regression: same-name case returns False without touching disk."""
|
||||
service, anime_dir = anime_service_with_dir
|
||||
source = anime_dir / "Naruto (1999)"
|
||||
source.mkdir()
|
||||
|
||||
ok = await service.rename_folder_if_needed(
|
||||
key="naruto",
|
||||
current_folder="Naruto (1999)",
|
||||
target_folder="Naruto (1999)",
|
||||
db=None,
|
||||
)
|
||||
|
||||
assert ok is False
|
||||
assert source.exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_op_when_source_missing(self, anime_service_with_dir):
|
||||
"""Regression: source missing on disk returns False without error."""
|
||||
service, anime_dir = anime_service_with_dir
|
||||
|
||||
ok = await service.rename_folder_if_needed(
|
||||
key="naruto",
|
||||
current_folder="Naruto",
|
||||
target_folder="Naruto (1999)",
|
||||
db=None,
|
||||
)
|
||||
|
||||
assert ok is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_path_typesafe_with_string_directory(self, tmp_path):
|
||||
"""Regression: directory_to_search being a string (not Path) works.
|
||||
|
||||
Original code did `self._directory / current_folder` which raised
|
||||
TypeError when _directory was a str. This was silently swallowed
|
||||
by the caller's try/except, leaving the rename undone.
|
||||
"""
|
||||
mock_app = MagicMock()
|
||||
mock_app.directory_to_search = str(tmp_path) # string, not Path
|
||||
mock_app.list.keyDict = {}
|
||||
progress = MagicMock()
|
||||
service = AnimeService(series_app=mock_app, progress_service=progress)
|
||||
|
||||
source = tmp_path / "Naruto"
|
||||
target = tmp_path / "Naruto (1999)"
|
||||
source.mkdir()
|
||||
target.mkdir()
|
||||
|
||||
ok = await service.rename_folder_if_needed(
|
||||
key="naruto",
|
||||
current_folder="Naruto",
|
||||
target_folder="Naruto (1999)",
|
||||
db=None,
|
||||
)
|
||||
|
||||
# Must not raise; must succeed (merge path).
|
||||
assert ok is True
|
||||
assert not source.exists()
|
||||
Reference in New Issue
Block a user