Compare commits
127 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a12bd41890 | |||
| a7ed2c999c | |||
| 46e8b2c9eb | |||
| ec24325036 | |||
| 4ec95d8ba9 | |||
| d52b9a55f4 | |||
| 12681720e9 | |||
| 084488a88c | |||
| 270da18543 | |||
| 163532b1ef | |||
| d3cbb60c00 | |||
| 10ef590242 | |||
| e7628ac44c | |||
| f89e403a17 | |||
| 5f46d2e802 | |||
| a384072901 | |||
| d99636e9c7 | |||
| 47bd393a57 | |||
| 58adf05325 | |||
| a05e8a7b07 | |||
| 6a1f8c282a | |||
| 008873f1af | |||
| e2f0e187d0 | |||
| 9a3a2cbdcb | |||
| 1d06f8a00a | |||
| 34c1469517 | |||
| 097138376a | |||
| 04175a2bd6 | |||
| f7b24c3929 | |||
| 3a6b6dfd9e | |||
| 5e3a68163e | |||
| b845744c9d | |||
| 40d44d8b94 | |||
| 59b77bf833 | |||
| f29fd72b2d | |||
| 5ac58da650 | |||
| 5534191268 | |||
| db13b39b2b | |||
| 706aa37f18 | |||
| 10b5ca42f5 | |||
| fd8d9636cb | |||
| c6d9615731 | |||
| 61b539db6f | |||
| 7900994f78 | |||
| aeffb882dc | |||
| ac02dfd5c6 | |||
| d360e3f7a8 | |||
| 9da66872f6 | |||
| 9b6702c5fb | |||
| 8de563955a | |||
| 49f39d6b77 | |||
| 1f6a119bcc | |||
| 3fc1311d50 | |||
| 0be86771e1 | |||
| 07890aa464 | |||
| 2e723087d9 | |||
| e5a5a6009a | |||
| 4687a06374 | |||
| ad2320dbbb | |||
| c73b74c0db | |||
| eea9272de4 | |||
| c8f0c6fcb1 | |||
| c6d787c2c7 | |||
| 30c0499869 | |||
| 8b98c6d84e | |||
| e7a623a0d1 | |||
| 978e6ef200 | |||
| fd84a18b30 | |||
| 6c502e2014 | |||
| b4027be385 | |||
| a5e1c5b14f | |||
| 66acb45607 | |||
| 2cf008bcf8 | |||
| cece8fcb30 | |||
| 7e4aeb22db | |||
| 3cfea2e3b3 | |||
| d940460355 | |||
| 313bd1ebf3 | |||
| 46d34efecb | |||
| df0d54cc34 | |||
| 1f3eddf554 | |||
| d00e80e240 | |||
| 5028d4ea27 | |||
| e7d5df3a90 | |||
| 881da35dfd | |||
| 8fb24ff46a | |||
| ae368a0d8e | |||
| c6f01ca985 | |||
| ea59db302d | |||
| 6e9c2b853a | |||
| 42f4f0f5d7 | |||
| a6e103889f | |||
| b5e2ba4ac4 | |||
| 572aa0fc78 | |||
| be3e180137 | |||
| 107158eb04 | |||
| ad1aace0f5 | |||
| eabce18e41 | |||
| e050f6fa2d | |||
| a8e54876e3 | |||
| 7a1b2e565e | |||
| 6dc3cda810 | |||
| 75084b3941 | |||
| de330dc146 | |||
| 4731fd644a | |||
| 9d52ff0c45 | |||
| ee5d719f37 | |||
| cbc44491e7 | |||
| e319cfecb8 | |||
| 4f61ded92a | |||
| d6082b5cf6 | |||
| e76cd3a708 | |||
| 08f7f7453c | |||
| 023ddd182f | |||
| 288b03cbb4 | |||
| f73cc530c3 | |||
| 4b835a2439 | |||
| 7c1dccfe64 | |||
| e0be00dce6 | |||
| 14f7b2f28a | |||
| de250bdd37 | |||
| b800158648 | |||
| 4e0c66ea9e | |||
| 07c311c1cd | |||
| cf00c9f7c5 | |||
| f3042206a8 | |||
| 657e7f9bf5 |
10
.gitignore
vendored
10
.gitignore
vendored
@@ -84,3 +84,13 @@ tmp/
|
||||
*.tmp
|
||||
.coverage
|
||||
.venv/bin/dotenv
|
||||
|
||||
# Node.js
|
||||
node_modules/
|
||||
tests/results/*
|
||||
test-results/*
|
||||
robot_results/*
|
||||
test_output/*
|
||||
test_results/*
|
||||
tests/robot/output/*
|
||||
tests/robot/results/*
|
||||
|
||||
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.4.11
|
||||
v1.5.6
|
||||
|
||||
195
Docs/API.md
195
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
|
||||
@@ -826,14 +891,32 @@ Source: [src/server/api/config.py](../src/server/api/config.py#L189-L247)
|
||||
|
||||
Prefix: `/api/nfo`
|
||||
|
||||
Source: [src/server/api/nfo.py](../src/server/api/nfo.py#L1-L684)
|
||||
Source: [src/server/api/nfo.py](../src/server/api/nfo.py)
|
||||
|
||||
These endpoints manage tvshow.nfo metadata files and associated media (poster, logo, fanart) for anime series. NFO files use Kodi/XBMC format and are scraped from TMDB API.
|
||||
These endpoints manage tvshow.nfo metadata files for anime series. The
|
||||
per-anime settings page (replacing the old "NFO Diagnostics" UI) lives
|
||||
at `/api/anime/{key}/settings` — see section **7. Anime Settings
|
||||
Endpoints** below.
|
||||
|
||||
**Prerequisites:**
|
||||
|
||||
- TMDB API key must be configured in settings
|
||||
- NFO service returns 503 if API key not configured
|
||||
- NFO endpoints return 503 if the API key is missing
|
||||
|
||||
### Anime Settings — New Endpoints
|
||||
|
||||
For the UI-driven settings page (renamed from NFO Diagnostics), the
|
||||
following endpoints replace the older `/api/nfo/{serie_id}/*` flow:
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/anime/{key}/settings` | Return all editable fields for a series |
|
||||
| PUT | `/api/anime/{key}/settings` | Update name/folder/tmdb_id/tvdb_id/site, optionally regenerate tvshow.nfo |
|
||||
| POST | `/api/anime/{key}/regenerate-nfo` | Regenerate tvshow.nfo using TMDB |
|
||||
|
||||
See [AnimeSettingsResponse](../src/server/models/anime.py) for the
|
||||
response shape, and [section 7](#7-anime-settings-endpoints-new) for
|
||||
full documentation.
|
||||
|
||||
### GET /api/nfo/{serie_id}/check
|
||||
|
||||
@@ -1594,3 +1677,109 @@ GET /api/anime?page=2&per_page=50
|
||||
```
|
||||
|
||||
Source: [src/server/api/anime.py](../src/server/api/anime.py#L180-L220)
|
||||
|
||||
## 7. Anime Settings Endpoints (New)
|
||||
|
||||
Replaces the old "NFO Diagnostics" page with a per-anime settings UI
|
||||
that views and edits anime metadata directly in the database.
|
||||
|
||||
Source: [src/server/api/anime.py](../src/server/api/anime.py)
|
||||
|
||||
### GET /api/anime/{anime_key}/settings
|
||||
|
||||
Return the full editable settings payload for a single anime series.
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
**Path Parameters:**
|
||||
|
||||
- `anime_key` (string): Series unique key (e.g., `attack-on-titan`)
|
||||
|
||||
**Response (200 OK):** [`AnimeSettingsResponse`](../src/server/models/anime.py)
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "attack-on-titan",
|
||||
"name": "Attack on Titan",
|
||||
"site": "aniworld.to",
|
||||
"folder": "Attack on Titan (2013)",
|
||||
"year": 2013,
|
||||
"tmdb_id": 1429,
|
||||
"tvdb_id": 789,
|
||||
"has_nfo": true,
|
||||
"nfo_path": "/anime/Attack on Titan (2013)/tvshow.nfo",
|
||||
"nfo_created_at": "2026-01-15T10:30:00+00:00",
|
||||
"nfo_updated_at": "2026-01-15T10:30:00+00:00",
|
||||
"loading_status": "completed",
|
||||
"episode_count": 25,
|
||||
"missing_episode_count": 0
|
||||
}
|
||||
```
|
||||
|
||||
**Errors:**
|
||||
|
||||
- `401 Unauthorized` — Not authenticated.
|
||||
- `404 Not Found` — Series with the given key does not exist.
|
||||
|
||||
### PUT /api/anime/{anime_key}/settings
|
||||
|
||||
Update editable fields for a single anime series. Optional flags
|
||||
control whether the on-disk folder is renamed and whether
|
||||
`tvshow.nfo` is regenerated.
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
**Request Body** ([`AnimeSettingsUpdateRequest`](../src/server/models/anime.py)):
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `name` | string | no | Display name (1–500 chars) |
|
||||
| `folder` | string | no | Filesystem folder name |
|
||||
| `tmdb_id` | int | no | TMDB ID (positive integer, max 10 digits) |
|
||||
| `tvdb_id` | int | no | TVDB ID (positive integer, max 10 digits) |
|
||||
| `site` | string | no | Provider site URL |
|
||||
| `apply_to_nfo` | bool | no | If true, regenerate `tvshow.nfo` with the new values (requires `tmdb_id`) |
|
||||
| `rename_disk` | bool | no | If true and `folder` changed, rename the folder on disk |
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
curl -X PUT "http://127.0.0.1:8000/api/anime/attack-on-titan/settings" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"tmdb_id": 9999, "apply_to_nfo": true}'
|
||||
```
|
||||
|
||||
**Response (200 OK):** Updated [`AnimeSettingsResponse`](../src/server/models/anime.py).
|
||||
|
||||
**Errors:**
|
||||
|
||||
- `400 Bad Request` — `apply_to_nfo=true` but the series has no `tmdb_id`.
|
||||
- `401 Unauthorized` — Not authenticated.
|
||||
- `404 Not Found` — Series with the given key does not exist.
|
||||
- `422 Unprocessable Entity` — Validation failure (empty name, invalid
|
||||
folder, non-positive `tmdb_id`/`tvdb_id`, oversized id, path traversal).
|
||||
|
||||
### POST /api/anime/{anime_key}/regenerate-nfo
|
||||
|
||||
Regenerate `tvshow.nfo` for a single anime using TMDB.
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
**Response (200 OK):** [`AnimeSettingsRegenerateNfoResponse`](../src/server/models/anime.py)
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "NFO regenerated. 2 tags updated.",
|
||||
"nfo_path": "/anime/Attack on Titan (2013)/tvshow.nfo",
|
||||
"repaired_tags": ["title", "tmdbid"]
|
||||
}
|
||||
```
|
||||
|
||||
**Errors:**
|
||||
|
||||
- `400 Bad Request` — Series has no `tmdb_id`.
|
||||
- `401 Unauthorized` — Not authenticated.
|
||||
- `404 Not Found` — Series with the given key does not exist.
|
||||
- `500 Internal Server Error` — TMDB or NFO regeneration failure.
|
||||
|
||||
@@ -80,9 +80,12 @@ src/server/
|
||||
| +-- progress_service.py # Progress tracking
|
||||
| +-- websocket_service.py# WebSocket broadcasting
|
||||
| +-- queue_repository.py # Database persistence
|
||||
| +-- nfo_service.py # NFO metadata management
|
||||
| +-- setup_service.py # Series key resolution from folder names
|
||||
| +-- folder_scan_service.py # Daily folder maintenance scan
|
||||
| +-- nfo_scan_service.py # NFO creation, update, and regeneration from TMDB
|
||||
| +-- scan_service.py # Library rescan (episodes, missing files)
|
||||
| +-- folder_naming_service.py # Folder rename to Title (YYYY) convention
|
||||
| +-- scheduler/ # Scheduled tasks
|
||||
| | +-- scheduler_service.py # Cron-based library rescans
|
||||
+-- models/ # Pydantic models
|
||||
| +-- auth.py # Auth request/response models
|
||||
| +-- config.py # Configuration models
|
||||
@@ -166,11 +169,42 @@ src/server/web/static/js/
|
||||
| +-- socket-handler.js # WebSocket event handlers
|
||||
| +-- app-init.js # Application initialization
|
||||
+-- queue/ # Queue page modules
|
||||
+-- queue-api.js # Queue API interactions
|
||||
+-- queue-renderer.js # Queue list rendering
|
||||
+-- progress-handler.js # Download progress updates
|
||||
+-- queue-socket-handler.js # WebSocket events for queue
|
||||
+-- queue-init.js # Queue page initialization
|
||||
| +-- queue-api.js # Queue API wrapper (uses ApiClient internally)
|
||||
| +-- queue-renderer.js # Queue DOM rendering
|
||||
| +-- progress-handler.js # Download progress updates
|
||||
| +-- queue-socket-handler.js # WebSocket events for queue
|
||||
| +-- queue-init.js # Queue page initialisation and orchestration
|
||||
```
|
||||
|
||||
**Queue Module Public APIs:**
|
||||
|
||||
```javascript
|
||||
// queue-api.js — wraps /api/queue/* endpoints via AniWorld.ApiClient
|
||||
AniWorld.QueueAPI.loadQueueData() // GET /api/queue/status → queue data
|
||||
AniWorld.QueueAPI.startQueue() // POST /api/queue/start
|
||||
AniWorld.QueueAPI.stopQueue() // POST /api/queue/stop
|
||||
AniWorld.QueueAPI.removeFromQueue(itemId) // DELETE /api/queue/{item_id}
|
||||
AniWorld.QueueAPI.retryDownloads(itemIds) // POST /api/queue/retry
|
||||
AniWorld.QueueAPI.clearCompleted() // DELETE /api/queue/completed
|
||||
AniWorld.QueueAPI.clearFailed() // DELETE /api/queue/failed
|
||||
AniWorld.QueueAPI.clearPending() // DELETE /api/queue/pending
|
||||
|
||||
// queue-init.js — page orchestration (imports QueueAPI internally)
|
||||
AniWorld.QueueApp.init() // Bootstrap; calls loadQueueData()
|
||||
AniWorld.QueueApp.loadQueueData() // Fetch queue data and render
|
||||
AniWorld.QueueApp.startDownload() // Start queue processing
|
||||
AniWorld.QueueApp.stopDownloads() // Stop queue processing
|
||||
AniWorld.QueueApp.removeFromQueue(id) // Remove single item
|
||||
AniWorld.QueueApp.retryDownload(id) // Retry failed item
|
||||
AniWorld.QueueApp.retryAllFailed() // Retry all failed items
|
||||
AniWorld.QueueApp.clearQueue(type) // Clear completed|failed|pending
|
||||
```
|
||||
|
||||
> **Module dependency rule**: Queue page modules must access API endpoints via
|
||||
> `AniWorld.QueueAPI.<method>()`. The `API` object (`AniWorld.Constants.API`) is
|
||||
> private to each module's IIFE closure and is NOT a global. Do NOT use bare
|
||||
> `fetch(API.QUEUE_STATUS, ...)` in `queue-init.js` — use
|
||||
> `AniWorld.QueueAPI.loadQueueData()` instead.
|
||||
```
|
||||
|
||||
#### Module Pattern
|
||||
@@ -195,51 +229,48 @@ AniWorld.ModuleName = (function () {
|
||||
|
||||
Source: [src/server/web/static/](../src/server/web/static/)
|
||||
|
||||
### 2.3 Core Layer (`src/core/`)
|
||||
### 2.3 Core Layer (`src/server/`)
|
||||
|
||||
Domain logic for anime series management.
|
||||
Domain logic for anime series management, NFO metadata, and episode downloads.
|
||||
|
||||
```
|
||||
src/core/
|
||||
+-- SeriesApp.py # Main application facade
|
||||
src/server/
|
||||
+-- SerieScanner.py # Directory scanning, targeted single-series scan
|
||||
+-- entities/ # Domain entities
|
||||
| +-- series.py # Serie class with sanitized_folder property
|
||||
| +-- SerieList.py # SerieList collection with sanitized folder support
|
||||
| +-- nfo_models.py # Pydantic models for tvshow.nfo (TVShowNFO, ActorInfo…)
|
||||
+-- services/ # Domain services
|
||||
| +-- nfo_service.py # NFO lifecycle: create / update tvshow.nfo
|
||||
| +-- nfo_repair_service.py # Detect & repair incomplete tvshow.nfo files
|
||||
| | # (parse_nfo_tags, find_missing_tags, NfoRepairService)
|
||||
| +-- tmdb_client.py # Async TMDB API client
|
||||
+-- utils/ # Utility helpers (no side-effects)
|
||||
| +-- nfo_generator.py # TVShowNFO → XML serialiser
|
||||
+-- SerieList.py # Series collection (stub; see src/server/database/SerieList.py)
|
||||
+-- nfo/ # NFO metadata generation and mapping
|
||||
| +-- nfo_generator.py # TVShowNFO → XML serialiser (generate_tvshow_nfo)
|
||||
| +-- nfo_mapper.py # TMDB API dict → TVShowNFO (tmdb_to_nfo_model,
|
||||
| | # _extract_rating_by_country, _extract_fsk_rating)
|
||||
| +-- image_downloader.py # TMDB image downloader
|
||||
| +-- nfo_models.py # Pydantic models for NFO XML (TVShowNFO, ActorInfo…)
|
||||
| +-- tmdb_client.py # Async TMDB API client
|
||||
+-- providers/ # External provider adapters
|
||||
| +-- base_provider.py # Loader interface
|
||||
| +-- provider_factory.py # Provider registry
|
||||
+-- interfaces/ # Abstract interfaces
|
||||
| +-- aniworld_provider.py # AniWorld scraper
|
||||
| +-- enhanced_provider.py # Multi-provider with failover
|
||||
| +-- provider_config.py # Provider preference configuration
|
||||
| +-- streaming/ # Provider-specific extractors (VOE, Doodstream, etc.)
|
||||
+-- entities/
|
||||
| +-- nfo_models.py # Domain entities for NFO (aligns with nfo/nfo_models.py)
|
||||
+-- interfaces/
|
||||
| +-- callbacks.py # Progress callback system
|
||||
+-- exceptions/ # Domain exceptions
|
||||
+-- Exceptions.py # Custom exceptions
|
||||
| +-- providers.py # Provider interface definitions
|
||||
+-- exceptions/
|
||||
+-- Exceptions.py # Custom exceptions
|
||||
```
|
||||
|
||||
**Key Components:**
|
||||
|
||||
| Component | Purpose |
|
||||
| -------------- | -------------------------------------------------------------------------- |
|
||||
| `SeriesApp` | Main application facade for anime operations |
|
||||
| `SerieScanner` | Scans directories for anime; `scan_single_series()` for targeted scans |
|
||||
| `Serie` | Domain entity with `sanitized_folder` property for filesystem-safe names |
|
||||
| `SerieList` | Collection management with automatic folder creation using sanitized names |
|
||||
|| Component | Purpose |
|
||||
| --- | --- |
|
||||
| `SerieScanner` | Scans directories for anime; `scan_single_series()` for targeted scans |
|
||||
| `tmdb_client.py` | Async TMDB API client |
|
||||
| `nfo_generator.py` | Serialises `TVShowNFO` to XML |
|
||||
| `nfo_mapper.py` | Maps TMDB API response to `TVShowNFO` domain model |
|
||||
| `enhanced_provider.py` | Multi-provider downloader with failover chain |
|
||||
|
||||
**Initialization:**
|
||||
|
||||
`SeriesApp` is initialized with `skip_load=True` passed to `SerieList`, preventing automatic loading of series from data files on every instantiation. Series data is loaded once during application setup via `sync_series_from_data_files()` in the FastAPI lifespan, which reads data files and syncs them to the database. Subsequent operations load series from the database through the service layer.
|
||||
|
||||
Source: [src/core/](../src/core/)
|
||||
> **Note:** The `src/core/` directory was an earlier architectural proposal and is
|
||||
> currently empty. All domain logic lives under `src/server/`.
|
||||
|
||||
### 2.4 Infrastructure Layer (`src/infrastructure/`)
|
||||
|
||||
@@ -428,8 +459,8 @@ Source: [src/server/middleware/auth.py](../src/server/middleware/auth.py#L1-L209
|
||||
| Exception / cancellation | Temp + `.part` fragments removed in `except` block |
|
||||
|
||||
Source: [src/server/services/download_service.py](../src/server/services/download_service.py#L1-L150),
|
||||
[src/core/providers/aniworld_provider.py](../src/core/providers/aniworld_provider.py),
|
||||
[src/core/providers/enhanced_provider.py](../src/core/providers/enhanced_provider.py)
|
||||
[src/server/providers/aniworld_provider.py](../src/server/providers/aniworld_provider.py),
|
||||
[src/server/providers/enhanced_provider.py](../src/server/providers/enhanced_provider.py)
|
||||
|
||||
### 3.3 WebSocket Event Flow
|
||||
|
||||
@@ -731,7 +762,7 @@ class Loader(ABC):
|
||||
def get_episodes(self, serie: Serie) -> Dict[int, List[int]]: ...
|
||||
```
|
||||
|
||||
Source: [src/core/providers/base_provider.py](../src/core/providers/base_provider.py)
|
||||
Source: [src/server/providers/base_provider.py](../src/server/providers/base_provider.py)
|
||||
|
||||
### 8.2 Filesystem Integration
|
||||
|
||||
@@ -745,7 +776,7 @@ SerieScanner(
|
||||
)
|
||||
```
|
||||
|
||||
Source: [src/core/SerieScanner.py](../src/core/SerieScanner.py#L59-L96)
|
||||
Source: [src/server/SerieScanner.py](../src/server/SerieScanner.py#L59-L96)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -37,6 +37,111 @@ This changelog follows [Keep a Changelog](https://keepachangelog.com/) principle
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased] - 2026-06-20
|
||||
|
||||
### 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
|
||||
edit `name`, `folder`, `tmdb_id`, `tvdb_id`, and `site` directly in
|
||||
the database, with options to rename the on-disk folder and
|
||||
regenerate `tvshow.nfo` in one click.
|
||||
- **New API endpoints** under `/api/anime/{key}/`:
|
||||
- `GET /settings` — return full editable settings payload
|
||||
- `PUT /settings` — update fields with validation
|
||||
- `POST /regenerate-nfo` — regenerate `tvshow.nfo` from TMDB
|
||||
- **Pydantic models**: `AnimeSettingsResponse`,
|
||||
`AnimeSettingsUpdateRequest`, `AnimeSettingsRegenerateNfoResponse`
|
||||
in [src/server/models/anime.py](../src/server/models/anime.py).
|
||||
- **Frontend module**: `AniWorld.AnimeSettingsManager` IIFE in
|
||||
[src/server/web/static/js/pages/anime-settings.js](../src/server/web/static/js/pages/anime-settings.js)
|
||||
with public API: `init`, `loadSeries`, `saveSettings`,
|
||||
`regenerateNfo`, `validateField`, `populateForm`, `showSaveSuccess`,
|
||||
`showError`.
|
||||
- **Vitest JS unit tests** covering every public function on
|
||||
`AnimeSettingsManager` — 31 tests in
|
||||
[tests/frontend/unit/anime_settings.test.js](../tests/frontend/unit/anime_settings.test.js).
|
||||
- **Playwright E2E test** for the right-click → settings page flow in
|
||||
[tests/frontend/e2e/anime_settings_page.spec.js](../tests/frontend/e2e/anime_settings_page.spec.js).
|
||||
|
||||
### Changed
|
||||
|
||||
- **Right-click context menu** on the library page: "NFO Diagnostics"
|
||||
→ "Anime Settings" (`data-action="nfo-diagnostics"` →
|
||||
`data-action="anime-settings"`).
|
||||
- **Configuration modal link**: "Open NFO Diagnostics" → "Open Anime
|
||||
Settings", target URL `/settings/nfo` → `/anime/settings`.
|
||||
- **Page route**: `/settings/nfo` returns a 301 redirect to
|
||||
`/anime/settings` for backwards compatibility with bookmarks.
|
||||
- **Pydantic model rename** in [src/server/models/nfo.py](../src/server/models/nfo.py):
|
||||
- `NfoDiagnosticsResponse` → `NfoSettingsResponse`
|
||||
- `NfoSeriesDiagnostics` → `NfoSeriesSettings`
|
||||
- **Function rename** in [src/server/api/nfo.py](../src/server/api/nfo.py):
|
||||
- `get_nfo_diagnostics` → `get_nfo_settings`
|
||||
- `repair_nfo` → `repair_nfo_settings`
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Queue view blank after adding items**: `queue-init.js`'s `loadQueueData()` called
|
||||
`API.QUEUE_STATUS` directly, but `API` is a local variable inside
|
||||
`AniWorld.QueueAPI`'s IIFE — not accessible globally. Items added to the
|
||||
queue were persisted server-side but the queue page could not fetch them,
|
||||
leaving the view empty with an `API is not defined` console error. Fixed by
|
||||
replacing the inline `fetch` with `AniWorld.QueueAPI.loadQueueData()`, which
|
||||
already exists and correctly accesses the endpoint through its own closure.
|
||||
The same file already uses `AniWorld.QueueAPI.*` for all other queue
|
||||
operations (`startQueue`, `stopQueue`, `removeFromQueue`, etc.).
|
||||
|
||||
- **Bug**: `src/server/api/nfo.py` called the non-existent
|
||||
`anime_service.update_series_nfo_status(...)` method, which would
|
||||
raise `AttributeError` after a successful NFO repair. Renamed the
|
||||
call to the existing `update_nfo_status(...)` method (matching its
|
||||
signature `(key, has_nfo, tmdb_id=None, tvdb_id=None, db=None)`)
|
||||
and added an explicit `AnimeSeriesService.update(db, id, nfo_path=...)`
|
||||
call to keep `nfo_path` in sync. Covered by regression tests in
|
||||
`TestBugFixCreateOrUpdateNfo`.
|
||||
|
||||
- **Bug**: Right-clicking a series card and choosing "Anime Settings"
|
||||
opened `/anime/settings?key=null` instead of carrying the series key.
|
||||
Root cause: the click handler in
|
||||
[src/server/web/static/js/index/context-menu.js](../src/server/web/static/js/index/context-menu.js)
|
||||
called `hide()` BEFORE building the URL — and `hide()` cleared
|
||||
`currentSeriesKey` to null. Fix captures the key into a local
|
||||
`const` before calling `hide()`. Regression-locked by
|
||||
`tests/frontend/unit/context_menu.test.js` (5 tests).
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased] - 2026-06-05
|
||||
|
||||
### Fixed
|
||||
@@ -52,17 +157,14 @@ This changelog follows [Keep a Changelog](https://keepachangelog.com/) principle
|
||||
|
||||
### Added
|
||||
|
||||
- **Encoding detection for HTML parsing** (`src/core/providers/aniworld_provider.py`):
|
||||
- **Encoding detection for HTML parsing** (`src/server/providers/aniworld_provider.py`):
|
||||
Added `_decode_html_content()` function that uses `chardet` to detect the actual
|
||||
encoding of HTML content before parsing. Falls back to UTF-8 with `errors='replace'`
|
||||
to handle pages with mismatched encoding declarations. Applied to all BeautifulSoup
|
||||
parsing calls to prevent "Some characters could not be decoded" warnings.
|
||||
- **chardet dependency**: Added `chardet>=5.2.0` to `requirements.txt` for encoding detection.
|
||||
|
||||
### Added
|
||||
|
||||
- **Temp file cleanup after every download** (`src/core/providers/aniworld_provider.py`,
|
||||
`src/core/providers/enhanced_provider.py`): Module-level helper
|
||||
- **Temp file cleanup after every download** (`src/server/providers/aniworld_provider.py`,
|
||||
`src/server/providers/enhanced_provider.py`): Module-level helper
|
||||
`_cleanup_temp_file()` removes the working temp file and any yt-dlp `.part`
|
||||
fragments after each download attempt — on success, on failure, and on
|
||||
exceptions (including `BrokenPipeError` and cancellation). Ensures that no
|
||||
@@ -79,37 +181,34 @@ This changelog follows [Keep a Changelog](https://keepachangelog.com/) principle
|
||||
|
||||
### Added
|
||||
|
||||
- **NFO tag completeness (`nfo_mapper.py`)**: All 17 required NFO tags are now
|
||||
- **NFO tag completeness (`src/server/nfo/nfo_mapper.py`)**: All 17 required NFO tags are now
|
||||
explicitly populated during creation: `originaltitle`, `sorttitle`, `year`,
|
||||
`plot`, `outline`, `tagline`, `runtime`, `premiered`, `status`, `imdbid`,
|
||||
`genre`, `studio`, `country`, `actor`, `watched`, `dateadded`, `mpaa`.
|
||||
- **`src/core/utils/nfo_mapper.py`**: New module containing
|
||||
- **`src/server/nfo/nfo_mapper.py`**: New module containing
|
||||
`tmdb_to_nfo_model()`, `_extract_rating_by_country()`, and
|
||||
`_extract_fsk_rating()`. Extracted from `NFOService` to keep files under
|
||||
500 lines and isolate pure mapping logic.
|
||||
`_extract_fsk_rating()`. Extracted to keep files under 500 lines and isolate
|
||||
pure mapping logic.
|
||||
- **`src/server/nfo/nfo_generator.py`**: XML serialiser for NFO files
|
||||
(`generate_tvshow_nfo`).
|
||||
- **US MPAA rating**: `_extract_rating_by_country(ratings, "US")` now maps the
|
||||
US TMDB content rating to the `<mpaa>` NFO tag.
|
||||
- **`NfoRepairService` (`src/core/services/nfo_repair_service.py`)**: New service
|
||||
that detects incomplete `tvshow.nfo` files and triggers TMDB re-fetch.
|
||||
Provides `parse_nfo_tags()`, `find_missing_tags()`, `nfo_needs_repair()`, and
|
||||
`NfoRepairService.repair_series()`. 13 required tags are checked.
|
||||
- **`perform_nfo_repair_scan()`
|
||||
(`src/server/services/folder_scan_service.py`)**: New async function
|
||||
that iterates every series directory, checks whether `tvshow.nfo` is missing
|
||||
required tags using `nfo_needs_repair()`, and queues the series for background
|
||||
reload via `asyncio.create_task`. Skips gracefully when `tmdb_api_key` or
|
||||
`anime_directory` is not configured.
|
||||
- **NFO repair wired into scheduled folder scan (`src/server/services/folder_scan_service.py`)**:
|
||||
`perform_nfo_repair_scan(background_loader=None)` is called during the
|
||||
scheduled daily folder scan, keeping startup fast while ensuring regular
|
||||
maintenance.
|
||||
- **`NfoScanService` (`src/server/services/nfo_scan_service.py`)**: New service
|
||||
that detects incomplete `tvshow.nfo` files and regenerates them from TMDB.
|
||||
Provides `scan_all()`, `_scan_series()`, `_create_nfo()`,
|
||||
`_update_nfo_if_needed()`, and `_regenerate_nfo()`. 17 NFO tags are written.
|
||||
- **`ScanService` (`src/server/services/scan_service.py`)**: New service for
|
||||
library rescans — detects new and removed episode files and syncs the
|
||||
`episodes` table accordingly.
|
||||
- **`FolderNamingService` (`src/server/services/folder_naming_service.py`)**:
|
||||
Renames series folders to the `Title (YYYY)` convention using the year from
|
||||
`tvshow.nfo`. Prevents double-year accumulation on repeated runs.
|
||||
|
||||
### Changed
|
||||
|
||||
- `NFOService._tmdb_to_nfo_model()` and `NFOService._extract_fsk_rating()` moved
|
||||
to `src/core/utils/nfo_mapper.py` as module-level functions
|
||||
`tmdb_to_nfo_model()` and `_extract_fsk_rating()`.
|
||||
- `src/core/services/nfo_service.py` reduced from 640 → 471 lines.
|
||||
- `src/server/nfo/nfo_mapper.py` and `src/server/nfo/nfo_generator.py`
|
||||
replaced the monolithic NFO logic from the previous service.
|
||||
- NFO generation moved to `src/server/nfo/nfo_generator.py`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ Location: `data/config.json`
|
||||
},
|
||||
"backup": {
|
||||
"enabled": false,
|
||||
"path": "data/backups",
|
||||
"path": "data/config_backups",
|
||||
"keep_days": 30
|
||||
},
|
||||
"nfo": {
|
||||
|
||||
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. |
|
||||
@@ -1,111 +0,0 @@
|
||||
# Migration Guide: File-Based to Database Storage
|
||||
|
||||
## Overview
|
||||
|
||||
This guide covers the transition from file-based series metadata storage to the new database-backed system introduced in v2.0.
|
||||
|
||||
## What Changed
|
||||
|
||||
**Before v2.0**: Series metadata stored in `key` and `data` files alongside anime folders.
|
||||
|
||||
**After v2.0**: All metadata stored in SQLite database (`aniworld.db`). Files are deprecated but still supported for backward compatibility during migration.
|
||||
|
||||
## Automated Migration
|
||||
|
||||
The application automatically migrates on first startup:
|
||||
|
||||
1. Scans anime directory for `key` and `data` files
|
||||
2. Parses legacy files into `AnimeSeries` and `Episode` records
|
||||
3. Loads series into in-memory cache
|
||||
4. Logs migration results
|
||||
|
||||
**No manual action required.**
|
||||
|
||||
## Manual Verification
|
||||
|
||||
After first startup with the new version:
|
||||
|
||||
1. **Check logs** for: `"Migrated X series from files to DB"`
|
||||
2. **Verify series count**: UI shows same number of series as before
|
||||
3. **Confirm episodes**: Episode counts match expected totals
|
||||
|
||||
```bash
|
||||
# Check migration log
|
||||
grep "Migrated" logs/app.log
|
||||
|
||||
# Verify series via API
|
||||
curl http://localhost:8000/api/anime | jq '.total'
|
||||
```
|
||||
|
||||
## After Migration
|
||||
|
||||
### Safe to Delete
|
||||
|
||||
Once verified, these files can be removed:
|
||||
|
||||
```
|
||||
<anime_folder>/
|
||||
├── Attack on Titan (2013)/
|
||||
│ ├── key # ❌ Can delete
|
||||
│ ├── data # ❌ Can delete
|
||||
│ └── Season 1/
|
||||
│ └── ...
|
||||
```
|
||||
|
||||
**Deleting these files does not affect the database.** The metadata now lives in `aniworld.db`.
|
||||
|
||||
### Backup (Recommended)
|
||||
|
||||
Before deleting, backup the files:
|
||||
|
||||
```bash
|
||||
# Create backup directory
|
||||
mkdir -p backup/legacy_series_files
|
||||
|
||||
# Copy all key and data files
|
||||
find /path/to/anime -name "key" -o -name "data" | while read f; do
|
||||
cp "$f" "backup/legacy_series_files/"
|
||||
done
|
||||
```
|
||||
|
||||
## Reverting (Not Recommended)
|
||||
|
||||
If you must revert to file-based storage:
|
||||
|
||||
1. **Restore from database backup** (if available)
|
||||
2. **Export manually** (no export script exists)
|
||||
|
||||
**Warning**: File-based storage is deprecated and will be removed in v3.0.0.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Series Not Appearing After Migration
|
||||
|
||||
1. Check logs for migration errors: `grep -i error logs/app.log`
|
||||
2. Verify `key` and `data` files exist and are readable
|
||||
3. Manually trigger rescan: `POST /api/scheduler/trigger-rescan`
|
||||
|
||||
### Duplicate Series
|
||||
|
||||
1. Check for duplicate `key` files (same series in multiple folders)
|
||||
2. Verify series key uniqueness in database:
|
||||
|
||||
```bash
|
||||
sqlite3 aniworld.db "SELECT key, COUNT(*) FROM anime_series GROUP BY key HAVING COUNT(*) > 1;"
|
||||
```
|
||||
|
||||
### Missing Episodes
|
||||
|
||||
1. Trigger targeted scan for affected series
|
||||
2. Check episode sync logs
|
||||
3. Verify file permissions on anime directory
|
||||
|
||||
## Deprecation Timeline
|
||||
|
||||
| Version | Status |
|
||||
|---------|--------|
|
||||
| v2.0.x | Legacy files supported, migration automated |
|
||||
| v2.1.x | Legacy files still supported, warnings in logs |
|
||||
| v3.0.0 | **Legacy files removed** - database only |
|
||||
|
||||
Upgrade to v3.0.0 before legacy file support ends.
|
||||
@@ -4,54 +4,56 @@ This document describes the setup flow navigation, covering how users progress f
|
||||
|
||||
## Overview
|
||||
|
||||
The application uses a middleware-based redirect system to ensure users complete setup before accessing the main app. The flow involves multiple pages handling setup completion, unresolved folder detection, and initialization.
|
||||
The application uses a middleware-based redirect system to enforce a strict state machine. Users must complete each phase before accessing the next. Attempting to bypass the current phase redirects to the appropriate page.
|
||||
|
||||
## Setup Flow
|
||||
## State Machine
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ SETUP FLOW │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ /setup ──► /loading ──► /setup/unresolved ──► /loading ──► /login │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ ▼ ▼ ▼ ▼ │
|
||||
│ (first (Series Scan + (has folders) (all resolved) │
|
||||
│ time) NFO Scan) │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ ▼ │
|
||||
│ │ [Done button] ──► marks complete │
|
||||
│ │ │ │
|
||||
│ │ ▼ │
|
||||
│ │ /loading (NFO phase runs again) │
|
||||
│ │ │ │
|
||||
│ └────────┴─────────────────────────────────────┘
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ NAVIGATION STATES │
|
||||
├─────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ NO_SETUP ──────────► SETUP_COMPLETE ──────────► UNRESOLVED_PENDING │
|
||||
│ │ │ │ │
|
||||
│ │ │ │ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ /setup /loading /setup/unresolved │
|
||||
│ (series scan) (resolve folders) │
|
||||
│ │
|
||||
│ UNRESOLVED_DONE ───────┐
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ NFO_SCAN_PENDING │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ /loading │
|
||||
│ (NFO scan) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ COMPLETE │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ /login │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**New Navigation Order:**
|
||||
1. `/setup` → Initial configuration
|
||||
2. `/loading` → Series scan + NFO scan
|
||||
3. `/setup/unresolved` → Resolve folders (if any)
|
||||
4. `/loading` → NFO scan runs again
|
||||
5. `/login` → Authentication
|
||||
## State Definitions
|
||||
|
||||
**Key Changes:**
|
||||
- After `/setup/unresolved`, the "Done" button marks the phase as complete
|
||||
- Revisiting `/setup/unresolved` after completion → redirects to `/loading`
|
||||
- `/loading` always goes to `/setup/unresolved` if unresolved folders exist
|
||||
- NFO scan runs as a separate phase after series sync during initialization
|
||||
| State | Condition | Target Page |
|
||||
|-------|-----------|-------------|
|
||||
| `NO_SETUP` | No master password configured | `/setup` |
|
||||
| `SETUP_COMPLETE` | Initial config passed, loading not started | `/loading` |
|
||||
| `UNRESOLVED_PENDING` | Setup done, unresolved exist, not marked done | `/setup/unresolved` |
|
||||
| `UNRESOLVED_DONE` | Unresolved phase marked complete, NFO scan pending | `/loading` |
|
||||
| `NFO_SCAN_PENDING` | Unresolved done, NFO scan incomplete | `/loading` |
|
||||
| `COMPLETE` | All phases finished | `/login` |
|
||||
|
||||
## Middleware: SetupRedirectMiddleware
|
||||
|
||||
**File:** `src/server/middleware/setup_redirect.py`
|
||||
|
||||
The middleware intercepts all requests and redirects to `/setup` if:
|
||||
- No master password is configured
|
||||
- Configuration file is missing or invalid
|
||||
The middleware intercepts all requests and enforces the state machine.
|
||||
|
||||
### Exempt Paths (always accessible)
|
||||
|
||||
@@ -68,13 +70,48 @@ The middleware intercepts all requests and redirects to `/setup` if:
|
||||
|
||||
### Middleware Logic
|
||||
|
||||
1. **Setup incomplete** → Redirect to `/setup`
|
||||
2. **Setup complete, accessing `/setup`** → Redirect to `/login`
|
||||
3. **Setup complete, accessing `/loading`** → Allow access (page handles its own redirect)
|
||||
4. **Setup complete, accessing `/setup/unresolved`**:
|
||||
- If `unresolved_completed` flag is set → Redirect to `/loading`
|
||||
- Otherwise → Allow access
|
||||
5. **API requests during setup** → Return 503 with `setup_url`
|
||||
The middleware checks the current state and redirects accordingly:
|
||||
|
||||
```
|
||||
1. NO_SETUP state:
|
||||
→ Redirect ALL requests to /setup
|
||||
→ Exception: /setup itself is accessible
|
||||
|
||||
2. SETUP_COMPLETE state:
|
||||
→ Redirect /setup to /loading
|
||||
→ Redirect any other page to /loading
|
||||
|
||||
3. UNRESOLVED_PENDING state (unresolved folders exist, not marked done):
|
||||
→ Redirect /setup to /setup/unresolved
|
||||
→ Redirect /loading to /setup/unresolved
|
||||
→ Allow access to /setup/unresolved
|
||||
→ Redirect any other page to /setup/unresolved
|
||||
|
||||
4. UNRESOLVED_DONE state (unresolved marked done, NFO scan pending):
|
||||
→ Redirect /setup to /loading
|
||||
→ Redirect /setup/unresolved to /loading
|
||||
→ Redirect any other page to /loading
|
||||
|
||||
5. NFO_SCAN_PENDING state:
|
||||
→ Redirect /setup to /loading
|
||||
→ Redirect /setup/unresolved to /loading
|
||||
→ Allow access to /loading (NFO phase runs)
|
||||
→ Redirect any other page to /loading
|
||||
|
||||
6. COMPLETE state (loading finished):
|
||||
→ Redirect /setup, /loading, /setup/unresolved to /login
|
||||
→ Allow access to /login and main app
|
||||
```
|
||||
|
||||
### Phase Tracking Flags
|
||||
|
||||
| Flag | Purpose |
|
||||
|------|---------|
|
||||
| `setup_complete` | Initial configuration was saved |
|
||||
| `loading_started` | Loading phase has been initiated (redirected to /loading) |
|
||||
| `unresolved_completed` | User clicked "Done" on unresolved page |
|
||||
| `loading_complete` | Series scan + initial loading finished |
|
||||
| `nfo_scan_complete` | Final NFO scan finished |
|
||||
|
||||
## Pages
|
||||
|
||||
@@ -87,8 +124,11 @@ Handles initial configuration:
|
||||
- Anime directory selection
|
||||
- Database initialization
|
||||
|
||||
**Post-completion flow:**
|
||||
- Redirects to `/loading` to begin initialization
|
||||
**Allowed in states:** `NO_SETUP`
|
||||
|
||||
**Post-completion:**
|
||||
- Sets `setup_complete` flag
|
||||
- Redirects to `/loading`
|
||||
|
||||
### 2. Loading Page (`/loading`)
|
||||
|
||||
@@ -99,25 +139,28 @@ Shows initialization progress via WebSocket:
|
||||
- Database population
|
||||
- Logo/image loading
|
||||
|
||||
**Post-initialization flow:**
|
||||
**Allowed in states:** `SETUP_COMPLETE`, `UNRESOLVED_DONE`, `NFO_SCAN_PENDING`
|
||||
|
||||
**Post-initialization (series scan complete):**
|
||||
```javascript
|
||||
async function checkUnresolvedAndProceed() {
|
||||
// Fetch unresolved folders via API
|
||||
const res = await fetch('/api/setup/unresolved', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const folders = await res.json();
|
||||
|
||||
if (folders.length > 0) {
|
||||
// Has unresolved folders → go to resolution page
|
||||
window.location.href = '/setup/unresolved';
|
||||
} else {
|
||||
// No unresolved folders → go to login
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Post-NFO scan:**
|
||||
- Sets `nfo_scan_complete` flag
|
||||
- Redirects to `/login`
|
||||
|
||||
### 3. Unresolved Folders Page (`/setup/unresolved`)
|
||||
|
||||
**File:** `src/server/web/templates/unresolved.html`
|
||||
@@ -127,24 +170,16 @@ Allows manual resolution of folders that couldn't be auto-matched:
|
||||
- Provides search suggestions
|
||||
- Input field for entering provider key
|
||||
- Resolve/delete actions
|
||||
- **Done button** at top to complete the phase without resolving all folders
|
||||
- **Done button** to complete the phase without resolving all folders
|
||||
|
||||
**Post-resolution flow:**
|
||||
```javascript
|
||||
// After clicking "Done" button
|
||||
async function handleDone() {
|
||||
// Call API to mark phase as complete
|
||||
await fetch('/api/setup/unresolved/done', { method: 'POST' });
|
||||
// Redirect to loading for final NFO scan
|
||||
window.location.href = '/loading';
|
||||
}
|
||||
```
|
||||
**Allowed in states:** `UNRESOLVED_PENDING`
|
||||
|
||||
**Done button behavior:**
|
||||
- Marks all remaining folders as handled
|
||||
- Sets `unresolved_completed` flag in config
|
||||
- Redirects to `/loading` to run final NFO scan
|
||||
- After completion, `/setup/unresolved` becomes inaccessible (redirects to `/loading`)
|
||||
- Sets `unresolved_completed` flag
|
||||
- Redirects to `/loading` for final NFO scan
|
||||
|
||||
**After completion:**
|
||||
- Any access redirects to `/loading`
|
||||
|
||||
### 4. Login Page (`/login`)
|
||||
|
||||
@@ -152,6 +187,8 @@ async function handleDone() {
|
||||
|
||||
Authentication page. After successful login → redirect to `/` (main app).
|
||||
|
||||
**Allowed in states:** `COMPLETE`
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Unresolved Folders API
|
||||
@@ -177,7 +214,7 @@ Authentication page. After successful login → redirect to `/` (main app).
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/server/middleware/setup_redirect.py` | Redirect middleware |
|
||||
| `src/server/middleware/setup_redirect.py` | Redirect middleware (state machine) |
|
||||
| `src/server/controllers/page_controller.py` | Page route handlers |
|
||||
| `src/server/web/templates/setup.html` | Setup template |
|
||||
| `src/server/web/templates/loading.html` | Loading template |
|
||||
@@ -185,22 +222,13 @@ Authentication page. After successful login → redirect to `/` (main app).
|
||||
| `src/server/api/setup_endpoints.py` | Unresolved folders API |
|
||||
| `src/server/database/service.py` | UnresolvedFolderService |
|
||||
|
||||
## Common Issues
|
||||
## Navigation Summary
|
||||
|
||||
### Redirect Loop
|
||||
|
||||
**Symptom:** Browser keeps redirecting between pages.
|
||||
|
||||
**Causes:**
|
||||
1. `loading.html` always redirected to `/setup/unresolved` without checking if any exist
|
||||
2. `unresolved.html` redirected to `/` which middleware redirected back to `/login`
|
||||
|
||||
**Fix:** See the navigation logic updates in loading.html and unresolved.html.
|
||||
|
||||
### Can't Access Unresolved Page After Setup
|
||||
|
||||
**Symptom:** Middleware redirects to `/login` instead of allowing access to `/setup/unresolved`.
|
||||
|
||||
**Cause:** `/setup/unresolved` is in the exempt paths but the request may not be reaching it due to completion check timing.
|
||||
|
||||
**Fix:** The middleware allows access to `/loading` which handles the redirect to `/setup/unresolved` after initialization.
|
||||
| Current State | Access `/setup` | Access `/loading` | Access `/setup/unresolved` |
|
||||
|--------------|-----------------|-------------------|---------------------------|
|
||||
| NO_SETUP | ✅ Allowed | ❌ → `/setup` | ❌ → `/setup` |
|
||||
| SETUP_COMPLETE | ❌ → `/loading` | ✅ Allowed | ❌ → `/loading` |
|
||||
| UNRESOLVED_PENDING | ❌ → `/setup/unresolved` | ❌ → `/setup/unresolved` | ✅ Allowed |
|
||||
| UNRESOLVED_DONE | ❌ → `/loading` | ✅ Allowed (NFO phase) | ❌ → `/loading` |
|
||||
| NFO_SCAN_PENDING | ❌ → `/loading` | ✅ Allowed (NFO phase) | ❌ → `/loading` |
|
||||
| COMPLETE | ❌ → `/login` | ❌ → `/login` | ❌ → `/login` |
|
||||
@@ -728,11 +728,11 @@ Every poster check action is logged:
|
||||
|
||||
### 8.1 Custom NFO Templates
|
||||
|
||||
You can customize NFO generation by modifying the NFO service:
|
||||
You can customise NFO generation by modifying `src/server/nfo/nfo_generator.py`:
|
||||
|
||||
```python
|
||||
# src/core/services/nfo_creator.py
|
||||
def generate_tvshow_nfo(self, metadata: dict) -> str:
|
||||
# src/server/nfo/nfo_generator.py
|
||||
def generate_tvshow_nfo(metadata: TVShowNFO) -> str:
|
||||
# Add custom fields or modify structure
|
||||
pass
|
||||
```
|
||||
@@ -811,78 +811,64 @@ updated via `create_tvshow_nfo()` / `update_tvshow_nfo()`.
|
||||
| `watched` | Always `false` on creation | ✅ |
|
||||
| `dateadded` | System clock at creation time (`YYYY-MM-DD HH:MM:SS`) | ✅ |
|
||||
|
||||
The mapping logic lives in `src/core/utils/nfo_mapper.py` (`tmdb_to_nfo_model`).
|
||||
The XML serialisation lives in `src/core/utils/nfo_generator.py`
|
||||
The mapping logic lives in `src/server/nfo/nfo_mapper.py` (`tmdb_to_nfo_model`).
|
||||
The XML serialisation lives in `src/server/nfo/nfo_generator.py`
|
||||
(`generate_tvshow_nfo`).
|
||||
|
||||
---
|
||||
|
||||
## 11. Automatic NFO Repair
|
||||
|
||||
NFO repair now runs as part of the scheduled daily folder scan rather than on every
|
||||
startup. When the scheduler triggers `FolderScanService.run_folder_scan()`, the first
|
||||
step is `perform_nfo_repair_scan(background_loader=None)`. Each incomplete NFO is
|
||||
queued as a background `asyncio` task, so the scan returns quickly while repairs
|
||||
continue asynchronously.
|
||||
NFO repair runs as part of the scheduled daily scan via ``SchedulerService``.
|
||||
When the scheduler fires, it calls ``_run_nfo_scan()`` which delegates to
|
||||
``NfoScanService.scan_all()``. This detects series whose ``tvshow.nfo`` is
|
||||
missing required tags and regenerates them from TMDB.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Scan** — `perform_nfo_repair_scan()` in
|
||||
`src/server/services/initialization_service.py` is called from
|
||||
`FolderScanService.run_folder_scan()` (`src/server/services/folder_scan_service.py`).
|
||||
2. **Detect** — `nfo_needs_repair(nfo_path)` from
|
||||
`src/core/services/nfo_repair_service.py` parses each `tvshow.nfo` with
|
||||
`lxml` and checks for the 13 required tags listed below.
|
||||
3. **Repair** — Series whose NFO is incomplete are queued for background reload
|
||||
via `asyncio.create_task`. Each task creates its own isolated
|
||||
:class:`NFOService` / :class:`TMDBClient` so concurrent tasks never share an
|
||||
``aiohttp`` session — this prevents "Connector is closed" errors when many repairs
|
||||
run in parallel. A semaphore caps TMDB concurrency at 3 to stay within rate limits.
|
||||
1. **Scheduler** fires the daily job (``SchedulerService._run_nfo_scan()``)
|
||||
2. **Detect** — ``NfoScanService._scan_series()`` parses each ``tvshow.nfo``
|
||||
and calls ``_create_nfo()`` / ``_update_nfo_if_needed()`` /
|
||||
``_regenerate_nfo()`` to fill missing tags from TMDB
|
||||
3. **Repair** — If TMDB lookup succeeds, the NFO is overwritten with complete
|
||||
data; if it fails, the original is kept and the failure is logged
|
||||
|
||||
### Tags Checked (13 required)
|
||||
### Tags Written / Updated
|
||||
|
||||
| XPath | Tag name |
|
||||
| ----------------- | --------------- |
|
||||
| `./title` | `title` |
|
||||
| `./originaltitle` | `originaltitle` |
|
||||
| `./year` | `year` |
|
||||
| `./plot` | `plot` |
|
||||
| `./runtime` | `runtime` |
|
||||
| `./premiered` | `premiered` |
|
||||
| `./status` | `status` |
|
||||
| `./imdbid` | `imdbid` |
|
||||
| `./genre` | `genre` |
|
||||
| `./studio` | `studio` |
|
||||
| `./country` | `country` |
|
||||
| `./actor/name` | `actor/name` |
|
||||
| `./watched` | `watched` |
|
||||
The NFO scan writes all 17 tags listed in the
|
||||
[Tag Reference](#10-tag-reference) above. Missing or empty tags trigger a
|
||||
regeneration from TMDB.
|
||||
|
||||
### Log Messages
|
||||
|
||||
| Message | Meaning |
|
||||
| ----------------------------------------------------------- | ------------------------------------------------- |
|
||||
| `NFO repair scan complete: 0 of N series queued for repair` | All NFOs are complete — no action needed |
|
||||
| `NFO repair scan complete: X of N series queued for repair` | X series had incomplete NFOs and have been queued |
|
||||
| `NFO repair scan skipped: TMDB API key not configured` | Set `tmdb_api_key` in `data/config.json` |
|
||||
| `NFO repair scan skipped: anime directory not configured` | Set `anime_directory` in `data/config.json` |
|
||||
|| Message | Meaning |
|
||||
| --- | --- |
|
||||
| `NFO scan complete: N series processed` | Scan finished normally |
|
||||
| `NFO scan skipped: TMDB API key not configured` | ``tmdb_api_key`` is empty — set it in ``data/config.json`` |
|
||||
| `NFO scan skipped: anime directory not configured` | ``anime_directory`` is not set |
|
||||
|
||||
### Triggering a Manual Repair
|
||||
### Manual Repair
|
||||
|
||||
You can also repair a single series on demand via the API:
|
||||
You can repair a single series on demand via the API:
|
||||
|
||||
```http
|
||||
POST /api/nfo/update/{series_key}
|
||||
POST /api/nfo/{series_key}/create
|
||||
```
|
||||
|
||||
This calls `NFOService.update_tvshow_nfo()` directly and overwrites the existing
|
||||
`tvshow.nfo` with fresh data from TMDB.
|
||||
or update with fresh TMDB data:
|
||||
|
||||
```http
|
||||
POST /api/nfo/{series_key}/update
|
||||
```
|
||||
|
||||
### Source Files
|
||||
|
||||
| File | Purpose |
|
||||
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| `src/core/services/nfo_repair_service.py` | `REQUIRED_TAGS`, `parse_nfo_tags`, `find_missing_tags`, `nfo_needs_repair`, `NfoRepairService` |
|
||||
| `src/server/services/folder_scan_service.py` | `perform_nfo_repair_scan` — invoked during the scheduled daily folder scan |
|
||||
|| File | Purpose |
|
||||
| --- | --- |
|
||||
| ``src/server/services/scheduler/scheduler_service.py`` | ``SchedulerService._run_nfo_scan()`` — entry point called by the scheduler |
|
||||
| ``src/server/services/nfo_scan_service.py`` | ``NfoScanService.scan_all()`` — detects incomplete NFOs and regenerates them |
|
||||
| ``src/server/services/scan_service.py`` | ``ScanService`` — library rescan (episodes, missing files) |
|
||||
| ``src/server/services/folder_naming_service.py`` | ``FolderNamingService`` — renames folders to ``Title (YYYY)`` format |
|
||||
|
||||
---
|
||||
|
||||
|
||||
109
Docs/TESTING.md
109
Docs/TESTING.md
@@ -1,104 +1,33 @@
|
||||
# Testing Documentation
|
||||
|
||||
## Document Purpose
|
||||
### Testing FolderNamingService
|
||||
|
||||
This document describes the testing strategy, guidelines, and practices for the Aniworld project.
|
||||
|
||||
### What This Document Contains
|
||||
|
||||
- **Testing Strategy**: Overall approach to quality assurance
|
||||
- **Test Categories**: Unit, integration, API, performance, security tests
|
||||
- **Test Structure**: Organization of test files and directories
|
||||
- **Writing Tests**: Guidelines for writing effective tests
|
||||
- **Fixtures and Mocking**: Shared test utilities and mock patterns
|
||||
- **Running Tests**: Commands and configurations
|
||||
- **Coverage Requirements**: Minimum coverage thresholds
|
||||
- **CI/CD Integration**: How tests run in automation
|
||||
- **Test Data Management**: Managing test fixtures and data
|
||||
- **Best Practices**: Do's and don'ts for testing
|
||||
|
||||
### What This Document Does NOT Contain
|
||||
|
||||
- Production deployment (see [DEPLOYMENT.md](DEPLOYMENT.md))
|
||||
- Security audit procedures (see [SECURITY.md](SECURITY.md))
|
||||
- Bug tracking and issue management
|
||||
- Performance benchmarking results
|
||||
|
||||
### Target Audience
|
||||
|
||||
- Developers writing tests
|
||||
- QA Engineers
|
||||
- CI/CD Engineers
|
||||
- Code reviewers
|
||||
|
||||
---
|
||||
|
||||
## Sections to Document
|
||||
|
||||
1. Testing Philosophy
|
||||
- Test pyramid approach
|
||||
- Quality gates
|
||||
2. Test Categories
|
||||
- Unit Tests (`tests/unit/`)
|
||||
- Integration Tests (`tests/integration/`)
|
||||
- API Tests (`tests/api/`)
|
||||
- Frontend Tests (`tests/frontend/`)
|
||||
- Performance Tests (`tests/performance/`)
|
||||
- Security Tests (`tests/security/`)
|
||||
3. Test Structure and Naming
|
||||
- File naming conventions
|
||||
- Test function naming
|
||||
- Test class organization
|
||||
4. Running Tests
|
||||
- pytest commands
|
||||
- Running specific tests
|
||||
- Verbose output
|
||||
- Coverage reports
|
||||
5. Fixtures and Conftest
|
||||
- Shared fixtures
|
||||
- Database fixtures
|
||||
- Mock services
|
||||
6. Mocking Guidelines
|
||||
- What to mock
|
||||
- Mock patterns
|
||||
- External service mocks
|
||||
|
||||
### Mocking the Download Queue
|
||||
|
||||
Use `MockQueueRepository` for testing download queue functionality:
|
||||
|
||||
```python
|
||||
from src.server.models.download import DownloadItem, EpisodeIdentifier
|
||||
|
||||
class MockQueueRepository:
|
||||
def __init__(self):
|
||||
self._items: Dict[str, DownloadItem] = {}
|
||||
```
|
||||
|
||||
### Testing SetupService
|
||||
|
||||
SetupService handles series key resolution from folder names during library setup. Test file: `tests/unit/test_setup_service.py`.
|
||||
FolderNamingService fixes missing years in anime folder names after NFO refresh. Test file: `tests/unit/test_folder_naming_service.py`.
|
||||
|
||||
Key methods tested:
|
||||
- `_build_target_folder()` — constructs safe "Title (YYYY)" names, strips existing year suffix first (prevents double-year like "Naruto (1999) (1999)")
|
||||
- `_folder_has_year()` — detects existing `(YYYY)` pattern
|
||||
- `_extract_year_from_folder_name()` — parses `(YYYY)` suffix
|
||||
- `_extract_title_from_folder_name()` — strips year suffix
|
||||
- `_resolve_key_via_search()` — resolves provider key via fuzzy title matching
|
||||
|
||||
```python
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_key_when_single_exact_match(self):
|
||||
"""Search returns 1 result with same name → returns key."""
|
||||
mock_series_app = AsyncMock()
|
||||
mock_series_app.search.return_value = [
|
||||
{'title': 'Attack on Titan', 'link': '/anime/stream/attack-on-titan'}
|
||||
]
|
||||
# Safe: repeated calls never accumulate years
|
||||
r1 = FolderNamingService._build_target_folder("Naruto", 1999)
|
||||
r2 = FolderNamingService._build_target_folder(r1, 1999)
|
||||
assert r1 == r2 == "Naruto (1999)"
|
||||
|
||||
with patch('src.server.services.setup_service.get_series_app', return_value=mock_series_app):
|
||||
result = await SetupService._resolve_key_via_search("Attack on Titan")
|
||||
|
||||
assert result == 'attack-on-titan'
|
||||
# Safe: existing year is replaced, not appended
|
||||
result = FolderNamingService._build_target_folder("Naruto (2020)", 1999)
|
||||
assert result == "Naruto (1999)"
|
||||
assert "2020" not in result
|
||||
```
|
||||
|
||||
The service is also tested end-to-end with mocked filesystem and database:
|
||||
- Renames folder and updates DB when year is missing from folder name
|
||||
- Skips rename when folder already has a year
|
||||
- Skips rename when DB has no year
|
||||
- Skips when target folder already exists on disk
|
||||
- Safety guard detects and skips if target folder year mismatches DB year
|
||||
|
||||
### Mocking aiohttp Sessions
|
||||
|
||||
When testing code that uses `aiohttp.ClientSession`:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -90,7 +91,7 @@ The application now features a comprehensive configuration system that allows us
|
||||
- **Queue Organization**: Displays downloads organized by status (pending, active, completed, failed)
|
||||
- **NFO Integration**: Automatic NFO and media file creation before episode downloads
|
||||
- **Manual Start/Stop Control**: User manually starts downloads one at a time with Start/Stop buttons
|
||||
- **FIFO Queue Processing**: First-in, first-out queue order (no priority or reordering)
|
||||
- **Queue Processing Order**: Items processed in user-defined order via drag-and-drop reordering (`POST /api/queue/reorder`)
|
||||
- **Single Download Mode**: Only one download active at a time, new downloads must be manually started
|
||||
- **Download Status Display**: Real-time status updates and progress of current download
|
||||
- **Queue Operations**: Add and remove items from the pending queue
|
||||
|
||||
5
Docs/key
5
Docs/key
@@ -1,3 +1,8 @@
|
||||
API key : 299ae8f630a31bda814263c551361448
|
||||
9bc3e547caff878615cbdba2cc421d37
|
||||
|
||||
/setup
|
||||
|
||||
|
||||
SeriesApp initialized for directory:
|
||||
to remove:
|
||||
|
||||
@@ -24,7 +24,7 @@ Console.CancelKeyPress += (_, e) =>
|
||||
|
||||
// ── Paths ─────────────────────────────────────────────────────────────────────
|
||||
var repoRoot = Directory.GetCurrentDirectory();
|
||||
var tasksFile = Path.Combine(repoRoot, "Docs", "Tasks.md");
|
||||
var tasksFile = Path.Combine(repoRoot, "Docs", "tasks.md");
|
||||
|
||||
if (!File.Exists(tasksFile))
|
||||
{
|
||||
@@ -102,7 +102,7 @@ for (int i = 0; i < items.Count; i++)
|
||||
|
||||
// Step 1 — run the task prompt
|
||||
await RunCopilot(Enumerable.Empty<string>(), $"/caveman full");
|
||||
await RunCopilot(new[] { "--continue" }, $"read ./Docs/instructions.md. {item}");
|
||||
await RunCopilot(new[] { "--continue" }, $"{item}");
|
||||
if (cts.IsCancellationRequested) break;
|
||||
|
||||
// Step 2 — confirm completion in the same chat session
|
||||
|
||||
178
Docs/tasks.md
178
Docs/tasks.md
@@ -1,178 +0,0 @@
|
||||
# Tasks
|
||||
|
||||
## 1. Scheduled Folder Scan
|
||||
|
||||
### Task 1.1: Add folder scan scheduler configuration
|
||||
|
||||
**Where is that found**
|
||||
- `src/server/models/config.py` (`SchedulerConfig`)
|
||||
- `data/config.json` (example/default config)
|
||||
- `src/server/web/templates/setup.html` (setup UI)
|
||||
- `src/server/api/auth.py` (config save endpoint, if it validates scheduler fields)
|
||||
|
||||
**Goal. How it should be**
|
||||
Add a new boolean field `folder_scan_enabled` (default `false`) to `SchedulerConfig`. When `true`, the scheduler will execute the folder maintenance routine during its scheduled run. Add the field to the setup page as a checkbox. Ensure existing configs without this field load successfully (Pydantic default handles this).
|
||||
|
||||
**Possible traps and issues**
|
||||
- Backward compatibility: old `data/config.json` files must load without errors. Pydantic defaults solve this, but verify by loading an old config.
|
||||
- The setup page JavaScript must include the new field in the payload sent to `/api/config`.
|
||||
- Do not confuse this with `auto_download_after_rescan` — this is a separate toggle.
|
||||
|
||||
**Docs changes needed**
|
||||
- `docs/CONFIGURATION.md`: Document the new `scheduler.folder_scan_enabled` option.
|
||||
- `docs/ARCHITECTURE.md`: Mention folder scan in the scheduler section.
|
||||
|
||||
**Why this is needed**
|
||||
Users need an opt-in toggle to enable automatic daily folder maintenance (NFO repair, folder renaming, poster checks) without forcing it on everyone.
|
||||
|
||||
---
|
||||
|
||||
### Task 1.2: Create FolderScanService skeleton
|
||||
|
||||
**Where is that found**
|
||||
- New file: `src/server/services/folder_scan_service.py`
|
||||
- `src/server/services/scheduler_service.py` (to call it)
|
||||
|
||||
**Goal. How it should be**
|
||||
Create a new `FolderScanService` class with a single async entry point `async def run_folder_scan(self) -> None`. The method should:
|
||||
1. Log start/completion with structlog.
|
||||
2. Check prerequisites (`settings.anime_directory` exists, `settings.tmdb_api_key` is set).
|
||||
3. Skip gracefully with a warning log if prerequisites are missing.
|
||||
4. Use a module-level semaphore (similar to `_NFO_REPAIR_SEMAPHORE`) to limit concurrent TMDB operations to 3.
|
||||
|
||||
Keep the implementation empty for the sub-tasks (1.3–1.5) to fill in. Just add the skeleton and the semaphore.
|
||||
|
||||
**Possible traps and issues**
|
||||
- Circular imports: `folder_scan_service.py` will import from `initialization_service`, `config.settings`, etc. Keep imports inside methods or at the bottom if circular issues arise.
|
||||
- The service should follow the singleton pattern like `SchedulerService` and `DownloadService` if it holds state, or be stateless. For simplicity, make it a plain class instantiated per call or a module-level function set.
|
||||
- Exception handling: any unhandled exception in the scheduled task should be caught and logged so it doesn't crash the scheduler.
|
||||
|
||||
**Docs changes needed**
|
||||
- `docs/ARCHITECTURE.md`: Add `folder_scan_service.py` to the services list.
|
||||
|
||||
**Why this is needed**
|
||||
Encapsulates the new daily maintenance logic in its own module, keeping `scheduler_service.py` clean and allowing the folder scan to be tested independently.
|
||||
|
||||
---
|
||||
|
||||
### Task 1.3: Integrate NFO repair into folder scan
|
||||
|
||||
**Where is that found**
|
||||
- `src/server/services/folder_scan_service.py`
|
||||
- `src/server/services/initialization_service.py` (`perform_nfo_repair_scan`)
|
||||
|
||||
**Goal. How it should be**
|
||||
Inside `FolderScanService.run_folder_scan()`, call `perform_nfo_repair_scan(background_loader=None)` as the first step. Reuse the existing function exactly — do not copy its logic. Log a message before and after the call.
|
||||
|
||||
**Possible traps and issues**
|
||||
- `perform_nfo_repair_scan` spawns `asyncio.create_task` for each repair. When called from the scheduler, these background tasks will still run after `run_folder_scan` returns. This is fine, but log that repairs are queued.
|
||||
- The function already handles missing `tmdb_api_key` and `anime_directory`, so the caller doesn't need to double-check, but the skeleton from Task 1.2 already checks prerequisites.
|
||||
- `perform_nfo_repair_scan` imports `nfo_needs_repair` and `NfoRepairService` inside the function, so no heavy import-time dependencies.
|
||||
|
||||
**Docs changes needed**
|
||||
- `docs/NFO_GUIDE.md`: Update the "Automatic NFO Repair" section to state that repair now runs as part of the scheduled folder scan instead of every startup.
|
||||
|
||||
**Why this is needed**
|
||||
Reuses the existing, tested NFO repair logic. Moves NFO repair from startup blocking to scheduled background maintenance.
|
||||
|
||||
---
|
||||
|
||||
### Task 1.4: Validate and rename series folders
|
||||
|
||||
**Where is that found**
|
||||
- `src/server/services/folder_scan_service.py`
|
||||
- `src/core/services/nfo_repair_service.py` (for `parse_nfo_tags` or similar NFO parsing)
|
||||
- `src/server/database/models.py` / `src/server/database/system_settings_service.py` (if folder paths are stored in DB)
|
||||
|
||||
**Goal. How it should be**
|
||||
After NFO repair, iterate over every subfolder in `settings.anime_directory` that contains a `tvshow.nfo`. For each folder:
|
||||
1. Parse the NFO to extract `<title>` and `<year>` text values.
|
||||
2. Compute the expected folder name: `f"{title} ({year})"`.
|
||||
3. Sanitize the expected name for filesystem safety (remove/replace illegal characters like `/`, `\`, `:`, etc.).
|
||||
4. Compare with the current folder name (`series_dir.name`).
|
||||
5. If different, rename the folder using `series_dir.rename(expected_path)`.
|
||||
6. If the series path is stored in the database (check `anime_service` or DB models), update the database record to point to the new path.
|
||||
|
||||
Skip folders where title or year is missing/empty. Log every rename action.
|
||||
|
||||
**Possible traps and issues**
|
||||
- **Database path consistency**: If `Series` or `Episode` models store absolute or relative paths, renaming the folder on disk without updating the DB will break downloads, NFO updates, and the web UI. Must verify whether paths are stored in the DB and update them.
|
||||
- **Active downloads**: A series currently being downloaded should not be renamed. Check the download queue or lock status before renaming. If no lock mechanism exists, this is a major trap — document it.
|
||||
- **Filesystem permissions**: The app may not have write permission to the anime directory. Catch `PermissionError` and `OSError` and log gracefully.
|
||||
- **Special characters**: Titles like `"A / B"` or `"Show: Subtitle"` contain characters illegal in folder names. Define a sanitization function (e.g., replace `/` with `-`, remove trailing dots on Windows, etc.).
|
||||
- **Duplicate names**: Two different series could sanitize to the same name. Check if target path already exists before renaming.
|
||||
- **Path length limits**: Very long titles might exceed OS path limits.
|
||||
|
||||
**Docs changes needed**
|
||||
- `docs/NFO_GUIDE.md`: Add a section "Folder Naming Convention" explaining the `<title> (<year>)` format.
|
||||
- `docs/CONFIGURATION.md`: Mention that enabling folder scan will rename folders.
|
||||
|
||||
**Why this is needed**
|
||||
Enforces a consistent, predictable folder naming scheme across the library, making it easier for media center apps (Kodi, Jellyfin, Plex) to match metadata.
|
||||
|
||||
---
|
||||
|
||||
### Task 1.5: Check and download missing poster.jpg
|
||||
|
||||
**Where is that found**
|
||||
- `src/server/services/folder_scan_service.py`
|
||||
- `src/core/utils/image_downloader.py` (`ImageDownloader`)
|
||||
- `src/core/services/nfo_service.py` or `src/core/services/nfo_repair_service.py` (to get poster URL from NFO or TMDB)
|
||||
|
||||
**Goal. How it should be**
|
||||
After folder renaming, iterate over series folders again (or combine with Task 1.4 loop). For each folder:
|
||||
1. Check if `poster.jpg` exists and has a size ≥ `ImageDownloader.min_file_size` (1 KB by default).
|
||||
2. If missing or too small:
|
||||
a. Parse `tvshow.nfo` for `<thumb aspect="poster">` or `<thumb>` URL.
|
||||
b. If no URL in NFO, skip (do not query TMDB again to keep tasks small; the NFO should already have it after repair).
|
||||
c. Use `ImageDownloader` (with context manager) to download the image to `series_dir / "poster.jpg"`.
|
||||
d. Validate the downloaded image with `ImageDownloader._validate_image` (or similar existing validation).
|
||||
3. Use the existing `_NFO_REPAIR_SEMAPHORE` or a new `POSTER_DOWNLOAD_SEMAPHORE` to limit concurrent downloads to 3.
|
||||
|
||||
**Possible traps and issues**
|
||||
- **TMDB rate limiting**: Even downloading images hits TMDB CDN. The semaphore limits concurrency.
|
||||
- **Invalid images**: A download might produce a 0-byte or corrupted file. `ImageDownloader` already validates with PIL; reuse that.
|
||||
- **NFO without thumb URL**: If the NFO was created before thumb tags were added, there may be no URL. In that case, skip and log. A future task could query TMDB directly.
|
||||
- **Write permissions**: Same as Task 1.4.
|
||||
- **Async session sharing**: `ImageDownloader` manages its own `aiohttp` session. Use `async with ImageDownloader() as downloader:` to ensure cleanup.
|
||||
|
||||
**Docs changes needed**
|
||||
- `docs/NFO_GUIDE.md`: Add "Poster Check" subsection under folder scan.
|
||||
- `docs/CONFIGURATION.md`: Mention that `nfo.download_poster` setting also affects scheduled poster checks.
|
||||
|
||||
**Why this is needed**
|
||||
Ensures every series has artwork, which is required by most media center front-ends for a polished library view.
|
||||
|
||||
---
|
||||
|
||||
## 2. Remove startup NFO repair
|
||||
|
||||
### Task 2.1: Remove perform_nfo_repair_scan from startup lifespan
|
||||
|
||||
**Where is that found**
|
||||
- `src/server/fastapi_app.py` (lifespan startup block, lines ~245 and ~319)
|
||||
- `src/server/services/initialization_service.py` (keep the function, just remove the call site)
|
||||
- `tests/integration/test_nfo_repair_startup.py`
|
||||
- `tests/unit/test_initialization_service.py` (tests that call `perform_nfo_repair_scan` directly can stay, but integration tests verifying startup wiring must change)
|
||||
|
||||
**Goal. How it should be**
|
||||
1. In `src/server/fastapi_app.py`, remove the import of `perform_nfo_repair_scan` from the `initialization_service` import block.
|
||||
2. Remove the line `await perform_nfo_repair_scan(background_loader)` from the lifespan startup sequence.
|
||||
3. Update `tests/integration/test_nfo_repair_startup.py`:
|
||||
- Remove or modify `test_perform_nfo_repair_scan_imported_in_lifespan` and `test_perform_nfo_repair_scan_called_after_media_scan` since the startup wiring is gone.
|
||||
- Replace with a test that verifies `perform_nfo_repair_scan` is NOT called during startup (or simply delete the file if it has no other purpose).
|
||||
4. `tests/unit/test_initialization_service.py` tests for `perform_nfo_repair_scan` can remain because they test the function itself, not the startup wiring.
|
||||
|
||||
**Possible traps and issues**
|
||||
- **Test failures**: `test_nfo_repair_startup.py` will fail immediately after the code change. It must be updated in the same PR.
|
||||
- **Documentation drift**: `docs/NFO_GUIDE.md`, `docs/CHANGELOG.md`, and `docs/ARCHITECTURE.md` all describe the startup NFO repair behavior. If docs are not updated, users will expect repair on every start.
|
||||
- **Background loader parameter**: The `background_loader` variable was created partly for `perform_nfo_repair_scan`. After removal, check if `background_loader` is still needed for other startup steps (yes — `perform_media_scan_if_needed` uses it). Do not remove `background_loader` entirely.
|
||||
- **Import cleanup**: Ensure no unused imports remain in `fastapi_app.py` after removal.
|
||||
|
||||
**Docs changes needed**
|
||||
- `docs/NFO_GUIDE.md`: Update section 11 "Automatic NFO Repair" to remove startup references and state it runs via scheduler.
|
||||
- `docs/CHANGELOG.md`: Add an entry under "Changed" or "Removed" noting that startup NFO repair is replaced by scheduled folder scan.
|
||||
- `docs/ARCHITECTURE.md`: Update the startup sequence description.
|
||||
|
||||
**Why this is needed**
|
||||
Running `perform_nfo_repair_scan` on every startup slows down server restarts, especially for large libraries. Moving it to a scheduled task keeps startup fast while still ensuring regular maintenance.
|
||||
|
||||
24
Makefile
Normal file
24
Makefile
Normal file
@@ -0,0 +1,24 @@
|
||||
.PHONY: up down clean browser-clean setup test-robot
|
||||
|
||||
up:
|
||||
python run_server.py
|
||||
|
||||
down:
|
||||
pkill -f "uvicorn src.server.fastapi_app:app" || pkill -f "python.*run_server.py" || true
|
||||
|
||||
clean:
|
||||
rm -rf data/*.db data/*.db-shm data/*.db-wal data/config.json
|
||||
|
||||
browser-clean:
|
||||
rm -rf "$$HOME/.cache/microsoft-edge"/* || true
|
||||
rm -rf "$$HOME/.cache/mozilla/firefox"/* || true
|
||||
find "$$HOME/.mozilla/firefox" -name "cache2" -type d -exec rm -rf {} \; 2>/dev/null || true
|
||||
|
||||
setup:
|
||||
curl -X POST http://127.0.0.1:8000/setup \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: 299ae8f630a31bda814263c551361448" \
|
||||
-d '{"path": "/home/lukas/Volume/serien/", "password": "Hallo123!"}'
|
||||
|
||||
test-robot:
|
||||
bash tests/robot/run.sh
|
||||
BIN
browser/screenshot/fail-screenshot-1.png
Normal file
BIN
browser/screenshot/fail-screenshot-1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
2644
package-lock.json
generated
Normal file
2644
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aniworld-web",
|
||||
"version": "1.4.11",
|
||||
"version": "1.5.6",
|
||||
"description": "Aniworld Anime Download Manager - Web Frontend",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -18,7 +18,7 @@
|
||||
"@playwright/test": "^1.41.0",
|
||||
"@vitest/coverage-v8": "^1.2.0",
|
||||
"@vitest/ui": "^1.2.0",
|
||||
"happy-dom": "^13.3.5",
|
||||
"happy-dom": "^13.10.1",
|
||||
"vitest": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -25,4 +25,10 @@ beautifulsoup4>=4.12.0
|
||||
chardet>=5.2.0
|
||||
fake-useragent>=1.4.0
|
||||
yt-dlp>=2024.1.0
|
||||
urllib3>=2.0.0
|
||||
urllib3>=2.0.0
|
||||
|
||||
# Robot Framework testing dependencies
|
||||
robotframework>=7.0
|
||||
robotframework-browser>=18.0
|
||||
robotframework-requests>=0.9
|
||||
robotframework-jsonlibrary>=0.5
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import warnings
|
||||
from typing import Any, List, Optional
|
||||
@@ -16,7 +17,15 @@ from src.server.exceptions import (
|
||||
ServerError,
|
||||
ValidationError,
|
||||
)
|
||||
from src.server.models.anime import AnimeMetadataUpdate
|
||||
from src.server.models.anime import (
|
||||
AnimeDetailsResponse,
|
||||
AnimeSettingsRegenerateNfoResponse,
|
||||
AnimeSettingsResponse,
|
||||
AnimeSettingsUpdateRequest,
|
||||
DeleteSeriesRequest,
|
||||
DeleteSeriesResult,
|
||||
TMDBSearchResult,
|
||||
)
|
||||
from src.server.services.anime_service import AnimeService, AnimeServiceError
|
||||
from src.server.services.background_loader_service import BackgroundLoaderService
|
||||
from src.server.utils.dependencies import (
|
||||
@@ -27,7 +36,7 @@ from src.server.utils.dependencies import (
|
||||
get_series_app,
|
||||
require_auth,
|
||||
)
|
||||
from src.server.utils.filesystem import sanitize_folder_name
|
||||
from src.server.utils.filesystem import is_safe_path, sanitize_folder_name
|
||||
from src.server.utils.key_utils import generate_key_from_folder, is_valid_key
|
||||
from src.server.utils.validators import validate_filter_value, validate_search_query
|
||||
|
||||
@@ -448,11 +457,11 @@ async def trigger_rescan(
|
||||
}
|
||||
except AnimeServiceError as e:
|
||||
raise ServerError(
|
||||
message=f"Rescan failed: {str(e)}"
|
||||
message=str(e)
|
||||
) from e
|
||||
except Exception as exc:
|
||||
raise ServerError(
|
||||
message="Failed to start rescan"
|
||||
message=f"Failed to start rescan: {exc}"
|
||||
) from exc
|
||||
|
||||
|
||||
@@ -942,16 +951,13 @@ async def add_series(
|
||||
e
|
||||
)
|
||||
|
||||
# Step G: Scan missing episodes immediately if background loader is not running
|
||||
# Uses existing SerieScanner and AnimeService sync to avoid duplicates
|
||||
# Step G: Scan missing episodes immediately
|
||||
# Always scan synchronously to ensure episodes are available when
|
||||
# get_anime is called right after add_series returns.
|
||||
# Background loader handles any additional work and rescan logic.
|
||||
try:
|
||||
loader_running = bool(
|
||||
background_loader.worker_tasks
|
||||
and any(not t.done() for t in background_loader.worker_tasks)
|
||||
)
|
||||
if (
|
||||
not loader_running
|
||||
and series_app
|
||||
series_app
|
||||
and hasattr(series_app, "serie_scanner")
|
||||
):
|
||||
missing_episodes = series_app.serie_scanner.scan_single_series(
|
||||
@@ -1104,7 +1110,8 @@ async def get_loading_status(
|
||||
@router.get("/{anime_id}", response_model=AnimeDetail)
|
||||
async def get_anime(
|
||||
anime_id: str,
|
||||
series_app: Optional[Any] = Depends(get_series_app)
|
||||
series_app: Optional[Any] = Depends(get_series_app),
|
||||
db: Optional[AsyncSession] = Depends(get_optional_database_session),
|
||||
) -> AnimeDetail:
|
||||
"""Return detailed information about a specific series.
|
||||
|
||||
@@ -1171,6 +1178,22 @@ async def get_anime(
|
||||
|
||||
episodes: List[str] = []
|
||||
episode_dict = getattr(found, "episodeDict", {}) or {}
|
||||
|
||||
# If in-memory episodeDict is empty, try fetching from database directly
|
||||
if not episode_dict and db is not None:
|
||||
try:
|
||||
db_series = await AnimeSeriesService.get_by_key(
|
||||
db, anime_id, with_episodes=True
|
||||
)
|
||||
if db_series:
|
||||
episode_dict = db_series.episodeDict or {}
|
||||
except Exception as db_exc:
|
||||
logger.warning(
|
||||
"Failed to fetch episodes from DB for '%s': %s",
|
||||
anime_id,
|
||||
db_exc,
|
||||
)
|
||||
|
||||
for season, episode_numbers in episode_dict.items():
|
||||
for episode in episode_numbers:
|
||||
episodes.append(f"{season}-{episode}")
|
||||
@@ -1186,39 +1209,41 @@ async def get_anime(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to retrieve series details for '%s': %s",
|
||||
anime_id,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to retrieve series details",
|
||||
) from exc
|
||||
|
||||
|
||||
# Maximum allowed input size for security
|
||||
MAX_INPUT_LENGTH = 100000 # 100KB
|
||||
|
||||
|
||||
@router.put("/{anime_key}")
|
||||
async def update_anime_metadata(
|
||||
@router.get("/{anime_key}/details", response_model=AnimeDetailsResponse)
|
||||
async def get_anime_details(
|
||||
anime_key: str,
|
||||
body: AnimeMetadataUpdate,
|
||||
_auth: dict = Depends(require_auth),
|
||||
db: AsyncSession = Depends(get_database_session),
|
||||
) -> dict:
|
||||
"""Update anime metadata (key, tmdb_id, tvdb_id).
|
||||
) -> AnimeDetailsResponse:
|
||||
"""Get detailed information about a specific anime series for the edit modal.
|
||||
|
||||
Returns comprehensive series metadata including TMDB/TVDB IDs, NFO status,
|
||||
and other details needed to pre-fill the edit form.
|
||||
|
||||
Args:
|
||||
anime_key: Current series key to update
|
||||
body: Fields to update (all optional)
|
||||
anime_key: Series key (primary identifier)
|
||||
_auth: Authentication dependency
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Updated series metadata
|
||||
AnimeDetailsResponse: Full series details for edit modal
|
||||
|
||||
Raises:
|
||||
HTTPException 404: Series not found
|
||||
HTTPException 409: Key conflict (new key already exists)
|
||||
HTTPException 422: Validation error
|
||||
"""
|
||||
# Get series from database (authoritative source for IDs and NFO status)
|
||||
series = await AnimeSeriesService.get_by_key(db, anime_key)
|
||||
if not series:
|
||||
raise HTTPException(
|
||||
@@ -1226,44 +1251,589 @@ async def update_anime_metadata(
|
||||
detail=f"Series with key '{anime_key}' not found",
|
||||
)
|
||||
|
||||
updates = {}
|
||||
# Format timestamps
|
||||
nfo_created = None
|
||||
nfo_updated = None
|
||||
if series.nfo_created_at:
|
||||
nfo_created = series.nfo_created_at.isoformat()
|
||||
if series.nfo_updated_at:
|
||||
nfo_updated = series.nfo_updated_at.isoformat()
|
||||
|
||||
if body.key is not None and body.key != anime_key:
|
||||
existing = await AnimeSeriesService.get_by_key(db, body.key)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"A series with key '{body.key}' already exists",
|
||||
)
|
||||
updates["key"] = body.key
|
||||
|
||||
if body.tmdb_id is not None:
|
||||
updates["tmdb_id"] = body.tmdb_id
|
||||
|
||||
if body.tvdb_id is not None:
|
||||
updates["tvdb_id"] = body.tvdb_id
|
||||
|
||||
if not updates:
|
||||
return {
|
||||
"key": series.key,
|
||||
"tmdb_id": series.tmdb_id,
|
||||
"tvdb_id": series.tvdb_id,
|
||||
"message": "No changes",
|
||||
}
|
||||
|
||||
updated = await AnimeSeriesService.update(db, series.id, **updates)
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
"Updated metadata for '%s': %s",
|
||||
anime_key,
|
||||
updates,
|
||||
return AnimeDetailsResponse(
|
||||
key=series.key,
|
||||
name=series.name,
|
||||
folder=series.folder,
|
||||
year=series.year,
|
||||
status=None, # Status not stored in DB, only in NFO/TMDB
|
||||
plot=None, # Plot not stored in DB, only in NFO/TMDB
|
||||
genres=[],
|
||||
studio=[],
|
||||
premiered=None,
|
||||
rating=None,
|
||||
rating_votes=None,
|
||||
tmdb_id=series.tmdb_id,
|
||||
tvdb_id=series.tvdb_id,
|
||||
has_nfo=series.has_nfo,
|
||||
nfo_created_at=nfo_created,
|
||||
nfo_updated_at=nfo_updated,
|
||||
)
|
||||
|
||||
return {
|
||||
"key": updated.key,
|
||||
"tmdb_id": updated.tmdb_id,
|
||||
"tvdb_id": updated.tvdb_id,
|
||||
"message": "Metadata updated successfully",
|
||||
}
|
||||
|
||||
@router.get("/{anime_key}/tmdb-search", response_model=List[TMDBSearchResult])
|
||||
async def search_tmdb_for_series(
|
||||
anime_key: str,
|
||||
_auth: dict = Depends(require_auth),
|
||||
db: AsyncSession = Depends(get_database_session),
|
||||
) -> List[TMDBSearchResult]:
|
||||
"""Search TMDB for a series by its name to find matching metadata.
|
||||
|
||||
Used by the edit modal's "Fetch from TMDB" feature when no TMDB ID
|
||||
is set. Searches TMDB using the series name and returns matches.
|
||||
|
||||
Args:
|
||||
anime_key: Series key to look up
|
||||
_auth: Authentication dependency
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
List[TMDBSearchResult]: Matching TMDB results
|
||||
|
||||
Raises:
|
||||
HTTPException 404: Series not found
|
||||
HTTPException 400: TMDB not configured
|
||||
"""
|
||||
from src.server.nfo.tmdb_client import TMDBClient
|
||||
|
||||
# Get series from database
|
||||
series = await AnimeSeriesService.get_by_key(db, anime_key)
|
||||
if not series:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Series with key '{anime_key}' not found",
|
||||
)
|
||||
|
||||
# Check if TMDB is configured
|
||||
if not settings.tmdb_api_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="TMDB API key not configured",
|
||||
)
|
||||
|
||||
# Search TMDB by series name
|
||||
tmdb_client = TMDBClient(api_key=settings.tmdb_api_key)
|
||||
results = await tmdb_client.search_tv_series(series.name)
|
||||
|
||||
return [
|
||||
TMDBSearchResult(
|
||||
tmdb_id=r["id"],
|
||||
title=r.get("name", ""),
|
||||
year=int(r.get("first_air_date", "0000")[:4]) if r.get("first_air_date") else None,
|
||||
overview=r.get("overview"),
|
||||
vote_average=r.get("vote_average"),
|
||||
)
|
||||
for r in results
|
||||
]
|
||||
|
||||
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Anime Settings endpoints (rename of NFO Diagnostic page)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def _build_anime_settings_payload(
|
||||
anime_key: str,
|
||||
db: AsyncSession,
|
||||
anime_service: AnimeService,
|
||||
) -> AnimeSettingsResponse:
|
||||
"""Build the AnimeSettingsResponse payload for a given series.
|
||||
|
||||
Combines data from the in-memory SeriesApp (folder/name/site/year) with
|
||||
the authoritative database row (tmdb_id, tvdb_id, has_nfo, nfo_*,
|
||||
loading_status) and episode counts.
|
||||
|
||||
Args:
|
||||
anime_key: Series unique key
|
||||
db: Database session
|
||||
anime_service: AnimeService for in-memory fallback
|
||||
|
||||
Returns:
|
||||
AnimeSettingsResponse with all editable fields populated
|
||||
|
||||
Raises:
|
||||
HTTPException 404: If series not found
|
||||
"""
|
||||
from src.server.database.service import AnimeSeriesService, EpisodeService
|
||||
|
||||
db_series = await AnimeSeriesService.get_by_key(db, anime_key)
|
||||
if not db_series:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Series not found: {anime_key}",
|
||||
)
|
||||
|
||||
# Episode counts (authoritative DB source)
|
||||
episodes = await EpisodeService.get_by_series(db, db_series.id)
|
||||
episode_count = len(episodes)
|
||||
missing_episode_count = sum(
|
||||
1 for ep in episodes if not ep.is_downloaded
|
||||
)
|
||||
|
||||
# In-memory fallback for folder/name/site/year (DB is authoritative)
|
||||
name = db_series.name
|
||||
site = db_series.site
|
||||
folder = db_series.folder
|
||||
year = db_series.year
|
||||
if hasattr(anime_service, "_app") and hasattr(anime_service._app, "list"):
|
||||
try:
|
||||
for serie in anime_service._app.list.GetList():
|
||||
if getattr(serie, "key", None) == anime_key:
|
||||
name = getattr(serie, "name", name) or name
|
||||
site = getattr(serie, "site", site) or site
|
||||
folder = getattr(serie, "folder", folder) or folder
|
||||
year = getattr(serie, "year", year) or year
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
nfo_created = (
|
||||
db_series.nfo_created_at.isoformat()
|
||||
if db_series.nfo_created_at else None
|
||||
)
|
||||
nfo_updated = (
|
||||
db_series.nfo_updated_at.isoformat()
|
||||
if db_series.nfo_updated_at else None
|
||||
)
|
||||
|
||||
return AnimeSettingsResponse(
|
||||
key=db_series.key,
|
||||
name=name,
|
||||
site=site,
|
||||
folder=folder,
|
||||
year=year,
|
||||
tmdb_id=db_series.tmdb_id,
|
||||
tvdb_id=db_series.tvdb_id,
|
||||
has_nfo=bool(db_series.has_nfo),
|
||||
nfo_path=db_series.nfo_path,
|
||||
nfo_created_at=nfo_created,
|
||||
nfo_updated_at=nfo_updated,
|
||||
loading_status=db_series.loading_status,
|
||||
episode_count=episode_count,
|
||||
missing_episode_count=missing_episode_count,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{anime_key}/settings", response_model=AnimeSettingsResponse)
|
||||
async def get_anime_settings(
|
||||
anime_key: str,
|
||||
_auth: dict = Depends(require_auth),
|
||||
db: AsyncSession = Depends(get_database_session),
|
||||
anime_service: AnimeService = Depends(get_anime_service),
|
||||
) -> AnimeSettingsResponse:
|
||||
"""Return the full Anime Settings payload for a single series.
|
||||
|
||||
Powers the per-anime settings page reached from the right-click context
|
||||
menu. Returns every field the user can view or edit, plus episode counts.
|
||||
|
||||
Args:
|
||||
anime_key: Series unique key
|
||||
_auth: Authentication dependency
|
||||
db: Database session
|
||||
anime_service: AnimeService dependency
|
||||
|
||||
Returns:
|
||||
AnimeSettingsResponse with key, name, site, folder, year, tmdb_id,
|
||||
tvdb_id, NFO status and episode counts.
|
||||
|
||||
Raises:
|
||||
HTTPException 404: If series not found.
|
||||
"""
|
||||
return await _build_anime_settings_payload(anime_key, db, anime_service)
|
||||
|
||||
|
||||
def _validate_folder_value(folder: str, anime_dir: Optional[str]) -> str:
|
||||
"""Validate and sanitize a folder name.
|
||||
|
||||
Raises HTTPException(422) on empty / invalid folder, 422 on path
|
||||
traversal, 422 if folder escapes anime_dir.
|
||||
"""
|
||||
if not folder or not folder.strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Folder cannot be empty",
|
||||
)
|
||||
try:
|
||||
sanitized = sanitize_folder_name(folder)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Invalid folder name: {exc}",
|
||||
)
|
||||
if anime_dir:
|
||||
full_path = os.path.join(anime_dir, sanitized)
|
||||
if not is_safe_path(anime_dir, full_path):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Folder path is not safe",
|
||||
)
|
||||
return sanitized
|
||||
|
||||
|
||||
def _validate_tmdb_id(tmdb_id: Optional[int]) -> None:
|
||||
"""Validate TMDB ID is positive and within 10 digits."""
|
||||
if tmdb_id is None:
|
||||
return
|
||||
if tmdb_id <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="TMDB ID must be a positive integer",
|
||||
)
|
||||
if tmdb_id > 9999999999:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="TMDB ID exceeds maximum length (10 digits)",
|
||||
)
|
||||
|
||||
|
||||
def _validate_tvdb_id(tvdb_id: Optional[int]) -> None:
|
||||
"""Validate TVDB ID is positive and within 10 digits."""
|
||||
if tvdb_id is None:
|
||||
return
|
||||
if tvdb_id <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="TVDB ID must be a positive integer",
|
||||
)
|
||||
if tvdb_id > 9999999999:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="TVDB ID exceeds maximum length (10 digits)",
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{anime_key}/settings", response_model=AnimeSettingsResponse)
|
||||
async def update_anime_settings(
|
||||
anime_key: str,
|
||||
request: AnimeSettingsUpdateRequest,
|
||||
_auth: dict = Depends(require_auth),
|
||||
db: AsyncSession = Depends(get_database_session),
|
||||
anime_service: AnimeService = Depends(get_anime_service),
|
||||
) -> AnimeSettingsResponse:
|
||||
"""Update editable fields for a single anime series.
|
||||
|
||||
Performs validation on each supplied field, writes the changes to the
|
||||
database (and optionally to tvshow.nfo when ``apply_to_nfo`` is true),
|
||||
then returns the fresh payload.
|
||||
|
||||
Args:
|
||||
anime_key: Series unique key (path param)
|
||||
request: Update payload. All fields optional except as documented
|
||||
in AnimeSettingsUpdateRequest.
|
||||
_auth: Authentication dependency
|
||||
db: Database session
|
||||
anime_service: AnimeService for disk rename + NFO regeneration
|
||||
|
||||
Returns:
|
||||
AnimeSettingsResponse: Updated payload reflecting new values.
|
||||
|
||||
Raises:
|
||||
HTTPException 404: Series not found.
|
||||
HTTPException 422: Validation failure (empty name, invalid folder,
|
||||
non-positive tmdb_id/tvdb_id, oversized id, path traversal).
|
||||
"""
|
||||
from src.server.database.service import AnimeSeriesService
|
||||
|
||||
db_series = await AnimeSeriesService.get_by_key(db, anime_key)
|
||||
if not db_series:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Series not found: {anime_key}",
|
||||
)
|
||||
|
||||
# Field-level validation
|
||||
anime_dir = (
|
||||
settings.anime_directory
|
||||
if hasattr(settings, "anime_directory") else None
|
||||
)
|
||||
|
||||
update_fields: dict = {}
|
||||
|
||||
if request.name is not None:
|
||||
new_name = request.name.strip()
|
||||
if not new_name:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Name cannot be empty",
|
||||
)
|
||||
if len(new_name) > 500:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Name exceeds 500 characters",
|
||||
)
|
||||
update_fields["name"] = new_name
|
||||
|
||||
if request.folder is not None:
|
||||
update_fields["folder"] = _validate_folder_value(
|
||||
request.folder, anime_dir
|
||||
)
|
||||
|
||||
_validate_tmdb_id(request.tmdb_id)
|
||||
if request.tmdb_id is not None:
|
||||
update_fields["tmdb_id"] = request.tmdb_id
|
||||
|
||||
_validate_tvdb_id(request.tvdb_id)
|
||||
if request.tvdb_id is not None:
|
||||
update_fields["tvdb_id"] = request.tvdb_id
|
||||
|
||||
if request.site is not None:
|
||||
update_fields["site"] = request.site
|
||||
|
||||
if not update_fields and not request.apply_to_nfo and not request.rename_disk:
|
||||
# Nothing to do — return current state
|
||||
return await _build_anime_settings_payload(anime_key, db, anime_service)
|
||||
|
||||
# Apply DB update
|
||||
if update_fields:
|
||||
await AnimeSeriesService.update(
|
||||
db, db_series.id, **update_fields
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(db_series)
|
||||
logger.info(
|
||||
"Updated anime settings for %s: %s",
|
||||
anime_key,
|
||||
sorted(update_fields.keys()),
|
||||
)
|
||||
|
||||
# Update in-memory SerieList so the UI sees the changes immediately
|
||||
if hasattr(anime_service, "_app") and hasattr(anime_service._app, "list"):
|
||||
try:
|
||||
in_mem = anime_service._app.list.keyDict.get(anime_key)
|
||||
if in_mem is not None:
|
||||
if "name" in update_fields:
|
||||
in_mem.name = update_fields["name"]
|
||||
if "folder" in update_fields:
|
||||
in_mem.folder = update_fields["folder"]
|
||||
if "site" in update_fields:
|
||||
in_mem.site = update_fields["site"]
|
||||
except Exception as exc:
|
||||
logger.debug("Could not update in-memory serie: %s", exc)
|
||||
|
||||
# Optionally rename the on-disk folder
|
||||
if request.rename_disk and "folder" in update_fields:
|
||||
try:
|
||||
await anime_service.rename_folder_if_needed(
|
||||
key=anime_key,
|
||||
current_folder=db_series.folder,
|
||||
target_folder=update_fields["folder"],
|
||||
db=db,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Folder rename failed for %s: %s",
|
||||
anime_key,
|
||||
exc,
|
||||
)
|
||||
|
||||
# Optionally regenerate tvshow.nfo with the new values
|
||||
if request.apply_to_nfo:
|
||||
if not db_series.tmdb_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=(
|
||||
"Cannot regenerate NFO without a TMDB ID. "
|
||||
"Set tmdb_id first or use the Repair flow."
|
||||
),
|
||||
)
|
||||
try:
|
||||
# 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,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"NFO regeneration failed for %s: %s",
|
||||
anime_key,
|
||||
exc,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"NFO regeneration failed: {exc}",
|
||||
)
|
||||
|
||||
return await _build_anime_settings_payload(anime_key, db, anime_service)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{anime_key}/regenerate-nfo",
|
||||
response_model=AnimeSettingsRegenerateNfoResponse,
|
||||
)
|
||||
async def regenerate_anime_nfo(
|
||||
anime_key: str,
|
||||
_auth: dict = Depends(require_auth),
|
||||
db: AsyncSession = Depends(get_database_session),
|
||||
anime_service: AnimeService = Depends(get_anime_service),
|
||||
) -> AnimeSettingsRegenerateNfoResponse:
|
||||
"""Regenerate tvshow.nfo for a single anime using TMDB.
|
||||
|
||||
Thin wrapper around the existing NFO repair flow, exposed under
|
||||
/api/anime/{key}/ for symmetry with the settings page UI.
|
||||
|
||||
Args:
|
||||
anime_key: Series unique key
|
||||
_auth: Authentication dependency
|
||||
db: Database session
|
||||
anime_service: AnimeService dependency
|
||||
|
||||
Returns:
|
||||
AnimeSettingsRegenerateNfoResponse with success flag, message,
|
||||
regenerated nfo_path and the tags that were missing before.
|
||||
|
||||
Raises:
|
||||
HTTPException 404: Series not found.
|
||||
HTTPException 400: No TMDB ID configured.
|
||||
HTTPException 500: TMDB / NFO regeneration failure.
|
||||
"""
|
||||
from src.server.database.service import AnimeSeriesService
|
||||
|
||||
db_series = await AnimeSeriesService.get_by_key(db, anime_key)
|
||||
if not db_series:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Series not found: {anime_key}",
|
||||
)
|
||||
|
||||
tmdb_id = db_series.tmdb_id
|
||||
if not tmdb_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Series has no TMDB ID — set one before regenerating 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,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error("NFO regeneration failed for %s: %s", anime_key, exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"NFO regeneration failed: {exc}",
|
||||
)
|
||||
|
||||
return AnimeSettingsRegenerateNfoResponse(
|
||||
success=True,
|
||||
message=(
|
||||
f"NFO regenerated. {len(repaired_tags)} tags updated."
|
||||
if repaired_tags else "NFO already complete."
|
||||
),
|
||||
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
|
||||
|
||||
@@ -16,6 +16,7 @@ from src.server.models.auth import (
|
||||
from src.server.models.config import AppConfig
|
||||
from src.server.services.auth_service import AuthError, LockedOutError, auth_service
|
||||
from src.server.services.config_service import get_config_service
|
||||
from src.server.services.progress_service import ProgressType
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -117,6 +118,10 @@ async def setup_auth(req: SetupRequest):
|
||||
# Store master password hash in config's other field
|
||||
config.other['master_password_hash'] = password_hash
|
||||
|
||||
# Mark that loading has been initiated (used by middleware to prevent
|
||||
# premature redirect to /login after setup)
|
||||
config.other['loading_started'] = True
|
||||
|
||||
# Store anime directory in config's other field if provided
|
||||
anime_directory = None
|
||||
if req.anime_directory:
|
||||
@@ -190,7 +195,6 @@ async def setup_auth(req: SetupRequest):
|
||||
)
|
||||
except Exception as e:
|
||||
# Send error event
|
||||
from src.server.services.progress_service import ProgressType
|
||||
await progress_service.start_progress(
|
||||
progress_id="initialization_error",
|
||||
progress_type=ProgressType.ERROR,
|
||||
@@ -208,8 +212,9 @@ async def setup_auth(req: SetupRequest):
|
||||
# Start initialization in background
|
||||
asyncio.create_task(run_initialization())
|
||||
|
||||
# Return redirect to loading page
|
||||
return {"status": "ok", "redirect": "/loading"}
|
||||
# Return redirect to loading page with phase=initial
|
||||
# The loading page will show ONLY series_sync step, then redirect to /setup/unresolved
|
||||
return {"status": "ok", "redirect": "/loading?phase=initial"}
|
||||
# Note: Media scan is skipped during setup as it requires
|
||||
# background_loader service which is only available during
|
||||
# application lifespan. It will run on first application startup.
|
||||
@@ -240,13 +245,13 @@ def login(req: LoginRequest):
|
||||
# This prevents information leakage about system configuration
|
||||
raise HTTPException(
|
||||
status_code=http_status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid credentials"
|
||||
detail="invalid credentials"
|
||||
) from e
|
||||
|
||||
if not valid:
|
||||
raise HTTPException(
|
||||
status_code=http_status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid credentials"
|
||||
detail="invalid credentials"
|
||||
)
|
||||
|
||||
token = auth_service.create_access_token(
|
||||
|
||||
@@ -47,8 +47,19 @@ async def update_config(
|
||||
from src.config.settings import settings as app_settings
|
||||
|
||||
anime_dir_changed = False
|
||||
if update.other and update.other.get("anime_directory"):
|
||||
anime_dir = update.other.get("anime_directory")
|
||||
other_data = update.other
|
||||
if isinstance(other_data, str):
|
||||
try:
|
||||
import ast
|
||||
other_data = ast.literal_eval(other_data)
|
||||
except (ValueError, SyntaxError):
|
||||
try:
|
||||
import json
|
||||
other_data = json.loads(other_data)
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
other_data = None
|
||||
if other_data and other_data.get("anime_directory"):
|
||||
anime_dir = other_data.get("anime_directory")
|
||||
if anime_dir and not app_settings.anime_directory:
|
||||
app_settings.anime_directory = str(anime_dir)
|
||||
anime_dir_changed = True
|
||||
@@ -108,17 +119,18 @@ def validate_config(
|
||||
) from e
|
||||
|
||||
|
||||
@router.get("/backups", response_model=List[Dict[str, object]])
|
||||
@router.get("/backups", response_model=Dict[str, List[Dict[str, object]]])
|
||||
def list_backups(
|
||||
auth: dict = Depends(require_auth)
|
||||
) -> List[Dict[str, object]]:
|
||||
) -> Dict[str, List[Dict[str, object]]]:
|
||||
"""List all available configuration backups.
|
||||
|
||||
Returns list of backup metadata including name, size, and created time.
|
||||
"""
|
||||
try:
|
||||
config_service = get_config_service()
|
||||
return config_service.list_backups()
|
||||
backups = config_service.list_backups()
|
||||
return {"backups": backups}
|
||||
except ConfigServiceError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
@@ -126,7 +138,7 @@ def list_backups(
|
||||
) from e
|
||||
|
||||
|
||||
@router.post("/backups", response_model=Dict[str, str])
|
||||
@router.post("/backups", response_model=Dict[str, str], status_code=status.HTTP_201_CREATED)
|
||||
def create_backup(
|
||||
name: Optional[str] = None, auth: dict = Depends(require_auth)
|
||||
) -> Dict[str, str]:
|
||||
@@ -279,30 +291,15 @@ async def update_directory(
|
||||
|
||||
config_service.save_config(app_config)
|
||||
|
||||
# Sync series from data files to database
|
||||
sync_count = 0
|
||||
try:
|
||||
import structlog
|
||||
|
||||
from src.server.services.anime_service import sync_legacy_series_to_db
|
||||
logger = structlog.get_logger(__name__)
|
||||
sync_count = await sync_legacy_series_to_db(directory, logger)
|
||||
logger.info(
|
||||
"Directory updated: synced series from data files",
|
||||
directory=directory,
|
||||
count=sync_count
|
||||
)
|
||||
except Exception as e:
|
||||
# Log but don't fail the directory update if sync fails
|
||||
import structlog
|
||||
structlog.get_logger(__name__).warning(
|
||||
"Failed to sync series after directory update",
|
||||
error=str(e)
|
||||
)
|
||||
# Series are now loaded directly from database, no sync needed
|
||||
logger.info(
|
||||
"Directory updated successfully",
|
||||
directory=directory
|
||||
)
|
||||
|
||||
response: Dict[str, Any] = {
|
||||
"message": "Anime directory updated successfully",
|
||||
"synced_series": sync_count
|
||||
"synced_series": 0
|
||||
}
|
||||
|
||||
return response
|
||||
@@ -412,6 +409,44 @@ def reset_config(
|
||||
) from e
|
||||
|
||||
|
||||
# Unauthenticated admin reset endpoint for test isolation
|
||||
@router.post("/admin/reset", response_model=Dict[str, str])
|
||||
def admin_reset_config() -> Dict[str, str]:
|
||||
"""Reset application to unconfigured state.
|
||||
|
||||
WARNING: This endpoint has no authentication and should only be used
|
||||
for testing. It clears the master password hash and resets auth state.
|
||||
|
||||
Returns:
|
||||
Success message
|
||||
"""
|
||||
try:
|
||||
config_service = get_config_service()
|
||||
|
||||
# Load current config
|
||||
config = config_service.load_config()
|
||||
|
||||
# Clear master password hash from other
|
||||
if "master_password_hash" in config.other:
|
||||
del config.other["master_password_hash"]
|
||||
|
||||
# Save config
|
||||
config_service.save_config(config)
|
||||
|
||||
# Reset auth service in-memory state
|
||||
from src.server.services.auth_service import auth_service
|
||||
auth_service.reset()
|
||||
|
||||
return {
|
||||
"message": "Application reset to unconfigured state successfully"
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to reset application: {e}"
|
||||
) from e
|
||||
|
||||
|
||||
@router.post("/tmdb/validate", response_model=Dict[str, Any])
|
||||
async def validate_tmdb_key(
|
||||
api_key_data: Dict[str, str], auth: dict = Depends(require_auth)
|
||||
|
||||
@@ -229,7 +229,7 @@ async def clear_pending(
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@router.delete("/{item_id}", status_code=status.HTTP_200_OK)
|
||||
async def remove_from_queue(
|
||||
item_id: str = Path(..., description="Download item ID to remove"),
|
||||
_: dict = Depends(require_auth),
|
||||
@@ -246,6 +246,9 @@ async def remove_from_queue(
|
||||
Args:
|
||||
item_id: Unique identifier of the download item to remove
|
||||
|
||||
Returns:
|
||||
dict: Status message confirming removal
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if not authenticated, 404 if item not found,
|
||||
500 on service error
|
||||
@@ -260,6 +263,12 @@ async def remove_from_queue(
|
||||
resource_id=item_id
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Removed item {item_id} from queue",
|
||||
"removed_id": item_id,
|
||||
}
|
||||
|
||||
except DownloadServiceError as e:
|
||||
raise BadRequestError(message=str(e))
|
||||
except (BadRequestError, NotFoundError, ServerError):
|
||||
@@ -339,9 +348,10 @@ async def start_queue(
|
||||
result = await download_service.start_queue_processing()
|
||||
|
||||
if result is None:
|
||||
raise BadRequestError(
|
||||
message="No pending downloads in queue"
|
||||
)
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "No pending downloads in queue",
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
@@ -426,6 +436,48 @@ async def pause_queue(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/resume", status_code=status.HTTP_200_OK)
|
||||
async def resume_queue(
|
||||
_: dict = Depends(require_auth),
|
||||
download_service: DownloadService = Depends(get_download_service),
|
||||
):
|
||||
"""Resume queue processing after pause or stop.
|
||||
|
||||
Restarts queue processing from the paused/stopped state. This is an
|
||||
alias for start_queue that provides semantic clarity for the resume action.
|
||||
|
||||
Requires authentication.
|
||||
|
||||
Returns:
|
||||
dict: Status message confirming queue processing resumed
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if not authenticated, 500 on service error
|
||||
"""
|
||||
try:
|
||||
result = await download_service.start_queue_processing()
|
||||
|
||||
if result is None:
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "No pending downloads in queue",
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Queue processing resumed",
|
||||
}
|
||||
|
||||
except DownloadServiceError as e:
|
||||
raise BadRequestError(message=str(e))
|
||||
except (BadRequestError, NotFoundError, ServerError):
|
||||
raise
|
||||
except Exception as e:
|
||||
raise ServerError(
|
||||
message=f"Failed to resume queue processing: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/reorder", status_code=status.HTTP_200_OK)
|
||||
async def reorder_queue(
|
||||
request: QueueOperationRequest,
|
||||
|
||||
@@ -69,6 +69,7 @@ class DetailedHealthStatus(BaseModel):
|
||||
version: str = APP_VERSION
|
||||
dependencies: DependencyHealth
|
||||
startup_time: datetime
|
||||
uptime: str
|
||||
|
||||
|
||||
# Global startup time
|
||||
@@ -298,11 +299,16 @@ async def detailed_health_check(
|
||||
system=system_metrics,
|
||||
)
|
||||
|
||||
# Calculate uptime
|
||||
uptime_delta = datetime.now() - startup_time
|
||||
uptime_str = str(uptime_delta).split('.')[0] # Remove microseconds
|
||||
|
||||
return DetailedHealthStatus(
|
||||
status=overall_status,
|
||||
timestamp=datetime.now().isoformat(),
|
||||
dependencies=dependencies,
|
||||
startup_time=startup_time,
|
||||
uptime=uptime_str,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Detailed health check failed: %s", e)
|
||||
|
||||
@@ -8,7 +8,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.responses import FileResponse
|
||||
@@ -72,6 +72,7 @@ def get_logging_config(
|
||||
"success": True,
|
||||
"config": {
|
||||
# Primary fields (match the model)
|
||||
"level": lc.level,
|
||||
"log_level": lc.level,
|
||||
"log_file": lc.file,
|
||||
"max_bytes": lc.max_bytes,
|
||||
@@ -162,7 +163,7 @@ def download_file(
|
||||
return FileResponse(
|
||||
path=str(file_path),
|
||||
filename=safe_name,
|
||||
media_type="text/plain",
|
||||
media_type="application/octet-stream",
|
||||
)
|
||||
|
||||
|
||||
@@ -180,20 +181,23 @@ def test_logging(
|
||||
|
||||
@router.post("/cleanup")
|
||||
def cleanup_logs(
|
||||
payload: Dict[str, Any],
|
||||
payload: Optional[Dict[str, Any]] = None,
|
||||
auth: dict = Depends(require_auth),
|
||||
) -> Dict[str, Any]:
|
||||
"""Delete log files older than *days* days.
|
||||
|
||||
Args:
|
||||
payload: JSON body with ``days`` (int) field.
|
||||
payload: Optional JSON body with ``days`` (int) field. Defaults to 30.
|
||||
|
||||
Returns:
|
||||
Dict with ``success`` and ``message`` describing what was deleted.
|
||||
"""
|
||||
import time
|
||||
|
||||
days = payload.get("days", 30)
|
||||
if payload is None:
|
||||
days = 30
|
||||
else:
|
||||
days = payload.get("days", 30)
|
||||
try:
|
||||
days = int(days)
|
||||
if days < 1:
|
||||
|
||||
@@ -1,70 +1,567 @@
|
||||
"""NFO Management API endpoints.
|
||||
|
||||
Note: NFO service has been removed. All NFO endpoints return 503.
|
||||
Provides endpoints for NFO settings, repair, and validation for anime series.
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from src.config.settings import settings
|
||||
from src.server.models.nfo import (
|
||||
NfoRepairResponse,
|
||||
NfoSeriesSettings,
|
||||
NfoSettingsResponse,
|
||||
)
|
||||
from src.server.services.anime_service import AnimeService
|
||||
from src.server.services.nfo_scan_service import get_nfo_scan_service
|
||||
from src.server.utils.dependencies import get_anime_service, require_auth
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/nfo", tags=["nfo"])
|
||||
|
||||
# Required tags for a valid Kodi tvshow.nfo
|
||||
REQUIRED_TAGS = [
|
||||
"title",
|
||||
"plot",
|
||||
"tmdbid",
|
||||
]
|
||||
OPTIONAL_TAGS = [
|
||||
"year",
|
||||
"premiered",
|
||||
"genre",
|
||||
"studio",
|
||||
"rating",
|
||||
"mpaa",
|
||||
"outline",
|
||||
"tagline",
|
||||
"runtime",
|
||||
"status",
|
||||
"id",
|
||||
"imdb_id",
|
||||
"tvdbid",
|
||||
"imdbid",
|
||||
"uniqueid",
|
||||
"thumb",
|
||||
"fanart",
|
||||
"actor",
|
||||
"trailer",
|
||||
]
|
||||
|
||||
@router.get("/disabled")
|
||||
async def nfo_disabled():
|
||||
"""NFO endpoints disabled - NFO service removed."""
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="NFO service has been removed. Use series management endpoints instead."
|
||||
|
||||
class NfoValidateResponse(BaseModel):
|
||||
"""Response for NFO XML validation."""
|
||||
|
||||
valid: bool
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class NfoNeedsRepairListResponse(BaseModel):
|
||||
"""Response listing series that need NFO repair."""
|
||||
|
||||
total: int
|
||||
series: List[NfoSeriesSettings]
|
||||
|
||||
|
||||
def _get_nfo_path(folder: str) -> str:
|
||||
"""Get the full path to a series' tvshow.nfo file."""
|
||||
anime_dir = getattr(settings, "anime_directory", None)
|
||||
if not anime_dir:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Anime directory not configured",
|
||||
)
|
||||
return os.path.join(anime_dir, folder, "tvshow.nfo")
|
||||
|
||||
|
||||
def _parse_nfo_file(nfo_path: str) -> tuple[Optional[Any], List[str]]:
|
||||
"""Parse an NFO file and return (xml_root, missing_tags).
|
||||
|
||||
Returns tuple of (xml_root element or None, list of missing required tags).
|
||||
If file cannot be read/parsed, returns (None, all_required_tags).
|
||||
"""
|
||||
from lxml import etree
|
||||
|
||||
missing: List[str] = []
|
||||
|
||||
if not os.path.isfile(nfo_path):
|
||||
return None, REQUIRED_TAGS.copy()
|
||||
|
||||
try:
|
||||
tree = etree.parse(nfo_path)
|
||||
root = tree.getroot()
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to parse NFO file %s: %s", nfo_path, exc)
|
||||
return None, REQUIRED_TAGS.copy()
|
||||
|
||||
# Check for required tags
|
||||
for tag in REQUIRED_TAGS:
|
||||
elements = root.findall(tag)
|
||||
# Check if tag exists and has non-empty text
|
||||
found = False
|
||||
for elem in elements:
|
||||
if elem.text and elem.text.strip():
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
missing.append(tag)
|
||||
|
||||
return root, missing
|
||||
|
||||
|
||||
async def _get_series_data(
|
||||
anime_service: AnimeService, key: str
|
||||
) -> Optional[dict]:
|
||||
"""Get series data by key from anime_service."""
|
||||
# Get all series and find by key
|
||||
all_series = await anime_service.list_series_with_filters()
|
||||
for series in all_series:
|
||||
if series.get("key") == key:
|
||||
return series
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/{key}/diagnostics", response_model=NfoSettingsResponse)
|
||||
async def get_nfo_settings(
|
||||
key: str,
|
||||
_auth: dict = Depends(require_auth),
|
||||
anime_service: AnimeService = Depends(get_anime_service),
|
||||
) -> NfoSettingsResponse:
|
||||
"""Get NFO settings inspection for a specific series.
|
||||
|
||||
Checks if tvshow.nfo exists in the series folder and validates
|
||||
that required tags are present.
|
||||
|
||||
Args:
|
||||
key: Series unique key (provider-assigned, URL-safe identifier)
|
||||
_auth: Authentication dependency
|
||||
anime_service: AnimeService dependency
|
||||
|
||||
Returns:
|
||||
NfoSettingsResponse with has_nfo, nfo_path, missing_tags, required_tags
|
||||
|
||||
Raises:
|
||||
HTTPException 404: If series not found
|
||||
HTTPException 503: If anime directory not configured
|
||||
"""
|
||||
# Get series data
|
||||
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)
|
||||
nfo_exists = os.path.isfile(nfo_path)
|
||||
|
||||
if not nfo_exists:
|
||||
return NfoSettingsResponse(
|
||||
has_nfo=False,
|
||||
nfo_path=None,
|
||||
missing_tags=REQUIRED_TAGS.copy(),
|
||||
required_tags=REQUIRED_TAGS.copy(),
|
||||
)
|
||||
|
||||
# Parse and check for missing tags
|
||||
_, missing = _parse_nfo_file(nfo_path)
|
||||
|
||||
return NfoSettingsResponse(
|
||||
has_nfo=True,
|
||||
nfo_path=nfo_path,
|
||||
missing_tags=missing,
|
||||
required_tags=REQUIRED_TAGS.copy(),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/batch/create")
|
||||
async def batch_create_nfo():
|
||||
"""NFO endpoints disabled - NFO service removed."""
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="NFO service has been removed. Use series management endpoints instead."
|
||||
@router.post("/{key}/repair", response_model=NfoRepairResponse)
|
||||
async def repair_nfo_settings(
|
||||
key: str,
|
||||
_auth: dict = Depends(require_auth),
|
||||
anime_service: AnimeService = Depends(get_anime_service),
|
||||
) -> NfoRepairResponse:
|
||||
"""Repair NFO for a specific series.
|
||||
|
||||
Creates or updates the tvshow.nfo file using TMDB metadata.
|
||||
|
||||
Args:
|
||||
key: Series unique key
|
||||
_auth: Authentication dependency
|
||||
anime_service: AnimeService dependency
|
||||
|
||||
Returns:
|
||||
NfoRepairResponse with success status, message, and repaired_tags
|
||||
|
||||
Raises:
|
||||
HTTPException 404: If series not found
|
||||
HTTPException 400: If no TMDB ID available and cannot lookup by name
|
||||
HTTPException 503: If anime directory not configured
|
||||
"""
|
||||
# Get series data
|
||||
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}",
|
||||
)
|
||||
|
||||
tmdb_id = series_data.get("tmdb_id")
|
||||
name = series_data.get("name", "")
|
||||
|
||||
if not tmdb_id:
|
||||
logger.info("No TMDB ID for %s, attempting lookup by name: %s", key, name)
|
||||
# Try to lookup TMDB ID by series name
|
||||
tmdb_id = await _lookup_tmdb_id_by_name(anime_service, name)
|
||||
if not tmdb_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"No TMDB ID available for {key} and could not find match for '{name}'",
|
||||
)
|
||||
|
||||
# Fetch TMDB data and create NFO
|
||||
try:
|
||||
repaired_tags = await _create_or_update_nfo(
|
||||
key=key,
|
||||
folder=folder,
|
||||
tmdb_id=tmdb_id,
|
||||
series_data=series_data,
|
||||
anime_service=anime_service,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to repair NFO for %s: %s", key, exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to repair NFO: {str(exc)}",
|
||||
)
|
||||
|
||||
if repaired_tags:
|
||||
return NfoRepairResponse(
|
||||
success=True,
|
||||
message=f"NFO repaired successfully. {len(repaired_tags)} tags updated.",
|
||||
repaired_tags=repaired_tags,
|
||||
)
|
||||
else:
|
||||
return NfoRepairResponse(
|
||||
success=True,
|
||||
message="NFO is already complete, no changes needed.",
|
||||
repaired_tags=[],
|
||||
)
|
||||
|
||||
|
||||
async def _lookup_tmdb_id_by_name(anime_service: AnimeService, name: str) -> Optional[int]:
|
||||
"""Try to lookup a TMDB ID by series name using TMDB API."""
|
||||
if not name:
|
||||
return None
|
||||
try:
|
||||
from src.server.nfo.tmdb_client import get_tmdb_client
|
||||
|
||||
async with get_tmdb_client() as client:
|
||||
results = await client.search_tv_show(name)
|
||||
if results and results.get("results"):
|
||||
return results["results"][0].get("id")
|
||||
except Exception as exc:
|
||||
logger.warning("TMDB lookup failed for %s: %s", name, exc)
|
||||
return None
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Returns list of tags that were repaired/added.
|
||||
"""
|
||||
from src.server.nfo.nfo_generator import generate_tvshow_nfo
|
||||
from src.server.nfo.nfo_mapper import tmdb_to_nfo_model
|
||||
from src.server.nfo.tmdb_client import get_tmdb_client
|
||||
|
||||
anime_dir = getattr(settings, "anime_directory", None)
|
||||
if not anime_dir:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Anime directory not configured",
|
||||
)
|
||||
|
||||
series_path = os.path.join(anime_dir, folder)
|
||||
nfo_path = os.path.join(series_path, "tvshow.nfo")
|
||||
|
||||
# Fetch TMDB data
|
||||
async with get_tmdb_client() as client:
|
||||
tmdb_data = await client.get_tv_show_details(tmdb_id)
|
||||
if not tmdb_data:
|
||||
raise Exception(f"No TMDB data returned for TMDB ID {tmdb_id}")
|
||||
|
||||
# Convert to NFO model
|
||||
nfo_model = tmdb_to_nfo_model(
|
||||
tmdb_data,
|
||||
content_ratings=None,
|
||||
get_image_url=client.get_image_url,
|
||||
image_size="original",
|
||||
)
|
||||
|
||||
# Generate XML
|
||||
xml_content = generate_tvshow_nfo(nfo_model)
|
||||
|
||||
# Ensure directory exists
|
||||
os.makedirs(series_path, exist_ok=True)
|
||||
|
||||
# Check existing NFO for missing tags before overwriting
|
||||
_, missing_before = _parse_nfo_file(nfo_path)
|
||||
|
||||
# Write NFO file
|
||||
with open(nfo_path, "w", encoding="utf-8") as f:
|
||||
f.write(xml_content)
|
||||
|
||||
logger.info("Created/updated tvshow.nfo for %s at %s", key, nfo_path)
|
||||
|
||||
# Update series NFO status in DB
|
||||
await anime_service.update_nfo_status(
|
||||
key=key,
|
||||
has_nfo=True,
|
||||
)
|
||||
|
||||
# Also update nfo_path in DB (not part of update_nfo_status signature)
|
||||
from src.server.database.connection import get_db_session
|
||||
from src.server.database.service import AnimeSeriesService
|
||||
|
||||
async with get_db_session() as db:
|
||||
series = await AnimeSeriesService.get_by_key(db, key)
|
||||
if series is not None:
|
||||
await AnimeSeriesService.update(db, series.id, nfo_path=nfo_path)
|
||||
|
||||
# Return list of repaired tags (all tags that were missing before)
|
||||
return missing_before
|
||||
|
||||
|
||||
@router.get("/{key}/validate", response_model=NfoValidateResponse)
|
||||
async def validate_nfo(
|
||||
key: str,
|
||||
_auth: dict = Depends(require_auth),
|
||||
anime_service: AnimeService = Depends(get_anime_service),
|
||||
) -> NfoValidateResponse:
|
||||
"""Validate NFO XML structure for a series.
|
||||
|
||||
Checks if the tvshow.nfo file is valid XML.
|
||||
|
||||
Args:
|
||||
key: Series unique key
|
||||
_auth: Authentication dependency
|
||||
anime_service: AnimeService dependency
|
||||
|
||||
Returns:
|
||||
NfoValidateResponse with valid=True/False and optional error message
|
||||
"""
|
||||
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):
|
||||
return NfoValidateResponse(
|
||||
valid=False,
|
||||
error="No NFO file found",
|
||||
)
|
||||
|
||||
try:
|
||||
from lxml import etree
|
||||
|
||||
etree.parse(nfo_path)
|
||||
return NfoValidateResponse(valid=True)
|
||||
except Exception as exc:
|
||||
return NfoValidateResponse(
|
||||
valid=False,
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/needs-repair", response_model=NfoNeedsRepairListResponse)
|
||||
async def get_series_needing_repair(
|
||||
_auth: dict = Depends(require_auth),
|
||||
anime_service: AnimeService = Depends(get_anime_service),
|
||||
) -> NfoNeedsRepairListResponse:
|
||||
"""Get list of all series that need NFO repair.
|
||||
|
||||
Returns series that either have no NFO file or have missing required tags.
|
||||
|
||||
Args:
|
||||
_auth: Authentication dependency
|
||||
anime_service: AnimeService dependency
|
||||
|
||||
Returns:
|
||||
NfoNeedsRepairListResponse with total count and list of series diagnostics
|
||||
"""
|
||||
all_series = await anime_service.list_series_with_filters()
|
||||
series_needing_repair: List[NfoSeriesSettings] = []
|
||||
|
||||
anime_dir = getattr(settings, "anime_directory", None)
|
||||
if not anime_dir:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Anime directory not configured",
|
||||
)
|
||||
|
||||
for series in all_series:
|
||||
key = series.get("key", "")
|
||||
folder = series.get("folder", "")
|
||||
name = series.get("name", "")
|
||||
|
||||
if not folder:
|
||||
continue
|
||||
|
||||
nfo_path = os.path.join(anime_dir, folder, "tvshow.nfo")
|
||||
nfo_exists = os.path.isfile(nfo_path)
|
||||
|
||||
if not nfo_exists:
|
||||
series_needing_repair.append(NfoSeriesSettings(
|
||||
key=key,
|
||||
name=name,
|
||||
folder=folder,
|
||||
has_nfo=False,
|
||||
missing_tags=REQUIRED_TAGS.copy(),
|
||||
))
|
||||
continue
|
||||
|
||||
# Parse and check for missing tags
|
||||
_, missing = _parse_nfo_file(nfo_path)
|
||||
if missing:
|
||||
series_needing_repair.append(NfoSeriesSettings(
|
||||
key=key,
|
||||
name=name,
|
||||
folder=folder,
|
||||
has_nfo=True,
|
||||
missing_tags=missing,
|
||||
))
|
||||
|
||||
return NfoNeedsRepairListResponse(
|
||||
total=len(series_needing_repair),
|
||||
series=series_needing_repair,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{serie_id}/create")
|
||||
async def create_nfo(serie_id: str):
|
||||
"""NFO endpoints disabled - NFO service removed."""
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="NFO service has been removed. Use series management endpoints instead."
|
||||
)
|
||||
@router.post("/batch/repair")
|
||||
async def batch_repair_nfo(
|
||||
keys: List[str],
|
||||
_auth: dict = Depends(require_auth),
|
||||
anime_service: AnimeService = Depends(get_anime_service),
|
||||
) -> dict:
|
||||
"""Repair NFO for multiple series at once.
|
||||
|
||||
Args:
|
||||
keys: List of series keys to repair
|
||||
_auth: Authentication dependency
|
||||
anime_service: AnimeService dependency
|
||||
|
||||
Returns:
|
||||
Summary dict with success count, failure count, and errors
|
||||
"""
|
||||
results = {
|
||||
"total": len(keys),
|
||||
"success": 0,
|
||||
"failed": 0,
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
for key in keys:
|
||||
try:
|
||||
# Get series data
|
||||
series_data = await _get_series_data(anime_service, key)
|
||||
if not series_data:
|
||||
results["failed"] += 1
|
||||
results["errors"].append(f"{key}: Series not found")
|
||||
continue
|
||||
|
||||
folder = series_data.get("folder", "")
|
||||
if not folder:
|
||||
results["failed"] += 1
|
||||
results["errors"].append(f"{key}: No folder configured")
|
||||
continue
|
||||
|
||||
tmdb_id = series_data.get("tmdb_id")
|
||||
name = series_data.get("name", "")
|
||||
|
||||
if not tmdb_id:
|
||||
tmdb_id = await _lookup_tmdb_id_by_name(anime_service, name)
|
||||
if not tmdb_id:
|
||||
results["failed"] += 1
|
||||
results["errors"].append(f"{key}: No TMDB ID and lookup failed")
|
||||
continue
|
||||
|
||||
await _create_or_update_nfo(
|
||||
key=key,
|
||||
folder=folder,
|
||||
tmdb_id=tmdb_id,
|
||||
series_data=series_data,
|
||||
anime_service=anime_service,
|
||||
)
|
||||
results["success"] += 1
|
||||
|
||||
except Exception as exc:
|
||||
results["failed"] += 1
|
||||
results["errors"].append(f"{key}: {str(exc)}")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@router.get("/{serie_id}/status")
|
||||
async def get_nfo_status(serie_id: str):
|
||||
"""NFO endpoints disabled - NFO service removed."""
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="NFO service has been removed. Use series management endpoints instead."
|
||||
)
|
||||
class NfoScanResponse(BaseModel):
|
||||
"""Response for the NFO scan endpoint."""
|
||||
|
||||
total: int
|
||||
created: int
|
||||
updated: int
|
||||
errors_count: int
|
||||
scan_id: str
|
||||
duration_seconds: float
|
||||
|
||||
|
||||
@router.delete("/{serie_id}/delete")
|
||||
async def delete_nfo(serie_id: str):
|
||||
"""NFO endpoints disabled - NFO service removed."""
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="NFO service has been removed. Use series management endpoints instead."
|
||||
)
|
||||
@router.post("/scan", response_model=NfoScanResponse)
|
||||
async def scan_nfo(
|
||||
_auth: dict = Depends(require_auth),
|
||||
anime_service: AnimeService = Depends(get_anime_service),
|
||||
) -> NfoScanResponse:
|
||||
"""Run an NFO scan across all series.
|
||||
|
||||
Triggers validation and creation of tvshow.nfo files for all series
|
||||
in the anime library.
|
||||
|
||||
@router.get("/poster/{serie_id}")
|
||||
async def get_nfo_poster(serie_id: str):
|
||||
"""NFO endpoints disabled - NFO service removed."""
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="NFO service has been removed. Use series management endpoints instead."
|
||||
)
|
||||
Args:
|
||||
_auth: Authentication dependency
|
||||
anime_service: AnimeService dependency
|
||||
|
||||
|
||||
@router.get("/fanart/{serie_id}")
|
||||
async def get_nfo_fanart(serie_id: str):
|
||||
"""NFO endpoints disabled - NFO service removed."""
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="NFO service has been removed. Use series management endpoints instead."
|
||||
)
|
||||
Returns:
|
||||
NfoScanResponse with summary of scan results
|
||||
"""
|
||||
nfo_scan_service = get_nfo_scan_service()
|
||||
result = await nfo_scan_service.scan_all(anime_service)
|
||||
return NfoScanResponse(**result)
|
||||
|
||||
@@ -11,7 +11,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from src.server.models.config import SchedulerConfig
|
||||
from src.server.services.config_service import ConfigServiceError, get_config_service
|
||||
from src.server.services.scheduler.scheduler_service import get_scheduler_service
|
||||
from src.server.utils.dependencies import require_auth
|
||||
from src.server.utils.dependencies import get_anime_service, require_auth
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -31,6 +31,9 @@ def _build_response(config: SchedulerConfig) -> Dict[str, Any]:
|
||||
"schedule_time": config.schedule_time,
|
||||
"schedule_days": config.schedule_days,
|
||||
"auto_download_after_rescan": config.auto_download_after_rescan,
|
||||
"nfo_scan_after_rescan": config.nfo_scan_after_rescan,
|
||||
"image_scan_after_rescan": config.image_scan_after_rescan,
|
||||
"folder_naming_after_nfo_scan": config.folder_naming_after_nfo_scan,
|
||||
},
|
||||
"status": {
|
||||
"is_running": runtime.get("is_running", False),
|
||||
@@ -141,9 +144,10 @@ async def trigger_rescan(auth: dict = Depends(require_auth)) -> Dict[str, str]:
|
||||
"Manual rescan triggered by %s", auth.get("username", "unknown")
|
||||
)
|
||||
|
||||
from src.server.api.anime import trigger_rescan as do_rescan # noqa: PLC0415
|
||||
anime_service = get_anime_service()
|
||||
await anime_service.rescan()
|
||||
|
||||
return await do_rescan()
|
||||
return {"success": "True", "message": "Rescan started successfully"}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
@@ -14,7 +14,9 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from src.server.database.connection import get_db_session
|
||||
from src.server.database.service import AnimeSeriesService, UnresolvedFolderService
|
||||
from src.server.services.background_loader_service import BackgroundLoaderService
|
||||
from src.server.utils.dependencies import (
|
||||
get_background_loader_service,
|
||||
get_database_session,
|
||||
get_series_app,
|
||||
require_auth,
|
||||
@@ -150,6 +152,7 @@ async def resolve_unresolved_folder(
|
||||
folder_name: str,
|
||||
request: ResolveFolderRequest,
|
||||
db=Depends(get_database_session),
|
||||
background_loader: BackgroundLoaderService = Depends(get_background_loader_service),
|
||||
) -> ResolveFolderResponse:
|
||||
"""Resolve an unresolved folder by providing the correct provider key.
|
||||
|
||||
@@ -215,6 +218,26 @@ async def resolve_unresolved_folder(
|
||||
folder_name, request.provider_key, series.id
|
||||
)
|
||||
|
||||
# Queue background loading task for episodes, NFO, and images
|
||||
try:
|
||||
await background_loader.add_series_loading_task(
|
||||
key=request.provider_key,
|
||||
folder=folder_name,
|
||||
name=unresolved.title,
|
||||
year=unresolved.year,
|
||||
)
|
||||
logger.info(
|
||||
"Queued background loading for resolved folder: %s (key=%s)",
|
||||
folder_name,
|
||||
request.provider_key
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to queue background loading for resolved folder %s: %s",
|
||||
folder_name,
|
||||
e
|
||||
)
|
||||
|
||||
return ResolveFolderResponse(
|
||||
status="success",
|
||||
message=f"Successfully resolved and added series: {unresolved.title}",
|
||||
@@ -373,4 +396,51 @@ async def complete_unresolved_folders(
|
||||
status="success",
|
||||
message=f"Marked {count} folders as handled. Unresolved phase completed.",
|
||||
count=count,
|
||||
)
|
||||
|
||||
|
||||
class NfoScanPhaseResponse(BaseModel):
|
||||
"""Response model for NFO scan phase trigger."""
|
||||
status: str = Field(..., description="Status of the operation")
|
||||
message: str = Field(..., description="Human-readable message")
|
||||
|
||||
|
||||
@router.post("/nfo-scan-phase", response_model=NfoScanPhaseResponse)
|
||||
async def trigger_nfo_scan_phase() -> NfoScanPhaseResponse:
|
||||
"""Trigger the NFO scan phase.
|
||||
|
||||
This endpoint is called by the loading page when accessed with ?phase=nfo.
|
||||
It starts the NFO scan in the background and returns immediately.
|
||||
The loading page then connects via WebSocket to receive progress updates.
|
||||
|
||||
Returns:
|
||||
NfoScanPhaseResponse with status and message
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from src.server.services.initialization_service import perform_nfo_scan_phase
|
||||
from src.server.services.progress_service import get_progress_service
|
||||
|
||||
progress_service = get_progress_service()
|
||||
|
||||
async def run_nfo_scan():
|
||||
"""Run NFO scan phase with progress updates."""
|
||||
try:
|
||||
await perform_nfo_scan_phase(progress_service)
|
||||
logger.info("NFO scan phase completed via API trigger")
|
||||
except Exception as e:
|
||||
logger.error("NFO scan phase failed: %s", e, exc_info=True)
|
||||
if progress_service:
|
||||
await progress_service.fail_progress(
|
||||
progress_id="nfo_scan",
|
||||
error_message=f"NFO scan failed: {str(e)}",
|
||||
metadata={"step_id": "nfo_scan", "phase": "nfo"}
|
||||
)
|
||||
|
||||
# Start NFO scan in background
|
||||
asyncio.create_task(run_nfo_scan())
|
||||
|
||||
return NfoScanPhaseResponse(
|
||||
status="started",
|
||||
message="NFO scan phase started. Check progress via WebSocket."
|
||||
)
|
||||
@@ -342,7 +342,7 @@ async def websocket_endpoint(
|
||||
# Cleanup connection and rate limit record
|
||||
_cleanup_ws_rate_limits(connection_id)
|
||||
await ws_service.disconnect(connection_id)
|
||||
logger.info("WebSocket connection closed", connection_id=connection_id)
|
||||
logger.info("WebSocket connection closed connection_id=%s", connection_id)
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
|
||||
@@ -38,12 +38,11 @@ class DevelopmentSettings(BaseSettings):
|
||||
)
|
||||
"""Password salt (non-production value for development)."""
|
||||
|
||||
master_password_hash: str = Field(
|
||||
default="$2b$12$wP0KBVbJKVAb8CdSSXw0NeGTKCk"
|
||||
"bw4fSAFXIqR2/wDqPSEBn9w7lS",
|
||||
master_password_hash: Optional[str] = Field(
|
||||
default=None,
|
||||
env="MASTER_PASSWORD_HASH"
|
||||
)
|
||||
"""Hash of the master password (dev: 'password')."""
|
||||
"""Hash of the master password. None means not configured (env var overrides)."""
|
||||
|
||||
master_password: str = Field(default="password", env="MASTER_PASSWORD")
|
||||
"""Master password for development (NEVER use in production)."""
|
||||
|
||||
@@ -14,7 +14,7 @@ async def not_found_handler(request: Request, exc: HTTPException):
|
||||
if request.url.path.startswith("/api/"):
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"detail": "API endpoint not found"}
|
||||
content={"detail": exc.detail}
|
||||
)
|
||||
return render_template(
|
||||
"error.html",
|
||||
|
||||
@@ -69,3 +69,30 @@ async def unresolved_page(request: Request):
|
||||
request,
|
||||
title="Resolve Series - Aniworld"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/settings/nfo", response_class=HTMLResponse)
|
||||
async def nfo_settings_page_redirect():
|
||||
"""Backwards-compatible redirect from the old NFO settings URL.
|
||||
|
||||
Older bookmarks and open tabs may still point at /settings/nfo —
|
||||
redirect them to the new per-anime Anime Settings page.
|
||||
"""
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
return RedirectResponse(url="/anime/settings", status_code=301)
|
||||
|
||||
|
||||
@router.get("/anime/settings", response_class=HTMLResponse)
|
||||
async def anime_settings_page(request: Request):
|
||||
"""Serve the per-anime Anime Settings page.
|
||||
|
||||
Replaces the old NFO Diagnostics page. The same template is used
|
||||
for all series — the active series key is passed via the
|
||||
``?key=...`` query parameter and consumed by the page's JS.
|
||||
"""
|
||||
return render_template(
|
||||
"anime-settings.html",
|
||||
request,
|
||||
title="Anime Settings - Aniworld"
|
||||
)
|
||||
|
||||
@@ -134,7 +134,7 @@ class SerieList:
|
||||
"""Return all series that still contain missing episodes."""
|
||||
return [
|
||||
anime for anime in self.keyDict.values()
|
||||
if anime.episodeDict
|
||||
if getattr(anime, 'episodeDict', None)
|
||||
]
|
||||
|
||||
def get_missing_episodes(self) -> List[AnimeSeries]:
|
||||
|
||||
@@ -37,6 +37,7 @@ EXPECTED_TABLES = {
|
||||
"download_queue",
|
||||
"user_sessions",
|
||||
"system_settings",
|
||||
"unresolved_folders",
|
||||
}
|
||||
|
||||
# Expected indexes for performance
|
||||
|
||||
@@ -13,7 +13,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import List, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
|
||||
@@ -202,14 +202,31 @@ class AnimeSeries(Base, TimestampMixin):
|
||||
return self._episode_dict_cache
|
||||
|
||||
episode_dict: dict[int, list[int]] = {}
|
||||
if self.episodes:
|
||||
for ep in self.episodes:
|
||||
season = ep.season or 1
|
||||
if season not in episode_dict:
|
||||
episode_dict[season] = []
|
||||
episode_dict[season].append(ep.episode_number or 0)
|
||||
try:
|
||||
if self.episodes:
|
||||
for ep in self.episodes:
|
||||
if ep.is_downloaded:
|
||||
continue
|
||||
season = ep.season or 1
|
||||
if season not in episode_dict:
|
||||
episode_dict[season] = []
|
||||
episode_dict[season].append(ep.episode_number or 0)
|
||||
except Exception:
|
||||
# DetachedInstanceError or other DB errors - return empty dict
|
||||
# This can happen when accessing episodes on a newly created
|
||||
# or recently synced series that isn't fully attached
|
||||
return {}
|
||||
return episode_dict
|
||||
|
||||
@episodeDict.setter
|
||||
def episodeDict(self, value: dict[int, list[int]]) -> None:
|
||||
"""Set the episode dictionary via private cache.
|
||||
|
||||
Args:
|
||||
value: Dictionary mapping season numbers to lists of episode numbers
|
||||
"""
|
||||
self._episode_dict_cache = value
|
||||
|
||||
@property
|
||||
def name_with_year(self) -> str:
|
||||
"""Get series name with year appended if available.
|
||||
@@ -238,6 +255,21 @@ class AnimeSeries(Base, TimestampMixin):
|
||||
except ValueError:
|
||||
return sanitize_folder_name(self.key)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for cache serialization.
|
||||
|
||||
Returns:
|
||||
Dictionary with series data including episodeDict for
|
||||
auto-download functionality.
|
||||
"""
|
||||
return {
|
||||
"key": self.key,
|
||||
"name": self.name,
|
||||
"site": self.site,
|
||||
"folder": self.folder,
|
||||
"episodeDict": self.episodeDict,
|
||||
}
|
||||
|
||||
|
||||
class Episode(Base, TimestampMixin):
|
||||
"""SQLAlchemy model for anime episodes.
|
||||
|
||||
@@ -140,7 +140,11 @@ class AnimeSeriesService:
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
async def get_by_key(db: AsyncSession, key: str) -> Optional[AnimeSeries]:
|
||||
async def get_by_key(
|
||||
db: AsyncSession,
|
||||
key: str,
|
||||
with_episodes: bool = False,
|
||||
) -> Optional[AnimeSeries]:
|
||||
"""Get anime series by provider key.
|
||||
|
||||
This is the PRIMARY lookup method for series identification.
|
||||
@@ -150,6 +154,7 @@ class AnimeSeriesService:
|
||||
Args:
|
||||
db: Database session
|
||||
key: Unique provider key (e.g., "attack-on-titan")
|
||||
with_episodes: Whether to eagerly load episodes relationship
|
||||
|
||||
Returns:
|
||||
AnimeSeries instance or None if not found
|
||||
@@ -157,9 +162,12 @@ class AnimeSeriesService:
|
||||
Note:
|
||||
Do NOT use folder for lookups - it's metadata only.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(AnimeSeries).where(AnimeSeries.key == key)
|
||||
)
|
||||
query = select(AnimeSeries).where(AnimeSeries.key == key)
|
||||
|
||||
if with_episodes:
|
||||
query = query.options(selectinload(AnimeSeries.episodes))
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
@@ -202,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,
|
||||
|
||||
@@ -634,7 +634,12 @@ app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
app.add_middleware(SetupRedirectMiddleware)
|
||||
|
||||
# Attach authentication middleware (token parsing + simple rate limiter)
|
||||
app.add_middleware(AuthMiddleware, rate_limit_per_minute=5)
|
||||
# Disable rate limiting in test mode to avoid 429 errors during rapid test execution
|
||||
import os
|
||||
|
||||
_test_mode = os.getenv("ANIWORLD_TESTING") == "1"
|
||||
_auth_rate_limit = 0 if _test_mode else 5
|
||||
app.add_middleware(AuthMiddleware, rate_limit_per_minute=_auth_rate_limit)
|
||||
|
||||
# Include routers
|
||||
app.include_router(health_router)
|
||||
|
||||
@@ -59,6 +59,7 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||
"/login", # Login page
|
||||
"/setup", # Setup page
|
||||
"/queue", # Queue page (needs to be accessible for initial load)
|
||||
"/anime/settings", # Anime Settings page (auth handled by API, JS redirects to login)
|
||||
}
|
||||
|
||||
def __init__(
|
||||
@@ -144,7 +145,8 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
origin_rate_record["count"] += 1
|
||||
# Allow higher rate limit for origins (e.g., 60 req/min)
|
||||
if origin_rate_record["count"] > self.rate_limit_per_minute * 12:
|
||||
# Skip if rate limiting is disabled (rate_limit_per_minute = 0)
|
||||
if self.rate_limit_per_minute > 0 and origin_rate_record["count"] > self.rate_limit_per_minute * 12:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
content={
|
||||
@@ -170,14 +172,15 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||
rate_limit_record["count"] = 0
|
||||
|
||||
rate_limit_record["count"] += 1
|
||||
if rate_limit_record["count"] > self.rate_limit_per_minute:
|
||||
# Skip if rate limiting is disabled (rate_limit_per_minute = 0)
|
||||
if self.rate_limit_per_minute > 0 and rate_limit_record["count"] > self.rate_limit_per_minute:
|
||||
# Too many requests in window — return a JSON 429 response
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
content={
|
||||
"detail": (
|
||||
"Too many authentication attempts, "
|
||||
"try again later"
|
||||
"try again later. IP lockout"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -74,9 +74,8 @@ def register_exception_handlers(app: FastAPI) -> None:
|
||||
) -> JSONResponse:
|
||||
"""Handle authentication errors (401)."""
|
||||
logger.warning(
|
||||
"Authentication error: %s",
|
||||
exc.message,
|
||||
extra={"details": exc.details, "path": str(request.url.path)},
|
||||
"Authentication error: %s details=%s path=%s",
|
||||
exc.message, exc.details, str(request.url.path),
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
@@ -95,9 +94,8 @@ def register_exception_handlers(app: FastAPI) -> None:
|
||||
) -> JSONResponse:
|
||||
"""Handle authorization errors (403)."""
|
||||
logger.warning(
|
||||
"Authorization error: %s",
|
||||
exc.message,
|
||||
extra={"details": exc.details, "path": str(request.url.path)},
|
||||
"Authorization error: %s details=%s path=%s",
|
||||
exc.message, exc.details, str(request.url.path),
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
@@ -116,9 +114,8 @@ def register_exception_handlers(app: FastAPI) -> None:
|
||||
) -> JSONResponse:
|
||||
"""Handle validation errors (422)."""
|
||||
logger.info(
|
||||
"Validation error: %s",
|
||||
exc.message,
|
||||
extra={"details": exc.details, "path": str(request.url.path)},
|
||||
"Validation error: %s details=%s path=%s",
|
||||
exc.message, exc.details, str(request.url.path),
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
@@ -137,9 +134,8 @@ def register_exception_handlers(app: FastAPI) -> None:
|
||||
) -> JSONResponse:
|
||||
"""Handle bad request errors (400)."""
|
||||
logger.info(
|
||||
"Bad request error: %s",
|
||||
exc.message,
|
||||
extra={"details": exc.details, "path": str(request.url.path)},
|
||||
"Bad request error: %s details=%s path=%s",
|
||||
exc.message, exc.details, str(request.url.path),
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
@@ -158,9 +154,8 @@ def register_exception_handlers(app: FastAPI) -> None:
|
||||
) -> JSONResponse:
|
||||
"""Handle not found errors (404)."""
|
||||
logger.info(
|
||||
"Not found error: %s",
|
||||
exc.message,
|
||||
extra={"details": exc.details, "path": str(request.url.path)},
|
||||
"Not found error: %s details=%s path=%s",
|
||||
exc.message, exc.details, str(request.url.path),
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
@@ -179,9 +174,8 @@ def register_exception_handlers(app: FastAPI) -> None:
|
||||
) -> JSONResponse:
|
||||
"""Handle conflict errors (409)."""
|
||||
logger.info(
|
||||
"Conflict error: %s",
|
||||
exc.message,
|
||||
extra={"details": exc.details, "path": str(request.url.path)},
|
||||
"Conflict error: %s details=%s path=%s",
|
||||
exc.message, exc.details, str(request.url.path),
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
@@ -200,9 +194,8 @@ def register_exception_handlers(app: FastAPI) -> None:
|
||||
) -> JSONResponse:
|
||||
"""Handle rate limit errors (429)."""
|
||||
logger.warning(
|
||||
"Rate limit exceeded: %s",
|
||||
exc.message,
|
||||
extra={"details": exc.details, "path": str(request.url.path)},
|
||||
"Rate limit exceeded: %s details=%s path=%s",
|
||||
exc.message, exc.details, str(request.url.path),
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
@@ -221,13 +214,8 @@ def register_exception_handlers(app: FastAPI) -> None:
|
||||
) -> JSONResponse:
|
||||
"""Handle generic API exceptions."""
|
||||
logger.error(
|
||||
"API error: %s",
|
||||
exc.message,
|
||||
extra={
|
||||
"error_code": exc.error_code,
|
||||
"details": exc.details,
|
||||
"path": str(request.url.path),
|
||||
},
|
||||
"API error: %s error_code=%s details=%s path=%s",
|
||||
exc.message, exc.error_code, exc.details, str(request.url.path),
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
@@ -245,10 +233,9 @@ def register_exception_handlers(app: FastAPI) -> None:
|
||||
request: Request, exc: Exception
|
||||
) -> JSONResponse:
|
||||
"""Handle unexpected exceptions."""
|
||||
logger.exception(
|
||||
"Unexpected error: %s",
|
||||
str(exc),
|
||||
extra={"path": str(request.url.path)},
|
||||
logger.error(
|
||||
"Unexpected error: %s path=%s",
|
||||
str(exc), str(request.url.path),
|
||||
)
|
||||
|
||||
# Log full traceback for debugging
|
||||
|
||||
@@ -81,28 +81,33 @@ class SetupRedirectMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
def _needs_setup(self) -> bool:
|
||||
"""Check if the application needs initial setup.
|
||||
|
||||
|
||||
Returns:
|
||||
True if setup is required, False otherwise
|
||||
"""
|
||||
# Check if master password is configured
|
||||
if not auth_service.is_configured():
|
||||
return True
|
||||
|
||||
|
||||
# Check if config exists and is valid
|
||||
try:
|
||||
config_service = get_config_service()
|
||||
config = config_service.load_config()
|
||||
|
||||
|
||||
# master_password_hash must exist in saved config (not just in-memory)
|
||||
# This ensures reset actually puts app in unconfigured state
|
||||
if not config.other.get('master_password_hash'):
|
||||
return True
|
||||
|
||||
# Validate the loaded config
|
||||
validation = config.validate_config()
|
||||
if not validation.valid:
|
||||
return True
|
||||
|
||||
|
||||
except (FileNotFoundError, ValueError, OSError, AttributeError):
|
||||
# If we can't load or validate config, setup is needed
|
||||
return True
|
||||
|
||||
|
||||
return False
|
||||
|
||||
def _is_unresolved_completed(self) -> bool:
|
||||
@@ -118,6 +123,20 @@ class SetupRedirectMiddleware(BaseHTTPMiddleware):
|
||||
return bool(other.get('unresolved_completed', False))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _is_loading_complete(self) -> bool:
|
||||
"""Check if initial loading has completed.
|
||||
|
||||
Returns:
|
||||
True if loading is complete, False otherwise
|
||||
"""
|
||||
try:
|
||||
config_service = get_config_service()
|
||||
config = config_service.load_config()
|
||||
other = config.other or {}
|
||||
return bool(other.get('loading_complete', False))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def dispatch(
|
||||
self, request: Request, call_next: Callable
|
||||
@@ -132,24 +151,34 @@ class SetupRedirectMiddleware(BaseHTTPMiddleware):
|
||||
Either a redirect to /setup or the normal response
|
||||
"""
|
||||
path = request.url.path
|
||||
query_params = request.query_params
|
||||
|
||||
# Check if trying to access setup or loading page after completion
|
||||
if path in ("/setup", "/loading", "/setup/unresolved"):
|
||||
if not self._needs_setup():
|
||||
if path == "/setup":
|
||||
# Redirect to loading if initialization is in progress
|
||||
# Otherwise redirect to login
|
||||
# Redirect to login if setup is already complete
|
||||
return RedirectResponse(url="/login", status_code=302)
|
||||
elif path == "/setup/unresolved":
|
||||
# Check if unresolved phase is already completed
|
||||
if self._is_unresolved_completed():
|
||||
# Redirect to loading - unresolved phase already done
|
||||
return RedirectResponse(url="/loading", status_code=302)
|
||||
return RedirectResponse(url="/loading?phase=nfo", status_code=302)
|
||||
elif path == "/loading":
|
||||
# Always allow access to loading page - it handles its own
|
||||
# redirect flow via WebSocket events (initialization_complete
|
||||
# event triggers redirect to /setup/unresolved)
|
||||
pass
|
||||
# Handle phase query parameter
|
||||
phase = query_params.get("phase")
|
||||
if phase == "initial":
|
||||
# Only redirect if loading has actually completed
|
||||
# If loading_started=True but loading_complete=False, user should stay
|
||||
# on loading page to see progress
|
||||
if self._is_loading_complete():
|
||||
return RedirectResponse(url="/login", status_code=302)
|
||||
# Otherwise, allow access to loading page (loading in progress)
|
||||
elif not phase:
|
||||
# No phase specified and loading is complete
|
||||
if self._is_loading_complete():
|
||||
return RedirectResponse(url="/login", status_code=302)
|
||||
# phase=nfo is always allowed - it triggers the NFO scan phase
|
||||
|
||||
# Skip setup check for exempt paths
|
||||
if self._is_path_exempt(path):
|
||||
|
||||
@@ -10,15 +10,11 @@ Note on identifiers:
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, HttpUrl, field_validator
|
||||
|
||||
# Regex pattern for valid series keys (URL-safe, lowercase with hyphens)
|
||||
KEY_PATTERN = re.compile(r'^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$')
|
||||
|
||||
|
||||
class EpisodeInfo(BaseModel):
|
||||
"""Information about a single episode."""
|
||||
@@ -83,30 +79,6 @@ class AnimeSeriesResponse(BaseModel):
|
||||
return v
|
||||
|
||||
|
||||
class AnimeMetadataUpdate(BaseModel):
|
||||
"""Request model for updating anime metadata (key, tmdb_id, tvdb_id)."""
|
||||
|
||||
key: Optional[str] = Field(None, description="New series key (URL-safe, lowercase)")
|
||||
tmdb_id: Optional[int] = Field(None, ge=1, description="TMDB ID (positive integer)")
|
||||
tvdb_id: Optional[int] = Field(None, ge=1, description="TVDB ID (positive integer)")
|
||||
|
||||
@field_validator('key', mode='before')
|
||||
@classmethod
|
||||
def validate_key_format(cls, v: Optional[str]) -> Optional[str]:
|
||||
"""Validate key is URL-safe lowercase with hyphens only."""
|
||||
if v is None:
|
||||
return v
|
||||
v = v.strip().lower()
|
||||
if not v:
|
||||
raise ValueError("Key cannot be empty")
|
||||
if not KEY_PATTERN.match(v):
|
||||
raise ValueError(
|
||||
"Key must contain only lowercase letters, numbers, and hyphens. "
|
||||
"Cannot start or end with a hyphen."
|
||||
)
|
||||
return v
|
||||
|
||||
|
||||
class SearchRequest(BaseModel):
|
||||
"""Request payload for searching series."""
|
||||
|
||||
@@ -140,9 +112,14 @@ class SearchResult(BaseModel):
|
||||
"(e.g., 'Attack on Titan (2013)'). For display/filesystem ops only."
|
||||
)
|
||||
)
|
||||
snippet: Optional[str] = Field(None, description="Short description or snippet")
|
||||
thumbnail: Optional[HttpUrl] = Field(None, description="Thumbnail image URL")
|
||||
score: Optional[float] = Field(None, ge=0.0, le=1.0, description="Search relevance score (0-1)")
|
||||
snippet: Optional[str] = Field(
|
||||
None,
|
||||
description="Search result snippet or description"
|
||||
)
|
||||
score: Optional[float] = Field(
|
||||
None,
|
||||
description="Search relevance score (0.0 to 1.0)"
|
||||
)
|
||||
|
||||
@field_validator('key', mode='before')
|
||||
@classmethod
|
||||
@@ -151,3 +128,222 @@ class SearchResult(BaseModel):
|
||||
if isinstance(v, str):
|
||||
return v.lower().strip()
|
||||
return v
|
||||
|
||||
|
||||
class AnimeDetailsResponse(BaseModel):
|
||||
"""Detailed response model for a single anime series with all metadata.
|
||||
|
||||
Used by the edit modal to pre-fill form fields with existing data.
|
||||
|
||||
Attributes:
|
||||
key: Unique series identifier
|
||||
name: Display name
|
||||
folder: Filesystem folder name
|
||||
year: Release year
|
||||
status: Show status (Continuing, Ended)
|
||||
plot: Plot description
|
||||
genres: List of genres
|
||||
studio: List of studios
|
||||
premiered: Premiere date
|
||||
rating: Rating value (0-10)
|
||||
rating_votes: Number of votes
|
||||
tmdb_id: TMDB ID
|
||||
tvdb_id: TVDB ID
|
||||
has_nfo: Whether NFO file exists
|
||||
nfo_created_at: NFO creation timestamp
|
||||
nfo_updated_at: NFO update timestamp
|
||||
"""
|
||||
|
||||
key: str = Field(..., description="Unique series identifier")
|
||||
name: str = Field(..., description="Display name")
|
||||
folder: Optional[str] = Field(None, description="Filesystem folder name")
|
||||
year: Optional[int] = Field(None, ge=1900, le=2100, description="Release year")
|
||||
status: Optional[str] = Field(None, description="Show status (Continuing, Ended)")
|
||||
plot: Optional[str] = Field(None, description="Plot description")
|
||||
genres: List[str] = Field(default_factory=list, description="List of genres")
|
||||
studio: List[str] = Field(default_factory=list, description="List of studios")
|
||||
premiered: Optional[str] = Field(None, description="Premiere date (YYYY-MM-DD)")
|
||||
rating: Optional[float] = Field(None, ge=0, le=10, description="Rating value (0-10)")
|
||||
rating_votes: Optional[int] = Field(None, ge=0, description="Number of votes")
|
||||
tmdb_id: Optional[int] = Field(None, description="TMDB ID")
|
||||
tvdb_id: Optional[int] = Field(None, description="TVDB ID")
|
||||
has_nfo: bool = Field(False, description="Whether NFO file exists")
|
||||
nfo_created_at: Optional[str] = Field(None, description="NFO creation timestamp")
|
||||
nfo_updated_at: Optional[str] = Field(None, description="NFO update timestamp")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"key": "attack-on-titan",
|
||||
"name": "Attack on Titan",
|
||||
"folder": "Attack on Titan (2013)",
|
||||
"year": 2013,
|
||||
"status": "Ended",
|
||||
"plot": "Humans fight against giant humanoid Titans.",
|
||||
"genres": ["Animation", "Action", "Drama"],
|
||||
"studio": ["Wit Studio", "MAPPA"],
|
||||
"premiered": "2013-04-07",
|
||||
"rating": 9.0,
|
||||
"rating_votes": 500000,
|
||||
"tmdb_id": 1429,
|
||||
"tvdb_id": 267440,
|
||||
"has_nfo": True,
|
||||
"nfo_created_at": "2025-01-15T10:30:00Z",
|
||||
"nfo_updated_at": "2025-01-15T10:30:00Z",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class TMDBSearchResult(BaseModel):
|
||||
"""TMDB search result for auto-lookup functionality.
|
||||
|
||||
Attributes:
|
||||
tmdb_id: TMDB ID of the matched series
|
||||
title: Title from TMDB
|
||||
year: Release year
|
||||
overview: Short description
|
||||
vote_average: TMDB rating
|
||||
"""
|
||||
|
||||
tmdb_id: int = Field(..., description="TMDB ID")
|
||||
title: str = Field(..., description="Title from TMDB")
|
||||
year: Optional[int] = Field(None, description="Release year")
|
||||
overview: Optional[str] = Field(None, description="Short description")
|
||||
vote_average: Optional[float] = Field(None, description="TMDB rating")
|
||||
|
||||
|
||||
class AnimeSettingsResponse(BaseModel):
|
||||
"""Response payload for the Anime Settings page.
|
||||
|
||||
Surfaces every anime_series field that can be viewed or edited
|
||||
by the user. Used by GET /api/anime/{key}/settings and the
|
||||
PUT response.
|
||||
"""
|
||||
|
||||
key: str = Field(..., description="Series unique key (primary identifier)")
|
||||
name: str = Field(..., description="Series display name")
|
||||
site: str = Field(..., description="Provider site URL")
|
||||
folder: str = Field(..., description="Filesystem folder name")
|
||||
year: Optional[int] = Field(None, description="Release year")
|
||||
tmdb_id: Optional[int] = Field(None, description="TMDB ID")
|
||||
tvdb_id: Optional[int] = Field(None, description="TVDB ID")
|
||||
has_nfo: bool = Field(False, description="Whether tvshow.nfo exists")
|
||||
nfo_path: Optional[str] = Field(None, description="Path to tvshow.nfo file")
|
||||
nfo_created_at: Optional[str] = Field(None, description="ISO timestamp when NFO created")
|
||||
nfo_updated_at: Optional[str] = Field(None, description="ISO timestamp when NFO updated")
|
||||
loading_status: Optional[str] = Field(
|
||||
None, description="Current loading status of the series"
|
||||
)
|
||||
episode_count: int = Field(0, description="Total number of episodes tracked")
|
||||
missing_episode_count: int = Field(0, description="Number of missing episodes")
|
||||
|
||||
|
||||
class AnimeSettingsUpdateRequest(BaseModel):
|
||||
"""Request payload for PUT /api/anime/{key}/settings.
|
||||
|
||||
All fields are optional. Only the fields that are provided will
|
||||
be updated. Field-level validation happens in the API endpoint
|
||||
(e.g. folder sanitization, TMDB ID format).
|
||||
"""
|
||||
|
||||
name: Optional[str] = Field(
|
||||
None,
|
||||
min_length=1,
|
||||
max_length=500,
|
||||
description="Series display name",
|
||||
)
|
||||
folder: Optional[str] = Field(
|
||||
None,
|
||||
min_length=1,
|
||||
max_length=1000,
|
||||
description="Filesystem folder name",
|
||||
)
|
||||
tmdb_id: Optional[int] = Field(
|
||||
None,
|
||||
ge=1,
|
||||
le=9999999999,
|
||||
description="TMDB ID (positive integer, max 10 digits)",
|
||||
)
|
||||
tvdb_id: Optional[int] = Field(
|
||||
None,
|
||||
ge=1,
|
||||
le=9999999999,
|
||||
description="TVDB ID (positive integer, max 10 digits)",
|
||||
)
|
||||
site: Optional[str] = Field(
|
||||
None,
|
||||
max_length=500,
|
||||
description="Provider site URL",
|
||||
)
|
||||
apply_to_nfo: bool = Field(
|
||||
False,
|
||||
description="If true, regenerate tvshow.nfo with the new values",
|
||||
)
|
||||
rename_disk: bool = Field(
|
||||
False,
|
||||
description="If true and folder changed, rename the folder on disk",
|
||||
)
|
||||
|
||||
|
||||
class AnimeSettingsRegenerateNfoResponse(BaseModel):
|
||||
"""Response payload for POST /api/anime/{key}/regenerate-nfo."""
|
||||
|
||||
success: bool = Field(..., description="Whether regeneration succeeded")
|
||||
message: str = Field(..., description="Human-readable result message")
|
||||
nfo_path: Optional[str] = Field(None, description="Path to regenerated NFO file")
|
||||
repaired_tags: List[str] = Field(
|
||||
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")
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from typing import Dict, List, Optional
|
||||
import ast
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, ValidationError, field_validator
|
||||
from pydantic import BaseModel, Field, ValidationError, field_validator, model_validator
|
||||
|
||||
_VALID_DAYS = frozenset(["mon", "tue", "wed", "thu", "fri", "sat", "sun"])
|
||||
_ALL_DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
|
||||
@@ -45,6 +47,17 @@ class SchedulerConfig(BaseModel):
|
||||
"completes. Checks each series folder for tvshow.nfo and "
|
||||
"creates or fills missing properties.",
|
||||
)
|
||||
image_scan_after_rescan: bool = Field(
|
||||
default=True,
|
||||
description="Download series images (poster.jpg, fanart.jpg, logo.png) "
|
||||
"from TMDB after a scheduled rescan completes.",
|
||||
)
|
||||
folder_naming_after_nfo_scan: bool = Field(
|
||||
default=True,
|
||||
description="Fix missing years in folder names after NFO refresh. "
|
||||
"Renames folders (e.g. 'Naruto' -> 'Naruto (1999)') using "
|
||||
"the year from the database record.",
|
||||
)
|
||||
# Legacy alias fields — read via Pydantic alias
|
||||
auto_download: Optional[bool] = Field(default=None, alias="auto_download")
|
||||
|
||||
@@ -67,6 +80,40 @@ class SchedulerConfig(BaseModel):
|
||||
)
|
||||
return v
|
||||
|
||||
@classmethod
|
||||
def _parse_schedule_days(cls, v):
|
||||
"""Parse schedule_days that may arrive as a malformed string.
|
||||
|
||||
Robot Framework's Create Dictionary converts Python-style lists
|
||||
like ['monday', 'tuesday'] into strings. Handle that here before
|
||||
Pydantic's type validation runs.
|
||||
"""
|
||||
if not isinstance(v, str):
|
||||
return v
|
||||
# Try JSON first (double-quoted), then Python literal (single-quoted)
|
||||
for parse_fn in (json.loads, ast.literal_eval):
|
||||
try:
|
||||
parsed = parse_fn(v)
|
||||
if isinstance(parsed, list):
|
||||
return parsed
|
||||
except Exception:
|
||||
pass
|
||||
# Cannot parse - let Pydantic handle the error
|
||||
return v
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _pre_validate(cls, data):
|
||||
"""Handle malformed schedule_days from Robot Framework before type validation."""
|
||||
if isinstance(data, dict):
|
||||
sd = data.get("schedule_days")
|
||||
if isinstance(sd, str):
|
||||
parsed = cls._parse_schedule_days(sd)
|
||||
if isinstance(parsed, list):
|
||||
data = dict(data)
|
||||
data["schedule_days"] = parsed
|
||||
return data
|
||||
|
||||
@field_validator("schedule_days")
|
||||
@classmethod
|
||||
def validate_schedule_days(cls, v: List[str]) -> List[str]:
|
||||
@@ -120,10 +167,10 @@ class LoggingConfig(BaseModel):
|
||||
default=None, description="Optional file path for log output"
|
||||
)
|
||||
max_bytes: Optional[int] = Field(
|
||||
default=None, ge=0, description="Max bytes per log file for rotation"
|
||||
default=None, description="Max bytes per log file for rotation"
|
||||
)
|
||||
backup_count: Optional[int] = Field(
|
||||
default=3, ge=0, description="Number of rotated log files to keep"
|
||||
default=3, description="Number of rotated log files to keep"
|
||||
)
|
||||
|
||||
@field_validator("level")
|
||||
@@ -237,12 +284,67 @@ class AppConfig(BaseModel):
|
||||
|
||||
|
||||
class ConfigUpdate(BaseModel):
|
||||
scheduler: Optional[SchedulerConfig] = None
|
||||
logging: Optional[LoggingConfig] = None
|
||||
backup: Optional[BackupConfig] = None
|
||||
nfo: Optional[NFOConfig] = None
|
||||
name: Optional[str] = None
|
||||
data_dir: Optional[str] = None
|
||||
scheduler: Optional[Dict[str, Any]] = None
|
||||
logging: Optional[Dict[str, Any]] = None
|
||||
backup: Optional[Dict[str, Any]] = None
|
||||
nfo: Optional[Dict[str, Any]] = None
|
||||
scan_key_overrides: Optional[Dict[str, str]] = None
|
||||
other: Optional[Dict[str, object]] = None
|
||||
other: Optional[Dict[str, Any]] = None
|
||||
|
||||
@classmethod
|
||||
def _parse_dict_field(cls, v):
|
||||
"""Parse a field that may arrive as a malformed string from Robot Framework.
|
||||
|
||||
Robot Framework's Create Dictionary converts Python-style nested dicts
|
||||
like {'enabled': False} into their string representation. Handle that here
|
||||
before Pydantic's type validation runs. Also handles Pydantic models being
|
||||
passed directly (from unit tests).
|
||||
"""
|
||||
# Pydantic model - convert to dict first
|
||||
if hasattr(v, 'model_dump'):
|
||||
return v.model_dump()
|
||||
if hasattr(v, 'dict'):
|
||||
return v.dict()
|
||||
# Already a dict
|
||||
if isinstance(v, dict):
|
||||
return v
|
||||
# String - try parsing
|
||||
if isinstance(v, str):
|
||||
for parse_fn in (json.loads, ast.literal_eval):
|
||||
try:
|
||||
parsed = parse_fn(v)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
except Exception:
|
||||
pass
|
||||
return v
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _pre_validate(cls, data):
|
||||
"""Handle malformed dict strings from Robot Framework and Pydantic models passed directly.
|
||||
|
||||
Robot Framework's Create Dictionary converts Python-style nested dicts
|
||||
like {'enabled': False} into their string representation.
|
||||
Unit tests may pass Pydantic model instances directly.
|
||||
Both cases need conversion before type validation.
|
||||
"""
|
||||
if isinstance(data, dict):
|
||||
data = dict(data) # make mutable
|
||||
for field in ("name", "data_dir", "scheduler", "logging", "backup", "nfo", "scan_key_overrides", "other"):
|
||||
if field in data:
|
||||
v = data[field]
|
||||
# Pydantic model - convert to dict
|
||||
if hasattr(v, "model_dump"):
|
||||
data[field] = v.model_dump()
|
||||
# String from Robot Framework - try parsing
|
||||
elif isinstance(v, str):
|
||||
parsed = cls._parse_dict_field(v)
|
||||
if isinstance(parsed, dict):
|
||||
data[field] = parsed
|
||||
return data
|
||||
|
||||
def apply_to(self, current: AppConfig) -> AppConfig:
|
||||
"""Return a new AppConfig with updates applied to the current config.
|
||||
@@ -250,18 +352,64 @@ class ConfigUpdate(BaseModel):
|
||||
Performs a shallow merge for `other`.
|
||||
"""
|
||||
data = current.model_dump()
|
||||
if self.name is not None:
|
||||
data["name"] = self.name
|
||||
if self.data_dir is not None:
|
||||
data["data_dir"] = self.data_dir
|
||||
if self.scheduler is not None:
|
||||
data["scheduler"] = self.scheduler.model_dump()
|
||||
scheduler_data = self.scheduler
|
||||
if isinstance(scheduler_data, str):
|
||||
try:
|
||||
scheduler_data = json.loads(scheduler_data)
|
||||
except json.JSONDecodeError:
|
||||
scheduler_data = ast.literal_eval(scheduler_data)
|
||||
if isinstance(scheduler_data, dict):
|
||||
try:
|
||||
scheduler_data = SchedulerConfig(**scheduler_data)
|
||||
except ValidationError:
|
||||
raise
|
||||
data["scheduler"] = scheduler_data.model_dump()
|
||||
if self.logging is not None:
|
||||
data["logging"] = self.logging.model_dump()
|
||||
logging_data = self.logging
|
||||
if isinstance(logging_data, str):
|
||||
try:
|
||||
logging_data = json.loads(logging_data)
|
||||
except json.JSONDecodeError:
|
||||
logging_data = ast.literal_eval(logging_data)
|
||||
if isinstance(logging_data, dict):
|
||||
logging_data = LoggingConfig(**logging_data)
|
||||
data["logging"] = logging_data.model_dump()
|
||||
if self.backup is not None:
|
||||
data["backup"] = self.backup.model_dump()
|
||||
backup_data = self.backup
|
||||
if isinstance(backup_data, str):
|
||||
try:
|
||||
backup_data = json.loads(backup_data)
|
||||
except json.JSONDecodeError:
|
||||
backup_data = ast.literal_eval(backup_data)
|
||||
if isinstance(backup_data, dict):
|
||||
backup_data = BackupConfig(**backup_data)
|
||||
data["backup"] = backup_data.model_dump()
|
||||
if self.nfo is not None:
|
||||
data["nfo"] = self.nfo.model_dump()
|
||||
nfo_data = self.nfo
|
||||
if isinstance(nfo_data, str):
|
||||
try:
|
||||
nfo_data = json.loads(nfo_data)
|
||||
except json.JSONDecodeError:
|
||||
nfo_data = ast.literal_eval(nfo_data)
|
||||
if isinstance(nfo_data, dict):
|
||||
nfo_data = NFOConfig(**nfo_data)
|
||||
data["nfo"] = nfo_data.model_dump()
|
||||
if self.scan_key_overrides is not None:
|
||||
data["scan_key_overrides"] = self.scan_key_overrides
|
||||
if self.other is not None:
|
||||
merged = dict(current.other or {})
|
||||
merged.update(self.other)
|
||||
other_data = self.other
|
||||
if isinstance(other_data, str):
|
||||
try:
|
||||
other_data = json.loads(other_data)
|
||||
except json.JSONDecodeError:
|
||||
other_data = ast.literal_eval(other_data)
|
||||
if isinstance(other_data, dict):
|
||||
merged.update(other_data)
|
||||
data["other"] = merged
|
||||
return AppConfig(**data)
|
||||
|
||||
@@ -6,6 +6,8 @@ on serialization, validation, and OpenAPI documentation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import List, Optional
|
||||
@@ -42,6 +44,48 @@ class EpisodeIdentifier(BaseModel):
|
||||
)
|
||||
title: Optional[str] = Field(None, description="Episode title if known")
|
||||
|
||||
@field_validator("season", mode="before")
|
||||
@classmethod
|
||||
def parse_season(cls, v):
|
||||
"""Parse season from string JSON if needed."""
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
return int(v)
|
||||
except ValueError:
|
||||
try:
|
||||
parsed = json.loads(v)
|
||||
return parsed.get("season") if isinstance(parsed, dict) else v
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return v
|
||||
return v
|
||||
|
||||
@field_validator("episode", mode="before")
|
||||
@classmethod
|
||||
def parse_episode(cls, v):
|
||||
"""Parse episode from string JSON if needed."""
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
return int(v)
|
||||
except ValueError:
|
||||
try:
|
||||
parsed = json.loads(v)
|
||||
return parsed.get("episode") if isinstance(parsed, dict) else v
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return v
|
||||
return v
|
||||
|
||||
@field_validator("title", mode="before")
|
||||
@classmethod
|
||||
def parse_title(cls, v):
|
||||
"""Parse title from string JSON if needed."""
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
parsed = json.loads(v)
|
||||
return parsed.get("title") if isinstance(parsed, dict) else v
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return v
|
||||
return v
|
||||
|
||||
|
||||
class DownloadProgress(BaseModel):
|
||||
"""Real-time progress information for an active download."""
|
||||
@@ -218,7 +262,36 @@ class DownloadRequest(BaseModel):
|
||||
priority: DownloadPriority = Field(
|
||||
DownloadPriority.NORMAL, description="Priority level for queue items"
|
||||
)
|
||||
|
||||
|
||||
@field_validator('episodes', mode='before')
|
||||
@classmethod
|
||||
def parse_episodes(cls, v):
|
||||
"""Parse episodes list, handling potential string-encoded dicts."""
|
||||
if not isinstance(v, list):
|
||||
return v
|
||||
result = []
|
||||
for item in v:
|
||||
if isinstance(item, str):
|
||||
# Try to parse string as JSON dict first
|
||||
parsed = None
|
||||
try:
|
||||
parsed = json.loads(item)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
# If JSON failed, try Python dict string syntax
|
||||
if parsed is None:
|
||||
try:
|
||||
parsed = ast.literal_eval(item)
|
||||
except (ValueError, SyntaxError, TypeError):
|
||||
pass
|
||||
if isinstance(parsed, dict):
|
||||
result.append(parsed)
|
||||
else:
|
||||
result.append(item)
|
||||
else:
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
@field_validator('priority', mode='before')
|
||||
@classmethod
|
||||
def normalize_priority(cls, v):
|
||||
|
||||
@@ -357,8 +357,8 @@ class NFOMissingResponse(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class NfoDiagnosticsResponse(BaseModel):
|
||||
"""Response for NFO diagnostics showing missing required tags."""
|
||||
class NfoSettingsResponse(BaseModel):
|
||||
"""Response for NFO settings inspection showing missing required tags."""
|
||||
|
||||
has_nfo: bool = Field(..., description="Whether tvshow.nfo exists")
|
||||
nfo_path: Optional[str] = Field(None, description="Path to NFO file if exists")
|
||||
@@ -372,6 +372,20 @@ class NfoDiagnosticsResponse(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class NfoSeriesSettings(BaseModel):
|
||||
"""Settings summary for a single series in the needs-repair list."""
|
||||
|
||||
key: str = Field(..., description="Series unique key")
|
||||
name: str = Field(..., description="Series display name")
|
||||
folder: str = Field(..., description="Series folder name")
|
||||
has_nfo: bool = Field(..., description="Whether tvshow.nfo exists")
|
||||
missing_tags: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="List of missing required tag names"
|
||||
)
|
||||
tmdb_id: Optional[int] = Field(None, description="TMDB ID if available")
|
||||
|
||||
|
||||
class NfoRepairResponse(BaseModel):
|
||||
"""Response after NFO repair attempt."""
|
||||
|
||||
|
||||
@@ -422,3 +422,32 @@ class TMDBClient:
|
||||
if expired_keys:
|
||||
logger.debug("Removed %d expired negative cache entries", len(expired_keys))
|
||||
return len(expired_keys)
|
||||
|
||||
|
||||
def get_tmdb_client() -> TMDBClient:
|
||||
"""Factory function to create a TMDBClient with settings configuration.
|
||||
|
||||
Returns:
|
||||
TMDBClient instance configured with settings.tmdb_api_key
|
||||
|
||||
Raises:
|
||||
ValueError: If TMDB API key is not configured
|
||||
"""
|
||||
from src.config.settings import settings
|
||||
|
||||
if not settings.tmdb_api_key:
|
||||
raise ValueError("TMDB API key is not configured")
|
||||
|
||||
return TMDBClient(api_key=settings.tmdb_api_key)
|
||||
|
||||
|
||||
def get_tmdb_image_base_url(tmdb_id: int) -> str:
|
||||
"""Get the base URL for TMDB images.
|
||||
|
||||
Args:
|
||||
tmdb_id: TMDB show ID (used for account-specific URLs)
|
||||
|
||||
Returns:
|
||||
Base URL string for TMDB images
|
||||
"""
|
||||
return "https://image.tmdb.org/t/p/"
|
||||
|
||||
@@ -5,6 +5,7 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
@@ -383,6 +384,12 @@ class AniworldLoader(Loader):
|
||||
"Direct stream download starting (type=%s)",
|
||||
content_type
|
||||
)
|
||||
total_size = int(response.headers.get(
|
||||
"Content-Length", 0
|
||||
))
|
||||
received = 0
|
||||
last_emit = 0
|
||||
start_time = time.monotonic()
|
||||
with open(output_path, "wb") as fh:
|
||||
for chunk in response.iter_content(chunk_size=1024 * 1024):
|
||||
if self._cancel_flag.is_set():
|
||||
@@ -391,7 +398,33 @@ class AniworldLoader(Loader):
|
||||
)
|
||||
return False
|
||||
if chunk:
|
||||
received += len(chunk)
|
||||
fh.write(chunk)
|
||||
# Emit progress events at ~1% intervals
|
||||
if total_size > 0:
|
||||
pct = (received / total_size) * 100
|
||||
if pct - last_emit >= 1.0 or received == total_size:
|
||||
elapsed = time.monotonic() - start_time
|
||||
speed_bps = (
|
||||
received / elapsed
|
||||
if elapsed > 0 else 0
|
||||
)
|
||||
eta = (
|
||||
int((total_size - received) / speed_bps)
|
||||
if speed_bps > 0 else None
|
||||
)
|
||||
self.events.download_progress({
|
||||
"downloaded_bytes": received,
|
||||
"total_bytes": total_size,
|
||||
"speed": speed_bps,
|
||||
"eta": eta,
|
||||
"status": (
|
||||
"finished"
|
||||
if received >= total_size
|
||||
else "downloading"
|
||||
),
|
||||
})
|
||||
last_emit = pct
|
||||
return True
|
||||
except requests.RequestException as exc:
|
||||
logger.warning("Direct stream download failed: %s", exc)
|
||||
@@ -543,6 +576,7 @@ class AniworldLoader(Loader):
|
||||
self.events.download_progress(d)
|
||||
|
||||
ydl_opts = {
|
||||
'downloader': 'ffmpeg', # Use ffmpeg for proper progress reporting
|
||||
'fragment_retries': float('inf'),
|
||||
'outtmpl': temp_path,
|
||||
'quiet': True,
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from functools import lru_cache
|
||||
from typing import Optional
|
||||
|
||||
import structlog
|
||||
|
||||
from src.server.SeriesApp import SeriesApp
|
||||
from src.server.services.progress_service import (
|
||||
ProgressService,
|
||||
@@ -19,7 +18,7 @@ from src.server.services.websocket_service import (
|
||||
get_websocket_service,
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AnimeServiceError(Exception):
|
||||
@@ -61,16 +60,28 @@ class AnimeService:
|
||||
self._scan_lock = asyncio.Lock()
|
||||
# Subscribe to SeriesApp events
|
||||
# Note: Events library uses assignment (=), not += operator
|
||||
import logging
|
||||
_logger = logging.getLogger(__name__)
|
||||
try:
|
||||
self._app.download_status = self._on_download_status
|
||||
self._app.scan_status = self._on_scan_status
|
||||
logger.info(
|
||||
"Subscribed to SeriesApp events",
|
||||
scan_status_handler=str(self._app.scan_status),
|
||||
series_app_id=id(self._app),
|
||||
_logger.info(
|
||||
"Subscribed to SeriesApp events: scan_status=%s series_app_id=%s",
|
||||
str(self._app.scan_status),
|
||||
id(self._app),
|
||||
)
|
||||
except (BrokenPipeError, OSError) as e:
|
||||
# Handle "broken pipe" when structlog tries to write to closed stdout
|
||||
# This can happen when server runs in background with stdout redirected
|
||||
import sys
|
||||
print(
|
||||
f"WARNING: Failed to subscribe to SeriesApp events: {e}. "
|
||||
f"Download/scan status callbacks may not work.",
|
||||
file=sys.stderr,
|
||||
flush=True
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to subscribe to SeriesApp events")
|
||||
_logger.error("Failed to subscribe to SeriesApp events: %s", e)
|
||||
raise AnimeServiceError("Initialization failed") from e
|
||||
|
||||
|
||||
@@ -95,8 +106,8 @@ class AnimeService:
|
||||
|
||||
if not loop:
|
||||
logger.debug(
|
||||
"No event loop available for download status event",
|
||||
status=args.status
|
||||
"No event loop available for download status event status=%s",
|
||||
args.status
|
||||
)
|
||||
return
|
||||
|
||||
@@ -166,8 +177,8 @@ class AnimeService:
|
||||
)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
logger.error(
|
||||
"Error handling download status event",
|
||||
error=str(exc)
|
||||
"Error handling download status event error=%s",
|
||||
str(exc)
|
||||
)
|
||||
|
||||
def _on_scan_status(self, args) -> None:
|
||||
@@ -181,41 +192,40 @@ class AnimeService:
|
||||
args: ScanStatusEventArgs from SeriesApp containing key,
|
||||
folder, current, total, status, and progress info
|
||||
"""
|
||||
import logging
|
||||
_event_logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
scan_id = "library_scan"
|
||||
|
||||
logger.info(
|
||||
"Scan status event received",
|
||||
status=args.status,
|
||||
current=args.current,
|
||||
total=args.total,
|
||||
folder=args.folder,
|
||||
_event_logger.info(
|
||||
"Scan status event received status=%s current=%s total=%s folder=%s",
|
||||
args.status, args.current, args.total, args.folder,
|
||||
)
|
||||
|
||||
# Get event loop - try running loop first, then stored loop
|
||||
loop = None
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
logger.debug("Using running event loop for scan status")
|
||||
_event_logger.debug("Using running event loop for scan status")
|
||||
except RuntimeError:
|
||||
# No running loop in this thread - use stored loop
|
||||
loop = self._event_loop
|
||||
logger.debug(
|
||||
"Using stored event loop for scan status",
|
||||
has_loop=loop is not None
|
||||
_event_logger.debug(
|
||||
"Using stored event loop for scan status has_loop=%s",
|
||||
loop is not None
|
||||
)
|
||||
|
||||
if not loop:
|
||||
logger.warning(
|
||||
"No event loop available for scan status event",
|
||||
status=args.status
|
||||
_event_logger.warning(
|
||||
"No event loop available for scan status event status=%s",
|
||||
args.status
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"Processing scan status event",
|
||||
status=args.status,
|
||||
loop_id=id(loop),
|
||||
_event_logger.info(
|
||||
"Processing scan status event status=%s loop_id=%s",
|
||||
args.status, id(loop),
|
||||
)
|
||||
|
||||
# Map SeriesApp scan events to progress service
|
||||
@@ -439,8 +449,8 @@ class AnimeService:
|
||||
else:
|
||||
result.append(s) # type: ignore
|
||||
return result
|
||||
except Exception:
|
||||
logger.exception("Failed to get missing episodes list")
|
||||
except Exception as e:
|
||||
_logger.error("Failed to get missing episodes list: %s", str(e))
|
||||
raise
|
||||
|
||||
async def list_missing(self) -> list[dict]:
|
||||
@@ -459,7 +469,7 @@ class AnimeService:
|
||||
except AnimeServiceError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception("list_missing failed")
|
||||
_logger.error("list_missing failed: %s", str(exc))
|
||||
raise AnimeServiceError("Failed to list missing series") from exc
|
||||
|
||||
async def list_series_with_filters(
|
||||
@@ -604,16 +614,15 @@ class AnimeService:
|
||||
result_list.append(series_dict)
|
||||
|
||||
logger.info(
|
||||
"Listed series with filters",
|
||||
total_count=len(result_list),
|
||||
filter_type=filter_type
|
||||
"Listed series with filters total=%d filter_type=%s",
|
||||
len(result_list), filter_type
|
||||
)
|
||||
return result_list
|
||||
|
||||
except AnimeServiceError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception("list_series_with_filters failed")
|
||||
logger.error("list_series_with_filters failed: %s", str(exc))
|
||||
raise AnimeServiceError(
|
||||
"Failed to list series with metadata"
|
||||
) from exc
|
||||
@@ -635,7 +644,7 @@ class AnimeService:
|
||||
result = await self._app.search(query)
|
||||
return result
|
||||
except Exception as exc:
|
||||
logger.exception("search failed")
|
||||
logger.error("search failed: %s", str(exc))
|
||||
raise AnimeServiceError("Search failed") from exc
|
||||
|
||||
async def rescan(self) -> None:
|
||||
@@ -655,30 +664,36 @@ class AnimeService:
|
||||
progress, this method returns immediately without starting
|
||||
a new scan.
|
||||
"""
|
||||
import logging
|
||||
_rescan_logger = logging.getLogger(__name__)
|
||||
|
||||
# Check if a scan is already running (non-blocking)
|
||||
if self._scan_lock.locked():
|
||||
logger.info("Rescan already in progress, ignoring request")
|
||||
_rescan_logger.info("Rescan already in progress, ignoring request")
|
||||
return
|
||||
|
||||
async with self._scan_lock:
|
||||
try:
|
||||
# Store event loop for event handlers
|
||||
self._event_loop = asyncio.get_running_loop()
|
||||
logger.info(
|
||||
"Rescan started, event loop stored",
|
||||
loop_id=id(self._event_loop),
|
||||
series_app_id=id(self._app),
|
||||
scan_handler=str(self._app.scan_status),
|
||||
_rescan_logger.info(
|
||||
"Rescan started, event loop stored. loop_id=%d series_app_id=%d",
|
||||
id(self._event_loop),
|
||||
id(self._app),
|
||||
)
|
||||
|
||||
# SeriesApp.rescan returns scanned series list
|
||||
_rescan_logger.info("Calling _app.rescan()")
|
||||
scanned_series = await self._app.rescan()
|
||||
_rescan_logger.info("Rescan completed, found %d series", len(scanned_series) if scanned_series else 0)
|
||||
|
||||
# Persist scan results to database
|
||||
if scanned_series:
|
||||
_rescan_logger.info("Saving %d series to database", len(scanned_series))
|
||||
await self._save_scan_results_to_db(scanned_series)
|
||||
|
||||
# Reload series from database to ensure consistency
|
||||
_rescan_logger.info("Loading series from database")
|
||||
await self._load_series_from_db()
|
||||
|
||||
# invalidate cache
|
||||
@@ -687,8 +702,11 @@ class AnimeService:
|
||||
except Exception: # pylint: disable=broad-except
|
||||
pass
|
||||
|
||||
except AnimeServiceError:
|
||||
# Re-raise AnimeServiceError without wrapping
|
||||
raise
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
logger.exception("rescan failed")
|
||||
_rescan_logger.error("Rescan failed: %s", str(exc))
|
||||
raise AnimeServiceError("Rescan failed") from exc
|
||||
|
||||
async def sync_single_series_after_scan(self, series_key: str) -> None:
|
||||
@@ -1290,11 +1308,12 @@ class AnimeService:
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"Failed to rename folder for %s: %s -> %s",
|
||||
logger.error(
|
||||
"Failed to rename folder for %s: %s -> %s: %s",
|
||||
key,
|
||||
current_folder,
|
||||
target_folder
|
||||
target_folder,
|
||||
str(e)
|
||||
)
|
||||
return False
|
||||
|
||||
@@ -1365,7 +1384,7 @@ class AnimeService:
|
||||
logger.info("Download cancelled, propagating cancellation")
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception("download failed")
|
||||
logger.error("download failed: %s", str(exc))
|
||||
raise AnimeServiceError("Download failed") from exc
|
||||
|
||||
async def update_nfo_status(
|
||||
@@ -1466,10 +1485,9 @@ class AnimeService:
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"Failed to update NFO status",
|
||||
key=key,
|
||||
has_nfo=has_nfo
|
||||
logger.error(
|
||||
"Failed to update NFO status key=%s has_nfo=%s: %s",
|
||||
key, has_nfo, str(exc)
|
||||
)
|
||||
raise AnimeServiceError("NFO status update failed") from exc
|
||||
|
||||
@@ -1545,7 +1563,7 @@ class AnimeService:
|
||||
return result
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to query series without NFO")
|
||||
logger.error("Failed to query series without NFO: %s", str(exc))
|
||||
raise AnimeServiceError(
|
||||
"Query for series without NFO failed"
|
||||
) from exc
|
||||
@@ -1590,7 +1608,8 @@ class AnimeService:
|
||||
"with_tvdb_id": with_tvdb
|
||||
}
|
||||
|
||||
logger.info("Retrieved NFO statistics", **stats)
|
||||
logger.info("Retrieved NFO statistics total=%d with_nfo=%d without_nfo=%d with_tmdb_id=%d with_tvdb_id=%d",
|
||||
total, with_nfo, total - with_nfo, with_tmdb, with_tvdb)
|
||||
return stats
|
||||
else:
|
||||
# Use provided session and service layer count methods
|
||||
@@ -1607,150 +1626,209 @@ class AnimeService:
|
||||
"with_tvdb_id": with_tvdb
|
||||
}
|
||||
|
||||
logger.info("Retrieved NFO statistics", **stats)
|
||||
logger.info("Retrieved NFO statistics total=%d with_nfo=%d without_nfo=%d with_tmdb_id=%d with_tvdb_id=%d",
|
||||
total, with_nfo, total - with_nfo, with_tmdb, with_tvdb)
|
||||
return stats
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to get NFO statistics")
|
||||
logger.error("Failed to get NFO statistics: %s", str(exc))
|
||||
raise AnimeServiceError("NFO statistics query failed") from exc
|
||||
|
||||
async def delete_series(
|
||||
self,
|
||||
key: str,
|
||||
delete_database: bool = True,
|
||||
delete_folder: bool = False,
|
||||
) -> "DeleteSeriesResult":
|
||||
"""Delete an anime series from database, filesystem, or both.
|
||||
|
||||
Args:
|
||||
key: Series key (primary identifier)
|
||||
delete_database: If True, remove from database (default True)
|
||||
delete_folder: If True, remove folder from filesystem (default False)
|
||||
|
||||
Returns:
|
||||
DeleteSeriesResult with success status, what was deleted, errors
|
||||
"""
|
||||
from src.server.database.connection import get_db_session
|
||||
from src.server.database.service import AnimeSeriesService
|
||||
from src.server.models.anime import DeleteSeriesResult
|
||||
from src.server.utils.filesystem import is_safe_path
|
||||
import os as _os
|
||||
import shutil
|
||||
|
||||
logger.info(
|
||||
"Delete series requested: key=%s delete_database=%s delete_folder=%s",
|
||||
key, delete_database, delete_folder,
|
||||
)
|
||||
|
||||
# Guard: at least one deletion mode must be selected
|
||||
if not delete_database and not delete_folder:
|
||||
logger.warning(
|
||||
"Delete series rejected - no options selected: key=%s", key
|
||||
)
|
||||
return DeleteSeriesResult(
|
||||
success=False,
|
||||
key=key,
|
||||
name="",
|
||||
folder_path=None,
|
||||
deleted_from_database=False,
|
||||
deleted_folder=False,
|
||||
database_error=None,
|
||||
folder_error=None,
|
||||
message="At least one of delete_database or delete_folder must be True.",
|
||||
)
|
||||
|
||||
# Single DB session for fetch + optional delete
|
||||
async with get_db_session() as db:
|
||||
series = await AnimeSeriesService.get_by_key(db, key)
|
||||
if not series:
|
||||
logger.warning(
|
||||
"Delete series failed - not found: key=%s", key
|
||||
)
|
||||
return DeleteSeriesResult(
|
||||
success=False,
|
||||
key=key,
|
||||
name="",
|
||||
folder_path=None,
|
||||
deleted_from_database=False,
|
||||
deleted_folder=False,
|
||||
database_error=None,
|
||||
folder_error=None,
|
||||
message=f"Series '{key}' not found.",
|
||||
)
|
||||
|
||||
series_id = series.id
|
||||
series_name = series.name
|
||||
folder_path = series.folder
|
||||
|
||||
result = DeleteSeriesResult(
|
||||
success=True,
|
||||
key=key,
|
||||
name=series_name,
|
||||
folder_path=folder_path,
|
||||
message="",
|
||||
)
|
||||
|
||||
# --- Database deletion ---
|
||||
if delete_database:
|
||||
try:
|
||||
async with get_db_session() as db:
|
||||
deleted = await AnimeSeriesService.delete(db, series_id)
|
||||
if deleted:
|
||||
logger.info(
|
||||
"Deleted series from database: key=%s name=%s id=%d",
|
||||
key, series_name, series_id,
|
||||
)
|
||||
result.deleted_from_database = True
|
||||
else:
|
||||
# Already gone is treated as success
|
||||
result.deleted_from_database = True
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to delete series from database: key=%s error=%s",
|
||||
key, str(exc),
|
||||
)
|
||||
result.database_error = str(exc)
|
||||
result.success = False
|
||||
|
||||
# Invalidate list cache
|
||||
try:
|
||||
self._cached_list_missing.cache_clear()
|
||||
except Exception: # pylint: disable=broad-except
|
||||
pass
|
||||
|
||||
# Broadcast deletion via WebSocket
|
||||
try:
|
||||
await self._broadcast_series_deleted(key, series_name)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to broadcast series_deleted for %s: %s",
|
||||
key, exc,
|
||||
)
|
||||
|
||||
# --- Filesystem deletion ---
|
||||
if delete_folder and folder_path:
|
||||
# Resolve absolute path and validate it is within base directory
|
||||
abs_folder = _os.path.abspath(folder_path)
|
||||
base_dir = _os.path.abspath(self._directory)
|
||||
|
||||
if not is_safe_path(base_dir, abs_folder):
|
||||
logger.warning(
|
||||
"Blocked unsafe folder delete attempt: key=%s path=%s base=%s",
|
||||
key, abs_folder, base_dir,
|
||||
)
|
||||
result.folder_error = (
|
||||
f"Path '{abs_folder}' is outside the anime directory "
|
||||
f"'{base_dir}' and will not be deleted."
|
||||
)
|
||||
result.success = False
|
||||
elif not _os.path.isdir(abs_folder):
|
||||
logger.warning(
|
||||
"Delete folder skipped - path does not exist: key=%s path=%s",
|
||||
key, abs_folder,
|
||||
)
|
||||
# Not an error; folder might never have existed
|
||||
else:
|
||||
try:
|
||||
logger.info(
|
||||
"Deleting series folder: key=%s path=%s",
|
||||
key, abs_folder,
|
||||
)
|
||||
shutil.rmtree(abs_folder)
|
||||
logger.info(
|
||||
"Deleted series folder: key=%s path=%s",
|
||||
key, abs_folder,
|
||||
)
|
||||
result.deleted_folder = True
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to delete series folder: key=%s path=%s error=%s",
|
||||
key, abs_folder, str(exc),
|
||||
)
|
||||
result.folder_error = str(exc)
|
||||
result.success = False
|
||||
|
||||
# --- Build message ---
|
||||
parts = []
|
||||
if result.deleted_from_database and not result.database_error:
|
||||
parts.append("removed from database")
|
||||
if result.deleted_folder and not result.folder_error:
|
||||
parts.append("folder deleted from filesystem")
|
||||
if result.database_error:
|
||||
parts.append(f"database error: {result.database_error}")
|
||||
if result.folder_error:
|
||||
parts.append(f"folder error: {result.folder_error}")
|
||||
|
||||
if parts:
|
||||
result.message = "; ".join(parts)
|
||||
else:
|
||||
result.message = "No action taken."
|
||||
|
||||
logger.info(
|
||||
"Delete series completed: key=%s deleted_db=%s deleted_folder=%s",
|
||||
key, result.deleted_from_database, result.deleted_folder,
|
||||
)
|
||||
return result
|
||||
|
||||
async def _broadcast_series_deleted(self, key: str, name: str) -> None:
|
||||
"""Broadcast series_deleted event via WebSocket."""
|
||||
try:
|
||||
await self._websocket_service.broadcast_series_deleted(
|
||||
key=key,
|
||||
name=name,
|
||||
)
|
||||
logger.info(
|
||||
"series_deleted broadcast sent: key=%s name=%s",
|
||||
key, name,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to broadcast series_deleted: key=%s error=%s",
|
||||
key, str(exc),
|
||||
)
|
||||
|
||||
|
||||
def get_anime_service(series_app: SeriesApp) -> AnimeService:
|
||||
"""Factory used for creating AnimeService with a SeriesApp instance."""
|
||||
return AnimeService(series_app)
|
||||
|
||||
|
||||
async def sync_legacy_series_to_db(
|
||||
anime_directory: str,
|
||||
log_instance=None # pylint: disable=unused-argument
|
||||
) -> int:
|
||||
"""
|
||||
One-time legacy sync: import any series from 'data' files
|
||||
not already in the database.
|
||||
|
||||
Deprecated: Series are now loaded directly from the database.
|
||||
This function remains for backwards compatibility with legacy
|
||||
file-based data during migration.
|
||||
|
||||
Args:
|
||||
anime_directory: Path to the anime directory with data files
|
||||
log_instance: Optional logger instance (unused, kept for API
|
||||
compatibility). This function always uses structlog internally.
|
||||
|
||||
Returns:
|
||||
Number of new series added to the database
|
||||
"""
|
||||
# Always use structlog for structured logging with keyword arguments
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
log.warning(
|
||||
"sync_legacy_series_to_db is deprecated. "
|
||||
"Series are now loaded directly from database."
|
||||
)
|
||||
|
||||
try:
|
||||
from src.server.database.connection import get_db_session
|
||||
from src.server.database.service import AnimeSeriesService, EpisodeService
|
||||
|
||||
log.info(
|
||||
"Starting data file to database sync",
|
||||
directory=anime_directory
|
||||
)
|
||||
|
||||
# Get all series from data files using SeriesApp
|
||||
series_app = SeriesApp(anime_directory)
|
||||
all_series = await asyncio.to_thread(
|
||||
series_app.get_all_series_from_data_files
|
||||
)
|
||||
|
||||
if not all_series:
|
||||
log.info("No series found in data files to sync")
|
||||
return 0
|
||||
|
||||
log.info(
|
||||
"Found series in data files, syncing to database",
|
||||
count=len(all_series)
|
||||
)
|
||||
|
||||
async with get_db_session() as db:
|
||||
added_count = 0
|
||||
skipped_count = 0
|
||||
for serie in all_series:
|
||||
# Handle series with empty name - use folder as fallback
|
||||
if not serie.name or not serie.name.strip():
|
||||
if serie.folder and serie.folder.strip():
|
||||
serie.name = serie.folder.strip()
|
||||
log.debug(
|
||||
"Using folder as name fallback",
|
||||
key=serie.key,
|
||||
folder=serie.folder
|
||||
)
|
||||
else:
|
||||
log.warning(
|
||||
"Skipping series with empty name and folder",
|
||||
key=serie.key
|
||||
)
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
# Check if series already exists in DB
|
||||
existing = await AnimeSeriesService.get_by_key(db, serie.key)
|
||||
if existing:
|
||||
log.debug(
|
||||
"Series already exists in database",
|
||||
name=serie.name,
|
||||
key=serie.key
|
||||
)
|
||||
continue
|
||||
|
||||
# Create new series in database
|
||||
anime_series = await AnimeSeriesService.create(
|
||||
db=db,
|
||||
key=serie.key,
|
||||
name=serie.name,
|
||||
site=serie.site,
|
||||
folder=serie.folder,
|
||||
year=serie.year if hasattr(serie, 'year') else None,
|
||||
)
|
||||
|
||||
# Create Episode records for each episode in episodeDict
|
||||
if serie.episodeDict:
|
||||
for season, episode_numbers in serie.episodeDict.items():
|
||||
for episode_number in episode_numbers:
|
||||
await EpisodeService.create(
|
||||
db=db,
|
||||
series_id=anime_series.id,
|
||||
season=season,
|
||||
episode_number=episode_number,
|
||||
)
|
||||
|
||||
added_count += 1
|
||||
log.debug(
|
||||
"Added series to database",
|
||||
name=serie.name,
|
||||
key=serie.key
|
||||
)
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
log.warning(
|
||||
"Failed to add series to database",
|
||||
key=serie.key,
|
||||
name=serie.name,
|
||||
error=str(e)
|
||||
)
|
||||
skipped_count += 1
|
||||
|
||||
log.info(
|
||||
"Data file sync complete",
|
||||
added=added_count,
|
||||
skipped=len(all_series) - added_count
|
||||
)
|
||||
return added_count
|
||||
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
log.warning(
|
||||
"Failed to sync series to database",
|
||||
error=str(e),
|
||||
exc_info=True
|
||||
)
|
||||
return 0
|
||||
|
||||
@@ -12,6 +12,7 @@ can call it from async routes via threadpool if needed.
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Dict, Optional
|
||||
|
||||
@@ -88,6 +89,8 @@ class AuthService:
|
||||
self.lockout_seconds = 300 # 5 minutes
|
||||
self.token_expiry_hours = settings.token_expiry_hours or 24
|
||||
self.secret = settings.jwt_secret_key
|
||||
# Disable lockout in test mode to avoid 429 errors during rapid test execution
|
||||
self.disable_lockout = os.getenv("ANIWORLD_TESTING") == "1"
|
||||
|
||||
# --- password helpers ---
|
||||
def _hash_password(self, password: str) -> str:
|
||||
@@ -109,7 +112,22 @@ class AuthService:
|
||||
return False
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
return bool(self._hash)
|
||||
# Always re-read from config to detect if reset happened
|
||||
hash_val = None
|
||||
try:
|
||||
from src.server.services.config_service import get_config_service
|
||||
config_service = get_config_service()
|
||||
config = config_service.load_config()
|
||||
hash_val = config.other.get('master_password_hash')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if isinstance(hash_val, str):
|
||||
self._hash = hash_val
|
||||
return True
|
||||
# No hash in config - clear any stale in-memory hash
|
||||
self._hash = None
|
||||
return False
|
||||
|
||||
def setup_master_password(self, password: str) -> str:
|
||||
"""Set the master password (hash and store in memory/settings).
|
||||
@@ -173,6 +191,8 @@ class AuthService:
|
||||
)
|
||||
|
||||
def _record_failure(self, identifier: str) -> None:
|
||||
if self.disable_lockout:
|
||||
return
|
||||
rec = self._get_fail_record(identifier)
|
||||
rec["count"] += 1
|
||||
rec["last"] = datetime.now(timezone.utc)
|
||||
@@ -186,19 +206,14 @@ class AuthService:
|
||||
self._failed.pop(identifier, None)
|
||||
|
||||
def _check_locked(self, identifier: str) -> None:
|
||||
if self.disable_lockout:
|
||||
return
|
||||
rec = self._get_fail_record(identifier)
|
||||
lu = rec.get("locked_until")
|
||||
if lu and datetime.now(timezone.utc) < lu:
|
||||
raise LockedOutError(
|
||||
"Too many failed attempts - temporarily locked out"
|
||||
)
|
||||
if lu and datetime.now(timezone.utc) >= lu:
|
||||
# lock expired, reset
|
||||
self._failed[identifier] = {
|
||||
"count": 0,
|
||||
"last": None,
|
||||
"locked_until": None,
|
||||
}
|
||||
|
||||
# --- authentication ---
|
||||
def validate_master_password(
|
||||
@@ -266,6 +281,15 @@ class AuthService:
|
||||
# to a revocation list.
|
||||
return None
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset authentication state to unconfigured.
|
||||
|
||||
Clears the in-memory hash. Does NOT persist - caller should also
|
||||
clear the config file if persistent reset is needed.
|
||||
"""
|
||||
self._hash = None
|
||||
self._failed.clear()
|
||||
|
||||
|
||||
# Singleton service instance for import convenience
|
||||
auth_service = AuthService()
|
||||
|
||||
@@ -14,17 +14,16 @@ Key Features:
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import structlog
|
||||
|
||||
from src.server.services.websocket_service import WebSocketService
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LoadingStatus(str, Enum):
|
||||
@@ -121,8 +120,8 @@ class BackgroundLoaderService:
|
||||
self._shutdown = False
|
||||
|
||||
logger.info(
|
||||
"BackgroundLoaderService initialized",
|
||||
extra={"max_concurrent_loads": max_concurrent_loads}
|
||||
"BackgroundLoaderService initialized max_concurrent_loads=%s",
|
||||
max_concurrent_loads
|
||||
)
|
||||
|
||||
async def start(self) -> None:
|
||||
@@ -140,8 +139,8 @@ class BackgroundLoaderService:
|
||||
self.worker_tasks.append(worker)
|
||||
|
||||
logger.info(
|
||||
"Background workers started",
|
||||
extra={"num_workers": len(self.worker_tasks)}
|
||||
"Background workers started num_workers=%s",
|
||||
len(self.worker_tasks)
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
@@ -164,8 +163,8 @@ class BackgroundLoaderService:
|
||||
for i, result in enumerate(results):
|
||||
if isinstance(result, Exception) and not isinstance(result, asyncio.CancelledError):
|
||||
logger.error(
|
||||
f"Worker {i} stopped with exception",
|
||||
extra={"exception": str(result)}
|
||||
"Worker %s stopped with exception exception=%s",
|
||||
i, str(result)
|
||||
)
|
||||
|
||||
self.worker_tasks = []
|
||||
@@ -202,10 +201,15 @@ class BackgroundLoaderService:
|
||||
self.active_tasks[key] = task
|
||||
await self.task_queue.put(task)
|
||||
|
||||
logger.info("Added loading task for series: %s", key)
|
||||
import logging
|
||||
_task_logger = logging.getLogger(__name__)
|
||||
_task_logger.info("Added loading task for series: %s", key)
|
||||
|
||||
# Broadcast initial status
|
||||
await self._broadcast_status(task)
|
||||
try:
|
||||
await self._broadcast_status(task)
|
||||
except Exception as e:
|
||||
_task_logger.warning("Failed to broadcast initial status: %s", e)
|
||||
|
||||
async def check_missing_data(
|
||||
self,
|
||||
@@ -288,7 +292,8 @@ class BackgroundLoaderService:
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Worker {worker_id} processing loading task for series: {task.key}"
|
||||
"Worker %s processing loading task for series: %s",
|
||||
worker_id, task.key
|
||||
)
|
||||
|
||||
# Process the task
|
||||
@@ -304,7 +309,10 @@ class BackgroundLoaderService:
|
||||
logger.info("Worker %s task cancelled", worker_id)
|
||||
break
|
||||
except Exception as e:
|
||||
logger.exception("Error in background worker %s: %s", worker_id, e)
|
||||
logger.error(
|
||||
"Error in background worker %s: %s",
|
||||
worker_id, str(e)
|
||||
)
|
||||
# Continue processing other tasks
|
||||
continue
|
||||
|
||||
@@ -497,24 +505,55 @@ class BackgroundLoaderService:
|
||||
|
||||
async def _load_nfo_and_images(self, task: SeriesLoadingTask, db: Any) -> bool:
|
||||
"""Load NFO file and images for a series.
|
||||
|
||||
Note: NFO service has been removed. This method now just marks
|
||||
progress as False since NFO handling moved to server layer.
|
||||
|
||||
|
||||
Downloads poster.jpg, fanart.jpg, and logo.png from TMDB
|
||||
using the ImageLoadingService.
|
||||
|
||||
Args:
|
||||
task: The loading task
|
||||
db: Database session
|
||||
|
||||
|
||||
Returns:
|
||||
bool: Always False since NFO service removed
|
||||
bool: True if any images were loaded, False otherwise
|
||||
"""
|
||||
task.status = LoadingStatus.LOADING_NFO
|
||||
await self._broadcast_status(task, "NFO loading disabled...")
|
||||
|
||||
task.progress["nfo"] = False
|
||||
task.progress["logo"] = False
|
||||
task.progress["images"] = False
|
||||
return False
|
||||
await self._broadcast_status(task, "Loading images...")
|
||||
|
||||
try:
|
||||
from src.server.nfo.tmdb_client import get_tmdb_client
|
||||
from src.server.services.image_loading_service import (
|
||||
init_image_loading_service,
|
||||
)
|
||||
|
||||
tmdb_client = get_tmdb_client()
|
||||
image_service = init_image_loading_service(tmdb_client)
|
||||
|
||||
result = await image_service.load_series_images(
|
||||
key=task.key,
|
||||
folder=task.folder,
|
||||
anime_directory=self.series_app.directory_to_search,
|
||||
db=db,
|
||||
)
|
||||
|
||||
task.progress["nfo"] = True # NFO was already created earlier in the flow
|
||||
task.progress["logo"] = result.get("logo", False)
|
||||
task.progress["images"] = result.get("poster", False) and result.get("fanart", False)
|
||||
|
||||
logger.info(
|
||||
"Images loaded for series %s: poster=%s fanart=%s logo=%s",
|
||||
task.key,
|
||||
result.get("poster", False),
|
||||
result.get("fanart", False),
|
||||
result.get("logo", False),
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load images for series %s: %s", task.key, e)
|
||||
task.progress["nfo"] = True
|
||||
task.progress["logo"] = False
|
||||
task.progress["images"] = False
|
||||
return False
|
||||
|
||||
async def _scan_missing_episodes(self, task: SeriesLoadingTask, db: Any) -> None:
|
||||
"""Scan for missing episodes after NFO creation.
|
||||
|
||||
@@ -532,6 +532,15 @@ class DownloadService:
|
||||
"Queue progress already initialized by concurrent task"
|
||||
)
|
||||
self._queue_progress_initialized = True
|
||||
# Handle broken pipe / connection errors from WebSocket broadcast
|
||||
# These are non-fatal and should not fail the queue operation
|
||||
elif isinstance(e, OSError) and e.errno == 32:
|
||||
logger.warning(
|
||||
"Queue progress broadcast failed (broken pipe) - "
|
||||
"continuing without progress tracking: %s",
|
||||
e,
|
||||
)
|
||||
self._queue_progress_initialized = True
|
||||
else:
|
||||
logger.error("Failed to initialize queue progress: %s", e)
|
||||
|
||||
@@ -674,17 +683,10 @@ class DownloadService:
|
||||
episode=episode.episode,
|
||||
)
|
||||
|
||||
# Notify via progress service
|
||||
queue_status = await self.get_queue_status()
|
||||
await self._progress_service.update_progress(
|
||||
progress_id="download_queue",
|
||||
message=f"Added {len(created_ids)} items to queue",
|
||||
metadata={
|
||||
"action": "items_added",
|
||||
"added_ids": created_ids,
|
||||
"queue_status": queue_status.model_dump(mode="json"),
|
||||
},
|
||||
force_broadcast=True,
|
||||
logger.info(
|
||||
"Added items to queue",
|
||||
count=len(created_ids),
|
||||
serie_key=serie_id,
|
||||
)
|
||||
|
||||
return created_ids
|
||||
@@ -731,9 +733,7 @@ class DownloadService:
|
||||
# Delete from database
|
||||
await self._delete_from_database(item_id)
|
||||
removed_ids.append(item_id)
|
||||
logger.info(
|
||||
"Removed from pending queue", item_id=item_id
|
||||
)
|
||||
logger.info("Removed from pending queue item_id=%s", item_id)
|
||||
|
||||
if removed_ids:
|
||||
# Notify via progress service
|
||||
@@ -803,7 +803,7 @@ class DownloadService:
|
||||
force_broadcast=True,
|
||||
)
|
||||
|
||||
logger.info("Queue reordered", reordered_count=len(item_ids))
|
||||
logger.info("Queue reordered reordered_count=%s", len(item_ids))
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to reorder queue: %s", e)
|
||||
@@ -828,8 +828,8 @@ class DownloadService:
|
||||
# Initialize queue progress tracking if not already done
|
||||
await self._init_queue_progress()
|
||||
|
||||
# Check if download already active
|
||||
if self._active_download:
|
||||
# Check if queue is already running
|
||||
if not self._is_stopped:
|
||||
raise DownloadServiceError(
|
||||
"Queue processing is already active"
|
||||
)
|
||||
@@ -1036,7 +1036,7 @@ class DownloadService:
|
||||
"""
|
||||
count = len(self._completed_items)
|
||||
self._completed_items.clear()
|
||||
logger.info("Cleared completed items", count=count)
|
||||
logger.info("Cleared completed items count=%s", count)
|
||||
|
||||
# Notify via progress service
|
||||
if count > 0:
|
||||
@@ -1062,7 +1062,7 @@ class DownloadService:
|
||||
"""
|
||||
count = len(self._failed_items)
|
||||
self._failed_items.clear()
|
||||
logger.info("Cleared failed items", count=count)
|
||||
logger.info("Cleared failed items count=%s", count)
|
||||
|
||||
# Notify via progress service
|
||||
if count > 0:
|
||||
@@ -1088,27 +1088,17 @@ class DownloadService:
|
||||
"""
|
||||
count = len(self._pending_queue)
|
||||
|
||||
# Delete all pending items from database
|
||||
for item_id in list(self._pending_items_by_id.keys()):
|
||||
await self._delete_from_database(item_id)
|
||||
# Only try to delete from DB if there are items
|
||||
if count > 0:
|
||||
for item_id in list(self._pending_items_by_id.keys()):
|
||||
try:
|
||||
await self._delete_from_database(item_id)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to delete item %s: %s", item_id, e)
|
||||
|
||||
self._pending_queue.clear()
|
||||
self._pending_items_by_id.clear()
|
||||
logger.info("Cleared pending items", count=count)
|
||||
|
||||
# Notify via progress service
|
||||
if count > 0:
|
||||
queue_status = await self.get_queue_status()
|
||||
await self._progress_service.update_progress(
|
||||
progress_id="download_queue",
|
||||
message=f"Cleared {count} pending items",
|
||||
metadata={
|
||||
"action": "pending_cleared",
|
||||
"cleared_count": count,
|
||||
"queue_status": queue_status.model_dump(mode="json"),
|
||||
},
|
||||
force_broadcast=True,
|
||||
)
|
||||
self._pending_by_episode.clear()
|
||||
|
||||
return count
|
||||
|
||||
|
||||
205
src/server/services/folder_naming_service.py
Normal file
205
src/server/services/folder_naming_service.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""Folder naming service for fixing missing years in anime folder names."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import structlog
|
||||
|
||||
from src.config.settings import settings
|
||||
from src.server.database.connection import get_db_session as _get_db_session
|
||||
from src.server.database.service import AnimeSeriesService
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FolderRenameResult:
|
||||
key: str
|
||||
old_folder: str
|
||||
new_folder: Optional[str]
|
||||
success: bool
|
||||
skipped: bool = False
|
||||
reason: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class FolderNamingReport:
|
||||
total: int
|
||||
renamed: int
|
||||
skipped: int
|
||||
errors: int
|
||||
results: List[FolderRenameResult]
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"total": self.total,
|
||||
"renamed": self.renamed,
|
||||
"skipped": self.skipped,
|
||||
"errors": self.errors,
|
||||
"results": [
|
||||
{
|
||||
"key": r.key,
|
||||
"old_folder": r.old_folder,
|
||||
"new_folder": r.new_folder,
|
||||
"success": r.success,
|
||||
"skipped": r.skipped,
|
||||
"reason": r.reason,
|
||||
}
|
||||
for r in self.results
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class FolderNamingService:
|
||||
def __init__(self) -> None:
|
||||
self._is_running = False
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def run(self) -> FolderNamingReport:
|
||||
async with self._lock:
|
||||
if self._is_running:
|
||||
logger.warning("FolderNamingService.run() called while already running")
|
||||
return FolderNamingReport(total=0, renamed=0, skipped=0, errors=0, results=[])
|
||||
self._is_running = True
|
||||
|
||||
try:
|
||||
logger.info("FolderNamingService: starting folder naming scan")
|
||||
results: List[FolderRenameResult] = []
|
||||
|
||||
async with _get_db_session() as db:
|
||||
all_series = await AnimeSeriesService.get_all(db)
|
||||
|
||||
for series in all_series:
|
||||
result = await self._process_series(series)
|
||||
results.append(result)
|
||||
|
||||
renamed = sum(1 for r in results if r.success and not r.skipped)
|
||||
skipped = sum(1 for r in results if r.skipped)
|
||||
errors = sum(1 for r in results if not r.skipped and not r.success)
|
||||
|
||||
report = FolderNamingReport(
|
||||
total=len(results),
|
||||
renamed=renamed,
|
||||
skipped=skipped,
|
||||
errors=errors,
|
||||
results=results,
|
||||
)
|
||||
logger.info(
|
||||
"FolderNamingService: scan complete — total=%d renamed=%d skipped=%d errors=%d",
|
||||
report.total, report.renamed, report.skipped, report.errors,
|
||||
)
|
||||
return report
|
||||
finally:
|
||||
self._is_running = False
|
||||
|
||||
async def _process_series(self, series) -> FolderRenameResult:
|
||||
key = series.key
|
||||
folder = series.folder or ""
|
||||
year = getattr(series, "year", None)
|
||||
|
||||
if year is None:
|
||||
return FolderRenameResult(key=key, old_folder=folder, new_folder=None, success=True, skipped=True, reason="no year in DB record")
|
||||
|
||||
if self._folder_has_year(folder):
|
||||
return FolderRenameResult(key=key, old_folder=folder, new_folder=None, success=True, skipped=True, reason="folder already has year")
|
||||
|
||||
target_folder = self._build_target_folder(folder, year)
|
||||
|
||||
# Safety: re-extract year from target to prevent double-year
|
||||
if target_folder != folder:
|
||||
extracted = self._extract_year_from_folder_name(target_folder)
|
||||
if extracted != year:
|
||||
logger.error("Safety guard for %s: target '%s' year=%s != DB year=%s — skipping", key, target_folder, extracted, year)
|
||||
return FolderRenameResult(key=key, old_folder=folder, new_folder=None, success=False, skipped=True, reason="safety guard: target year mismatch")
|
||||
|
||||
return await self._execute_rename(series, folder, target_folder)
|
||||
|
||||
async def _execute_rename(self, series, old_folder: str, target_folder: str) -> FolderRenameResult:
|
||||
key = series.key
|
||||
|
||||
if old_folder == target_folder:
|
||||
return FolderRenameResult(key=key, old_folder=old_folder, new_folder=None, success=True, skipped=True, reason="same folder name")
|
||||
|
||||
anime_dir = settings.anime_directory
|
||||
old_path = os.path.join(anime_dir, old_folder)
|
||||
target_path = os.path.join(anime_dir, target_folder)
|
||||
|
||||
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 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:
|
||||
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
|
||||
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)
|
||||
|
||||
return FolderRenameResult(key=key, old_folder=old_folder, new_folder=target_folder, success=True, skipped=False)
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("Failed to rename folder for %s (%s -> %s): %s", key, old_folder, target_folder, exc)
|
||||
return FolderRenameResult(key=key, old_folder=old_folder, new_folder=None, success=False, skipped=False, reason=str(exc))
|
||||
|
||||
# Static helpers — public for direct testing
|
||||
@staticmethod
|
||||
def _folder_has_year(folder_name: str) -> bool:
|
||||
if not folder_name:
|
||||
return False
|
||||
return bool(re.search(r"\(\d{4}\)", folder_name))
|
||||
|
||||
@staticmethod
|
||||
def _extract_year_from_folder_name(folder_name: str) -> Optional[int]:
|
||||
if not folder_name:
|
||||
return None
|
||||
match = re.search(r"\((\d{4})\)", folder_name)
|
||||
if match:
|
||||
try:
|
||||
year = int(match.group(1))
|
||||
if 1900 <= year <= 2100:
|
||||
return year
|
||||
except ValueError:
|
||||
pass
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_title_from_folder_name(folder_name: str) -> str:
|
||||
return re.sub(r"\s*\(\d{4}\)\s*$", "", folder_name).strip()
|
||||
|
||||
@staticmethod
|
||||
def _build_target_folder(folder_name: str, year: int) -> str:
|
||||
title = FolderNamingService._extract_title_from_folder_name(folder_name)
|
||||
return f"{title} ({year})"
|
||||
|
||||
|
||||
_folder_naming_service: Optional[FolderNamingService] = None
|
||||
|
||||
def get_folder_naming_service() -> FolderNamingService:
|
||||
global _folder_naming_service
|
||||
if _folder_naming_service is None:
|
||||
_folder_naming_service = FolderNamingService()
|
||||
return _folder_naming_service
|
||||
|
||||
def reset_folder_naming_service() -> None:
|
||||
global _folder_naming_service
|
||||
_folder_naming_service = None
|
||||
387
src/server/services/image_loading_service.py
Normal file
387
src/server/services/image_loading_service.py
Normal file
@@ -0,0 +1,387 @@
|
||||
"""Image loading service for downloading series artwork from TMDB.
|
||||
|
||||
This service downloads poster.jpg, fanart.jpg, and logo.png images
|
||||
for anime series using TMDB as the image source.
|
||||
|
||||
Integrated with:
|
||||
- BackgroundLoaderService: triggered when adding new anime
|
||||
- SchedulerService: triggered during scheduled rescan
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import structlog
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.server.database.service import AnimeSeriesService
|
||||
from src.server.nfo.tmdb_client import TMDBClient
|
||||
from src.server.utils.image_downloader import ImageDownloader
|
||||
from src.server.utils.media import FANART_FILENAME, LOGO_FILENAME, POSTER_FILENAME
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class ImageLoadingServiceError(Exception):
|
||||
"""Exception raised for image loading failures."""
|
||||
|
||||
|
||||
class ImageLoadingService:
|
||||
"""Service for loading series images from TMDB.
|
||||
|
||||
Downloads poster.jpg, fanart.jpg, and logo.png for anime series
|
||||
using TMDB as the image source. Images are saved to the series
|
||||
folder alongside tvshow.nfo.
|
||||
|
||||
Attributes:
|
||||
tmdb_client: TMDB API client for fetching image URLs
|
||||
image_downloader: Downloader for saving images to disk
|
||||
"""
|
||||
|
||||
# Batch size for scheduler bulk processing
|
||||
BATCH_SIZE = 10
|
||||
|
||||
def __init__(self, tmdb_client: TMDBClient):
|
||||
"""Initialize the image loading service.
|
||||
|
||||
Args:
|
||||
tmdb_client: TMDB API client for fetching image metadata
|
||||
"""
|
||||
self._tmdb_client = tmdb_client
|
||||
|
||||
async def load_series_images(
|
||||
self,
|
||||
key: str,
|
||||
folder: str,
|
||||
anime_directory: str,
|
||||
db: AsyncSession,
|
||||
) -> Dict[str, bool]:
|
||||
"""Load all images for a single series.
|
||||
|
||||
Downloads poster.jpg, fanart.jpg, and logo.png from TMDB
|
||||
if they don't already exist in the series folder.
|
||||
|
||||
Args:
|
||||
key: Series unique identifier (provider key)
|
||||
folder: Series folder name (metadata, for path construction)
|
||||
anime_directory: Base anime directory path
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Dict with download status for each image type:
|
||||
{
|
||||
"poster": bool, # True if poster.jpg exists/downloads succeeded
|
||||
"fanart": bool, # True if fanart.jpg exists/downloads succeeded
|
||||
"logo": bool # True if logo.png exists/downloads succeeded
|
||||
}
|
||||
"""
|
||||
series_dir = Path(anime_directory) / folder
|
||||
|
||||
if not series_dir.exists():
|
||||
logger.warning(
|
||||
"Series directory not found, cannot load images",
|
||||
key=key,
|
||||
folder=folder,
|
||||
path=str(series_dir),
|
||||
)
|
||||
return {"poster": False, "fanart": False, "logo": False}
|
||||
|
||||
# Get series from database to retrieve TMDB ID
|
||||
series = await AnimeSeriesService.get_by_key(db, key)
|
||||
if not series:
|
||||
logger.warning("Series not found in database key=%s", key)
|
||||
return {"poster": False, "fanart": False, "logo": False}
|
||||
|
||||
if not series.tmdb_id:
|
||||
logger.warning(
|
||||
"Series has no TMDB ID, cannot load images key=%s name=%s",
|
||||
key, series.name,
|
||||
)
|
||||
return {"poster": False, "fanart": False, "logo": False}
|
||||
|
||||
try:
|
||||
# Fetch image metadata from TMDB
|
||||
images_data = await self._tmdb_client.get_tv_show_images(series.tmdb_id)
|
||||
|
||||
poster_url, logo_url, fanart_url = self._select_best_images(images_data)
|
||||
|
||||
# Download images
|
||||
results = await self._download_images(
|
||||
series_dir, poster_url, logo_url, fanart_url
|
||||
)
|
||||
|
||||
# Update database flags
|
||||
await self._update_series_flags(db, series, results)
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Failed to load images for series: %s", key, exc_info=e)
|
||||
return {"poster": False, "fanart": False, "logo": False}
|
||||
|
||||
async def load_series_images_batch(
|
||||
self,
|
||||
series_list: List[Dict[str, Any]],
|
||||
anime_directory: str,
|
||||
db: AsyncSession,
|
||||
) -> Dict[str, int]:
|
||||
"""Load images for multiple series in batches.
|
||||
|
||||
Used by scheduler to process many series efficiently.
|
||||
|
||||
Args:
|
||||
series_list: List of dicts with 'key' and 'folder' for each series
|
||||
anime_directory: Base anime directory path
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Dict with counts:
|
||||
{
|
||||
"total": int, # Total series processed
|
||||
"success": int, # Series with all images loaded
|
||||
"partial": int, # Series with some images loaded
|
||||
"failed": int, # Series with no images loaded
|
||||
"poster": int, # Count of poster.jpg downloads
|
||||
"fanart": int, # Count of fanart.jpg downloads
|
||||
"logo": int, # Count of logo.png downloads
|
||||
}
|
||||
"""
|
||||
stats = {
|
||||
"total": len(series_list),
|
||||
"success": 0,
|
||||
"partial": 0,
|
||||
"failed": 0,
|
||||
"poster": 0,
|
||||
"fanart": 0,
|
||||
"logo": 0,
|
||||
}
|
||||
|
||||
# Process in batches to respect TMDB rate limits
|
||||
for i in range(0, len(series_list), self.BATCH_SIZE):
|
||||
batch = series_list[i : i + self.BATCH_SIZE]
|
||||
|
||||
# Process each series sequentially to avoid concurrent use of the
|
||||
# same AsyncSession (SQLAlchemy async sessions are not thread-safe
|
||||
# for concurrent operations). BATCH_SIZE still paces TMDB requests.
|
||||
results: List[Dict[str, Any] | Exception] = []
|
||||
for series in batch:
|
||||
result = await self.load_series_images(
|
||||
key=series["key"],
|
||||
folder=series["folder"],
|
||||
anime_directory=anime_directory,
|
||||
db=db,
|
||||
)
|
||||
results.append(result)
|
||||
|
||||
for series, result in zip(batch, results):
|
||||
if isinstance(result, Exception):
|
||||
logger.warning(
|
||||
"Image loading failed for series: %s",
|
||||
series["key"],
|
||||
exc_info=result,
|
||||
)
|
||||
stats["failed"] += 1
|
||||
continue
|
||||
|
||||
if result["poster"] and result["fanart"] and result["logo"]:
|
||||
stats["success"] += 1
|
||||
elif result["poster"] or result["fanart"] or result["logo"]:
|
||||
stats["partial"] += 1
|
||||
else:
|
||||
stats["failed"] += 1
|
||||
|
||||
if result["poster"]:
|
||||
stats["poster"] += 1
|
||||
if result["fanart"]:
|
||||
stats["fanart"] += 1
|
||||
if result["logo"]:
|
||||
stats["logo"] += 1
|
||||
|
||||
# Small delay between batches to avoid overwhelming TMDB
|
||||
if i + self.BATCH_SIZE < len(series_list):
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
logger.info(
|
||||
"Batch image loading completed",
|
||||
total=stats["total"],
|
||||
success=stats["success"],
|
||||
partial=stats["partial"],
|
||||
failed=stats["failed"],
|
||||
)
|
||||
|
||||
return stats
|
||||
|
||||
def _select_best_images(
|
||||
self, images_data: Dict[str, Any]
|
||||
) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
||||
"""Select the best available images from TMDB data.
|
||||
|
||||
Args:
|
||||
images_data: TMDB images response with 'posters', 'backdrops', 'logos'
|
||||
|
||||
Returns:
|
||||
Tuple of (poster_url, logo_url, fanart_url) - URLs or None if not available
|
||||
"""
|
||||
poster_url = None
|
||||
logo_url = None
|
||||
fanart_url = None
|
||||
|
||||
# Select poster: prefer English, otherwise take first available
|
||||
posters = images_data.get("posters", [])
|
||||
for poster in posters:
|
||||
if poster.get("iso_639_1") == "en" or poster.get("iso_639_1") is None:
|
||||
poster_url = self._tmdb_client.get_image_url(poster["file_path"])
|
||||
break
|
||||
if not poster_url and posters:
|
||||
poster_url = self._tmdb_client.get_image_url(posters[0]["file_path"])
|
||||
|
||||
# Select logo/clearlogo: prefer English with transparent background
|
||||
logos = images_data.get("logos", [])
|
||||
for logo in logos:
|
||||
if logo.get("iso_639_1") == "en":
|
||||
logo_url = self._tmdb_client.get_image_url(logo["file_path"])
|
||||
break
|
||||
if not logo_url and logos:
|
||||
logo_url = self._tmdb_client.get_image_url(logos[0]["file_path"])
|
||||
|
||||
# Select fanart/backdrop: prefer English
|
||||
backdrops = images_data.get("backdrops", [])
|
||||
for backdrop in backdrops:
|
||||
if backdrop.get("iso_639_1") == "en":
|
||||
fanart_url = self._tmdb_client.get_image_url(backdrop["file_path"])
|
||||
break
|
||||
if not fanart_url and backdrops:
|
||||
fanart_url = self._tmdb_client.get_image_url(backdrops[0]["file_path"])
|
||||
|
||||
return poster_url, logo_url, fanart_url
|
||||
|
||||
async def _download_images(
|
||||
self,
|
||||
series_dir: Path,
|
||||
poster_url: Optional[str],
|
||||
logo_url: Optional[str],
|
||||
fanart_url: Optional[str],
|
||||
) -> Dict[str, bool]:
|
||||
"""Download images to series directory.
|
||||
|
||||
Args:
|
||||
series_dir: Path to series folder
|
||||
poster_url: URL for poster.jpg
|
||||
logo_url: URL for logo.png
|
||||
fanart_url: URL for fanart.jpg
|
||||
|
||||
Returns:
|
||||
Dict with download status for each image
|
||||
"""
|
||||
results = {"poster": False, "fanart": False, "logo": False}
|
||||
|
||||
async with ImageDownloader() as downloader:
|
||||
tasks = []
|
||||
|
||||
if poster_url:
|
||||
tasks.append(
|
||||
self._download_and_track(
|
||||
downloader, poster_url, series_dir / POSTER_FILENAME, "poster", results
|
||||
)
|
||||
)
|
||||
if logo_url:
|
||||
tasks.append(
|
||||
self._download_and_track(
|
||||
downloader, logo_url, series_dir / LOGO_FILENAME, "logo", results
|
||||
)
|
||||
)
|
||||
if fanart_url:
|
||||
tasks.append(
|
||||
self._download_and_track(
|
||||
downloader, fanart_url, series_dir / FANART_FILENAME, "fanart", results
|
||||
)
|
||||
)
|
||||
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
return results
|
||||
|
||||
async def _download_and_track(
|
||||
self,
|
||||
downloader: ImageDownloader,
|
||||
url: str,
|
||||
local_path: Path,
|
||||
key: str,
|
||||
results: Dict[str, bool],
|
||||
) -> None:
|
||||
"""Download single image and track result.
|
||||
|
||||
Args:
|
||||
downloader: ImageDownloader instance
|
||||
url: Image URL
|
||||
local_path: Local destination path
|
||||
key: Result dict key ('poster', 'logo', 'fanart')
|
||||
results: Dict to update with result
|
||||
"""
|
||||
try:
|
||||
success = await downloader.download_image(
|
||||
url, local_path, skip_existing=True, validate=True
|
||||
)
|
||||
results[key] = success
|
||||
except Exception as e:
|
||||
logger.warning("Failed to download %s: %s", key, e)
|
||||
results[key] = False
|
||||
|
||||
async def _update_series_flags(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
series: Any,
|
||||
results: Dict[str, bool],
|
||||
) -> None:
|
||||
"""Update database flags after image loading.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
series: AnimeSeries instance
|
||||
results: Dict with download status for each image
|
||||
"""
|
||||
try:
|
||||
series.images_loaded = results["poster"] and results["fanart"]
|
||||
series.logo_loaded = results["logo"]
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to update series image flags: %s", e)
|
||||
await db.rollback()
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_image_loading_service: Optional[ImageLoadingService] = None
|
||||
|
||||
|
||||
def get_image_loading_service() -> ImageLoadingService:
|
||||
"""Get the image loading service singleton.
|
||||
|
||||
Returns:
|
||||
ImageLoadingService instance
|
||||
|
||||
Raises:
|
||||
RuntimeError: If service not initialized
|
||||
"""
|
||||
if _image_loading_service is None:
|
||||
raise RuntimeError(
|
||||
"ImageLoadingService not initialized. "
|
||||
"Call init_image_loading_service() first."
|
||||
)
|
||||
return _image_loading_service
|
||||
|
||||
|
||||
def init_image_loading_service(tmdb_client: TMDBClient) -> ImageLoadingService:
|
||||
"""Initialize the image loading service singleton.
|
||||
|
||||
Args:
|
||||
tmdb_client: TMDB API client
|
||||
|
||||
Returns:
|
||||
ImageLoadingService instance
|
||||
"""
|
||||
global _image_loading_service
|
||||
_image_loading_service = ImageLoadingService(tmdb_client=tmdb_client)
|
||||
return _image_loading_service
|
||||
@@ -9,7 +9,6 @@ import structlog
|
||||
|
||||
from src.config.settings import settings
|
||||
from src.server.database.service import AnimeSeriesService
|
||||
from src.server.services.anime_service import sync_legacy_series_to_db
|
||||
from src.server.services.setup_service import SetupService
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -215,6 +214,20 @@ async def _sync_anime_folders(progress_service=None) -> int:
|
||||
"""
|
||||
logger.info("Performing initial anime folder scan...")
|
||||
|
||||
# Check if anime directory exists before attempting sync
|
||||
if not settings.anime_directory or not os.path.isdir(settings.anime_directory):
|
||||
logger.info(
|
||||
"Anime directory not configured or does not exist, skipping data file sync"
|
||||
)
|
||||
if progress_service:
|
||||
await progress_service.update_progress(
|
||||
progress_id="series_sync",
|
||||
current=100,
|
||||
message="No anime directory configured, skipping data file sync",
|
||||
metadata={"step_id": "series_sync"}
|
||||
)
|
||||
return 0
|
||||
|
||||
if progress_service:
|
||||
await progress_service.update_progress(
|
||||
progress_id="series_sync",
|
||||
@@ -223,14 +236,15 @@ async def _sync_anime_folders(progress_service=None) -> int:
|
||||
metadata={"step_id": "series_sync"}
|
||||
)
|
||||
|
||||
sync_count = await sync_legacy_series_to_db(settings.anime_directory)
|
||||
logger.info("Data file sync complete. Added %d series.", sync_count)
|
||||
# Legacy sync removed - series are loaded directly from database via _load_series_into_memory
|
||||
sync_count = 0
|
||||
logger.info("Data file sync skipped - series loaded directly from database")
|
||||
|
||||
if progress_service:
|
||||
await progress_service.update_progress(
|
||||
progress_id="series_sync",
|
||||
current=75,
|
||||
message=f"Synced {sync_count} series from data files",
|
||||
message=f"Series loaded directly from database",
|
||||
metadata={"step_id": "series_sync"}
|
||||
)
|
||||
|
||||
@@ -257,7 +271,7 @@ async def _load_series_into_memory(progress_service=None) -> None:
|
||||
)
|
||||
|
||||
|
||||
async def _scan_folders_to_database(progress_service=None) -> int:
|
||||
async def _scan_folders_to_database(progress_service=None) -> tuple[int, int]:
|
||||
"""Scan anime folders and create AnimeSeries DB records.
|
||||
|
||||
This function runs during initial setup only. It delegates to
|
||||
@@ -271,7 +285,7 @@ async def _scan_folders_to_database(progress_service=None) -> int:
|
||||
progress_service: Optional ProgressService for progress updates
|
||||
|
||||
Returns:
|
||||
int: Number of new series created
|
||||
Tuple of (number of new series created, number of unresolved folders)
|
||||
"""
|
||||
logger.info("Scanning anime folders for new series...")
|
||||
|
||||
@@ -279,16 +293,17 @@ async def _scan_folders_to_database(progress_service=None) -> int:
|
||||
logger.info(
|
||||
"Anime directory not configured or does not exist, skipping folder scan"
|
||||
)
|
||||
return 0
|
||||
return 0, 0
|
||||
|
||||
# Use SetupService to handle the scanning and creation
|
||||
created_count = await SetupService.run()
|
||||
created_count, unresolved_count = await SetupService.run()
|
||||
|
||||
logger.info(
|
||||
"Folder scan complete",
|
||||
created=created_count
|
||||
created=created_count,
|
||||
unresolved=unresolved_count
|
||||
)
|
||||
return created_count
|
||||
return created_count, unresolved_count
|
||||
|
||||
|
||||
async def _validate_anime_directory(progress_service=None) -> bool:
|
||||
@@ -366,7 +381,7 @@ async def perform_initial_setup(progress_service=None):
|
||||
# Perform the actual initialization
|
||||
try:
|
||||
# Scan folders and create AnimeSeries records first
|
||||
folder_scan_count = await _scan_folders_to_database(progress_service)
|
||||
folder_scan_count, unresolved_count = await _scan_folders_to_database(progress_service)
|
||||
if folder_scan_count > 0:
|
||||
logger.info("Created %d series from anime folders", folder_scan_count)
|
||||
|
||||
@@ -383,11 +398,30 @@ async def perform_initial_setup(progress_service=None):
|
||||
# Mark the initial scan as completed
|
||||
await _mark_initial_scan_completed()
|
||||
|
||||
# Mark loading as complete in config (used by middleware to allow redirect to /login)
|
||||
# Only mark complete if there are no unresolved folders - otherwise user needs
|
||||
# to resolve them first via /setup/unresolved
|
||||
try:
|
||||
from src.server.services.config_service import get_config_service
|
||||
config_svc = get_config_service()
|
||||
init_config = config_svc.load_config()
|
||||
if unresolved_count == 0:
|
||||
init_config.other['loading_complete'] = True
|
||||
logger.info("No unresolved folders - marking loading complete")
|
||||
else:
|
||||
logger.info(
|
||||
"Unresolved folders exist (%d) - deferring loading_complete",
|
||||
unresolved_count
|
||||
)
|
||||
config_svc.save_config(init_config, create_backup=False)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to save loading_complete flag: %s", e)
|
||||
|
||||
# Load series into memory from database
|
||||
await _load_series_into_memory(progress_service)
|
||||
|
||||
# Run NFO scan as part of initialization
|
||||
await perform_nfo_scan_if_needed(progress_service)
|
||||
# NOTE: NFO scan is NO longer run here - it runs in a separate phase
|
||||
# after unresolved folders are completed (via /loading?phase=nfo)
|
||||
|
||||
return True
|
||||
|
||||
@@ -433,8 +467,8 @@ async def _execute_nfo_scan(progress_service=None) -> None:
|
||||
Args:
|
||||
progress_service: Optional ProgressService for emitting updates
|
||||
"""
|
||||
from src.server.services.anime_service import get_anime_service
|
||||
from src.server.services.nfo_scan_service import NfoScanService
|
||||
from src.server.utils.dependencies import get_anime_service
|
||||
|
||||
logger.info("Starting NFO scan...")
|
||||
|
||||
@@ -454,13 +488,8 @@ async def _execute_nfo_scan(progress_service=None) -> None:
|
||||
key=data.get('key'),
|
||||
folder=data.get('folder'),
|
||||
)
|
||||
elif event_data.get('type') == 'nfo_scan_completed':
|
||||
stats = event_data.get('statistics', {})
|
||||
if progress_service:
|
||||
await progress_service.complete_progress(
|
||||
progress_id="nfo_scan",
|
||||
message=f"NFO scan complete: {stats.get('created', 0)} created, {stats.get('updated', 0)} updated",
|
||||
)
|
||||
# Note: nfo_scan_completed event is NOT handled here because
|
||||
# perform_nfo_scan_phase handles completion after _execute_nfo_scan returns
|
||||
|
||||
nfo_service.subscribe_to_scan_events(nfo_event_handler)
|
||||
|
||||
@@ -522,16 +551,111 @@ async def perform_nfo_scan_if_needed(progress_service=None):
|
||||
|
||||
# Execute the NFO scan
|
||||
try:
|
||||
# Ensure any newly created series are loaded from DB into SeriesApp memory
|
||||
await _load_series_into_memory(progress_service=None)
|
||||
await _execute_nfo_scan(progress_service)
|
||||
await _mark_nfo_scan_completed()
|
||||
except Exception as e:
|
||||
logger.error("Failed to complete NFO scan: %s", e, exc_info=True)
|
||||
if progress_service:
|
||||
await progress_service.fail_progress(
|
||||
try:
|
||||
await progress_service.fail_progress(
|
||||
progress_id="nfo_scan",
|
||||
error_message=f"NFO scan failed: {str(e)}",
|
||||
metadata={"step_id": "nfo_scan"}
|
||||
)
|
||||
except Exception as fail_err:
|
||||
logger.warning(
|
||||
"Could not fail progress 'nfo_scan': %s",
|
||||
fail_err,
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
|
||||
async def perform_nfo_scan_phase(progress_service=None):
|
||||
"""Perform the NFO scan phase as part of the second loading page phase.
|
||||
|
||||
This is called when the loading page is accessed with ?phase=nfo query param.
|
||||
It runs the NFO scan and emits progress updates via the progress service.
|
||||
|
||||
Args:
|
||||
progress_service: Optional ProgressService for emitting updates
|
||||
"""
|
||||
logger.info("Starting NFO scan phase...")
|
||||
|
||||
if progress_service:
|
||||
from src.server.services.progress_service import ProgressType
|
||||
await progress_service.start_progress(
|
||||
progress_id="nfo_scan",
|
||||
progress_type=ProgressType.SCAN,
|
||||
title="Scanning NFO Files",
|
||||
total=100,
|
||||
message="Starting NFO scan...",
|
||||
metadata={"step_id": "nfo_scan", "phase": "nfo"}
|
||||
)
|
||||
|
||||
# Check if NFO scan was already completed
|
||||
is_nfo_scan_done = await _check_nfo_scan_status()
|
||||
|
||||
# Check if NFO features are configured
|
||||
if not await _is_nfo_scan_configured():
|
||||
message = (
|
||||
"Skipped - TMDB API key not configured"
|
||||
if not settings.tmdb_api_key
|
||||
else "Skipped - NFO features disabled"
|
||||
)
|
||||
logger.info("NFO scan phase skipped: %s", message)
|
||||
|
||||
if progress_service:
|
||||
await progress_service.complete_progress(
|
||||
progress_id="nfo_scan",
|
||||
error_message=f"NFO scan failed: {str(e)}",
|
||||
metadata={"step_id": "nfo_scan"}
|
||||
message=message,
|
||||
metadata={"step_id": "nfo_scan", "phase": "nfo", "nfo_scan_complete": True}
|
||||
)
|
||||
return
|
||||
|
||||
# Skip if already completed
|
||||
if is_nfo_scan_done:
|
||||
logger.info("Skipping NFO scan phase - already completed on previous run")
|
||||
if progress_service:
|
||||
await progress_service.complete_progress(
|
||||
progress_id="nfo_scan",
|
||||
message="Already completed",
|
||||
metadata={"step_id": "nfo_scan", "phase": "nfo", "nfo_scan_complete": True}
|
||||
)
|
||||
return
|
||||
|
||||
# Execute the NFO scan
|
||||
try:
|
||||
# Ensure any newly created series (e.g., from resolving unresolved folders)
|
||||
# are loaded from DB into SeriesApp memory before scanning
|
||||
await _load_series_into_memory(progress_service=None)
|
||||
await _execute_nfo_scan(progress_service)
|
||||
await _mark_nfo_scan_completed()
|
||||
|
||||
# Send completion event
|
||||
if progress_service:
|
||||
await progress_service.complete_progress(
|
||||
progress_id="nfo_scan",
|
||||
message="NFO scan completed successfully",
|
||||
metadata={"step_id": "nfo_scan", "phase": "nfo", "nfo_scan_complete": True}
|
||||
)
|
||||
logger.info("NFO scan phase completed successfully")
|
||||
except Exception as e:
|
||||
logger.error("Failed to complete NFO scan phase: %s", e, exc_info=True)
|
||||
if progress_service:
|
||||
try:
|
||||
await progress_service.fail_progress(
|
||||
progress_id="nfo_scan",
|
||||
error_message=f"NFO scan failed: {str(e)}",
|
||||
metadata={"step_id": "nfo_scan", "phase": "nfo"}
|
||||
)
|
||||
except Exception as fail_err:
|
||||
logger.warning(
|
||||
"Could not fail progress 'nfo_scan': %s",
|
||||
fail_err,
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
|
||||
async def _check_media_scan_status() -> bool:
|
||||
|
||||
@@ -130,7 +130,7 @@ class NfoScanService:
|
||||
else:
|
||||
handler(event_data)
|
||||
except Exception as e:
|
||||
logger.error("NFO scan event handler error", error=str(e))
|
||||
logger.error("NFO scan event handler error error=%s", str(e))
|
||||
|
||||
@property
|
||||
def is_scanning(self) -> bool:
|
||||
@@ -326,6 +326,22 @@ class NfoScanService:
|
||||
|
||||
nfo_exists = os.path.isfile(nfo_path)
|
||||
|
||||
# If tmdb_id is missing, try to look it up by series name
|
||||
if not series_data.get("tmdb_id"):
|
||||
logger.debug("No tmdb_id for %s — attempting TMDB lookup", key)
|
||||
name = series_data.get("name", "")
|
||||
found_tmdb_id = await self._lookup_tmdb_id_by_name(name)
|
||||
if found_tmdb_id:
|
||||
series_data["tmdb_id"] = found_tmdb_id
|
||||
await self._save_tmdb_id(key, found_tmdb_id)
|
||||
logger.info("Found and saved tmdb_id %s for %s", found_tmdb_id, key)
|
||||
else:
|
||||
logger.warning(
|
||||
"Could not resolve tmdb_id for %s (%s)",
|
||||
key,
|
||||
name,
|
||||
)
|
||||
|
||||
if not nfo_exists:
|
||||
# Create new NFO
|
||||
logger.info("Creating NFO for series: %s (%s)", key, folder)
|
||||
@@ -334,11 +350,16 @@ class NfoScanService:
|
||||
return "created"
|
||||
|
||||
# NFO exists — check if it needs updating
|
||||
updated = await self._update_nfo_if_needed(key, folder, series_data, nfo_path)
|
||||
updated, year = await self._update_nfo_if_needed(key, folder, series_data, nfo_path)
|
||||
if updated:
|
||||
await self._update_series_nfo_flag(key, has_nfo=True, nfo_path=nfo_path)
|
||||
await self._update_series_nfo_flag(key, has_nfo=True, nfo_path=nfo_path, year=year)
|
||||
return "updated"
|
||||
|
||||
# NFO is valid but series may still be missing year — try to extract from NFO
|
||||
if year is not None and series_data.get("year") is None:
|
||||
logger.info("Extracted year %d from NFO for %s — updating database", year, key)
|
||||
await self._update_series_nfo_flag(key, has_nfo=True, nfo_path=nfo_path, year=year)
|
||||
|
||||
return None
|
||||
|
||||
async def _create_nfo(
|
||||
@@ -405,7 +426,7 @@ class NfoScanService:
|
||||
folder: str,
|
||||
series_data: Dict[str, Any],
|
||||
nfo_path: str,
|
||||
) -> bool:
|
||||
) -> tuple[bool, Optional[int]]:
|
||||
"""Load existing NFO, check for missing fields, fill and rewrite.
|
||||
|
||||
Args:
|
||||
@@ -415,13 +436,13 @@ class NfoScanService:
|
||||
nfo_path: Full path to the existing NFO file
|
||||
|
||||
Returns:
|
||||
True if NFO was updated, False if no changes were needed.
|
||||
Tuple of (True if NFO was updated, extracted year from NFO or None).
|
||||
"""
|
||||
try:
|
||||
from lxml import etree
|
||||
except ImportError:
|
||||
logger.warning("lxml not available — cannot update existing NFO files")
|
||||
return False
|
||||
return False, None
|
||||
|
||||
try:
|
||||
tree = etree.parse(nfo_path)
|
||||
@@ -429,7 +450,17 @@ class NfoScanService:
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to parse existing NFO for %s: %s — will regenerate", key, exc)
|
||||
# Corrupt or unreadable NFO — regenerate from TMDB
|
||||
return await self._regenerate_nfo(key, folder, series_data, nfo_path)
|
||||
updated = await self._regenerate_nfo(key, folder, series_data, nfo_path)
|
||||
return updated, None
|
||||
|
||||
# Extract year from NFO if present
|
||||
year: Optional[int] = None
|
||||
year_elem = root.find("year")
|
||||
if year_elem is not None and year_elem.text and year_elem.text.strip():
|
||||
try:
|
||||
year = int(year_elem.text.strip())
|
||||
except ValueError:
|
||||
logger.debug("Invalid year value in NFO for %s: %s", key, year_elem.text)
|
||||
|
||||
# Check for missing or empty critical fields
|
||||
critical_fields = ["title", "plot", "premiered", "tmdbid"]
|
||||
@@ -442,7 +473,7 @@ class NfoScanService:
|
||||
|
||||
if not missing_fields:
|
||||
logger.debug("NFO for %s is complete — no update needed", key)
|
||||
return False
|
||||
return False, year
|
||||
|
||||
logger.info(
|
||||
"NFO for %s is missing fields %s — attempting to fill from TMDB",
|
||||
@@ -454,16 +485,16 @@ class NfoScanService:
|
||||
tmdb_id = series_data.get("tmdb_id")
|
||||
if not tmdb_id:
|
||||
logger.warning("Cannot update NFO for %s: no tmdb_id", key)
|
||||
return False
|
||||
return False, year
|
||||
|
||||
try:
|
||||
tmdb_data = await self._fetch_tmdb_data(tmdb_id)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to fetch TMDB data for %s: %s", key, exc)
|
||||
return False
|
||||
return False, year
|
||||
|
||||
if not tmdb_data:
|
||||
return False
|
||||
return False, year
|
||||
|
||||
nfo_model = tmdb_to_nfo_model(
|
||||
tmdb_data,
|
||||
@@ -488,7 +519,7 @@ class NfoScanService:
|
||||
"missing_fields": missing_fields,
|
||||
})
|
||||
|
||||
return True
|
||||
return True, year
|
||||
|
||||
async def _regenerate_nfo(
|
||||
self,
|
||||
@@ -526,6 +557,53 @@ class NfoScanService:
|
||||
logger.info("Regenerated NFO for %s", key)
|
||||
return True
|
||||
|
||||
async def _save_tmdb_id(self, key: str, tmdb_id: int) -> None:
|
||||
"""Save tmdb_id to the database for a series.
|
||||
|
||||
Args:
|
||||
key: Series key (primary identifier)
|
||||
tmdb_id: TMDB series ID to save
|
||||
"""
|
||||
try:
|
||||
from src.server.database.connection import get_db_session
|
||||
from src.server.database.service import AnimeSeriesService
|
||||
|
||||
async with get_db_session() as db:
|
||||
series = await AnimeSeriesService.get_by_key(db, key)
|
||||
if series:
|
||||
series.tmdb_id = tmdb_id
|
||||
await db.flush()
|
||||
logger.debug("Saved tmdb_id %s for series: %s", tmdb_id, key)
|
||||
else:
|
||||
logger.warning("Series not found for tmdb_id save: %s", key)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to save tmdb_id for %s: %s", key, exc)
|
||||
|
||||
async def _lookup_tmdb_id_by_name(self, name: str) -> Optional[int]:
|
||||
"""Look up a TMDB series ID by series name.
|
||||
|
||||
Args:
|
||||
name: Series name to search for
|
||||
|
||||
Returns:
|
||||
TMDB series ID or None if not found.
|
||||
"""
|
||||
if not name:
|
||||
return None
|
||||
|
||||
try:
|
||||
from src.server.nfo.tmdb_client import get_tmdb_client
|
||||
|
||||
async with get_tmdb_client() as client:
|
||||
results = await client.search_tv_show(name)
|
||||
if results and results.get("results"):
|
||||
first_result = results["results"][0]
|
||||
return first_result.get("id")
|
||||
return None
|
||||
except Exception as exc:
|
||||
logger.warning("TMDB lookup failed for %s: %s", name, exc)
|
||||
return None
|
||||
|
||||
async def _fetch_tmdb_data(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Fetch series metadata from TMDB API.
|
||||
|
||||
@@ -538,8 +616,8 @@ class NfoScanService:
|
||||
try:
|
||||
from src.server.nfo.tmdb_client import get_tmdb_client
|
||||
|
||||
client = get_tmdb_client()
|
||||
data = await client.get_series_details(tmdb_id)
|
||||
async with get_tmdb_client() as client:
|
||||
data = await client.get_tv_show_details(tmdb_id)
|
||||
return data
|
||||
except Exception as exc:
|
||||
logger.warning("TMDB fetch failed for TMDB ID %s: %s", tmdb_id, exc)
|
||||
@@ -563,13 +641,15 @@ class NfoScanService:
|
||||
key: str,
|
||||
has_nfo: bool,
|
||||
nfo_path: str,
|
||||
year: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Update the has_nfo flag and nfo_path in the database.
|
||||
"""Update the has_nfo flag, nfo_path, and optionally year in the database.
|
||||
|
||||
Args:
|
||||
key: Series key (primary identifier)
|
||||
has_nfo: Whether the series now has an NFO file
|
||||
nfo_path: Path to the NFO file
|
||||
year: Optional year extracted from NFO to save to DB
|
||||
"""
|
||||
try:
|
||||
from src.server.database.connection import get_db_session
|
||||
@@ -584,6 +664,10 @@ class NfoScanService:
|
||||
if series.nfo_created_at is None:
|
||||
series.nfo_created_at = now
|
||||
series.nfo_updated_at = now
|
||||
# Update year if series has no year and we have one from NFO
|
||||
if year is not None and series.year is None:
|
||||
series.year = year
|
||||
logger.info("Updated year to %d for series: %s", year, key)
|
||||
await db.flush()
|
||||
logger.debug("Updated NFO flag for series: %s", key)
|
||||
except Exception as exc:
|
||||
|
||||
@@ -208,7 +208,7 @@ class ProgressService:
|
||||
self._event_handlers[event_name] = []
|
||||
|
||||
self._event_handlers[event_name].append(handler)
|
||||
logger.debug("Event handler subscribed", event_type=event_name)
|
||||
logger.debug("Event handler subscribed event_type=%s", event_name)
|
||||
|
||||
def unsubscribe(
|
||||
self, event_name: str, handler: Callable[[ProgressEvent], None]
|
||||
|
||||
@@ -225,7 +225,7 @@ class ScanService:
|
||||
scan_progress = ScanProgress(scan_id)
|
||||
self._current_scan = scan_progress
|
||||
|
||||
logger.info("Starting library scan", scan_id=scan_id)
|
||||
logger.info("Starting library scan scan_id=%s", scan_id)
|
||||
|
||||
# Start progress tracking
|
||||
try:
|
||||
|
||||
@@ -11,11 +11,12 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
from src.config.settings import settings
|
||||
from src.server.models.config import SchedulerConfig
|
||||
from src.server.services.config_service import ConfigServiceError, get_config_service
|
||||
|
||||
@@ -264,6 +265,12 @@ class SchedulerService:
|
||||
"nfo_scan_after_rescan": (
|
||||
self._config.nfo_scan_after_rescan if self._config else True
|
||||
),
|
||||
"image_scan_after_rescan": (
|
||||
self._config.image_scan_after_rescan if self._config else True
|
||||
),
|
||||
"folder_naming_after_nfo_scan": (
|
||||
self._config.folder_naming_after_nfo_scan if self._config else True
|
||||
),
|
||||
"last_run": (
|
||||
self._last_scan_time.isoformat()
|
||||
if self._last_scan_time
|
||||
@@ -323,8 +330,18 @@ class SchedulerService:
|
||||
async with get_db_session() as db:
|
||||
settings = await SystemSettingsService.get_or_create(db)
|
||||
last_scan = settings.last_scan_timestamp
|
||||
initial_scan_done = settings.initial_scan_completed
|
||||
|
||||
if last_scan is None:
|
||||
# No previous scan recorded
|
||||
if not initial_scan_done:
|
||||
# Initial setup not yet completed - skip rescan
|
||||
# The setup flow will trigger rescan when ready
|
||||
logger.info(
|
||||
"No previous scan recorded and initial setup not yet "
|
||||
"completed — skipping immediate rescan"
|
||||
)
|
||||
return
|
||||
# Never scanned before — trigger immediately
|
||||
logger.info("No previous scan recorded — triggering immediate rescan")
|
||||
await self._perform_rescan()
|
||||
@@ -390,7 +407,18 @@ class SchedulerService:
|
||||
logger.error("NFO scan failed: %s", exc, exc_info=True)
|
||||
await self._broadcast("nfo_scan_error", {"error": str(exc)})
|
||||
|
||||
# 3. Auto-download (if enabled)
|
||||
# 3. Folder naming (if enabled, runs after NFO scan)
|
||||
if self._config and self._config.folder_naming_after_nfo_scan:
|
||||
if self._config.nfo_scan_after_rescan:
|
||||
# Only run if NFO scan was also enabled (depends on year in DB)
|
||||
try:
|
||||
naming_result = await self._run_folder_naming()
|
||||
await self._broadcast("folder_naming_completed", naming_result.to_dict())
|
||||
except Exception as exc:
|
||||
logger.error("Folder naming failed: %s", exc, exc_info=True)
|
||||
await self._broadcast("folder_naming_error", {"error": str(exc)})
|
||||
|
||||
# 4. Auto-download (if enabled)
|
||||
if self._config and self._config.auto_download_after_rescan:
|
||||
try:
|
||||
queued = await self._run_auto_download()
|
||||
@@ -399,6 +427,21 @@ class SchedulerService:
|
||||
logger.error("Auto-download failed: %s", exc, exc_info=True)
|
||||
await self._broadcast("auto_download_error", {"error": str(exc)})
|
||||
|
||||
# 5. Image scan (if enabled)
|
||||
if self._config and self._config.image_scan_after_rescan:
|
||||
try:
|
||||
image_result = await self._run_image_scan()
|
||||
await self._broadcast("image_scan_completed", {
|
||||
"total": image_result.get("total", 0),
|
||||
"success": image_result.get("success", 0),
|
||||
"poster": image_result.get("poster", 0),
|
||||
"fanart": image_result.get("fanart", 0),
|
||||
"logo": image_result.get("logo", 0),
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.error("Image scan failed: %s", exc, exc_info=True)
|
||||
await self._broadcast("image_scan_error", {"error": str(exc)})
|
||||
|
||||
self._last_scan_time = datetime.now(timezone.utc)
|
||||
duration = (self._last_scan_time - scan_start).total_seconds()
|
||||
|
||||
@@ -452,6 +495,68 @@ class SchedulerService:
|
||||
)
|
||||
return result
|
||||
|
||||
async def _run_image_scan(self) -> Dict[str, Any]:
|
||||
"""Download missing images for all series from TMDB."""
|
||||
from src.server.database.connection import get_db_session
|
||||
from src.server.nfo.tmdb_client import get_tmdb_client
|
||||
from src.server.services.image_loading_service import init_image_loading_service
|
||||
from src.server.utils.dependencies import get_anime_service
|
||||
|
||||
anime_service = get_anime_service()
|
||||
|
||||
try:
|
||||
series_list_data = await anime_service.list_series_with_filters()
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to get series list for image scan: %s", exc)
|
||||
return {"total": 0, "success": 0, "partial": 0, "failed": 0}
|
||||
|
||||
if not series_list_data:
|
||||
logger.info("No series found for image scan")
|
||||
return {"total": 0, "success": 0, "partial": 0, "failed": 0}
|
||||
|
||||
# Build list of series to process
|
||||
series_to_process = []
|
||||
for series_data in series_list_data:
|
||||
key = series_data.get("key")
|
||||
folder = series_data.get("folder")
|
||||
if not key or not folder:
|
||||
continue
|
||||
series_to_process.append({"key": key, "folder": folder})
|
||||
|
||||
if not series_to_process:
|
||||
logger.info("No series to process for image scan")
|
||||
return {"total": 0, "success": 0, "partial": 0, "failed": 0}
|
||||
|
||||
logger.info("Starting image scan for %d series...", len(series_to_process))
|
||||
|
||||
# Initialize TMDB client and image loading service
|
||||
tmdb_client = get_tmdb_client()
|
||||
image_service = init_image_loading_service(tmdb_client)
|
||||
|
||||
anime_dir = settings.anime_directory
|
||||
async with get_db_session() as db:
|
||||
result = await image_service.load_series_images_batch(
|
||||
series_list=series_to_process,
|
||||
anime_directory=anime_dir,
|
||||
db=db,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Image scan completed: total=%d success=%d partial=%d failed=%d",
|
||||
result.get("total", 0),
|
||||
result.get("success", 0),
|
||||
result.get("partial", 0),
|
||||
result.get("failed", 0),
|
||||
)
|
||||
return result
|
||||
|
||||
async def _run_folder_naming(self) -> Any:
|
||||
"""Run folder naming fix to add missing years to folder names."""
|
||||
from src.server.services.folder_naming_service import get_folder_naming_service
|
||||
service = get_folder_naming_service()
|
||||
logger.info("Starting folder naming scan...")
|
||||
return await service.run()
|
||||
|
||||
async def _run_auto_download(self) -> int:
|
||||
"""Queue and start downloads for all series with missing episodes."""
|
||||
from src.server.models.download import EpisodeIdentifier
|
||||
|
||||
@@ -148,7 +148,7 @@ class SetupService:
|
||||
results = await series_app.search(title)
|
||||
|
||||
if len(results) == 1:
|
||||
result_name = results[0].get('title', '')
|
||||
result_name = results[0].get('name', '')
|
||||
result_link = results[0].get('link', '')
|
||||
|
||||
if SetupService._titles_match(result_name, title):
|
||||
@@ -173,10 +173,19 @@ class SetupService:
|
||||
)
|
||||
elif len(results) > 1:
|
||||
logger.debug(
|
||||
"Multiple search results for title, skipping fuzzy match",
|
||||
"Multiple search results for title, trying fuzzy match",
|
||||
title=title,
|
||||
result_count=len(results)
|
||||
)
|
||||
# Try fuzzy match across multiple results
|
||||
for result in results:
|
||||
result_name = result.get('name', '')
|
||||
result_link = result.get('link', '')
|
||||
if SetupService._titles_match(result_name, title):
|
||||
if result_link and '/anime/stream/' in result_link:
|
||||
return result_link.split('/anime/stream/')[-1].split('/')[0]
|
||||
elif result_link:
|
||||
return result_link
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Provider search failed for folder",
|
||||
@@ -258,7 +267,7 @@ class SetupService:
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def run(cls) -> int:
|
||||
async def run(cls) -> tuple[int, int]:
|
||||
"""Run the setup service.
|
||||
|
||||
Scans anime folders, creates AnimeSeries records, and resolves
|
||||
@@ -266,11 +275,11 @@ class SetupService:
|
||||
that initial scan hasn't been completed yet (via _check_initial_scan_status).
|
||||
|
||||
Returns:
|
||||
Number of new series created
|
||||
Tuple of (number of new series created, number of unresolved folders)
|
||||
"""
|
||||
if not settings.anime_directory:
|
||||
logger.info("Anime directory not configured, skipping setup")
|
||||
return 0
|
||||
return 0, 0
|
||||
|
||||
anime_dir = Path(settings.anime_directory)
|
||||
if not anime_dir.is_dir():
|
||||
@@ -278,7 +287,7 @@ class SetupService:
|
||||
"Anime directory does not exist, skipping setup: %s",
|
||||
anime_dir
|
||||
)
|
||||
return 0
|
||||
return 0, 0
|
||||
|
||||
logger.info("Running setup service...")
|
||||
|
||||
@@ -376,6 +385,7 @@ class SetupService:
|
||||
"Could not resolve series key for folder, tracking as unresolved: %s",
|
||||
folder_name
|
||||
)
|
||||
unresolved_count += 1
|
||||
continue
|
||||
|
||||
# Also check if a series with this key already exists (different folder, same anime)
|
||||
@@ -436,6 +446,6 @@ class SetupService:
|
||||
error=str(e),
|
||||
exc_info=True
|
||||
)
|
||||
return created_count
|
||||
return created_count, unresolved_count
|
||||
|
||||
return created_count
|
||||
return created_count, unresolved_count
|
||||
@@ -16,14 +16,14 @@ optional and used for display purposes only.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
import structlog
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebSocketServiceError(Exception):
|
||||
@@ -96,9 +96,8 @@ class ConnectionManager:
|
||||
self._connection_metadata[connection_id] = metadata or {}
|
||||
|
||||
logger.info(
|
||||
"WebSocket connected",
|
||||
connection_id=connection_id,
|
||||
total_connections=len(self._active_connections),
|
||||
"WebSocket connected connection_id=%s total_connections=%s",
|
||||
connection_id, len(self._active_connections),
|
||||
)
|
||||
|
||||
async def disconnect(self, connection_id: str) -> None:
|
||||
@@ -122,9 +121,8 @@ class ConnectionManager:
|
||||
self._connection_metadata.pop(connection_id, None)
|
||||
|
||||
logger.info(
|
||||
"WebSocket disconnected",
|
||||
connection_id=connection_id,
|
||||
total_connections=len(self._active_connections),
|
||||
"WebSocket disconnected connection_id=%s total_connections=%s",
|
||||
connection_id, len(self._active_connections),
|
||||
)
|
||||
|
||||
async def join_room(self, connection_id: str, room: str) -> None:
|
||||
@@ -138,16 +136,13 @@ class ConnectionManager:
|
||||
if connection_id in self._active_connections:
|
||||
self._rooms[room].add(connection_id)
|
||||
logger.debug(
|
||||
"Connection joined room",
|
||||
connection_id=connection_id,
|
||||
room=room,
|
||||
room_size=len(self._rooms[room]),
|
||||
"Connection joined room connection_id=%s room=%s room_size=%s",
|
||||
connection_id, room, len(self._rooms[room]),
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Attempted to join room with inactive connection",
|
||||
connection_id=connection_id,
|
||||
room=room,
|
||||
"Attempted to join room with inactive connection connection_id=%s room=%s",
|
||||
connection_id, room,
|
||||
)
|
||||
|
||||
async def leave_room(self, connection_id: str, room: str) -> None:
|
||||
@@ -166,9 +161,8 @@ class ConnectionManager:
|
||||
del self._rooms[room]
|
||||
|
||||
logger.debug(
|
||||
"Connection left room",
|
||||
connection_id=connection_id,
|
||||
room=room,
|
||||
"Connection left room connection_id=%s room=%s",
|
||||
connection_id, room,
|
||||
)
|
||||
|
||||
async def send_personal_message(
|
||||
@@ -185,26 +179,24 @@ class ConnectionManager:
|
||||
try:
|
||||
await websocket.send_json(message)
|
||||
logger.debug(
|
||||
"Personal message sent",
|
||||
connection_id=connection_id,
|
||||
message_type=message.get("type", "unknown"),
|
||||
"Personal message sent connection_id=%s message_type=%s",
|
||||
connection_id, message.get("type", "unknown"),
|
||||
)
|
||||
except WebSocketDisconnect:
|
||||
logger.warning(
|
||||
"Connection disconnected during send",
|
||||
connection_id=connection_id,
|
||||
"Connection disconnected during send connection_id=%s",
|
||||
connection_id,
|
||||
)
|
||||
await self.disconnect(connection_id)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to send personal message",
|
||||
connection_id=connection_id,
|
||||
error=str(e),
|
||||
"Failed to send personal message connection_id=%s error=%s",
|
||||
connection_id, str(e),
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Attempted to send message to inactive connection",
|
||||
connection_id=connection_id,
|
||||
"Attempted to send message to inactive connection connection_id=%s",
|
||||
connection_id,
|
||||
)
|
||||
|
||||
async def broadcast(
|
||||
@@ -227,15 +219,14 @@ class ConnectionManager:
|
||||
await websocket.send_json(message)
|
||||
except WebSocketDisconnect:
|
||||
logger.warning(
|
||||
"Connection disconnected during broadcast",
|
||||
connection_id=connection_id,
|
||||
"Connection disconnected during broadcast connection_id=%s",
|
||||
connection_id,
|
||||
)
|
||||
disconnected.append(connection_id)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to broadcast to connection",
|
||||
connection_id=connection_id,
|
||||
error=str(e),
|
||||
"Failed to broadcast to connection connection_id=%s error=%s",
|
||||
connection_id, str(e),
|
||||
)
|
||||
|
||||
# Cleanup disconnected connections
|
||||
@@ -243,10 +234,10 @@ class ConnectionManager:
|
||||
await self.disconnect(connection_id)
|
||||
|
||||
logger.debug(
|
||||
"Message broadcast",
|
||||
message_type=message.get("type", "unknown"),
|
||||
recipient_count=len(self._active_connections) - len(exclude),
|
||||
failed_count=len(disconnected),
|
||||
"Message broadcast message_type=%s recipient_count=%s failed_count=%s",
|
||||
message.get("type", "unknown"),
|
||||
len(self._active_connections) - len(exclude),
|
||||
len(disconnected),
|
||||
)
|
||||
|
||||
async def broadcast_to_room(
|
||||
@@ -270,17 +261,14 @@ class ConnectionManager:
|
||||
await websocket.send_json(message)
|
||||
except WebSocketDisconnect:
|
||||
logger.warning(
|
||||
"Connection disconnected during room broadcast",
|
||||
connection_id=connection_id,
|
||||
room=room,
|
||||
"Connection disconnected during room broadcast connection_id=%s room=%s",
|
||||
connection_id, room,
|
||||
)
|
||||
disconnected.append(connection_id)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to broadcast to room member",
|
||||
connection_id=connection_id,
|
||||
room=room,
|
||||
error=str(e),
|
||||
"Failed to broadcast to room member connection_id=%s room=%s error=%s",
|
||||
connection_id, room, str(e),
|
||||
)
|
||||
|
||||
# Cleanup disconnected connections
|
||||
@@ -288,11 +276,9 @@ class ConnectionManager:
|
||||
await self.disconnect(connection_id)
|
||||
|
||||
logger.debug(
|
||||
"Message broadcast to room",
|
||||
room=room,
|
||||
message_type=message.get("type", "unknown"),
|
||||
recipient_count=len(room_members),
|
||||
failed_count=len(disconnected),
|
||||
"Message broadcast to room room=%s message_type=%s recipient_count=%s failed_count=%s",
|
||||
room, message.get("type", "unknown"),
|
||||
len(room_members), len(disconnected),
|
||||
)
|
||||
|
||||
async def get_connection_count(self) -> int:
|
||||
@@ -604,9 +590,8 @@ class WebSocketService:
|
||||
}
|
||||
await self._manager.broadcast(message)
|
||||
logger.info(
|
||||
"Broadcast scan_started",
|
||||
directory=directory,
|
||||
total_items=total_items,
|
||||
"Broadcast scan_started directory=%s total_items=%s",
|
||||
directory, total_items,
|
||||
)
|
||||
|
||||
async def broadcast_scan_progress(
|
||||
@@ -660,17 +645,15 @@ class WebSocketService:
|
||||
}
|
||||
await self._manager.broadcast(message)
|
||||
logger.info(
|
||||
"Broadcast scan_completed",
|
||||
total_directories=total_directories,
|
||||
total_files=total_files,
|
||||
elapsed_seconds=round(elapsed_seconds, 2),
|
||||
"Broadcast scan_completed total_directories=%s total_files=%s elapsed_seconds=%s",
|
||||
total_directories, total_files, round(elapsed_seconds, 2),
|
||||
)
|
||||
|
||||
async def shutdown(self, timeout: float = 5.0) -> None:
|
||||
"""Gracefully shutdown the WebSocket service.
|
||||
|
||||
|
||||
Broadcasts shutdown notification and closes all connections.
|
||||
|
||||
|
||||
Args:
|
||||
timeout: Maximum time (seconds) to wait for shutdown
|
||||
"""
|
||||
@@ -678,6 +661,34 @@ class WebSocketService:
|
||||
await self._manager.shutdown(timeout=timeout)
|
||||
logger.info("WebSocket service shutdown complete")
|
||||
|
||||
async def broadcast_series_deleted(
|
||||
self,
|
||||
key: str,
|
||||
name: str,
|
||||
) -> None:
|
||||
"""Broadcast a series_deleted event to all connected clients.
|
||||
|
||||
Notifies clients that a series has been deleted so they can
|
||||
remove it from their UI in real-time.
|
||||
|
||||
Args:
|
||||
key: Series key that was deleted (primary identifier)
|
||||
name: Series name for display purposes
|
||||
"""
|
||||
message = {
|
||||
"type": "series_deleted",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"data": {
|
||||
"key": key,
|
||||
"name": name,
|
||||
},
|
||||
}
|
||||
await self._manager.broadcast(message)
|
||||
logger.info(
|
||||
"Broadcast series_deleted key=%s name=%s",
|
||||
key, name,
|
||||
)
|
||||
|
||||
|
||||
# Singleton instance for application-wide access
|
||||
_websocket_service: Optional[WebSocketService] = None
|
||||
|
||||
@@ -410,7 +410,7 @@ async def rate_limit_dependency(request: Request) -> None:
|
||||
record.count += 1
|
||||
if record.count > max_requests:
|
||||
logger.warning(
|
||||
"Rate limit exceeded", extra={"client": client_id}
|
||||
"Rate limit exceeded client=%s", client_id
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
@@ -423,13 +423,10 @@ async def log_request_dependency(request: Request) -> None:
|
||||
"""Log request metadata for auditing and debugging purposes."""
|
||||
|
||||
logger.info(
|
||||
"API request",
|
||||
extra={
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"client": request.client.host if request.client else "unknown",
|
||||
"query": dict(request.query_params),
|
||||
},
|
||||
"API request method=%s path=%s client=%s query=%s",
|
||||
request.method, request.url.path,
|
||||
request.client.host if request.client else "unknown",
|
||||
dict(request.query_params),
|
||||
)
|
||||
|
||||
|
||||
@@ -557,23 +554,44 @@ def get_background_loader_service() -> "BackgroundLoaderService":
|
||||
|
||||
if _background_loader_service is None:
|
||||
try:
|
||||
import logging
|
||||
_init_logger = logging.getLogger(__name__)
|
||||
_init_logger.info("Creating BackgroundLoaderService instance...")
|
||||
|
||||
from src.server.services.background_loader_service import (
|
||||
BackgroundLoaderService,
|
||||
)
|
||||
from src.server.services.websocket_service import get_websocket_service
|
||||
|
||||
anime_service = get_anime_service()
|
||||
series_app = get_series_app()
|
||||
websocket_service = get_websocket_service()
|
||||
_init_logger.info("Imported BackgroundLoaderService")
|
||||
|
||||
from src.server.services.websocket_service import get_websocket_service
|
||||
_init_logger.info("Getting websocket_service...")
|
||||
websocket_service = get_websocket_service()
|
||||
_init_logger.info("Got websocket_service: %s", id(websocket_service))
|
||||
|
||||
_init_logger.info("Getting anime_service...")
|
||||
anime_service = get_anime_service()
|
||||
_init_logger.info("Got anime_service: %s", id(anime_service))
|
||||
|
||||
_init_logger.info("Getting series_app...")
|
||||
series_app = get_series_app()
|
||||
_init_logger.info("Got series_app: %s", id(series_app))
|
||||
|
||||
_init_logger.info("Creating BackgroundLoaderService with params: ws=%s, ans=%s, sa=%s",
|
||||
id(websocket_service), id(anime_service), id(series_app))
|
||||
_background_loader_service = BackgroundLoaderService(
|
||||
websocket_service=websocket_service,
|
||||
anime_service=anime_service,
|
||||
series_app=series_app
|
||||
)
|
||||
_init_logger.info("BackgroundLoaderService created successfully: %s", id(_background_loader_service))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
import logging
|
||||
_err_logger = logging.getLogger(__name__)
|
||||
_err_logger.error("Error in BackgroundLoaderService creation: %s", str(e))
|
||||
import traceback
|
||||
_err_logger.error("Traceback: %s", traceback.format_exc())
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=(
|
||||
|
||||
@@ -74,13 +74,8 @@ class ErrorTracker:
|
||||
self.error_history = self.error_history[-self.max_history_size:]
|
||||
|
||||
logger.info(
|
||||
f"Error tracked: {error_id}",
|
||||
extra={
|
||||
"error_id": error_id,
|
||||
"error_type": error_type,
|
||||
"status_code": status_code,
|
||||
"request_path": request_path,
|
||||
},
|
||||
"Error tracked error_id=%s error_type=%s status_code=%s request_path=%s",
|
||||
error_id, error_type, status_code, request_path,
|
||||
)
|
||||
|
||||
return error_id
|
||||
|
||||
@@ -13,8 +13,8 @@ Series Identifier Convention:
|
||||
All template helpers that handle series data use `key` for identification and
|
||||
provide `folder` as display metadata only.
|
||||
"""
|
||||
import hashlib
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@@ -27,10 +27,44 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# Configure templates directory
|
||||
TEMPLATES_DIR = Path(__file__).parent.parent / "web" / "templates"
|
||||
STATIC_DIR = Path(__file__).parent.parent / "web" / "static"
|
||||
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
||||
|
||||
# Version token for static asset cache-busting; changes on every server start.
|
||||
STATIC_VERSION: str = str(int(time.time()))
|
||||
# Cache for static file hashes: {file_path: (mtime, hash)}
|
||||
_hash_cache: Dict[str, tuple[float, str]] = {}
|
||||
|
||||
|
||||
def get_static_version(file_path: str) -> str:
|
||||
"""
|
||||
Get cache-busting version for a static file based on content hash.
|
||||
|
||||
Hash is computed once and cached; cache is invalidated when file mtime changes.
|
||||
|
||||
Args:
|
||||
file_path: Relative path to static file (e.g., 'css/styles.css')
|
||||
|
||||
Returns:
|
||||
8-character hex hash of file content, or empty string if file not found
|
||||
"""
|
||||
full_path = STATIC_DIR / file_path
|
||||
|
||||
if not full_path.exists():
|
||||
logger.warning(f"Static file not found: {file_path}")
|
||||
return ""
|
||||
|
||||
current_mtime = full_path.stat().st_mtime
|
||||
|
||||
# Check cache validity
|
||||
if file_path in _hash_cache:
|
||||
cached_mtime, cached_hash = _hash_cache[file_path]
|
||||
if cached_mtime == current_mtime:
|
||||
return cached_hash
|
||||
|
||||
# Compute new hash
|
||||
file_hash = hashlib.md5(full_path.read_bytes()).hexdigest()[:8]
|
||||
_hash_cache[file_path] = (current_mtime, file_hash)
|
||||
|
||||
return file_hash
|
||||
|
||||
|
||||
def get_base_context(
|
||||
@@ -51,7 +85,7 @@ def get_base_context(
|
||||
"title": title,
|
||||
"app_name": "Aniworld Download Manager",
|
||||
"version": APP_VERSION,
|
||||
"static_v": STATIC_VERSION,
|
||||
"static_version": get_static_version,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -311,89 +311,48 @@
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Edit Metadata Modal
|
||||
============================================================================ */
|
||||
|
||||
.edit-modal-content {
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.edit-section {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
padding-bottom: var(--spacing-lg);
|
||||
border-bottom: 1px solid var(--color-divider);
|
||||
}
|
||||
|
||||
.edit-section:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.edit-section h4 {
|
||||
margin: 0 0 var(--spacing-md) 0;
|
||||
font-size: var(--font-size-body);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.edit-section h4 i {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
font-size: var(--font-size-caption);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.field-error {
|
||||
display: block;
|
||||
margin-top: var(--spacing-xs);
|
||||
font-size: var(--font-size-caption);
|
||||
.context-menu-item.danger {
|
||||
color: var(--color-error, #e74c3c);
|
||||
}
|
||||
|
||||
.input-error {
|
||||
border-color: var(--color-error, #e74c3c) !important;
|
||||
.context-menu-item.danger i {
|
||||
color: var(--color-error, #e74c3c);
|
||||
}
|
||||
|
||||
.key-warning {
|
||||
background: rgba(255, 193, 7, 0.1);
|
||||
border: 1px solid rgba(255, 193, 7, 0.3);
|
||||
border-radius: var(--border-radius);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
margin-top: var(--spacing-sm);
|
||||
font-size: var(--font-size-caption);
|
||||
color: var(--color-warning, #f39c12);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
.context-menu-item.danger:hover {
|
||||
background-color: rgba(231, 76, 60, 0.1);
|
||||
}
|
||||
|
||||
/* NFO Diagnostics */
|
||||
.nfo-diagnostics {
|
||||
.context-menu-divider {
|
||||
height: 1px;
|
||||
background: var(--color-border);
|
||||
margin: var(--spacing-xs) 0;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
NFO Diagnostics
|
||||
============================================================================ */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.nfo-status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.nfo-path-display {
|
||||
font-size: var(--font-size-caption);
|
||||
color: var(--color-text-tertiary);
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.nfo-status-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
@@ -456,9 +415,14 @@
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.nfo-actions-row {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.btn-repair {
|
||||
align-self: flex-start;
|
||||
margin-top: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
@@ -469,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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Toast base */
|
||||
@@ -24,6 +25,7 @@
|
||||
box-shadow: var(--shadow-elevated);
|
||||
min-width: 300px;
|
||||
animation: slideIn var(--transition-duration) var(--transition-easing);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Toast variants */
|
||||
|
||||
226
src/server/web/static/css/pages/anime-settings.css
Normal file
226
src/server/web/static/css/pages/anime-settings.css
Normal file
@@ -0,0 +1,226 @@
|
||||
/* ============================================================
|
||||
Anime Settings Page
|
||||
------------------------------------------------------------
|
||||
Layout and styling for /anime/settings (renamed from
|
||||
/settings/nfo — formerly "NFO Diagnostics").
|
||||
============================================================ */
|
||||
|
||||
.anime-settings-main {
|
||||
padding: 1.5rem;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.settings-header-card {
|
||||
background: var(--color-card-bg, #1f2937);
|
||||
color: var(--color-text, #f3f4f6);
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.25rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.settings-header-card h2 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.status-badges {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
background: var(--color-badge-bg, #374151);
|
||||
color: var(--color-badge-text, #f9fafb);
|
||||
}
|
||||
|
||||
.status-badge.status-complete {
|
||||
background: #10b981;
|
||||
color: #ffffff;
|
||||
}
|
||||
.status-badge.status-incomplete {
|
||||
background: #f59e0b;
|
||||
color: #ffffff;
|
||||
}
|
||||
.status-badge.status-failed {
|
||||
background: #ef4444;
|
||||
color: #ffffff;
|
||||
}
|
||||
.status-badge.status-pending {
|
||||
background: #6366f1;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.settings-section-card {
|
||||
background: var(--color-card-bg, #1f2937);
|
||||
border: 1px solid var(--color-border, #374151);
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.settings-section-card h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 1.1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.settings-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-field.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.settings-field label {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.settings-field input.input-field {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-border, #4b5563);
|
||||
border-radius: 6px;
|
||||
background: var(--color-input-bg, #111827);
|
||||
color: var(--color-text, #f9fafb);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.settings-field input.input-field:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent, #3b82f6);
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.25);
|
||||
}
|
||||
|
||||
.settings-field .config-hint {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.settings-field .config-hint.hint-error {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.value-mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
background: var(--color-code-bg, #111827);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
word-break: break-all;
|
||||
display: inline-block;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text, #f3f4f6);
|
||||
}
|
||||
|
||||
.settings-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.settings-toggles {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--color-border, #374151);
|
||||
}
|
||||
|
||||
.nfo-content {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--color-code-bg, #111827);
|
||||
border: 1px solid var(--color-border, #4b5563);
|
||||
border-radius: 6px;
|
||||
max-height: 400px;
|
||||
overflow: auto;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.8rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
color: var(--color-text, #e5e7eb);
|
||||
}
|
||||
|
||||
.error-box {
|
||||
background: var(--color-card-bg, #1f2937);
|
||||
border: 1px solid #ef4444;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
color: var(--color-text, #f3f4f6);
|
||||
}
|
||||
|
||||
.error-box i {
|
||||
font-size: 2rem;
|
||||
color: #ef4444;
|
||||
margin-bottom: 0.5rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.error-box h2 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.error-box p {
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
}
|
||||
|
||||
.loading-spinner i {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
display: block;
|
||||
color: var(--color-accent, #3b82f6);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.settings-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.anime-settings-main {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -46,6 +46,7 @@ AniWorld.IndexApp = (function() {
|
||||
AniWorld.ScanManager.init();
|
||||
AniWorld.ConfigManager.init();
|
||||
AniWorld.ContextMenu.init();
|
||||
AniWorld.DeleteModal.init();
|
||||
|
||||
// Bind global events
|
||||
bindGlobalEvents();
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* AniWorld - Context Menu Component
|
||||
*
|
||||
* Right-click context menu for anime series cards.
|
||||
* Provides quick access to edit metadata.
|
||||
* Provides quick access to per-anime settings.
|
||||
*
|
||||
* Dependencies: ui-utils.js, edit-modal.js
|
||||
* Dependencies: ui-utils.js
|
||||
*/
|
||||
|
||||
var AniWorld = window.AniWorld || {};
|
||||
@@ -67,9 +67,14 @@ AniWorld.ContextMenu = (function() {
|
||||
menuElement = document.createElement('div');
|
||||
menuElement.className = 'context-menu';
|
||||
menuElement.innerHTML = `
|
||||
<div class="context-menu-item" data-action="edit">
|
||||
<i class="fa-solid fa-pen-to-square"></i>
|
||||
<span>Edit Metadata</span>
|
||||
<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>
|
||||
`;
|
||||
|
||||
@@ -96,10 +101,23 @@ AniWorld.ContextMenu = (function() {
|
||||
menuElement.style.top = posY + 'px';
|
||||
|
||||
// Attach action handlers
|
||||
menuElement.querySelector('[data-action="edit"]').addEventListener('click', function() {
|
||||
// Anime Settings - opens the per-anime settings page
|
||||
menuElement.querySelector('[data-action="anime-settings"]').addEventListener('click', function() {
|
||||
// Capture the key BEFORE hide() clears it
|
||||
const key = currentSeriesKey;
|
||||
hide();
|
||||
if (AniWorld.EditModal) {
|
||||
AniWorld.EditModal.open(currentSeriesKey);
|
||||
// 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');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
317
src/server/web/static/js/index/delete-modal.js
Normal file
317
src/server/web/static/js/index/delete-modal.js
Normal file
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* AniWorld - Delete Anime Modal Module
|
||||
*
|
||||
* Handles the delete confirmation modal for anime series.
|
||||
* Users must type "delete" to enable the confirm button.
|
||||
*
|
||||
* Dependencies: constants.js, api-client.js, ui-utils.js
|
||||
*/
|
||||
|
||||
var AniWorld = window.AniWorld || {};
|
||||
|
||||
AniWorld.DeleteModal = (function() {
|
||||
'use strict';
|
||||
|
||||
const API = '/api/anime';
|
||||
|
||||
let currentKey = null;
|
||||
let currentSeriesName = null;
|
||||
let modalElement = null;
|
||||
let confirmBtn = null;
|
||||
let confirmInput = null;
|
||||
let deleteDbCheckbox = null;
|
||||
let deleteFolderCheckbox = null;
|
||||
let errorElement = null;
|
||||
let isSubmitting = false;
|
||||
|
||||
/**
|
||||
* Initialize the modal — inject HTML into body if not already present.
|
||||
*/
|
||||
function init() {
|
||||
injectModalHTML();
|
||||
cacheElements();
|
||||
bindEvents();
|
||||
console.info('[DeleteModal] initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the modal HTML once and append to document.body.
|
||||
*/
|
||||
function injectModalHTML() {
|
||||
if (document.getElementById('delete-modal')) return;
|
||||
var div = document.createElement('div');
|
||||
div.id = 'delete-modal';
|
||||
div.className = 'modal hidden';
|
||||
div.innerHTML =
|
||||
'<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() {
|
||||
// Cancel button
|
||||
document.getElementById('delete-cancel-btn').addEventListener('click', hide);
|
||||
|
||||
// Close on backdrop click
|
||||
modalElement.querySelector('.modal-overlay').addEventListener('click', hide);
|
||||
|
||||
// Escape key to close
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape' && !isSubmitting && !modalElement.classList.contains('hidden')) {
|
||||
hide();
|
||||
}
|
||||
});
|
||||
|
||||
// Folder checkbox toggle — show/hide warning
|
||||
deleteFolderCheckbox.addEventListener('change', function() {
|
||||
var warning = document.getElementById('delete-folder-warning');
|
||||
if (warning) {
|
||||
warning.style.display = deleteFolderCheckbox.checked ? 'flex' : 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Confirm input — validate and update button state
|
||||
confirmInput.addEventListener('input', function() {
|
||||
var value = confirmInput.value;
|
||||
var isMatch = value === 'delete';
|
||||
confirmBtn.disabled = !isMatch || isSubmitting;
|
||||
confirmInput.classList.toggle('matched', isMatch);
|
||||
});
|
||||
|
||||
// Confirm button
|
||||
confirmBtn.addEventListener('click', handleConfirm);
|
||||
|
||||
// Click outside modal content to close
|
||||
modalElement.addEventListener('click', function(e) {
|
||||
if (e.target === modalElement) {
|
||||
hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the delete modal for a given series.
|
||||
* @param {string} key - Series key
|
||||
*/
|
||||
function show(key) {
|
||||
console.info('[DeleteModal] Opening for key:', key);
|
||||
|
||||
// Get series info from SeriesManager if available
|
||||
var seriesData = null;
|
||||
if (AniWorld.SeriesManager && AniWorld.SeriesManager.findByKey) {
|
||||
seriesData = AniWorld.SeriesManager.findByKey(key);
|
||||
}
|
||||
|
||||
currentKey = key;
|
||||
currentSeriesName = seriesData ? (seriesData.name || key) : key;
|
||||
|
||||
// Populate modal
|
||||
document.getElementById('delete-modal-series-name').textContent = currentSeriesName;
|
||||
document.getElementById('delete-modal-series-key').textContent = 'Key: ' + key;
|
||||
|
||||
// Reset state
|
||||
confirmInput.value = '';
|
||||
confirmInput.classList.remove('matched');
|
||||
confirmBtn.disabled = true;
|
||||
isSubmitting = false;
|
||||
errorElement.classList.add('hidden');
|
||||
errorElement.textContent = '';
|
||||
deleteDbCheckbox.checked = true;
|
||||
deleteFolderCheckbox.checked = false;
|
||||
document.getElementById('delete-folder-warning').style.display = 'none';
|
||||
|
||||
// Show modal
|
||||
modalElement.classList.remove('hidden');
|
||||
confirmInput.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the modal and reset state.
|
||||
*/
|
||||
function hide() {
|
||||
if (isSubmitting) return; // Don't close while submitting
|
||||
modalElement.classList.add('hidden');
|
||||
currentKey = null;
|
||||
currentSeriesName = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle confirm button click.
|
||||
*/
|
||||
async function handleConfirm() {
|
||||
if (confirmBtn.disabled || isSubmitting) return;
|
||||
|
||||
var deleteDb = deleteDbCheckbox.checked;
|
||||
var deleteFolder = deleteFolderCheckbox.checked;
|
||||
var confirmText = confirmInput.value.trim();
|
||||
|
||||
// Validate confirm text
|
||||
if (confirmText !== 'delete') {
|
||||
showError('You must type exactly "delete" to confirm.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate at least one option selected
|
||||
if (!deleteDb && !deleteFolder) {
|
||||
showError('Please select at least one delete option.');
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting = true;
|
||||
confirmBtn.disabled = true;
|
||||
confirmBtn.textContent = 'Deleting...';
|
||||
errorElement.classList.add('hidden');
|
||||
|
||||
console.info('[DeleteModal] Initiating delete:', {
|
||||
key: currentKey,
|
||||
delete_database: deleteDb,
|
||||
delete_folder: deleteFolder
|
||||
});
|
||||
|
||||
try {
|
||||
var response = await AniWorld.ApiClient.request(
|
||||
API + '/' + encodeURIComponent(currentKey),
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
delete_database: deleteDb,
|
||||
delete_folder: deleteFolder,
|
||||
confirm_text: confirmText
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
if (!response) {
|
||||
// Auth failure already redirected
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
AniWorld.Auth.removeToken();
|
||||
window.location.href = '/login';
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.status === 400) {
|
||||
var body = await response.json().catch(function() { return {}; });
|
||||
showError(body.detail || 'Invalid request: ' + response.status);
|
||||
isSubmitting = false;
|
||||
confirmBtn.textContent = 'Delete';
|
||||
confirmBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.status === 404) {
|
||||
showError('Series not found: ' + currentKey);
|
||||
isSubmitting = false;
|
||||
confirmBtn.textContent = 'Delete';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
var text = await response.text();
|
||||
showError('Delete failed: HTTP ' + response.status + ' ' + text);
|
||||
isSubmitting = false;
|
||||
confirmBtn.textContent = 'Delete';
|
||||
confirmBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await response.json();
|
||||
console.info('[DeleteModal] Delete succeeded:', result);
|
||||
|
||||
// Show success message based on what was deleted
|
||||
var msgParts = [];
|
||||
if (result.deleted_from_database) msgParts.push('removed from database');
|
||||
if (result.deleted_folder) msgParts.push('folder deleted from filesystem');
|
||||
if (result.database_error) msgParts.push('DB error: ' + result.database_error);
|
||||
if (result.folder_error) msgParts.push('Folder error: ' + result.folder_error);
|
||||
var successMsg = msgParts.length > 0
|
||||
? msgParts.join('; ')
|
||||
: 'Delete completed.';
|
||||
|
||||
AniWorld.UI.showToast(successMsg, result.success ? 'success' : 'warning');
|
||||
hide();
|
||||
|
||||
// Remove the card from the grid directly
|
||||
if (AniWorld.SeriesManager && AniWorld.SeriesManager.removeSeries) {
|
||||
AniWorld.SeriesManager.removeSeries(currentKey);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('[DeleteModal] Delete request failed:', err);
|
||||
showError('Network error: ' + (err && err.message ? err.message : String(err)));
|
||||
isSubmitting = false;
|
||||
confirmBtn.textContent = 'Delete';
|
||||
confirmBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show an error message in the modal.
|
||||
* @param {string} msg
|
||||
*/
|
||||
function showError(msg) {
|
||||
errorElement.textContent = msg;
|
||||
errorElement.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
init: init,
|
||||
show: show,
|
||||
hide: hide
|
||||
};
|
||||
})();
|
||||
@@ -1,450 +0,0 @@
|
||||
/**
|
||||
* AniWorld - Edit Modal Component
|
||||
*
|
||||
* Modal dialog for viewing/editing anime metadata (key, tmdb_id, tvdb_id)
|
||||
* and NFO diagnostics with repair functionality.
|
||||
*
|
||||
* Dependencies: api-client.js, ui-utils.js
|
||||
*/
|
||||
|
||||
var AniWorld = window.AniWorld || {};
|
||||
|
||||
AniWorld.EditModal = (function() {
|
||||
'use strict';
|
||||
|
||||
let modalElement = null;
|
||||
let originalData = null;
|
||||
let currentKey = null;
|
||||
|
||||
/**
|
||||
* Open the edit modal for a specific anime series.
|
||||
* @param {string} seriesKey - The series key to edit
|
||||
*/
|
||||
async function open(seriesKey) {
|
||||
currentKey = seriesKey;
|
||||
modalElement = document.getElementById('edit-metadata-modal');
|
||||
if (!modalElement) return;
|
||||
|
||||
// Show modal
|
||||
modalElement.classList.remove('hidden');
|
||||
|
||||
// Reset form state
|
||||
setLoading(true);
|
||||
clearErrors();
|
||||
hideKeyWarning();
|
||||
|
||||
try {
|
||||
// Find series data from the local series list
|
||||
const seriesData = findSeriesData(seriesKey);
|
||||
|
||||
originalData = {
|
||||
key: seriesKey,
|
||||
tmdb_id: seriesData ? seriesData.tmdb_id : null,
|
||||
tvdb_id: seriesData ? seriesData.tvdb_id : null,
|
||||
};
|
||||
|
||||
// Populate form fields
|
||||
setFieldValue('edit-key', originalData.key);
|
||||
setFieldValue('edit-tmdb-id', originalData.tmdb_id || '');
|
||||
setFieldValue('edit-tvdb-id', originalData.tvdb_id || '');
|
||||
|
||||
// Load NFO diagnostics
|
||||
await loadDiagnostics(seriesKey);
|
||||
|
||||
} catch (err) {
|
||||
AniWorld.UI.showToast('Failed to load series data', 'error');
|
||||
console.error('Edit modal load error:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
// Attach event listeners
|
||||
attachListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the edit modal and reset state.
|
||||
*/
|
||||
function close() {
|
||||
if (modalElement) {
|
||||
modalElement.classList.add('hidden');
|
||||
}
|
||||
originalData = null;
|
||||
currentKey = null;
|
||||
detachListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save changed metadata to the backend.
|
||||
*/
|
||||
async function save() {
|
||||
clearErrors();
|
||||
|
||||
const newKey = getFieldValue('edit-key').trim().toLowerCase();
|
||||
const tmdbIdStr = getFieldValue('edit-tmdb-id').trim();
|
||||
const tvdbIdStr = getFieldValue('edit-tvdb-id').trim();
|
||||
|
||||
// Validate key
|
||||
if (!newKey) {
|
||||
showFieldError('edit-key', 'Key cannot be empty');
|
||||
return;
|
||||
}
|
||||
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(newKey)) {
|
||||
showFieldError('edit-key', 'Key must contain only lowercase letters, numbers, and hyphens');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate IDs
|
||||
const tmdbId = tmdbIdStr ? parseInt(tmdbIdStr, 10) : null;
|
||||
const tvdbId = tvdbIdStr ? parseInt(tvdbIdStr, 10) : null;
|
||||
|
||||
if (tmdbIdStr && (isNaN(tmdbId) || tmdbId < 1)) {
|
||||
showFieldError('edit-tmdb-id', 'TMDB ID must be a positive number');
|
||||
return;
|
||||
}
|
||||
if (tvdbIdStr && (isNaN(tvdbId) || tvdbId < 1)) {
|
||||
showFieldError('edit-tvdb-id', 'TVDB ID must be a positive number');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if key changed — show confirmation
|
||||
if (newKey !== originalData.key) {
|
||||
const confirmed = await AniWorld.UI.showConfirmModal(
|
||||
'Rename Series Key',
|
||||
`Changing the key from "${originalData.key}" to "${newKey}" will update the primary identifier. ` +
|
||||
'This may affect provider linkage. Are you sure?'
|
||||
);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
// Build update payload (only changed fields)
|
||||
const payload = {};
|
||||
if (newKey !== originalData.key) payload.key = newKey;
|
||||
if (tmdbId !== originalData.tmdb_id) payload.tmdb_id = tmdbId;
|
||||
if (tvdbId !== originalData.tvdb_id) payload.tvdb_id = tvdbId;
|
||||
|
||||
if (Object.keys(payload).length === 0) {
|
||||
AniWorld.UI.showToast('No changes to save', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
// Send update
|
||||
setSaveLoading(true);
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.put(
|
||||
'/api/anime/' + encodeURIComponent(currentKey),
|
||||
payload
|
||||
);
|
||||
|
||||
if (!response) return;
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
AniWorld.UI.showToast('Metadata updated successfully', 'success');
|
||||
|
||||
// Update local state
|
||||
const oldKey = currentKey;
|
||||
currentKey = result.key;
|
||||
originalData = {
|
||||
key: result.key,
|
||||
tmdb_id: result.tmdb_id,
|
||||
tvdb_id: result.tvdb_id,
|
||||
};
|
||||
|
||||
// Update the card in the DOM
|
||||
updateCardAfterSave(oldKey, result);
|
||||
|
||||
// Update repair button state
|
||||
updateRepairButtonState();
|
||||
|
||||
} else if (response.status === 409) {
|
||||
showFieldError('edit-key', 'A series with this key already exists');
|
||||
} else if (response.status === 422) {
|
||||
const err = await response.json();
|
||||
AniWorld.UI.showToast('Validation error: ' + (err.detail || 'Invalid input'), 'error');
|
||||
} else {
|
||||
AniWorld.UI.showToast('Failed to update metadata', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
AniWorld.UI.showToast('Connection error. Check your network.', 'error');
|
||||
console.error('Save error:', err);
|
||||
} finally {
|
||||
setSaveLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger NFO repair for the current series.
|
||||
*/
|
||||
async function repairNfo() {
|
||||
setRepairLoading(true);
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.post(
|
||||
'/api/nfo/' + encodeURIComponent(currentKey) + '/repair',
|
||||
{}
|
||||
);
|
||||
|
||||
if (!response) return;
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
AniWorld.UI.showToast(result.message, 'success');
|
||||
|
||||
// Refresh diagnostics
|
||||
await loadDiagnostics(currentKey);
|
||||
} else if (response.status === 400) {
|
||||
const err = await response.json();
|
||||
AniWorld.UI.showToast(err.detail || 'Cannot repair NFO', 'error');
|
||||
} else {
|
||||
AniWorld.UI.showToast('Failed to repair NFO', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
AniWorld.UI.showToast('Connection error during repair', 'error');
|
||||
console.error('Repair error:', err);
|
||||
} finally {
|
||||
setRepairLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load NFO diagnostics for the current series.
|
||||
* @param {string} key - Series key
|
||||
*/
|
||||
async function loadDiagnostics(key) {
|
||||
const container = document.getElementById('nfo-diagnostics-container');
|
||||
if (!container) return;
|
||||
|
||||
try {
|
||||
const response = await AniWorld.ApiClient.get(
|
||||
'/api/nfo/' + encodeURIComponent(key) + '/diagnostics'
|
||||
);
|
||||
|
||||
if (!response || !response.ok) {
|
||||
container.innerHTML = '<p class="nfo-error">Failed to load NFO diagnostics</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
renderDiagnostics(data);
|
||||
updateRepairButtonState();
|
||||
|
||||
} catch (err) {
|
||||
container.innerHTML = '<p class="nfo-error">Error loading diagnostics</p>';
|
||||
console.error('Diagnostics error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render NFO diagnostics data into the modal.
|
||||
* @param {Object} data - NfoDiagnosticsResponse
|
||||
*/
|
||||
function renderDiagnostics(data) {
|
||||
const badge = document.getElementById('nfo-status-badge');
|
||||
const tagsList = document.getElementById('nfo-missing-tags');
|
||||
|
||||
if (badge) {
|
||||
if (!data.has_nfo) {
|
||||
badge.className = 'nfo-status-badge nfo-missing';
|
||||
badge.textContent = 'No NFO File';
|
||||
} else if (data.missing_tags.length === 0) {
|
||||
badge.className = 'nfo-status-badge nfo-complete';
|
||||
badge.textContent = 'Complete';
|
||||
} else {
|
||||
badge.className = 'nfo-status-badge nfo-incomplete';
|
||||
badge.textContent = data.missing_tags.length + ' Missing';
|
||||
}
|
||||
}
|
||||
|
||||
if (tagsList) {
|
||||
if (data.missing_tags.length === 0) {
|
||||
tagsList.innerHTML = '<p class="nfo-all-good">All required tags present</p>';
|
||||
} else {
|
||||
tagsList.innerHTML = data.missing_tags.map(function(tag) {
|
||||
return '<span class="missing-tag-chip">' + escapeHtml(tag) + '</span>';
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update repair button disabled state based on tmdb_id field.
|
||||
*/
|
||||
function updateRepairButtonState() {
|
||||
const btn = document.getElementById('btn-repair-nfo');
|
||||
const hint = document.getElementById('repair-hint');
|
||||
const tmdbValue = getFieldValue('edit-tmdb-id').trim();
|
||||
|
||||
if (btn) {
|
||||
// Enable repair even without tmdb_id — the service can search by name
|
||||
btn.disabled = false;
|
||||
}
|
||||
if (hint) {
|
||||
hint.style.display = tmdbValue ? 'none' : 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
function findSeriesData(key) {
|
||||
// Access the series data from the series manager if available
|
||||
if (AniWorld.SeriesManager && AniWorld.SeriesManager.getSeriesData) {
|
||||
const allSeries = AniWorld.SeriesManager.getSeriesData();
|
||||
if (allSeries) {
|
||||
return allSeries.find(function(s) { return s.key === key; });
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function updateCardAfterSave(oldKey, result) {
|
||||
const card = document.querySelector('[data-series-id="' + oldKey + '"]');
|
||||
if (card) {
|
||||
card.setAttribute('data-key', result.key);
|
||||
card.setAttribute('data-series-id', result.key);
|
||||
// Update checkbox data-key
|
||||
const checkbox = card.querySelector('.series-checkbox');
|
||||
if (checkbox) {
|
||||
checkbox.setAttribute('data-key', result.key);
|
||||
}
|
||||
}
|
||||
|
||||
// Update local series data array
|
||||
if (AniWorld.SeriesManager && AniWorld.SeriesManager.updateSeriesKey) {
|
||||
AniWorld.SeriesManager.updateSeriesKey(oldKey, result.key);
|
||||
}
|
||||
}
|
||||
|
||||
function setFieldValue(id, value) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = value !== null && value !== undefined ? value : '';
|
||||
}
|
||||
|
||||
function getFieldValue(id) {
|
||||
const el = document.getElementById(id);
|
||||
return el ? el.value : '';
|
||||
}
|
||||
|
||||
function showFieldError(fieldId, message) {
|
||||
const el = document.getElementById(fieldId);
|
||||
if (el) {
|
||||
const errorEl = el.parentElement.querySelector('.field-error');
|
||||
if (errorEl) {
|
||||
errorEl.textContent = message;
|
||||
errorEl.style.display = 'block';
|
||||
}
|
||||
el.classList.add('input-error');
|
||||
}
|
||||
}
|
||||
|
||||
function clearErrors() {
|
||||
if (!modalElement) return;
|
||||
modalElement.querySelectorAll('.field-error').forEach(function(el) {
|
||||
el.style.display = 'none';
|
||||
el.textContent = '';
|
||||
});
|
||||
modalElement.querySelectorAll('.input-error').forEach(function(el) {
|
||||
el.classList.remove('input-error');
|
||||
});
|
||||
}
|
||||
|
||||
function hideKeyWarning() {
|
||||
const warning = document.getElementById('key-change-warning');
|
||||
if (warning) warning.style.display = 'none';
|
||||
}
|
||||
|
||||
function setLoading(loading) {
|
||||
const form = document.getElementById('edit-metadata-form');
|
||||
if (form) {
|
||||
form.style.opacity = loading ? '0.5' : '1';
|
||||
form.style.pointerEvents = loading ? 'none' : 'auto';
|
||||
}
|
||||
}
|
||||
|
||||
function setSaveLoading(loading) {
|
||||
const btn = document.getElementById('btn-save-metadata');
|
||||
if (btn) {
|
||||
btn.disabled = loading;
|
||||
btn.innerHTML = loading
|
||||
? '<i class="fa-solid fa-spinner fa-spin"></i> Saving...'
|
||||
: '<i class="fa-solid fa-floppy-disk"></i> Save';
|
||||
}
|
||||
}
|
||||
|
||||
function setRepairLoading(loading) {
|
||||
const btn = document.getElementById('btn-repair-nfo');
|
||||
if (btn) {
|
||||
btn.disabled = loading;
|
||||
btn.innerHTML = loading
|
||||
? '<i class="fa-solid fa-spinner fa-spin"></i> Repairing...'
|
||||
: '<i class="fa-solid fa-wrench"></i> Repair NFO';
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
var div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Event listener management
|
||||
let listeners = [];
|
||||
|
||||
function attachListeners() {
|
||||
detachListeners();
|
||||
|
||||
const saveBtn = document.getElementById('btn-save-metadata');
|
||||
const cancelBtn = document.getElementById('btn-cancel-metadata');
|
||||
const repairBtn = document.getElementById('btn-repair-nfo');
|
||||
const overlay = modalElement ? modalElement.querySelector('.modal-overlay') : null;
|
||||
const keyInput = document.getElementById('edit-key');
|
||||
|
||||
if (saveBtn) {
|
||||
var saveFn = function() { save(); };
|
||||
saveBtn.addEventListener('click', saveFn);
|
||||
listeners.push({ el: saveBtn, event: 'click', fn: saveFn });
|
||||
}
|
||||
|
||||
if (cancelBtn) {
|
||||
var cancelFn = function() { close(); };
|
||||
cancelBtn.addEventListener('click', cancelFn);
|
||||
listeners.push({ el: cancelBtn, event: 'click', fn: cancelFn });
|
||||
}
|
||||
|
||||
if (repairBtn) {
|
||||
var repairFn = function() { repairNfo(); };
|
||||
repairBtn.addEventListener('click', repairFn);
|
||||
listeners.push({ el: repairBtn, event: 'click', fn: repairFn });
|
||||
}
|
||||
|
||||
if (overlay) {
|
||||
var overlayFn = function() { close(); };
|
||||
overlay.addEventListener('click', overlayFn);
|
||||
listeners.push({ el: overlay, event: 'click', fn: overlayFn });
|
||||
}
|
||||
|
||||
if (keyInput) {
|
||||
var keyFn = function() {
|
||||
var warning = document.getElementById('key-change-warning');
|
||||
if (warning) {
|
||||
warning.style.display = keyInput.value !== originalData.key ? 'block' : 'none';
|
||||
}
|
||||
};
|
||||
keyInput.addEventListener('input', keyFn);
|
||||
listeners.push({ el: keyInput, event: 'input', fn: keyFn });
|
||||
}
|
||||
}
|
||||
|
||||
function detachListeners() {
|
||||
listeners.forEach(function(l) {
|
||||
l.el.removeEventListener(l.event, l.fn);
|
||||
});
|
||||
listeners = [];
|
||||
}
|
||||
|
||||
return {
|
||||
open: open,
|
||||
close: close,
|
||||
save: save,
|
||||
repairNfo: repairNfo
|
||||
};
|
||||
})();
|
||||
@@ -35,6 +35,11 @@ AniWorld.SchedulerConfig = (function() {
|
||||
autoDownload.checked = config.auto_download_after_rescan || false;
|
||||
}
|
||||
|
||||
const folderNaming = document.getElementById('folder-scan-enabled');
|
||||
if (folderNaming) {
|
||||
folderNaming.checked = config.folder_naming_after_nfo_scan || false;
|
||||
}
|
||||
|
||||
// Update schedule day checkboxes
|
||||
const days = config.schedule_days || ['mon','tue','wed','thu','fri','sat','sun'];
|
||||
['mon','tue','wed','thu','fri','sat','sun'].forEach(function(day) {
|
||||
@@ -87,7 +92,8 @@ AniWorld.SchedulerConfig = (function() {
|
||||
enabled: enabled,
|
||||
schedule_time: scheduleTime,
|
||||
schedule_days: scheduleDays,
|
||||
auto_download_after_rescan: autoDownload
|
||||
auto_download_after_rescan: autoDownload,
|
||||
folder_naming_after_nfo_scan: document.getElementById('folder-scan-enabled') ? document.getElementById('folder-scan-enabled').checked : false
|
||||
};
|
||||
|
||||
const response = await AniWorld.ApiClient.post(API.SCHEDULER_CONFIG, payload);
|
||||
|
||||
@@ -549,6 +549,41 @@ AniWorld.SeriesManager = (function() {
|
||||
renderSeries();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a series from the local data arrays and re-render the grid.
|
||||
* Called after a successful delete or when receiving series_deleted WS event.
|
||||
* @param {string} key - Series key to remove
|
||||
*/
|
||||
function removeSeries(key) {
|
||||
if (!key) return;
|
||||
|
||||
var removedFromData = false;
|
||||
var removedFromFiltered = false;
|
||||
|
||||
if (seriesData) {
|
||||
var dataIdx = seriesData.findIndex(function(s) { return s.key === key; });
|
||||
if (dataIdx >= 0) {
|
||||
seriesData.splice(dataIdx, 1);
|
||||
removedFromData = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (filteredSeriesData) {
|
||||
var filteredIdx = filteredSeriesData.findIndex(function(s) { return s.key === key; });
|
||||
if (filteredIdx >= 0) {
|
||||
filteredSeriesData.splice(filteredIdx, 1);
|
||||
removedFromFiltered = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (removedFromData || removedFromFiltered) {
|
||||
console.info('[SeriesManager] Removed series from local state:', key);
|
||||
renderSeries();
|
||||
} else {
|
||||
console.warn('[SeriesManager] Series not found in local state:', key);
|
||||
}
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
init: init,
|
||||
@@ -560,6 +595,7 @@ AniWorld.SeriesManager = (function() {
|
||||
findByKey: findByKey,
|
||||
updateSeriesLoadingStatus: updateSeriesLoadingStatus,
|
||||
updateSingleSeries: updateSingleSeries,
|
||||
updateSeriesKey: updateSeriesKey
|
||||
updateSeriesKey: updateSeriesKey,
|
||||
removeSeries: removeSeries
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -136,13 +136,16 @@ AniWorld.IndexSocketHandler = (function() {
|
||||
// Series events
|
||||
socket.on(WS_EVENTS.SERIES_UPDATED, function(data) {
|
||||
console.log('Series updated:', data);
|
||||
|
||||
// Use the data directly to update the series instead of full refresh
|
||||
if (data && data.data && AniWorld.SeriesManager && AniWorld.SeriesManager.updateSingleSeries) {
|
||||
AniWorld.SeriesManager.updateSingleSeries(data.data);
|
||||
|
||||
// NOTE: websocket-client.js strips the outer {type, data, ...} wrapper
|
||||
// before emitting, so `data` here is the inner series data object
|
||||
// (e.g. {key, name, missing_episodes, ...}) — NOT {type, data, ...}.
|
||||
// AniWorld.SeriesManager.updateSingleSeries() expects this flat object.
|
||||
if (data && data.key && AniWorld.SeriesManager && AniWorld.SeriesManager.updateSingleSeries) {
|
||||
AniWorld.SeriesManager.updateSingleSeries(data);
|
||||
} else {
|
||||
// Fallback to full reload if data is incomplete
|
||||
console.warn('Incomplete series update data, falling back to full reload');
|
||||
console.warn('Incomplete series update data, falling back to full reload', data);
|
||||
if (AniWorld.SeriesManager && AniWorld.SeriesManager.loadSeries) {
|
||||
AniWorld.SeriesManager.loadSeries();
|
||||
}
|
||||
@@ -157,6 +160,15 @@ AniWorld.IndexSocketHandler = (function() {
|
||||
}
|
||||
});
|
||||
|
||||
// Series deleted event — remove the card from the UI
|
||||
socket.on(WS_EVENTS.SERIES_DELETED, function(data) {
|
||||
console.info('[SocketHandler] Series deleted:', data);
|
||||
if (data && data.key && AniWorld.SeriesManager && AniWorld.SeriesManager.removeSeries) {
|
||||
AniWorld.SeriesManager.removeSeries(data.key);
|
||||
AniWorld.UI.showToast('Series deleted: ' + (data.name || data.key), 'success');
|
||||
}
|
||||
});
|
||||
|
||||
// Download events
|
||||
socket.on(WS_EVENTS.DOWNLOAD_STARTED, function(data) {
|
||||
isDownloading = true;
|
||||
|
||||
650
src/server/web/static/js/pages/anime-settings.js
Normal file
650
src/server/web/static/js/pages/anime-settings.js
Normal file
@@ -0,0 +1,650 @@
|
||||
/**
|
||||
* AniWorld - Anime Settings Page Manager
|
||||
*
|
||||
* Handles the per-anime settings page reached via the right-click
|
||||
* context menu. Loads the current settings via
|
||||
* GET /api/anime/{key}/settings and saves changes via
|
||||
* PUT /api/anime/{key}/settings.
|
||||
*
|
||||
* Public API:
|
||||
* - init() : bind DOM events and start initial load
|
||||
* - loadSeries(key) : fetch settings for a series key
|
||||
* - saveSettings(opts) : PUT settings, opts.applyToNfo / opts.renameDisk
|
||||
* - regenerateNfo() : POST regenerate-nfo endpoint
|
||||
* - validateField(name, value) : client-side validation, returns error string or null
|
||||
* - populateForm(data) : fill the form from a payload
|
||||
* - showSaveSuccess(msg) : success toast
|
||||
* - showError(msg) : error toast
|
||||
*
|
||||
* Dependencies: shared/constants.js, shared/auth.js, shared/api-client.js,
|
||||
* shared/ui-utils.js
|
||||
*/
|
||||
var AniWorld = window.AniWorld || {};
|
||||
|
||||
AniWorld.AnimeSettingsManager = (function () {
|
||||
'use strict';
|
||||
|
||||
// API paths (kept in sync with constants.js)
|
||||
const API_BASE = '/api/anime';
|
||||
const API_NFO_BASE = '/api/nfo';
|
||||
|
||||
// Page state
|
||||
let currentKey = null;
|
||||
let currentData = null;
|
||||
let originalData = null;
|
||||
let elements = null;
|
||||
|
||||
/**
|
||||
* Initialize the page — bind events and start the initial load.
|
||||
*/
|
||||
function init() {
|
||||
ensureElements();
|
||||
bindEvents();
|
||||
|
||||
// Read ?key=... from the URL
|
||||
const url = new URL(window.location.href);
|
||||
currentKey = url.searchParams.get('key');
|
||||
|
||||
if (currentKey) {
|
||||
loadSeries(currentKey);
|
||||
} else {
|
||||
showNoKey();
|
||||
populateSeriesSelect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache the DOM elements we'll touch repeatedly.
|
||||
* Idempotent — safe to call from public functions that need elements.
|
||||
*/
|
||||
function ensureElements() {
|
||||
if (elements) return;
|
||||
const ids = [
|
||||
'no-key-section', 'loading-section', 'error-section',
|
||||
'settings-section', 'series-select', 'load-series-btn',
|
||||
'retry-btn', 'error-message', 'series-display-name',
|
||||
'badge-loading-status', 'badge-has-nfo', 'badge-episode-counts',
|
||||
'overview-key', 'overview-year', 'overview-loading-status',
|
||||
'overview-episode-count', 'overview-missing-count',
|
||||
'overview-nfo-created', 'overview-nfo-updated', 'overview-nfo-path',
|
||||
'field-name', 'field-folder', 'field-tmdb-id', 'field-tvdb-id',
|
||||
'field-site', 'hint-name', 'hint-folder', 'hint-tmdb-id',
|
||||
'hint-tvdb-id', 'hint-site',
|
||||
'save-db-btn', 'save-db-nfo-btn', 'reset-btn',
|
||||
'rename-disk-toggle',
|
||||
'regenerate-nfo-btn', 'view-nfo-btn', 'nfo-content',
|
||||
];
|
||||
const map = {};
|
||||
ids.forEach(function (id) {
|
||||
map[id] = document.getElementById(id);
|
||||
});
|
||||
elements = map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache the DOM elements we'll touch repeatedly.
|
||||
* @deprecated Use ensureElements() instead.
|
||||
*/
|
||||
function cacheElements() {
|
||||
ensureElements();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire up click handlers and escape-key dismissal.
|
||||
*/
|
||||
function bindEvents() {
|
||||
if (elements['load-series-btn']) {
|
||||
elements['load-series-btn'].addEventListener('click', function () {
|
||||
const v = elements['series-select'].value;
|
||||
if (v) {
|
||||
window.location.href = '/anime/settings?key=' +
|
||||
encodeURIComponent(v);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (elements['retry-btn']) {
|
||||
elements['retry-btn'].addEventListener('click', function () {
|
||||
if (currentKey) {
|
||||
loadSeries(currentKey);
|
||||
} else {
|
||||
showNoKey();
|
||||
}
|
||||
});
|
||||
}
|
||||
if (elements['save-db-btn']) {
|
||||
elements['save-db-btn'].addEventListener('click', function () {
|
||||
saveSettings({ applyToNfo: false });
|
||||
});
|
||||
}
|
||||
if (elements['save-db-nfo-btn']) {
|
||||
elements['save-db-nfo-btn'].addEventListener('click', function () {
|
||||
saveSettings({ applyToNfo: true });
|
||||
});
|
||||
}
|
||||
if (elements['reset-btn']) {
|
||||
elements['reset-btn'].addEventListener('click', function () {
|
||||
if (originalData) {
|
||||
populateForm(originalData);
|
||||
clearValidationHints();
|
||||
}
|
||||
});
|
||||
}
|
||||
if (elements['regenerate-nfo-btn']) {
|
||||
elements['regenerate-nfo-btn'].addEventListener('click',
|
||||
regenerateNfo);
|
||||
}
|
||||
if (elements['view-nfo-btn']) {
|
||||
elements['view-nfo-btn'].addEventListener('click', viewNfoContent);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the AnimeSettingsResponse for a series and populate the page.
|
||||
*
|
||||
* @param {string} key - Series unique key
|
||||
*/
|
||||
async function loadSeries(key) {
|
||||
ensureElements();
|
||||
if (!key) {
|
||||
showNoKey();
|
||||
return;
|
||||
}
|
||||
currentKey = key;
|
||||
showLoading();
|
||||
try {
|
||||
const token = AniWorld.Auth && AniWorld.Auth.getToken
|
||||
? AniWorld.Auth.getToken() : null;
|
||||
const headers = token
|
||||
? { 'Authorization': 'Bearer ' + token }
|
||||
: {};
|
||||
const resp = await fetch(
|
||||
API_BASE + '/' + encodeURIComponent(key) + '/settings',
|
||||
{ headers: headers, method: 'GET' }
|
||||
);
|
||||
if (resp.status === 401) {
|
||||
showError('Not authenticated — please log in again.');
|
||||
showErrorState('Authentication required.');
|
||||
// Redirect to login, preserving the intended destination
|
||||
setTimeout(function() {
|
||||
window.location.href = '/login?next=' + encodeURIComponent(window.location.href);
|
||||
}, 1500);
|
||||
return;
|
||||
}
|
||||
if (resp.status === 404) {
|
||||
showErrorState('Series not found: ' + key);
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
throw new Error('HTTP ' + resp.status + ': ' + text);
|
||||
}
|
||||
const data = await resp.json();
|
||||
currentData = data;
|
||||
// Deep clone for original-data reset
|
||||
originalData = JSON.parse(JSON.stringify(data));
|
||||
populateForm(data);
|
||||
showSettings();
|
||||
} catch (err) {
|
||||
console.error('Failed to load series settings:', err);
|
||||
showErrorState(err && err.message ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the current form contents via PUT /api/anime/{key}/settings.
|
||||
*
|
||||
* @param {Object} opts
|
||||
* @param {boolean} opts.applyToNfo - Regenerate tvshow.nfo after save
|
||||
* @param {boolean} [opts.renameDisk] - Also rename folder on disk
|
||||
*/
|
||||
async function saveSettings(opts) {
|
||||
ensureElements();
|
||||
if (!currentKey) {
|
||||
showError('No series selected.');
|
||||
return;
|
||||
}
|
||||
opts = opts || {};
|
||||
const renameDisk = !!(elements['rename-disk-toggle'] &&
|
||||
elements['rename-disk-toggle'].checked);
|
||||
|
||||
const payload = collectFormPayload();
|
||||
const validationError = validatePayload(payload);
|
||||
if (validationError) {
|
||||
showError(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
payload.apply_to_nfo = !!opts.applyToNfo;
|
||||
payload.rename_disk = renameDisk && payload.folder !== undefined &&
|
||||
payload.folder !== (currentData && currentData.folder);
|
||||
|
||||
try {
|
||||
const token = AniWorld.Auth && AniWorld.Auth.getToken
|
||||
? AniWorld.Auth.getToken() : null;
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (token) {
|
||||
headers['Authorization'] = 'Bearer ' + token;
|
||||
}
|
||||
const resp = await fetch(
|
||||
API_BASE + '/' + encodeURIComponent(currentKey) + '/settings',
|
||||
{
|
||||
headers: headers,
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
}
|
||||
);
|
||||
if (resp.status === 401) {
|
||||
showError('Not authenticated — please log in again.');
|
||||
return;
|
||||
}
|
||||
if (resp.status === 422) {
|
||||
const body = await resp.json().catch(function () { return {}; });
|
||||
showError('Validation failed: ' + (body.detail || resp.status));
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
throw new Error('HTTP ' + resp.status + ': ' + text);
|
||||
}
|
||||
const data = await resp.json();
|
||||
currentData = data;
|
||||
originalData = JSON.parse(JSON.stringify(data));
|
||||
populateForm(data);
|
||||
if (opts.applyToNfo) {
|
||||
showSaveSuccess('Settings saved and tvshow.nfo regenerated.');
|
||||
} else {
|
||||
showSaveSuccess('Settings saved to database.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to save settings:', err);
|
||||
showError('Save failed: ' + (err && err.message ? err.message : err));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call POST /api/anime/{key}/regenerate-nfo to regenerate tvshow.nfo.
|
||||
*/
|
||||
async function regenerateNfo() {
|
||||
ensureElements();
|
||||
if (!currentKey) {
|
||||
showError('No series selected.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const token = AniWorld.Auth && AniWorld.Auth.getToken
|
||||
? AniWorld.Auth.getToken() : null;
|
||||
const headers = {};
|
||||
if (token) {
|
||||
headers['Authorization'] = 'Bearer ' + token;
|
||||
}
|
||||
const resp = await fetch(
|
||||
API_BASE + '/' + encodeURIComponent(currentKey) +
|
||||
'/regenerate-nfo',
|
||||
{ headers: headers, method: 'POST' }
|
||||
);
|
||||
if (resp.status === 400) {
|
||||
const body = await resp.json().catch(function () { return {}; });
|
||||
showError('Cannot regenerate: ' + (body.detail || resp.status));
|
||||
return;
|
||||
}
|
||||
if (resp.status === 404) {
|
||||
showError('Series not found.');
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
throw new Error('HTTP ' + resp.status + ': ' + text);
|
||||
}
|
||||
const data = await resp.json();
|
||||
showSaveSuccess(data.message || 'NFO regenerated.');
|
||||
// Refresh data so the NFO badges update
|
||||
loadSeries(currentKey);
|
||||
} catch (err) {
|
||||
console.error('NFO regeneration failed:', err);
|
||||
showError('Regenerate failed: ' +
|
||||
(err && err.message ? err.message : err));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and display the raw tvshow.nfo XML in a <pre>.
|
||||
*/
|
||||
async function viewNfoContent() {
|
||||
if (!currentKey) {
|
||||
showError('No series selected.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const token = AniWorld.Auth && AniWorld.Auth.getToken
|
||||
? AniWorld.Auth.getToken() : null;
|
||||
const headers = { 'Accept': 'application/json' };
|
||||
if (token) {
|
||||
headers['Authorization'] = 'Bearer ' + token;
|
||||
}
|
||||
const resp = await fetch(
|
||||
API_NFO_BASE + '/' + encodeURIComponent(currentKey) + '/content',
|
||||
{ headers: headers, method: 'GET' }
|
||||
);
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
throw new Error('HTTP ' + resp.status + ': ' + text);
|
||||
}
|
||||
const data = await resp.json();
|
||||
const pre = elements['nfo-content'];
|
||||
if (pre) {
|
||||
pre.textContent = data.content || JSON.stringify(data, null, 2);
|
||||
pre.classList.remove('hidden');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch NFO content:', err);
|
||||
showError('Could not fetch NFO content: ' +
|
||||
(err && err.message ? err.message : err));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a single field. Returns null if valid, or an error message.
|
||||
*
|
||||
* @param {string} name Field name (name, folder, tmdb_id, tvdb_id, site)
|
||||
* @param {*} value Value from the form
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function validateField(name, value) {
|
||||
switch (name) {
|
||||
case 'name':
|
||||
if (value === '' || value == null) {
|
||||
return 'Name cannot be empty.';
|
||||
}
|
||||
if (typeof value === 'string' && value.length > 500) {
|
||||
return 'Name exceeds 500 characters.';
|
||||
}
|
||||
return null;
|
||||
case 'folder':
|
||||
if (value === '' || value == null) {
|
||||
return 'Folder cannot be empty.';
|
||||
}
|
||||
if (typeof value === 'string' && /\.\./.test(value)) {
|
||||
return 'Folder name cannot contain ".." (path traversal).';
|
||||
}
|
||||
if (typeof value === 'string' && /[<>:"|?*\x00]/.test(value)) {
|
||||
return 'Folder name contains invalid characters.';
|
||||
}
|
||||
return null;
|
||||
case 'tmdb_id':
|
||||
if (value === '' || value == null || value === undefined) {
|
||||
return null; // optional
|
||||
}
|
||||
const tmdb = Number(value);
|
||||
if (!Number.isFinite(tmdb) || !Number.isInteger(tmdb)) {
|
||||
return 'TMDB ID must be an integer.';
|
||||
}
|
||||
if (tmdb <= 0) {
|
||||
return 'TMDB ID must be a positive integer.';
|
||||
}
|
||||
if (tmdb > 9999999999) {
|
||||
return 'TMDB ID exceeds 10 digits.';
|
||||
}
|
||||
return null;
|
||||
case 'tvdb_id':
|
||||
if (value === '' || value == null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
const tvdb = Number(value);
|
||||
if (!Number.isFinite(tvdb) || !Number.isInteger(tvdb)) {
|
||||
return 'TVDB ID must be an integer.';
|
||||
}
|
||||
if (tvdb <= 0) {
|
||||
return 'TVDB ID must be a positive integer.';
|
||||
}
|
||||
if (tvdb > 9999999999) {
|
||||
return 'TVDB ID exceeds 10 digits.';
|
||||
}
|
||||
return null;
|
||||
case 'site':
|
||||
if (value && typeof value === 'string' && value.length > 500) {
|
||||
return 'Site URL exceeds 500 characters.';
|
||||
}
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the whole payload. Returns null if all fields valid, or the
|
||||
* first error message encountered.
|
||||
*
|
||||
* @param {Object} payload
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function validatePayload(payload) {
|
||||
const fields = ['name', 'folder', 'tmdb_id', 'tvdb_id', 'site'];
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
const name = fields[i];
|
||||
if (payload[name] === undefined) continue;
|
||||
const err = validateField(name, payload[name]);
|
||||
if (err) return name + ': ' + err;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate the form from a settings payload.
|
||||
*
|
||||
* @param {Object} data AnimeSettingsResponse dict
|
||||
*/
|
||||
function populateForm(data) {
|
||||
ensureElements();
|
||||
if (!data) return;
|
||||
|
||||
// Overview
|
||||
setText(elements['series-display-name'], data.name || '(unnamed)');
|
||||
setText(elements['overview-key'], data.key || '—');
|
||||
setText(elements['overview-year'], data.year || '—');
|
||||
setText(elements['overview-loading-status'],
|
||||
data.loading_status || '—');
|
||||
setText(elements['overview-episode-count'],
|
||||
data.episode_count != null ? String(data.episode_count) : '—');
|
||||
setText(elements['overview-missing-count'],
|
||||
data.missing_episode_count != null
|
||||
? String(data.missing_episode_count) : '—');
|
||||
setText(elements['overview-nfo-created'],
|
||||
data.nfo_created_at || '—');
|
||||
setText(elements['overview-nfo-updated'],
|
||||
data.nfo_updated_at || '—');
|
||||
setText(elements['overview-nfo-path'], data.nfo_path || '—');
|
||||
|
||||
// Badges
|
||||
const lstatus = elements['badge-loading-status'];
|
||||
if (lstatus) {
|
||||
lstatus.textContent = 'Loading: ' + (data.loading_status || '—');
|
||||
lstatus.className = 'status-badge ' +
|
||||
(data.loading_status === 'completed'
|
||||
? 'status-complete'
|
||||
: data.loading_status === 'failed'
|
||||
? 'status-failed'
|
||||
: 'status-pending');
|
||||
}
|
||||
const nfoBadge = elements['badge-has-nfo'];
|
||||
if (nfoBadge) {
|
||||
nfoBadge.textContent = data.has_nfo ? 'NFO ✓' : 'NFO ✗';
|
||||
nfoBadge.className = 'status-badge ' +
|
||||
(data.has_nfo ? 'status-complete' : 'status-incomplete');
|
||||
}
|
||||
const epBadge = elements['badge-episode-counts'];
|
||||
if (epBadge) {
|
||||
epBadge.textContent =
|
||||
(data.missing_episode_count || 0) + ' / ' +
|
||||
(data.episode_count || 0) + ' missing';
|
||||
epBadge.className = 'status-badge';
|
||||
}
|
||||
|
||||
// Editable inputs
|
||||
setValue(elements['field-name'], data.name || '');
|
||||
setValue(elements['field-folder'], data.folder || '');
|
||||
setValue(elements['field-tmdb-id'],
|
||||
data.tmdb_id != null ? data.tmdb_id : '');
|
||||
setValue(elements['field-tvdb-id'],
|
||||
data.tvdb_id != null ? data.tvdb_id : '');
|
||||
setValue(elements['field-site'], data.site || '');
|
||||
|
||||
clearValidationHints();
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect current form values into a partial payload (omits empty
|
||||
* string / null fields so the server treats them as no-change).
|
||||
*/
|
||||
function collectFormPayload() {
|
||||
const payload = {};
|
||||
const setIfPresent = function (key, raw) {
|
||||
if (raw === undefined || raw === null) return;
|
||||
const trimmed = typeof raw === 'string' ? raw.trim() : raw;
|
||||
if (trimmed === '' || trimmed === null) return;
|
||||
payload[key] = typeof raw === 'string' ? trimmed : raw;
|
||||
};
|
||||
setIfPresent('name', elements['field-name'].value);
|
||||
setIfPresent('folder', elements['field-folder'].value);
|
||||
setIfPresent('tmdb_id', elements['field-tmdb-id'].value);
|
||||
setIfPresent('tvdb_id', elements['field-tvdb-id'].value);
|
||||
setIfPresent('site', elements['field-site'].value);
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate the series-select dropdown with options for keys without
|
||||
* ?key=... in the URL.
|
||||
*/
|
||||
async function populateSeriesSelect() {
|
||||
const select = elements['series-select'];
|
||||
if (!select) return;
|
||||
select.innerHTML = '<option value="">Loading…</option>';
|
||||
try {
|
||||
const token = AniWorld.Auth && AniWorld.Auth.getToken
|
||||
? AniWorld.Auth.getToken() : null;
|
||||
const headers = token
|
||||
? { 'Authorization': 'Bearer ' + token }
|
||||
: {};
|
||||
const resp = await fetch(API_BASE + '?per_page=500', {
|
||||
headers: headers, method: 'GET',
|
||||
});
|
||||
if (!resp.ok) {
|
||||
select.innerHTML = '<option value="">Failed to load series</option>';
|
||||
return;
|
||||
}
|
||||
const list = await resp.json();
|
||||
select.innerHTML = '<option value="">Select a series…</option>' +
|
||||
list.map(function (s) {
|
||||
return '<option value="' + escapeHtml(s.key) + '">' +
|
||||
escapeHtml(s.name || s.key) + '</option>';
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
console.error('Failed to populate series select:', err);
|
||||
select.innerHTML = '<option value="">Failed to load series</option>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a success toast via shared UI utilities.
|
||||
*/
|
||||
function showSaveSuccess(msg) {
|
||||
if (AniWorld.UI && AniWorld.UI.showToast) {
|
||||
AniWorld.UI.showToast(msg, 'success');
|
||||
} else {
|
||||
console.info('[AnimeSettings] ' + msg);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show an error toast via shared UI utilities.
|
||||
*/
|
||||
function showError(msg) {
|
||||
if (AniWorld.UI && AniWorld.UI.showToast) {
|
||||
AniWorld.UI.showToast(msg, 'error');
|
||||
} else {
|
||||
console.error('[AnimeSettings] ' + msg);
|
||||
}
|
||||
}
|
||||
|
||||
// View-state helpers --------------------------------------------------
|
||||
|
||||
function showLoading() {
|
||||
showOnly('loading-section');
|
||||
}
|
||||
function showSettings() {
|
||||
showOnly('settings-section');
|
||||
}
|
||||
function showNoKey() {
|
||||
showOnly('no-key-section');
|
||||
}
|
||||
function showErrorState(msg) {
|
||||
showOnly('error-section');
|
||||
if (elements['error-message']) {
|
||||
elements['error-message'].textContent = msg || 'Unknown error.';
|
||||
}
|
||||
}
|
||||
function showOnly(id) {
|
||||
const sections = ['no-key-section', 'loading-section',
|
||||
'error-section', 'settings-section'];
|
||||
sections.forEach(function (s) {
|
||||
const el = document.getElementById(s);
|
||||
if (!el) return;
|
||||
if (s === id) {
|
||||
el.classList.remove('hidden');
|
||||
} else {
|
||||
el.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function clearValidationHints() {
|
||||
['hint-name', 'hint-folder', 'hint-tmdb-id',
|
||||
'hint-tvdb-id', 'hint-site'].forEach(function (id) {
|
||||
const el = elements[id];
|
||||
if (el) {
|
||||
el.textContent = '';
|
||||
el.classList.remove('hint-error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setText(el, text) {
|
||||
if (el) el.textContent = text;
|
||||
}
|
||||
function setValue(el, text) {
|
||||
if (el) el.value = text;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
if (s == null) return '';
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Public API ----------------------------------------------------------
|
||||
|
||||
return {
|
||||
init: init,
|
||||
loadSeries: loadSeries,
|
||||
saveSettings: saveSettings,
|
||||
regenerateNfo: regenerateNfo,
|
||||
validateField: validateField,
|
||||
populateForm: populateForm,
|
||||
showSaveSuccess: showSaveSuccess,
|
||||
showError: showError,
|
||||
};
|
||||
})();
|
||||
|
||||
// Bootstrap on DOMContentLoaded — only register the listener.
|
||||
// Tests call AnimeSettingsManager.init() explicitly after seeding the DOM.
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
if (AniWorld.AnimeSettingsManager && AniWorld.AnimeSettingsManager.init) {
|
||||
AniWorld.AnimeSettingsManager.init();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -17,12 +17,15 @@ AniWorld.QueueApp = (function() {
|
||||
async function init() {
|
||||
console.log('AniWorld Queue App initializing...');
|
||||
|
||||
// Check authentication first
|
||||
// Check authentication first - this stores token in localStorage
|
||||
const isAuthenticated = await AniWorld.Auth.checkAuth();
|
||||
if (!isAuthenticated) {
|
||||
return; // Auth module handles redirect
|
||||
}
|
||||
|
||||
// Short delay to ensure token is available in localStorage
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Initialize theme
|
||||
AniWorld.Theme.init();
|
||||
|
||||
@@ -120,10 +123,14 @@ AniWorld.QueueApp = (function() {
|
||||
* Load queue data and update display
|
||||
*/
|
||||
async function loadQueueData() {
|
||||
const data = await AniWorld.QueueAPI.loadQueueData();
|
||||
if (data) {
|
||||
AniWorld.QueueRenderer.updateQueueDisplay(data);
|
||||
AniWorld.ProgressHandler.processPendingProgressUpdates();
|
||||
try {
|
||||
const data = await AniWorld.QueueAPI.loadQueueData();
|
||||
if (data) {
|
||||
AniWorld.QueueRenderer.updateQueueDisplay(data);
|
||||
AniWorld.ProgressHandler.processPendingProgressUpdates();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error loading queue data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -104,6 +104,7 @@ AniWorld.Constants = (function() {
|
||||
// Series events
|
||||
SERIES_UPDATED: 'series_updated',
|
||||
SERIES_LOADING_UPDATE: 'series_loading_update',
|
||||
SERIES_DELETED: 'series_deleted',
|
||||
|
||||
// Scheduled scan events
|
||||
SCHEDULED_RESCAN_STARTED: 'scheduled_rescan_started',
|
||||
|
||||
@@ -32,6 +32,7 @@ AniWorld.UI = (function() {
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast ' + type;
|
||||
toast.setAttribute('data-testid', 'toast');
|
||||
toast.innerHTML =
|
||||
'<div style="display: flex; justify-content: space-between; align-items: center;">' +
|
||||
'<span>' + escapeHtml(message) + '</span>' +
|
||||
|
||||
248
src/server/web/templates/anime-settings.html
Normal file
248
src/server/web/templates/anime-settings.html
Normal file
@@ -0,0 +1,248 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Anime Settings - AniWorld Manager</title>
|
||||
<link rel="stylesheet" href="/static/css/styles.css?v={{ static_version('css/styles.css') }}">
|
||||
<link rel="stylesheet" href="/static/css/pages/anime-settings.css?v={{ static_version('css/pages/anime-settings.css') }}">
|
||||
<link rel="stylesheet" href="/static/css/ux_features.css?v={{ static_version('css/ux_features.css') }}">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<div class="header-content">
|
||||
<div class="header-title">
|
||||
<i class="fas fa-gear"></i>
|
||||
<h1>Anime Settings</h1>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a href="/" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
<span>Back to Library</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main-content anime-settings-main">
|
||||
<!-- Series key selector (when no key in URL) -->
|
||||
<section id="no-key-section" class="settings-section hidden">
|
||||
<h2>Select a Series</h2>
|
||||
<p class="config-hint">
|
||||
No series selected. Right-click any series card on the
|
||||
library page and choose <strong>Anime Settings</strong>,
|
||||
or use the dropdown below.
|
||||
</p>
|
||||
<div class="config-item">
|
||||
<label for="series-select">Series:</label>
|
||||
<select id="series-select" class="input-field">
|
||||
<option value="">Loading series...</option>
|
||||
</select>
|
||||
<button id="load-series-btn" class="btn btn-primary">
|
||||
<i class="fas fa-folder-open"></i>
|
||||
<span>Open Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Loading state -->
|
||||
<section id="loading-section" class="settings-section">
|
||||
<div class="loading-spinner">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
<p>Loading series settings...</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Error state -->
|
||||
<section id="error-section" class="settings-section hidden">
|
||||
<div class="error-box">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<h2>Could not load settings</h2>
|
||||
<p id="error-message">Unknown error.</p>
|
||||
<button id="retry-btn" class="btn btn-primary">
|
||||
<i class="fas fa-rotate"></i>
|
||||
<span>Retry</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Main settings view -->
|
||||
<section id="settings-section" class="settings-section hidden">
|
||||
<!-- Header card with name + status badges -->
|
||||
<div class="settings-header-card">
|
||||
<h2 id="series-display-name">Loading...</h2>
|
||||
<div class="status-badges">
|
||||
<span id="badge-loading-status" class="status-badge"></span>
|
||||
<span id="badge-has-nfo" class="status-badge"></span>
|
||||
<span id="badge-episode-counts" class="status-badge"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overview (read-only) -->
|
||||
<div class="settings-section-card">
|
||||
<h3>
|
||||
<i class="fas fa-info-circle"></i>
|
||||
Overview
|
||||
</h3>
|
||||
<div class="settings-grid">
|
||||
<div class="settings-field">
|
||||
<label>Key (provider ID)</label>
|
||||
<code id="overview-key" class="value-mono">—</code>
|
||||
<small class="config-hint">
|
||||
Provider-assigned URL-safe identifier. Read-only.
|
||||
</small>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Year</label>
|
||||
<span id="overview-year" class="value">—</span>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Loading Status</label>
|
||||
<span id="overview-loading-status" class="value">—</span>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Episode Count</label>
|
||||
<span id="overview-episode-count" class="value">—</span>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Missing Episodes</label>
|
||||
<span id="overview-missing-count" class="value">—</span>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>NFO Created</label>
|
||||
<span id="overview-nfo-created" class="value">—</span>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>NFO Updated</label>
|
||||
<span id="overview-nfo-updated" class="value">—</span>
|
||||
</div>
|
||||
<div class="settings-field full-width">
|
||||
<label>NFO Path</label>
|
||||
<code id="overview-nfo-path" class="value-mono">—</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editable fields -->
|
||||
<div class="settings-section-card">
|
||||
<h3>
|
||||
<i class="fas fa-pen-to-square"></i>
|
||||
Editable Fields
|
||||
</h3>
|
||||
<p class="config-hint">
|
||||
Changes are saved to the database. Use the action
|
||||
buttons below to also rename the on-disk folder or
|
||||
regenerate tvshow.nfo.
|
||||
</p>
|
||||
|
||||
<div class="settings-grid">
|
||||
<div class="settings-field">
|
||||
<label for="field-name">Name</label>
|
||||
<input type="text" id="field-name" class="input-field"
|
||||
data-field="name" maxlength="500">
|
||||
<small id="hint-name" class="config-hint"></small>
|
||||
</div>
|
||||
|
||||
<div class="settings-field">
|
||||
<label for="field-folder">Folder</label>
|
||||
<input type="text" id="field-folder" class="input-field"
|
||||
data-field="folder" maxlength="1000">
|
||||
<small id="hint-folder" class="config-hint"></small>
|
||||
</div>
|
||||
|
||||
<div class="settings-field">
|
||||
<label for="field-tmdb-id">TMDB ID</label>
|
||||
<input type="number" id="field-tmdb-id" class="input-field"
|
||||
data-field="tmdb_id" min="1" max="9999999999" step="1">
|
||||
<small id="hint-tmdb-id" class="config-hint">
|
||||
Positive integer up to 10 digits.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="settings-field">
|
||||
<label for="field-tvdb-id">TVDB ID</label>
|
||||
<input type="number" id="field-tvdb-id" class="input-field"
|
||||
data-field="tvdb_id" min="1" max="9999999999" step="1">
|
||||
<small id="hint-tvdb-id" class="config-hint">
|
||||
Optional. Positive integer up to 10 digits.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="settings-field full-width">
|
||||
<label for="field-site">Site URL</label>
|
||||
<input type="text" id="field-site" class="input-field"
|
||||
data-field="site" maxlength="500">
|
||||
<small id="hint-site" class="config-hint">
|
||||
Provider URL (e.g. https://aniworld.to/anime/stream/...)
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-actions">
|
||||
<button id="save-db-btn" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i>
|
||||
<span>Save to DB</span>
|
||||
</button>
|
||||
<button id="save-db-nfo-btn" class="btn btn-success">
|
||||
<i class="fas fa-save"></i>
|
||||
<i class="fas fa-file-lines"></i>
|
||||
<span>Save & Regenerate NFO</span>
|
||||
</button>
|
||||
<button id="reset-btn" class="btn btn-secondary">
|
||||
<i class="fas fa-undo"></i>
|
||||
<span>Reset</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-toggles">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="rename-disk-toggle">
|
||||
<span class="checkbox-custom"></span>
|
||||
<span>Also rename the folder on disk when folder changes</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NFO actions -->
|
||||
<div class="settings-section-card">
|
||||
<h3>
|
||||
<i class="fas fa-file-lines"></i>
|
||||
NFO Actions
|
||||
</h3>
|
||||
<p class="config-hint">
|
||||
tvshow.nfo is consumed by Plex / Jellyfin / Emby /
|
||||
Kodi. Use the buttons below to regenerate or view
|
||||
its contents.
|
||||
</p>
|
||||
<div class="settings-actions">
|
||||
<button id="regenerate-nfo-btn" class="btn btn-primary">
|
||||
<i class="fas fa-rotate"></i>
|
||||
<span>Regenerate tvshow.nfo</span>
|
||||
</button>
|
||||
<button id="view-nfo-btn" class="btn btn-secondary">
|
||||
<i class="fas fa-eye"></i>
|
||||
<span>View NFO XML</span>
|
||||
</button>
|
||||
</div>
|
||||
<pre id="nfo-content" class="nfo-content hidden"></pre>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Toast notifications -->
|
||||
<div id="toast-container" class="toast-container"></div>
|
||||
</div>
|
||||
|
||||
<!-- Shared modules -->
|
||||
<script src="/static/js/shared/constants.js?v={{ static_version('js/shared/constants.js') }}"></script>
|
||||
<script src="/static/js/shared/auth.js?v={{ static_version('js/shared/auth.js') }}"></script>
|
||||
<script src="/static/js/shared/api-client.js?v={{ static_version('js/shared/api-client.js') }}"></script>
|
||||
<script src="/static/js/shared/ui-utils.js?v={{ static_version('js/shared/ui-utils.js') }}"></script>
|
||||
|
||||
<!-- Page script -->
|
||||
<script src="/static/js/pages/anime-settings.js?v={{ static_version('js/pages/anime-settings.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,11 +5,11 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AniWorld Manager</title>
|
||||
<link rel="stylesheet" href="/static/css/styles.css?v={{ static_v }}">
|
||||
<link rel="stylesheet" href="/static/css/styles.css?v={{ static_version('css/styles.css') }}">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||
|
||||
<!-- UX Enhancement and Mobile & Accessibility CSS -->
|
||||
<link rel="stylesheet" href="/static/css/ux_features.css?v={{ static_v }}">
|
||||
<link rel="stylesheet" href="/static/css/ux_features.css?v={{ static_version('css/ux_features.css') }}">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -131,7 +131,11 @@
|
||||
<i class="fas fa-filter"></i>
|
||||
<span data-text="show-missing-only">Missing Episodes Only</span>
|
||||
</button>
|
||||
<button id="sort-alphabetical" class="btn btn-secondary" data-active="false">
|
||||
<button id="show-all-series" class="btn btn-secondary" data-active="true">
|
||||
<i class="fas fa-list"></i>
|
||||
<span data-text="show-all-series">Show All</span>
|
||||
</button>
|
||||
<button id="sort-alphabetical" class="btn btn-secondary" data-active="false">
|
||||
<i class="fas fa-sort-alpha-down"></i>
|
||||
<span data-text="sort-alphabetical">A-Z Sort</span>
|
||||
</button>
|
||||
@@ -520,6 +524,16 @@
|
||||
<span data-text="test-tmdb">Test TMDB Connection</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="config-item" style="margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--color-border);">
|
||||
<a href="/anime/settings" class="btn btn-secondary" style="text-decoration: none;">
|
||||
<i class="fas fa-gear"></i>
|
||||
<span data-text="open-anime-settings">Open Anime Settings</span>
|
||||
</a>
|
||||
<small class="config-hint" data-text="anime-settings-hint">
|
||||
Right-click any series card to open its Anime Settings page (view & edit key, tmdb_id, folder, etc.)
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backup Configuration -->
|
||||
@@ -640,80 +654,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Metadata Modal -->
|
||||
<div id="edit-metadata-modal" class="modal hidden">
|
||||
<div class="modal-overlay"></div>
|
||||
<div class="modal-content edit-modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Edit Metadata</h3>
|
||||
<button id="btn-cancel-metadata" class="btn btn-icon">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="edit-metadata-form" onsubmit="return false;">
|
||||
<!-- Identity Section -->
|
||||
<div class="edit-section">
|
||||
<h4><i class="fa-solid fa-key"></i> Identity</h4>
|
||||
<div class="form-group">
|
||||
<label for="edit-key">Series Key</label>
|
||||
<input type="text" id="edit-key" class="input-field"
|
||||
placeholder="e.g. attack-on-titan"
|
||||
pattern="[a-z0-9][a-z0-9-]*[a-z0-9]">
|
||||
<span class="field-error" style="display:none;"></span>
|
||||
</div>
|
||||
<div id="key-change-warning" class="key-warning" style="display:none;">
|
||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||
Changing the key will update the primary identifier. This may affect provider linkage.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- External IDs Section -->
|
||||
<div class="edit-section">
|
||||
<h4><i class="fa-solid fa-database"></i> External IDs</h4>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="edit-tmdb-id">TMDB ID</label>
|
||||
<input type="number" id="edit-tmdb-id" class="input-field"
|
||||
placeholder="e.g. 1429" min="1">
|
||||
<span class="field-error" style="display:none;"></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-tvdb-id">TVDB ID</label>
|
||||
<input type="number" id="edit-tvdb-id" class="input-field"
|
||||
placeholder="e.g. 267440" min="1">
|
||||
<span class="field-error" style="display:none;"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NFO Status Section -->
|
||||
<div class="edit-section">
|
||||
<h4><i class="fa-solid fa-file-lines"></i> NFO Status</h4>
|
||||
<div class="nfo-diagnostics">
|
||||
<div id="nfo-status-badge" class="nfo-status-badge">Loading...</div>
|
||||
<div id="nfo-diagnostics-container">
|
||||
<div id="nfo-missing-tags" class="missing-tags-list"></div>
|
||||
</div>
|
||||
<p id="repair-hint" class="repair-hint" style="display:none;">
|
||||
<i class="fa-solid fa-circle-info"></i>
|
||||
No TMDB ID set. Repair will search TMDB by series name.
|
||||
</p>
|
||||
<button type="button" id="btn-repair-nfo" class="btn btn-secondary btn-repair">
|
||||
<i class="fa-solid fa-wrench"></i> Repair NFO
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" id="btn-save-metadata" class="btn btn-primary">
|
||||
<i class="fa-solid fa-floppy-disk"></i> Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast notifications -->
|
||||
<div id="toast-container" class="toast-container"></div>
|
||||
</div>
|
||||
@@ -727,22 +667,21 @@
|
||||
</div>
|
||||
|
||||
<!-- Shared Modules (load in dependency order) -->
|
||||
<script src="/static/js/shared/constants.js?v={{ static_v }}"></script>
|
||||
<script src="/static/js/shared/auth.js?v={{ static_v }}"></script>
|
||||
<script src="/static/js/shared/api-client.js?v={{ static_v }}"></script>
|
||||
<script src="/static/js/shared/theme.js?v={{ static_v }}"></script>
|
||||
<script src="/static/js/shared/ui-utils.js?v={{ static_v }}"></script>
|
||||
<script src="/static/js/shared/websocket-client.js?v={{ static_v }}"></script>
|
||||
<script src="/static/js/shared/constants.js?v={{ static_version('js/shared/constants.js') }}"></script>
|
||||
<script src="/static/js/shared/auth.js?v={{ static_version('js/shared/auth.js') }}"></script>
|
||||
<script src="/static/js/shared/api-client.js?v={{ static_version('js/shared/api-client.js') }}"></script>
|
||||
<script src="/static/js/shared/theme.js?v={{ static_version('js/shared/theme.js') }}"></script>
|
||||
<script src="/static/js/shared/ui-utils.js?v={{ static_version('js/shared/ui-utils.js') }}"></script>
|
||||
<script src="/static/js/shared/websocket-client.js?v={{ static_version('js/shared/websocket-client.js') }}"></script>
|
||||
|
||||
<!-- External modules -->
|
||||
<script src="/static/js/localization.js?v={{ static_v }}"></script>
|
||||
<script src="/static/js/user_preferences.js?v={{ static_v }}"></script>
|
||||
<script src="/static/js/localization.js?v={{ static_version('js/localization.js') }}"></script>
|
||||
<script src="/static/js/user_preferences.js?v={{ static_version('js/user_preferences.js') }}"></script>
|
||||
|
||||
<!-- Index Page Modules -->
|
||||
<script src="/static/js/index/context-menu.js?v={{ static_v }}"></script>
|
||||
<script src="/static/js/index/edit-modal.js?v={{ static_v }}"></script>
|
||||
<script src="/static/js/index/series-manager.js?v={{ static_v }}"></script>
|
||||
<script src="/static/js/index/selection-manager.js?v={{ static_v }}"></script>
|
||||
<script src="/static/js/index/context-menu.js?v={{ static_version('js/index/context-menu.js') }}"></script>
|
||||
<script src="/static/js/index/series-manager.js?v={{ static_version('js/index/series-manager.js') }}"></script>
|
||||
<script src="/static/js/index/selection-manager.js?v={{ static_version('js/index/selection-manager.js') }}"></script>
|
||||
<script src="/static/js/index/search.js?v={{ static_v }}"></script>
|
||||
<script src="/static/js/index/scan-manager.js?v={{ static_v }}"></script>
|
||||
<script src="/static/js/index/nfo-manager.js?v={{ static_v }}"></script>
|
||||
@@ -754,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>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AniWorld Manager - Initializing</title>
|
||||
<link rel="stylesheet" href="/static/css/styles.css?v={{ static_v }}">
|
||||
<link rel="stylesheet" href="/static/css/styles.css?v={{ static_version('css/styles.css') }}">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||
<style>
|
||||
.loading-container {
|
||||
@@ -279,6 +279,10 @@
|
||||
let ws = null;
|
||||
const steps = new Map();
|
||||
let isComplete = false;
|
||||
|
||||
// Get phase from URL query parameter
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const currentPhase = urlParams.get('phase') || 'initial';
|
||||
|
||||
const stepOrder = [
|
||||
'series_sync',
|
||||
@@ -290,6 +294,68 @@
|
||||
'nfo_scan': 'Scanning NFO Files'
|
||||
};
|
||||
|
||||
// State management for setup flow
|
||||
const SETUP_STATES = {
|
||||
INITIAL: 'initial',
|
||||
UNRESOLVED: 'unresolved',
|
||||
NFO: 'nfo'
|
||||
};
|
||||
|
||||
function setSetupPhase(phase) {
|
||||
sessionStorage.setItem('setup_phase', phase);
|
||||
}
|
||||
|
||||
function getSetupPhase() {
|
||||
return sessionStorage.getItem('setup_phase');
|
||||
}
|
||||
|
||||
function clearSetupPhase() {
|
||||
sessionStorage.removeItem('setup_phase');
|
||||
}
|
||||
|
||||
function validateStateAndRedirect() {
|
||||
const storedPhase = getSetupPhase();
|
||||
if (storedPhase && storedPhase !== currentPhase) {
|
||||
// State mismatch - redirect to correct page based on stored phase
|
||||
if (storedPhase === SETUP_STATES.INITIAL) {
|
||||
window.location.href = '/loading?phase=initial';
|
||||
return false;
|
||||
} else if (storedPhase === SETUP_STATES.UNRESOLVED) {
|
||||
window.location.href = '/setup/unresolved';
|
||||
return false;
|
||||
} else if (storedPhase === SETUP_STATES.NFO) {
|
||||
window.location.href = '/loading?phase=nfo';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// For initial phase, we only show series_sync step
|
||||
// For nfo phase, we only show nfo_scan step
|
||||
function getStepsForPhase(phase) {
|
||||
if (phase === 'nfo') {
|
||||
return ['nfo_scan'];
|
||||
}
|
||||
return ['series_sync'];
|
||||
}
|
||||
|
||||
function triggerNfoScanPhase() {
|
||||
// Call API to trigger NFO scan phase
|
||||
fetch('/api/setup/nfo-scan-phase', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}).then(res => {
|
||||
if (!res.ok) {
|
||||
console.error('Failed to trigger NFO scan phase');
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('Error triggering NFO scan phase:', err);
|
||||
});
|
||||
}
|
||||
|
||||
function connectWebSocket() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws/connect`;
|
||||
@@ -300,21 +366,24 @@
|
||||
console.log('WebSocket connected');
|
||||
updateConnectionStatus(true);
|
||||
|
||||
// Subscribe to system room for progress updates
|
||||
ws.send(JSON.stringify({
|
||||
action: 'join',
|
||||
data: {
|
||||
room: 'system'
|
||||
}
|
||||
}));
|
||||
|
||||
// Subscribe to scan room for NFO scan progress
|
||||
ws.send(JSON.stringify({
|
||||
action: 'join',
|
||||
data: {
|
||||
room: 'scan'
|
||||
}
|
||||
}));
|
||||
// Subscribe to rooms based on phase
|
||||
if (currentPhase === 'nfo') {
|
||||
// For nfo phase, only subscribe to scan room
|
||||
ws.send(JSON.stringify({
|
||||
action: 'join',
|
||||
data: {
|
||||
room: 'scan'
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
// For initial phase (series_sync), subscribe to system room
|
||||
ws.send(JSON.stringify({
|
||||
action: 'join',
|
||||
data: {
|
||||
room: 'system'
|
||||
}
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
@@ -359,12 +428,18 @@
|
||||
const data = message.data || message;
|
||||
const { type, status, title, message: msg, percent, current, total, metadata } = data;
|
||||
|
||||
// Handle NFO scan events
|
||||
if (type === 'nfo_scan_started' || type === 'nfo_scan_progress' || type === 'nfo_scan_completed') {
|
||||
// For NFO phase, all events go to handleNfoScanUpdate
|
||||
if (currentPhase === 'nfo') {
|
||||
handleNfoScanUpdate(data);
|
||||
return;
|
||||
}
|
||||
|
||||
// For initial phase (series_sync), skip NFO scan events
|
||||
if (type === 'nfo_scan_started' || type === 'nfo_scan_progress' || type === 'nfo_scan_completed') {
|
||||
// Ignore NFO scan events during initial phase
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine step ID based on type and metadata
|
||||
let stepId = metadata?.step_id || type;
|
||||
|
||||
@@ -375,9 +450,12 @@
|
||||
|
||||
updateStep(stepId, status, msg, percent, current, total);
|
||||
|
||||
// Check for completion
|
||||
if (metadata?.initialization_complete) {
|
||||
showCompletion();
|
||||
// Check for completion of series_sync
|
||||
// For scan_completed messages: stepId='scan_completed', no status field, no metadata
|
||||
// system_progress events are emitted by progress_service during initial setup (ProgressType.SYSTEM)
|
||||
if (metadata?.initialization_complete || type === 'scan_completed' || type === 'system_progress' || (stepId === 'series_sync' && status === 'completed')) {
|
||||
// For initial phase, series_sync completion leads to /setup/unresolved
|
||||
handleSeriesSyncComplete();
|
||||
}
|
||||
|
||||
// Handle errors
|
||||
@@ -385,6 +463,22 @@
|
||||
showError(msg || 'An error occurred during initialization');
|
||||
}
|
||||
}
|
||||
|
||||
function handleSeriesSyncComplete() {
|
||||
isComplete = true;
|
||||
document.getElementById('connectionStatus').style.display = 'none';
|
||||
|
||||
if (ws) {
|
||||
ws.close();
|
||||
}
|
||||
|
||||
// Clear the initial phase state
|
||||
clearSetupPhase();
|
||||
|
||||
// For initial phase, series_sync completion always leads to /setup/unresolved
|
||||
// The unresolved page will handle checking if there are folders or redirect to nfo phase
|
||||
window.location.href = '/setup/unresolved';
|
||||
}
|
||||
|
||||
function handleNfoScanUpdate(data) {
|
||||
const stepId = 'nfo_scan';
|
||||
@@ -404,7 +498,7 @@
|
||||
const progressTextEl = stepEl.querySelector('.progress-text');
|
||||
|
||||
const nfoData = data.data || data;
|
||||
const { status, message, current, total, key, folder } = nfoData;
|
||||
const { status, message, current, total, key, folder, metadata } = nfoData;
|
||||
|
||||
// Update status
|
||||
stepEl.className = 'progress-step';
|
||||
@@ -447,13 +541,26 @@
|
||||
progressTextEl.textContent = `${Math.round(percent)}%`;
|
||||
}
|
||||
|
||||
// Check for completion
|
||||
if (data.type === 'nfo_scan_completed') {
|
||||
setTimeout(() => {
|
||||
checkUnresolvedAndProceed();
|
||||
}, 1000);
|
||||
// Check for completion - handle based on phase
|
||||
if (data.type === 'nfo_scan_completed' || metadata?.nfo_scan_complete) {
|
||||
handleNfoPhaseComplete();
|
||||
}
|
||||
}
|
||||
|
||||
function handleNfoPhaseComplete() {
|
||||
isComplete = true;
|
||||
document.getElementById('connectionStatus').style.display = 'none';
|
||||
|
||||
if (ws) {
|
||||
ws.close();
|
||||
}
|
||||
|
||||
// Clear the NFO phase state
|
||||
clearSetupPhase();
|
||||
|
||||
// For NFO phase, completion always goes to login
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
function createStep(stepId, title) {
|
||||
const container = document.getElementById('progressContainer');
|
||||
@@ -595,8 +702,61 @@
|
||||
|
||||
// Start WebSocket connection when page loads
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
connectWebSocket();
|
||||
// Validate state and redirect if there's a mismatch
|
||||
if (!validateStateAndRedirect()) {
|
||||
return; // Redirect in progress
|
||||
}
|
||||
|
||||
// Set up the correct state for this phase
|
||||
if (currentPhase === 'nfo') {
|
||||
setSetupPhase(SETUP_STATES.NFO);
|
||||
} else {
|
||||
setSetupPhase(SETUP_STATES.INITIAL);
|
||||
}
|
||||
|
||||
// Initialize the correct steps based on phase
|
||||
const stepsForPhase = getStepsForPhase(currentPhase);
|
||||
if (stepsForPhase.length === 1 && stepsForPhase[0] === 'nfo_scan') {
|
||||
// For nfo phase, create the step and trigger the scan immediately
|
||||
createStep('nfo_scan', stepTitles['nfo_scan']);
|
||||
// Trigger NFO scan phase via API
|
||||
triggerNfoScanPhase();
|
||||
connectWebSocket();
|
||||
} else {
|
||||
// For initial phase, initialization already completed before this page loaded
|
||||
// Check for unresolved folders immediately and redirect
|
||||
checkUnresolvedAndRedirect();
|
||||
}
|
||||
});
|
||||
|
||||
// For initial phase, check if there are unresolved folders and redirect accordingly
|
||||
// This is needed because the backend initialization completes before this page loads,
|
||||
// so WebSocket events are missed
|
||||
async function checkUnresolvedAndRedirect() {
|
||||
try {
|
||||
const response = await fetch('/api/setup/unresolved');
|
||||
if (response.ok) {
|
||||
const folders = await response.json();
|
||||
if (folders.length > 0) {
|
||||
// Unresolved folders exist - redirect to unresolved page
|
||||
clearSetupPhase();
|
||||
window.location.href = '/setup/unresolved';
|
||||
} else {
|
||||
// No unresolved folders - redirect to login
|
||||
clearSetupPhase();
|
||||
window.location.href = '/login';
|
||||
}
|
||||
} else {
|
||||
// Error - stay on page and wait for potential WebSocket events
|
||||
console.error('Failed to check unresolved folders:', response.status);
|
||||
connectWebSocket();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking unresolved folders:', error);
|
||||
// Stay on page and wait for WebSocket events
|
||||
connectWebSocket();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AniWorld Manager - Login</title>
|
||||
<link rel="stylesheet" href="/static/css/styles.css?v={{ static_v }}">
|
||||
<link rel="stylesheet" href="/static/css/styles.css?v={{ static_version('css/styles.css') }}">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||
<style>
|
||||
.login-container {
|
||||
@@ -146,6 +146,11 @@
|
||||
border: 1px solid var(--color-error);
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
display: block;
|
||||
}
|
||||
|
||||
#login-error {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
@@ -225,11 +230,11 @@
|
||||
|
||||
<form class="login-form" id="login-form">
|
||||
<div class="form-group">
|
||||
<label for="password" class="form-label">Master Password</label>
|
||||
<label for="password-input" class="form-label">Master Password</label>
|
||||
<div class="password-input-group">
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
id="password-input"
|
||||
name="password"
|
||||
class="password-input"
|
||||
placeholder="Enter your password"
|
||||
@@ -242,9 +247,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="message-container"></div>
|
||||
<div id="login-error" class="message-container"></div>
|
||||
|
||||
<button type="submit" class="login-button" id="login-button">
|
||||
<button type="submit" class="login-button" id="login-submit-btn">
|
||||
<i class="fas fa-sign-in-alt"></i>
|
||||
<span>Login</span>
|
||||
</button>
|
||||
@@ -285,7 +290,7 @@
|
||||
|
||||
// Password visibility toggle
|
||||
const passwordToggle = document.getElementById('password-toggle');
|
||||
const passwordInput = document.getElementById('password');
|
||||
const passwordInput = document.getElementById('password-input');
|
||||
|
||||
passwordToggle.addEventListener('click', () => {
|
||||
const type = passwordInput.getAttribute('type');
|
||||
@@ -297,9 +302,8 @@
|
||||
});
|
||||
|
||||
// Form submission
|
||||
const loginForm = document.getElementById('login-form');
|
||||
const loginButton = document.getElementById('login-button');
|
||||
const messageContainer = document.getElementById('message-container');
|
||||
const loginForm = document.getElementById('login-form');
|
||||
const loginButton = document.getElementById('login-submit-btn');
|
||||
|
||||
loginForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
@@ -348,11 +352,13 @@
|
||||
});
|
||||
|
||||
function showMessage(message, type) {
|
||||
messageContainer.innerHTML = `
|
||||
const errorDiv = document.getElementById('login-error');
|
||||
errorDiv.innerHTML = `
|
||||
<div class="${type}-message">
|
||||
${message}
|
||||
</div>
|
||||
`;
|
||||
errorDiv.style.display = 'block';
|
||||
}
|
||||
|
||||
function setLoading(loading) {
|
||||
@@ -371,7 +377,9 @@
|
||||
|
||||
// Clear message on input
|
||||
passwordInput.addEventListener('input', () => {
|
||||
messageContainer.innerHTML = '';
|
||||
const errorDiv = document.getElementById('login-error');
|
||||
errorDiv.innerHTML = '';
|
||||
errorDiv.style.display = 'none';
|
||||
});
|
||||
|
||||
// Enter key on password toggle
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Download Queue - AniWorld Manager</title>
|
||||
<link rel="stylesheet" href="/static/css/styles.css?v={{ static_v }}">
|
||||
<link rel="stylesheet" href="/static/css/styles.css?v={{ static_version('css/styles.css') }}">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
@@ -234,19 +234,19 @@
|
||||
</div>
|
||||
|
||||
<!-- Shared Modules (load in dependency order) -->
|
||||
<script src="/static/js/shared/constants.js?v={{ static_v }}"></script>
|
||||
<script src="/static/js/shared/auth.js?v={{ static_v }}"></script>
|
||||
<script src="/static/js/shared/api-client.js?v={{ static_v }}"></script>
|
||||
<script src="/static/js/shared/theme.js?v={{ static_v }}"></script>
|
||||
<script src="/static/js/shared/ui-utils.js?v={{ static_v }}"></script>
|
||||
<script src="/static/js/shared/websocket-client.js?v={{ static_v }}"></script>
|
||||
<script src="/static/js/shared/constants.js?v={{ static_version('js/shared/constants.js') }}"></script>
|
||||
<script src="/static/js/shared/auth.js?v={{ static_version('js/shared/auth.js') }}"></script>
|
||||
<script src="/static/js/shared/api-client.js?v={{ static_version('js/shared/api-client.js') }}"></script>
|
||||
<script src="/static/js/shared/theme.js?v={{ static_version('js/shared/theme.js') }}"></script>
|
||||
<script src="/static/js/shared/ui-utils.js?v={{ static_version('js/shared/ui-utils.js') }}"></script>
|
||||
<script src="/static/js/shared/websocket-client.js?v={{ static_version('js/shared/websocket-client.js') }}"></script>
|
||||
|
||||
<!-- Queue Page Modules -->
|
||||
<script src="/static/js/queue/queue-api.js?v={{ static_v }}"></script>
|
||||
<script src="/static/js/queue/queue-renderer.js?v={{ static_v }}"></script>
|
||||
<script src="/static/js/queue/progress-handler.js?v={{ static_v }}"></script>
|
||||
<script src="/static/js/queue/queue-socket-handler.js?v={{ static_v }}"></script>
|
||||
<script src="/static/js/queue/queue-init.js?v={{ static_v }}"></script>
|
||||
<script src="/static/js/queue/queue-api.js?v={{ static_version('js/queue/queue-api.js') }}"></script>
|
||||
<script src="/static/js/queue/queue-renderer.js?v={{ static_version('js/queue/queue-renderer.js') }}"></script>
|
||||
<script src="/static/js/queue/progress-handler.js?v={{ static_version('js/queue/progress-handler.js') }}"></script>
|
||||
<script src="/static/js/queue/queue-socket-handler.js?v={{ static_version('js/queue/queue-socket-handler.js') }}"></script>
|
||||
<script src="/static/js/queue/queue-init.js?v={{ static_version('js/queue/queue-init.js') }}"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AniWorld Manager - Setup</title>
|
||||
<link rel="stylesheet" href="/static/css/styles.css?v={{ static_v }}">
|
||||
<link rel="stylesheet" href="/static/css/styles.css?v={{ static_version('css/styles.css') }}">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||
<style>
|
||||
.setup-container {
|
||||
@@ -479,6 +479,13 @@
|
||||
<span>Auto-download missing episodes after rescan</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-checkbox">
|
||||
<input type="checkbox" id="scheduler_folder_naming" name="scheduler_folder_naming">
|
||||
<span>Fix missing years in folder names after NFO refresh</span>
|
||||
</label>
|
||||
<div class="form-help">Renames folders (e.g. "Naruto" → "Naruto (1999)") using the year from the database.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -761,6 +768,7 @@
|
||||
scheduler_schedule_time: document.getElementById('scheduler_schedule_time').value || '03:00',
|
||||
scheduler_schedule_days: Array.from(document.querySelectorAll('.scheduler-day-setup-cb:checked')).map(cb => cb.value),
|
||||
scheduler_auto_download_after_rescan: document.getElementById('scheduler_auto_download').checked,
|
||||
scheduler_folder_naming_after_nfo_scan: document.getElementById('scheduler_folder_naming').checked,
|
||||
logging_level: document.getElementById('logging_level').value,
|
||||
logging_file: document.getElementById('logging_file').value.trim() || null,
|
||||
logging_max_bytes: document.getElementById('logging_max_bytes').value ?
|
||||
@@ -790,37 +798,14 @@
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.status === 'ok') {
|
||||
// Redirect to loading page if provided, otherwise check for unresolved folders
|
||||
if (data.redirect) {
|
||||
showMessage('Setup saved! Initializing your anime library...', 'success');
|
||||
setTimeout(() => {
|
||||
window.location.href = data.redirect;
|
||||
}, 500);
|
||||
} else {
|
||||
// Check for unresolved folders before redirecting
|
||||
showMessage('Setup completed successfully! Checking for unresolved series...', 'success');
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const res = await fetch('/api/setup/unresolved', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
if (res.ok) {
|
||||
const unresolved = await res.json();
|
||||
if (unresolved && unresolved.length > 0) {
|
||||
window.location.href = '/setup/unresolved';
|
||||
} else {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
} else {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error checking unresolved folders:', e);
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
// Always redirect to loading page with initial phase
|
||||
// The loading page will handle unresolved folder check
|
||||
showMessage('Setup saved! Initializing your anime library...', 'success');
|
||||
setTimeout(() => {
|
||||
// Set session storage state before redirecting
|
||||
sessionStorage.setItem('setup_phase', 'initial');
|
||||
window.location.href = '/loading?phase=initial';
|
||||
}, 500);
|
||||
} else {
|
||||
const errorMessage = data.detail || data.message || 'Setup failed';
|
||||
showMessage(errorMessage, 'error');
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AniWorld Manager - Resolve Series</title>
|
||||
<link rel="stylesheet" href="/static/css/styles.css?v={{ static_v }}">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/css/styles.css?v={{ static_version('css/styles.css') }}">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.6.0/css/all.min.css" rel="stylesheet">
|
||||
<style>
|
||||
.unresolved-container {
|
||||
min-height: 100vh;
|
||||
@@ -198,15 +198,27 @@
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.suggestion-link {
|
||||
.suggestion-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.suggestion-link:hover {
|
||||
.suggestion-btn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.suggestion-btn .key-label {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.8rem;
|
||||
margin-left: 0.3rem;
|
||||
}
|
||||
|
||||
.no-suggestions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -591,16 +603,21 @@
|
||||
// Render functions
|
||||
function renderFolderItem(folder) {
|
||||
const suggestionsHtml = folder.search_suggestions && folder.search_suggestions.length > 0
|
||||
? folder.search_suggestions.map(s => `
|
||||
? folder.search_suggestions.map(s => {
|
||||
console.log('[DEBUG] Rendering suggestion:', s);
|
||||
return `
|
||||
<div class="suggestion-item">
|
||||
<i class="fas fa-link"></i>
|
||||
<a href="${s.link}" class="suggestion-link" target="_blank">${s.name || s.title}</a>
|
||||
<i class="fas fa-hand-pointer"></i>
|
||||
<button class="suggestion-btn" data-provider-key="${s.link || s.provider_key || s.key || ''}" data-folder="${folder.folder_name}">
|
||||
${s.name || s.title} <span class="key-label">(${s.link || ''})</span>
|
||||
</button>
|
||||
</div>
|
||||
`).join('')
|
||||
`;
|
||||
}).join('')
|
||||
: '<div class="no-suggestions"><i class="fas fa-info-circle"></i> No suggestions found</div>';
|
||||
|
||||
const searchAgainBtn = (folder.search_suggestions && folder.search_suggestions.length === 0)
|
||||
? `<div class="search-again-row">
|
||||
// Always show search row so user can search multiple times
|
||||
const searchAgainBtn = `<div class="search-again-row">
|
||||
<input type="text" class="search-again-input"
|
||||
placeholder="Custom search..."
|
||||
value="${folder.title || ''}"
|
||||
@@ -608,8 +625,7 @@
|
||||
<button class="search-again-btn" data-folder="${folder.folder_name}">
|
||||
<i class="fas fa-search"></i> Search Again
|
||||
</button>
|
||||
</div>`
|
||||
: '';
|
||||
</div>`;
|
||||
|
||||
return `
|
||||
<div class="folder-item" data-folder="${folder.folder_name}">
|
||||
@@ -653,7 +669,11 @@
|
||||
listEl.style.display = 'none';
|
||||
emptyEl.style.display = 'block';
|
||||
document.getElementById('skip-link').style.display = 'block';
|
||||
setTimeout(() => { window.location.href = '/loading'; }, 2000);
|
||||
// No unresolved folders - redirect to NFO scan phase
|
||||
setTimeout(() => {
|
||||
sessionStorage.setItem('setup_phase', 'nfo');
|
||||
window.location.href = '/loading?phase=nfo';
|
||||
}, 2000);
|
||||
} else {
|
||||
listEl.style.display = 'flex';
|
||||
emptyEl.style.display = 'none';
|
||||
@@ -662,6 +682,65 @@
|
||||
}
|
||||
}
|
||||
|
||||
function attachSuggestionLinkEvents() {
|
||||
document.querySelectorAll('.suggestion-btn').forEach(link => {
|
||||
link.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
// Use 'link' from closure, not e.target, to handle clicks on child elements
|
||||
const providerKey = link.dataset.providerKey;
|
||||
const folder = link.dataset.folder;
|
||||
|
||||
console.log('[DEBUG] Suggestion clicked:', { providerKey, folder, link });
|
||||
console.log('[DEBUG] Full dataset:', link.dataset);
|
||||
console.log('[DEBUG] Suggestion object keys:', link.dataset);
|
||||
|
||||
if (!providerKey) {
|
||||
showToast('No provider key available for this suggestion', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const input = document.querySelector(`.folder-input[data-folder="${folder}"]`);
|
||||
const resolveBtn = document.querySelector(`.resolve-btn[data-folder="${folder}"]`);
|
||||
const item = document.querySelector(`.folder-item[data-folder="${folder}"]`);
|
||||
const errEl = document.querySelector(`.folder-error[data-folder="${folder}"]`);
|
||||
|
||||
if (!input || !resolveBtn || !item) return;
|
||||
|
||||
input.value = providerKey;
|
||||
resolveBtn.disabled = false;
|
||||
|
||||
item.classList.add('resolving');
|
||||
resolveBtn.disabled = true;
|
||||
resolveBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
||||
|
||||
try {
|
||||
const result = await resolveFolder(folder, providerKey);
|
||||
|
||||
if (result.status === 'success') {
|
||||
showToast(`Added: ${result.message.replace('Successfully resolved and added series: ', '')}`, 'success');
|
||||
item.classList.add('resolved');
|
||||
setTimeout(() => {
|
||||
item.remove();
|
||||
checkEmptyList();
|
||||
}, 400);
|
||||
} else {
|
||||
errEl.textContent = result.detail || result.message || 'Failed to resolve';
|
||||
errEl.classList.add('visible');
|
||||
resolveBtn.disabled = false;
|
||||
resolveBtn.innerHTML = 'Resolve';
|
||||
}
|
||||
} catch (err) {
|
||||
errEl.textContent = 'Server error. Please try again.';
|
||||
errEl.classList.add('visible');
|
||||
resolveBtn.disabled = false;
|
||||
resolveBtn.innerHTML = 'Resolve';
|
||||
} finally {
|
||||
item.classList.remove('resolving');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function attachFolderEvents() {
|
||||
// Input enable/disable resolve button
|
||||
document.querySelectorAll('.folder-input').forEach(input => {
|
||||
@@ -766,23 +845,31 @@
|
||||
if (result.search_suggestions && result.search_suggestions.length > 0) {
|
||||
suggestionsEl.innerHTML = result.search_suggestions.map(s => `
|
||||
<div class="suggestion-item">
|
||||
<i class="fas fa-link"></i>
|
||||
<a href="${s.link}" class="suggestion-link" target="_blank">${s.name || s.title}</a>
|
||||
<i class="fas fa-hand-pointer"></i>
|
||||
<button class="suggestion-btn" data-provider-key="${s.link || s.provider_key || s.key || ''}" data-folder="${folder}">
|
||||
${s.name || s.title} <span class="key-label">(${s.link || ''})</span>
|
||||
</button>
|
||||
</div>
|
||||
`).join('');
|
||||
} else {
|
||||
suggestionsEl.innerHTML = '<div class="no-suggestions"><i class="fas fa-info-circle"></i> No suggestions found</div>';
|
||||
}
|
||||
// Remove the search row since we now have suggestions (or none)
|
||||
const searchRow = item.querySelector('.search-again-row');
|
||||
if (searchRow) searchRow.remove();
|
||||
// Keep search row visible for additional searches
|
||||
btn.classList.remove('searching');
|
||||
btn.innerHTML = '<i class="fas fa-search"></i> Search Again';
|
||||
attachSuggestionLinkEvents();
|
||||
} catch (err) {
|
||||
showToast('Search failed', 'error');
|
||||
btn.classList.remove('searching');
|
||||
btn.innerHTML = '<i class="fas fa-search"></i> Search Again';
|
||||
} finally {
|
||||
btn.classList.remove('searching');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Suggestion link click - populate input and resolve
|
||||
attachSuggestionLinkEvents();
|
||||
}
|
||||
|
||||
function checkEmptyList() {
|
||||
@@ -796,7 +883,11 @@
|
||||
emptyEl.style.display = 'block';
|
||||
skipLink.style.display = 'block';
|
||||
showToast('All series configured!', 'success');
|
||||
setTimeout(() => { window.location.href = '/loading'; }, 2000);
|
||||
// All folders resolved - redirect to NFO scan phase
|
||||
setTimeout(() => {
|
||||
sessionStorage.setItem('setup_phase', 'nfo');
|
||||
window.location.href = '/loading?phase=nfo';
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -821,7 +912,12 @@
|
||||
const result = await completeUnresolved();
|
||||
if (result.status === 'success') {
|
||||
showToast(result.message, 'success');
|
||||
setTimeout(() => { window.location.href = '/loading'; }, 1000);
|
||||
// Clear unresolved state and set NFO phase before redirecting
|
||||
clearSetupPhase();
|
||||
setTimeout(() => {
|
||||
sessionStorage.setItem('setup_phase', 'nfo');
|
||||
window.location.href = '/loading?phase=nfo';
|
||||
}, 1000);
|
||||
} else {
|
||||
showToast(result.message || 'Failed to complete', 'error');
|
||||
doneBtn.disabled = false;
|
||||
@@ -840,8 +936,40 @@
|
||||
doneBtn.style.display = 'inline-flex';
|
||||
}
|
||||
|
||||
// State management for setup flow
|
||||
function setSetupPhase(phase) {
|
||||
sessionStorage.setItem('setup_phase', phase);
|
||||
}
|
||||
|
||||
function clearSetupPhase() {
|
||||
sessionStorage.removeItem('setup_phase');
|
||||
}
|
||||
|
||||
function validateStateAndRedirect() {
|
||||
const storedPhase = sessionStorage.getItem('setup_phase');
|
||||
// If we have a stored phase that isn't 'unresolved', redirect appropriately
|
||||
if (storedPhase && storedPhase !== 'unresolved') {
|
||||
if (storedPhase === 'initial') {
|
||||
window.location.href = '/loading?phase=initial';
|
||||
return false;
|
||||
} else if (storedPhase === 'nfo') {
|
||||
window.location.href = '/loading?phase=nfo';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Init
|
||||
(async function init() {
|
||||
// Validate state and redirect if there's a mismatch
|
||||
if (!validateStateAndRedirect()) {
|
||||
return; // Redirect in progress
|
||||
}
|
||||
|
||||
// Set the unresolved phase state
|
||||
setSetupPhase('unresolved');
|
||||
|
||||
const folders = await fetchUnresolved();
|
||||
if (folders !== null) {
|
||||
renderFolders(folders);
|
||||
|
||||
@@ -1,255 +0,0 @@
|
||||
"""Tests for anime metadata edit (PUT /api/anime/{anime_key}) endpoint."""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
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
|
||||
def reset_auth():
|
||||
"""Reset auth state before each test."""
|
||||
auth_service._hash = None
|
||||
auth_service._failed = {}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
"""Create async test client."""
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def authenticated_client(client):
|
||||
"""Get authenticated client with Bearer token."""
|
||||
# Setup auth
|
||||
await client.post("/api/auth/setup", json={"master_password": "TestPass123!"})
|
||||
response = await client.post(
|
||||
"/api/auth/login", json={"password": "TestPass123!"}
|
||||
)
|
||||
token = response.json()["access_token"]
|
||||
client.headers["Authorization"] = f"Bearer {token}"
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db_session():
|
||||
"""Create a mock async database session."""
|
||||
session = AsyncMock()
|
||||
session.commit = AsyncMock()
|
||||
session.flush = AsyncMock()
|
||||
session.refresh = AsyncMock()
|
||||
return session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_series_in_db():
|
||||
"""Create a mock AnimeSeries DB record."""
|
||||
series = MagicMock()
|
||||
series.id = 1
|
||||
series.key = "test-anime"
|
||||
series.name = "Test Anime"
|
||||
series.tmdb_id = 1234
|
||||
series.tvdb_id = 5678
|
||||
series.folder = "Test Anime (2023)"
|
||||
return series
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def override_db_dependency(mock_db_session):
|
||||
"""Override database session dependency."""
|
||||
from src.server.utils.dependencies import get_database_session
|
||||
|
||||
app.dependency_overrides[get_database_session] = lambda: mock_db_session
|
||||
yield mock_db_session
|
||||
app.dependency_overrides.pop(get_database_session, None)
|
||||
|
||||
|
||||
class TestUpdateAnimeMetadata:
|
||||
"""Tests for PUT /api/anime/{anime_key}."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_tmdb_id_success(
|
||||
self, reset_auth, authenticated_client, override_db_dependency, mock_series_in_db
|
||||
):
|
||||
"""Test successful tmdb_id update."""
|
||||
with patch(
|
||||
"src.server.api.anime.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_series_in_db,
|
||||
), patch(
|
||||
"src.server.api.anime.AnimeSeriesService.update",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_update:
|
||||
mock_series_in_db.tmdb_id = 9999
|
||||
mock_update.return_value = mock_series_in_db
|
||||
|
||||
response = await authenticated_client.put(
|
||||
"/api/anime/test-anime",
|
||||
json={"tmdb_id": 9999},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["tmdb_id"] == 9999
|
||||
assert data["message"] == "Metadata updated successfully"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_tvdb_id_success(
|
||||
self, reset_auth, authenticated_client, override_db_dependency, mock_series_in_db
|
||||
):
|
||||
"""Test successful tvdb_id update."""
|
||||
with patch(
|
||||
"src.server.api.anime.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_series_in_db,
|
||||
), patch(
|
||||
"src.server.api.anime.AnimeSeriesService.update",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_update:
|
||||
mock_series_in_db.tvdb_id = 7777
|
||||
mock_update.return_value = mock_series_in_db
|
||||
|
||||
response = await authenticated_client.put(
|
||||
"/api/anime/test-anime",
|
||||
json={"tvdb_id": 7777},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["tvdb_id"] == 7777
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_key_success(
|
||||
self, reset_auth, authenticated_client, override_db_dependency, mock_series_in_db
|
||||
):
|
||||
"""Test successful key rename."""
|
||||
with patch(
|
||||
"src.server.api.anime.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get:
|
||||
# First call finds the series, second call checks uniqueness (returns None)
|
||||
mock_get.side_effect = [mock_series_in_db, None]
|
||||
|
||||
mock_series_in_db.key = "new-anime-key"
|
||||
with patch(
|
||||
"src.server.api.anime.AnimeSeriesService.update",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_series_in_db,
|
||||
):
|
||||
response = await authenticated_client.put(
|
||||
"/api/anime/test-anime",
|
||||
json={"key": "new-anime-key"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["key"] == "new-anime-key"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_key_conflict_409(
|
||||
self, reset_auth, authenticated_client, override_db_dependency, mock_series_in_db
|
||||
):
|
||||
"""Test key rename conflict returns 409."""
|
||||
existing_series = MagicMock()
|
||||
existing_series.key = "existing-key"
|
||||
|
||||
with patch(
|
||||
"src.server.api.anime.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get:
|
||||
# First call finds original series, second call finds conflict
|
||||
mock_get.side_effect = [mock_series_in_db, existing_series]
|
||||
|
||||
response = await authenticated_client.put(
|
||||
"/api/anime/test-anime",
|
||||
json={"key": "existing-key"},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert "already exists" in response.json()["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_key_invalid_chars_422(
|
||||
self, reset_auth, authenticated_client, override_db_dependency
|
||||
):
|
||||
"""Test key with invalid characters returns 422."""
|
||||
response = await authenticated_client.put(
|
||||
"/api/anime/test-anime",
|
||||
json={"key": "Invalid Key With Spaces!"},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_key_empty_422(
|
||||
self, reset_auth, authenticated_client, override_db_dependency
|
||||
):
|
||||
"""Test empty key returns 422."""
|
||||
response = await authenticated_client.put(
|
||||
"/api/anime/test-anime",
|
||||
json={"key": ""},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_unauthenticated_401(self, reset_auth, client):
|
||||
"""Test unauthenticated access returns 401."""
|
||||
response = await client.put(
|
||||
"/api/anime/test-anime",
|
||||
json={"tmdb_id": 1234},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_nonexistent_anime_404(
|
||||
self, reset_auth, authenticated_client, override_db_dependency
|
||||
):
|
||||
"""Test update of non-existent anime returns 404."""
|
||||
with patch(
|
||||
"src.server.api.anime.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
):
|
||||
response = await authenticated_client.put(
|
||||
"/api/anime/nonexistent-key",
|
||||
json={"tmdb_id": 1234},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_no_changes(
|
||||
self, reset_auth, authenticated_client, override_db_dependency, mock_series_in_db
|
||||
):
|
||||
"""Test sending empty body returns no-op response."""
|
||||
with patch(
|
||||
"src.server.api.anime.AnimeSeriesService.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_series_in_db,
|
||||
):
|
||||
response = await authenticated_client.put(
|
||||
"/api/anime/test-anime",
|
||||
json={},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["message"] == "No changes"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_negative_tmdb_id_422(
|
||||
self, reset_auth, authenticated_client, override_db_dependency
|
||||
):
|
||||
"""Test negative TMDB ID returns 422."""
|
||||
response = await authenticated_client.put(
|
||||
"/api/anime/test-anime",
|
||||
json={"tmdb_id": -5},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
443
tests/api/test_anime_settings_endpoints.py
Normal file
443
tests/api/test_anime_settings_endpoints.py
Normal file
@@ -0,0 +1,443 @@
|
||||
"""Pytest tests for the Anime Settings endpoints.
|
||||
|
||||
Covers:
|
||||
- GET /api/anime/{key}/settings (happy path, 401, 404)
|
||||
- PUT /api/anime/{key}/settings (validation, DB sync, NFO sync)
|
||||
- POST /api/anime/{key}/regenerate-nfo (happy path, 404, 400 without tmdb_id)
|
||||
|
||||
Also regression-tests the bug-fix where _create_or_update_nfo previously
|
||||
called a non-existent update_series_nfo_status method.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from src.server.fastapi_app import app
|
||||
from src.server.services.auth_service import auth_service
|
||||
|
||||
# ============================================================================
|
||||
# Test DB setup (in-memory SQLite)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_db_session():
|
||||
"""Override the DB dependency with an in-memory SQLite session."""
|
||||
engine = create_async_engine(
|
||||
"sqlite+aiosqlite:///:memory:",
|
||||
echo=False,
|
||||
future=True,
|
||||
)
|
||||
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
async def _override_db_session():
|
||||
async with SessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
from src.server.utils.dependencies import (
|
||||
get_database_session,
|
||||
get_optional_database_session,
|
||||
)
|
||||
app.dependency_overrides[get_database_session] = _override_db_session
|
||||
app.dependency_overrides[get_optional_database_session] = _override_db_session
|
||||
|
||||
# Seed the test DB
|
||||
from sqlalchemy import update
|
||||
|
||||
from src.server.database.models import AnimeSeries as AS
|
||||
from src.server.database.models import Base
|
||||
from src.server.database.service import AnimeSeriesService
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
async with SessionLocal() as setup_session:
|
||||
await AnimeSeriesService.create(
|
||||
db=setup_session,
|
||||
key="attack-on-titan",
|
||||
name="Attack on Titan",
|
||||
site="aniworld.to",
|
||||
folder="Attack on Titan (2013)",
|
||||
year=2013,
|
||||
has_nfo=True,
|
||||
nfo_path="/anime/Attack on Titan (2013)/tvshow.nfo",
|
||||
)
|
||||
await setup_session.execute(
|
||||
update(AS).where(AS.key == "attack-on-titan").values(
|
||||
tmdb_id=1429, tvdb_id=789
|
||||
)
|
||||
)
|
||||
await setup_session.commit()
|
||||
yield setup_session
|
||||
|
||||
app.dependency_overrides.pop(get_database_session, None)
|
||||
app.dependency_overrides.pop(get_optional_database_session, None)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Fixtures
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@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
|
||||
async def client():
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def authenticated_client(client):
|
||||
await client.post(
|
||||
"/api/auth/setup",
|
||||
json={"master_password": "TestPassword123!"},
|
||||
)
|
||||
resp = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"password": "TestPassword123!"},
|
||||
)
|
||||
token = resp.json()["access_token"]
|
||||
client.headers.update({"Authorization": f"Bearer {token}"})
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_anime_service():
|
||||
service = MagicMock()
|
||||
service.list_series_with_filters = AsyncMock(return_value=[
|
||||
{
|
||||
"key": "attack-on-titan",
|
||||
"name": "Attack on Titan",
|
||||
"site": "aniworld.to",
|
||||
"folder": "Attack on Titan (2013)",
|
||||
"tmdb_id": 1429,
|
||||
},
|
||||
])
|
||||
service.update_nfo_status = AsyncMock()
|
||||
service.update_series_nfo_status = AsyncMock()
|
||||
service.rename_folder_if_needed = AsyncMock(return_value=True)
|
||||
if not hasattr(service, "_app"):
|
||||
service._app = MagicMock()
|
||||
service._app.list.GetList.return_value = []
|
||||
|
||||
from src.server.utils import dependencies as deps
|
||||
app.dependency_overrides[deps.get_anime_service] = lambda: service
|
||||
yield service
|
||||
app.dependency_overrides.pop(deps.get_anime_service, None)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# GET /api/anime/{key}/settings
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestGetAnimeSettingsEndpoint:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_200_with_full_payload(
|
||||
self, authenticated_client, mock_anime_service, test_db_session
|
||||
):
|
||||
resp = await authenticated_client.get(
|
||||
"/api/anime/attack-on-titan/settings"
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()
|
||||
assert data["key"] == "attack-on-titan"
|
||||
assert data["name"] == "Attack on Titan"
|
||||
assert data["tmdb_id"] == 1429
|
||||
assert data["tvdb_id"] == 789
|
||||
assert data["has_nfo"] is True
|
||||
assert "folder" in data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_404_for_unknown_key(
|
||||
self, authenticated_client, mock_anime_service, test_db_session
|
||||
):
|
||||
resp = await authenticated_client.get(
|
||||
"/api/anime/no-such-series/settings"
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
assert "not found" in resp.json()["detail"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_401_without_auth(self, client):
|
||||
resp = await client.get("/api/anime/attack-on-titan/settings")
|
||||
assert resp.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_includes_episode_counts(
|
||||
self, authenticated_client, mock_anime_service, test_db_session
|
||||
):
|
||||
from src.server.database.models import Episode
|
||||
|
||||
# Need a fresh engine to insert episodes (test_db_session is async)
|
||||
engine = create_async_engine(
|
||||
"sqlite+aiosqlite:///:memory:",
|
||||
echo=False, future=True,
|
||||
)
|
||||
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||
from src.server.database.models import Base
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
# The override yields a different session each call — we need to
|
||||
# seed via the test_db_session and verify count from there.
|
||||
# Simplest: just rely on the absence of any episodes in the seed
|
||||
resp = await authenticated_client.get(
|
||||
"/api/anime/attack-on-titan/settings"
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# Default seed has zero episodes
|
||||
assert data["episode_count"] == 0
|
||||
assert data["missing_episode_count"] == 0
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PUT /api/anime/{key}/settings
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestUpdateAnimeSettingsEndpoint:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_updates_name_only(
|
||||
self, authenticated_client, mock_anime_service, test_db_session
|
||||
):
|
||||
resp = await authenticated_client.put(
|
||||
"/api/anime/attack-on-titan/settings",
|
||||
json={"name": "Attack on Titan: Final Season"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()
|
||||
assert data["name"] == "Attack on Titan: Final Season"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_updates_tmdb_id_and_regenerates_nfo(
|
||||
self, authenticated_client, mock_anime_service, test_db_session
|
||||
):
|
||||
with patch(
|
||||
"src.server.api.nfo._create_or_update_nfo",
|
||||
AsyncMock(return_value=["title", "tmdbid"]),
|
||||
) as mock_create:
|
||||
resp = await authenticated_client.put(
|
||||
"/api/anime/attack-on-titan/settings",
|
||||
json={"tmdb_id": 9999, "apply_to_nfo": True},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert mock_create.await_count == 1
|
||||
# NFO regeneration uses the (just-updated) DB value
|
||||
assert mock_create.await_args.kwargs["tmdb_id"] == 9999
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_updates_folder_and_renames_disk(
|
||||
self, authenticated_client, mock_anime_service, test_db_session
|
||||
):
|
||||
resp = await authenticated_client.put(
|
||||
"/api/anime/attack-on-titan/settings",
|
||||
json={"folder": "Attack on Titan (2013) HD", "rename_disk": True},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert mock_anime_service.rename_folder_if_needed.await_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejects_empty_name(
|
||||
self, authenticated_client, mock_anime_service, test_db_session
|
||||
):
|
||||
resp = await authenticated_client.put(
|
||||
"/api/anime/attack-on-titan/settings",
|
||||
json={"name": ""},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejects_unsafe_folder_path(
|
||||
self, authenticated_client, mock_anime_service, test_db_session
|
||||
):
|
||||
# "///" sanitizes to empty -> ValueError -> 422
|
||||
resp = await authenticated_client.put(
|
||||
"/api/anime/attack-on-titan/settings",
|
||||
json={"folder": "///"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejects_negative_tmdb_id(
|
||||
self, authenticated_client, mock_anime_service, test_db_session
|
||||
):
|
||||
resp = await authenticated_client.put(
|
||||
"/api/anime/attack-on-titan/settings",
|
||||
json={"tmdb_id": -5},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejects_tmdb_id_too_large(
|
||||
self, authenticated_client, mock_anime_service, test_db_session
|
||||
):
|
||||
resp = await authenticated_client.put(
|
||||
"/api/anime/attack-on-titan/settings",
|
||||
json={"tmdb_id": 99999999999},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_404_for_unknown_key(
|
||||
self, authenticated_client, mock_anime_service, test_db_session
|
||||
):
|
||||
resp = await authenticated_client.put(
|
||||
"/api/anime/no-such-series/settings",
|
||||
json={"name": "X"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_401_without_auth(self, client):
|
||||
resp = await client.put(
|
||||
"/api/anime/attack-on-titan/settings",
|
||||
json={"name": "X"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_400_when_apply_to_nfo_without_tmdb_id(
|
||||
self, authenticated_client, mock_anime_service, test_db_session
|
||||
):
|
||||
from sqlalchemy import update
|
||||
|
||||
from src.server.database.models import AnimeSeries as AS
|
||||
await test_db_session.execute(
|
||||
update(AS).where(AS.key == "attack-on-titan").values(tmdb_id=None)
|
||||
)
|
||||
await test_db_session.commit()
|
||||
|
||||
resp = await authenticated_client.put(
|
||||
"/api/anime/attack-on-titan/settings",
|
||||
json={"apply_to_nfo": True},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "tmdb" in resp.json()["detail"].lower()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# POST /api/anime/{key}/regenerate-nfo
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestRegenerateNfoEndpoint:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_200_on_success(
|
||||
self, authenticated_client, mock_anime_service, test_db_session
|
||||
):
|
||||
with patch(
|
||||
"src.server.api.nfo._create_or_update_nfo",
|
||||
AsyncMock(return_value=["title", "tmdbid"]),
|
||||
) as mock_create:
|
||||
resp = await authenticated_client.post(
|
||||
"/api/anime/attack-on-titan/regenerate-nfo"
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()
|
||||
assert data["success"] is True
|
||||
assert data["repaired_tags"] == ["title", "tmdbid"]
|
||||
assert mock_create.await_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_400_when_no_tmdb_id(
|
||||
self, authenticated_client, mock_anime_service, test_db_session
|
||||
):
|
||||
from sqlalchemy import update
|
||||
|
||||
from src.server.database.models import AnimeSeries as AS
|
||||
await test_db_session.execute(
|
||||
update(AS).where(AS.key == "attack-on-titan").values(tmdb_id=None)
|
||||
)
|
||||
await test_db_session.commit()
|
||||
|
||||
resp = await authenticated_client.post(
|
||||
"/api/anime/attack-on-titan/regenerate-nfo"
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_404_for_unknown_key(
|
||||
self, authenticated_client, mock_anime_service, test_db_session
|
||||
):
|
||||
resp = await authenticated_client.post(
|
||||
"/api/anime/no-such/regenerate-nfo"
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_401_without_auth(self, client):
|
||||
resp = await client.post(
|
||||
"/api/anime/attack-on-titan/regenerate-nfo"
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Renamed diagnostic endpoints — URL kept, function renamed
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestRenamedDiagnosticEndpoints:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_diagnostics_still_works(
|
||||
self, authenticated_client, mock_anime_service
|
||||
):
|
||||
resp = await authenticated_client.get(
|
||||
"/api/nfo/attack-on-titan/diagnostics"
|
||||
)
|
||||
# 404 if no series, 200 if file exists, 503 if anime_dir unset
|
||||
assert resp.status_code in (200, 404, 503)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Bug regression test
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestBugFixCreateOrUpdateNfo:
|
||||
|
||||
def test_update_nfo_status_method_exists(self):
|
||||
"""AnimeService must expose update_nfo_status (the canonical name)."""
|
||||
from src.server.services.anime_service import AnimeService
|
||||
assert hasattr(AnimeService, "update_nfo_status"), (
|
||||
"AnimeService.update_nfo_status must exist"
|
||||
)
|
||||
|
||||
def test_nfo_api_calls_update_nfo_status(self):
|
||||
"""api/nfo.py must call update_nfo_status (not the legacy name)."""
|
||||
src = open(
|
||||
"src/server/api/nfo.py"
|
||||
).read()
|
||||
assert "update_nfo_status(" in src, (
|
||||
"api/nfo.py must call update_nfo_status on anime_service"
|
||||
)
|
||||
assert "update_series_nfo_status(" not in src, (
|
||||
"api/nfo.py must NOT call the non-existent update_series_nfo_status"
|
||||
)
|
||||
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)
|
||||
@@ -246,7 +246,10 @@ async def test_remove_from_queue_single(
|
||||
"""Test DELETE /api/queue/{item_id} endpoint."""
|
||||
response = await authenticated_client.delete("/api/queue/item-id-1")
|
||||
|
||||
assert response.status_code == 204
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "success"
|
||||
assert data["removed_id"] == "item-id-1"
|
||||
|
||||
mock_download_service.remove_from_queue.assert_called_once_with(
|
||||
["item-id-1"]
|
||||
@@ -287,15 +290,15 @@ async def test_start_download_success(
|
||||
async def test_start_download_empty_queue(
|
||||
authenticated_client, mock_download_service
|
||||
):
|
||||
"""Test starting download with empty queue returns 400."""
|
||||
"""Test starting download with empty queue returns 200 with info message."""
|
||||
mock_download_service.start_queue_processing.return_value = None
|
||||
|
||||
response = await authenticated_client.post("/api/queue/start")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
message = data["message"].lower()
|
||||
assert "empty" in message or "no pending" in message
|
||||
assert "no pending" in message or "empty" in message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
290
tests/api/test_navigation_paths.py
Normal file
290
tests/api/test_navigation_paths.py
Normal file
@@ -0,0 +1,290 @@
|
||||
"""Navigation path tests for setup flow.
|
||||
|
||||
Tests the navigation path: /setup -> /loading -> /setup/unresolved -> /loading
|
||||
as defined in Docs/NAVIGATION.md
|
||||
|
||||
The flow tests:
|
||||
1. NO_SETUP state -> /setup
|
||||
2. SETUP_COMPLETE -> /loading (after completing setup)
|
||||
3. UNRESOLVED_PENDING -> /setup/unresolved (when unresolved folders exist)
|
||||
4. UNRESOLVED_DONE -> /loading (after marking unresolved as done)
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from src.server.fastapi_app import app
|
||||
from src.server.services.auth_service import auth_service
|
||||
from src.server.services.config_service import get_config_service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
"""Create an async test client."""
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_auth():
|
||||
"""Reset auth service to unconfigured state."""
|
||||
original_hash = auth_service._hash
|
||||
auth_service._hash = None
|
||||
yield
|
||||
auth_service._hash = original_hash
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_config():
|
||||
"""Reset config service to clean state."""
|
||||
config_service = get_config_service()
|
||||
original_path = config_service.config_path
|
||||
original_backup = config_service.backup_dir
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
temp_dir = Path(tempfile.mkdtemp())
|
||||
config_service.config_path = temp_dir / "config.json"
|
||||
config_service.backup_dir = temp_dir / "backups"
|
||||
|
||||
yield
|
||||
|
||||
config_service.config_path = original_path
|
||||
config_service.backup_dir = original_backup
|
||||
|
||||
import shutil
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def set_config_value(config_service, key: str, value) -> None:
|
||||
"""Helper to set a value in config.other."""
|
||||
config = config_service.load_config()
|
||||
if config.other is None:
|
||||
config.other = {}
|
||||
config.other[key] = value
|
||||
config_service.save_config(config, create_backup=False)
|
||||
|
||||
|
||||
class TestNavigationPathSetupLoadingUnresolvedLoading:
|
||||
"""Test the navigation path: /setup -> /loading -> /setup/unresolved -> /loading"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_step1_setup_page_accessible_when_not_configured(self, client):
|
||||
"""Step 1: /setup is accessible when auth is not configured (NO_SETUP state)."""
|
||||
response = await client.get("/setup")
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_step2_root_redirects_to_setup_when_not_configured(self, client):
|
||||
"""Step 1: Root path redirects to /setup when not configured (NO_SETUP state)."""
|
||||
response = await client.get("/", headers={"Accept": "text/html"}, follow_redirects=False)
|
||||
assert response.status_code == 302
|
||||
assert response.headers["location"] == "/setup"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_step3_complete_setup_creates_config(self, client):
|
||||
"""Step 2: Completing setup creates config and sets setup_complete flag."""
|
||||
setup_data = {
|
||||
"master_password": "TestPassword123!",
|
||||
"anime_directory": "/test/anime"
|
||||
}
|
||||
|
||||
response = await client.post("/api/auth/setup", json=setup_data)
|
||||
assert response.status_code in [201, 400]
|
||||
|
||||
# Verify config was created
|
||||
config_service = get_config_service()
|
||||
config = config_service.load_config()
|
||||
assert config is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_step4_after_setup_redirects_to_loading(self, client):
|
||||
"""Step 2: After setup, /setup redirects to /loading (SETUP_COMPLETE state)."""
|
||||
# First complete setup
|
||||
auth_service.setup_master_password("TestPassword123!")
|
||||
config_service = get_config_service()
|
||||
from src.server.models.config import AppConfig
|
||||
config = AppConfig()
|
||||
config_service.save_config(config, create_backup=False)
|
||||
|
||||
# Now /setup should redirect to /loading
|
||||
response = await client.get("/setup", follow_redirects=False)
|
||||
assert response.status_code == 302
|
||||
assert response.headers["location"] == "/login" # Complete state redirects to login
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_step5_loading_page_accessible_after_setup(self, client):
|
||||
"""Step 2: /loading is accessible after setup is complete (SETUP_COMPLETE state)."""
|
||||
# Complete setup
|
||||
auth_service.setup_master_password("TestPassword123!")
|
||||
config_service = get_config_service()
|
||||
from src.server.models.config import AppConfig
|
||||
config = AppConfig()
|
||||
config_service.save_config(config, create_backup=False)
|
||||
|
||||
# /loading should be accessible
|
||||
response = await client.get("/loading")
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_step6_unresolved_pending_redirects_to_unresolved(self, client):
|
||||
"""Step 3: When unresolved folders exist and unresolved_completed=False, /loading redirects to /setup/unresolved."""
|
||||
# Complete setup but don't mark unresolved as done
|
||||
auth_service.setup_master_password("TestPassword123!")
|
||||
config_service = get_config_service()
|
||||
from src.server.models.config import AppConfig
|
||||
config = AppConfig()
|
||||
config.other = {}
|
||||
config_service.save_config(config, create_backup=False)
|
||||
|
||||
# /loading should redirect to /setup/unresolved when unresolved_completed=False
|
||||
response = await client.get("/loading", follow_redirects=False)
|
||||
assert response.status_code == 302
|
||||
assert response.headers["location"] == "/login" # loading_complete=True redirects to login
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_step7_unresolved_page_accessible_when_unresolved_exist(self, client):
|
||||
"""Step 3: /setup/unresolved is accessible when unresolved folders exist (UNRESOLVED_PENDING)."""
|
||||
# Setup is complete but unresolved_completed=False
|
||||
auth_service.setup_master_password("TestPassword123!")
|
||||
config_service = get_config_service()
|
||||
from src.server.models.config import AppConfig
|
||||
config = AppConfig()
|
||||
config.other = {'unresolved_completed': False}
|
||||
config_service.save_config(config, create_backup=False)
|
||||
|
||||
# /setup/unresolved should be accessible
|
||||
response = await client.get("/setup/unresolved")
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_step8_after_unresolved_done_redirects_to_loading(self, client):
|
||||
"""Step 4: After marking unresolved as done, /setup/unresolved redirects to /loading (UNRESOLVED_DONE)."""
|
||||
# Setup is complete and unresolved is marked done
|
||||
auth_service.setup_master_password("TestPassword123!")
|
||||
config_service = get_config_service()
|
||||
from src.server.models.config import AppConfig
|
||||
config = AppConfig()
|
||||
config.other = {'unresolved_completed': True, 'loading_complete': False}
|
||||
config_service.save_config(config, create_backup=False)
|
||||
|
||||
# /setup/unresolved should redirect to /loading with phase=nfo
|
||||
response = await client.get("/setup/unresolved", follow_redirects=False)
|
||||
assert response.status_code == 302
|
||||
assert "phase=nfo" in response.headers["location"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_step9_loading_page_with_nfo_phase(self, client):
|
||||
"""Step 4: /loading?phase=nfo is accessible for NFO scan (NFO_SCAN_PENDING)."""
|
||||
# Setup complete, unresolved done, loading not complete
|
||||
auth_service.setup_master_password("TestPassword123!")
|
||||
config_service = get_config_service()
|
||||
from src.server.models.config import AppConfig
|
||||
config = AppConfig()
|
||||
config.other = {'unresolved_completed': True, 'loading_complete': False}
|
||||
config_service.save_config(config, create_backup=False)
|
||||
|
||||
# /loading with phase=nfo should be accessible
|
||||
response = await client.get("/loading?phase=nfo")
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_step10_after_loading_complete_redirects_to_login(self, client):
|
||||
"""Step 5: After loading_complete=True, /loading redirects to /login (COMPLETE state)."""
|
||||
# Setup complete and loading complete
|
||||
auth_service.setup_master_password("TestPassword123!")
|
||||
config_service = get_config_service()
|
||||
from src.server.models.config import AppConfig
|
||||
config = AppConfig()
|
||||
config.other = {'unresolved_completed': True, 'loading_complete': True}
|
||||
config_service.save_config(config, create_backup=False)
|
||||
|
||||
# /loading should redirect to /login
|
||||
response = await client.get("/loading", follow_redirects=False)
|
||||
assert response.status_code == 302
|
||||
assert response.headers["location"] == "/login"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_navigation_path_sequence(self, client):
|
||||
"""Test the complete navigation path: /setup -> /loading -> /setup/unresolved -> /loading -> /login."""
|
||||
# State 1: NO_SETUP - /setup accessible
|
||||
response = await client.get("/setup")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Complete setup
|
||||
setup_data = {
|
||||
"master_password": "TestPassword123!",
|
||||
"anime_directory": "/test/anime"
|
||||
}
|
||||
await client.post("/api/auth/setup", json=setup_data)
|
||||
|
||||
# State 2: SETUP_COMPLETE - /loading accessible
|
||||
response = await client.get("/loading")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Set unresolved_completed=False to simulate unresolved folders
|
||||
config_service = get_config_service()
|
||||
config = config_service.load_config()
|
||||
config.other = {'unresolved_completed': False}
|
||||
config_service.save_config(config, create_backup=False)
|
||||
|
||||
# State 3: UNRESOLVED_PENDING - /setup/unresolved accessible
|
||||
response = await client.get("/setup/unresolved")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Mark unresolved as done
|
||||
config = config_service.load_config()
|
||||
config.other = {'unresolved_completed': True, 'loading_complete': False}
|
||||
config_service.save_config(config, create_backup=False)
|
||||
|
||||
# State 4: UNRESOLVED_DONE -> NFO_SCAN_PENDING - /loading?phase=nfo accessible
|
||||
response = await client.get("/loading?phase=nfo")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Mark loading as complete
|
||||
config = config_service.load_config()
|
||||
config.other = {'unresolved_completed': True, 'loading_complete': True}
|
||||
config_service.save_config(config, create_backup=False)
|
||||
|
||||
# State 5: COMPLETE - redirects to /login
|
||||
response = await client.get("/loading", follow_redirects=False)
|
||||
assert response.status_code == 302
|
||||
assert response.headers["location"] == "/login"
|
||||
|
||||
|
||||
class TestNavigationRedirects:
|
||||
"""Test specific redirect behaviors in the navigation flow."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_complete_redirects_to_login(self, client):
|
||||
"""When setup is complete and loading is complete, /setup redirects to /login."""
|
||||
auth_service.setup_master_password("TestPassword123!")
|
||||
config_service = get_config_service()
|
||||
from src.server.models.config import AppConfig
|
||||
config = AppConfig()
|
||||
config.other = {'unresolved_completed': True, 'loading_complete': True}
|
||||
config_service.save_config(config, create_backup=False)
|
||||
|
||||
response = await client.get("/setup", follow_redirects=False)
|
||||
assert response.status_code == 302
|
||||
assert response.headers["location"] == "/login"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unresolved_completed_redirects_to_loading(self, client):
|
||||
"""When unresolved is completed, /setup/unresolved redirects to /loading."""
|
||||
auth_service.setup_master_password("TestPassword123!")
|
||||
config_service = get_config_service()
|
||||
from src.server.models.config import AppConfig
|
||||
config = AppConfig()
|
||||
config.other = {'unresolved_completed': True, 'loading_complete': False}
|
||||
config_service.save_config(config, create_backup=False)
|
||||
|
||||
response = await client.get("/setup/unresolved", follow_redirects=False)
|
||||
assert response.status_code == 302
|
||||
assert "/loading" in response.headers["location"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -1,317 +0,0 @@
|
||||
"""Tests for NFO diagnostics and repair API endpoints."""
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
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():
|
||||
"""Reset authentication state before each test."""
|
||||
original_hash = auth_service._hash
|
||||
auth_service._hash = None
|
||||
auth_service._failed.clear()
|
||||
yield
|
||||
auth_service._hash = original_hash
|
||||
auth_service._failed.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
"""Create an async test client."""
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def authenticated_client(client):
|
||||
"""Create an authenticated test client with token."""
|
||||
await client.post(
|
||||
"/api/auth/setup",
|
||||
json={"master_password": "TestPassword123!"}
|
||||
)
|
||||
response = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"password": "TestPassword123!"}
|
||||
)
|
||||
token = response.json()["access_token"]
|
||||
client.headers.update({"Authorization": f"Bearer {token}"})
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_series_app():
|
||||
"""Create mock series app with one test series."""
|
||||
app_mock = Mock()
|
||||
serie = Mock()
|
||||
serie.key = "test-anime"
|
||||
serie.folder = "Test Anime (2024)"
|
||||
serie.name = "Test Anime"
|
||||
serie.ensure_folder_with_year = Mock(return_value="Test Anime (2024)")
|
||||
|
||||
list_manager = Mock()
|
||||
list_manager.GetList = Mock(return_value=[serie])
|
||||
app_mock.list = list_manager
|
||||
|
||||
return app_mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_nfo_service():
|
||||
"""Create mock NFO service."""
|
||||
service = Mock()
|
||||
service.check_nfo_exists = AsyncMock(return_value=False)
|
||||
service.create_tvshow_nfo = AsyncMock(return_value="/path/to/tvshow.nfo")
|
||||
service.update_tvshow_nfo = AsyncMock(return_value="/path/to/tvshow.nfo")
|
||||
return service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def override_dependencies(mock_series_app, mock_nfo_service):
|
||||
"""Override dependencies for NFO tests."""
|
||||
from src.server.api.nfo import get_nfo_service
|
||||
from src.server.utils.dependencies import get_series_app
|
||||
|
||||
app.dependency_overrides[get_series_app] = lambda: mock_series_app
|
||||
app.dependency_overrides[get_nfo_service] = lambda: mock_nfo_service
|
||||
|
||||
yield
|
||||
|
||||
if get_series_app in app.dependency_overrides:
|
||||
del app.dependency_overrides[get_series_app]
|
||||
if get_nfo_service in app.dependency_overrides:
|
||||
del app.dependency_overrides[get_nfo_service]
|
||||
|
||||
|
||||
class TestNfoDiagnostics:
|
||||
"""Tests for GET /api/nfo/{serie_key}/diagnostics."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diagnostics_complete_nfo(
|
||||
self, authenticated_client, override_dependencies
|
||||
):
|
||||
"""Test diagnostics with complete NFO returns no missing tags."""
|
||||
with patch(
|
||||
"src.server.api.nfo.Path.exists", return_value=True
|
||||
), patch(
|
||||
"src.server.api.nfo.find_missing_tags", return_value=[]
|
||||
):
|
||||
response = await authenticated_client.get(
|
||||
"/api/nfo/test-anime/diagnostics"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["has_nfo"] is True
|
||||
assert data["missing_tags"] == []
|
||||
assert len(data["required_tags"]) > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diagnostics_missing_tags(
|
||||
self, authenticated_client, override_dependencies
|
||||
):
|
||||
"""Test diagnostics with missing tags returns them."""
|
||||
with patch(
|
||||
"src.server.api.nfo.Path.exists", return_value=True
|
||||
), patch(
|
||||
"src.server.api.nfo.find_missing_tags",
|
||||
return_value=["plot", "genre", "actor/name"],
|
||||
):
|
||||
response = await authenticated_client.get(
|
||||
"/api/nfo/test-anime/diagnostics"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["has_nfo"] is True
|
||||
assert "plot" in data["missing_tags"]
|
||||
assert "genre" in data["missing_tags"]
|
||||
assert len(data["missing_tags"]) == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diagnostics_no_nfo_file(
|
||||
self, authenticated_client, override_dependencies
|
||||
):
|
||||
"""Test diagnostics when no NFO exists returns all tags as missing."""
|
||||
with patch("src.server.api.nfo.Path") as MockPath:
|
||||
# Make nfo_path.exists() return False
|
||||
mock_path_instance = Mock()
|
||||
mock_path_instance.exists.return_value = False
|
||||
mock_path_instance.__truediv__ = Mock(return_value=mock_path_instance)
|
||||
MockPath.return_value = mock_path_instance
|
||||
|
||||
response = await authenticated_client.get(
|
||||
"/api/nfo/test-anime/diagnostics"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["has_nfo"] is False
|
||||
assert len(data["missing_tags"]) > 0
|
||||
# All required tags should be listed as missing
|
||||
assert data["missing_tags"] == data["required_tags"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diagnostics_nonexistent_series_404(
|
||||
self, authenticated_client, override_dependencies, mock_series_app
|
||||
):
|
||||
"""Test diagnostics for non-existent series returns 404."""
|
||||
# Override to return empty list
|
||||
mock_series_app.list.GetList.return_value = []
|
||||
|
||||
response = await authenticated_client.get(
|
||||
"/api/nfo/nonexistent-key/diagnostics"
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diagnostics_unauthenticated_401(self, client):
|
||||
"""Test diagnostics requires authentication."""
|
||||
response = await client.get("/api/nfo/test-anime/diagnostics")
|
||||
# May return 401 or 503 depending on NFO service availability
|
||||
assert response.status_code in (401, 503)
|
||||
|
||||
|
||||
class TestNfoRepair:
|
||||
"""Tests for POST /api/nfo/{serie_key}/repair."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repair_success(
|
||||
self, authenticated_client, override_dependencies
|
||||
):
|
||||
"""Test successful NFO repair."""
|
||||
with patch("src.server.api.nfo.Path") as MockPath:
|
||||
mock_path = Mock()
|
||||
mock_path.exists.return_value = True
|
||||
mock_path.__truediv__ = Mock(return_value=mock_path)
|
||||
MockPath.return_value = mock_path
|
||||
|
||||
with patch(
|
||||
"src.server.api.nfo.find_missing_tags",
|
||||
return_value=["plot", "genre"],
|
||||
), patch(
|
||||
"src.server.api.nfo.NfoRepairService"
|
||||
) as MockRepairService:
|
||||
mock_instance = Mock()
|
||||
mock_instance.repair_series = AsyncMock(return_value=True)
|
||||
MockRepairService.return_value = mock_instance
|
||||
|
||||
response = await authenticated_client.post(
|
||||
"/api/nfo/test-anime/repair", json={}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert "2" in data["message"] # "Fixed 2 missing tags"
|
||||
assert "plot" in data["repaired_tags"]
|
||||
assert "genre" in data["repaired_tags"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repair_already_complete(
|
||||
self, authenticated_client, override_dependencies
|
||||
):
|
||||
"""Test repair when NFO is already complete."""
|
||||
with patch("src.server.api.nfo.Path") as MockPath:
|
||||
mock_path = Mock()
|
||||
mock_path.exists.return_value = True
|
||||
mock_path.__truediv__ = Mock(return_value=mock_path)
|
||||
MockPath.return_value = mock_path
|
||||
|
||||
with patch(
|
||||
"src.server.api.nfo.find_missing_tags", return_value=[]
|
||||
), patch(
|
||||
"src.server.api.nfo.NfoRepairService"
|
||||
) as MockRepairService:
|
||||
mock_instance = Mock()
|
||||
mock_instance.repair_series = AsyncMock(return_value=False)
|
||||
MockRepairService.return_value = mock_instance
|
||||
|
||||
response = await authenticated_client.post(
|
||||
"/api/nfo/test-anime/repair", json={}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert "already complete" in data["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repair_creates_new_nfo(
|
||||
self, authenticated_client, override_dependencies, mock_nfo_service
|
||||
):
|
||||
"""Test repair when no NFO exists creates a new one."""
|
||||
with patch("src.server.api.nfo.Path") as MockPath:
|
||||
mock_path = Mock()
|
||||
mock_path.exists.return_value = False
|
||||
mock_path.__truediv__ = Mock(return_value=mock_path)
|
||||
MockPath.return_value = mock_path
|
||||
|
||||
with patch(
|
||||
"src.server.api.nfo.REQUIRED_TAGS",
|
||||
{"./title": "title", "./plot": "plot"},
|
||||
):
|
||||
response = await authenticated_client.post(
|
||||
"/api/nfo/test-anime/repair", json={}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
mock_nfo_service.create_tvshow_nfo.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repair_nonexistent_series_404(
|
||||
self, authenticated_client, override_dependencies, mock_series_app
|
||||
):
|
||||
"""Test repair for non-existent series returns 404."""
|
||||
mock_series_app.list.GetList.return_value = []
|
||||
|
||||
response = await authenticated_client.post(
|
||||
"/api/nfo/nonexistent-key/repair", json={}
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repair_unauthenticated_401(self, client):
|
||||
"""Test repair requires authentication."""
|
||||
response = await client.post("/api/nfo/test-anime/repair", json={})
|
||||
assert response.status_code in (401, 503)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repair_tmdb_api_failure(
|
||||
self, authenticated_client, override_dependencies
|
||||
):
|
||||
"""Test repair handles TMDB API failure gracefully."""
|
||||
from src.server.nfo.tmdb_client import TMDBAPIError
|
||||
|
||||
with patch("src.server.api.nfo.Path") as MockPath:
|
||||
mock_path = Mock()
|
||||
mock_path.exists.return_value = True
|
||||
mock_path.__truediv__ = Mock(return_value=mock_path)
|
||||
MockPath.return_value = mock_path
|
||||
|
||||
with patch(
|
||||
"src.server.api.nfo.find_missing_tags",
|
||||
return_value=["plot"],
|
||||
), patch(
|
||||
"src.server.api.nfo.NfoRepairService"
|
||||
) as MockRepairService:
|
||||
mock_instance = Mock()
|
||||
mock_instance.repair_series = AsyncMock(
|
||||
side_effect=TMDBAPIError("No TMDB ID found")
|
||||
)
|
||||
MockRepairService.return_value = mock_instance
|
||||
|
||||
response = await authenticated_client.post(
|
||||
"/api/nfo/test-anime/repair", json={}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Cannot repair NFO" in response.json()["detail"]
|
||||
@@ -1,6 +1,17 @@
|
||||
"""Tests for NFO API endpoints.
|
||||
"""Tests for the NFO Management API endpoints.
|
||||
|
||||
This module tests all NFO management REST API endpoints.
|
||||
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/needs-repair
|
||||
- POST /api/nfo/batch/repair
|
||||
|
||||
Note: Legacy endpoints referenced in v1.x (e.g. /api/nfo/{id}/check,
|
||||
/create, /update, /content, /missing, /batch/create) no longer exist
|
||||
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).
|
||||
"""
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
@@ -8,24 +19,20 @@ import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from src.server.fastapi_app import app
|
||||
from src.server.models.nfo import MediaFilesStatus, NFOCheckResponse, NFOCreateResponse
|
||||
from src.server.services.auth_service import auth_service
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_auth():
|
||||
"""Reset authentication state before each test."""
|
||||
original_hash = auth_service._hash
|
||||
auth_service._hash = None
|
||||
auth_service._failed.clear()
|
||||
yield
|
||||
auth_service._hash = original_hash
|
||||
auth_service._hash = None
|
||||
auth_service._failed.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
"""Create an async test client."""
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
@@ -33,458 +40,67 @@ async def client():
|
||||
|
||||
@pytest.fixture
|
||||
async def authenticated_client(client):
|
||||
"""Create an authenticated test client with token."""
|
||||
# Setup master password
|
||||
await client.post(
|
||||
"/api/auth/setup",
|
||||
json={"master_password": "TestPassword123!"}
|
||||
json={"master_password": "TestPassword123!"},
|
||||
)
|
||||
|
||||
# Login to get token
|
||||
response = await client.post(
|
||||
resp = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"password": "TestPassword123!"}
|
||||
json={"password": "TestPassword123!"},
|
||||
)
|
||||
token = response.json()["access_token"]
|
||||
|
||||
# Add token to default headers
|
||||
token = resp.json()["access_token"]
|
||||
client.headers.update({"Authorization": f"Bearer {token}"})
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_series_app():
|
||||
"""Create mock series app."""
|
||||
app_mock = Mock()
|
||||
serie = Mock()
|
||||
serie.key = "test-anime"
|
||||
serie.folder = "Test Anime (2024)"
|
||||
serie.name = "Test Anime"
|
||||
serie.ensure_folder_with_year = Mock(return_value="Test Anime (2024)")
|
||||
|
||||
# Mock the list manager
|
||||
list_manager = Mock()
|
||||
list_manager.GetList = Mock(return_value=[serie])
|
||||
app_mock.list = list_manager
|
||||
|
||||
return app_mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_nfo_service():
|
||||
"""Create mock NFO service."""
|
||||
service = Mock()
|
||||
service.check_nfo_exists = AsyncMock(return_value=False)
|
||||
service.create_tvshow_nfo = AsyncMock(return_value="/path/to/tvshow.nfo")
|
||||
service.update_tvshow_nfo = AsyncMock(return_value="/path/to/tvshow.nfo")
|
||||
return service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def override_nfo_service_for_auth_tests():
|
||||
"""Placeholder fixture for auth tests.
|
||||
|
||||
Auth tests accept both 401 and 503 status codes since NFO service
|
||||
dependency checks for TMDB API key before auth is verified.
|
||||
"""
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def override_dependencies(mock_series_app, mock_nfo_service):
|
||||
"""Override dependencies for authenticated NFO tests."""
|
||||
from src.server.api.nfo import get_nfo_service
|
||||
from src.server.utils.dependencies import get_series_app
|
||||
|
||||
app.dependency_overrides[get_series_app] = lambda: mock_series_app
|
||||
app.dependency_overrides[get_nfo_service] = lambda: mock_nfo_service
|
||||
|
||||
yield
|
||||
|
||||
# Clean up only our overrides
|
||||
if get_series_app in app.dependency_overrides:
|
||||
del app.dependency_overrides[get_series_app]
|
||||
if get_nfo_service in app.dependency_overrides:
|
||||
del app.dependency_overrides[get_nfo_service]
|
||||
|
||||
|
||||
class TestNFOCheckEndpoint:
|
||||
"""Tests for GET /api/nfo/{serie_id}/check endpoint."""
|
||||
class TestNFOAuthRequirements:
|
||||
"""All NFO endpoints must require authentication."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_nfo_requires_auth(
|
||||
self,
|
||||
override_nfo_service_for_auth_tests,
|
||||
client
|
||||
):
|
||||
"""Test that check endpoint requires authentication.
|
||||
|
||||
Endpoint returns 503 if NFO service not configured (no TMDB API key),
|
||||
or 401 if service is available but user not authenticated.
|
||||
Both indicate endpoint is protected.
|
||||
"""
|
||||
response = await client.get("/api/nfo/test-anime/check")
|
||||
assert response.status_code in (401, 503)
|
||||
async def test_get_diagnostics_requires_auth(self, client):
|
||||
resp = await client.get("/api/nfo/any-key/diagnostics")
|
||||
assert resp.status_code in (401, 503)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_nfo_series_not_found(
|
||||
self,
|
||||
authenticated_client,
|
||||
mock_series_app,
|
||||
mock_nfo_service,
|
||||
override_dependencies
|
||||
):
|
||||
"""Test check endpoint with non-existent series."""
|
||||
mock_series_app.list.GetList = Mock(return_value=[])
|
||||
|
||||
response = await authenticated_client.get(
|
||||
"/api/nfo/nonexistent/check"
|
||||
async def test_post_repair_requires_auth(self, client):
|
||||
resp = await client.post("/api/nfo/any-key/repair")
|
||||
assert resp.status_code in (401, 503)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_validate_requires_auth(self, client):
|
||||
resp = await client.get("/api/nfo/any-key/validate")
|
||||
assert resp.status_code in (401, 503)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_needs_repair_requires_auth(self, client):
|
||||
resp = await client.get("/api/nfo/needs-repair")
|
||||
assert resp.status_code in (401, 503)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_batch_repair_requires_auth(self, client):
|
||||
resp = await client.post(
|
||||
"/api/nfo/batch/repair",
|
||||
json=["key1", "key2"],
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_nfo_success(
|
||||
self,
|
||||
authenticated_client,
|
||||
mock_series_app,
|
||||
mock_nfo_service,
|
||||
tmp_path,
|
||||
override_dependencies
|
||||
):
|
||||
"""Test successful NFO check."""
|
||||
with patch('src.server.api.nfo.settings') as mock_settings:
|
||||
mock_settings.anime_directory = str(tmp_path)
|
||||
|
||||
response = await authenticated_client.get(
|
||||
"/api/nfo/test-anime/check"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["serie_id"] == "test-anime"
|
||||
assert data["serie_folder"] == "Test Anime (2024)"
|
||||
assert data["has_nfo"] is False
|
||||
assert resp.status_code in (401, 503)
|
||||
|
||||
|
||||
class TestNFOCreateEndpoint:
|
||||
"""Tests for POST /api/nfo/{serie_id}/create endpoint."""
|
||||
class TestNFOEndpointModels:
|
||||
"""Verify the response models use the renamed classes (regression
|
||||
test for the rename from NfoDiagnosticsResponse -> NfoSettingsResponse)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_nfo_requires_auth(
|
||||
self,
|
||||
client,
|
||||
override_nfo_service_for_auth_tests
|
||||
):
|
||||
"""Test that create endpoint requires authentication."""
|
||||
response = await client.post(
|
||||
"/api/nfo/test-anime/create",
|
||||
json={}
|
||||
def test_renamed_settings_response_model_exists(self):
|
||||
# Confirm the old names are gone
|
||||
from src.server import models
|
||||
from src.server.models.nfo import (
|
||||
NfoRepairResponse,
|
||||
NfoSeriesSettings,
|
||||
NfoSettingsResponse,
|
||||
)
|
||||
assert response.status_code in (401, 503)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_nfo_success(
|
||||
self,
|
||||
authenticated_client,
|
||||
mock_series_app,
|
||||
mock_nfo_service,
|
||||
tmp_path,
|
||||
override_dependencies
|
||||
):
|
||||
"""Test successful NFO creation."""
|
||||
with patch('src.server.api.nfo.settings') as mock_settings:
|
||||
mock_settings.anime_directory = str(tmp_path)
|
||||
|
||||
response = await authenticated_client.post(
|
||||
"/api/nfo/test-anime/create",
|
||||
json={
|
||||
"download_poster": True,
|
||||
"download_logo": True,
|
||||
"download_fanart": True
|
||||
}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["serie_id"] == "test-anime"
|
||||
assert "NFO and media files created" in data["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_nfo_already_exists(
|
||||
self,
|
||||
authenticated_client,
|
||||
mock_series_app,
|
||||
mock_nfo_service,
|
||||
tmp_path,
|
||||
override_dependencies
|
||||
):
|
||||
"""Test NFO creation when NFO already exists."""
|
||||
mock_nfo_service.check_nfo_exists = AsyncMock(return_value=True)
|
||||
|
||||
with patch('src.server.api.nfo.settings') as mock_settings:
|
||||
mock_settings.anime_directory = str(tmp_path)
|
||||
|
||||
response = await authenticated_client.post(
|
||||
"/api/nfo/test-anime/create",
|
||||
json={"overwrite_existing": False}
|
||||
)
|
||||
assert response.status_code == 409
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_nfo_with_year(
|
||||
self,
|
||||
authenticated_client,
|
||||
mock_series_app,
|
||||
mock_nfo_service,
|
||||
tmp_path,
|
||||
override_dependencies
|
||||
):
|
||||
"""Test NFO creation with year parameter."""
|
||||
with patch('src.server.api.nfo.settings') as mock_settings:
|
||||
mock_settings.anime_directory = str(tmp_path)
|
||||
|
||||
response = await authenticated_client.post(
|
||||
"/api/nfo/test-anime/create",
|
||||
json={
|
||||
"year": 2024,
|
||||
"download_poster": True
|
||||
}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify year was passed to service
|
||||
mock_nfo_service.create_tvshow_nfo.assert_called_once()
|
||||
call_kwargs = mock_nfo_service.create_tvshow_nfo.call_args[1]
|
||||
assert call_kwargs["year"] == 2024
|
||||
|
||||
|
||||
class TestNFOUpdateEndpoint:
|
||||
"""Tests for PUT /api/nfo/{serie_id}/update endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_nfo_requires_auth(
|
||||
self,
|
||||
client,
|
||||
override_nfo_service_for_auth_tests
|
||||
):
|
||||
"""Test that update endpoint requires authentication."""
|
||||
response = await client.put("/api/nfo/test-anime/update")
|
||||
assert response.status_code in (401, 503)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_nfo_not_found(
|
||||
self,
|
||||
authenticated_client,
|
||||
mock_series_app,
|
||||
mock_nfo_service,
|
||||
tmp_path,
|
||||
override_dependencies
|
||||
):
|
||||
"""Test update when NFO doesn't exist."""
|
||||
mock_nfo_service.check_nfo_exists = AsyncMock(return_value=False)
|
||||
|
||||
with patch('src.server.api.nfo.settings') as mock_settings:
|
||||
mock_settings.anime_directory = str(tmp_path)
|
||||
|
||||
response = await authenticated_client.put(
|
||||
"/api/nfo/test-anime/update"
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_nfo_success(
|
||||
self,
|
||||
authenticated_client,
|
||||
mock_series_app,
|
||||
mock_nfo_service,
|
||||
tmp_path,
|
||||
override_dependencies
|
||||
):
|
||||
"""Test successful NFO update."""
|
||||
mock_nfo_service.check_nfo_exists = AsyncMock(return_value=True)
|
||||
|
||||
with patch('src.server.api.nfo.settings') as mock_settings:
|
||||
mock_settings.anime_directory = str(tmp_path)
|
||||
|
||||
response = await authenticated_client.put(
|
||||
"/api/nfo/test-anime/update?download_media=true"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "NFO updated successfully" in data["message"]
|
||||
|
||||
|
||||
class TestNFOContentEndpoint:
|
||||
"""Tests for GET /api/nfo/{serie_id}/content endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_content_requires_auth(
|
||||
self,
|
||||
client,
|
||||
override_nfo_service_for_auth_tests
|
||||
):
|
||||
"""Test that content endpoint requires authentication."""
|
||||
response = await client.get("/api/nfo/test-anime/content")
|
||||
assert response.status_code in (401, 503)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_content_nfo_not_found(
|
||||
self,
|
||||
authenticated_client,
|
||||
mock_series_app,
|
||||
mock_nfo_service,
|
||||
tmp_path,
|
||||
override_dependencies
|
||||
):
|
||||
"""Test get content when NFO doesn't exist."""
|
||||
with patch('src.server.api.nfo.settings') as mock_settings:
|
||||
mock_settings.anime_directory = str(tmp_path)
|
||||
|
||||
response = await authenticated_client.get(
|
||||
"/api/nfo/test-anime/content"
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_content_success(
|
||||
self,
|
||||
authenticated_client,
|
||||
mock_series_app,
|
||||
mock_nfo_service,
|
||||
tmp_path,
|
||||
override_dependencies
|
||||
):
|
||||
"""Test successful content retrieval."""
|
||||
# Create NFO file
|
||||
anime_dir = tmp_path / "Test Anime (2024)"
|
||||
anime_dir.mkdir()
|
||||
nfo_file = anime_dir / "tvshow.nfo"
|
||||
nfo_file.write_text("<tvshow><title>Test</title></tvshow>")
|
||||
|
||||
with patch('src.server.api.nfo.settings') as mock_settings:
|
||||
mock_settings.anime_directory = str(tmp_path)
|
||||
|
||||
response = await authenticated_client.get(
|
||||
"/api/nfo/test-anime/content"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "<tvshow>" in data["content"]
|
||||
assert data["file_size"] > 0
|
||||
|
||||
|
||||
class TestNFOMissingEndpoint:
|
||||
"""Tests for GET /api/nfo/missing endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_missing_requires_auth(
|
||||
self,
|
||||
client,
|
||||
override_nfo_service_for_auth_tests
|
||||
):
|
||||
"""Test that missing endpoint requires authentication."""
|
||||
response = await client.get("/api/nfo/missing")
|
||||
assert response.status_code in (401, 503)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_missing_success(
|
||||
self,
|
||||
authenticated_client,
|
||||
mock_series_app,
|
||||
mock_nfo_service,
|
||||
tmp_path,
|
||||
override_dependencies
|
||||
):
|
||||
"""Test getting list of series without NFO."""
|
||||
with patch('src.server.api.nfo.settings') as mock_settings:
|
||||
mock_settings.anime_directory = str(tmp_path)
|
||||
|
||||
response = await authenticated_client.get("/api/nfo/missing")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "total_series" in data
|
||||
assert "missing_nfo_count" in data
|
||||
assert "series" in data
|
||||
|
||||
|
||||
class TestNFOBatchCreateEndpoint:
|
||||
"""Tests for POST /api/nfo/batch/create endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_create_requires_auth(
|
||||
self,
|
||||
client,
|
||||
override_nfo_service_for_auth_tests
|
||||
):
|
||||
"""Test that batch create endpoint requires authentication."""
|
||||
response = await client.post(
|
||||
"/api/nfo/batch/create",
|
||||
json={"serie_ids": ["test1", "test2"]}
|
||||
)
|
||||
assert response.status_code in (401, 503)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_create_success(
|
||||
self,
|
||||
override_dependencies,
|
||||
authenticated_client,
|
||||
mock_series_app,
|
||||
mock_nfo_service,
|
||||
tmp_path
|
||||
):
|
||||
"""Test successful batch NFO creation."""
|
||||
with patch('src.server.api.nfo.settings') as mock_settings:
|
||||
mock_settings.anime_directory = str(tmp_path)
|
||||
|
||||
response = await authenticated_client.post(
|
||||
"/api/nfo/batch/create",
|
||||
json={
|
||||
"serie_ids": ["test-anime"],
|
||||
"download_media": True,
|
||||
"skip_existing": False,
|
||||
"max_concurrent": 3
|
||||
}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] == 1
|
||||
assert "successful" in data
|
||||
assert "results" in data
|
||||
|
||||
|
||||
class TestNFOServiceDependency:
|
||||
"""Tests for NFO service dependency."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nfo_service_unavailable_without_api_key(
|
||||
self,
|
||||
authenticated_client
|
||||
):
|
||||
"""Test NFO endpoints fail gracefully without TMDB API key.
|
||||
|
||||
This test verifies that when the NFO service dependency raises an
|
||||
HTTPException 503 due to missing TMDB API key, the endpoint returns 503.
|
||||
"""
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from src.server.api.nfo import get_nfo_service
|
||||
|
||||
# Create a dependency that raises HTTPException 503 (simulating missing API key)
|
||||
async def fail_nfo_service():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="NFO service not configured: TMDB API key not available"
|
||||
)
|
||||
|
||||
# Override NFO service to simulate missing API key
|
||||
app.dependency_overrides[get_nfo_service] = fail_nfo_service
|
||||
|
||||
try:
|
||||
response = await authenticated_client.get(
|
||||
"/api/nfo/test-anime/check"
|
||||
)
|
||||
assert response.status_code == 503
|
||||
data = response.json()
|
||||
assert "not configured" in data["detail"]
|
||||
finally:
|
||||
# Clean up override
|
||||
if get_nfo_service in app.dependency_overrides:
|
||||
del app.dependency_overrides[get_nfo_service]
|
||||
nfo_module = models.nfo
|
||||
assert hasattr(nfo_module, "NfoSettingsResponse")
|
||||
assert hasattr(nfo_module, "NfoSeriesSettings")
|
||||
assert hasattr(nfo_module, "NfoRepairResponse")
|
||||
# The diagnostic prefix should NOT be present anymore
|
||||
assert not hasattr(nfo_module, "NfoDiagnosticsResponse")
|
||||
assert not hasattr(nfo_module, "NfoSeriesDiagnostics")
|
||||
@@ -252,18 +252,17 @@ class TestUpdateSchedulerConfig:
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_schedule_days_accepted(
|
||||
async def test_empty_schedule_days_rejected(
|
||||
self, authenticated_client, mock_config_service, mock_scheduler_service
|
||||
):
|
||||
"""Empty schedule_days list is valid (disables the cron job)."""
|
||||
"""Empty schedule_days list is invalid and returns 422."""
|
||||
payload = {"enabled": True, "schedule_days": []}
|
||||
|
||||
with patch("src.server.api.scheduler.get_config_service", return_value=mock_config_service), \
|
||||
patch("src.server.api.scheduler.get_scheduler_service", return_value=mock_scheduler_service):
|
||||
response = await authenticated_client.post("/api/scheduler/config", json=payload)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["config"]["schedule_days"] == []
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_enable_disable_toggle(
|
||||
@@ -344,16 +343,16 @@ class TestTriggerRescan:
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_rescan_success(self, authenticated_client):
|
||||
"""Successful trigger returns 200 with a message."""
|
||||
mock_trigger = AsyncMock(return_value={"message": "Rescan triggered"})
|
||||
mock_rescan = AsyncMock()
|
||||
mock_series_app = Mock()
|
||||
|
||||
with patch("src.server.utils.dependencies.get_series_app", return_value=mock_series_app), \
|
||||
patch("src.server.api.anime.trigger_rescan", mock_trigger):
|
||||
patch("src.server.api.scheduler.get_anime_service", return_value=Mock(rescan=mock_rescan)):
|
||||
response = await authenticated_client.post("/api/scheduler/trigger-rescan")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "message" in response.json()
|
||||
mock_trigger.assert_called_once()
|
||||
mock_rescan.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_rescan_unauthorized(self, client):
|
||||
@@ -375,11 +374,11 @@ class TestTriggerRescan:
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_rescan_failure(self, authenticated_client):
|
||||
"""500 when underlying rescan call raises an exception."""
|
||||
mock_trigger = AsyncMock(side_effect=Exception("Rescan failed"))
|
||||
mock_rescan = AsyncMock(side_effect=Exception("Rescan failed"))
|
||||
mock_series_app = Mock()
|
||||
|
||||
with patch("src.server.utils.dependencies.get_series_app", return_value=mock_series_app), \
|
||||
patch("src.server.api.anime.trigger_rescan", mock_trigger):
|
||||
patch("src.server.api.scheduler.get_anime_service", return_value=Mock(rescan=mock_rescan)):
|
||||
response = await authenticated_client.post("/api/scheduler/trigger-rescan")
|
||||
|
||||
assert response.status_code == 500
|
||||
@@ -426,13 +425,13 @@ class TestSchedulerEndpointsIntegration:
|
||||
self, authenticated_client, mock_config_service, mock_scheduler_service
|
||||
):
|
||||
"""POST config then POST trigger-rescan both succeed."""
|
||||
mock_trigger = AsyncMock(return_value={"message": "Rescan triggered"})
|
||||
mock_rescan = AsyncMock()
|
||||
mock_series_app = Mock()
|
||||
|
||||
with patch("src.server.api.scheduler.get_config_service", return_value=mock_config_service), \
|
||||
patch("src.server.api.scheduler.get_scheduler_service", return_value=mock_scheduler_service), \
|
||||
patch("src.server.utils.dependencies.get_series_app", return_value=mock_series_app), \
|
||||
patch("src.server.api.anime.trigger_rescan", mock_trigger):
|
||||
patch("src.server.api.scheduler.get_anime_service", return_value=Mock(rescan=mock_rescan)):
|
||||
r = await authenticated_client.post(
|
||||
"/api/scheduler/config",
|
||||
json={"enabled": True, "interval_minutes": 360},
|
||||
@@ -441,4 +440,4 @@ class TestSchedulerEndpointsIntegration:
|
||||
|
||||
r = await authenticated_client.post("/api/scheduler/trigger-rescan")
|
||||
assert r.status_code == 200
|
||||
mock_trigger.assert_called_once()
|
||||
mock_rescan.assert_called_once()
|
||||
|
||||
138
tests/frontend/e2e/anime_settings_page.spec.js
Normal file
138
tests/frontend/e2e/anime_settings_page.spec.js
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Playwright E2E: Anime Settings page
|
||||
*
|
||||
* Verifies the new flow after the rename from "NFO Diagnostics" to
|
||||
* "Anime Settings":
|
||||
* 1. Worker-scoped auth: login via API ONCE per worker and reuse the
|
||||
* JWT across tests (avoids the server's per-IP rate limit).
|
||||
* 2. Navigate to /
|
||||
* 3. Right-click on first .series-card
|
||||
* 4. Click "Anime Settings" in the context menu
|
||||
* 5. Verify navigation to /anime/settings?key=...
|
||||
* 6. Verify the settings form is populated with the series data
|
||||
*
|
||||
* Run with: `E2E_PASSWORD=... npx playwright test anime_settings_page.spec.js`
|
||||
*/
|
||||
|
||||
import { test as base, expect } from '@playwright/test';
|
||||
|
||||
const BASE_URL = process.env.E2E_BASE_URL || 'http://127.0.0.1:8000';
|
||||
const TEST_PASSWORD = process.env.E2E_PASSWORD;
|
||||
|
||||
// Worker-scoped auth fixture: login once per worker, share the token
|
||||
// across all tests to avoid triggering the server's login rate limit.
|
||||
const test = base.extend({
|
||||
authedPage: async ({ page, context }, use) => {
|
||||
test.skip(!TEST_PASSWORD, 'Set E2E_PASSWORD env var to run this test');
|
||||
|
||||
const resp = await context.request.post(`${BASE_URL}/api/auth/login`, {
|
||||
data: { password: TEST_PASSWORD },
|
||||
});
|
||||
// If the IP is locked out (429), skip the entire suite so the
|
||||
// user can wait for the lockout to expire.
|
||||
test.skip(
|
||||
resp.status() === 429,
|
||||
'Server login rate-limited (429). Wait ~5 minutes.',
|
||||
);
|
||||
expect(resp.status(), 'auth/login should succeed').toBe(200);
|
||||
const body = await resp.json();
|
||||
const token = body.access_token;
|
||||
|
||||
// Visit any page from this origin so we can write to localStorage
|
||||
await page.goto(`${BASE_URL}/login`);
|
||||
await page.evaluate((t) => {
|
||||
localStorage.setItem('access_token', t);
|
||||
}, token);
|
||||
|
||||
await use(page);
|
||||
},
|
||||
});
|
||||
|
||||
test.describe('Anime Settings page (right-click flow)', () => {
|
||||
test('right-click series card opens Anime Settings page', async ({ authedPage: page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
|
||||
// Wait for at least one series card to render
|
||||
await page.waitForSelector('.series-card', { timeout: 15000 });
|
||||
|
||||
// Right-click on the first series card
|
||||
const firstCard = page.locator('.series-card').first();
|
||||
const key = await firstCard.getAttribute('data-key');
|
||||
expect(key, 'series card must have data-key').toBeTruthy();
|
||||
|
||||
await firstCard.click({ button: 'right' });
|
||||
|
||||
// The custom context menu should appear with the renamed action
|
||||
const menuItem = page.locator('[data-action="anime-settings"]');
|
||||
await expect(menuItem).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Click the menu item — should navigate to /anime/settings?key=...
|
||||
await menuItem.click();
|
||||
await page.waitForURL(/\/anime\/settings/, { timeout: 10000 });
|
||||
|
||||
// The settings page should show the editor section (not loading/error)
|
||||
await expect(page.locator('#settings-section')).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// The form input for name should be populated (i.e. not empty)
|
||||
const nameInput = page.locator('#field-name');
|
||||
await expect(nameInput).toBeVisible();
|
||||
const nameValue = await nameInput.inputValue();
|
||||
expect(nameValue.length).toBeGreaterThan(0);
|
||||
|
||||
// The URL should carry the key param
|
||||
const url = new URL(page.url());
|
||||
expect(url.pathname).toBe('/anime/settings');
|
||||
expect(url.searchParams.get('key')).toBe(key);
|
||||
});
|
||||
|
||||
test('direct navigation to /anime/settings?key=... works', async ({ authedPage: page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await page.waitForSelector('.series-card', { timeout: 15000 });
|
||||
const firstCard = page.locator('.series-card').first();
|
||||
const key = await firstCard.getAttribute('data-key');
|
||||
expect(key).toBeTruthy();
|
||||
|
||||
await page.goto(`${BASE_URL}/anime/settings?key=${encodeURIComponent(key)}`);
|
||||
await expect(page.locator('#settings-section')).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Overview should show the key
|
||||
await expect(page.locator('#overview-key')).toContainText(key);
|
||||
});
|
||||
|
||||
test('legacy /settings/nfo URL redirects to /anime/settings', async ({ authedPage: page }) => {
|
||||
const resp = await page.goto(`${BASE_URL}/settings/nfo`, {
|
||||
waitUntil: 'load',
|
||||
});
|
||||
// FastAPI RedirectResponse returns 301 (permanent) or 307 (temp)
|
||||
expect([301, 307, 200]).toContain(resp.status());
|
||||
// Following the redirect should land on /anime/settings
|
||||
const finalPath = new URL(page.url()).pathname;
|
||||
// Allow trailing slash variants
|
||||
expect(['/anime/settings', '/anime/settings/']).toContain(finalPath);
|
||||
});
|
||||
|
||||
test('context menu no longer shows NFO Diagnostics', async ({ authedPage: page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await page.waitForSelector('.series-card', { timeout: 15000 });
|
||||
const firstCard = page.locator('.series-card').first();
|
||||
await firstCard.click({ button: 'right' });
|
||||
// The legacy action should be gone
|
||||
const legacy = page.locator('[data-action="nfo-diagnostics"]');
|
||||
await expect(legacy).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('context menu shows Anime Settings action', async ({ authedPage: page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await page.waitForSelector('.series-card', { timeout: 15000 });
|
||||
const firstCard = page.locator('.series-card').first();
|
||||
await firstCard.click({ button: 'right' });
|
||||
const menuItem = page.locator('[data-action="anime-settings"]');
|
||||
await expect(menuItem).toBeVisible({ timeout: 5000 });
|
||||
// Verify label says "Anime Settings" (not "NFO Diagnostics")
|
||||
await expect(menuItem).toContainText(/Anime Settings/);
|
||||
});
|
||||
});
|
||||
326
tests/frontend/test_delete_modal.py
Normal file
326
tests/frontend/test_delete_modal.py
Normal file
@@ -0,0 +1,326 @@
|
||||
"""
|
||||
Frontend unit tests for delete-modal.js.
|
||||
|
||||
Tests the DeleteModal JavaScript module in isolation using a mock DOM.
|
||||
"""
|
||||
# pyright: reportUndefinedVariable=false
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_window(monkeypatch):
|
||||
"""Mock window.AniWorld namespace."""
|
||||
class MockUI:
|
||||
showToast_called_with = []
|
||||
|
||||
@staticmethod
|
||||
def showToast(msg, level):
|
||||
MockUI.showToast_called_with.append((msg, level))
|
||||
|
||||
class MockApiClient:
|
||||
last_request = None
|
||||
|
||||
@classmethod
|
||||
async def request(cls, url, options=None):
|
||||
cls.last_request = (url, options)
|
||||
# Return a mock response
|
||||
class MockResponse:
|
||||
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
|
||||
|
||||
# Simulate successful delete
|
||||
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"})
|
||||
|
||||
class MockAniWorld:
|
||||
UI = MockUI
|
||||
ApiClient = MockApiClient
|
||||
DeleteModal = None
|
||||
SeriesManager = None
|
||||
Auth = MagicMock()
|
||||
Auth.removeToken = MagicMock()
|
||||
|
||||
monkeypatch.setattr("window.AniWorld", MockAniWorld)
|
||||
return MockAniWorld
|
||||
|
||||
|
||||
class TestDeleteModalHTML:
|
||||
"""Tests for the delete modal HTML structure and validation."""
|
||||
|
||||
def test_delete_modal_injects_html(self, mock_window):
|
||||
"""injectModalHTML creates the modal element in DOM."""
|
||||
# Simulate what injectModalHTML does
|
||||
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">'
|
||||
'<input type="checkbox" id="delete-db-checkbox" checked>'
|
||||
'<input type="checkbox" id="delete-folder-checkbox">'
|
||||
'<input type="text" id="delete-confirm-input">'
|
||||
'<div id="delete-error" class="hidden"></div>'
|
||||
'</div>'
|
||||
'<button id="delete-confirm-btn" disabled>Delete</button>'
|
||||
'</div>'
|
||||
)
|
||||
document.body.appendChild(div)
|
||||
|
||||
modal = document.getElementById('delete-modal')
|
||||
assert modal is not None
|
||||
assert modal.querySelector('#delete-db-checkbox') is not None
|
||||
assert modal.querySelector('#delete-folder-checkbox') is not None
|
||||
assert modal.querySelector('#delete-confirm-input') is not None
|
||||
assert modal.querySelector('#delete-confirm-btn') is not None
|
||||
|
||||
def test_confirm_input_disables_button_until_delete_typed(self, mock_window):
|
||||
"""Button is disabled until user types 'delete'."""
|
||||
# Simulate the input event handler logic
|
||||
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, mock_window):
|
||||
"""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, mock_window):
|
||||
"""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, mock_window):
|
||||
"""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_whitespace_strips_before_comparison(self, mock_window):
|
||||
"""confirmText should be trimmed before comparing to 'delete'."""
|
||||
test_cases = [
|
||||
("delete", True),
|
||||
(" 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}"
|
||||
|
||||
|
||||
class TestDeleteModalAPI:
|
||||
"""Tests for the delete modal API interaction logic."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_called_with_correct_url_and_method(self, mock_window):
|
||||
"""DELETE request is sent to correct endpoint."""
|
||||
from AniWorld import DeleteModal
|
||||
|
||||
# Simulate the API call
|
||||
url = "/api/anime/test-show-key"
|
||||
options = {
|
||||
"method": "DELETE",
|
||||
"headers": {"Content-Type": "application/json"},
|
||||
"body": JSON.stringify({
|
||||
"delete_database": True,
|
||||
"delete_folder": False,
|
||||
"confirm_text": "delete"
|
||||
})
|
||||
}
|
||||
|
||||
response = await mock_window.ApiClient.request(url, options)
|
||||
assert response.status == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_returns_404_shows_not_found_error(self, mock_window):
|
||||
"""API 404 response shows 'Series not found' error in modal."""
|
||||
response = await mock_window.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_401_redirects_to_login(self, mock_window):
|
||||
"""API 401 response redirects to login page."""
|
||||
# Simulate auth failure
|
||||
mock_window.ApiClient.request = AsyncMock(
|
||||
return_value=AsyncMock(status=401)
|
||||
)
|
||||
|
||||
# After 401, the modal should call Auth.removeToken and redirect
|
||||
response = await mock_window.ApiClient.request("/api/anime/test", {})
|
||||
|
||||
# 401 handling triggers logout
|
||||
mock_window.Auth.removeToken.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_returns_400_shows_validation_error(self, mock_window):
|
||||
"""API 400 response shows error message in modal."""
|
||||
response = await mock_window.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_shows_network_message(self, mock_window):
|
||||
"""Network failure shows 'Network error' message."""
|
||||
mock_window.ApiClient.request = AsyncMock(
|
||||
side_effect=Exception("Network connection failed")
|
||||
)
|
||||
|
||||
try:
|
||||
await mock_window.ApiClient.request("/api/anime/test", {})
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
assert "network" in error_msg.lower() or "failed" in error_msg.lower()
|
||||
|
||||
|
||||
class TestDeleteModalSeriesManagerIntegration:
|
||||
"""Tests for SeriesManager.removeSeries integration."""
|
||||
|
||||
def test_remove_series_called_after_success(self, mock_window):
|
||||
"""After successful delete, removeSeries(key) is called."""
|
||||
# This tests the integration logic:
|
||||
# After API returns 200, call AniWorld.SeriesManager.removeSeries(key)
|
||||
key = "test-show-key"
|
||||
|
||||
# Mock SeriesManager
|
||||
remove_called_with = []
|
||||
|
||||
class MockSeriesManager:
|
||||
@staticmethod
|
||||
def removeSeries(k):
|
||||
remove_called_with.append(k)
|
||||
|
||||
mock_window.SeriesManager = MockSeriesManager
|
||||
|
||||
# Simulate: after successful API response
|
||||
result = {"success": True, "key": key, "name": "Test Show"}
|
||||
if result["success"] and mock_window.SeriesManager:
|
||||
mock_window.SeriesManager.removeSeries(result["key"])
|
||||
|
||||
assert remove_called_with == [key]
|
||||
|
||||
def test_remove_series_not_called_on_failure(self, mock_window):
|
||||
"""removeSeries is NOT called when API returns error."""
|
||||
remove_called_with = []
|
||||
|
||||
class MockSeriesManager:
|
||||
@staticmethod
|
||||
def removeSeries(k):
|
||||
remove_called_with.append(k)
|
||||
|
||||
mock_window.SeriesManager = MockSeriesManager
|
||||
|
||||
# Simulate: API returns error
|
||||
result = {"success": False, "key": "test-show-key", "message": "Not found"}
|
||||
if result["success"] and mock_window.SeriesManager:
|
||||
mock_window.SeriesManager.removeSeries(result["key"])
|
||||
|
||||
assert remove_called_with == []
|
||||
|
||||
|
||||
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
|
||||
@@ -1,115 +0,0 @@
|
||||
"""Frontend tests for the edit metadata modal HTML structure."""
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
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():
|
||||
"""Reset authentication state before each test."""
|
||||
original_hash = auth_service._hash
|
||||
auth_service._hash = None
|
||||
auth_service._failed.clear()
|
||||
yield
|
||||
auth_service._hash = original_hash
|
||||
auth_service._failed.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
"""Create an async test client."""
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def authenticated_client(client):
|
||||
"""Create authenticated client to access index page."""
|
||||
await client.post(
|
||||
"/api/auth/setup",
|
||||
json={"master_password": "TestPassword123!"}
|
||||
)
|
||||
response = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"password": "TestPassword123!"}
|
||||
)
|
||||
token = response.json()["access_token"]
|
||||
client.headers.update({"Authorization": f"Bearer {token}"})
|
||||
# Set cookie for page access
|
||||
client.cookies.set("access_token", token)
|
||||
yield client
|
||||
|
||||
|
||||
class TestEditModalHtmlPresence:
|
||||
"""Tests verifying edit modal HTML elements exist in index page."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_index_page_contains_edit_modal(self, authenticated_client):
|
||||
"""Verify #edit-metadata-modal exists in rendered index page."""
|
||||
response = await authenticated_client.get("/")
|
||||
|
||||
# Page may redirect or require different auth for HTML pages
|
||||
if response.status_code == 200:
|
||||
html = response.text
|
||||
assert 'id="edit-metadata-modal"' in html
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_index_page_loads_context_menu_script(self, authenticated_client):
|
||||
"""Verify context-menu.js script tag is present."""
|
||||
response = await authenticated_client.get("/")
|
||||
|
||||
if response.status_code == 200:
|
||||
html = response.text
|
||||
assert "context-menu.js" in html
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_index_page_loads_edit_modal_script(self, authenticated_client):
|
||||
"""Verify edit-modal.js script tag is present."""
|
||||
response = await authenticated_client.get("/")
|
||||
|
||||
if response.status_code == 200:
|
||||
html = response.text
|
||||
assert "edit-modal.js" in html
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modal_form_fields_present(self, authenticated_client):
|
||||
"""Verify key, tmdb_id, tvdb_id input fields exist in modal."""
|
||||
response = await authenticated_client.get("/")
|
||||
|
||||
if response.status_code == 200:
|
||||
html = response.text
|
||||
assert 'id="edit-key"' in html
|
||||
assert 'id="edit-tmdb-id"' in html
|
||||
assert 'id="edit-tvdb-id"' in html
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nfo_repair_button_present(self, authenticated_client):
|
||||
"""Verify repair NFO button exists in modal."""
|
||||
response = await authenticated_client.get("/")
|
||||
|
||||
if response.status_code == 200:
|
||||
html = response.text
|
||||
assert 'id="btn-repair-nfo"' in html
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_button_present(self, authenticated_client):
|
||||
"""Verify save button exists in modal."""
|
||||
response = await authenticated_client.get("/")
|
||||
|
||||
if response.status_code == 200:
|
||||
html = response.text
|
||||
assert 'id="btn-save-metadata"' in html
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modal_starts_hidden(self, authenticated_client):
|
||||
"""Verify modal has hidden class by default."""
|
||||
response = await authenticated_client.get("/")
|
||||
|
||||
if response.status_code == 200:
|
||||
html = response.text
|
||||
assert 'id="edit-metadata-modal" class="modal hidden"' in html
|
||||
501
tests/frontend/unit/anime_settings.test.js
Normal file
501
tests/frontend/unit/anime_settings.test.js
Normal file
@@ -0,0 +1,501 @@
|
||||
/**
|
||||
* Unit tests for AniWorld.AnimeSettingsManager
|
||||
*
|
||||
* Tests every public function on the per-anime settings page JS module:
|
||||
* - init() : binds DOM events, starts initial load
|
||||
* - loadSeries(key) : fetches /api/anime/{key}/settings
|
||||
* - saveSettings(opts) : PUTs /api/anime/{key}/settings
|
||||
* - regenerateNfo() : POSTs /api/anime/{key}/regenerate-nfo
|
||||
* - validateField(name, value) : client-side validation
|
||||
* - populateForm(data) : fills form from payload
|
||||
* - showSaveSuccess(msg) : success toast
|
||||
* - showError(msg) : error toast
|
||||
*
|
||||
* Also verifies the auth header is included on every fetch.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Polyfill fetch globally (Vitest JSDOM env provides it but stub for clarity)
|
||||
function mockFetchSequence(responses) {
|
||||
let callIndex = 0;
|
||||
global.fetch = vi.fn(async () => {
|
||||
const r = responses[callIndex++];
|
||||
if (!r) {
|
||||
throw new Error('Unexpected fetch call');
|
||||
}
|
||||
return {
|
||||
ok: r.ok !== false,
|
||||
status: r.status || 200,
|
||||
json: async () => r.body || {},
|
||||
text: async () => r.text || JSON.stringify(r.body || {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function readModuleSource() {
|
||||
// Load the AnimeSettingsManager source via fs and eval inside a
|
||||
// window-like scope. This mirrors the production IIFE pattern.
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const src = fs.readFileSync(
|
||||
path.resolve(__dirname, '../../../src/server/web/static/js/pages/anime-settings.js'),
|
||||
'utf8'
|
||||
);
|
||||
// Execute in global scope
|
||||
// eslint-disable-next-line no-eval
|
||||
(0, eval)(src);
|
||||
return global.AniWorld.AnimeSettingsManager;
|
||||
}
|
||||
|
||||
describe('AnimeSettingsManager', () => {
|
||||
let manager;
|
||||
|
||||
beforeEach(() => {
|
||||
// Build a minimal DOM tree covering every id the module touches
|
||||
document.body.innerHTML = `
|
||||
<div id="no-key-section" class="hidden"></div>
|
||||
<div id="loading-section" class="hidden"></div>
|
||||
<div id="error-section" class="hidden"></div>
|
||||
<div id="settings-section" class="hidden"></div>
|
||||
|
||||
<select id="series-select"></select>
|
||||
<button id="load-series-btn"></button>
|
||||
<button id="retry-btn"></button>
|
||||
|
||||
<p id="error-message"></p>
|
||||
<h2 id="series-display-name"></h2>
|
||||
<span id="badge-loading-status"></span>
|
||||
<span id="badge-has-nfo"></span>
|
||||
<span id="badge-episode-counts"></span>
|
||||
|
||||
<code id="overview-key"></code>
|
||||
<span id="overview-year"></span>
|
||||
<span id="overview-loading-status"></span>
|
||||
<span id="overview-episode-count"></span>
|
||||
<span id="overview-missing-count"></span>
|
||||
<span id="overview-nfo-created"></span>
|
||||
<span id="overview-nfo-updated"></span>
|
||||
<code id="overview-nfo-path"></code>
|
||||
|
||||
<input type="text" id="field-name" />
|
||||
<input type="text" id="field-folder" />
|
||||
<input type="number" id="field-tmdb-id" />
|
||||
<input type="number" id="field-tvdb-id" />
|
||||
<input type="text" id="field-site" />
|
||||
|
||||
<small id="hint-name"></small>
|
||||
<small id="hint-folder"></small>
|
||||
<small id="hint-tmdb-id"></small>
|
||||
<small id="hint-tvdb-id"></small>
|
||||
<small id="hint-site"></small>
|
||||
|
||||
<button id="save-db-btn"></button>
|
||||
<button id="save-db-nfo-btn"></button>
|
||||
<button id="reset-btn"></button>
|
||||
<input type="checkbox" id="rename-disk-toggle" />
|
||||
|
||||
<button id="regenerate-nfo-btn"></button>
|
||||
<button id="view-nfo-btn"></button>
|
||||
<pre id="nfo-content" class="hidden"></pre>
|
||||
`;
|
||||
|
||||
// Provide the shared helpers the module expects
|
||||
global.AniWorld = {
|
||||
Auth: {
|
||||
getToken: vi.fn(() => 'fake-jwt-token'),
|
||||
checkAuth: vi.fn().mockResolvedValue(true),
|
||||
},
|
||||
UI: {
|
||||
showToast: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
// Load module
|
||||
manager = readModuleSource();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// init()
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
describe('init()', () => {
|
||||
it('reads ?key= from URL and calls loadSeries', async () => {
|
||||
mockFetchSequence([{
|
||||
status: 200,
|
||||
body: { key: 'aot', name: 'AOT', tmdb_id: 1 },
|
||||
}]);
|
||||
|
||||
// Replace window.location with a controllable mock
|
||||
delete window.location;
|
||||
window.location = { search: '?key=aot', href: 'http://x/anime/settings?key=aot' };
|
||||
|
||||
manager.init();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
const url = global.fetch.mock.calls[0][0];
|
||||
expect(url).toContain('/api/anime/aot/settings');
|
||||
});
|
||||
|
||||
it('shows no-key section when no ?key is present', async () => {
|
||||
delete window.location;
|
||||
window.location = { search: '', href: 'http://x/anime/settings' };
|
||||
|
||||
// Stub populateSeriesSelect to avoid network
|
||||
global.fetch = vi.fn(async () => ({
|
||||
ok: true, status: 200,
|
||||
json: async () => [],
|
||||
text: async () => '[]',
|
||||
}));
|
||||
|
||||
manager.init();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const section = document.getElementById('no-key-section');
|
||||
expect(section.classList.contains('hidden')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// loadSeries()
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
describe('loadSeries()', () => {
|
||||
it('calls fetch with auth header', async () => {
|
||||
mockFetchSequence([{
|
||||
status: 200,
|
||||
body: {
|
||||
key: 'naruto',
|
||||
name: 'Naruto',
|
||||
site: 'aniworld.to',
|
||||
folder: 'Naruto (2002)',
|
||||
year: 2002,
|
||||
tmdb_id: 20,
|
||||
tvdb_id: null,
|
||||
has_nfo: true,
|
||||
nfo_path: '/anime/Naruto/tvshow.nfo',
|
||||
episode_count: 5,
|
||||
missing_episode_count: 2,
|
||||
loading_status: 'completed',
|
||||
},
|
||||
}]);
|
||||
|
||||
await manager.loadSeries('naruto');
|
||||
const [url, opts] = global.fetch.mock.calls[0];
|
||||
expect(url).toBe('/api/anime/naruto/settings');
|
||||
expect(opts.headers.Authorization).toBe('Bearer fake-jwt-token');
|
||||
});
|
||||
|
||||
it('populates the form on success', async () => {
|
||||
mockFetchSequence([{
|
||||
status: 200,
|
||||
body: {
|
||||
key: 'naruto',
|
||||
name: 'Naruto',
|
||||
site: 'aniworld.to',
|
||||
folder: 'Naruto (2002)',
|
||||
year: 2002,
|
||||
tmdb_id: 20,
|
||||
tvdb_id: null,
|
||||
has_nfo: true,
|
||||
nfo_path: '/anime/Naruto/tvshow.nfo',
|
||||
episode_count: 5,
|
||||
missing_episode_count: 2,
|
||||
loading_status: 'completed',
|
||||
},
|
||||
}]);
|
||||
|
||||
await manager.loadSeries('naruto');
|
||||
expect(document.getElementById('field-name').value).toBe('Naruto');
|
||||
expect(document.getElementById('field-folder').value).toBe('Naruto (2002)');
|
||||
expect(document.getElementById('field-tmdb-id').value).toBe('20');
|
||||
});
|
||||
|
||||
it('handles 404 by showing the error section', async () => {
|
||||
mockFetchSequence([{ status: 404, body: { detail: 'not found' } }]);
|
||||
await manager.loadSeries('missing');
|
||||
expect(
|
||||
document.getElementById('error-section').classList.contains('hidden')
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('handles 401 by calling showError', async () => {
|
||||
mockFetchSequence([{ status: 401, body: { detail: 'unauthorized' } }]);
|
||||
await manager.loadSeries('whatever');
|
||||
expect(global.AniWorld.UI.showToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining('authenticated'),
|
||||
'error'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// saveSettings()
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
describe('saveSettings()', () => {
|
||||
beforeEach(async () => {
|
||||
// First, set currentKey via loadSeries (matches URL-based init)
|
||||
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: false,
|
||||
nfo_path: null, episode_count: 0, missing_episode_count: 0,
|
||||
loading_status: 'completed',
|
||||
},
|
||||
}]);
|
||||
await manager.loadSeries('a');
|
||||
|
||||
// Now overwrite the form values with what we want to save.
|
||||
// (loadSeries populates form from server, but we want to test
|
||||
// that saveSettings sends the user-typed values, so we mutate
|
||||
// them AFTER the load.)
|
||||
document.getElementById('field-name').value = 'New Name';
|
||||
document.getElementById('field-folder').value = 'New Folder';
|
||||
document.getElementById('field-tmdb-id').value = '1234';
|
||||
document.getElementById('field-tvdb-id').value = '';
|
||||
document.getElementById('field-site').value = 'https://x';
|
||||
});
|
||||
|
||||
it('sends PUT with auth header and JSON body', async () => {
|
||||
mockFetchSequence([{
|
||||
status: 200,
|
||||
body: {
|
||||
key: 'a',
|
||||
name: 'New Name',
|
||||
folder: 'New Folder',
|
||||
tmdb_id: 1234,
|
||||
},
|
||||
}]);
|
||||
|
||||
await manager.saveSettings({ applyToNfo: false });
|
||||
|
||||
const [url, opts] = global.fetch.mock.calls[0];
|
||||
expect(url).toBe('/api/anime/a/settings');
|
||||
expect(opts.method).toBe('PUT');
|
||||
expect(opts.headers.Authorization).toBe('Bearer fake-jwt-token');
|
||||
expect(opts.headers['Content-Type']).toBe('application/json');
|
||||
const body = JSON.parse(opts.body);
|
||||
expect(body.name).toBe('New Name');
|
||||
expect(body.folder).toBe('New Folder');
|
||||
// form inputs return strings; the module passes them through
|
||||
// verbatim — the server coerces to int.
|
||||
expect(String(body.tmdb_id)).toBe('1234');
|
||||
expect(body.apply_to_nfo).toBe(false);
|
||||
});
|
||||
|
||||
it('shows success toast on save', async () => {
|
||||
mockFetchSequence([{
|
||||
status: 200,
|
||||
body: { key: 'a', name: 'New Name' },
|
||||
}]);
|
||||
await manager.saveSettings({ applyToNfo: false });
|
||||
expect(global.AniWorld.UI.showToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining('saved'),
|
||||
'success'
|
||||
);
|
||||
});
|
||||
|
||||
it('shows "regenerated" message when applyToNfo=true', async () => {
|
||||
mockFetchSequence([{
|
||||
status: 200,
|
||||
body: { key: 'a', name: 'New Name', has_nfo: true },
|
||||
}]);
|
||||
await manager.saveSettings({ applyToNfo: true });
|
||||
expect(global.AniWorld.UI.showToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining('regenerated'),
|
||||
'success'
|
||||
);
|
||||
});
|
||||
|
||||
it('shows error toast on 422', async () => {
|
||||
mockFetchSequence([{ status: 422, body: { detail: 'bad tmdb_id' } }]);
|
||||
await manager.saveSettings({ applyToNfo: false });
|
||||
expect(global.AniWorld.UI.showToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Validation'),
|
||||
'error'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// regenerateNfo()
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
describe('regenerateNfo()', () => {
|
||||
beforeEach(async () => {
|
||||
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: false,
|
||||
nfo_path: null, episode_count: 0, missing_episode_count: 0,
|
||||
loading_status: 'completed',
|
||||
},
|
||||
}]);
|
||||
await manager.loadSeries('a');
|
||||
});
|
||||
|
||||
it('calls POST /regenerate-nfo and shows success toast', async () => {
|
||||
mockFetchSequence([{
|
||||
status: 200,
|
||||
body: {
|
||||
success: true,
|
||||
message: 'NFO regenerated.',
|
||||
repaired_tags: ['title'],
|
||||
},
|
||||
}]);
|
||||
|
||||
await manager.regenerateNfo();
|
||||
const [url, opts] = global.fetch.mock.calls[0];
|
||||
expect(url).toBe('/api/anime/a/regenerate-nfo');
|
||||
expect(opts.method).toBe('POST');
|
||||
expect(global.AniWorld.UI.showToast).toHaveBeenCalledWith(
|
||||
'NFO regenerated.',
|
||||
'success'
|
||||
);
|
||||
});
|
||||
|
||||
it('shows error toast on 400 (no tmdb_id)', async () => {
|
||||
mockFetchSequence([{ status: 400, body: { detail: 'no TMDB ID' } }]);
|
||||
await manager.regenerateNfo();
|
||||
expect(global.AniWorld.UI.showToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Cannot regenerate'),
|
||||
'error'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// validateField()
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
describe('validateField()', () => {
|
||||
it('rejects empty name', () => {
|
||||
expect(manager.validateField('name', '')).toMatch(/empty/i);
|
||||
expect(manager.validateField('name', null)).toMatch(/empty/i);
|
||||
});
|
||||
it('rejects too-long name', () => {
|
||||
expect(manager.validateField('name', 'x'.repeat(501))).toMatch(/exceeds/);
|
||||
});
|
||||
it('accepts valid name', () => {
|
||||
expect(manager.validateField('name', 'Naruto')).toBeNull();
|
||||
});
|
||||
it('rejects folder with path traversal', () => {
|
||||
expect(manager.validateField('folder', '../etc')).toMatch(/path traversal/i);
|
||||
});
|
||||
it('rejects folder with invalid characters', () => {
|
||||
expect(manager.validateField('folder', 'foo\x00bar')).toMatch(/invalid/i);
|
||||
});
|
||||
it('accepts tmdb_id as integer string', () => {
|
||||
expect(manager.validateField('tmdb_id', '12345')).toBeNull();
|
||||
});
|
||||
it('rejects tmdb_id = "abc"', () => {
|
||||
expect(manager.validateField('tmdb_id', 'abc')).toMatch(/integer/i);
|
||||
});
|
||||
it('rejects negative tmdb_id', () => {
|
||||
expect(manager.validateField('tmdb_id', '-5')).toMatch(/positive/i);
|
||||
});
|
||||
it('rejects oversized tmdb_id', () => {
|
||||
expect(manager.validateField('tmdb_id', '99999999999')).toMatch(/10 digits/i);
|
||||
});
|
||||
it('accepts empty tvdb_id (optional)', () => {
|
||||
expect(manager.validateField('tvdb_id', '')).toBeNull();
|
||||
expect(manager.validateField('tvdb_id', undefined)).toBeNull();
|
||||
});
|
||||
it('rejects negative tvdb_id', () => {
|
||||
expect(manager.validateField('tvdb_id', '-1')).toMatch(/positive/i);
|
||||
});
|
||||
it('accepts valid site', () => {
|
||||
expect(manager.validateField('site', 'https://aniworld.to')).toBeNull();
|
||||
});
|
||||
it('rejects too-long site', () => {
|
||||
expect(manager.validateField('site', 'x'.repeat(501))).toMatch(/exceeds/);
|
||||
});
|
||||
it('returns null for unknown field name', () => {
|
||||
expect(manager.validateField('mystery_field', 'anything')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// populateForm()
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
describe('populateForm()', () => {
|
||||
it('sets all overview and form fields', () => {
|
||||
manager.populateForm({
|
||||
key: 'a',
|
||||
name: 'A',
|
||||
site: 'aniworld.to',
|
||||
folder: 'A (2020)',
|
||||
year: 2020,
|
||||
tmdb_id: 100,
|
||||
tvdb_id: 200,
|
||||
has_nfo: true,
|
||||
nfo_path: '/anime/A/tvshow.nfo',
|
||||
episode_count: 12,
|
||||
missing_episode_count: 3,
|
||||
loading_status: 'completed',
|
||||
});
|
||||
expect(document.getElementById('field-name').value).toBe('A');
|
||||
expect(document.getElementById('field-folder').value).toBe('A (2020)');
|
||||
expect(document.getElementById('field-tmdb-id').value).toBe('100');
|
||||
expect(document.getElementById('field-tvdb-id').value).toBe('200');
|
||||
expect(document.getElementById('overview-key').textContent).toBe('a');
|
||||
expect(document.getElementById('overview-year').textContent).toBe('2020');
|
||||
});
|
||||
|
||||
it('handles missing optional fields gracefully', () => {
|
||||
manager.populateForm({ key: 'a', name: 'A' });
|
||||
expect(document.getElementById('field-tmdb-id').value).toBe('');
|
||||
expect(document.getElementById('field-tvdb-id').value).toBe('');
|
||||
expect(document.getElementById('field-name').value).toBe('A');
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// showSaveSuccess() / showError()
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
describe('showSaveSuccess()', () => {
|
||||
it('calls AniWorld.UI.showToast with success type', () => {
|
||||
manager.showSaveSuccess('Saved!');
|
||||
expect(global.AniWorld.UI.showToast).toHaveBeenCalledWith(
|
||||
'Saved!', 'success'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('showError()', () => {
|
||||
it('calls AniWorld.UI.showToast with error type', () => {
|
||||
manager.showError('Boom');
|
||||
expect(global.AniWorld.UI.showToast).toHaveBeenCalledWith(
|
||||
'Boom', 'error'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Public API surface
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
it('exposes all expected public methods', () => {
|
||||
expect(typeof manager.init).toBe('function');
|
||||
expect(typeof manager.loadSeries).toBe('function');
|
||||
expect(typeof manager.saveSettings).toBe('function');
|
||||
expect(typeof manager.regenerateNfo).toBe('function');
|
||||
expect(typeof manager.validateField).toBe('function');
|
||||
expect(typeof manager.populateForm).toBe('function');
|
||||
expect(typeof manager.showSaveSuccess).toBe('function');
|
||||
expect(typeof manager.showError).toBe('function');
|
||||
});
|
||||
});
|
||||
167
tests/frontend/unit/context_menu.test.js
Normal file
167
tests/frontend/unit/context_menu.test.js
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Unit tests for AniWorld.ContextMenu
|
||||
*
|
||||
* Covers the right-click → "Anime Settings" navigation flow including
|
||||
* the regression where `hide()` was called BEFORE the navigation
|
||||
* `window.location.href` was built, which caused the key to be reset
|
||||
* to null and the URL to become `/anime/settings?key=null`.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const SRC_PATH = resolve(
|
||||
__dirname,
|
||||
'../../../src/server/web/static/js/index/context-menu.js',
|
||||
);
|
||||
|
||||
function loadContextMenu() {
|
||||
// Reset module state so each test gets a fresh closure.
|
||||
delete global.AniWorld;
|
||||
const src = readFileSync(SRC_PATH, 'utf8');
|
||||
// Indirect eval → runs in global scope so `var AniWorld = window.AniWorld
|
||||
// || {}` mutates the real `global.AniWorld` (and through it,
|
||||
// `window.AniWorld` since happy-dom exposes global on window).
|
||||
// eslint-disable-next-line no-eval
|
||||
(0, eval)(src);
|
||||
return global.AniWorld.ContextMenu;
|
||||
}
|
||||
|
||||
describe('ContextMenu — right-click → Anime Settings flow', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
delete window.AniWorld;
|
||||
delete global.AniWorld;
|
||||
delete window.location;
|
||||
window.location = { href: '' };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('navigates to /anime/settings?key=<series-key> after menu click', () => {
|
||||
const ctx = loadContextMenu();
|
||||
expect(ctx).toBeTruthy();
|
||||
expect(typeof ctx.show).toBe('function');
|
||||
|
||||
const grid = document.createElement('div');
|
||||
grid.id = 'series-grid';
|
||||
const card = document.createElement('div');
|
||||
card.className = 'series-card';
|
||||
card.setAttribute('data-key', 'attack-on-titan');
|
||||
grid.appendChild(card);
|
||||
document.body.appendChild(grid);
|
||||
|
||||
ctx.init();
|
||||
|
||||
card.dispatchEvent(
|
||||
new MouseEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
clientX: 10,
|
||||
clientY: 10,
|
||||
}),
|
||||
);
|
||||
|
||||
const menuItem = document.querySelector(
|
||||
'[data-action="anime-settings"]',
|
||||
);
|
||||
expect(menuItem).toBeTruthy();
|
||||
|
||||
menuItem.click();
|
||||
|
||||
expect(window.location.href).toBe(
|
||||
'/anime/settings?key=attack-on-titan',
|
||||
);
|
||||
});
|
||||
|
||||
it('encodes special characters in the key (URL-unsafe slugs)', () => {
|
||||
const ctx = loadContextMenu();
|
||||
|
||||
const grid = document.createElement('div');
|
||||
grid.id = 'series-grid';
|
||||
const card = document.createElement('div');
|
||||
card.className = 'series-card';
|
||||
card.setAttribute('data-key', 'a/b c');
|
||||
grid.appendChild(card);
|
||||
document.body.appendChild(grid);
|
||||
|
||||
ctx.init();
|
||||
card.dispatchEvent(
|
||||
new MouseEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
clientX: 5,
|
||||
clientY: 5,
|
||||
}),
|
||||
);
|
||||
document.querySelector('[data-action="anime-settings"]').click();
|
||||
|
||||
expect(window.location.href).toBe('/anime/settings?key=a%2Fb%20c');
|
||||
});
|
||||
|
||||
it('source captures the key before calling hide() — regression guard', () => {
|
||||
// Static invariant: the click handler must read currentSeriesKey
|
||||
// BEFORE calling hide(). This guards against regressions of the
|
||||
// bug where hide() cleared currentSeriesKey before the URL was
|
||||
// built, resulting in /anime/settings?key=null.
|
||||
const src = readFileSync(SRC_PATH, 'utf8');
|
||||
const clickHandlerMatch = src.match(
|
||||
/querySelector\('\[data-action="anime-settings"\]'\)\.addEventListener\('click',\s*function\s*\(\)\s*{([\s\S]*?)\}\);/,
|
||||
);
|
||||
expect(clickHandlerMatch, 'click handler should exist').toBeTruthy();
|
||||
const body = clickHandlerMatch[1];
|
||||
|
||||
expect(body).toMatch(/currentSeriesKey/);
|
||||
expect(body).toMatch(/\bhide\s*\(\s*\)/);
|
||||
expect(body).toMatch(/const\s+key\s*=\s*currentSeriesKey/);
|
||||
});
|
||||
|
||||
it('does not expose legacy nfo-diagnostics action', () => {
|
||||
const ctx = loadContextMenu();
|
||||
|
||||
const grid = document.createElement('div');
|
||||
grid.id = 'series-grid';
|
||||
const card = document.createElement('div');
|
||||
card.className = 'series-card';
|
||||
card.setAttribute('data-key', 'k');
|
||||
grid.appendChild(card);
|
||||
document.body.appendChild(grid);
|
||||
|
||||
ctx.init();
|
||||
card.dispatchEvent(
|
||||
new MouseEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
clientX: 5,
|
||||
clientY: 5,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
document.querySelector('[data-action="nfo-diagnostics"]'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
document.querySelector('[data-action="anime-settings"]'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('right-click outside a series card does not show the menu', () => {
|
||||
const ctx = loadContextMenu();
|
||||
|
||||
const grid = document.createElement('div');
|
||||
grid.id = 'series-grid';
|
||||
document.body.appendChild(grid);
|
||||
|
||||
ctx.init();
|
||||
// Click on empty grid area — should NOT show menu (no .series-card ancestor).
|
||||
grid.dispatchEvent(
|
||||
new MouseEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
clientX: 5,
|
||||
clientY: 5,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(document.querySelector('.context-menu')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,26 @@
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Load the real queue-api.js module
|
||||
function loadQueueAPI() {
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const src = fs.readFileSync(
|
||||
path.resolve(__dirname, '../../../src/server/web/static/js/queue/queue-api.js'),
|
||||
'utf8'
|
||||
);
|
||||
// eslint-disable-next-line no-eval
|
||||
(0, eval)(src);
|
||||
return global.AniWorld.QueueAPI;
|
||||
}
|
||||
|
||||
// Stub the minimal dependencies queue-api.js needs that aren't in setupMockAniWorld
|
||||
function stubQueueAPI() {
|
||||
// queue-api.js calls AniWorld.Constants.API.* — those are already in setupMockAniWorld
|
||||
// AniWorld.ApiClient is already a vi.fn() stub in setupMockAniWorld
|
||||
// Nothing extra needed — the ApiClient stubs are already correct
|
||||
}
|
||||
|
||||
// Mock DOM setup
|
||||
function setupDOM() {
|
||||
document.body.innerHTML = `
|
||||
@@ -93,24 +113,36 @@ function setupMockAniWorld() {
|
||||
ProgressHandler: {
|
||||
processPendingProgressUpdates: vi.fn(),
|
||||
updateProgress: vi.fn()
|
||||
},
|
||||
QueueAPI: {
|
||||
loadQueueData: vi.fn(),
|
||||
startQueue: vi.fn(),
|
||||
stopQueue: vi.fn(),
|
||||
removeFromQueue: vi.fn(),
|
||||
retryDownloads: vi.fn(),
|
||||
clearCompleted: vi.fn(),
|
||||
clearFailed: vi.fn(),
|
||||
clearPending: vi.fn()
|
||||
}
|
||||
// QueueAPI intentionally omitted — tests that need it call loadQueueAPI()
|
||||
// to get the real module; inline handlers in button tests need the mock to
|
||||
// delegate, so we patch it after setupMockAniWorld in those describe blocks.
|
||||
};
|
||||
}
|
||||
|
||||
// Patch setupMockAniWorld's QueueAPI stub to delegate to the real module.
|
||||
// Called inside each beforeEach that has inline handlers referencing QueueAPI.
|
||||
function patchQueueAPIDelegate() {
|
||||
const real = loadQueueAPI();
|
||||
global.AniWorld.QueueAPI = {
|
||||
loadQueueData: real.loadQueueData,
|
||||
startQueue: real.startQueue,
|
||||
stopQueue: real.stopQueue,
|
||||
removeFromQueue: real.removeFromQueue,
|
||||
retryDownloads: real.retryDownloads,
|
||||
clearCompleted: real.clearCompleted,
|
||||
clearFailed: real.clearFailed,
|
||||
clearPending: real.clearPending,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Queue API - Data Loading', () => {
|
||||
let QueueAPI;
|
||||
|
||||
beforeEach(() => {
|
||||
setupDOM();
|
||||
setupMockAniWorld();
|
||||
QueueAPI = loadQueueAPI();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -141,7 +173,7 @@ describe('Queue API - Data Loading', () => {
|
||||
};
|
||||
global.AniWorld.ApiClient.get.mockResolvedValue(mockResponse);
|
||||
|
||||
const data = await global.AniWorld.QueueAPI.loadQueueData();
|
||||
const data = await QueueAPI.loadQueueData();
|
||||
|
||||
expect(global.AniWorld.ApiClient.get).toHaveBeenCalledWith('/api/queue/status');
|
||||
expect(data).toHaveProperty('statistics');
|
||||
@@ -151,7 +183,7 @@ describe('Queue API - Data Loading', () => {
|
||||
it('should handle API error gracefully', async () => {
|
||||
global.AniWorld.ApiClient.get.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const data = await global.AniWorld.QueueAPI.loadQueueData();
|
||||
const data = await QueueAPI.loadQueueData();
|
||||
|
||||
expect(data).toBeNull();
|
||||
});
|
||||
@@ -176,7 +208,7 @@ describe('Queue API - Data Loading', () => {
|
||||
};
|
||||
global.AniWorld.ApiClient.get.mockResolvedValue(mockResponse);
|
||||
|
||||
const data = await global.AniWorld.QueueAPI.loadQueueData();
|
||||
const data = await QueueAPI.loadQueueData();
|
||||
|
||||
expect(data.is_running).toBe(true);
|
||||
expect(data.pending_items).toHaveLength(1);
|
||||
@@ -185,9 +217,12 @@ describe('Queue API - Data Loading', () => {
|
||||
});
|
||||
|
||||
describe('Queue API - Queue Control', () => {
|
||||
let QueueAPI;
|
||||
|
||||
beforeEach(() => {
|
||||
setupDOM();
|
||||
setupMockAniWorld();
|
||||
QueueAPI = loadQueueAPI();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -200,7 +235,7 @@ describe('Queue API - Queue Control', () => {
|
||||
};
|
||||
global.AniWorld.ApiClient.post.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await global.AniWorld.QueueAPI.startQueue();
|
||||
const result = await QueueAPI.startQueue();
|
||||
|
||||
expect(global.AniWorld.ApiClient.post).toHaveBeenCalledWith('/api/queue/start', {});
|
||||
expect(result.message).toBe('Queue started');
|
||||
@@ -212,29 +247,32 @@ describe('Queue API - Queue Control', () => {
|
||||
};
|
||||
global.AniWorld.ApiClient.post.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await global.AniWorld.QueueAPI.stopQueue();
|
||||
const result = await QueueAPI.stopQueue();
|
||||
|
||||
expect(global.AniWorld.ApiClient.post).toHaveBeenCalledWith('/api/queue/stop', {});
|
||||
expect(result.message).toBe('Queue stopped');
|
||||
});
|
||||
|
||||
it('should handle start queue error', async () => {
|
||||
global.AniWorld.ApiClient.post.mockRejectedValue(new Error('Already running'));
|
||||
|
||||
await expect(global.AniWorld.QueueAPI.startQueue()).rejects.toThrow('Already running');
|
||||
global.AniWorld.ApiClient.post.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
await expect(QueueAPI.startQueue()).rejects.toThrow('Network error');
|
||||
});
|
||||
|
||||
|
||||
it('should handle stop queue error', async () => {
|
||||
global.AniWorld.ApiClient.post.mockRejectedValue(new Error('Not running'));
|
||||
|
||||
await expect(global.AniWorld.QueueAPI.stopQueue()).rejects.toThrow('Not running');
|
||||
global.AniWorld.ApiClient.post.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
await expect(QueueAPI.stopQueue()).rejects.toThrow('Network error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Queue API - Item Management', () => {
|
||||
let QueueAPI;
|
||||
|
||||
beforeEach(() => {
|
||||
setupDOM();
|
||||
setupMockAniWorld();
|
||||
QueueAPI = loadQueueAPI();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -247,7 +285,7 @@ describe('Queue API - Item Management', () => {
|
||||
};
|
||||
global.AniWorld.ApiClient.delete.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await global.AniWorld.QueueAPI.removeFromQueue('item-123');
|
||||
const result = await QueueAPI.removeFromQueue('item-123');
|
||||
|
||||
expect(global.AniWorld.ApiClient.delete).toHaveBeenCalledWith('/api/queue/remove/item-123');
|
||||
expect(result).toBe(true);
|
||||
@@ -260,7 +298,7 @@ describe('Queue API - Item Management', () => {
|
||||
global.AniWorld.ApiClient.post.mockResolvedValue(mockResponse);
|
||||
|
||||
const itemIds = ['item-1', 'item-2'];
|
||||
const result = await global.AniWorld.QueueAPI.retryDownloads(itemIds);
|
||||
const result = await QueueAPI.retryDownloads(itemIds);
|
||||
|
||||
expect(global.AniWorld.ApiClient.post).toHaveBeenCalledWith('/api/queue/retry', { item_ids: itemIds });
|
||||
expect(result.retried).toBe(2);
|
||||
@@ -272,7 +310,7 @@ describe('Queue API - Item Management', () => {
|
||||
};
|
||||
global.AniWorld.ApiClient.delete.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await global.AniWorld.QueueAPI.clearCompleted();
|
||||
const result = await QueueAPI.clearCompleted();
|
||||
|
||||
expect(global.AniWorld.ApiClient.delete).toHaveBeenCalledWith('/api/queue/completed');
|
||||
expect(result.cleared).toBe(5);
|
||||
@@ -284,7 +322,7 @@ describe('Queue API - Item Management', () => {
|
||||
};
|
||||
global.AniWorld.ApiClient.delete.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await global.AniWorld.QueueAPI.clearFailed();
|
||||
const result = await QueueAPI.clearFailed();
|
||||
|
||||
expect(global.AniWorld.ApiClient.delete).toHaveBeenCalledWith('/api/queue/failed');
|
||||
expect(result.cleared).toBe(3);
|
||||
@@ -296,7 +334,7 @@ describe('Queue API - Item Management', () => {
|
||||
};
|
||||
global.AniWorld.ApiClient.delete.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await global.AniWorld.QueueAPI.clearPending();
|
||||
const result = await QueueAPI.clearPending();
|
||||
|
||||
expect(global.AniWorld.ApiClient.delete).toHaveBeenCalledWith('/api/queue/pending');
|
||||
expect(result.cleared).toBe(2);
|
||||
@@ -339,6 +377,15 @@ describe('Queue Renderer - Statistics Display', () => {
|
||||
});
|
||||
|
||||
it('should handle zero statistics', () => {
|
||||
// Rebuild DOM from scratch
|
||||
document.body.innerHTML = `
|
||||
<span id="pending-count"></span>
|
||||
<span id="active-count"></span>
|
||||
<span id="completed-count"></span>
|
||||
<span id="failed-count"></span>
|
||||
<span id="total-count"></span>
|
||||
`;
|
||||
|
||||
const data = {
|
||||
statistics: {
|
||||
pending: 0,
|
||||
@@ -348,20 +395,21 @@ describe('Queue Renderer - Statistics Display', () => {
|
||||
total: 0
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('pending-count').textContent = data.statistics.pending;
|
||||
document.getElementById('active-count').textContent = data.statistics.active;
|
||||
document.getElementById('completed-count').textContent = data.statistics.completed;
|
||||
document.getElementById('failed-count').textContent = data.statistics.failed;
|
||||
document.getElementById('total-count').textContent = data.statistics.total;
|
||||
|
||||
|
||||
// Use innerHTML to set values directly (avoids textContent coercion issues in JSDOM)
|
||||
document.getElementById('pending-count').innerHTML = data.statistics.pending;
|
||||
document.getElementById('active-count').innerHTML = data.statistics.active;
|
||||
document.getElementById('completed-count').innerHTML = data.statistics.completed;
|
||||
document.getElementById('failed-count').innerHTML = data.statistics.failed;
|
||||
document.getElementById('total-count').innerHTML = data.statistics.total;
|
||||
|
||||
expect(document.getElementById('pending-count').textContent).toBe('0');
|
||||
expect(document.getElementById('active-count').textContent).toBe('0');
|
||||
expect(document.getElementById('completed-count').textContent).toBe('0');
|
||||
expect(document.getElementById('failed-count').textContent).toBe('0');
|
||||
expect(document.getElementById('total-count').textContent).toBe('0');
|
||||
});
|
||||
|
||||
|
||||
it('should update statistics when queue changes', () => {
|
||||
// Initial state
|
||||
document.getElementById('pending-count').textContent = '5';
|
||||
@@ -540,6 +588,7 @@ describe('Queue Button Handlers', () => {
|
||||
beforeEach(() => {
|
||||
setupDOM();
|
||||
setupMockAniWorld();
|
||||
patchQueueAPIDelegate();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -810,6 +859,12 @@ describe('Queue Edge Cases', () => {
|
||||
});
|
||||
|
||||
it('should handle empty queue gracefully', () => {
|
||||
// Rebuild DOM from scratch to guarantee clean state
|
||||
document.body.innerHTML = `
|
||||
<span id="pending-count"></span>
|
||||
<div id="pending-queue"></div>
|
||||
`;
|
||||
|
||||
const data = {
|
||||
statistics: {
|
||||
pending: 0,
|
||||
@@ -823,10 +878,11 @@ describe('Queue Edge Cases', () => {
|
||||
completed_items: [],
|
||||
failed_items: []
|
||||
};
|
||||
|
||||
document.getElementById('pending-count').textContent = data.statistics.pending;
|
||||
|
||||
// Use innerHTML to set values (avoids textContent coercion issues in JSDOM)
|
||||
document.getElementById('pending-count').innerHTML = data.statistics.pending;
|
||||
document.getElementById('pending-queue').innerHTML = '';
|
||||
|
||||
|
||||
expect(document.getElementById('pending-count').textContent).toBe('0');
|
||||
expect(document.getElementById('pending-queue').children.length).toBe(0);
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user