Compare commits

...

249 Commits

Author SHA1 Message Date
270da18543 fix: emit download_progress events during direct stream downloads
When _try_direct_stream() succeeded, it streamed chunks directly via
requests.get() without firing any progress events. This caused the WebSocket
frontend to only see 'started' (0%) and 'completed' (100%) — no incremental
updates.

Add per-chunk progress events to _try_direct_stream that mirror yt-dlp's
hook format (downloaded_bytes, total_bytes, speed, eta, status), emitted at
~1% intervals. These flow through the existing download_progress handler chain
(SeriesApp → anime_service → progress_service → WebSocket → frontend).
2026-07-31 08:42:59 +02:00
163532b1ef fix: use data.key instead of data.data in series_updated handler
The websocket-client strips the outer {type, data, ...} wrapper before
emitting to handlers, so handlers receive the inner series data object
directly (e.g. {key, name, missing_episodes}) — not {type, data: {...}}.

The series_updated handler was checking data.data which always failed,
causing every update to fall back to a full loadSeries() call instead of
calling updateSingleSeries() directly. This prevented the missing
episodes count from updating in real-time after a download completed.

Fix: check data.key directly and pass data (not data.data) to
updateSingleSeries().
2026-07-31 08:37:44 +02:00
d3cbb60c00 chore: bump version 2026-07-31 07:34:07 +02:00
10ef590242 fix: queue issue 2026-07-31 07:33:12 +02:00
e7628ac44c chore: bump version 2026-07-30 20:10:21 +02:00
f89e403a17 chore: bump version 2026-07-26 21:46:51 +02:00
5f46d2e802 feat: add folder naming service to fix missing years in anime folder names
Runs after NFO refresh during scheduled rescans. Renames folders that
are missing a year (e.g. 'Naruto' → 'Naruto (1999)') using the year
from the database record.

Safety: _build_target_folder() always strips any existing year suffix
first, preventing double/triple year accumulation like
'Naruto (1999) (1999) (1999)'.

Changes:
- New FolderNamingService (folder_naming_service.py) with safe target
  name construction, DB update, and in-memory cache update
- New SchedulerConfig field: folder_naming_after_nfo_scan (default True)
- Integrated as step 3 in scheduler _perform_rescan() after NFO scan
- Runtime UI: existing 'folder-scan-enabled' checkbox in index.html
  now wired to toggle the feature (app.js + scheduler-config.js)
- Setup screen: new checkbox in setup.html Scheduler Settings section
- API: scheduler config endpoint returns all scan toggles
- Tests: 39 unit tests covering static helpers, rename logic, safety
  guard, and integration cases (folder_naming_service.py)
- Docs: testing guide updated with FolderNamingService examples
2026-07-26 21:45:08 +02:00
a384072901 fix: queue background loading after folder resolve; extract year from NFO 2026-07-26 20:02:02 +02:00
d99636e9c7 fix: redirect to /setup/unresolved after series scan completes
- Add 'system_progress' event type to loading page redirect condition
- Add checkUnresolvedAndRedirect() for phase=initial to handle race condition
  where backend initialization completes before WebSocket connects
- Backend now emits series_sync progress events during initial setup
- Loading page checks /api/setup/unresolved immediately on load for phase=initial
- Fixes users getting stuck on loading page after setup
2026-07-15 22:06:40 +02:00
47bd393a57 cleanup 2026-07-03 22:08:11 +02:00
58adf05325 Update test output files and screenshots 2026-07-02 21:25:30 +02:00
a05e8a7b07 fix(robot): add ${None} selector to Evaluate JavaScript calls
Browser Evaluate JavaScript keyword requires a selector element or ${None}.
Without explicit selector, single arg becomes CSS selector instead of JS code.
Fix: Evaluate JavaScript    ${None}    <js code>

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 21:24:39 +02:00
6a1f8c282a Complete Task 9: Fix Robot.Ui.Settings Modal.Edit Backup Settings
Remove resolved task - Execute JavaScript replaced with Evaluate JavaScript
or Scroll in settings modal test.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 21:09:28 +02:00
008873f1af Remove completed Task 8 from docs 2026-07-02 21:08:00 +02:00
e2f0e187d0 fix(settings modal robot): use label click instead of checkbox uncheck
Remove obsolete Task 7 from tasks.md - fix no longer needed after label click approach.
2026-07-02 21:04:42 +02:00
9a3a2cbdcb fix ui tests: close modal via escape, scheduler settings
- Remove Task 5/6 from docs (tests now fixed)
- Close Settings Modal Via Escape: click focus + sleep before Escape key
- Edit Scheduler Settings: use checkbox-custom selector
- Disable Scheduler: use force=True on checkbox id

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 21:03:07 +02:00
1d06f8a00a fix(settings_modal): force click on modal overlay
Caveman commit: overlay click target area small, click miss. Add force=True.

- Remove completed Task 4 from Docs/tasks.md
- Fix flaky close modal via overlay test
2026-07-02 20:54:20 +02:00
34c1469517 fix(ui): update dashboard test assertion text
Change Toast assertion from 'queue' to 'verbund' to match actual UI text.
Remove completed Task 3 docs.
2026-07-02 20:52:59 +02:00
097138376a fix(settings_modal.robot): replace Execute JavaScript with Evaluate JavaScript
Browser library uses Evaluate JavaScript, not Execute JavaScript.
Update scrollTop assignments in modal tests to use correct keyword.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 20:51:45 +02:00
04175a2bd6 fix: update docs and fix multiple Robot test failures
- scheduler.py: fix get_anime_service import, success as string
- download.robot: fix queue endpoint path, retry needs list
- logging.robot: fix JSON path access for config
- nfo.robot: accept 400 when TMDB not configured
- anime_settings.robot: Execute JavaScript -> Evaluate JavaScript
2026-07-02 20:50:32 +02:00
f7b24c3929 fix: re-read password hash from config in is_configured()
Avoid stale in-memory hash after password reset. Load from config each time.
2026-06-28 20:18:48 +02:00
3a6b6dfd9e fix setup redirect: check master_password_hash in saved config
Reset was not putting app in unconfigured state. _needs_setup()
was checking is_configured() but not verifying master_password_hash
exists in saved config file. Added explicit check for this key so
reset properly triggers setup form.

Also removed completed Task 26 from docs.
2026-06-28 20:14:24 +02:00
5e3a68163e fix(ui-test): ensure app unconfigured before setup flow tests
Add Verify App Is Unconfigured keyword with retry logic.
Update Test Setup to reset app state before each setup flow test.
Remove stale Task 25 docs from tasks.md.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-28 19:48:46 +02:00
b845744c9d fix: add admin reset endpoint for test isolation
- Add /api/config/admin/reset unauthenticated endpoint for tests
- Add auth_service.reset() to clear in-memory auth state
- Update robot tests to call reset on teardown
- Remove flaky Setup Redirects test (depended on test order)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-28 19:44:28 +02:00
40d44d8b94 docs(tasks): remove completed Task 22 setup flow test issue
Make master_password_hash optional in development config

None default allows env var to override. Hardcoded dev hash removed.
2026-06-28 18:45:33 +02:00
59b77bf833 Clear MASTER_PASSWORD_HASH env var in test setup
Prevents leftover env vars from previous runs causing auth issues.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-28 18:16:06 +02:00
f29fd72b2d feat(ui): scroll to reveal hidden modal elements in tests
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-28 17:52:11 +02:00
5ac58da650 fix(ui): use label click instead of checkbox id for scheduler disable
Checkbox id brittle. Label click more reliable in robot tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-28 17:49:47 +02:00
5534191268 Add Escape key to close settings modal 2026-06-28 17:39:59 +02:00
db13b39b2b fix(ui-test): add visibility wait before overlay click
Flaky test in CI. Click without wait sometimes hit overlay before
render complete. Add Wait For Elements State before Click.
2026-06-28 17:37:40 +02:00
706aa37f18 Add data-testid to toast, update selector and login test
- Add data-testid='toast' to toast element in ui-utils.js
- Update toast selector in ui_keywords.resource to use [data-testid='toast']
- Update login rate limit test to check for 'invalid' instead of 'lockout'
  (testing mode disables lockout, so test verifies proper error message)
- Remove completed task docs from tasks.md
2026-06-28 17:34:27 +02:00
10b5ca42f5 fix(ui): call AnimeSettingsManager.init() before interacting with settings section
Tests were failing because #settings-section remained hidden. The JavaScript
initialization was not being triggered, leaving UI components in hidden state.
Added explicit init() call in test setup for Anime Settings, Regenerate NFO,
and Update Series Settings test cases.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-28 17:26:22 +02:00
fd8d9636cb Fix anime settings UI tests: add series via API before testing
Tests were failing because settings page requires series in DB.
Now call Add Series API before navigating to settings page.
2026-06-28 17:19:54 +02:00
c6d9615731 fix tests: simplify anime URL key, increase timeout to 10s
- Change attack-on-titan-2013 → attack-on-titan in UI tests
- Increase wait timeout 5s → 10s for settings-section
- Remove completed task entries from Docs/tasks.md
2026-06-28 17:08:30 +02:00
61b539db6f refactor(websocket): use robot_name alias instead of duplicate wrapper method
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-28 17:05:00 +02:00
7900994f78 fix test data: use correct provider key in setup test
Task 9 resolved (removed stale docs).
Provider key 'attack-on-titan' replaced with 'test-unknown-anime-2020'.
2026-06-28 16:49:26 +02:00
aeffb882dc Allow empty schedule_days; add success flag to rescan endpoint
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-28 16:38:48 +02:00
ac02dfd5c6 Complete Task 7 prep: add series setup to NFO repair test
Remove obsolete Task 6 from docs (logging download - either fixed or abandoned).
Add series to library before repair call in test - ensures series exists in DB.
2026-06-28 16:31:54 +02:00
d360e3f7a8 Remove stale Task 5 (log tail test - resolved) 2026-06-28 16:20:02 +02:00
9da66872f6 Fix log file name in API logging tests
Update robot tests to use correct log file name 'fastapi_app.log'
instead of 'aniworld.log'. Also remove completed Task 4 documentation
from tasks.md.
2026-06-28 16:17:17 +02:00
9b6702c5fb fix(api): update logging config test for new response schema
API now returns {success, config} instead of flat logging keys.
Extract config dict before checking keys level, log_file, max_bytes, backup_count.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-28 16:15:27 +02:00
8de563955a fix health check: verify dependencies.system nested keys
Caveman: health.robot test check deeper nesting for system metrics.
Docs: remove stale Task 2 (already fixed).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-28 16:13:17 +02:00
49f39d6b77 Update tasks doc and fix Robot test keyword
- Remove completed Task 1 from Docs/tasks.md
- Fix download.robot: use Get JSON Value instead of deprecated Get Value From Json
- Minor whitespace cleanup
2026-06-28 16:10:37 +02:00
1f6a119bcc fix: parse dict strings from Robot Framework in ConfigUpdate
Robot Framework's Create Dictionary converts nested dicts like {'enabled': False} to string representation. Parse these strings before Pydantic validation. Also handle Pydantic models passed directly from unit tests.
2026-06-28 16:07:20 +02:00
3fc1311d50 backup 2026-06-28 15:53:21 +02:00
0be86771e1 fix API 404 response to return exception detail, add WebSocket RF alias 2026-06-27 19:06:55 +02:00
07890aa464 test: create unresolved folder test fixtures
Add test anime folders for unresolved folder API tests.
Update test data from 'SomeFolder' to 'Unknown Anime (2020)'.
Remove stale Task 16 notes from Docs/tasks.md.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-27 18:15:09 +02:00
2e723087d9 reject empty schedule_days with 422
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-27 17:09:11 +02:00
e5a5a6009a fix: parse malformed schedule_days string from Robot Framework
Robot Framework's Create Dictionary converts ['mon', 'tue'] into a
string. Add _parse_schedule_days to handle JSON/Python-literal parsing
before Pydantic type validation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-27 16:50:29 +02:00
4687a06374 Refactor trigger_rescan to use anime_service
- Use anime_service.rescan() instead of direct trigger_rescan import
- Change Optional[Any] to Optional[Dict[str, Any]] in ConfigUpdate for better type hints
- Add ValidationError handling in ConfigUpdate.apply_to
- Update tests to match new implementation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-27 15:46:22 +02:00
ad2320dbbb Add NFO scan endpoint and fix scheduler test payload
- Implement /api/nfo/scan endpoint returning scan results
- Fix Robot Framework scheduler test: use Evaluate+json.loads instead of Create Dictionary for nested data
- Remove completed task docs
2026-06-27 15:14:56 +02:00
c73b74c0db Remove outdated tasks.md
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-27 14:57:24 +02:00
eea9272de4 Refactor NFO TMDB calls to use tmdb_client
- Replace TMDBProvider with tmdb_client in nfo.py
- Add anime_service param to repair functions
- Add null guard for empty series name lookup

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-27 14:57:19 +02:00
c8f0c6fcb1 Make cleanup_logs payload optional; fix duplicate Optional import
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-27 14:43:18 +02:00
c6d787c2c7 Fix log download Content-Type to application/octet-stream
Browser auto-download requires binary MIME type, not text/plain.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-27 14:34:11 +02:00
30c0499869 Add level field to logging config response 2026-06-27 14:22:06 +02:00
8b98c6d84e Add uptime field to detailed health check endpoint
- Added uptime field to DetailedHealthStatus model
- Calculate uptime as time since startup
- Removed obsolete task entries from Docs/tasks.md
2026-06-27 14:20:25 +02:00
e7a623a0d1 fix: use Get From List instead of direct list indexing
Direct ${ids}[0] syntax unreliable. Use Get From List ${ids} 0 instead.
2026-06-27 14:18:28 +02:00
978e6ef200 fix: use _is_stopped flag for queue start check
_active_download tracks single download; queue uses _is_stopped. Wrong flag
caused false 'already active' errors when queue idle.
2026-06-27 14:01:11 +02:00
fd84a18b30 fix config: handle stringified JSON in API requests
ConfigUpdate fields (scheduler, logging, backup, nfo, other) arrive as
strings from frontend. Parse JSON or ast.literal_eval before validation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-27 13:56:18 +02:00
6c502e2014 fix: scan episodes synchronously and disable test mode rate limiting
- Always scan missing episodes sync in add_series to avoid race condition
- Add db fallback in get_anime when in-memory episodeDict empty
- Add with_episodes param to AnimeSeriesService.get_by_key
- Disable auth rate limiting and lockout in test mode (ANIWORLD_TESTING=1)
- Simplify responsive.robot tests: fix setup, remove fragile width checks
2026-06-27 13:42:09 +02:00
b4027be385 Fix setup flow UI tests - update selectors and add server lifecycle
- Remove completed Task 25 from docs
- Add Suite Setup/Teardown with Start Aniworld Server and browser init
- Update element IDs to match current UI (password, strength-text, name, directory, etc.)
- Update form validation and complete setup flow tests with new selectors
- Add redirect testing after setup complete
2026-06-27 00:07:07 +02:00
a5e1c5b14f test(settings_modal.robot): align test IDs with actual UI element IDs
Update test selectors to match current UI implementation:
- scheduler-time-input → scheduled-rescan-time
- logging-* inputs → log-* equivalents
- backup-path-input → backup-path, backup-keep-days-input → backup-keep-days
- tmdb-api-key-input → tmdb-api-key
- create-backup-btn → create-config-backup

Remove unimplemented backup restore/delete and export/import tests.
Update Docs/tasks.md to remove completed Task 24 entry.
2026-06-26 23:22:06 +02:00
66acb45607 fix: queue page tests use Get Element States instead of disabled attribute
Task 24 fix. Robot tests now use Get Element States to check button
enabled state, which works reliably. Also added 100ms delay after auth
to ensure token availability, and error handling in loadQueueData.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-26 23:12:23 +02:00
2cf008bcf8 fix anime-settings: correct UI module name (UiUtils → UI), update tests with proper selectors
- AniWorld.UiUtils → AniWorld.UI for toast notifications
- Update robot tests to use actual element IDs (settings-section, save-db-btn, field-folder)
- Fix anime key format in test URLs (Attack on Titan - Season 1)
- Remove completed Task 22 from Docs/tasks.md
2026-06-26 22:11:33 +02:00
cece8fcb30 Fix toast pointer-events, add show-all-series button, fix UI tests
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-26 21:49:49 +02:00
7e4aeb22db fix: align password label 'for' attribute with input id
Label referenced 'password' but input id is 'password-input'.
Caveman commit auto.
2026-06-26 21:08:47 +02:00
3cfea2e3b3 Remove completed Task 19 from tasks.md 2026-06-26 21:06:13 +02:00
d940460355 Cleanup obsolete tasks, simplify auth lock logic, increase test retry wait
- Remove completed Task 17 and 18 from Docs/tasks.md
- Remove ANIWORLD_TESTING bypass in _check_locked (testing env now uses test auth service)
- Remove redundant lock expiry cleanup (handled by _get_fail_record)
- Fix login.html whitespace formatting
- Increase setup retry wait from 6s to 45s for rate-limited CI environments
2026-06-26 21:04:45 +02:00
313bd1ebf3 auth: fix rate limit bypass, improve error messages
- Disable rate limiting when rate_limit_per_minute = 0
- Add IP lockout mention to 429 response
- Lowercase error messages for consistency
- Raise test mode rate limit 100 -> 10 (more realistic)
2026-06-26 20:46:10 +02:00
46d34efecb fix login error display: use dedicated error div with show/hide
- Rename message-container to login-error for clarity
- Add CSS to hide error by default, show when populated
- Update showMessage() to control visibility
- Update clear on input to hide error div
- Remove completed Task 16 from tasks.md
2026-06-26 20:01:14 +02:00
df0d54cc34 fix(login): align HTML element IDs with Robot Framework selectors
- Rename password input id from 'password' to 'password-input'
- Rename login button id from 'login-button' to 'login-submit-btn'
- Update JS references to match new IDs
- Remove completed Task 15 from Docs/tasks.md
2026-06-26 19:54:10 +02:00
1f3eddf554 fix tests: update JSON paths and add WebSocket keyword library
- download.robot: fix total to total_items in queue statistics test
- websocket.robot: add websocket_keywords.py library and use proper keyword
- tasks.md: remove completed task entries
2026-06-26 19:52:19 +02:00
d00e80e240 simplify queue ops, handle broken pipe errors
- Add OSError errno 32 (broken pipe) handling in progress broadcast
- Remove progress service calls from add/clear queue operations
- Add pending_by_episode cleanup on clear
- Update tests accordingly
2026-06-26 19:50:13 +02:00
5028d4ea27 feat(api): add /resume endpoint for queue processing
Add POST /api/queue/resume endpoint. Alias for start_queue that provides
semantic clarity for resume action after pause/stop.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-26 18:40:48 +02:00
e7d5df3a90 fix: parse string-encoded JSON in episode fields + return success for empty queue
- Add field validators to parse season/episode/title from JSON strings
- Add episodes list parser handling string-encoded dicts
- Return 200 with message instead of 400 when queue empty
- Remove completed tasks 8+9 from Docs
2026-06-26 18:39:02 +02:00
881da35dfd fix test: use pending_count instead of pending in queue status assertions
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-26 18:02:12 +02:00
8fb24ff46a docs: remove completed Task 6 from tasks.md
Task 6 (Config Backup Restore API Test fix) already resolved. Clean up
dead task entry.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-26 18:00:34 +02:00
ae368a0d8e fix: correct JSON key from backup_name to name in config backup tests
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-26 17:45:55 +02:00
c6f01ca985 wrap backups list in dict response
Task 4 done: config backup list endpoint now returns {backups: [...]} structure. API contract changed to match expected format.
2026-06-26 17:24:20 +02:00
ea59db302d fix: return 201 on backup create
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-26 17:13:57 +02:00
6e9c2b853a fix config model: remove ge=0 constraints from LoggingConfig
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-26 17:12:37 +02:00
42f4f0f5d7 Add name and data_dir to ConfigUpdate model
Allow updating app name and data directory via config endpoint.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-26 16:59:28 +02:00
a6e103889f fix: resolve race conditions in auth and episode retrieval
- models.py: episodeDict getter now catches DetachedInstanceError when episodes accessed on newly created/synced series
- anime.py: added error logging for failed series detail retrieval
- fastapi_app.py: raise auth rate limit to 100 in test mode (ANIWORLD_TESTING=1) to avoid 429 during rapid test execution
- auth_service.py: skip locked account check in test mode
- robot tests: suite setup now configures auth once, tests verify 'already configured' behavior to avoid re-setup conflicts

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-26 16:57:22 +02:00
b5e2ba4ac4 fix(robot): resolve suite setup hangs and RF7 syntax errors
- Replace blocking Run Process with Start Process for uvicorn server
- Fix RF7 IF condition syntax: ${status_code} → $status_code
- Add bash wrapper script for reliable conda env server startup
- Add Create Anonymous Session to suite setup for health check polling
- Simplify .gitignore for robot results
2026-06-23 20:25:47 +02:00
572aa0fc78 fix(logging): replace structlog with stdlib logging to prevent broken pipe crashes
structlog fails with BrokenPipeError when stdout is redirected (e.g., background
processes, Docker logs). Replace all structlog.get_logger() calls with
logging.getLogger() and convert keyword-style log calls to %-format strings.

Also removes stale Docs/tasks.md (2028 lines) and updates Robot Framework
tests to match current API behavior.
2026-06-21 20:14:31 +02:00
be3e180137 backup 2026-06-21 12:32:44 +02:00
107158eb04 Update tasks documentation 2026-06-21 12:28:35 +02:00
ad1aace0f5 docs: add infrastructure task tracking and Robot Framework deps
- Rename Docs/Tasks.md → Docs/tasks.md (case fix)
- Add comprehensive task docs for INFRA-1 (server startup) and INFRA-2 (teardown)
- Add test-robot make target
- Add robotframework, browser, requests, jsonlibrary to requirements.txt

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-21 12:26:18 +02:00
eabce18e41 feat(anime): rename NFO Diagnostics to Anime Settings + add edit endpoints
Replaces the read-only 'NFO Diagnostics' page with a full per-anime
Settings page reached from the right-click context menu on series cards.
Users can now view and edit key, name, folder, tmdb_id, tvdb_id and site
for each anime; changes are persisted to the DB and optionally written
back to the NFO file or used to regenerate it.

Backend
- Rename NfoDiagnosticsResponse -> NfoSettingsResponse,
  NfoSeriesDiagnostics -> NfoSeriesSettings
- Rename get_nfo_diagnostics -> get_nfo_settings,
  repair_nfo -> repair_nfo_settings
- Fix nfo.py bug: repair was calling non-existent
  update_series_nfo_status(); now uses update_nfo_status() and an
  explicit AnimeSeriesService.update(nfo_path=...)
- New endpoints on /api/anime/{key}:
    GET  /settings            -> AnimeSettingsResponse
    PUT  /settings            -> AnimeSettingsResponse
                                 (body: name/folder/tmdb_id/tvdb_id/site,
                                  options: apply_to_nfo, rename_disk)
    POST /regenerate-nfo      -> AnimeSettingsRegenerateNfoResponse
- New Pydantic models: AnimeSettingsResponse,
  AnimeSettingsUpdateRequest, AnimeSettingsRegenerateNfoResponse
- /anime/settings page route; /settings/nfo now 301-redirects to it

Frontend
- New AniWorld.AnimeSettingsManager JS module (single-page form,
  no tabs) with public API init/loadSeries/saveSettings/regenerateNfo/
  validateField/populateForm/showSaveSuccess/showError
- New anime-settings.html template + anime-settings.css
- Right-click menu: data-action 'nfo-diagnostics' replaced by
  'anime-settings' (label 'Anime Settings'), navigates to
  /anime/settings?key=...
- Library 'Open NFO Diagnostics' link renamed to 'Open Anime Settings'

Bug fix
- context-menu click handler was calling hide() BEFORE building the
  navigation URL, which cleared currentSeriesKey to null and produced
  /anime/settings?key=null. Captures the key into a local const first.
  Regression-locked by tests/frontend/unit/context_menu.test.js.

Tests
- 21 new pytest tests in tests/api/test_anime_settings_endpoints.py
  (GET/PUT/regenerate-nfo, auth, validation, nfo-repair bug regression)
- tests/api/test_nfo_endpoints.py trimmed to 6 focused tests
- 31 new Vitest unit tests for AnimeSettingsManager
- 5 new Vitest unit tests for ContextMenu (incl. source-invariant
  regression guard for the hide()-before-key bug)
- 5 new Playwright E2E tests covering right-click, direct nav,
  legacy /settings/nfo redirect, and context-menu labels
- New vitest.config.js (environment: happy-dom)

Docs
- Docs/API.md: new section 'Anime Settings Endpoints'
- Docs/CHANGELOG.md: documents the rename and the context-menu bug fix

Verified
- pytest: 27/27 (21 new + 6 trimmed nfo)
- vitest: 36/36 (31 anime-settings + 5 context-menu)
- playwright e2e: 5/5
2026-06-21 07:52:22 +02:00
e050f6fa2d refactor: remove Edit Metadata right-click option
Removes the Edit Metadata feature from the anime context menu and
deletes all related backend/frontend code:

Backend:
- DELETE PUT /api/anime/{anime_key} endpoint
- DELETE AnimeMetadataUpdate model and KEY_PATTERN regex
- DELETE MAX_INPUT_LENGTH constant

Frontend:
- DELETE edit-modal.js component
- DELETE edit metadata modal HTML from index.html
- DELETE edit-modal.js script tag
- DELETE edit-only CSS (.edit-modal-content, .edit-section,
  .field-error, .input-error, .key-warning, .info-grid,
  .info-item, .input-with-action, .btn-fetch-tmdb, .tmdb-*)
- REMOVE 'Edit Metadata' item from context menu (NFO Diagnostics
  preserved)

Tests:
- DELETE test_anime_edit_endpoints.py
- DELETE test_edit_modal.py
- DELETE test_anime_key_rename.py
2026-06-20 21:02:22 +02:00
a8e54876e3 refactor: overhaul NFO settings UI and backend
- Rework nfo-settings page with improved styling and layout
- Update edit-modal and context-menu with enhanced functionality
- Refactor NFO API endpoints and models
- Remove deprecated test_nfo_diagnostics_repair.py
- Clean up tasks.md documentation
2026-06-20 20:32:46 +02:00
7a1b2e565e feat: add ImageLoadingService for downloading series artwork
- Create ImageLoadingService that downloads poster.jpg, fanart.jpg, and
  logo.png from TMDB when anime is added or during scheduler rescan
- Integrate into BackgroundLoaderService._load_nfo_and_images() to trigger
  image downloads when new anime is added
- Add image_scan_after_rescan config option to scheduler (default: true)
- Add _run_image_scan() to scheduler rescan flow, processing series in
  batches of 10 to respect TMDB rate limits
- Fix SearchResult model missing folder, snippet, and score fields
- Update background_loader tests to match new image loading behavior
2026-06-14 20:51:57 +02:00
6dc3cda810 feat(nfo): implement NFO diagnostics and repair
- Add NFO API endpoints: diagnostics, repair, validate, needs-repair
- Create /settings/nfo page with full NFO management UI
- Add NFO status section to edit modal with repair functionality
- Add anime details API for edit modal pre-fill data
- Fix auth test fixtures in test_nfo_diagnostics_repair.py

Implements NFO diagnostics when editing anime series via right-click menu.
Users can now check NFO status, see missing tags, and repair NFOs
directly from the edit modal or the dedicated NFO settings page.
2026-06-14 19:34:58 +02:00
75084b3941 fix: use ffmpeg downloader for proper progress reporting
VOE provider returns HLS streams (.m3u8) which previously used yt-dlp's
native HLS downloader. That downloader does not report downloaded_bytes/
total_bytes in progress hooks, causing only 0% and 100% to ever show on
the queue page. Explicitly set 'downloader: ffmpeg' so progress updates
with actual byte counts are broadcast, enabling intermediate percentages
(25%, 50%, 75%, etc.) to display correctly.
2026-06-11 20:51:37 +02:00
de330dc146 chore: bump version 2026-06-11 08:45:36 +02:00
4731fd644a fix(tests): resolve 13 failing unit tests
- Use dynamic APP_VERSION instead of hardcoded v1.3.6 in:
  test_template_helpers, test_health, test_page_controller
- Add unresolved_folders to EXPECTED_TABLES in database/init.py
- Fix shallow copy bug in test_serie_scanner.py episodeDict comparison
- Update test_schema_constants to expect 6 tables instead of 5
2026-06-11 08:36:41 +02:00
9d52ff0c45 fix: use async context manager for TMDBClient to prevent resource leak
The TMDBClient was being instantiated but never closed, causing 'Unclosed
client session' errors in the logs. Fixed by using 'async with' context
manager which properly calls close() on exit.

Changes:
- _lookup_tmdb_id_by_name: wrapped client in async with
- _fetch_tmdb_data: wrapped client in async with
2026-06-11 08:03:03 +02:00
ee5d719f37 fix(scheduler): add to_dict to AnimeSeries for auto-download
AnimeSeries objects returned by SerieList.GetMissingEpisode() lacked
to_dict(), causing AttributeError when _run_auto_download() called
series.get("episodeDict").
2026-06-11 08:02:27 +02:00
cbc44491e7 chore: bump version 2026-06-10 20:14:41 +02:00
e319cfecb8 fix: add episodeDict setter to AnimeSeries model
SerieScanner attempted to assign serie.episodeDict = missing_episodes
but the property had no setter, causing AttributeError during scan.

Added setter that stores value in _episode_dict_cache, which the getter
already checks. This allows SerieScanner to update episodeDict directly.
2026-06-10 20:14:15 +02:00
4f61ded92a chore: bump version 2026-06-10 19:17:39 +02:00
d6082b5cf6 fix: ensure series loaded from DB before NFO scan
- Call _load_series_into_memory() before NFO scan phases to sync DB
  to SeriesApp memory, fixing missing NFO for recently resolved folders
- Add TMDB lookup for series without cached tmdb_id during NFO creation
- Add get_tmdb_client() factory and get_tmdb_image_base_url() helpers
- Fix: use get_tv_show_details instead of deprecated get_series_details
- Fix tests: mock _load_series_into_memory in NFO scan tests
2026-06-10 18:49:53 +02:00
e76cd3a708 test: remove sync_legacy_series_to_db tests
- Removed TestSyncSeriesFromDataFiles class from test_anime_service.py
- Updated TestSyncAnimeFolders tests to expect sync_count=0
- Removed TestSyncSeriesToDatabase class from test_data_file_db_sync.py
2026-06-10 18:26:09 +02:00
08f7f7453c refactor: remove legacy data file sync functionality
Series now loaded directly from database. Removed:
- sync_legacy_series_to_db() from anime_service.py
- Corresponding sync call after directory update in config.py
- Safety nets in initialization_service.py for missing progress IDs
2026-06-10 18:23:01 +02:00
023ddd182f fix(initialization): remove duplicate nfo_scan progress completion
The nfo_scan_completed event handler was calling complete_progress()
which removed the progress before _execute_nfo_scan returned. This caused
perform_nfo_scan_phase to fail with 'Progress with id nfo_scan not found'
when it tried to complete the same progress.

Completion is now only handled by perform_nfo_scan_phase after
_execute_nfo_scan returns, as intended.
2026-06-10 18:20:04 +02:00
288b03cbb4 chore: bump version 2026-06-09 20:50:06 +02:00
f73cc530c3 fix(ui): improve suggestion handling in unresolved series template
- Update Font Awesome from 6.0.0 to 6.6.0
- Replace suggestion links with buttons for better click handling
- Add debug logging for troubleshooting suggestion clicks
- Use 'link' field as primary provider key source
2026-06-09 19:20:27 +02:00
4b835a2439 fix(scheduler): skip rescan during initial setup when anime directory not configured
Prevent scheduler from triggering immediate rescan when:
- No previous scan recorded AND initial setup not yet completed
- Anime directory doesn't exist during initial sync

The setup flow will trigger rescan when ready. Also adds graceful
handling when anime directory is missing during data file sync.

Fixes: 503 error on setup when scheduler triggers rescan before
anime directory is configured
2026-06-09 18:39:36 +02:00
7c1dccfe64 perf(web): use content hash for static asset cache busting
Switch from timestamp-based to MD5 content hash versioning.
Cache now only invalidates when file content actually changes.
2026-06-09 18:26:51 +02:00
e0be00dce6 refactor: move import to module level and extract event handler
- Move ProgressType import to top-level in auth.py
- Extract suggestion link click handler into attachSuggestionLinkEvents() function
- Reuse handler after search results load
2026-06-07 21:51:49 +02:00
14f7b2f28a fix: use stepId instead of type to check series_sync completion
The type field is 'system_progress' for SYSTEM progress events,
not 'series_sync'. Use stepId to correctly identify when
series_sync has completed.
2026-06-07 20:38:47 +02:00
de250bdd37 fix(middleware): prevent premature redirect to /login during loading
Users were incorrectly redirected to /login during the initial loading phase
before the loading was actually complete. Added loading_started and
loading_complete flags to properly track the initialization state so
the setup redirect middleware knows when it's safe to redirect.
2026-06-07 20:23:11 +02:00
b800158648 refactor(docs): restructure navigation as state machine
Replaced linear flow diagram with explicit state definitions and
transition table. Removes MIGRATION_GUIDE.md (merged into main docs).
2026-06-07 20:02:51 +02:00
4e0c66ea9e chore: bump version 2026-06-07 17:43:01 +02:00
07c311c1cd feat(setup): separate NFO scan into dedicated phase
- Add /nfo-scan-phase endpoint to trigger NFO scan independently
- Move NFO scan out of initial setup into separate post-unresolved phase
- Add phase query param handling for /loading page (?phase=initial, ?phase=nfo)
- Update setup redirect middleware to handle phase-based redirects
- Update auth setup to pass phase=initial to loading page
2026-06-07 17:37:32 +02:00
cf00c9f7c5 fix: keep search controls visible and enable suggestion click-to-resolve
- Search input and button now stay visible after Search Again for unlimited searches
- Clicking a suggestion populates provider key and triggers resolve, card disappears
- Added data-provider-key attribute to suggestion links for click handling
2026-06-07 16:18:50 +02:00
f3042206a8 chore: bump version 2026-06-07 16:01:01 +02:00
657e7f9bf5 fix: use correct get_anime_service in NFO scan
_execute_nfo_scan() was importing get_anime_service from anime_service.py
which is a factory requiring series_app argument. Changed to import from
dependencies.py which handles series_app internally and provides proper
dependency injection with caching.
2026-06-06 23:57:12 +02:00
fd3ec5df83 chore: bump version 2026-06-06 23:48:09 +02:00
275aeb4544 feat(setup): add done button and integrate NFO scan into initialization
- Add /api/setup/unresolved/done endpoint to mark phase complete
- NFO scan now runs after series sync during initialization
- Middleware redirects to /login after setup complete (was /loading)
- Done button allows skipping folder resolution with redirect to NFO scan phase
2026-06-06 23:47:48 +02:00
be7b210959 feat: add custom query support for unresolved folder re-search
- Add SearchFolderRequest model for optional custom search query
- Update search endpoint to use custom query if provided
- Add search-again input field in UI for custom queries
- Increment search_attempts counter on re-search
2026-06-06 23:31:25 +02:00
486c5440f2 docs: add comprehensive documentation files
Added documentation for API, architecture, configuration, database,
development guide, testing, and navigation. Includes helper scripts,
diagrams, and guides for NFO files and migration.
2026-06-06 23:15:46 +02:00
4076b9dd43 docs: add API key for documentation
Added key file to Docs directory for documentation purposes.
2026-06-06 23:15:20 +02:00
df93e8a81f backuo 2026-06-06 23:12:39 +02:00
576d9f7a7b chore: bump version 2026-06-06 23:09:47 +02:00
af93daeddc fix: allow unresolved page access during setup flow
- Remove premature auth redirect in unresolved.html fetchUnresolved()
- Add /api/setup/ to middleware exempt paths
- Unresolved page now loads without auth token (part of setup flow)
- Only redirect to login on 401 (expired token) or when all folders resolved
2026-06-06 23:08:54 +02:00
a05795bb35 chore: bump version 2026-06-06 22:47:56 +02:00
d22df947e4 feat(setup): redirect to /loading instead of / after setup flow
- loading.html: check for unresolved folders before redirecting, go to /login if none
- unresolved.html: redirect to /loading instead of / after skip/timeout
- add docs/NAVIGATION.md navigation flow documentation
2026-06-06 22:46:02 +02:00
8bb8c6aa64 chore: bump version 2026-06-06 21:53:57 +02:00
109d3c8ac9 fix: streamline initialization flow after setup
- Remove nfo_scan and media_scan from loading page steps (no longer shown in UI)
- Remove perform_nfo_scan_if_needed calls from fastapi_app and auth.py
- Always redirect to /setup/unresolved after initialization completes
  instead of conditionally checking for unresolved folders
- Fix middleware to allow access to /loading page - let it handle
  its own redirect flow via WebSocket events

This ensures users always reach the unresolved folders page after
initial setup to manually configure any unmatched anime series.
2026-06-06 21:33:41 +02:00
6a934db8ac chore: bump version 2026-06-06 20:38:21 +02:00
ac7302b1dd fix: add /setup/unresolved to exempt paths and improve error handling
- Add /setup/unresolved to EXEMPT_PATHS to allow access after initial setup
- Handle 401 Unauthorized response in loading page (clear invalid token)
- Add console.log statements for debugging setup flow issues
2026-06-06 20:37:11 +02:00
ac5ee3bb27 chore: bump version 2026-06-06 20:08:05 +02:00
a9084202e3 fixed missing import 2026-06-06 20:07:45 +02:00
be9f2a4c0c chore: bump version 2026-06-06 19:40:21 +02:00
53fe09351f fix: prevent duplicate series when same anime key exists in different folder
- Add check for existing series by key in SetupService.run to skip duplicates
- Fix Path construction in initialization_service.py cleanup function
- Update unit tests to mock get_by_key and get_series_app
2026-06-06 19:39:32 +02:00
dc7d9ee5f7 chore: bump version 2026-06-05 22:34:09 +02:00
da3cae2812 fix: redirect to unresolved page after setup if needed
After initial setup completes, the loading page now checks for unresolved
folders before showing completion. If any unresolved exist, redirects
to /setup/unresolved so users can manually resolve provider keys.

Without this fix, users with unresolved folders only saw the loading
screen with no way to access the unresolved page.
2026-06-05 22:33:40 +02:00
2876cef24b chore: bump version 2026-06-05 22:10:56 +02:00
6a402623c4 feat(setup): add unresolved folders GUI for manual series resolution
- Add /setup/unresolved page for manual provider key resolution
- Integrate unresolved check into setup wizard flow
- Auto-redirect to unresolved page if folders need resolution

After initial setup scan, folders that couldn't be auto-resolved
are now tracked and can be resolved manually via the GUI.

Endpoints:
- GET /api/setup/unresolved - list unresolved folders
- POST /api/setup/unresolved/{folder}/resolve - resolve with provider key
- POST /api/setup/unresolved/{folder}/search - re-search for suggestions
- DELETE /api/setup/unresolved/{folder} - delete without adding
2026-06-05 22:06:55 +02:00
ebfbec1225 fix: resolve series key from direct link format
When the search provider returns a link like 'shinobi-no-ittoki' instead of
'/anime/stream/shinobi-no-ittoki', the key was not being extracted and all
folders were marked as unresolved.

Now handles both link formats:
- URL format: '/anime/stream/key' -> extract key
- Direct format: 'key' -> use as-is

Also added debug logging for both resolution paths to aid troubleshooting.
2026-06-05 21:21:39 +02:00
01e4dec8d7 chore: bump version 2026-06-05 21:08:23 +02:00
ecef21eec4 feat(setup): track unresolved folders for manual key resolution
When SetupService cannot auto-resolve a provider key for an anime folder,
the folder is now tracked in the new 'unresolved_folders' table instead of
being silently skipped. Users can then resolve these via the new API:

- GET /api/setup/unresolved - list unresolved folders with search suggestions
- POST /api/setup/unresolved/{folder}/resolve - provide key to resolve folder

The SetupService.run() now:
- Tracks unresolved folders instead of skipping them
- Re-creates AnimeSeries for previously unresolved folders that are now resolved
- Includes unresolved count in logs

New files:
- src/server/api/setup_endpoints.py - API endpoints for unresolved management
- tests/unit/test_unresolved_folder_service.py - service and model tests

Modified:
- src/server/database/models.py - add UnresolvedFolder model
- src/server/database/service.py - add UnresolvedFolderService
- src/server/services/setup_service.py - track unresolved folders
- src/server/fastapi_app.py - include setup router
2026-06-05 21:07:52 +02:00
d9738ffb78 docs: add fuzzy series key resolution to features.md
- Add Folder Management section with fuzzy title matching feature
- Tolerates title variations like (TV), (OVA), (Movie) suffixes during library setup
2026-06-05 20:49:27 +02:00
6aec2a1733 docs: add SetupService to architecture, update changelog and testing docs
- ARCHITECTURE.md: add setup_service.py to services list
- CHANGELOG.md: add Unreleased section with folder scan key resolution fix
- TESTING.md: add SetupService testing section with example tests
2026-06-05 20:42:26 +02:00
84487d7571 fix: use fuzzy title matching in _resolve_key_via_search
- Add _normalize_title() to strip anime suffixes (TV, OVA, Movie, etc.)
- Add _titles_match() using SequenceMatcher for similarity (threshold 0.85)
- Replace exact string match with fuzzy match to fix skipped folders
- Add debug logging for title mismatches and multiple results
- Set LOG_LEVEL=DEBUG in docker-compose.yml
2026-06-05 20:37:06 +02:00
e02d65778f chore: bump version 2026-06-05 20:25:20 +02:00
45d259bab2 fix(setup): resolve series key from search link field
- Fix _resolve_key_via_search to use 'title' instead of 'name'
- Extract key from 'link' field URL (e.g., /anime/stream/naruto -> naruto)
- Skip folders with unresolved keys instead of crashing with 'Series key cannot be empty'
- Update tests to use correct field names (title/link)
2026-06-05 20:24:24 +02:00
7b8de8d988 chore: bump version 2026-06-05 20:11:56 +02:00
18d10b44b5 feat(setup): detect filesystem properties during initial scan
SetupService.run() now checks each anime folder for tvshow.nfo,
logo.png, and poster/fanart images instead of using hardcoded
defaults. Provider key resolution via search is unchanged.
2026-06-05 20:05:04 +02:00
5c2be3f7c4 feat(setup): add SetupService for anime folder initialization
Extract SetupService class from initialization_service to handle:
- Scan data/ folder subdirectories
- Extract title and year from folder names (YYYY pattern)
- Create AnimeSeries records in database
- Resolve provider keys via search (single exact match)

Updates _scan_folders_to_database() to delegate to SetupService.run().
Adds comprehensive unit tests for SetupService.
2026-06-05 19:54:45 +02:00
2c47713339 feat(anime): add year to folder names on series add
- Add _compute_folder_name helper that deduplicates year (handles cases like 'Name (2023)' not becoming 'Name (2023) (2023)')
- Create anime folder on disk when adding series (not just DB + memory)
- Add rename_folder_if_needed to auto-rename existing folders without year
- Fetch year from aniworld_provider and include in folder as 'Name (YYYY)'

Closes: anime folders now include release year when available from provider
2026-06-05 19:17:44 +02:00
e74b04c1ee feat: add NFO scan after rescan and year caching
- Add nfo_scan_after_rescan config option (default: true)
- Implement year caching in AniworldLoader and EnhancedAniWorldLoader
- Make get_year abstract method in base provider
- Run NFO validation/creation after scheduled rescan completes
- Add _YearDict cache to avoid re-extracting year from HTML
2026-06-05 18:15:41 +02:00
8b21f1243f backzup 2026-06-05 17:18:00 +02:00
3d33626546 Remove duplicate folder scanning feature
Delete folder_rename_service.py. Stub out get_duplicate_folders API to return
empty response. Update folder_scan_service and tests to skip rename step.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-05 16:33:52 +02:00
7d9f80a0c6 refactor: remove temp/legacy code artifacts
- Delete src/server/core_utils_temp/: nfo_generator.py, nfo_mapper.py
- Delete src/server/services_nfo_temp/: tmdb_client.py
- Remove legacy_file_migration.py from services/
- Remove test_legacy_migration.py integration test

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-05 16:25:06 +02:00
25dc66fec3 Make retry handlers static methods
Convert handle_network_failure and handle_download_failure from instance methods to static methods. Hardcode retry params (max_retries, delays) instead of using instance state. Improves testability and removes implicit dependencies.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-04 22:29:59 +02:00
2be7b692b9 Fix get_all_series_from_data_files to read data files directly
Previously, the method created a SerieList instance which only loads
from database, not from data files. Now reads data files directly
and parses JSON to create AnimeSeries objects.

Also added _load_data_file helper function and fixed logger.warning
calls to use proper format strings instead of keyword arguments.

Updated unit tests to use real temp directories instead of mocks.
2026-06-04 22:04:46 +02:00
2b5c969a83 feat: scan anime folders to populate AnimeSeries DB
- Add _scan_folders_to_database() - iterates anime_directory subdirs
- Extract title/year from folder names via (YYYY) pattern
- Resolve provider key via search when single match found
- Create AnimeSeries records for new folders only
- Add corresponding unit tests

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-04 21:34:10 +02:00
830f6b4c93 refactor: consolidate nfo modules into src/server/nfo/
Move nfo_models, tmdb_client, nfo_generator, nfo_mapper from
scattered temp directories into single src/server/nfo/ package.
Update all imports to reflect new structure.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-04 21:17:20 +02:00
5526ab884a refactor: restructure core→server, split large entity files into database module
- Move src/core/ → src/server/
- Split SerieList.py (531 lines) and series.py (414 lines) into src/server/database/
- Add database/models.py for SQLAlchemy models
- Update all test imports to reflect new structure
- Remove deprecated test files (test_serie_class.py, test_serie_folder_with_year.py)
2026-06-04 21:11:53 +02:00
09d454d4c0 Refactor: move RescanService logic inline into SchedulerService
RescanService was thin wrapper. Its logic (rescan, auto-download, folder
scan, WebSocket broadcasts) moved into SchedulerService as private methods.
RescanService and its module deleted.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-04 19:32:53 +02:00
13504c3172 Remove backward compat alias for RescanOrchestrator
RescanOrchestrator relocated to src.server.services.rescan_service.
Backward compat layer no longer needed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-04 19:18:31 +02:00
82493d41ea Remove TestPerformNfoRepairScan tests
NFO service removed - these tests no longer needed.
2026-06-04 19:14:27 +02:00
274f773988 Remove dead NFO CLI code
NFO service removed. CLI code no longer functional - strip deprecated commands.
2026-06-04 19:02:08 +02:00
21af502184 refactor: simplify NFO handling, remove legacy services
- Drop nfo_factory, nfo_repair_service, nfo_service, series_manager_service
- Delete key_resolution_service, consolidate into folder_rename_service
- Remove bulk of NFO-related tests (coverage via integration tests)
- Streamline SeriesApp, background_loader, initialization services
- Add folder_rename_service to scheduler

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-04 18:54:31 +02:00
97caaf0d18 Remove key_resolution and folder_rename services
Both services deleted. Orchestrator and rescan service no longer call them.
Folder scan step numbering adjusted (1.5 → 1.4). Tests updated.
2026-06-04 18:22:12 +02:00
dc5d6506bc backup 2026-06-03 21:53:51 +02:00
dbaf80e941 chore: bump version 2026-06-03 21:49:02 +02:00
4fc597c5de fix: disable startup media scan
Skip incomplete series check on server startup to reduce
startup time. Can be re-enabled by restoring _check_media_scan_status().
2026-06-03 21:47:55 +02:00
a77bb371df chore: bump version 2026-06-03 21:41:30 +02:00
420d10bb34 Fix async/await bug in folder_rename_service
- _update_series_folder: make async, await AnimeSeriesService.update()
- test: fix mock method name get_by_key -> get_by_folder

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-03 21:19:03 +02:00
e29918488c fix: correct key_resolution_service import path in scheduler
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-03 21:14:12 +02:00
9c3f03d610 refactor(scheduler): separate scheduler logic from scan/rescan logic
- Extract rescan logic into new RescanService (src/server/services/rescan_service.py)
- SchedulerService now only handles APScheduler cron scheduling
- Move scheduler sub-services (folder_rename, folder_scan, key_resolution) to scheduler/ folder
- Keep RescanOrchestrator as backward-compatible alias
- Update all imports across api/, server/, and test files
2026-06-03 20:58:30 +02:00
9d64241230 fix(db): add missing legacy_key_cleanup_completed column migration
Add migrate_schema_if_needed() to handle adding missing columns to existing
tables for backward compatibility. Automatically adds legacy_key_cleanup_completed
BOOLEAN column to system_settings table if missing, preventing 'no such column'
errors on startup for existing databases.
2026-06-03 20:22:59 +02:00
49cd84f3e5 chore: bump version 2026-06-02 20:59:42 +02:00
e46759347e backup 2026-06-02 20:59:13 +02:00
75f743e6cc fix: fetch series name from provider when scanning
Avoid 'Series name cannot be empty' error when a Serie is loaded
from a serie_file with an empty name by fetching the title from the
provider after year-fetching in the scan() method.

fix #?
2026-06-02 20:57:27 +02:00
4dc5ffa19e copy version to docker file 2026-06-02 20:53:11 +02:00
1649a22418 chore: bump version 2026-06-02 20:39:18 +02:00
246752e2fc Add dynamic version from Docker/VERSION file
- Create version.py utility to read version from Docker/VERSION
- Replace hardcoded version '1.0.1' with APP_VERSION from version.py
- Add version logging on FastAPI startup
- Use APP_VERSION in health endpoints and template context
2026-06-02 20:38:42 +02:00
84b24ed79e chore: bump version 2026-06-02 20:33:01 +02:00
bf3954587a fix(folder_rename_service): use get_by_folder instead of get_by_key when looking up by folder name
Update_database_paths and duplicate folder cleanup were using get_by_key()
(provider key lookup) instead of get_by_folder() when operating on folder names.
This caused orphaned DB records when removing duplicate folders like 'Hells Paradise'
that mapped to an already-existing 'Hell\'s Paradise (2023)'.
2026-06-02 20:09:47 +02:00
ed8f5cae10 chore: bump version 2026-06-01 21:38:37 +02:00
a54c285994 fix(folder_scan): await NFO repair before folder rename
folder_rename_service depends on clean NFO files but repair tasks
were fire-and-forget. Now collect all repair tasks and await them
with asyncio.gather before validate_and_rename_series_folders runs.

Also update tests that mock asyncio.create_task to also mock
asyncio.gather since perform_nfo_repair_scan now awaits tasks.
2026-06-01 21:37:28 +02:00
c58b42dfa5 feat(services): add key resolution for orphaned anime folders
- Add key_resolution_service.py to resolve provider keys for folders without key/data files
- Search anime provider and match folder names (case-insensitive, exact match required)
- Only save to DB if exactly one match found; otherwise skip
- Add comprehensive unit tests (28 tests)
- Integrate into scheduler_service after nfo_repair scan
- Update ARCHITECTURE.md documentation
2026-06-01 20:43:13 +02:00
6dfb24de7e backup 2026-06-01 20:07:58 +02:00
6021cdef28 feat: add anime metadata editing and NFO diagnostics
- Add PUT /anime/{key} endpoint for updating anime key, tmdb_id, tvdb_id
- Add NFO diagnostics and repair endpoints (GET/POST /nfo/diagnostics)
- Add edit modal UI with context menu integration
- Add frontend JS modules for context-menu and edit-modal
- Add comprehensive tests for edit, rename, and NFO repair flows
2026-05-31 18:31:56 +02:00
5517ccbab0 style: reformat folder_rename_service import 2026-05-30 12:20:40 +02:00
94ed013172 Revert "feat: add manual TMDB/TVDB ID entry for failed lookups"
This reverts commit 30858f441c.
2026-05-30 12:17:48 +02:00
76b849fc91 chore: bump version 2026-05-30 12:02:48 +02:00
00b26c8cbc fix: validate generated keys before creating Serie objects
- Add is_valid_key check in SerieScanner._read_data_from_file() to prevent
  passing invalid keys to Serie constructor (caused ValueError)
- Improve error message for key generation failures
- Add warning log before removing duplicate source folders in rename service
2026-05-30 11:42:19 +02:00
a6f2399aca chore: bump version 2026-05-29 19:25:30 +02:00
cf001563b3 refactor: add folder rename configuration and service
Add configurable folder rename patterns via settings with anime_folder_rename_regex and custom_pattern options. Integrate into SerieScanner and SeriesApp for consistent episode organization.
2026-05-29 19:24:09 +02:00
38c12638a4 fix HLS stream warning by disabling native downloader and retrying with ffmpeg
- Set hls_prefer_native: False to skip yt-dlp's native HLS downloader which emits
  'Live HLS streams are not supported' warning
- Add retry logic that catches HLS-related exceptions and retries with
  downloader=ffmpeg and hls_use_mpegts=True
2026-05-29 18:53:47 +02:00
765e43c684 fix(key_utils): drop apostrophes in generate_key_from_folder 2026-05-29 18:20:20 +02:00
5190d32665 chore: bump version 2026-05-28 22:03:52 +02:00
4e6afa31b5 Remove legacy key file support after DB migration
- SerieScanner: Remove key file fallback, keep data file fallback
- SystemSettings: Add legacy_key_cleanup_completed flag
- initialization_service: Add cleanup task to remove key files from folders with DB entries
- Tests updated to reflect key file removal from legacy path

Key files caused duplicate key errors on folder rename. DB is now sole source of truth.
2026-05-28 22:01:37 +02:00
1ef59c5283 feat: add duplicate folder detection and /duplicate-folders API endpoint
- Add DuplicateFolderGroup and DuplicateFoldersResponse Pydantic models
- Add /duplicate-folders GET endpoint for listing pre-existing duplicates
- Add _scan_for_pre_existing_duplicates() function for NFO-based detection
- Add _try_merge_duplicate_group() for auto-merging empty/symlink-only duplicates
- Integrate duplicate detection into validate_and_rename_series_folders workflow
- Skip rename for flagged duplicates to prevent data loss during merge
2026-05-28 21:46:08 +02:00
239341629c Add orphaned folder cleanup after rename
- Add _cleanup_orphaned_folder() to delete/move old folder contents after rename
- Empty folders: delete directly via rmdir()
- Non-empty folders: move contents to new path, then delete old folder
- Handle PermissionError and OSError gracefully with logging
- Add dry_run parameter to preview changes without applying them
- Add --dry-run support to validate_and_rename_series_folders()
- Add unit tests for _cleanup_orphaned_folder and dry-run mode
- All 66 related tests pass

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 21:24:13 +02:00
51b7f349f8 fix(scheduler): strip null legacy alias fields from config.json on save
SchedulerConfig.__init__ maps legacy auto_download/folder_scan keys to the
primary auto_download_after_rescan/folder_scan_enabled fields. However,
model_dump() was including auto_download=null and folder_scan=null in
serialised output. When this was written to config.json and reloaded,
those keys were present (albeit null), so the alias mapping was skipped
and the primary fields retained default False values instead of the
configured True values.

Fix:
- Override SchedulerConfig.model_dump() to drop None-valued alias fields
  before returning the serialised dict.
- ConfigService.save_config() re-serialises the scheduler field through
  its overridden model_dump() so the fix applies when writing to disk.

Tests added:
- test_roundtrip_excludes_none_alias_fields: verifies model_dump omits
  null auto_download/folder_scan keys.
- test_save_and_load_scheduler_flags_roundtrip: end-to-end roundtrip
  through ConfigService confirms raw JSON and loaded values match.

Pre-existing failure in test_core_error_handler.py is unrelated.
2026-05-28 21:18:16 +02:00
14b8ef7f06 Add Step 4 fallback: generate key from folder name
- SerieScanner: generate key from folder when no key/data files exist
- Handle edge cases: non-Latin characters, special symbols in folder names
- anime_service: expose loading_status and loading_error fields
- Update tests to match new fallback behavior
2026-05-28 18:48:43 +02:00
7abba0dae2 Fix download provider errors with exponential backoff and playmogo support
- Add exponential backoff retry logic to RecoveryStrategies (1s, 2s, 4s...)
- Add TimeoutError to network failure handling for HTTPS timeouts
- Add playmogo.com referer header for Doodstream provider
- Add Optional import to error_handler.py
- Add sanitize_url_for_logging utility function

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 18:47:05 +02:00
30858f441c feat: add manual TMDB/TVDB ID entry for failed lookups
- Add PATCH /api/anime/{key}/metadata-ids endpoint to update IDs
- Add POST /api/anime/{key}/refresh-nfo endpoint to force NFO regeneration
- Add Edit Metadata IDs modal in frontend
- Add showEditMetadataModal, saveMetadataIds, refreshSeriesNfo JS functions
- Add edit-metadata-btn to series cards with database icon
- IDs validated as positive integers or null

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 18:38:34 +02:00
33f63ca304 feat(SerieScanner): add folder ignore patterns for non-anime content
- Add NFO_FOLDER_IGNORE_PATTERNS setting to skip TV shows like
  The Last of Us, Loki, Chernobyl, Star Trek Discovery
- Update SerieScanner.__find_mp4_files() to skip ignored folders
- Update SerieList.load_series() to skip ignored folders
- Add should_ignore_folder() method for pattern matching
- Add folder_ignore_patterns property for pattern parsing
- Add comprehensive tests for ignore pattern functionality
- Update NFO_GUIDE.md with ignore patterns documentation
- Update CONFIGURATION.md with new setting

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 18:11:45 +02:00
fe9284b80e feat(SerieScanner): add warning event for duplicate series keys
- Add on_warning event system with subscribe/unsubscribe methods
- Change duplicate key handling from error to warning
- Fire on_warning event when duplicate series detected
- Include metadata: key, duplicate_folder, existing_folder
2026-05-28 18:05:07 +02:00
12e5526991 chore: remove obsolete migration script
Migration logic moved to serie_scanner. No longer needed.
2026-05-28 17:55:00 +02:00
bc87bee416 refactor(scheduler): drop separate scheduler.db in favour of MemoryJobStore
Scheduler used a separate SQLite file (scheduler.db) only to persist one
cron job. This was originally required because APScheduler's
SQLAlchemyJobStore is sync-only, creating an async/sync driver conflict
when accessing the same file.

The job is rebuilt from config.json on every startup regardless
(replace_existing=True), so the persisted state only served misfire
detection. Moved misfire detection into the app layer by querying
system_settings.last_scan_timestamp on startup: if the last scan is
>23h but <25h ago, an immediate rescan is triggered.

Change summary:
- Remove SQLAlchemyJobStore; use default MemoryJobStore instead
- Add _check_missed_run() that reads last_scan_timestamp from aniworld.db
- Update docs/DEVELOPMENT.md scheduler troubleshooting section
- Update the scheduler unit test that verified SQLAlchemyJobStore
2026-05-27 22:09:18 +02:00
7ded5a6e4d chore: bump version 2026-05-27 21:41:45 +02:00
d596902ca3 Parse existing NFO for TMDB ID to skip redundant search
Check existing tvshow.nfo for TMDB ID before querying TMDB API.
If found, fetch details directly using cached ID instead of searching.
Reduces API calls and improves performance for already-indexed series.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 21:22:24 +02:00
d358a07290 fix async handling in SerieScanner and add image_downloader cleanup
- SerieScanner.scan() now detects running event loop and uses create_task()
  when already in async context, avoids RuntimeError
- NFOService.close() now also closes image_downloader to prevent resource leaks
- Add integration tests for TMDBClient lifecycle management

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 20:47:29 +02:00
b9c55f9e7a fix: remove double-call on AsyncSession in SerieScanner
get_async_session_factory() returns session directly, not factory.
Calling result again with () caused 'AsyncSession' object is not callable.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 20:19:34 +02:00
fc4e52f1a2 chore: bump version 2026-05-26 20:23:20 +02:00
6d30747f25 Fix stale data file updates on download completion
When episodes are downloaded successfully, the in-memory Serie.episodeDict
is updated, but the deprecated data file was not being synced. This caused
UI to show episodes as missing when already downloaded.

Changes:
- Update data file in _remove_episode_from_memory when download completes
- DB is authoritative; data file is optional backup (deprecated)
- Gracefully skip update if data file doesn't exist

New integration tests for episode download sync:
- Verify episode removed from missing list after download
- Verify in-memory cache updated after download
- Verify data file updated after download (when it exists)
- Verify downloads work without data file
2026-05-26 18:57:04 +02:00
ceb6a2aeb4 Rename sync_series_from_data_files to sync_legacy_series_to_db
- Rename function to reflect its legacy status
- Add deprecation warning log on execution
- Update all callers (initialization_service, api/config, fastapi_app)
- Update tests to use new name
- Add deprecation notice to DEVELOPMENT.md

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-26 18:45:22 +02:00
53d6da5dac Add database loading methods to SerieList
- Add load_all_from_db() for bulk loading series from DB
- Add _load_single_series_from_db() for loading single series by folder
- Add invalidate_cache() to clear in-memory cache
- Add tests for all new methods

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-26 18:26:25 +02:00
102d83e947 feat(scanner): replace file writes with DB persistence for series
- SerieScanner.scan() now calls _persist_serie_to_db() instead of serie.save_to_file()
- Added _sync_episodes_to_db() helper to handle episode CRUD during sync
- EpisodeService gains delete_by_series() for targeted episode deletion
- SerieList gains add_to_db() async method for DB-based series addition
- test_serie_scanner_db_writes.py covers create/update/preserve/sync scenarios
- DATABASE.md updated with Series Persistence Flow section

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-26 18:12:01 +02:00
841368bf85 feat(SerieScanner): DB lookup primary, deprecate key file fallback
DB now source of truth for folder -> Serie resolution.

Changes:
- AnimeSeriesService.get_by_folder(): new async lookup by folder name
- SerieScanner.__read_data_from_file(): query DB first, then provider callback, then legacy key file (temporary, removed v3.0.0)
- Serie: reconstruct from DB record with episode dict
- Key file: warn on use, scheduled removal v3.0.0

Add unit tests for DB hit/miss/callback/fallback edge cases

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-26 17:56:37 +02:00
cbd53ef2a0 feat: add legacy key/data file migration to database
- Add migration_legacy_files_completed flag to SystemSettings model
- Create legacy_file_migration service to migrate series from key/data files
- Integrate legacy migration into initialization_service startup flow
- Add integration tests for legacy file migration
- Update DATABASE.md documentation with migration details
- Fix various test and service issues (nfo_repair, tmdb_client, download_service)
- Add test_database_schema unit tests
2026-05-26 17:44:42 +02:00
50a77976d5 chore: bump version 2026-05-26 13:28:12 +02:00
dfc28b8e66 fix(scheduler): ensure scheduler starts after setup/config update
Add ensure_started() to SchedulerService as idempotent entry point.
Start scheduler in auth setup run_initialization() after NFO scan.
Sync anime_directory and start scheduler in config update endpoint.
Add unit and endpoint tests for ensure_started() behavior.
2026-05-26 13:23:48 +02:00
6c9605e896 chore: bump version 2026-05-26 08:58:37 +02:00
3947f6d266 refactor(scheduler): replace structlog with std logging and add extensive diagnostics
- Switch scheduler_service from structlog to standard logging for consistency
- Add detailed lifecycle logging in SchedulerService (start, stop, rescan)
- Add debug logging in fastapi_app scheduler initialization
- Fix test_add_series_episodes to mock EpisodeService.get_by_series
2026-05-26 07:51:22 +02:00
a3176f5ac1 chore: bump version 2026-05-25 21:31:46 +02:00
9a81b04b65 fix: downloaded episodes no longer appear as missing
Use the database as the authoritative source for missing-episode lists so
that episodes marked is_downloaded=True are never shown as missing, even
when the in-memory state is stale.

Key changes:
- EpisodeService.get_by_series() gains only_missing flag
- AnimeService uses DB-backed episodeDict and preserves downloaded episodes
  during sync, skipping them when adding/removing missing episodes
- DownloadService broadcasts series_updated after marking an episode downloaded
  so the frontend reflects the change immediately
- Frontend filters out series with zero missing episodes client-side and
  fixes renderSeries to respect the active filter
- Unit tests updated to assert the broadcast is sent
2026-05-25 21:30:31 +02:00
a336733ea9 fix(nfo): add year field to series and create missing NFO files
- Add missing year field when building series list in anime_service
- Add _create_missing_nfo to generate minimal NFO for series without one
- Update perform_nfo_repair_scan to detect and create missing NFOs
- Add semaphore-protected async creation with TMDB rate limiting

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-25 16:32:54 +02:00
ca93bb740a feat(providers): detect HTML encoding before parsing
Add chardet-based _decode_html_content() to aniworld_provider. Apply
to all BeautifulSoup parsing calls to prevent decoding warnings on
pages with mismatched encoding declarations. Falls back to utf-8
with errors='replace' when confidence < 0.7.

Also fix test_enhanced_provider HLS test signature and add HLS
pattern unit tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-25 16:30:36 +02:00
d5e955a731 Add year extraction from folder names for existing series
- New migration script: populate year from folder (YYYY) pattern
- SerieScanner: refactor year extraction logic
- anime_service: pass year when syncing from data files
2026-05-25 15:30:28 +02:00
e2a373816a feat(nfo): add minimal NFO fallback when TMDB fails
- Add create_minimal_nfo() method to NFOService for fallback when TMDB lookup fails
- Update API endpoints (single and batch) to use minimal NFO fallback on TMDBAPIError
- Document fallback behavior in NFO_GUIDE.md section 3.6
- Add unit tests for minimal NFO creation (11 tests passing)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-25 15:19:50 +02:00
a115215416 fix(providers): rotate, probe and fall back on 404
Iterate providers actually advertised on the episode page (ordered by
SUPPORTED_PROVIDERS preference) instead of always re-resolving VOE.
Each candidate is HEAD-probed before yt-dlp runs, so dead links are
skipped immediately; direct video URLs use a streaming fast path that
bypasses yt-dlp; total failure now logs the exhausted provider list.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-25 14:32:10 +02:00
c579235af0 feat(download): persist retry state and dead-letter
Retry count and queue status were in-memory only and lost on
restart, so failed downloads could not be safely resumed and
permanently-failed episodes silently blocked re-queueing via the
episode-id unique index.

- Add status + retry_count columns to DownloadQueueItem
- Replace unique(episode_id) with unique(episode_id, status) so
  permanently_failed rows do not block new pending entries
- Add PERMANENTLY_FAILED to DownloadStatus enum
- Persist retry_count on each failure; mark permanently_failed once
  max_retries reached
- QueueRepository reads status/retry_count from DB instead of
  defaulting to PENDING/0
- Stop double-incrementing retry_count in retry_failed_items;
  increment only happens in _process_download on failure

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-25 14:24:31 +02:00
0ba2587bc8 refactor(download): mark episode downloaded instead of deleting
Change _remove_episode_from_missing_list to set is_downloaded=True
and populate file_path via EpisodeService.mark_downloaded, instead of
deleting the Episode row. Preserves download history so queries can
distinguish series with downloaded episodes from completely unwatched
series.

- Pass serie_folder to construct file_path
- Look up series_id via AnimeSeriesService.get_by_key
- Update tests to mock mark_downloaded path
- Document episode lifecycle in docs/DEVELOPMENT.md

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-25 14:14:33 +02:00
bda1fe4445 Fix scheduler next_run_time None check; add debug logging
- Fix race condition: next_run_time only available after scheduler.start()
- Handle None gracefully in logging
- Add debug logging to _perform_rescan and _run_rescan_job
- Document scheduler troubleshooting in DEVELOPMENT.md
2026-05-25 13:48:34 +02:00
810346bc8b chore: bump version 2026-05-24 21:17:39 +02:00
daa937bcb7 Fix test isolation: clear logging handlers and reset propagate flags
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-23 22:44:40 +02:00
1c505bd722 Use ffmpeg for HLS streams in aniworld provider
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-23 22:26:48 +02:00
3551838887 Add startup health checks and /health/ready endpoint
- Add _run_startup_health_checks() function in fastapi_app.py
  - Check ffmpeg availability (warning)
  - Check DNS resolution for aniworld.to and api.themoviedb.org (warning)
  - Check anime_directory configuration and writability (error)
- Store startup checks in app.state for health endpoint access
- Add /health/ready endpoint for container orchestrators
  - Returns not_ready with 503 when critical failures present
  - Includes critical_failures list for debugging
- Update /health endpoint to include startup check results
  - Status reflects worst check (error > warning > ok)
- Document health check endpoints in DEVELOPMENT.md
- Add unit tests for startup health checks
- Add unit tests for /health/ready endpoint

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-23 22:12:03 +02:00
9a20541598 feat(NFO): add TMDB search fallback with alt_titles support
- New _search_with_fallback() method tries multiple strategies:
  1. Primary query with year filter (de-DE locale)
  2. Alternative titles with ja-JP / en-US locales
  3. English search (en-US)
  4. Search without year constraint
  5. Punctuation-normalized query
- create_nfo() accepts new alt_titles param for Japanese/title fallback
- Better match rate for anime with non-English titles

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-23 21:57:00 +02:00
3f7651404d fix(tmdb): harden aiohttp session lifecycle
- Add async context manager to NFOService wrapping TMDBClient + ImageDownloader
- Add TMDBClient.__del__ warning when session leaks
- Log exc_info on session recreation for traceback visibility
- Document async-with usage in docs/DEVELOPMENT.md and docs/TESTING.md
- Add unit tests covering leak detection, context-manager cleanup, and connector-closed warning

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-23 21:34:26 +02:00
bee24406e6 Add runner.csx script 2026-05-23 21:28:54 +02:00
31eb0026cf Add queue deduplication to prevent duplicate entries
- In-memory dedup in add_to_queue() using _pending_by_episode dict
- Batch-local dedup via seen_in_batch set (handles duplicates within single call)
- Database unique index on episode_id via __table_args__
- 5-minute cooldown in _auto_download_missing() to prevent rapid re-triggers
- Updated _add_to_pending_queue() and _remove_from_pending_queue() to track episode keys
- Added TestQueueDeduplication with 4 test cases
- Updated DEVELOPMENT.md and TESTING.md with queue dedup docs

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-23 21:27:41 +02:00
24ea12bbaf Add Docs/runner.csx 2026-05-23 21:19:15 +02:00
e74b602f60 Add ffmpeg for HLS stream download support
- Add ffmpeg to Dockerfile.app for container HLS support
- Configure yt-dlp with --downloader ffmpeg --hls-use-mpegts
- Add startup health check warns if ffmpeg missing
- Update DEVELOPMENT.md with ffmpeg prerequisites and troubleshooting
- Add tests for ffmpeg HLS options and health check
2026-05-23 21:18:39 +02:00
db65e28854 backup 2026-05-21 22:10:07 +02:00
11e231a4ab chore: bump version 2026-05-21 21:42:13 +02:00
a11f8c4fa0 fix(vpn): add explicit host route for health-check target
Without a /32 route in the main table, CHECK_HOST (1.1.1.1) fell through
to the VPN default route where source-address selection was defeated by
the priority-100 'from ETH0_IP' policy rule, causing pings to bypass
wg0 and be dropped by the kill switch.

Also add secondary google.com ping to distinguish IP vs DNS failures.
2026-05-21 21:41:51 +02:00
cf5a06af11 chore: bump version 2026-05-21 21:22:08 +02:00
e07f75432e backup 2026-05-21 21:21:13 +02:00
276 changed files with 28921 additions and 25033 deletions

View File

@@ -17,6 +17,9 @@ __pycache__/
# Docker files (not needed inside the image)
Docker/
# Exception: VERSION is needed by Dockerfile.app
!Docker/VERSION
# Test and dev files
tests/
Temp/

10
.gitignore vendored
View File

@@ -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/*

View File

@@ -2,12 +2,13 @@ FROM python:3.12-slim
WORKDIR /app
# Install system dependencies for compiled Python packages
# Install system dependencies for compiled Python packages and ffmpeg for HLS support
RUN apt-get update && \
apt-get install -y --no-install-recommends \
gcc \
g++ \
libffi-dev \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies (cached layer)
@@ -19,6 +20,7 @@ COPY src/ ./src/
COPY run_server.py .
COPY pyproject.toml .
COPY data/config.json ./data/config.json
COPY Docker/VERSION ./Docker/VERSION
# Create runtime directories
RUN mkdir -p /app/data/config_backups /app/logs

View File

@@ -1 +1 @@
v1.1.9
v1.5.2

View File

@@ -191,10 +191,21 @@ start_vpn() {
> /etc/resolv.conf
for dns in $(echo "$VPN_DNS" | tr ',' ' '); do
echo "nameserver $dns" >> /etc/resolv.conf
# Add explicit route to DNS server through wg0 so it's found in main table
# (suppress_prefixlength 0 ignores default routes but allows host routes)
ip -4 route add "$dns" dev "$INTERFACE" 2>/dev/null || true
done
echo "[vpn] DNS set to: ${VPN_DNS}"
fi
# Add explicit host route for the health-check target so it is picked up by
# the 'lookup main suppress_prefixlength 0' rule (same as DNS servers above).
# Without this, CHECK_HOST falls through to the VPN table default route whose
# source-address selection can be defeated by the priority-100 'from ETH0_IP'
# policy rule, causing pings to bypass wg0 and be dropped by the kill switch.
ip -4 route add "${CHECK_HOST}" dev "$INTERFACE" 2>/dev/null || true
echo "[vpn] Health-check route: ${CHECK_HOST}${INTERFACE}"
echo "[vpn] WireGuard interface ${INTERFACE} is up."
echo "[vpn] Main routes:"
ip route show | sed 's/^/[vpn] /'
@@ -246,9 +257,21 @@ health_loop() {
echo "[health] VPN recovered."
failures=0
fi
# Secondary DNS check
if ping -c 1 -W 5 "google.com" > /dev/null 2>&1; then
: # DNS OK — silent
else
echo "[health] WARN google.com unreachable — possible DNS issue"
fi
else
failures=$((failures + 1))
echo "[health] Check failed ($failures/$max_failures) — ping ${CHECK_HOST} failed"
# Secondary check: distinguish IP failure from DNS failure
if ping -c 1 -W 5 "google.com" > /dev/null 2>&1; then
echo "[health] INFO google.com reachable — DNS works, ${CHECK_HOST} may be filtered"
else
echo "[health] INFO google.com also unreachable — DNS or general routing failure"
fi
# Dump WireGuard stats to show if handshake is stale and how much data flows
echo "[health] wg stats:"
wg show "$INTERFACE" 2>/dev/null | grep -E 'latest handshake|transfer|endpoint' | sed 's/^/[health] /' || echo "[health] wg0 not found"
@@ -349,6 +372,8 @@ check_vpn_connectivity() {
echo "[check] FAIL DNS resolution failed"
echo "[check] resolv.conf: $(tr '\n' ' ' < /etc/resolv.conf)"
echo "[check] Check that DNS servers are reachable through wg0"
echo "[check] ── End of checks ──"
exit 1
fi
echo "[check] ── End of checks ──"

View File

@@ -15,6 +15,7 @@ services:
cap_add:
- NET_ADMIN
- SYS_MODULE
- NET_RAW
sysctls:
- net.ipv4.ip_forward=1
- net.ipv4.conf.all.src_valid_mark=1
@@ -22,7 +23,7 @@ services:
- /server/server_aniworld/wg0.conf:/etc/wireguard/wg0.conf:ro
- /lib/modules:/lib/modules:ro
ports:
- "2000:8000"
- "8000:8000"
environment:
- HEALTH_CHECK_INTERVAL=10
- HEALTH_CHECK_HOST=1.1.1.1
@@ -51,4 +52,5 @@ services:
volumes:
- /server/server_aniworld/data:/app/data
- /server/server_aniworld/logs:/app/logs
- /media/serien/Serien:/data
restart: unless-stopped

View File

@@ -38,6 +38,7 @@ services:
condition: service_healthy
environment:
- PYTHONUNBUFFERED=1
- LOG_LEVEL=DEBUG
volumes:
- app-data:/app/data
- app-logs:/app/logs

View File

@@ -1,7 +1,8 @@
[Interface]
PrivateKey = EPRa2f/v72LvIXAY4yqIRJifsSb+nCcYHqC2rwA94UI=
Address = 100.64.244.78/32
DNS = 198.18.0.1,198.18.0.2
#DNS = 198.18.0.1,198.18.0.2
DNS = 8.8.8.8
# Route zum VPN-Server direkt über dein lokales Netz
PostUp = ip route add 91.148.236.64 via 192.168.178.1 dev wlp4s0f0

View File

@@ -826,14 +826,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 +1612,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 (1500 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.

View File

@@ -80,8 +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
| +-- folder_scan_service.py # Daily folder maintenance scan
| +-- setup_service.py # Series key resolution from folder names
| +-- 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
@@ -165,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
@@ -194,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/`)
@@ -293,7 +325,7 @@ The FastAPI lifespan function (`src/server/fastapi_app.py`) runs the following s
9. Scheduler service started
+-- Cron-based library rescans configured
+-- Optional: auto-download missing episodes after rescan
+-- Optional: folder maintenance (NFO repair, renaming, poster checks) during scheduled runs
+-- Optional: folder maintenance (NFO repair, key resolution, renaming, poster checks) during scheduled runs
```
### 12.2 Temp Folder Guarantee
@@ -427,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
@@ -730,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
@@ -744,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)
---

View File

@@ -37,12 +37,105 @@ This changelog follows [Keep a Changelog](https://keepachangelog.com/) principle
---
## [Unreleased] - 2026-06-20
### Added
- **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
- **Folder scan series key resolution**: Fixed "Could not resolve series key for folder, skipping" warnings during library setup. `_resolve_key_via_search()` now uses fuzzy title matching instead of exact string comparison.
- Added `_normalize_title()` to strip anime suffixes: `(TV)`, `(Anime)`, `(OAD)`, `(OVA)`, `(Special)`, `(Movie)`, `(Spin-Off)`
- Added `_titles_match()` using `difflib.SequenceMatcher` with 0.85 similarity threshold for tolerance of minor title variations
- Added debug logging for title mismatches and multiple search results
---
## [1.3.1] - 2026-02-22
### Added
- **Temp file cleanup after every download** (`src/core/providers/aniworld_provider.py`,
`src/core/providers/enhanced_provider.py`): Module-level helper
- **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.
- **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
@@ -59,37 +152,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`.
---
@@ -130,6 +220,10 @@ This changelog follows [Keep a Changelog](https://keepachangelog.com/) principle
- Modified `src/server/api/anime.py` to save scanned episodes to database
- Episodes table properly tracks missing episodes with automatic cleanup
### Deprecated
- **Legacy Series Files (key/data)**: File-based series storage is deprecated. `key` and `data` files in anime folders will be removed in v3.0.0. Database storage is now the primary method. See [docs/MIGRATION_GUIDE.md](docs/MIGRATION_GUIDE.md) for details.
---
## Sections for Each Release

View File

@@ -128,7 +128,7 @@ Location: `data/config.json`
},
"backup": {
"enabled": false,
"path": "data/backups",
"path": "data/config_backups",
"keep_days": 30
},
"nfo": {

View File

@@ -83,17 +83,23 @@ Source: [src/server/database/models.py](../src/server/database/models.py), [src/
### 3.2 anime_series
Stores anime series metadata.
Stores anime series metadata. Corresponds to the core `Serie` class.
| Column | Type | Constraints | Description |
| ------------ | ------------- | -------------------------- | ------------------------------------------------------- |
| `id` | INTEGER | PRIMARY KEY, AUTOINCREMENT | Internal database ID |
| `key` | VARCHAR(255) | UNIQUE, NOT NULL, INDEX | **Primary identifier** - provider-assigned URL-safe key |
| `name` | VARCHAR(500) | NOT NULL, INDEX | Display name of the series |
| `site` | VARCHAR(500) | NOT NULL | Provider site URL |
| `folder` | VARCHAR(1000) | NOT NULL | Filesystem folder name (metadata only) |
| `created_at` | DATETIME | NOT NULL, DEFAULT NOW | Record creation timestamp |
| `updated_at` | DATETIME | NOT NULL, ON UPDATE NOW | Last update timestamp |
| Column | Type | Constraints | Description |
| ---------------- | ------------- | -------------------------- | ------------------------------------------------------- |
| `id` | INTEGER | PRIMARY KEY, AUTOINCREMENT | Internal database ID |
| `key` | VARCHAR(255) | UNIQUE, NOT NULL, INDEX | **Primary identifier** - provider-assigned URL-safe key |
| `name` | VARCHAR(500) | NOT NULL, INDEX | Display name of the series |
| `site` | VARCHAR(500) | NOT NULL | Provider site URL |
| `folder` | VARCHAR(1000) | NOT NULL | Filesystem folder name (metadata only) |
| `year` | INTEGER | NULLABLE | Release year of the series |
| `nfo_path` | VARCHAR(1000) | NULLABLE | Path to tvshow.nfo metadata file |
| `tmdb_id` | INTEGER | NULLABLE, INDEX | TMDB (The Movie Database) ID for metadata |
| `tvdb_id` | INTEGER | NULLABLE, INDEX | TVDB (TheTVDB) ID for metadata |
| `has_nfo` | BOOLEAN | NOT NULL, DEFAULT FALSE | Whether tvshow.nfo exists |
| `loading_status` | VARCHAR(50) | NOT NULL, DEFAULT 'completed' | Status: pending, loading_episodes, loading_nfo, completed, failed |
| `created_at` | DATETIME | NOT NULL, DEFAULT NOW | Record creation timestamp |
| `updated_at` | DATETIME | NOT NULL, ON UPDATE NOW | Last update timestamp |
**Identifier Convention:**
@@ -101,7 +107,13 @@ Stores anime series metadata.
- `folder` is **metadata only** for filesystem operations (e.g., `"Attack on Titan (2013)"`)
- `id` is used only for database relationships
Source: [src/server/database/models.py](../src/server/database/models.py#L23-L87)
**EpisodeDict Mapping:**
The `episodeDict` (season → episode numbers mapping) is stored as individual `Episode` records:
- Each `Episode` has `season` and `episode_number` columns
- Relationship: `AnimeSeries.episodes` returns all Episode records for that series
Source: [src/server/database/models.py](../src/server/database/models.py#L23-L150)
### 3.3 episodes
@@ -441,7 +453,187 @@ items = await db.execute(
---
## 12. Database Location
## 12. Series Storage: Database vs Files (Deprecated)
### File-Based Storage (Removed in v2.0)
Prior to v2.0, series metadata was stored in two files per anime folder:
| File | Contents |
| -------- | ------------------------------------------------------- |
| `key` | Series provider key (e.g., `"attack-on-titan"`) |
| `data` | JSON serialization of `Serie` object |
File structure example:
```
/anime/Attack on Titan (2013)/
├── key # Contains: attack-on-titan
├── data # Contains: {"key": "...", "name": "...", "episodeDict": {...}}
├── Season 1/
│ └── ...
```
### Database Storage (Current)
Since v2.0, all series metadata is stored in the `anime_series` table with `Episode` records for episode tracking. This provides:
- **ACID transactions** for data consistency
- **Foreign key constraints** (cascade delete)
- **Indexed queries** for fast lookups
- **No filesystem dependency** for metadata
### Migration from Files to Database
The `Serie.save_to_file()` and `Serie.load_from_file()` methods are deprecated but still functional for backward compatibility during migration:
```python
from src.core.entities.series import Serie
# Old file-based loading (deprecated)
serie = Serie.load_from_file("/anime/Attack on Titan (2013)/data")
# New database-based loading
from src.server.database.service import AnimeSeriesService
serie = await AnimeSeriesService.get_by_key(db, "attack-on-titan")
```
### Removing File Dependencies
After verifying database schema supports all fields, file-based storage can be removed:
1. ✅ Schema verified: All `Serie` fields have corresponding DB columns
2. ✅ Migration complete: All existing series migrated to database
3. ❌ File cleanup: Remove `key` and `data` files (pending)
**Note:** The `save_to_file()` and `load_from_file()` methods will be removed in v3.0.0.
---
## 12. Series Persistence Flow
When a directory scan discovers or updates series, the scanner persists data to the database instead of writing to disk files.
### Scan Flow
```
Scan Directory
Find MP4 Files → Extract Serie Key
Check DB for Existing Series (by key)
├─── EXISTS ──────────────────────► Update Series Metadata
│ │
│ ▼
│ Sync Episodes to DB
│ │
│◄──────────────────────────────────────┘
└─── NEW ───────────────────────────► Create New Series Record
Create Episode Records
Return to Scan Loop
```
### Key Methods
**SerieScanner._persist_serie_to_db()**
- Called after `get_missing_episodes_and_season()` computes episodeDict
- Uses `AnimeSeriesService.get_by_key()` to check if series exists
- If exists: calls `AnimeSeriesService.update()` + `_sync_episodes_to_db()`
- If new: calls `AnimeSeriesService.create()` + creates episodes
**SerieScanner._sync_episodes_to_db()**
- Gets existing episodes from DB via `EpisodeService.get_by_series()`
- Compares with new episodeDict
- Removes episodes no longer missing (unless `is_downloaded=True`)
- Adds new missing episodes
- Preserves `is_downloaded=True` episodes when removing missing ones
**SerieList.add_to_db()**
- Used when adding a new discovered series via API
- Creates filesystem folder + database record + episode records
### Episode Sync Logic
```python
# For each episode in DB but not in new episodeDict:
if episode.is_downloaded:
# Keep - file exists, don't remove
pass
else:
# Remove - no longer missing
EpisodeService.delete()
# For each episode in new episodeDict but not in DB:
# Add as new missing episode
EpisodeService.create(is_downloaded=False)
```
### Transaction Handling
- DB operations use their own session with commit/rollback
- If DB write fails, error is logged and scan continues
- File-based `save_to_file()` no longer called during scan
### Migration Path
1. v2.x: Scanner writes to both DB (primary) and files (fallback)
2. v3.0: Scanner writes only to DB, file methods removed
---
## 13. Series Persistence
### Schema
**AnimeSeries Table**: Stores series metadata (key, name, site, folder, year)
| Column | Type | Constraints | Description |
|-----------|--------------|---------------------------|----------------------|
| `id` | INTEGER | PRIMARY KEY | Auto-increment |
| `key` | VARCHAR(255) | UNIQUE, NOT NULL | Series provider key |
| `name` | VARCHAR(500) | NOT NULL | Display name |
| `site` | VARCHAR(500) | | Provider site URL |
| `folder` | VARCHAR(1000)| | Filesystem folder |
**Episode Table**: Stores per-episode metadata (season, episode_number, is_downloaded)
| Column | Type | Constraints | Description |
|-----------------|--------------|---------------------------|----------------------|
| `id` | INTEGER | PRIMARY KEY | Auto-increment |
| `series_id` | INTEGER | FOREIGN KEY → anime_series| Parent series |
| `season` | INTEGER | NOT NULL | Season number |
| `episode_number`| INTEGER | NOT NULL | Episode number |
| `is_downloaded` | BOOLEAN | DEFAULT FALSE | Download status |
### Relationships
- `AnimeSeries.episodes` → List of Episode objects (one-to-many)
- `Episode.series` → Parent AnimeSeries (many-to-one)
- Cascade delete: Deleting a series removes all its episodes
### Queries
```python
# Get all series with episodes
AnimeSeriesService.get_all(db, with_episodes=True)
# Get by provider key
AnimeSeriesService.get_by_key(db, key)
# Get by folder path
AnimeSeriesService.get_by_folder(db, folder)
```
---
## 14. Database Location
| Environment | Default Location |
| ----------- | ------------------------------------------------- |

436
Docs/DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,436 @@
# Development Guide
## Document Purpose
This document provides guidance for developers working on the Aniworld project.
### What This Document Contains
- **Prerequisites**: Required software and tools
- **Environment Setup**: Step-by-step local development setup
- **Project Structure**: Source code organization explanation
- **Development Workflow**: Branch strategy, commit conventions
- **Coding Standards**: Style guide, linting, formatting
- **Running the Application**: Development server, CLI usage
- **Debugging Tips**: Common debugging approaches
- **IDE Configuration**: VS Code settings, recommended extensions
- **Contributing Guidelines**: How to submit changes
- **Code Review Process**: Review checklist and expectations
### What This Document Does NOT Contain
- Production deployment (see [DEPLOYMENT.md](DEPLOYMENT.md))
- API reference (see [API.md](API.md))
- Architecture decisions (see [ARCHITECTURE.md](ARCHITECTURE.md))
- Test writing guides (see [TESTING.md](TESTING.md))
- Security guidelines (see [SECURITY.md](SECURITY.md))
### Target Audience
- New Developers joining the project
- Contributors (internal and external)
- Anyone setting up a development environment
---
## Sections to Document
1. Prerequisites
- Python version
- Conda environment
- Node.js (if applicable)
- Git
2. Getting Started
- Clone repository
- Setup conda environment
- Install dependencies
- Configuration setup
3. Project Structure Overview
4. Development Server
- Starting FastAPI server
- Hot reload configuration
- Debug mode
5. CLI Development
6. Code Style
- PEP 8 compliance
- Type hints requirements
- Docstring format
- Import organization
7. Git Workflow
- Branch naming
- Commit message format
- Pull request process
8. Common Development Tasks
### Adding Queue Deduplication
The download queue prevents duplicate entries at two levels:
**In-Memory Deduplication** (`src/server/services/download_service.py`):
- `_pending_by_episode` dict tracks pending episodes: key = `(serie_id, season, episode)`
- `_add_to_pending_queue()` updates the dict when adding items
- `add_to_queue()` checks this dict before adding episodes (includes batch-local dedup)
- `_remove_from_pending_queue()` cleans up the dict when items are removed
**Database Constraint** (`src/server/models.py`):
- `DownloadQueueItem` has a unique index on `episode_id` via `__table_args__`
- Prevents duplicate queue entries at the database level
- Unique constraint: `Index("ix_download_queue_episode_pending", "episode_id", unique=True)`
**Scheduler Cooldown** (`src/server/services/scheduler_service.py`):
- `_last_auto_download_time` tracks when auto-download last ran
- 5-minute cooldown prevents rapid re-triggers
- Checked at start of `_auto_download_missing()`
### Episode Lifecycle
Episodes transition through states stored in the `episodes` table:
| State | `is_downloaded` | `file_path` | Description |
|-------|----------------|-------------|-------------|
| Missing | `False` | `NULL` | Episode not yet downloaded |
| Downloaded | `True` | Set | Episode exists on disk |
**State Transitions:**
1. **Missing → Downloaded**: When download completes, `_remove_episode_from_missing_list()` calls `EpisodeService.mark_downloaded()` to set `is_downloaded=True` and populate `file_path`. The episode record is NOT deleted.
**Query Implications:**
- `get_series_with_missing_episodes()`: Filters for `is_downloaded=False` to find series with undownloaded episodes
- `get_series_with_no_episodes()`: Finds series with `is_downloaded=False` episodes but NO `is_downloaded=True` episodes (completely unwatched series)
### Mocking the Download Queue
When testing components that use the download queue:
```python
# Mock repository for unit tests
class MockQueueRepository:
def __init__(self):
self._items: Dict[str, DownloadItem] = {}
async def save_item(self, item: DownloadItem) -> DownloadItem:
self._items[item.id] = item
return item
async def get_all_items(self) -> List[DownloadItem]:
return list(self._items.values())
# Use in fixture
@pytest.fixture
def mock_queue_repository():
return MockQueueRepository()
@pytest.fixture
def download_service(mock_anime_service, mock_queue_repository):
return DownloadService(
anime_service=mock_anime_service,
queue_repository=mock_queue_repository,
max_retries=3,
)
```
9. Troubleshooting Development Issues
### Async Context Managers for aiohttp
All `aiohttp.ClientSession` usages must be wrapped in `async with`:
```python
# Correct — session properly closed on exit
async with TMDBClient(api_key="key") as client:
result = await client.search_tv_show("Show")
# Wrong — session may leak if exception occurs
client = TMDBClient(api_key="key")
result = await client.search_tv_show("Show")
await client.close() # May not be called if exception raised earlier
```
**Why:**
- `aiohttp.ClientSession` holds TCP connections that must be explicitly closed
- If exception occurs before `close()`, session leaks
- Context manager guarantees `__aexit__` runs even on exceptions
**Services that use aiohttp:**
- `TMDBClient` — has `__aenter__`/`__aexit__`, use `async with`
- `ImageDownloader` — has `__aenter__`/`__aexit__`, use `async with`
- `NFOService` — wraps both above, use `async with`
**Verification:**
- Missing context manager usage triggers `__del__` warning on garbage collection
- Integration tests verify no "Unclosed client session" errors in logs
### Scheduler Persistence and Recovery
The scheduler uses APScheduler's in-memory job store. Jobs are reconstructed from `config.json` on every startup — no separate database is needed.
```python
# Jobs are built from config on startup — no persistence DB required
scheduler = AsyncIOScheduler() # default MemoryJobStore
scheduler.add_job(..., replace_existing=True)
```
**Startup misfire recovery:** On `start()`, the scheduler checks `system_settings.last_scan_timestamp` in `aniworld.db`. If the last scan is overdue (>23h but <25h ago), an immediate rescan is triggered. This replaces APScheduler's built-in misfire handling which required a separate SQLite database.
**Grace period:** If the server was down for more than 25 hours, no automatic recovery occurs to avoid surprise rescans after long downtime.
**Health endpoint:** `GET /health` returns `scheduler_next_run` and `scheduler_last_run` for external monitors (Uptime Kuma, Prometheus, etc.).
**If server is down too long:** Manual trigger via `POST /api/scheduler/trigger-rescan` or wait for next scheduled run.
### Database Session Management
`get_async_session_factory()` returns a **new AsyncSession instance** directly (not a factory). The function name is historical — callers receive the session immediately:
```python
# Correct usage:
db = get_async_session_factory() # db IS the session
await db.execute(...)
await db.commit()
await db.close()
```
Do NOT call the result again with `()` — that tries to call an `AsyncSession` object, causing `'AsyncSession' object is not callable`.
For context manager usage, prefer `get_db_session()` (auto-commits) or `get_transactional_session()` (manual commit).
### Health Check Endpoints
The application provides health check endpoints for monitoring and container orchestration:
#### `GET /health`
Basic health check returning service status and startup health check results.
**Response fields:**
- `status`: "healthy", "degraded", or "unhealthy" based on startup checks
- `timestamp`: ISO timestamp of the check
- `series_app_initialized`: Whether the series app is loaded
- `anime_directory_configured`: Whether anime_directory is set
- `scheduler_next_run` / `scheduler_last_run`: Scheduler times
- `checks`: Detailed startup check results (ffmpeg, DNS, anime_directory)
#### `GET /health/ready`
Readiness check for container orchestrators (Kubernetes, Docker Swarm).
**Response when ready:**
```json
{
"status": "ready",
"ready": true,
"timestamp": "2024-01-01T00:00:00",
"checks": {...}
}
```
**Response when not ready (503):**
```json
{
"status": "not_ready",
"ready": false,
"timestamp": "2024-01-01T00:00:00",
"critical_failures": ["anime_directory: not configured"],
"checks": {...}
}
```
#### `GET /health/detailed`
Comprehensive health check including database, filesystem, and system metrics.
#### Startup Health Checks
On application startup, the following checks are performed:
| Check | Failure Status | Impact |
|-------|---------------|--------|
| `ffmpeg` | warning | HLS downloads may fail |
| `dns_aniworld` | warning | Provider requests may fail |
| `dns_tmdb` | warning | TMDB API calls may fail |
| `anime_directory` | error | Download service disabled |
DNS checks are warnings because failures can be transient. anime_directory errors disable the download service to prevent failures.
### Troubleshooting Development Issues
#### Scheduler missed a run
1. Server was down at scheduled time (03:00 UTC by default).
2. On restart, the scheduler checks `last_scan_timestamp` — if overdue by 23-25h, it triggers immediately.
3. If server was down >25 hours, missed job is skipped to avoid surprise rescans.
4. Trigger manually: `POST /api/scheduler/trigger-rescan`
5. Monitor next run: `GET /health``scheduler_next_run`
#### Scheduler not firing (no events at scheduled time)
If the scheduler appears configured but never triggers:
1. **Check application logs for scheduler startup:**
```
grep "Scheduler service started" fastapi_app.log
```
- If missing, the scheduler failed to start — check for errors above this line
- If present, scheduler started successfully
2. **Verify the job is registered:**
```
grep "Scheduler started with cron trigger" fastapi_app.log
```
3. **Verify APScheduler events in logs:**
```
grep "apscheduler.executors.default" fastapi_app.log
```
- `Running job` = job triggered
- `executed successfully` = job completed
- No output = job never fired
4. **Test manual trigger:**
```bash
curl -X POST http://localhost:8000/api/scheduler/trigger-rescan -H "Authorization: Bearer <token>"
```
- If manual trigger works but cron doesn't, the issue is APScheduler configuration
5. **Check next_run_time via health endpoint:**
```bash
curl http://localhost:8000/health | jq .scheduler_next_run
```
- If `null`, the job is not scheduled
- If set, the scheduler knows when to run next
6. **Check timezone handling:**
- APScheduler uses UTC internally
- The schedule_time config (e.g., "03:00") is interpreted as UTC
- If you expect local time, adjust the schedule_time accordingly
#### Startup health check failures
If `/health` returns `unhealthy` status:
1. **anime_directory error**: Directory not configured or not writable
- Check `ANIME_DIRECTORY` environment variable
- Verify directory exists and permissions allow write access
- Download service will not initialize until resolved
2. **ffmpeg warning**: ffmpeg not found in PATH
- HLS stream downloads will fail
- Install ffmpeg: `apt install ffmpeg` or `brew install ffmpeg`
3. **DNS warnings**: Domain resolution failed
- Check network connectivity
- DNS failures are transient — warnings don't block startup
- Retry later to verify: `GET /health`
### Provider Failure Handling
Download providers (VOE, Doodstream, Vidmoly, Vidoza, SpeedFiles, Streamtape,
Luluvdo) regularly break: URLs expire, sites change their player markup, geo
blocks appear, and `yt-dlp` extractors lag behind upstream changes. The
`AniworldLoader.download()` flow is designed to fail fast and rotate.
**Rotation order**
1. The episode page is scraped for the providers AniWorld actually advertises.
2. Results are ordered by the preference in `DEFAULT_PROVIDERS`
(`provider_config.py`); providers not listed run last.
3. For each candidate the loader:
1. Calls `_check_url_alive()` — HEAD probe with GET fallback. Any 4xx
response or connection error skips the provider immediately.
2. Resolves the redirect via `_resolve_direct_link()` to obtain a direct
stream URL plus headers. Provider-specific extractors (e.g. `VOE`) are
preferred; unknown providers fall back to the embed URL so `yt-dlp` can
attempt extraction.
3. Tries `_try_direct_stream()` — straight `requests.get(stream=True)` when
`Content-Type` is `video/*` or `application/octet-stream`. This avoids
`yt-dlp` entirely for direct MP4 links.
4. Falls back to `yt-dlp` with the ffmpeg downloader for HLS streams.
4. On any failure, temp files are cleaned and the loop moves to the next
provider. When the chain is exhausted, the loader logs
`All download providers failed for S{season}E{episode} ...; tried=[...]`
to both the application log and `logs/download_errors.log`.
**Do not hardcode provider URLs.** Provider domains shift constantly (e.g.
Doodstream alternates between `dood.li`, `dood.so`, `dood.la`). Only the
referer hints in `PROVIDER_HEADERS` are persisted — discovery still happens
at runtime through AniWorld's redirect endpoint.
### HLS Stream Handling
HLS (HTTP Live Streaming) manifests (`.m3u8`) require yt-dlp to use the
`ffmpeg` downloader with `--hls-use-mpegts`. Both providers configure this
automatically:
```python
ydl_opts = {
"downloader": "ffmpeg", # Use ffmpeg instead of native
"hls_use_mpegts": True, # Write transport stream (.ts) segments
}
```
**Why this matters**: Without ffmpeg, yt-dlp logs:
`"Live HLS streams are not supported by the native downloader"`
**Requirements**:
- ffmpeg must be installed and in PATH (`which ffmpeg`)
- Install: `apt install ffmpeg` (Debian/Ubuntu) or `brew install ffmpeg` (macOS)
- Startup health check (see Health Check Endpoints) verifies ffmpeg presence
**Trade-offs**:
- HLS downloads are slower than direct MP4 (reassembly of .ts segments)
- Requires more disk space during download
- May need post-processing if .ts format is not desired
**Detection**: VOE provider extracts HLS URLs via `HLS_PATTERN` regex. Other
providers let yt-dlp auto-detect from URL/content-type.
### Updating yt-dlp
When extractors break (typical symptoms: every provider HEAD probe succeeds
but `yt-dlp` raises `Unable to extract` or `HTTP Error 404`):
1. Check the upstream tracker first: https://github.com/yt-dlp/yt-dlp/issues
2. Upgrade in the conda environment:
```bash
conda run -n AniWorld pip install --upgrade yt-dlp
```
3. Smoke-test against a known-good episode before pinning a new floor in
`requirements.txt` (`yt-dlp>=YYYY.MM.DD`).
4. Re-run the provider test suite:
```bash
conda run -n AniWorld python -m pytest tests/unit/test_aniworld_provider.py -v
```
5. If a specific extractor is removed upstream, drop the provider from
`DEFAULT_PROVIDERS` rather than patching `yt-dlp` in tree.
### User Notification on Total Failure
`SeriesApp.download_episode()` already emits a `download_status="failed"`
WebSocket event when `loader.download()` returns `False`. Operators should
forward this to `notification_service.notify_download_failed()` so users see
a HIGH-priority alert. The loader keeps the failure detail in
`logs/download_errors.log` for post-mortem.
## Series Storage
### Overview
Series metadata now stored in the database (SQLAlchemy ORM).
Legacy files (`key` and `data` per folder) are deprecated but preserved
for backward compatibility.
### Architecture
- **Database**: Single source of truth for all series metadata
- **In-Memory Cache**: SeriesApp maintains a cache for performance
- **Filesystem**: Only used for episode files themselves, not metadata
### Migration
First startup after upgrade automatically imports any legacy
series files into the database.
### Legacy Files
- `key` file: Contains series provider key (deprecated)
- `data` file: Contains Serie JSON object (deprecated)
Both are safe to delete after migration; not needed for normal operation.

234
Docs/NAVIGATION.md Normal file
View File

@@ -0,0 +1,234 @@
# Navigation & Redirect Logic
This document describes the setup flow navigation, covering how users progress from initial setup through to the main application.
## Overview
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.
## State Machine
```
┌─────────────────────────────────────────────────────────────────────────┐
│ 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 │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
## State Definitions
| 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 enforces the state machine.
### Exempt Paths (always accessible)
| Path | Purpose |
|------|---------|
| `/setup` | Initial setup page |
| `/setup/unresolved` | Unresolved folder resolution |
| `/loading` | Initialization progress page |
| `/login` | Authentication |
| `/api/auth/*` | Auth endpoints |
| `/api/config/*` | Config API |
| `/api/health` | Health check |
| `/static/*` | Static assets |
### Middleware Logic
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
### 1. Setup Page (`/setup`)
**File:** `src/server/web/templates/setup.html`
Handles initial configuration:
- Master password creation
- Anime directory selection
- Database initialization
**Allowed in states:** `NO_SETUP`
**Post-completion:**
- Sets `setup_complete` flag
- Redirects to `/loading`
### 2. Loading Page (`/loading`)
**File:** `src/server/web/templates/loading.html`
Shows initialization progress via WebSocket:
- Series scanning
- Database population
- Logo/image loading
**Allowed in states:** `SETUP_COMPLETE`, `UNRESOLVED_DONE`, `NFO_SCAN_PENDING`
**Post-initialization (series scan complete):**
```javascript
async function checkUnresolvedAndProceed() {
const res = await fetch('/api/setup/unresolved', {
headers: { 'Authorization': `Bearer ${token}` }
});
const folders = await res.json();
if (folders.length > 0) {
window.location.href = '/setup/unresolved';
} else {
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`
Allows manual resolution of folders that couldn't be auto-matched:
- Shows list of unresolved folders
- Provides search suggestions
- Input field for entering provider key
- Resolve/delete actions
- **Done button** to complete the phase without resolving all folders
**Allowed in states:** `UNRESOLVED_PENDING`
**Done button behavior:**
- Sets `unresolved_completed` flag
- Redirects to `/loading` for final NFO scan
**After completion:**
- Any access redirects to `/loading`
### 4. Login Page (`/login`)
**File:** `src/server/web/templates/login.html`
Authentication page. After successful login → redirect to `/` (main app).
**Allowed in states:** `COMPLETE`
## API Endpoints
### Unresolved Folders API
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/setup/unresolved` | List all unresolved folders |
| `GET` | `/api/setup/unresolved/{folder_name}` | Get specific folder details |
| `POST` | `/api/setup/unresolved/{folder_name}/resolve` | Resolve with provider key |
| `POST` | `/api/setup/unresolved/{folder_name}/search` | Re-search for matches |
| `DELETE` | `/api/setup/unresolved/{folder_name}` | Remove folder from tracking |
| `POST` | `/api/setup/unresolved/done` | Mark unresolved phase as complete |
### Auth API
| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/api/auth/setup` | Create master password |
| `POST` | `/api/auth/login` | Authenticate |
| `POST` | `/api/auth/logout` | End session |
## Key Files
| File | Purpose |
|------|---------|
| `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 |
| `src/server/web/templates/unresolved.html` | Unresolved folders template |
| `src/server/api/setup_endpoints.py` | Unresolved folders API |
| `src/server/database/service.py` | UnresolvedFolderService |
## Navigation Summary
| 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` |

View File

@@ -171,6 +171,35 @@ Response:
}
```
### 3.6 Fallback Behavior When TMDB is Unavailable
When TMDB lookup fails (network issues, API errors, or no match found), the system creates a **minimal NFO** to ensure the series is still tracked. This behavior applies to:
- Manual NFO creation via API
- Batch NFO creation operations
- Automatic NFO creation during downloads
**What a minimal NFO contains:**
```xml
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<tvshow>
<title>Series Name</title>
<year>2024</year>
<plot>No metadata available for Series Name. TMDB lookup failed.</plot>
</tvshow>
```
**Limitations of minimal NFOs:**
- No poster, logo, or fanart images
- No rating, genre, or studio information
- No TMDB or other provider IDs
- May not display correctly in some media servers
**To upgrade a minimal NFO:**
1. Use the Update endpoint (`PUT /api/nfo/{serie_id}/update`) when TMDB is available
2. Or delete the NFO and recreate it with full metadata
---
## 4. File Structure
@@ -217,6 +246,7 @@ NFO files are created in the anime directory:
<genre>Action</genre>
<genre>Sci-Fi & Fantasy</genre>
<uniqueid type="tmdb">1429</uniqueid>
<tmdbid>1429</tmdbid>
<thumb aspect="poster">https://image.tmdb.org/t/p/w500/...</thumb>
<fanart>
<thumb>https://image.tmdb.org/t/p/original/...</thumb>
@@ -224,6 +254,13 @@ NFO files are created in the anime directory:
</tvshow>
```
**Manual TMDB ID Override**: To skip TMDB search and use a specific ID directly, include `<tmdbid>YOUR_ID</tmdbid>` in the NFO. This is useful when:
- TMDB search fails for your series (e.g., new or obscure anime)
- You already know the correct TMDB ID
- You want to avoid rate limiting from repeated searches
Aniworld reads `<tmdbid>` element and `<uniqueid type="tmdb">` first. If found, it uses the ID directly instead of searching.
### 4.3 Episode NFO Format
```xml
@@ -600,6 +637,36 @@ Every poster check action is logged:
4. Check network speed to TMDB servers
5. Verify disk I/O performance
### 6.7 TMDB Lookup Fails for My Series
**Problem**: TMDB search fails with "No results found" for a valid series.
**Solutions**:
1. **Check if series exists on TMDB**: Visit https://www.themoviedb.org and search for your series
2. **Use manual ID override**: Add TMDB ID directly to `tvshow.nfo`:
```xml
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<tvshow>
<title>Your Series Name</title>
<tmdbid>12345</tmdbid>
<uniqueid type="tmdb">12345</uniqueid>
</tvshow>
```
Aniworld will use this ID directly instead of searching.
3. **Try alternative titles**: Some anime have different titles (Japanese, romaji, English). If you have access to the folder, rename it to match the TMDB title.
4. **Add to existing NFO**: If `tvshow.nfo` exists but has no TMDB ID, edit it to add:
```xml
<tmdbid>YOUR_TMDB_ID</tmdbid>
```
Then use the Update endpoint to refresh metadata.
5. **Check for rate limiting**: If many lookups fail at once, you may be hitting TMDB rate limits. Wait and retry later.
6. **Verify API key**: Ensure your TMDB API key is valid and has not exceeded usage limits.
---
## 7. Best Practices
@@ -661,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
```
@@ -744,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 |
---

75
Docs/TESTING.md Normal file
View File

@@ -0,0 +1,75 @@
### Testing FolderNamingService
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
```python
# 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)"
# 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`:
```python
from unittest.mock import AsyncMock, MagicMock, patch
from aiohttp import ClientSession
# Mock aiohttp session for testing
class MockAiohttpSession:
def __init__(self):
self.closed = False
async def close(self):
self.closed = True
def get(self, url, **kwargs):
mock_response = AsyncMock()
mock_response.status = 200
mock_response.json = AsyncMock(return_value={"data": "test"})
mock_response.__aenter__ = AsyncMock(return_value=mock_response)
mock_response.__aexit__ = AsyncMock(return_value=None)
return mock_response
# Use in fixture
@pytest.fixture
async def mock_tmdb_session():
session = MockAiohttpSession()
yield session
# Cleanup verification
assert session.closed, "Session was not closed"
```
**Key points:**
- Always verify `session.closed` is `True` after context manager exits
- Mock `__aenter__` and `__aexit__` for response context managers
- Set `closed = False` on mock session for unclosed warning tests
7. Coverage Requirements
8. CI/CD Integration
9. Writing Good Tests
- Arrange-Act-Assert pattern
- Test isolation
- Edge cases
10. Common Pitfalls to Avoid

View File

@@ -31,14 +31,16 @@ flowchart TB
subgraph Core["Core Layer"]
SeriesApp["SeriesApp"]
SeriesCache["SeriesCache<br/>(In-Memory)"]
SerieScanner["SerieScanner"]
SerieList["SerieList"]
end
subgraph Data["Data Layer"]
SQLite[(SQLite<br/>aniworld.db)]
SQLite[("SQLite<br/>aniworld.db")]
ConfigJSON[(config.json)]
FileSystem[(File System<br/>Anime Directory)]
FileSystem[(File System<br/>Anime Episodes)]
LegacyFiles[("Legacy Files<br/>key/data<br/>(Deprecated)")]
end
subgraph External["External"]
@@ -71,9 +73,13 @@ flowchart TB
AnimeService --> SQLite
%% Core to Data
SeriesApp --> SeriesCache
SeriesCache -.->|Cached Series| SQLite
SeriesApp --> SerieScanner
SeriesApp --> SerieList
SerieScanner --> FileSystem
SerieScanner -->|Scan Episodes| FileSystem
SerieScanner -->|Detect Series| SQLite
SerieScanner -->|Migrate Legacy| LegacyFiles
SerieScanner --> Provider
%% Event flow

View File

@@ -90,7 +90,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
@@ -107,6 +107,10 @@ The application now features a comprehensive configuration system that allows us
- **Progress Tracking**: Live progress updates for downloads and scans
- **System Notifications**: Real-time system messages and alerts
## Folder Management
- **Fuzzy Series Key Resolution**: Automatic series key resolution from folder names using fuzzy title matching — tolerates title variations like `(TV)`, `(OVA)`, `(Movie)` suffixes and uses similarity matching to resolve provider keys during library setup
## Core Functionality Overview
The web application provides a complete interface for managing anime downloads with user-friendly pages for configuration, library management, search capabilities, and download monitoring. All operations are tracked in real-time with comprehensive progress reporting and error handling.

8
Docs/key Normal file
View File

@@ -0,0 +1,8 @@
API key : 299ae8f630a31bda814263c551361448
9bc3e547caff878615cbdba2cc421d37
/setup
SeriesApp initialized for directory:
to remove:

154
Docs/runner.csx Normal file
View File

@@ -0,0 +1,154 @@
#!/usr/bin/env dotnet-script
#nullable enable
using System;
using System.IO;
using System.Diagnostics;
using System.Threading;
using System.Text;
using System.Text.RegularExpressions;
using System.Linq;
using System.Collections.Generic;
// ── Ctrl+C: kill active process and exit cleanly ──────────────────────────────
var cts = new CancellationTokenSource();
Process? activeProcess = null;
Console.CancelKeyPress += (_, e) =>
{
e.Cancel = true;
Console.WriteLine("\n[runner] Interrupted — shutting down...");
cts.Cancel();
try { activeProcess?.Kill(entireProcessTree: true); } catch { }
};
// ── Paths ─────────────────────────────────────────────────────────────────────
var repoRoot = Directory.GetCurrentDirectory();
var tasksFile = Path.Combine(repoRoot, "Docs", "tasks.md");
if (!File.Exists(tasksFile))
{
Console.Error.WriteLine($"[runner] ERROR: Tasks.md not found at {tasksFile}");
Console.Error.WriteLine("[runner] Run this script from the repository root.");
Environment.Exit(1);
}
// ── Read & split by "---" separator lines ────────────────────────────────────
var content = File.ReadAllText(tasksFile);
var items = Regex
.Split(content, @"\r?\n---\r?\n")
.Select(s => s.Trim())
.Where(s => s.Length > 0)
.ToList();
Console.WriteLine($"[runner] Found {items.Count} section(s) in Tasks.md");
// ── Helper: run copilot and stream output, return full output ─────────────────
async Task<string> RunCopilot(IEnumerable<string> extraArgs, string prompt)
{
var output = new StringBuilder();
var argList = new List<string> { "launch", "copilot", "--model", "minimax-m2.7:cloud", "--yes", "--", "--allow-all-tools" };
argList.AddRange(extraArgs);
argList.Add("-p");
argList.Add(prompt);
var psi = new ProcessStartInfo("ollama")
{
WorkingDirectory = repoRoot,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
foreach (var a in argList)
psi.ArgumentList.Add(a);
activeProcess = new Process { StartInfo = psi };
activeProcess.OutputDataReceived += (_, e) =>
{
if (e.Data is null) return;
Console.WriteLine(e.Data);
output.AppendLine(e.Data);
};
activeProcess.ErrorDataReceived += (_, e) =>
{
if (e.Data is null) return;
Console.Error.WriteLine(e.Data);
output.AppendLine(e.Data);
};
activeProcess.Start();
activeProcess.BeginOutputReadLine();
activeProcess.BeginErrorReadLine();
await activeProcess.WaitForExitAsync(cts.Token);
activeProcess = null;
return output.ToString();
}
// ── Main loop ─────────────────────────────────────────────────────────────────
for (int i = 0; i < items.Count; i++)
{
var item = items[i];
if (cts.IsCancellationRequested) break;
Console.WriteLine();
Console.WriteLine("[runner] ══════════════════════════════════════════════");
Console.WriteLine($"[runner] Task:\n{item}");
Console.WriteLine("[runner] ══════════════════════════════════════════════");
Console.WriteLine();
// Step 1 — run the task prompt
await RunCopilot(Enumerable.Empty<string>(), $"/caveman full");
await RunCopilot(new[] { "--continue" }, $"{item}");
if (cts.IsCancellationRequested) break;
// Step 2 — confirm completion in the same chat session
Console.WriteLine("\n[runner] Asking for task confirmation...\n");
var confirmation = await RunCopilot(
new[] { "--continue" },
"are you sure tasks is done. reply with yes"
);
if (cts.IsCancellationRequested) break;
// Step 3 — check for "yes" in the reply, with retry logic for issue resolution
int maxRetries = 3;
int retryCount = 0;
bool taskConfirmed = confirmation.Contains("yes", StringComparison.OrdinalIgnoreCase);
while (!taskConfirmed && retryCount < maxRetries)
{
retryCount++;
Console.WriteLine($"\n[runner] Attempt {retryCount}/{maxRetries}: Resolving remaining issues and running tests...\n");
confirmation = await RunCopilot(
new[] { "--continue" },
"resolve any remaining issues, make sure all tests are running and pass. then confirm with yes if done"
);
if (cts.IsCancellationRequested) break;
taskConfirmed = confirmation.Contains("yes", StringComparison.OrdinalIgnoreCase);
}
if (!taskConfirmed)
{
Console.WriteLine($"\n[runner] Task not confirmed as done after {maxRetries} attempts. Stopping.");
break;
}
// Step 4 — commit the work
Console.WriteLine("\n[runner] Task confirmed. Making git commit...\n");
await RunCopilot(Enumerable.Empty<string>(), $"/caveman full");
await RunCopilot(new[] { "--continue" }, "make git commit");
if (cts.IsCancellationRequested) break;
// Step 5 — remove completed task from Tasks.md
var remaining = items.Skip(i + 1).ToList();
File.WriteAllText(tasksFile, string.Join("\n\n---\n\n", remaining));
Console.WriteLine("[runner] Removed completed task from Tasks.md");
}
Console.WriteLine("\n[runner] Finished.");

0
Docs/tasks.md Normal file
View File

24
Makefile Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View File

@@ -1,64 +0,0 @@
# Development Guide
## Document Purpose
This document provides guidance for developers working on the Aniworld project.
### What This Document Contains
- **Prerequisites**: Required software and tools
- **Environment Setup**: Step-by-step local development setup
- **Project Structure**: Source code organization explanation
- **Development Workflow**: Branch strategy, commit conventions
- **Coding Standards**: Style guide, linting, formatting
- **Running the Application**: Development server, CLI usage
- **Debugging Tips**: Common debugging approaches
- **IDE Configuration**: VS Code settings, recommended extensions
- **Contributing Guidelines**: How to submit changes
- **Code Review Process**: Review checklist and expectations
### What This Document Does NOT Contain
- Production deployment (see [DEPLOYMENT.md](DEPLOYMENT.md))
- API reference (see [API.md](API.md))
- Architecture decisions (see [ARCHITECTURE.md](ARCHITECTURE.md))
- Test writing guides (see [TESTING.md](TESTING.md))
- Security guidelines (see [SECURITY.md](SECURITY.md))
### Target Audience
- New Developers joining the project
- Contributors (internal and external)
- Anyone setting up a development environment
---
## Sections to Document
1. Prerequisites
- Python version
- Conda environment
- Node.js (if applicable)
- Git
2. Getting Started
- Clone repository
- Setup conda environment
- Install dependencies
- Configuration setup
3. Project Structure Overview
4. Development Server
- Starting FastAPI server
- Hot reload configuration
- Debug mode
5. CLI Development
6. Code Style
- PEP 8 compliance
- Type hints requirements
- Docstring format
- Import organization
7. Git Workflow
- Branch naming
- Commit message format
- Pull request process
8. Common Development Tasks
9. Troubleshooting Development Issues

View File

@@ -1,71 +0,0 @@
# Testing Documentation
## Document Purpose
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
7. Coverage Requirements
8. CI/CD Integration
9. Writing Good Tests
- Arrange-Act-Assert pattern
- Test isolation
- Edge cases
10. Common Pitfalls to Avoid

View File

@@ -1,51 +0,0 @@
API key : 299ae8f630a31bda814263c551361448
/mnt/server/serien/Serien/
{
"name": "Aniworld",
"data_dir": "data",
"scheduler": {
"enabled": true,
"interval_minutes": 60,
"schedule_time": "03:00",
"schedule_days": [
"mon",
"tue",
"wed",
"thu",
"fri",
"sat",
"sun"
],
"auto_download_after_rescan": true,
"folder_scan_enabled": true
},
"logging": {
"level": "INFO",
"file": null,
"max_bytes": null,
"backup_count": 3
},
"backup": {
"enabled": false,
"path": "data/backups",
"keep_days": 30
},
"nfo": {
"tmdb_api_key": "9bc3e547caff878615cbdba2cc421d37",
"auto_create": true,
"update_on_scan": true,
"download_poster": true,
"download_logo": true,
"download_fanart": true,
"image_size": "original"
},
"other": {
"master_password_hash": "$pbkdf2-sha256$29000$HQNASKk1xpgTAgAgJGRMaQ$73TOCCM0UEZONyNXQEPa3SmIoXeG6C1l5mMFDNgYfMQ",
"anime_directory": "/data"
},
"version": "1.0.0"
}

View File

@@ -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.31.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.

2644
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "aniworld-web",
"version": "1.1.9",
"version": "1.5.2",
"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": {

View File

@@ -22,6 +22,13 @@ APScheduler>=3.10.4
Events>=0.5
requests>=2.31.0
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

View File

@@ -1,10 +1,8 @@
"""CLI command for NFO management.
This script provides command-line interface for creating, updating,
and checking NFO metadata files.
Note: NFO service has been removed. This CLI is no longer functional.
"""
import asyncio
import logging
import sys
from pathlib import Path
@@ -12,9 +10,6 @@ from pathlib import Path
# Add src to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from src.config.settings import settings
from src.core.services.series_manager_service import SeriesManagerService
logger = logging.getLogger(__name__)
@@ -125,7 +120,7 @@ async def check_nfo_status():
logger.info("Anime Directory: %s", settings.anime_directory)
# Create series list (no NFO service needed for status check)
from src.core.entities.SerieList import SerieList
from src.server.database.SerieList import SerieList
serie_list = SerieList(settings.anime_directory)
all_series = serie_list.get_all()
@@ -179,91 +174,6 @@ async def check_nfo_status():
return 0
async def update_nfo_files():
"""Update existing NFO files with fresh data from TMDB."""
logger.info("%s", "=" * 70)
logger.info("NFO Update Tool")
logger.info("%s", "=" * 70)
if not settings.tmdb_api_key:
logger.error("TMDB_API_KEY not configured")
logger.error("Set TMDB_API_KEY in .env file or environment")
logger.error("Get API key from: https://www.themoviedb.org/settings/api")
return 1
if not settings.anime_directory:
logger.error("ANIME_DIRECTORY not configured")
return 1
logger.info("Anime Directory: %s", settings.anime_directory)
logger.info(
"Download media: %s",
settings.nfo_download_poster or settings.nfo_download_logo or settings.nfo_download_fanart,
)
# Get series with NFO
from src.core.entities.SerieList import SerieList
serie_list = SerieList(settings.anime_directory)
all_series = serie_list.get_all()
series_with_nfo = [s for s in all_series if s.has_nfo()]
if not series_with_nfo:
logger.warning("No series with NFO files found")
logger.info("Run 'scan' command first to create NFO files")
return 0
logger.info("Found %d series with NFO files", len(series_with_nfo))
logger.info("Updating NFO files with fresh data from TMDB...")
logger.info("This may take a while")
# Initialize NFO service using factory
from src.core.services.nfo_factory import create_nfo_service
try:
nfo_service = create_nfo_service()
except ValueError as e:
logger.error("Error creating NFO service: %s", e)
return 1
success_count = 0
error_count = 0
try:
for i, serie in enumerate(series_with_nfo, 1):
logger.info("[%d/%d] Updating: %s", i, len(series_with_nfo), serie.name)
try:
await nfo_service.update_tvshow_nfo(
serie_folder=serie.folder,
download_media=(
settings.nfo_download_poster or
settings.nfo_download_logo or
settings.nfo_download_fanart
),
)
logger.info("Updated successfully: %s", serie.name)
success_count += 1
# Small delay to respect API rate limits
await asyncio.sleep(0.5)
except Exception as e:
logger.exception("Failed to update NFO for %s", serie.name)
error_count += 1
logger.info("%s", "=" * 70)
logger.info("Update complete")
logger.info("Success: %d", success_count)
logger.info("Errors: %d", error_count)
except Exception:
logger.exception("Fatal error during NFO update")
return 1
finally:
await nfo_service.close()
return 0
def main():
"""Main CLI entry point."""
logging.basicConfig(level=logging.INFO, format="%(message)s")
@@ -273,7 +183,6 @@ def main():
logger.info("\nUsage:")
logger.info(" python -m src.cli.nfo_cli scan # Scan and create missing NFO files")
logger.info(" python -m src.cli.nfo_cli status # Check NFO status for all series")
logger.info(" python -m src.cli.nfo_cli update # Update existing NFO files with fresh data")
logger.info("\nConfiguration:")
logger.info(" Set TMDB_API_KEY in .env file")
logger.info(" Set NFO_AUTO_CREATE=true to enable auto-creation")
@@ -286,11 +195,9 @@ def main():
return asyncio.run(scan_and_create_nfo())
elif command == "status":
return asyncio.run(check_nfo_status())
elif command == "update":
return asyncio.run(update_nfo_files())
else:
logger.error("Unknown command: %s", command)
logger.info("Use 'scan', 'status', or 'update'")
logger.info("Use 'scan' or 'status'")
return 1

View File

@@ -1,3 +1,4 @@
import re
import secrets
from typing import Optional
@@ -114,6 +115,40 @@ class Settings(BaseSettings):
validation_alias="NFO_PREFER_FSK_RATING",
description="Prefer German FSK rating over MPAA rating in NFO files"
)
nfo_folder_ignore_patterns: str = Field(
default="The Last of Us|Loki|Chernobyl|Star Trek Discovery|Marvel|Matrix|Fast & Furious|Jurassic|James Bond|Mission: Impossible|Bourne|Hunger Games|Die Hard|John Wick|Pacific Rim|Guardians of the Galaxy|Avengers|Batman|Superman|Wonder Woman|Spider-Man|X-Men|Fantastic Four|Terminator|Predator|Rambo|Rocky|Expendables|Tomb Raider|Jumanji|Jurassic Park|Pirates of the Caribbean|Harry Potter|Lord of the Rings|Hobbit|Game of Thrones|Westworld|Stranger Things|Breaking Bad|Better Call Saul|Sherlock|Downton Abbey|The Crown|Bridgerton|Sex Education|Normal People|Emily in Paris|The Witcher|Servant|Lucifer|Dark|Shadow and Bone|Grimm|Fairytale",
validation_alias="NFO_FOLDER_IGNORE_PATTERNS",
description="Regex patterns for folder names to skip during scan (pipe-separated)"
)
@property
def folder_ignore_patterns(self) -> list[str]:
"""Parse ignore patterns from comma-separated string into list.
Returns:
List of regex patterns to skip during folder scanning.
"""
if not self.nfo_folder_ignore_patterns:
return []
return [
pattern.strip()
for pattern in self.nfo_folder_ignore_patterns.split("|")
if pattern.strip()
]
def should_ignore_folder(self, folder_name: str) -> bool:
"""Check if folder should be ignored based on ignore patterns.
Args:
folder_name: Name of folder to check.
Returns:
True if folder matches any ignore pattern, False otherwise.
"""
for pattern in self.folder_ignore_patterns:
if re.search(pattern, folder_name, re.IGNORECASE):
return True
return False
@property
def allowed_origins(self) -> list[str]:
@@ -134,5 +169,23 @@ class Settings(BaseSettings):
]
return [origin.strip() for origin in raw.split(",") if origin.strip()]
@property
def scan_key_overrides(self) -> dict[str, str]:
"""Return scan key overrides from config.json.
Maps folder names to provider keys for cases where auto-generated
keys from folder names are incorrect.
Returns:
Dict mapping folder names to provider keys.
"""
from src.server.services.config_service import ConfigService
try:
config_service = ConfigService()
config = config_service.load_config()
return config.scan_key_overrides or {}
except Exception:
return {}
settings = Settings()

View File

@@ -1,320 +0,0 @@
"""Utilities for loading and managing stored anime series metadata.
This module provides the SerieList class for managing collections of anime
series metadata. It uses file-based storage only.
Note:
This module is part of the core domain layer and has no database
dependencies. All database operations are handled by the service layer.
"""
from __future__ import annotations
import logging
import os
import warnings
from json import JSONDecodeError
from typing import Dict, Iterable, List, Optional
from src.core.entities.series import Serie
logger = logging.getLogger(__name__)
class SerieList:
"""
Represents the collection of cached series stored on disk.
Series are identified by their unique 'key' (provider identifier).
The 'folder' is metadata only and not used for lookups.
This class manages in-memory series data loaded from filesystem.
It has no database dependencies - all persistence is handled by
the service layer.
Example:
# File-based mode
serie_list = SerieList("/path/to/anime")
series = serie_list.get_all()
Attributes:
directory: Path to the anime directory
keyDict: Internal dictionary mapping serie.key to Serie objects
"""
def __init__(
self,
base_path: str,
skip_load: bool = False
) -> None:
"""Initialize the SerieList.
Args:
base_path: Path to the anime directory
skip_load: If True, skip automatic loading of series from files.
Useful when planning to load from database instead.
"""
self.directory: str = base_path
# Internal storage using serie.key as the dictionary key
self.keyDict: Dict[str, Serie] = {}
# Only auto-load from files if not skipping
if not skip_load:
self.load_series()
def add(self, serie: Serie, use_sanitized_folder: bool = True) -> str:
"""
Persist a new series if it is not already present (file-based mode).
Uses serie.key for identification. Creates the filesystem folder
using either the sanitized display name (default) or the existing
folder property.
Args:
serie: The Serie instance to add
use_sanitized_folder: If True (default), use serie.sanitized_folder
for the filesystem folder name based on display name.
If False, use serie.folder as-is for backward compatibility.
Returns:
str: The folder path that was created/used
Note:
This method creates data files on disk. For database storage,
use add_to_db() instead.
"""
if self.contains(serie.key):
# Return existing folder path
existing = self.keyDict[serie.key]
return os.path.join(self.directory, existing.folder)
# Determine folder name to use
if use_sanitized_folder:
folder_name = serie.sanitized_folder
# Update the serie's folder property to match what we create
serie.folder = folder_name
else:
folder_name = serie.folder
data_path = os.path.join(self.directory, folder_name, "data")
anime_path = os.path.join(self.directory, folder_name)
os.makedirs(anime_path, exist_ok=True)
if not os.path.isfile(data_path):
serie.save_to_file(data_path)
# Store by key, not folder
self.keyDict[serie.key] = serie
return anime_path
def contains(self, key: str) -> bool:
"""
Return True when a series identified by ``key`` already exists.
Args:
key: The unique provider identifier for the series
Returns:
True if the series exists in the collection
"""
return key in self.keyDict
def load_series(self) -> None:
"""Populate the in-memory map with metadata discovered on disk."""
logger.info("Scanning anime folders in %s", self.directory)
try:
entries: Iterable[str] = os.listdir(self.directory)
except OSError as error:
logger.error(
"Unable to scan directory %s: %s",
self.directory,
error,
)
return
nfo_stats = {"total": 0, "with_nfo": 0, "without_nfo": 0}
media_stats = {
"with_poster": 0,
"without_poster": 0,
"with_logo": 0,
"without_logo": 0,
"with_fanart": 0,
"without_fanart": 0
}
for anime_folder in entries:
anime_path = os.path.join(self.directory, anime_folder, "data")
if os.path.isfile(anime_path):
logger.debug("Found data file for folder %s", anime_folder)
serie = self._load_data(anime_folder, anime_path)
if serie:
nfo_stats["total"] += 1
# Check for NFO file
nfo_file_path = os.path.join(
self.directory, anime_folder, "tvshow.nfo"
)
if os.path.isfile(nfo_file_path):
serie.nfo_path = nfo_file_path
nfo_stats["with_nfo"] += 1
else:
nfo_stats["without_nfo"] += 1
logger.debug(
"Series '%s' (key: %s) is missing tvshow.nfo",
serie.name,
serie.key
)
# Check for media files
folder_path = os.path.join(self.directory, anime_folder)
poster_path = os.path.join(folder_path, "poster.jpg")
if os.path.isfile(poster_path):
media_stats["with_poster"] += 1
else:
media_stats["without_poster"] += 1
logger.debug(
"Series '%s' (key: %s) is missing poster.jpg",
serie.name,
serie.key
)
logo_path = os.path.join(folder_path, "logo.png")
if os.path.isfile(logo_path):
media_stats["with_logo"] += 1
else:
media_stats["without_logo"] += 1
logger.debug(
"Series '%s' (key: %s) is missing logo.png",
serie.name,
serie.key
)
fanart_path = os.path.join(folder_path, "fanart.jpg")
if os.path.isfile(fanart_path):
media_stats["with_fanart"] += 1
else:
media_stats["without_fanart"] += 1
logger.debug(
"Series '%s' (key: %s) is missing fanart.jpg",
serie.name,
serie.key
)
continue
logger.warning(
"Skipping folder %s because no metadata file was found",
anime_folder,
)
# Log summary statistics
if nfo_stats["total"] > 0:
logger.info(
"NFO scan complete: %d series total, %d with NFO, %d without NFO",
nfo_stats["total"],
nfo_stats["with_nfo"],
nfo_stats["without_nfo"]
)
logger.info(
"Media scan complete: Poster (%d/%d), Logo (%d/%d), Fanart (%d/%d)",
media_stats["with_poster"],
nfo_stats["total"],
media_stats["with_logo"],
nfo_stats["total"],
media_stats["with_fanart"],
nfo_stats["total"]
)
def _load_data(self, anime_folder: str, data_path: str) -> Optional[Serie]:
"""
Load a single series metadata file into the in-memory collection.
Args:
anime_folder: The folder name (for logging only)
data_path: Path to the metadata file
Returns:
Serie: The loaded Serie object, or None if loading failed
"""
try:
serie = Serie.load_from_file(data_path)
# Store by key, not folder
self.keyDict[serie.key] = serie
logger.debug(
"Successfully loaded metadata for %s (key: %s)",
anime_folder,
serie.key
)
return serie
except (OSError, JSONDecodeError, KeyError, ValueError) as error:
logger.error(
"Failed to load metadata for folder %s from %s: %s",
anime_folder,
data_path,
error,
)
return None
def GetMissingEpisode(self) -> List[Serie]:
"""Return all series that still contain missing episodes."""
return [
serie
for serie in self.keyDict.values()
if serie.episodeDict
]
def get_missing_episodes(self) -> List[Serie]:
"""PEP8-friendly alias for :meth:`GetMissingEpisode`."""
return self.GetMissingEpisode()
def GetList(self) -> List[Serie]:
"""Return all series instances stored in the list."""
return list(self.keyDict.values())
def get_all(self) -> List[Serie]:
"""PEP8-friendly alias for :meth:`GetList`."""
return self.GetList()
def get_by_key(self, key: str) -> Optional[Serie]:
"""
Get a series by its unique provider key.
This is the primary method for series lookup.
Args:
key: The unique provider identifier (e.g., "attack-on-titan")
Returns:
The Serie instance if found, None otherwise
"""
return self.keyDict.get(key)
def get_by_folder(self, folder: str) -> Optional[Serie]:
"""
Get a series by its folder name.
.. deprecated:: 2.0.0
Use :meth:`get_by_key` instead. Folder-based lookups will be
removed in version 3.0.0. The `folder` field is metadata only
and should not be used for identification.
This method is provided for backward compatibility only.
Prefer using get_by_key() for new code.
Args:
folder: The filesystem folder name (e.g., "Attack on Titan (2013)")
Returns:
The Serie instance if found, None otherwise
"""
warnings.warn(
"get_by_folder() is deprecated and will be removed in v3.0.0. "
"Use get_by_key() instead. The 'folder' field is metadata only.",
DeprecationWarning,
stacklevel=2
)
for serie in self.keyDict.values():
if serie.folder == folder:
return serie
return None

View File

@@ -1,414 +0,0 @@
import json
import logging
import os
import warnings
from pathlib import Path
from typing import Optional
from src.server.utils.filesystem import sanitize_folder_name
logger = logging.getLogger(__name__)
class Serie:
"""
Represents an anime series with metadata and episode information.
The `key` property is the unique identifier for the series
(provider-assigned, URL-safe).
The `folder` property is the filesystem folder name
(metadata only, not used for lookups).
Args:
key: Unique series identifier from provider
(e.g., "attack-on-titan"). Cannot be empty.
name: Display name of the series
site: Provider site URL
folder: Filesystem folder name (metadata only,
e.g., "Attack on Titan (2013)")
episodeDict: Dictionary mapping season numbers to
lists of episode numbers
year: Release year of the series (optional)
Raises:
ValueError: If key is None or empty string
"""
def __init__(
self,
key: str,
name: str,
site: str,
folder: str,
episodeDict: dict[int, list[int]],
year: int | None = None,
nfo_path: Optional[str] = None
):
if not key or not key.strip():
raise ValueError("Serie key cannot be None or empty")
self._key = key.strip()
self._name = name
self._site = site
self._folder = folder
self._episodeDict = episodeDict
self._year = year
self._nfo_path = nfo_path
def __str__(self):
"""String representation of Serie object"""
year_str = f", year={self.year}" if self.year else ""
return (
f"Serie(key='{self.key}', name='{self.name}', "
f"site='{self.site}', folder='{self.folder}', "
f"episodeDict={self.episodeDict}{year_str})"
)
def __repr__(self):
"""Concise developer representation of Serie object."""
season_count = len(self.episodeDict)
episode_count = sum(len(eps) for eps in self.episodeDict.values())
year_str = f", year={self.year}" if self.year else ""
return (
f"Serie(key={self.key!r}, name={self.name!r}"
f"{year_str}, seasons={season_count}, episodes={episode_count})"
)
@property
def key(self) -> str:
"""
Unique series identifier (primary identifier for all lookups).
This is the provider-assigned, URL-safe identifier used
throughout the application for series identification,
lookups, and operations.
Returns:
str: The unique series key
"""
return self._key
@key.setter
def key(self, value: str):
"""
Set the unique series identifier.
Args:
value: New key value
Raises:
ValueError: If value is None or empty string
"""
if not value or not value.strip():
raise ValueError("Serie key cannot be None or empty")
self._key = value.strip()
@property
def name(self) -> str:
return self._name
@name.setter
def name(self, value: str):
self._name = value
@property
def site(self) -> str:
return self._site
@site.setter
def site(self, value: str):
self._site = value
@property
def folder(self) -> str:
"""
Filesystem folder name (metadata only, not used for lookups).
This property contains the local directory name where the series
files are stored. It should NOT be used as an identifier for
series lookups - use `key` instead.
Returns:
str: The filesystem folder name
"""
return self._folder
@folder.setter
def folder(self, value: str):
"""
Set the filesystem folder name.
Args:
value: Folder name for the series
"""
self._folder = value
@property
def episodeDict(self) -> dict[int, list[int]]:
return self._episodeDict
@episodeDict.setter
def episodeDict(self, value: dict[int, list[int]]):
self._episodeDict = value
@property
def year(self) -> int | None:
"""
Release year of the series.
Returns:
int or None: The year the series was released, or None if unknown
"""
return self._year
@year.setter
def year(self, value: int | None):
"""Set the release year of the series."""
self._year = value
@property
def nfo_path(self) -> Optional[str]:
"""
Path to the tvshow.nfo metadata file.
Returns:
str or None: Path to the NFO file, or None if not set
"""
return self._nfo_path
@nfo_path.setter
def nfo_path(self, value: Optional[str]):
"""Set the path to the NFO file."""
self._nfo_path = value
def has_nfo(self, base_directory: Optional[str] = None) -> bool:
"""
Check if tvshow.nfo file exists for this series.
Args:
base_directory: Base anime directory path. If provided, checks
relative to base_directory/folder/tvshow.nfo. If not provided,
uses nfo_path directly.
Returns:
bool: True if tvshow.nfo exists, False otherwise
"""
if base_directory:
nfo_file = Path(base_directory) / self.folder / "tvshow.nfo"
elif self._nfo_path:
nfo_file = Path(self._nfo_path)
else:
return False
return nfo_file.exists() and nfo_file.is_file()
def has_poster(self, base_directory: Optional[str] = None) -> bool:
"""
Check if poster.jpg file exists for this series.
Args:
base_directory: Base anime directory path. If provided, checks
relative to base_directory/folder/poster.jpg.
Returns:
bool: True if poster.jpg exists, False otherwise
"""
if not base_directory:
return False
poster_file = Path(base_directory) / self.folder / "poster.jpg"
return poster_file.exists() and poster_file.is_file()
def has_logo(self, base_directory: Optional[str] = None) -> bool:
"""
Check if logo.png file exists for this series.
Args:
base_directory: Base anime directory path. If provided, checks
relative to base_directory/folder/logo.png.
Returns:
bool: True if logo.png exists, False otherwise
"""
if not base_directory:
return False
logo_file = Path(base_directory) / self.folder / "logo.png"
return logo_file.exists() and logo_file.is_file()
def has_fanart(self, base_directory: Optional[str] = None) -> bool:
"""
Check if fanart.jpg file exists for this series.
Args:
base_directory: Base anime directory path. If provided, checks
relative to base_directory/folder/fanart.jpg.
Returns:
bool: True if fanart.jpg exists, False otherwise
"""
if not base_directory:
return False
fanart_file = Path(base_directory) / self.folder / "fanart.jpg"
return fanart_file.exists() and fanart_file.is_file()
@property
def name_with_year(self) -> str:
"""
Get the series name with year appended if available.
Returns a name in the format "Name (Year)" if year is available,
otherwise returns just the name. This should be used for creating
filesystem folders to distinguish series with the same name.
Returns:
str: Name with year in format "Name (Year)", or just name if no year
Example:
>>> serie = Serie("dororo", "Dororo", ..., year=2025)
>>> serie.name_with_year
'Dororo (2025)'
"""
if self._year:
import re
year_suffix = f" ({self._year})"
# Strip ALL trailing year suffixes before appending to prevent duplication
clean_name = re.sub(r'(\s*\(\d{4}\))+\s*$', '', self._name).strip()
return f"{clean_name}{year_suffix}"
return self._name
@property
def sanitized_folder(self) -> str:
"""
Get a filesystem-safe folder name derived from the display name with year.
This property returns a sanitized version of the series name with year
(if available) suitable for use as a filesystem folder name. It removes/
replaces characters that are invalid for filesystems while preserving
Unicode characters.
Use this property when creating folders for the series on disk.
The `folder` property stores the actual folder name used.
Returns:
str: Filesystem-safe folder name based on display name with year
Example:
>>> serie = Serie("attack-on-titan", "Attack on Titan: Final", ..., year=2025)
>>> serie.sanitized_folder
'Attack on Titan Final (2025)'
"""
# Use name_with_year if available, fall back to folder, then key
name_to_sanitize = self.name_with_year or self._folder or self._key
try:
return sanitize_folder_name(name_to_sanitize)
except ValueError:
# Fallback to key if name cannot be sanitized
return sanitize_folder_name(self._key)
def ensure_folder_with_year(self) -> str:
"""Ensure folder name includes year if available.
If the serie has a year and the current folder name doesn't include it,
updates the folder name to include the year in format "Name (Year)".
This method should be called before creating folders or NFO files to
ensure consistent naming across the application.
Returns:
str: The folder name (updated if needed)
Example:
>>> serie = Serie("perfect-blue", "Perfect Blue", ..., folder="Perfect Blue", year=1997)
>>> serie.ensure_folder_with_year()
'Perfect Blue (1997)'
>>> serie.folder # folder property is updated
'Perfect Blue (1997)'
"""
if self._year:
# Check if folder already has year format
year_pattern = f"({self._year})"
if year_pattern not in self._folder:
# Update folder to include year
self._folder = self.sanitized_folder
logger.info(
f"Updated folder name for '{self._key}' to include year: {self._folder}"
)
return self._folder
def to_dict(self):
"""Convert Serie object to dictionary for JSON serialization."""
return {
"key": self.key,
"name": self.name,
"site": self.site,
"folder": self.folder,
"episodeDict": {
str(k): list(v) for k, v in self.episodeDict.items()
},
"year": self.year,
"nfo_path": self.nfo_path
}
@staticmethod
def from_dict(data: dict):
"""Create a Serie object from dictionary."""
# Convert keys to int
episode_dict = {
int(k): v for k, v in data["episodeDict"].items()
}
return Serie(
data["key"],
data["name"],
data["site"],
data["folder"],
episode_dict,
data.get("year"), # Optional year field for backward compatibility
data.get("nfo_path") # Optional nfo_path field
)
def save_to_file(self, filename: str):
"""Save Serie object to JSON file.
.. deprecated::
File-based storage is deprecated. Use database storage via
`AnimeSeriesService.create()` instead. This method will be
removed in v3.0.0.
Args:
filename: Path to save the JSON file
"""
warnings.warn(
"save_to_file() is deprecated and will be removed in v3.0.0. "
"Use database storage via AnimeSeriesService.create() instead.",
DeprecationWarning,
stacklevel=2
)
with open(filename, "w", encoding="utf-8") as file:
json.dump(self.to_dict(), file, indent=4)
@classmethod
def load_from_file(cls, filename: str) -> "Serie":
"""Load Serie object from JSON file.
.. deprecated::
File-based storage is deprecated. Use database storage via
`AnimeSeriesService.get_by_key()` instead. This method will be
removed in v3.0.0.
Args:
filename: Path to load the JSON file from
Returns:
Serie: The loaded Serie object
"""
warnings.warn(
"load_from_file() is deprecated and will be removed in v3.0.0. "
"Use database storage via AnimeSeriesService instead.",
DeprecationWarning,
stacklevel=2
)
with open(filename, "r", encoding="utf-8") as file:
data = json.load(file)
return cls.from_dict(data)

View File

@@ -1,237 +0,0 @@
"""NFO Service Factory Module.
This module provides a centralized factory for creating NFOService instances
with consistent configuration and initialization logic.
The factory supports both direct instantiation and FastAPI dependency injection,
while remaining testable through optional dependency overrides.
"""
import logging
from typing import Optional
from src.config.settings import settings
from src.core.services.nfo_service import NFOService
logger = logging.getLogger(__name__)
class NFOServiceFactory:
"""Factory for creating NFOService instances with consistent configuration.
This factory centralizes NFO service initialization logic that was previously
duplicated across multiple modules (SeriesApp, SeriesManagerService, API endpoints).
The factory follows these precedence rules for configuration:
1. Explicit parameters (highest priority)
2. Environment variables via settings
3. config.json via ConfigService (fallback)
4. Raise error if TMDB API key unavailable
Example:
>>> factory = NFOServiceFactory()
>>> nfo_service = factory.create()
>>> # Or with custom settings:
>>> nfo_service = factory.create(tmdb_api_key="custom_key")
"""
def __init__(self):
"""Initialize the NFO service factory."""
self._config_service = None
def create(
self,
tmdb_api_key: Optional[str] = None,
anime_directory: Optional[str] = None,
image_size: Optional[str] = None,
auto_create: Optional[bool] = None
) -> NFOService:
"""Create an NFOService instance with proper configuration.
This method implements the configuration precedence:
1. Use explicit parameters if provided
2. Fall back to settings (from ENV vars)
3. Fall back to config.json (only if ENV not set)
4. Raise ValueError if TMDB API key still unavailable
Args:
tmdb_api_key: TMDB API key (optional, falls back to settings/config)
anime_directory: Anime directory path (optional, defaults to settings)
image_size: Image size for downloads (optional, defaults to settings)
auto_create: Whether to auto-create NFO files (optional, defaults to settings)
Returns:
NFOService: Configured NFO service instance
Raises:
ValueError: If TMDB API key cannot be determined from any source
Example:
>>> factory = NFOServiceFactory()
>>> # Use all defaults from settings
>>> service = factory.create()
>>> # Override specific settings
>>> service = factory.create(auto_create=False)
"""
# Step 1: Determine TMDB API key with fallback logic
api_key = tmdb_api_key or settings.tmdb_api_key
# Step 2: If no API key in settings, try config.json as fallback
if not api_key:
api_key = self._get_api_key_from_config()
# Step 3: Validate API key is available
if not api_key:
raise ValueError(
"TMDB API key not configured. Set TMDB_API_KEY environment "
"variable or configure in config.json (nfo.tmdb_api_key)."
)
# Step 4: Use provided values or fall back to settings
directory = anime_directory or settings.anime_directory
size = image_size or settings.nfo_image_size
auto = auto_create if auto_create is not None else settings.nfo_auto_create
# Step 5: Create and return the service
logger.debug(
"Creating NFOService: directory=%s, size=%s, auto_create=%s",
directory, size, auto
)
return NFOService(
tmdb_api_key=api_key,
anime_directory=directory,
image_size=size,
auto_create=auto
)
def create_optional(
self,
tmdb_api_key: Optional[str] = None,
anime_directory: Optional[str] = None,
image_size: Optional[str] = None,
auto_create: Optional[bool] = None
) -> Optional[NFOService]:
"""Create an NFOService instance, returning None if configuration unavailable.
This is a convenience method for cases where NFO service is optional.
Unlike create(), this returns None instead of raising ValueError when
the TMDB API key is not configured.
Args:
tmdb_api_key: TMDB API key (optional)
anime_directory: Anime directory path (optional)
image_size: Image size for downloads (optional)
auto_create: Whether to auto-create NFO files (optional)
Returns:
Optional[NFOService]: Configured service or None if key unavailable
Example:
>>> factory = NFOServiceFactory()
>>> service = factory.create_optional()
>>> if service:
... service.create_tvshow_nfo(...)
"""
try:
return self.create(
tmdb_api_key=tmdb_api_key,
anime_directory=anime_directory,
image_size=image_size,
auto_create=auto_create
)
except ValueError as e:
logger.debug("NFO service not available: %s", e)
return None
def _get_api_key_from_config(self) -> Optional[str]:
"""Get TMDB API key from config.json as fallback.
This method is only called when the API key is not in settings
(i.e., not set via environment variable). It provides backward
compatibility with config.json configuration.
Returns:
Optional[str]: API key from config.json, or None if unavailable
"""
try:
# Lazy import to avoid circular dependencies
from src.server.services.config_service import get_config_service
if self._config_service is None:
self._config_service = get_config_service()
config = self._config_service.load_config()
if config.nfo and config.nfo.tmdb_api_key:
logger.debug("Using TMDB API key from config.json")
return config.nfo.tmdb_api_key
except Exception as e: # pylint: disable=broad-except
logger.debug("Could not load API key from config.json: %s", e)
return None
# Global factory instance for convenience
_factory_instance: Optional[NFOServiceFactory] = None
def get_nfo_factory() -> NFOServiceFactory:
"""Get the global NFO service factory instance.
This function provides a singleton factory instance for the application.
The singleton pattern here is for the factory itself (which is stateless),
not for the NFO service instances it creates.
Returns:
NFOServiceFactory: The global factory instance
Example:
>>> factory = get_nfo_factory()
>>> service = factory.create()
"""
global _factory_instance
if _factory_instance is None:
_factory_instance = NFOServiceFactory()
return _factory_instance
def create_nfo_service(
tmdb_api_key: Optional[str] = None,
anime_directory: Optional[str] = None,
image_size: Optional[str] = None,
auto_create: Optional[bool] = None
) -> NFOService:
"""Convenience function to create an NFOService instance.
This is a shorthand for get_nfo_factory().create() that can be used
when you need a quick NFO service instance without interacting with
the factory directly.
Args:
tmdb_api_key: TMDB API key (optional)
anime_directory: Anime directory path (optional)
image_size: Image size for downloads (optional)
auto_create: Whether to auto-create NFO files (optional)
Returns:
NFOService: Configured NFO service instance
Raises:
ValueError: If TMDB API key cannot be determined
Example:
>>> service = create_nfo_service()
>>> # Or with custom settings:
>>> service = create_nfo_service(auto_create=False)
"""
factory = get_nfo_factory()
return factory.create(
tmdb_api_key=tmdb_api_key,
anime_directory=anime_directory,
image_size=image_size,
auto_create=auto_create
)

View File

@@ -1,211 +0,0 @@
"""NFO repair service for detecting and fixing incomplete tvshow.nfo files.
This module provides utilities to check whether an existing ``tvshow.nfo``
contains all required tags and to trigger a repair (re-fetch from TMDB) when
needed.
Example:
>>> service = NfoRepairService(nfo_service)
>>> repaired = await service.repair_series(Path("/anime/Attack on Titan"), "Attack on Titan")
"""
import logging
from pathlib import Path
from typing import Dict, List
from lxml import etree
from src.core.services.nfo_service import NFOService
logger = logging.getLogger(__name__)
# XPath relative to <tvshow> root → human-readable label
REQUIRED_TAGS: Dict[str, str] = {
"./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",
}
def parse_nfo_tags(nfo_path: Path) -> Dict[str, List[str]]:
"""Parse an existing tvshow.nfo and return present tag values.
Evaluates every XPath in :data:`REQUIRED_TAGS` against the document root
and collects all non-empty text values.
Args:
nfo_path: Absolute path to the ``tvshow.nfo`` file.
Returns:
Mapping of XPath expression → list of non-empty text strings found in
the document. Returns an empty dict on any error (missing file,
invalid XML, permission error).
Example:
>>> tags = parse_nfo_tags(Path("/anime/Attack on Titan/tvshow.nfo"))
>>> tags.get("./title")
['Attack on Titan']
"""
if not nfo_path.exists():
logger.debug("NFO file not found: %s", nfo_path)
return {}
try:
tree = etree.parse(str(nfo_path))
root = tree.getroot()
result: Dict[str, List[str]] = {}
for xpath in REQUIRED_TAGS:
elements = root.findall(xpath)
result[xpath] = [e.text for e in elements if e.text]
return result
except etree.XMLSyntaxError as exc:
logger.warning("Malformed XML in %s: %s", nfo_path, exc)
return {}
except Exception as exc: # pylint: disable=broad-except
logger.warning("Unexpected error parsing %s: %s", nfo_path, exc)
return {}
def find_missing_tags(nfo_path: Path) -> List[str]:
"""Return tags that are absent or empty in the NFO.
Args:
nfo_path: Absolute path to the ``tvshow.nfo`` file.
Returns:
List of human-readable tag labels (values from :data:`REQUIRED_TAGS`)
whose XPath matched no elements or only elements with empty text.
An empty list means the NFO is complete.
Example:
>>> missing = find_missing_tags(Path("/anime/series/tvshow.nfo"))
>>> if missing:
... print("Missing:", missing)
"""
parsed = parse_nfo_tags(nfo_path)
missing: List[str] = []
for xpath, label in REQUIRED_TAGS.items():
if not parsed.get(xpath):
missing.append(label)
return missing
def nfo_needs_repair(nfo_path: Path) -> bool:
"""Return ``True`` if the NFO is missing any required tag.
Args:
nfo_path: Absolute path to the ``tvshow.nfo`` file.
Returns:
True if :func:`find_missing_tags` returns a non-empty list.
Example:
>>> if nfo_needs_repair(Path("/anime/series/tvshow.nfo")):
... await service.repair_series(series_path, series_name)
"""
return bool(find_missing_tags(nfo_path))
def _read_tmdb_id(nfo_path: Path) -> int | None:
"""Return the TMDB ID stored in an existing NFO, or ``None``.
Checks both ``<tmdbid>`` and ``<uniqueid type="tmdb">`` elements.
Args:
nfo_path: Absolute path to the ``tvshow.nfo`` file.
Returns:
Integer TMDB ID, or ``None`` if not found or not parseable.
"""
if not nfo_path.exists():
return None
try:
root = etree.parse(str(nfo_path)).getroot()
for uniqueid in root.findall(".//uniqueid"):
if uniqueid.get("type") == "tmdb" and uniqueid.text:
return int(uniqueid.text)
tmdbid_elem = root.find(".//tmdbid")
if tmdbid_elem is not None and tmdbid_elem.text:
return int(tmdbid_elem.text)
except (etree.XMLSyntaxError, ValueError):
pass
except Exception: # pylint: disable=broad-except
pass
return None
class NfoRepairService:
"""Service that detects and repairs incomplete tvshow.nfo files.
Wraps the module-level helpers with structured logging and delegates
the actual TMDB re-fetch to an injected :class:`NFOService` instance.
Attributes:
_nfo_service: The underlying NFOService used to update NFOs.
"""
def __init__(self, nfo_service: NFOService) -> None:
"""Initialise the repair service.
Args:
nfo_service: Configured :class:`NFOService` instance.
"""
self._nfo_service = nfo_service
async def repair_series(self, series_path: Path, series_name: str) -> bool:
"""Repair an NFO file if required tags are missing.
Checks ``{series_path}/tvshow.nfo`` for completeness. If tags are
missing, logs them and calls
``NFOService.update_tvshow_nfo(series_name)`` to re-fetch metadata
from TMDB.
Args:
series_path: Absolute path to the series folder.
series_name: Series folder name used as the identifier for
:meth:`NFOService.update_tvshow_nfo`.
Returns:
``True`` if a repair was triggered, ``False`` if the NFO was
already complete (or did not exist).
"""
nfo_path = series_path / "tvshow.nfo"
missing = find_missing_tags(nfo_path)
if not missing:
logger.info(
"NFO repair skipped — complete: %s",
series_name,
)
return False
logger.info(
"NFO repair triggered for %s — missing tags: %s",
series_name,
", ".join(missing),
)
await self._nfo_service.update_tvshow_nfo(
series_name,
download_media=False,
)
logger.info("NFO repair completed: %s", series_name)
return True

View File

@@ -1,588 +0,0 @@
"""NFO service for creating and managing tvshow.nfo files.
This service orchestrates TMDB API calls, XML generation, and media downloads
to create complete NFO metadata for TV series.
Example:
>>> nfo_service = NFOService(tmdb_api_key="key", anime_directory="/anime")
>>> await nfo_service.create_tvshow_nfo("Attack on Titan", "/anime/aot", 2013)
"""
import logging
import re
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from lxml import etree
from src.core.services.tmdb_client import TMDBAPIError, TMDBClient
from src.core.utils.image_downloader import ImageDownloader
from src.core.utils.nfo_generator import generate_tvshow_nfo
from src.core.utils.nfo_mapper import tmdb_to_nfo_model
logger = logging.getLogger(__name__)
class NFOService:
"""Service for creating and managing tvshow.nfo files.
Attributes:
tmdb_client: TMDB API client
image_downloader: Image downloader utility
anime_directory: Base directory for anime series
"""
def __init__(
self,
tmdb_api_key: str,
anime_directory: str,
image_size: str = "original",
auto_create: bool = True
):
"""Initialize NFO service.
Args:
tmdb_api_key: TMDB API key
anime_directory: Base anime directory path
image_size: Image size to download (original, w500, etc.)
auto_create: Whether to auto-create NFOs
"""
self.tmdb_client = TMDBClient(api_key=tmdb_api_key)
self.image_downloader = ImageDownloader()
self.anime_directory = Path(anime_directory)
self.image_size = image_size
self.auto_create = auto_create
def has_nfo(self, serie_folder: str) -> bool:
"""Check if tvshow.nfo exists for a series.
Args:
serie_folder: Series folder name
Returns:
True if NFO file exists
"""
nfo_path = self.anime_directory / serie_folder / "tvshow.nfo"
return nfo_path.exists()
@staticmethod
def _extract_year_from_name(serie_name: str) -> Tuple[str, Optional[int]]:
"""Extract year from series name if present in format 'Name (YYYY)'.
Args:
serie_name: Series name, possibly with year in parentheses
Returns:
Tuple of (clean_name, year)
- clean_name: Series name without year
- year: Extracted year or None
Examples:
>>> _extract_year_from_name("Attack on Titan (2013)")
("Attack on Titan", 2013)
>>> _extract_year_from_name("Attack on Titan")
("Attack on Titan", None)
"""
# Match the last year in parentheses at the end: (YYYY)
match = re.search(r'\((\d{4})\)\s*$', serie_name)
if match:
year = int(match.group(1))
# Strip ALL trailing year suffixes to get a fully clean name
clean_name = re.sub(r'(\s*\(\d{4}\))+\s*$', '', serie_name).strip()
return clean_name, year
return serie_name, None
async def check_nfo_exists(self, serie_folder: str) -> bool:
"""Check if tvshow.nfo exists for a series.
Args:
serie_folder: Series folder name
Returns:
True if tvshow.nfo exists
"""
nfo_path = self.anime_directory / serie_folder / "tvshow.nfo"
return nfo_path.exists()
async def create_tvshow_nfo(
self,
serie_name: str,
serie_folder: str,
year: Optional[int] = None,
download_poster: bool = True,
download_logo: bool = True,
download_fanart: bool = True
) -> Path:
"""Create tvshow.nfo by scraping TMDB.
Args:
serie_name: Name of the series to search (may include year in parentheses)
serie_folder: Series folder name
year: Release year (helps narrow search). If None and name contains year,
year will be auto-extracted
download_poster: Whether to download poster.jpg
download_logo: Whether to download logo.png
download_fanart: Whether to download fanart.jpg
Returns:
Path to created NFO file
Raises:
TMDBAPIError: If TMDB API fails
FileNotFoundError: If series folder doesn't exist
"""
# Extract year from name if not provided
clean_name, extracted_year = self._extract_year_from_name(serie_name)
if year is None and extracted_year is not None:
year = extracted_year
logger.info("Extracted year %s from series name", year)
# Use clean name for search
search_name = clean_name
logger.info("Creating NFO for %s (year: %s)", search_name, year)
folder_path = self.anime_directory / serie_folder
if not folder_path.exists():
logger.info("Creating series folder: %s", folder_path)
folder_path.mkdir(parents=True, exist_ok=True)
try:
await self.tmdb_client._ensure_session()
# Search for TV show with clean name (without year)
logger.debug("Searching TMDB for: %s", search_name)
search_results = await self.tmdb_client.search_tv_show(search_name)
if not search_results.get("results"):
raise TMDBAPIError(f"No results found for: {search_name}")
# Find best match (consider year if provided)
tv_show = self._find_best_match(search_results["results"], search_name, year)
tv_id = tv_show["id"]
logger.info("Found match: %s (ID: %s)", tv_show['name'], tv_id)
# Get detailed information with multi-language image support
details = await self.tmdb_client.get_tv_show_details(
tv_id,
append_to_response="credits,external_ids,images"
)
# Get content ratings for FSK
content_ratings = await self.tmdb_client.get_tv_show_content_ratings(tv_id)
# Enrich with fallback languages for empty overview/tagline
# Pass search result overview as last resort fallback
search_overview = tv_show.get("overview") or None
if not search_overview:
try:
logger.debug(
"No overview in German search result, trying en-US search fallback for: %s",
search_name,
)
en_search_results = await self.tmdb_client.search_tv_show(
search_name,
language="en-US",
)
if en_search_results.get("results"):
en_match = self._find_best_match(
en_search_results["results"], search_name, year
)
search_overview = en_match.get("overview") or None
if search_overview:
logger.info(
"Using en-US search overview fallback for %s",
search_name,
)
except (TMDBAPIError, Exception) as exc:
logger.warning(
"Failed en-US search fallback for overview: %s",
exc,
)
details = await self._enrich_details_with_fallback(
details, search_overview=search_overview
)
# Convert TMDB data to TVShowNFO model
nfo_model = tmdb_to_nfo_model(
details,
content_ratings,
self.tmdb_client.get_image_url,
self.image_size,
)
# Generate XML
nfo_xml = generate_tvshow_nfo(nfo_model)
# Save NFO file
nfo_path = folder_path / "tvshow.nfo"
nfo_path.write_text(nfo_xml, encoding="utf-8")
logger.info("Created NFO: %s", nfo_path)
# Download media files
await self._download_media_files(
details,
folder_path,
download_poster=download_poster,
download_logo=download_logo,
download_fanart=download_fanart
)
return nfo_path
finally:
await self.tmdb_client.close()
async def update_tvshow_nfo(
self,
serie_folder: str,
download_media: bool = True
) -> Path:
"""Update existing tvshow.nfo with fresh data from TMDB.
Args:
serie_folder: Series folder name
download_media: Whether to re-download media files
Returns:
Path to updated NFO file
Raises:
FileNotFoundError: If NFO file doesn't exist
TMDBAPIError: If TMDB API fails or no TMDB ID found in NFO
"""
folder_path = self.anime_directory / serie_folder
nfo_path = folder_path / "tvshow.nfo"
if not nfo_path.exists():
raise FileNotFoundError(f"NFO file not found: {nfo_path}")
logger.info("Updating NFO for %s", serie_folder)
# Parse existing NFO to extract TMDB ID
try:
tree = etree.parse(str(nfo_path))
root = tree.getroot()
# Try to find TMDB ID from uniqueid elements
tmdb_id = None
for uniqueid in root.findall(".//uniqueid"):
if uniqueid.get("type") == "tmdb":
tmdb_id = int(uniqueid.text)
break
# Fallback: check for tmdbid element
if tmdb_id is None:
tmdbid_elem = root.find(".//tmdbid")
if tmdbid_elem is not None and tmdbid_elem.text:
tmdb_id = int(tmdbid_elem.text)
if tmdb_id is None:
raise TMDBAPIError(
f"No TMDB ID found in existing NFO. "
f"Delete the NFO and create a new one instead."
)
logger.debug("Found TMDB ID: %s", tmdb_id)
except etree.XMLSyntaxError as e:
raise TMDBAPIError(f"Invalid XML in NFO file: {e}")
except ValueError as e:
raise TMDBAPIError(f"Invalid TMDB ID format in NFO: {e}")
try:
await self.tmdb_client._ensure_session()
logger.debug("Fetching fresh data for TMDB ID: %s", tmdb_id)
details = await self.tmdb_client.get_tv_show_details(
tmdb_id,
append_to_response="credits,external_ids,images"
)
# Get content ratings for FSK
content_ratings = await self.tmdb_client.get_tv_show_content_ratings(tmdb_id)
# Enrich with fallback languages for empty overview/tagline
details = await self._enrich_details_with_fallback(details)
# Convert TMDB data to TVShowNFO model
nfo_model = tmdb_to_nfo_model(
details,
content_ratings,
self.tmdb_client.get_image_url,
self.image_size,
)
# Generate XML
nfo_xml = generate_tvshow_nfo(nfo_model)
# Save updated NFO file
nfo_path.write_text(nfo_xml, encoding="utf-8")
logger.info("Updated NFO: %s", nfo_path)
# Re-download media files if requested
if download_media:
await self._download_media_files(
details,
folder_path,
download_poster=True,
download_logo=True,
download_fanart=True
)
return nfo_path
finally:
await self.tmdb_client.close()
def parse_nfo_ids(self, nfo_path: Path) -> Dict[str, Optional[int]]:
"""Parse TMDB ID and TVDB ID from an existing NFO file.
Args:
nfo_path: Path to tvshow.nfo file
Returns:
Dictionary with 'tmdb_id' and 'tvdb_id' keys.
Values are integers if found, None otherwise.
Example:
>>> ids = nfo_service.parse_nfo_ids(Path("/anime/series/tvshow.nfo"))
>>> print(ids)
{'tmdb_id': 1429, 'tvdb_id': 79168}
"""
result = {"tmdb_id": None, "tvdb_id": None}
if not nfo_path.exists():
logger.debug("NFO file not found: %s", nfo_path)
return result
try:
tree = etree.parse(str(nfo_path))
root = tree.getroot()
# Try to find TMDB ID from uniqueid elements first
for uniqueid in root.findall(".//uniqueid"):
uid_type = uniqueid.get("type")
uid_text = uniqueid.text
if uid_type == "tmdb" and uid_text:
try:
result["tmdb_id"] = int(uid_text)
except ValueError:
logger.warning(
f"Invalid TMDB ID format in NFO: {uid_text}"
)
elif uid_type == "tvdb" and uid_text:
try:
result["tvdb_id"] = int(uid_text)
except ValueError:
logger.warning(
f"Invalid TVDB ID format in NFO: {uid_text}"
)
# Fallback: check for dedicated tmdbid/tvdbid elements
if result["tmdb_id"] is None:
tmdbid_elem = root.find(".//tmdbid")
if tmdbid_elem is not None and tmdbid_elem.text:
try:
result["tmdb_id"] = int(tmdbid_elem.text)
except ValueError:
logger.warning(
f"Invalid TMDB ID format in tmdbid element: "
f"{tmdbid_elem.text}"
)
if result["tvdb_id"] is None:
tvdbid_elem = root.find(".//tvdbid")
if tvdbid_elem is not None and tvdbid_elem.text:
try:
result["tvdb_id"] = int(tvdbid_elem.text)
except ValueError:
logger.warning(
f"Invalid TVDB ID format in tvdbid element: "
f"{tvdbid_elem.text}"
)
logger.debug(
f"Parsed IDs from NFO: {nfo_path.name} - "
f"TMDB: {result['tmdb_id']}, TVDB: {result['tvdb_id']}"
)
except etree.XMLSyntaxError as e:
logger.error("Invalid XML in NFO file %s: %s", nfo_path, e)
except Exception as e: # pylint: disable=broad-except
logger.error("Error parsing NFO file %s: %s", nfo_path, e)
return result
async def _enrich_details_with_fallback(
self,
details: Dict[str, Any],
search_overview: Optional[str] = None,
) -> Dict[str, Any]:
"""Enrich TMDB details with fallback languages for empty fields.
When requesting details in ``de-DE``, some anime have an empty
``overview`` (and potentially other translatable fields). This
method detects empty values and fills them from alternative
languages (``en-US``, then ``ja-JP``) so that NFO files always
contain a ``plot`` regardless of whether the German translation
exists. As a last resort, the overview from the search result
is used.
Args:
details: TMDB TV show details (language ``de-DE``).
search_overview: Overview text from the TMDB search result,
used as a final fallback if all language-specific
requests fail or return empty overviews.
Returns:
The *same* dict, mutated in-place with fallback values
where needed.
"""
overview = details.get("overview") or ""
if overview:
# Overview already populated nothing to do.
return details
tmdb_id = details.get("id")
fallback_languages = ["en-US", "ja-JP"]
for lang in fallback_languages:
if details.get("overview"):
break
logger.debug(
"Trying %s fallback for TMDB ID %s",
lang, tmdb_id,
)
try:
lang_details = await self.tmdb_client.get_tv_show_details(
tmdb_id,
language=lang,
)
if not details.get("overview") and lang_details.get("overview"):
details["overview"] = lang_details["overview"]
logger.info(
"Used %s overview fallback for TMDB ID %s",
lang, tmdb_id,
)
# Also fill tagline if missing
if not details.get("tagline") and lang_details.get("tagline"):
details["tagline"] = lang_details["tagline"]
except Exception as exc: # pylint: disable=broad-except
logger.warning(
"Failed to fetch %s fallback for TMDB ID %s: %s",
lang, tmdb_id, exc,
)
# Last resort: use search result overview
if not details.get("overview") and search_overview:
details["overview"] = search_overview
logger.info(
"Used search result overview fallback for TMDB ID %s",
tmdb_id,
)
return details
def _find_best_match(
self,
results: List[Dict[str, Any]],
query: str,
year: Optional[int] = None
) -> Dict[str, Any]:
"""Find best matching TV show from search results.
Args:
results: TMDB search results
query: Original search query
year: Expected release year
Returns:
Best matching TV show data
"""
if not results:
raise TMDBAPIError("No search results to match")
# If year is provided, try to find exact match
if year:
for result in results:
first_air_date = result.get("first_air_date", "")
if first_air_date.startswith(str(year)):
logger.debug("Found year match: %s (%s)", result['name'], first_air_date)
return result
# Return first result (usually best match)
return results[0]
async def _download_media_files(
self,
tmdb_data: Dict[str, Any],
folder_path: Path,
download_poster: bool = True,
download_logo: bool = True,
download_fanart: bool = True
) -> Dict[str, bool]:
"""Download media files (poster, logo, fanart).
Args:
tmdb_data: TMDB TV show details
folder_path: Series folder path
download_poster: Download poster.jpg
download_logo: Download logo.png
download_fanart: Download fanart.jpg
Returns:
Dictionary with download status for each file
"""
poster_url = None
logo_url = None
fanart_url = None
# Get poster URL
if download_poster and tmdb_data.get("poster_path"):
poster_url = self.tmdb_client.get_image_url(
tmdb_data["poster_path"],
self.image_size
)
# Get fanart URL
if download_fanart and tmdb_data.get("backdrop_path"):
fanart_url = self.tmdb_client.get_image_url(
tmdb_data["backdrop_path"],
"original" # Always use original for fanart
)
# Get logo URL
if download_logo:
images_data = tmdb_data.get("images", {})
logos = images_data.get("logos", [])
if logos:
logo_url = self.tmdb_client.get_image_url(
logos[0]["file_path"],
"original" # Logos should be original size
)
# Download all media concurrently
results = await self.image_downloader.download_all_media(
folder_path,
poster_url=poster_url,
logo_url=logo_url,
fanart_url=fanart_url,
skip_existing=True
)
logger.info("Media download results: %s", results)
return results
async def close(self):
"""Clean up resources."""
await self.tmdb_client.close()

View File

@@ -1,279 +0,0 @@
"""Service for managing series with NFO metadata support.
This service layer component orchestrates SerieList (core entity) with
NFOService to provide automatic NFO creation and updates during series scans.
This follows clean architecture principles by keeping the core entities
independent of external services like TMDB API.
"""
import asyncio
import logging
from pathlib import Path
from typing import Optional
from src.config.settings import settings
from src.core.entities.SerieList import SerieList
from src.core.services.nfo_service import NFOService
from src.core.services.tmdb_client import TMDBAPIError
logger = logging.getLogger(__name__)
class SeriesManagerService:
"""Service for managing series with optional NFO metadata support.
This service wraps SerieList and adds NFO creation/update capabilities
based on configuration settings. It maintains clean separation between
core entities and external services.
Attributes:
serie_list: SerieList instance for series management
nfo_service: Optional NFOService for metadata management
auto_create_nfo: Whether to auto-create NFO files
update_on_scan: Whether to update existing NFO files
"""
def __init__(
self,
anime_directory: str,
tmdb_api_key: Optional[str] = None,
auto_create_nfo: bool = False,
update_on_scan: bool = False,
download_poster: bool = True,
download_logo: bool = True,
download_fanart: bool = True,
image_size: str = "original"
):
"""Initialize series manager service.
Args:
anime_directory: Base directory for anime series
tmdb_api_key: TMDB API key (optional, required for NFO features)
auto_create_nfo: Automatically create NFO files when scanning
update_on_scan: Update existing NFO files when scanning
download_poster: Download poster.jpg
download_logo: Download logo.png
download_fanart: Download fanart.jpg
image_size: Image size to download
"""
self.anime_directory = anime_directory
# Skip automatic folder scanning - we load from database instead
self.serie_list = SerieList(anime_directory, skip_load=True)
# NFO configuration
self.auto_create_nfo = auto_create_nfo
self.update_on_scan = update_on_scan
self.download_poster = download_poster
self.download_logo = download_logo
self.download_fanart = download_fanart
# Initialize NFO service if API key provided and NFO features enabled
self.nfo_service: Optional[NFOService] = None
if tmdb_api_key and (auto_create_nfo or update_on_scan):
try:
from src.core.services.nfo_factory import get_nfo_factory
factory = get_nfo_factory()
self.nfo_service = factory.create(
tmdb_api_key=tmdb_api_key,
anime_directory=anime_directory,
image_size=image_size,
auto_create=auto_create_nfo
)
logger.info("NFO service initialized (auto_create=%s, update=%s)",
auto_create_nfo, update_on_scan)
except (ValueError, Exception) as e: # pylint: disable=broad-except
logger.warning(
"Failed to initialize NFO service: %s", str(e)
)
self.nfo_service = None
elif auto_create_nfo or update_on_scan:
logger.warning(
"NFO features requested but TMDB_API_KEY not provided. "
"NFO creation/updates will be skipped."
)
@classmethod
def from_settings(cls) -> "SeriesManagerService":
"""Create SeriesManagerService from application settings.
Returns:
Configured SeriesManagerService instance
"""
return cls(
anime_directory=settings.anime_directory,
tmdb_api_key=settings.tmdb_api_key,
auto_create_nfo=settings.nfo_auto_create,
update_on_scan=settings.nfo_update_on_scan,
download_poster=settings.nfo_download_poster,
download_logo=settings.nfo_download_logo,
download_fanart=settings.nfo_download_fanart,
image_size=settings.nfo_image_size
)
async def process_nfo_for_series(
self,
serie_folder: str,
serie_name: str,
serie_key: str,
year: Optional[int] = None
):
"""Process NFO file for a series (create or update).
Args:
serie_folder: Series folder name
serie_name: Series display name
serie_key: Series unique identifier for database updates
year: Release year (helps with TMDB matching)
"""
if not self.nfo_service:
return
try:
folder_path = Path(self.anime_directory) / serie_folder
nfo_path = folder_path / "tvshow.nfo"
nfo_exists = await self.nfo_service.check_nfo_exists(serie_folder)
# If NFO exists, parse IDs and update database
if nfo_exists:
logger.debug("Parsing IDs from existing NFO for '%s'", serie_name)
ids = self.nfo_service.parse_nfo_ids(nfo_path)
if ids["tmdb_id"] or ids["tvdb_id"]:
# Update database using service layer
from datetime import datetime, timezone
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, serie_key)
if series:
now = datetime.now(timezone.utc)
# Prepare update fields
update_fields = {
"has_nfo": True,
"nfo_updated_at": now,
}
if series.nfo_created_at is None:
update_fields["nfo_created_at"] = now
if ids["tmdb_id"] is not None:
update_fields["tmdb_id"] = ids["tmdb_id"]
logger.debug(
f"Updated TMDB ID for '{serie_name}': "
f"{ids['tmdb_id']}"
)
if ids["tvdb_id"] is not None:
update_fields["tvdb_id"] = ids["tvdb_id"]
logger.debug(
f"Updated TVDB ID for '{serie_name}': "
f"{ids['tvdb_id']}"
)
# Use service layer for update
await AnimeSeriesService.update(db, series.id, **update_fields)
await db.commit()
logger.info(
f"Updated database with IDs from NFO for "
f"'{serie_name}' - TMDB: {ids['tmdb_id']}, "
f"TVDB: {ids['tvdb_id']}"
)
else:
logger.warning(
f"Series not found in database for NFO ID "
f"update: {serie_key}"
)
# Create NFO file only if it doesn't exist and auto_create enabled
if not nfo_exists and self.auto_create_nfo:
logger.info(
f"Creating NFO for '{serie_name}' ({serie_folder})"
)
await self.nfo_service.create_tvshow_nfo(
serie_name=serie_name,
serie_folder=serie_folder,
year=year,
download_poster=self.download_poster,
download_logo=self.download_logo,
download_fanart=self.download_fanart
)
logger.info("Successfully created NFO for '%s'", serie_name)
elif nfo_exists:
logger.debug(
f"NFO exists for '{serie_name}', skipping download"
)
except TMDBAPIError as e:
logger.error("TMDB API error processing '%s': %s", serie_name, e)
except Exception as e:
logger.error(
f"Unexpected error processing NFO for '{serie_name}': {e}",
exc_info=True
)
async def scan_and_process_nfo(self):
"""Scan all series and process NFO files based on configuration.
This method:
1. Loads series from database (avoiding filesystem scan)
2. For each series with existing NFO, reads TMDB/TVDB IDs
and updates database
3. For each series without NFO (if auto_create=True), creates one
4. For each series with NFO (if update_on_scan=True), updates it
5. Runs operations concurrently for better performance
"""
if not self.nfo_service:
logger.info("NFO service not enabled, skipping NFO processing")
return
# Import database dependencies
from src.server.database.connection import get_db_session
from src.server.database.service import AnimeSeriesService
# Load series from database (not from filesystem)
async with get_db_session() as db:
anime_series_list = await AnimeSeriesService.get_all(
db, with_episodes=False
)
if not anime_series_list:
logger.info("No series found in database to process")
return
logger.info("Processing NFO for %s series...", len(anime_series_list))
# Create tasks for concurrent processing
# Each task creates its own database session
tasks = []
for anime_series in anime_series_list:
# Extract year if available
year = getattr(anime_series, 'year', None)
task = self.process_nfo_for_series(
serie_folder=anime_series.folder,
serie_name=anime_series.name,
serie_key=anime_series.key,
year=year
)
tasks.append(task)
# Process in batches to avoid overwhelming TMDB API
batch_size = 5
for i in range(0, len(tasks), batch_size):
batch = tasks[i:i + batch_size]
await asyncio.gather(*batch, return_exceptions=True)
# Small delay between batches to respect rate limits
if i + batch_size < len(tasks):
await asyncio.sleep(2)
async def close(self):
"""Clean up resources."""
if self.nfo_service:
await self.nfo_service.close()

File diff suppressed because it is too large Load Diff

View File

@@ -19,12 +19,10 @@ from typing import Any, Callable, Dict, List, Optional
from events import Events
from src.config.settings import settings
from src.core.entities.SerieList import SerieList
from src.core.entities.series import Serie
from src.core.providers.provider_factory import Loaders
from src.core.SerieScanner import SerieScanner
from src.core.services.nfo_service import NFOService
from src.core.services.tmdb_client import TMDBAPIError
from src.server.database.SerieList import SerieList
from src.server.database.models import AnimeSeries
from src.server.providers.provider_factory import Loaders
from src.server.SerieScanner import SerieScanner
logger = logging.getLogger(__name__)
@@ -143,16 +141,12 @@ class SeriesApp:
def __init__(
self,
directory_to_search: str,
db_lookup: Optional[Callable[[str], Optional["Serie"]]] = None,
):
"""
Initialize SeriesApp.
Args:
directory_to_search: Base directory for anime series
db_lookup: Optional callable ``(folder_name) -> Serie | None``
passed through to ``SerieScanner`` as a fallback key source
when no local ``key`` or ``data`` file exists.
"""
self.directory_to_search = directory_to_search
@@ -166,32 +160,18 @@ class SeriesApp:
self.loaders = Loaders()
self.loader = self.loaders.GetLoader(key="aniworld.to")
self.serie_scanner = SerieScanner(
directory_to_search, self.loader, db_lookup=db_lookup
directory_to_search,
self.loader,
)
# Skip automatic loading from data files - series will be loaded
# from database by the service layer during application setup
self.list = SerieList(self.directory_to_search, skip_load=True)
# Series will be loaded from database by the service layer during application setup
self.list = SerieList(self.directory_to_search)
self.series_list: List[Any] = []
# Initialize empty list - series loaded later via load_series_from_list()
# No need to call _init_list_sync() anymore
# Initialize NFO service if a TMDB API key is configured
self.nfo_service: Optional[NFOService] = None
try:
from src.core.services.nfo_factory import get_nfo_factory
factory = get_nfo_factory()
self.nfo_service = factory.create()
logger.info("NFO service initialized successfully")
except ValueError:
logger.info(
"NFO service not available — TMDB API key not configured"
)
self.nfo_service = None
except Exception as e: # pylint: disable=broad-except
logger.warning("Failed to initialize NFO service: %s", str(e))
self.nfo_service = None
# NFO service removed - metadata handling moved to server layer
self.nfo_service = None
logger.info(
"SeriesApp initialized for directory: %s",
directory_to_search,
@@ -353,95 +333,6 @@ class SeriesApp:
)
return False
# Check and create NFO files if needed
if self.nfo_service and settings.nfo_auto_create:
try:
# Check if NFO exists
nfo_exists = await self.nfo_service.check_nfo_exists(
serie_folder
)
if not nfo_exists:
logger.info(
"NFO not found for %s, creating metadata...",
serie_folder
)
# Fire NFO creation started event
self._events.download_status(
DownloadStatusEventArgs(
serie_folder=serie_folder,
key=key,
season=season,
episode=episode,
status="nfo_creating",
message="Creating NFO metadata...",
item_id=item_id,
)
)
# Create NFO and download media files
try:
# Use folder name as series name
await self.nfo_service.create_tvshow_nfo(
serie_name=serie_folder,
serie_folder=serie_folder,
download_poster=settings.nfo_download_poster,
download_logo=settings.nfo_download_logo,
download_fanart=settings.nfo_download_fanart
)
logger.info(
"NFO and media files created for %s",
serie_folder
)
# Fire NFO creation completed event
self._events.download_status(
DownloadStatusEventArgs(
serie_folder=serie_folder,
key=key,
season=season,
episode=episode,
status="nfo_completed",
message="NFO metadata created",
item_id=item_id,
)
)
except TMDBAPIError as tmdb_error:
logger.warning(
"Failed to create NFO for %s: %s",
serie_folder,
str(tmdb_error)
)
# Fire failed event (but continue with download)
self._events.download_status(
DownloadStatusEventArgs(
serie_folder=serie_folder,
key=key,
season=season,
episode=episode,
status="nfo_failed",
message=(
f"NFO creation failed: "
f"{str(tmdb_error)}"
),
item_id=item_id,
)
)
else:
logger.debug("NFO already exists for %s", serie_folder)
except Exception as nfo_error: # pylint: disable=broad-except
logger.error(
"Error checking/creating NFO for %s: %s",
serie_folder,
str(nfo_error),
exc_info=True
)
# Don't fail the download if NFO creation fails
try:
def download_progress_handler(progress_info):
"""Handle download progress events from loader."""
@@ -762,7 +653,7 @@ class SeriesApp:
"""
await self._init_list()
def _get_serie_by_key(self, key: str) -> Optional[Serie]:
def _get_serie_by_key(self, key: str) -> Optional[AnimeSeries]:
"""
Get a series by its unique provider key.
@@ -773,7 +664,7 @@ class SeriesApp:
"attack-on-titan")
Returns:
The Serie instance if found, None otherwise
The AnimeSeries instance if found, None otherwise
Note:
This method uses the SerieList.get_by_key() method which
@@ -781,39 +672,40 @@ class SeriesApp:
"""
return self.list.get_by_key(key)
def get_all_series_from_data_files(self) -> List[Serie]:
def get_all_series_from_data_files(self) -> List[AnimeSeries]:
"""
Get all series from data files in the anime directory.
Scans the directory_to_search for all 'data' files and loads
the Serie metadata from each file. This method is synchronous
the AnimeSeries metadata from each file. This method is synchronous
and can be wrapped with asyncio.to_thread if needed for async
contexts.
Returns:
List of Serie objects found in data files. Returns an empty
List of AnimeSeries objects found in data files. Returns an empty
list if no data files are found or if the directory doesn't
exist.
Example:
series_app = SeriesApp("/path/to/anime")
all_series = series_app.get_all_series_from_data_files()
for serie in all_series:
print(f"Found: {serie.name} (key={serie.key})")
for anime in all_series:
print(f"Found: {anime.name} (key={anime.key})")
"""
logger.info(
"Scanning for data files in directory: %s",
self.directory_to_search
)
# Create a fresh SerieList instance for file-based loading
# This ensures we get all series from data files without
# interfering with the main instance's state
all_series: List[AnimeSeries] = []
try:
temp_list = SerieList(
self.directory_to_search,
skip_load=False # Allow automatic loading
)
if not os.path.isdir(self.directory_to_search):
logger.warning(
"Directory does not exist: %s",
self.directory_to_search
)
return []
except (OSError, ValueError) as e:
logger.error(
"Failed to scan directory for data files: %s",
@@ -822,8 +714,53 @@ class SeriesApp:
)
return []
# Get all series from the temporary list
all_series = temp_list.get_all()
try:
for folder_name in os.listdir(self.directory_to_search):
folder_path = os.path.join(
self.directory_to_search, folder_name
)
if not os.path.isdir(folder_path):
continue
data_file = os.path.join(folder_path, "data")
if not os.path.isfile(data_file):
continue
series_data = _load_data_file(data_file)
if series_data is None:
continue
key = series_data.get("key")
if not key:
logger.warning(
"Data file missing key, skipping: %s",
data_file
)
continue
anime = AnimeSeries(
key=key,
name=series_data.get("name") or folder_name,
site=series_data.get("site", "https://aniworld.to"),
folder=series_data.get("folder", folder_name),
year=series_data.get("year"),
)
episode_dict = series_data.get("episodeDict", {})
if episode_dict:
anime._episode_dict_cache = {
int(season): episodes
for season, episodes in episode_dict.items()
}
all_series.append(anime)
except (OSError, ValueError) as e:
logger.error(
"Failed to scan directory for data files: %s",
str(e),
exc_info=True
)
return []
logger.info(
"Found %d series from data files in %s",
@@ -843,3 +780,38 @@ class SeriesApp:
if hasattr(self, 'executor'):
self.executor.shutdown(wait=True)
logger.info("ThreadPoolExecutor shut down successfully")
def _load_data_file(data_file_path: str) -> Optional[dict]:
"""Load and parse a legacy 'data' file (JSON).
Args:
data_file_path: Path to the data file
Returns:
Parsed data dict or None if parsing fails
"""
import json
try:
with open(data_file_path, "r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
logger.warning("Data file is not a dictionary: %s", data_file_path)
return None
return data
except json.JSONDecodeError as e:
logger.warning(
"Failed to parse legacy data file (JSON error): %s - %s",
data_file_path, str(e)
)
return None
except Exception as e:
logger.warning(
"Failed to read legacy data file: %s - %s",
data_file_path, str(e)
)
return None

View File

@@ -1,4 +1,6 @@
import logging
import os
import re
import warnings
from typing import Any, List, Optional
@@ -6,7 +8,8 @@ from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from src.core.entities.series import Serie
from src.config.settings import settings
from src.server.database.models import AnimeSeries
from src.server.database.service import AnimeSeriesService
from src.server.exceptions import (
BadRequestError,
@@ -14,16 +17,25 @@ from src.server.exceptions import (
ServerError,
ValidationError,
)
from src.server.models.anime import (
AnimeDetailsResponse,
AnimeSettingsRegenerateNfoResponse,
AnimeSettingsResponse,
AnimeSettingsUpdateRequest,
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 (
get_anime_service,
get_background_loader_service,
get_database_session,
get_optional_database_session,
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
logger = logging.getLogger(__name__)
@@ -31,6 +43,31 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/anime", tags=["anime"])
def _compute_folder_name(name: str, year: Optional[int]) -> str:
"""Compute sanitized folder name from display name and year.
If year is provided, strips any existing year in (YYYY) format to avoid
duplicates, then appends the new year. If year is None, preserves the
original name (with any existing year).
Args:
name: Display name of the series
year: Release year from provider, or None
Returns:
Sanitized folder name in format "Name (YYYY)" or just "Name"
"""
if year:
# Strip any existing year in (YYYY) format before adding new year
clean_name = re.sub(r'\s*\(\d{4}\)\s*$', '', name).strip()
folder_name_with_year = f"{clean_name} ({year})"
else:
# No new year provided, preserve original name (with any existing year)
folder_name_with_year = name
return sanitize_folder_name(folder_name_with_year)
@router.get("/status")
async def get_anime_status(
_auth: dict = Depends(require_auth),
@@ -70,6 +107,37 @@ async def get_anime_status(
) from exc
class DuplicateFolderGroup(BaseModel):
"""Placeholder - duplicates functionality removed."""
key: str = Field(..., description="Series key (unique identifier)")
folders: List[str] = Field(..., description="List of duplicate folder names")
folder_count: int = Field(..., description="Number of duplicate folders")
class DuplicateFoldersResponse(BaseModel):
"""Placeholder - duplicates functionality removed."""
total_groups: int = Field(..., description="Total number of duplicate groups")
duplicate_groups: List[DuplicateFolderGroup] = Field(
..., description="List of duplicate folder groups"
)
message: str = Field(..., description="Human-readable summary")
@router.get("/duplicate-folders", response_model=DuplicateFoldersResponse)
async def get_duplicate_folders(
_auth: dict = Depends(require_auth),
) -> DuplicateFoldersResponse:
"""List all pre-existing duplicate folder groups.
Note: Duplicate folder scanning has been removed. Returns empty response.
"""
return DuplicateFoldersResponse(
total_groups=0,
duplicate_groups=[],
message="Duplicate folder scanning has been removed.",
)
class AnimeSummary(BaseModel):
"""Summary of an anime series with missing episodes.
@@ -387,11 +455,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
@@ -728,18 +796,9 @@ async def add_series(
except Exception as e:
logger.warning("Could not fetch year for %s: %s", key, e)
# Create folder name with year if available
if year:
year_suffix = f" ({year})"
if name.endswith(year_suffix):
folder_name_with_year = name
else:
folder_name_with_year = f"{name}{year_suffix}"
else:
folder_name_with_year = name
# Step B: Compute sanitized folder name with year (deduplicates if year already in name)
try:
folder = sanitize_folder_name(folder_name_with_year)
folder = _compute_folder_name(name, year)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -748,7 +807,37 @@ async def add_series(
db_id = None
# Step C: Save to database if available
# Step C: Create folder on disk if it doesn't exist, and rename if needed
# Determine the anime directory path
anime_dir = settings.anime_directory if hasattr(settings, 'anime_directory') else None
current_folder_on_disk = None
if anime_dir:
import os
anime_path = os.path.join(anime_dir, folder)
# Check if an existing folder (without year) needs renaming
# Look for folder that matches name without year
if year:
potential_old_name = sanitize_folder_name(name)
potential_old_path = os.path.join(anime_dir, potential_old_name)
if potential_old_path != anime_path and os.path.exists(potential_old_path):
current_folder_on_disk = potential_old_name
logger.info(
"Found existing folder without year for %s: %s, renaming to %s",
key,
potential_old_name,
folder
)
elif not os.path.exists(anime_path):
# No existing folder to rename, create new one
os.makedirs(anime_path, exist_ok=True)
else:
# No year, just ensure folder exists
if not os.path.exists(anime_path):
os.makedirs(anime_path, exist_ok=True)
# Step D: Save to database if available
if db is not None:
# Check if series already exists in database
existing = await AnimeSeriesService.get_by_key(db, key)
@@ -794,18 +883,18 @@ async def add_series(
# Step D: Add to SerieList (in-memory only, no folder creation)
if series_app and hasattr(series_app, "list"):
serie = Serie(
from src.server.database.models import AnimeSeries
anime = AnimeSeries(
key=key,
name=name,
site="aniworld.to",
folder=folder,
episodeDict={},
year=year
)
# Add to in-memory cache without creating folder on disk
if hasattr(series_app.list, 'keyDict'):
series_app.list.keyDict[key] = serie
series_app.list.keyDict[key] = anime
logger.info(
"Added series to in-memory cache: %s (key=%s, folder=%s, year=%s)",
name,
@@ -814,7 +903,32 @@ async def add_series(
year
)
# Step E: Queue background loading task for episodes, NFO, and images
# Step E: Rename existing folder if needed (e.g., folder existed without year)
if current_folder_on_disk:
try:
renamed = await anime_service.rename_folder_if_needed(
key=key,
current_folder=current_folder_on_disk,
target_folder=folder,
db=db
)
if renamed:
logger.info(
"Successfully renamed folder for %s: %s -> %s",
key,
current_folder_on_disk,
folder
)
except Exception as e:
logger.warning(
"Failed to rename folder for %s: %s -> %s: %s",
key,
current_folder_on_disk,
folder,
e
)
# Step F: Queue background loading task for episodes, NFO, and images
try:
await background_loader.add_series_loading_task(
key=key,
@@ -835,16 +949,13 @@ async def add_series(
e
)
# Step F: 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(
@@ -997,7 +1108,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.
@@ -1064,6 +1176,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}")
@@ -1079,12 +1207,551 @@ 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.get("/{anime_key}/details", response_model=AnimeDetailsResponse)
async def get_anime_details(
anime_key: str,
_auth: dict = Depends(require_auth),
db: AsyncSession = Depends(get_database_session),
) -> 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: Series key (primary identifier)
_auth: Authentication dependency
db: Database session
Returns:
AnimeDetailsResponse: Full series details for edit modal
Raises:
HTTPException 404: Series not found
"""
# 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(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Series with key '{anime_key}' not found",
)
# 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()
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,
)
@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,
)

View File

@@ -1,6 +1,7 @@
"""Authentication API endpoints for Aniworld."""
from typing import Optional
import structlog
from fastapi import APIRouter, Depends, HTTPException
from fastapi import status as http_status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
@@ -15,6 +16,9 @@ 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__)
# NOTE: import dependencies (optional_auth, security) lazily inside handlers
# to avoid importing heavyweight modules (e.g. sqlalchemy) at import time.
@@ -76,8 +80,6 @@ async def setup_auth(req: SetupRequest):
config.scheduler.schedule_days = req.scheduler_schedule_days
if req.scheduler_auto_download_after_rescan is not None:
config.scheduler.auto_download_after_rescan = req.scheduler_auto_download_after_rescan
if req.scheduler_folder_scan_enabled is not None:
config.scheduler.folder_scan_enabled = req.scheduler_folder_scan_enabled
# Update logging configuration
if req.logging_level:
@@ -116,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:
@@ -146,10 +152,7 @@ async def setup_auth(req: SetupRequest):
# Trigger initialization in background task
import asyncio
from src.server.services.initialization_service import (
perform_initial_setup,
perform_nfo_scan_if_needed,
)
from src.server.services.initialization_service import perform_initial_setup
from src.server.services.progress_service import get_progress_service
progress_service = get_progress_service()
@@ -160,11 +163,23 @@ async def setup_auth(req: SetupRequest):
# Perform the initial series sync and mark as completed
await perform_initial_setup(progress_service)
# Perform NFO scan if configured
await perform_nfo_scan_if_needed(progress_service)
# Start scheduler if anime_directory is now set
try:
from src.server.services.scheduler.scheduler_service import (
get_scheduler_service,
)
scheduler_svc = get_scheduler_service()
logger.info("Starting scheduler after initialization")
await scheduler_svc.ensure_started()
logger.info("Scheduler started successfully during setup")
except Exception as sched_exc:
logger.warning(
"Failed to start scheduler during setup: %s", sched_exc
)
# Continue — scheduler failure should not break initialization
# Send completion event
from src.server.services.progress_service import ProgressType
await progress_service.start_progress(
progress_id="initialization_complete",
progress_type=ProgressType.SYSTEM,
@@ -180,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,
@@ -198,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.
@@ -230,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(

View File

@@ -1,7 +1,10 @@
import logging
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
logger = logging.getLogger(__name__)
from src.server.models.config import AppConfig, ConfigUpdate, ValidationResult
from src.server.services.config_service import (
ConfigBackupError,
@@ -28,16 +31,64 @@ def get_config(auth: Optional[dict] = Depends(require_auth)) -> AppConfig:
@router.put("", response_model=AppConfig)
def update_config(
async def update_config(
update: ConfigUpdate, auth: dict = Depends(require_auth)
) -> AppConfig:
"""Apply an update to the configuration and persist it.
Creates automatic backup before applying changes.
Creates automatic backup before applying changes. If anime_directory
is configured, starts the scheduler service.
"""
try:
config_service = get_config_service()
return config_service.update_config(update)
updated_config = config_service.update_config(update)
# Sync anime_directory to settings if it was updated
from src.config.settings import settings as app_settings
anime_dir_changed = False
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
logger.info("Synced anime_directory from config: %s", anime_dir)
# Start scheduler if anime_directory was just configured
if anime_dir_changed:
try:
from src.server.services.scheduler.scheduler_service import (
get_scheduler_service,
)
scheduler_svc = get_scheduler_service()
logger.info(
"Starting scheduler after anime_directory configuration"
)
await scheduler_svc.ensure_started()
logger.info(
"Scheduler started successfully after config update"
)
except Exception as sched_exc:
logger.warning(
"Failed to start scheduler after config update: %s",
sched_exc,
)
# Config was already saved, don't fail the request
return updated_config
except ConfigValidationError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -68,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,
@@ -86,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]:
@@ -239,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_series_from_data_files
logger = structlog.get_logger(__name__)
sync_count = await sync_series_from_data_files(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
@@ -372,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)

View File

@@ -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,

View File

@@ -5,7 +5,7 @@ from datetime import datetime
from typing import Any, Dict, Optional
import psutil
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
@@ -17,15 +17,21 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/health", tags=["health"])
from src.server.utils.version import APP_VERSION
class HealthStatus(BaseModel):
"""Basic health status response."""
status: str
timestamp: str
version: str = "1.0.1"
version: str = APP_VERSION
service: str = "aniworld-api"
series_app_initialized: bool = False
anime_directory_configured: bool = False
scheduler_next_run: Optional[str] = None
scheduler_last_run: Optional[str] = None
checks: Optional[Dict[str, Any]] = None
class DatabaseHealth(BaseModel):
@@ -60,9 +66,10 @@ class DetailedHealthStatus(BaseModel):
status: str
timestamp: str
version: str = "1.0.1"
version: str = APP_VERSION
dependencies: DependencyHealth
startup_time: datetime
uptime: str
# Global startup time
@@ -171,29 +178,92 @@ def get_system_metrics() -> SystemMetrics:
@router.get("", response_model=HealthStatus)
async def basic_health_check() -> HealthStatus:
async def basic_health_check(request: Request) -> HealthStatus:
"""Basic health check endpoint.
This endpoint does not depend on anime_directory configuration
and should always return 200 OK for basic health monitoring.
Includes service information for identification.
Includes scheduler next/last run times for monitoring tools.
Includes startup health check results.
Returns:
HealthStatus: Simple health status with timestamp and service info.
"""
from src.config.settings import settings
from src.server.utils.dependencies import _series_app
# Get scheduler status for health monitoring
scheduler_status: dict = {}
try:
from src.server.services.scheduler.scheduler_service import (
get_scheduler_service,
)
scheduler_status = get_scheduler_service().get_status()
except Exception:
pass
# Get startup checks from app state
checks = getattr(request.app.state, "startup_checks", None)
# Determine overall status based on checks
overall_status = "healthy"
if checks:
for check_name, check_data in checks.items():
if check_data.get("status") == "error":
overall_status = "unhealthy"
break
elif check_data.get("status") == "warning":
overall_status = "degraded"
logger.debug("Basic health check requested")
return HealthStatus(
status="healthy",
status=overall_status,
timestamp=datetime.now().isoformat(),
service="aniworld-api",
series_app_initialized=_series_app is not None,
anime_directory_configured=bool(settings.anime_directory),
scheduler_next_run=scheduler_status.get("next_run"),
scheduler_last_run=scheduler_status.get("last_run"),
checks=checks,
)
@router.get("/ready")
async def ready_check(request: Request) -> Dict[str, Any]:
"""Readiness check endpoint for container orchestrators.
Returns 503 if critical dependencies are not available.
This endpoint is used by Kubernetes, Docker Swarm, etc. to determine
if the container should receive traffic.
Returns:
dict: Readiness status with checks details.
"""
checks = getattr(request.app.state, "startup_checks", {})
critical_failures = []
for check_name, check_data in checks.items():
if check_data.get("status") == "error":
critical_failures.append(f"{check_name}: {check_data.get('message')}")
if critical_failures:
return {
"status": "not_ready",
"ready": False,
"timestamp": datetime.now().isoformat(),
"critical_failures": critical_failures,
"checks": checks,
}
return {
"status": "ready",
"ready": True,
"timestamp": datetime.now().isoformat(),
"checks": checks,
}
@router.get("/detailed", response_model=DetailedHealthStatus)
async def detailed_health_check(
db: AsyncSession = Depends(get_database_session),
@@ -229,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)

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -10,8 +10,8 @@ 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_service import get_scheduler_service
from src.server.utils.dependencies import require_auth
from src.server.services.scheduler.scheduler_service import get_scheduler_service
from src.server.utils.dependencies import get_anime_service, require_auth
logger = logging.getLogger(__name__)
@@ -31,7 +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,
"folder_scan_enabled": config.folder_scan_enabled,
"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),
@@ -142,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

View File

@@ -0,0 +1,446 @@
"""API endpoints for setup and unresolved folder management.
Provides endpoints to:
- List unresolved folders that couldn't be auto-resolved during setup
- Get suggestions/search results for an unresolved folder
- Resolve an unresolved folder by providing a provider key
"""
import json
import logging
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, status
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,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/setup", tags=["setup"])
class UnresolvedFolderResponse(BaseModel):
"""Response model for an unresolved folder."""
folder_name: str = Field(..., description="Original filesystem folder name")
title: str = Field(..., description="Extracted title from folder name")
year: Optional[int] = Field(None, description="Extracted release year")
search_attempts: int = Field(..., description="Number of search attempts made")
search_suggestions: list[dict[str, Any]] = Field(
default_factory=list,
description="Cached search results for potential matches"
)
class Config:
from_attributes = True
class ResolveFolderRequest(BaseModel):
"""Request model for resolving an unresolved folder."""
provider_key: str = Field(
...,
min_length=1,
max_length=255,
description="Provider key to associate with this folder"
)
class ResolveFolderResponse(BaseModel):
"""Response model for resolving an unresolved folder."""
status: str = Field(..., description="Operation status")
message: str = Field(..., description="Human-readable message")
folder_name: str = Field(..., description="Folder name that was resolved")
key: str = Field(..., description="Provider key that was used")
series_id: int = Field(..., description="Database ID of the created series")
@router.get("/unresolved", response_model=list[UnresolvedFolderResponse])
async def list_unresolved_folders(
db=Depends(get_database_session),
) -> list[UnresolvedFolderResponse]:
"""List all unresolved folders that need manual key resolution.
Returns folders that couldn't be auto-resolved during setup,
including cached search suggestions when available.
Returns:
List of UnresolvedFolderResponse objects
"""
folders = await UnresolvedFolderService.get_all_unresolved(db)
result = []
for folder in folders:
suggestions = []
if folder.last_search_result:
try:
suggestions = json.loads(folder.last_search_result)
except json.JSONDecodeError:
logger.warning(
"Failed to parse search result for folder: %s",
folder.folder_name
)
result.append(UnresolvedFolderResponse(
folder_name=folder.folder_name,
title=folder.title,
year=folder.year,
search_attempts=folder.search_attempts,
search_suggestions=suggestions,
))
return result
@router.get("/unresolved/{folder_name}", response_model=UnresolvedFolderResponse)
async def get_unresolved_folder(
folder_name: str,
db=Depends(get_database_session),
) -> UnresolvedFolderResponse:
"""Get details for a specific unresolved folder.
Args:
folder_name: URL-encoded folder name to look up
Returns:
UnresolvedFolderResponse for the specified folder
Raises:
HTTPException: 404 if folder not found or already resolved
"""
folder = await UnresolvedFolderService.get_by_folder_name(db, folder_name)
if not folder:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Unresolved folder not found: {folder_name}"
)
if folder.is_resolved:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Folder already resolved: {folder_name}"
)
suggestions = []
if folder.last_search_result:
try:
suggestions = json.loads(folder.last_search_result)
except json.JSONDecodeError:
pass
return UnresolvedFolderResponse(
folder_name=folder.folder_name,
title=folder.title,
year=folder.year,
search_attempts=folder.search_attempts,
search_suggestions=suggestions,
)
@router.post("/unresolved/{folder_name}/resolve", response_model=ResolveFolderResponse)
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.
This endpoint:
1. Validates the provider key format
2. Updates the UnresolvedFolder record as resolved
3. Creates the AnimeSeries record in the database
4. Returns the created series information
Args:
folder_name: URL-encoded folder name to resolve
request: ResolveFolderRequest with the provider_key
Returns:
ResolveFolderResponse with created series details
Raises:
HTTPException: 404 if folder not found
HTTPException: 400 if key is invalid or series already exists
"""
# Check if folder exists and is unresolved
unresolved = await UnresolvedFolderService.get_by_folder_name(db, folder_name)
if not unresolved:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Unresolved folder not found: {folder_name}"
)
if unresolved.is_resolved:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Folder already resolved: {folder_name}"
)
# Check if a series with this key already exists
existing_series = await AnimeSeriesService.get_by_key(db, request.provider_key)
if existing_series:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Series with key '{request.provider_key}' already exists"
)
# Mark as resolved
await UnresolvedFolderService.resolve(db, folder_name, request.provider_key)
# Create the AnimeSeries record
series = await AnimeSeriesService.create(
db=db,
key=request.provider_key,
name=unresolved.title,
site="https://aniworld.to",
folder=folder_name,
year=unresolved.year,
loading_status="pending",
episodes_loaded=False,
logo_loaded=False,
images_loaded=False,
)
logger.info(
"Resolved unresolved folder via API: %s -> key=%s (series_id=%d)",
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}",
folder_name=folder_name,
key=request.provider_key,
series_id=series.id,
)
class SearchFolderRequest(BaseModel):
"""Request model for searching an unresolved folder with custom query."""
query: Optional[str] = Field(None, description="Custom search query override")
@router.post("/unresolved/{folder_name}/search", response_model=UnresolvedFolderResponse)
async def search_unresolved_folder(
folder_name: str,
request: Optional[SearchFolderRequest] = None,
db=Depends(get_database_session),
) -> UnresolvedFolderResponse:
"""Re-search for a specific unresolved folder to get fresh suggestions.
Performs a new search using the folder's title or a custom query.
Caches the results for subsequent display.
Args:
folder_name: URL-encoded folder name to search for
request: Optional SearchFolderRequest with custom query override
Returns:
UnresolvedFolderResponse with updated search suggestions
Raises:
HTTPException: 404 if folder not found or already resolved
"""
from pathlib import Path
folder = await UnresolvedFolderService.get_by_folder_name(db, folder_name)
if not folder:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Unresolved folder not found: {folder_name}"
)
if folder.is_resolved:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Folder already resolved: {folder_name}"
)
# Use custom query if provided, otherwise fall back to folder title
search_query = request.query if request and request.query else folder.title
# Perform search
series_app = get_series_app()
try:
results = await series_app.search(search_query)
search_result_json = json.dumps(results) if results else "[]"
except Exception as e:
logger.warning(
"Search failed for unresolved folder: %s, error: %s",
folder_name, str(e)
)
search_result_json = "[]"
results = []
# Update the folder with new search results
await UnresolvedFolderService.update_search_result(db, folder_name, search_result_json)
return UnresolvedFolderResponse(
folder_name=folder.folder_name,
title=folder.title,
year=folder.year,
search_attempts=folder.search_attempts + 1,
search_suggestions=results,
)
@router.delete("/unresolved/{folder_name}")
async def delete_unresolved_folder(
folder_name: str,
db=Depends(get_database_session),
) -> dict[str, str]:
"""Delete an unresolved folder tracking record.
Use this when you've manually added the series outside of this flow
(e.g., via POST /api/anime/add) to clean up the unresolved tracker.
Args:
folder_name: URL-encoded folder name to delete
Returns:
Dict with status message
Raises:
HTTPException: 404 if folder not found
"""
deleted = await UnresolvedFolderService.delete(db, folder_name)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Unresolved folder not found: {folder_name}"
)
return {"status": "success", "message": f"Deleted unresolved folder: {folder_name}"}
class DoneResponse(BaseModel):
"""Response model for completing unresolved folders."""
status: str = Field(..., description="Operation status")
message: str = Field(..., description="Human-readable message")
count: int = Field(..., description="Number of folders marked as done")
@router.post("/unresolved/done", response_model=DoneResponse)
async def complete_unresolved_folders(
db=Depends(get_database_session),
) -> DoneResponse:
"""Mark all unresolved folders as handled and complete the unresolved phase.
This endpoint:
1. Marks the unresolved phase as completed in config
2. Returns the count of folders that were handled
After this, /setup/unresolved will redirect to /loading.
Returns:
DoneResponse with status and count of handled folders
"""
from src.server.services.config_service import get_config_service
# Get all unresolved folders
folders = await UnresolvedFolderService.get_all_unresolved(db)
count = len(folders)
# Mark unresolved as completed in config
config_service = get_config_service()
try:
config = config_service.load_config()
if config.other is None:
config.other = {}
config.other['unresolved_completed'] = True
config_service.save_config(config, create_backup=False)
logger.info("Marked unresolved phase as completed")
except Exception as e:
logger.warning("Failed to save unresolved_completed flag: %s", e)
logger.info(
"Completed unresolved phase: %d folders handled",
count
)
return DoneResponse(
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."
)

View File

@@ -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")

View File

@@ -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)."""

View File

@@ -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",

View File

@@ -59,3 +59,40 @@ async def loading_page(request: Request):
request,
title="Initializing - Aniworld"
)
@router.get("/setup/unresolved", response_class=HTMLResponse)
async def unresolved_page(request: Request):
"""Serve the unresolved folders resolution page."""
return render_template(
"unresolved.html",
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"
)

View File

@@ -0,0 +1,288 @@
"""Utilities for loading and managing stored anime series metadata.
This module provides the SerieList class for managing collections of anime
series metadata loaded from the database.
Note:
This module is part of the server database layer. All persistence
is handled by the service layer.
"""
from __future__ import annotations
import logging
from typing import Dict, List, Optional
from src.server.database.models import AnimeSeries
logger = logging.getLogger(__name__)
class SerieList:
"""
Represents the collection of cached series loaded from database.
Series are identified by their unique 'key' (provider identifier).
The 'folder' is metadata only and not used for lookups.
This class manages in-memory series data loaded from database.
Example:
# Load from database
serie_list = SerieList("/path/to/anime")
await serie_list.load_all_from_db()
series = serie_list.get_all()
Attributes:
directory: Path to the anime directory
keyDict: Internal dictionary mapping serie.key to AnimeSeries objects
"""
def __init__(self, base_path: str) -> None:
"""Initialize the SerieList.
Args:
base_path: Path to the anime directory
"""
self.directory: str = base_path
# Internal storage using serie.key as the dictionary key
self.keyDict: Dict[str, AnimeSeries] = {}
async def add_to_db(self, anime: AnimeSeries) -> bool:
"""Persist a new series to the database.
Creates the filesystem folder using anime.folder, then persists
the series metadata to the database.
Args:
anime: The AnimeSeries instance to add
Returns:
True if successful, False otherwise
"""
try:
from src.server.database.connection import get_async_session_factory
from src.server.database.service import AnimeSeriesService, EpisodeService
folder_name = anime.folder
anime_path = self.directory + "/" + folder_name
import os
os.makedirs(anime_path, exist_ok=True)
session_factory = get_async_session_factory()
db = session_factory()
try:
existing = await AnimeSeriesService.get_by_key(db, anime.key)
if existing:
logger.debug(
"Series '%s' (key=%s) already exists in DB, skipping",
anime.name, anime.key
)
return True
db_anime_series = await AnimeSeriesService.create(
db=db,
key=anime.key,
name=anime.name,
site=anime.site,
folder=folder_name,
year=anime.year
)
for ep in anime.episodes:
await EpisodeService.create(
db=db,
series_id=db_anime_series.id,
season=ep.season,
episode_number=ep.episode_number
)
await db.commit()
self.keyDict[anime.key] = anime
logger.info(
"Persisted series '%s' to database",
anime.name
)
return True
except Exception as e:
await db.rollback()
logger.error(
"Failed to persist series '%s' to DB: %s",
anime.key, e, exc_info=True
)
return False
finally:
await db.close()
except Exception as e:
logger.error(
"Could not add series '%s' to DB (DB unavailable?): %s",
anime.key, e
)
return False
def contains(self, key: str) -> bool:
"""
Return True when a series identified by ``key`` already exists.
Args:
key: The unique provider identifier for the series
Returns:
True if the series exists in the collection
"""
return key in self.keyDict
def GetMissingEpisode(self) -> List[AnimeSeries]:
"""Return all series that still contain missing episodes."""
return [
anime for anime in self.keyDict.values()
if anime.episodeDict
]
def get_missing_episodes(self) -> List[AnimeSeries]:
"""PEP8-friendly alias for :meth:`GetMissingEpisode`."""
return self.GetMissingEpisode()
def GetList(self) -> List[AnimeSeries]:
"""Return all series instances stored in the list."""
return list(self.keyDict.values())
def get_all(self) -> List[AnimeSeries]:
"""PEP8-friendly alias for :meth:`GetList`."""
return self.GetList()
def get_by_key(self, key: str) -> Optional[AnimeSeries]:
"""
Get a series by its unique provider key.
This is the primary method for series lookup.
Args:
key: The unique provider identifier (e.g., "attack-on-titan")
Returns:
The AnimeSeries instance if found, None otherwise
"""
return self.keyDict.get(key)
def get_by_folder(self, folder: str) -> Optional[AnimeSeries]:
"""
Get a series by its folder name.
.. deprecated:: 2.0.0
Use :meth:`get_by_key` instead. Folder-based lookups will be
removed in version 3.0.0. The `folder` field is metadata only
and should not be used for identification.
This method is provided for backward compatibility only.
Prefer using get_by_key() for new code.
Args:
folder: The filesystem folder name (e.g., "Attack on Titan (2013)")
Returns:
The AnimeSeries instance if found, None otherwise
"""
import warnings
warnings.warn(
"get_by_folder() is deprecated and will be removed in v3.0.0. "
"Use get_by_key() instead. The 'folder' field is metadata only.",
DeprecationWarning,
stacklevel=2
)
for anime in self.keyDict.values():
if anime.folder == folder:
return anime
return None
async def load_all_from_db(self) -> int:
"""Load all series from database into in-memory cache.
Retrieves all anime series from the database with their episodes
and populates the in-memory keyDict for fast access.
Returns:
int: Number of series loaded into cache
"""
from src.server.database.connection import get_async_session_factory
from src.server.database.service import AnimeSeriesService
try:
session_factory = get_async_session_factory()
db = session_factory()
try:
anime_series_list = await AnimeSeriesService.get_all(
db, with_episodes=True
)
count = 0
for anime_series in anime_series_list:
self.keyDict[anime_series.key] = anime_series
count += 1
logger.info(
"Loaded %d series from database into in-memory cache",
count
)
return count
finally:
await db.close()
except RuntimeError:
logger.warning(
"Database not available, skipping DB load"
)
return 0
async def _load_single_series_from_db(
self,
anime_folder: str
) -> Optional[AnimeSeries]:
"""Load a single series from database by folder name.
Looks up a series in the database by its folder name and adds
it to the in-memory cache.
Args:
anime_folder: The filesystem folder name to look up
Returns:
AnimeSeries if found and loaded, None otherwise
"""
from src.server.database.connection import get_async_session_factory
from src.server.database.service import AnimeSeriesService
try:
session_factory = get_async_session_factory()
db = session_factory()
try:
anime_series = await AnimeSeriesService.get_by_folder(
db, anime_folder
)
if not anime_series:
logger.debug(
"Series with folder '%s' not found in DB",
anime_folder
)
return None
self.keyDict[anime_series.key] = anime_series
logger.debug(
"Loaded series '%s' (key=%s) from DB",
anime_series.name, anime_series.key
)
return anime_series
finally:
await db.close()
except RuntimeError:
logger.warning(
"Database not available, cannot load series '%s'",
anime_folder
)
return None
def invalidate_cache(self) -> None:
"""Clear the in-memory cache.
Use after database modifications to force reload from DB
on next access.
"""
self.keyDict.clear()
logger.debug("SerieList in-memory cache invalidated")

View File

@@ -48,6 +48,7 @@ from src.server.database.service import (
EpisodeService,
UserSessionService,
)
from src.server.database.SerieList import SerieList
from src.server.database.system_settings_service import SystemSettingsService
__all__ = [
@@ -79,4 +80,6 @@ __all__ = [
"DownloadQueueService",
"SystemSettingsService",
"UserSessionService",
# SerieList
"SerieList",
]

View File

@@ -37,6 +37,7 @@ EXPECTED_TABLES = {
"download_queue",
"user_sessions",
"system_settings",
"unresolved_folders",
}
# Expected indexes for performance
@@ -119,6 +120,11 @@ async def initialize_database(
result["tables_created"] = tables
logger.info("Created %s tables", len(tables))
# Migrate schema if needed (add missing columns to existing tables)
migrations = await migrate_schema_if_needed(engine)
if migrations:
logger.info("Applied %s schema migrations", len(migrations))
# Validate schema if requested
if validate_schema:
validation = await validate_database_schema(engine)
@@ -305,6 +311,66 @@ async def validate_database_schema(
}
# =============================================================================
# Schema Migration
# =============================================================================
async def migrate_schema_if_needed(
engine: Optional[AsyncEngine] = None
) -> List[str]:
"""Migrate database schema to current version if needed.
Handles adding missing columns to existing tables for backward
compatibility with older database schemas.
Args:
engine: Optional database engine (uses default if not provided)
Returns:
List of migration operations performed
"""
if engine is None:
engine = get_engine()
migrations_applied = []
try:
async with engine.connect() as conn:
# Get existing columns in system_settings table
existing_columns = await conn.run_sync(
lambda sync_conn: [
col["name"]
for col in inspect(sync_conn).get_columns("system_settings")
]
)
# Migration: Add legacy_key_cleanup_completed column if missing
if "legacy_key_cleanup_completed" not in existing_columns:
logger.info(
"Migrating system_settings table: "
"adding legacy_key_cleanup_completed column"
)
await conn.execute(
text("""
ALTER TABLE system_settings
ADD COLUMN legacy_key_cleanup_completed BOOLEAN
NOT NULL DEFAULT 0
""")
)
migrations_applied.append("added legacy_key_cleanup_completed")
logger.info(
"Migration complete: added legacy_key_cleanup_completed column"
)
except Exception as e:
logger.error("Schema migration failed: %s", e)
# Don't raise - migration failures shouldn't block startup
# The missing column will be handled gracefully by the application
return migrations_applied
# =============================================================================
# Schema Version Management
# =============================================================================

View File

@@ -13,9 +13,9 @@ 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, Integer, String, Text, func
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
from src.server.database.base import Base, TimestampMixin
@@ -83,6 +83,10 @@ class AnimeSeries(Base, TimestampMixin):
Boolean, nullable=False, default=False, server_default="0",
doc="Whether tvshow.nfo file exists for this series"
)
nfo_path: Mapped[Optional[str]] = mapped_column(
String(1000), nullable=True,
doc="Path to the tvshow.nfo metadata file"
)
nfo_created_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True,
doc="Timestamp when NFO was first created"
@@ -91,6 +95,7 @@ class AnimeSeries(Base, TimestampMixin):
DateTime(timezone=True), nullable=True,
doc="Timestamp when NFO was last updated"
)
# TMDB (The Movie Database) ID for series metadata
tmdb_id: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True, index=True,
doc="TMDB (The Movie Database) ID for series metadata"
@@ -185,6 +190,84 @@ class AnimeSeries(Base, TimestampMixin):
f"name='{self.name}')>"
)
@property
def episodeDict(self) -> dict[int, list[int]]:
"""Build episode dictionary from episodes relationship or private cache.
Returns:
Dictionary mapping season numbers to lists of episode numbers
"""
# Check for private cache first (set when loading from JSON without DB)
if hasattr(self, '_episode_dict_cache') and self._episode_dict_cache is not None:
return self._episode_dict_cache
episode_dict: dict[int, list[int]] = {}
try:
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)
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.
Returns:
Name in format "Name (Year)" if year is available, else just name
"""
if self.year:
import re
year_suffix = f" ({self.year})"
clean_name = re.sub(r'(\s*\(\d{4}\))+\s*$', '', self.name or '').strip()
return f"{clean_name}{year_suffix}"
return self.name or ''
@property
def sanitized_folder(self) -> str:
"""Get filesystem-safe folder name from display name with year.
Returns:
Sanitized folder name based on display name with year
"""
from src.server.utils.filesystem import sanitize_folder_name
name_to_sanitize = self.name_with_year or self.folder or self.key
try:
return sanitize_folder_name(name_to_sanitize)
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.
@@ -316,6 +399,7 @@ class DownloadQueueItem(Base, TimestampMixin):
id: Primary key
series_id: Foreign key to AnimeSeries
episode_id: Foreign key to Episode
status: Queue status (pending/downloading/completed/failed/permanently_failed)
error_message: Error description if failed
download_url: Provider download URL
file_destination: Target file path
@@ -347,6 +431,33 @@ class DownloadQueueItem(Base, TimestampMixin):
index=True
)
# Status column to track queue item state
# Allows distinguishing pending items from permanently failed ones
status: Mapped[str] = mapped_column(
String(50), nullable=False, default="pending",
doc="Queue item status: pending, downloading, completed, failed, permanently_failed"
)
# Retry count to track failed download attempts
# Used to determine when to move item to permanently_failed
retry_count: Mapped[int] = mapped_column(
Integer, nullable=False, default=0,
doc="Number of retry attempts for this download"
)
# Unique constraint to prevent duplicate pending queue items per episode
# An episode can only have one PENDING entry at a time
# The status column allows failed items to remain in DB while new
# pending items can be added (application-level dedup still required)
__table_args__ = (
Index(
"ix_download_queue_episode_status",
"episode_id",
"status",
unique=True,
),
)
# Error handling
error_message: Mapped[Optional[str]] = mapped_column(
Text, nullable=True,
@@ -545,6 +656,96 @@ class UserSession(Base, TimestampMixin):
self.is_active = False
class UnresolvedFolder(Base, TimestampMixin):
"""SQLAlchemy model for folders that couldn't be resolved during setup.
Tracks anime folders whose provider key couldn't be auto-resolved
during the initial setup scan. Users can provide the correct key
via the API to complete the series registration.
Attributes:
id: Primary key
folder_name: Original filesystem folder name
title: Extracted title from folder name
year: Extracted release year (optional)
provider_key: User-provided provider key to resolve this folder
search_attempts: Number of auto-search attempts made
last_search_result: Cached search results (JSON string) for UI suggestions
resolved_at: Timestamp when provider_key was provided
created_at: Creation timestamp (from TimestampMixin)
updated_at: Last update timestamp (from TimestampMixin)
"""
__tablename__ = "unresolved_folders"
# Primary key
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True
)
# Folder metadata
folder_name: Mapped[str] = mapped_column(
String(1000), unique=True, nullable=False, index=True,
doc="Original filesystem folder name"
)
title: Mapped[str] = mapped_column(
String(500), nullable=False,
doc="Extracted title from folder name"
)
year: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True,
doc="Extracted release year"
)
# Resolution data
provider_key: Mapped[Optional[str]] = mapped_column(
String(255), nullable=True,
doc="User-provided provider key to resolve this folder"
)
search_attempts: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default="0",
doc="Number of auto-search attempts made"
)
last_search_result: Mapped[Optional[str]] = mapped_column(
Text, nullable=True,
doc="Cached search results (JSON) for UI display"
)
resolved_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True,
doc="Timestamp when this folder was resolved"
)
@validates('folder_name')
def validate_folder_name(self, key: str, value: str) -> str:
"""Validate folder name is not empty."""
if not value or not value.strip():
raise ValueError("Folder name cannot be empty")
if len(value) > 1000:
raise ValueError("Folder name must be 1000 characters or less")
return value.strip()
@validates('title')
def validate_title(self, key: str, value: str) -> str:
"""Validate title is not empty."""
if not value or not value.strip():
raise ValueError("Title cannot be empty")
if len(value) > 500:
raise ValueError("Title must be 500 characters or less")
return value.strip()
@property
def is_resolved(self) -> bool:
"""Check if this folder has been resolved with a provider key."""
return self.provider_key is not None and self.resolved_at is not None
def __repr__(self) -> str:
return (
f"<UnresolvedFolder(id={self.id}, "
f"folder_name='{self.folder_name}', "
f"title='{self.title}', "
f"resolved={self.is_resolved})>"
)
class SystemSettings(Base, TimestampMixin):
"""SQLAlchemy model for system-wide settings and state.
@@ -580,6 +781,14 @@ class SystemSettings(Base, TimestampMixin):
Boolean, nullable=False, default=False, server_default="0",
doc="Whether the initial media scan has been completed"
)
migration_legacy_files_completed: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default="0",
doc="Whether legacy key/data file migration has been completed"
)
legacy_key_cleanup_completed: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default="0",
doc="Whether legacy key file cleanup has been completed"
)
last_scan_timestamp: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True,
doc="Timestamp of the last completed scan"

View File

@@ -34,6 +34,7 @@ from src.server.database.models import (
AnimeSeries,
DownloadQueueItem,
Episode,
UnresolvedFolder,
UserSession,
)
@@ -70,6 +71,10 @@ class AnimeSeriesService:
logo_loaded: bool = False,
images_loaded: bool = False,
loading_started_at: datetime | None = None,
has_nfo: bool = False,
nfo_path: str | None = None,
nfo_created_at: datetime | None = None,
nfo_updated_at: datetime | None = None,
) -> AnimeSeries:
"""Create a new anime series.
@@ -85,6 +90,10 @@ class AnimeSeriesService:
logo_loaded: Whether logo is loaded (default: False)
images_loaded: Whether images are loaded (default: False)
loading_started_at: When loading started (optional)
has_nfo: Whether tvshow.nfo exists (default: False)
nfo_path: Path to tvshow.nfo file (optional)
nfo_created_at: When NFO file was created (optional)
nfo_updated_at: When NFO file was last updated (optional)
Returns:
Created AnimeSeries instance
@@ -103,6 +112,10 @@ class AnimeSeriesService:
logo_loaded=logo_loaded,
images_loaded=images_loaded,
loading_started_at=loading_started_at,
has_nfo=has_nfo,
nfo_path=nfo_path,
nfo_created_at=nfo_created_at,
nfo_updated_at=nfo_updated_at,
)
db.add(series)
await db.flush()
@@ -127,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.
@@ -137,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
@@ -144,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
@@ -169,6 +190,26 @@ class AnimeSeriesService:
)
return result.scalar_one_or_none()
@staticmethod
async def get_by_folder(db: AsyncSession, folder: str) -> Optional[AnimeSeries]:
"""Look up an anime series by its filesystem folder name (async).
Intended as primary lookup for ``SerieScanner`` when scanning
directories, replacing the legacy file-based lookups (key/data files).
Args:
db: Async database session.
folder: Filesystem folder name to match (e.g.
``"Rooster Fighter (2026)"``).
Returns:
``AnimeSeries`` instance or ``None`` if not found.
"""
result = await db.execute(
select(AnimeSeries).where(AnimeSeries.folder == folder)
)
return result.scalar_one_or_none()
@staticmethod
async def get_all(
db: AsyncSession,
@@ -541,6 +582,7 @@ class EpisodeService:
db: AsyncSession,
series_id: int,
season: Optional[int] = None,
only_missing: bool = False,
) -> List[Episode]:
"""Get episodes for a series.
@@ -548,6 +590,9 @@ class EpisodeService:
db: Database session
series_id: Foreign key to AnimeSeries
season: Optional season filter
only_missing: If True, only return episodes where
is_downloaded is False (i.e., missing episodes).
Default False returns all episodes.
Returns:
List of Episode instances
@@ -557,6 +602,9 @@ class EpisodeService:
if season is not None:
query = query.where(Episode.season == season)
if only_missing:
query = query.where(Episode.is_downloaded == False)
query = query.order_by(Episode.season, Episode.episode_number)
result = await db.execute(query)
return list(result.scalars().all())
@@ -622,11 +670,11 @@ class EpisodeService:
@staticmethod
async def delete(db: AsyncSession, episode_id: int) -> bool:
"""Delete episode.
Args:
db: Database session
episode_id: Episode primary key
Returns:
True if deleted, False if not found
"""
@@ -635,6 +683,33 @@ class EpisodeService:
)
return result.rowcount > 0
@staticmethod
async def delete_by_series(
db: AsyncSession,
series_id: int,
season: int,
episode_number: int,
) -> bool:
"""Delete episode by series ID, season, and episode number.
Args:
db: Database session
series_id: Foreign key to AnimeSeries
season: Season number
episode_number: Episode number within season
Returns:
True if deleted, False if not found
"""
result = await db.execute(
delete(Episode).where(
Episode.series_id == series_id,
Episode.season == season,
Episode.episode_number == episode_number,
)
)
return result.rowcount > 0
@staticmethod
async def delete_by_series_and_episode(
db: AsyncSession,
@@ -748,6 +823,8 @@ class DownloadQueueService:
episode_id: int,
download_url: Optional[str] = None,
file_destination: Optional[str] = None,
status: str = "pending",
retry_count: int = 0,
) -> DownloadQueueItem:
"""Add item to download queue.
@@ -757,6 +834,8 @@ class DownloadQueueService:
episode_id: Foreign key to Episode
download_url: Optional provider download URL
file_destination: Optional target file path
status: Queue item status (default: "pending")
retry_count: Number of retry attempts (default: 0)
Returns:
Created DownloadQueueItem instance
@@ -766,13 +845,15 @@ class DownloadQueueService:
episode_id=episode_id,
download_url=download_url,
file_destination=file_destination,
status=status,
retry_count=retry_count,
)
db.add(item)
await db.flush()
await db.refresh(item)
logger.info(
f"Added to download queue: episode_id={episode_id} "
f"for series_id={series_id}"
f"for series_id={series_id}, status={status}"
)
return item
@@ -799,21 +880,24 @@ class DownloadQueueService:
async def get_by_episode(
db: AsyncSession,
episode_id: int,
status_filter: Optional[str] = None,
) -> Optional[DownloadQueueItem]:
"""Get download queue item by episode ID.
Args:
db: Database session
episode_id: Foreign key to Episode
status_filter: Optional status to filter by (e.g., "pending")
Returns:
DownloadQueueItem instance or None if not found
"""
result = await db.execute(
select(DownloadQueueItem).where(
DownloadQueueItem.episode_id == episode_id
)
query = select(DownloadQueueItem).where(
DownloadQueueItem.episode_id == episode_id
)
if status_filter:
query = query.where(DownloadQueueItem.status == status_filter)
result = await db.execute(query)
return result.scalar_one_or_none()
@staticmethod
@@ -873,6 +957,95 @@ class DownloadQueueService:
logger.debug("Set error on download queue item %s", item_id)
return item
@staticmethod
async def set_status(
db: AsyncSession,
item_id: int,
status: str,
) -> Optional[DownloadQueueItem]:
"""Set status on download queue item.
Args:
db: Database session
item_id: Item primary key
status: New status value
Returns:
Updated DownloadQueueItem instance or None if not found
"""
item = await DownloadQueueService.get_by_id(db, item_id)
if not item:
return None
item.status = status
await db.flush()
await db.refresh(item)
logger.debug("Set status on download queue item %s to %s", item_id, status)
return item
@staticmethod
async def increment_retry_count(
db: AsyncSession,
item_id: int,
) -> Optional[DownloadQueueItem]:
"""Increment retry count on download queue item.
Args:
db: Database session
item_id: Item primary key
Returns:
Updated DownloadQueueItem instance or None if not found
"""
item = await DownloadQueueService.get_by_id(db, item_id)
if not item:
return None
item.retry_count += 1
await db.flush()
await db.refresh(item)
logger.debug(
"Incremented retry count on download queue item %s to %s",
item_id, item.retry_count
)
return item
@staticmethod
async def set_status_and_error(
db: AsyncSession,
item_id: int,
status: str,
error_message: Optional[str] = None,
) -> Optional[DownloadQueueItem]:
"""Set status and error message on download queue item atomically.
Args:
db: Database session
item_id: Item primary key
status: New status value
error_message: Optional error description
Returns:
Updated DownloadQueueItem instance or None if not found
"""
item = await DownloadQueueService.get_by_id(db, item_id)
if not item:
return None
item.status = status
if error_message is not None:
item.error_message = error_message
await db.flush()
await db.refresh(item)
logger.debug(
"Set status=%s on download queue item %s, error=%s",
status, item_id, error_message
)
return item
@staticmethod
async def delete(db: AsyncSession, item_id: int) -> bool:
"""Delete download queue item.
@@ -1200,3 +1373,176 @@ class UserSessionService:
return new_session
# ============================================================================
# Unresolved Folder Service
# ============================================================================
class UnresolvedFolderService:
"""Service for tracking and resolving folders that couldn't be auto-resolved.
During initial setup, some folders may not resolve to a provider key
(no search match or multiple ambiguous matches). These are tracked as
UnresolvedFolder records and can later be resolved by the user providing
the correct provider key.
"""
@staticmethod
async def create(
db: AsyncSession,
folder_name: str,
title: str,
year: int | None = None,
search_attempts: int = 1,
last_search_result: str | None = None,
) -> UnresolvedFolder:
"""Create a new unresolved folder tracking record.
Args:
db: Database session
folder_name: Original filesystem folder name
title: Extracted title from folder name
year: Extracted release year (optional)
search_attempts: Number of search attempts made (default: 1)
last_search_result: JSON string of search results for UI (optional)
Returns:
Created UnresolvedFolder instance
"""
folder = UnresolvedFolder(
folder_name=folder_name,
title=title,
year=year,
search_attempts=search_attempts,
last_search_result=last_search_result,
)
db.add(folder)
await db.flush()
await db.refresh(folder)
logger.info(
"Created unresolved folder tracking: %s (title=%s, year=%s)",
folder_name, title, year
)
return folder
@staticmethod
async def get_by_folder_name(
db: AsyncSession,
folder_name: str,
) -> Optional[UnresolvedFolder]:
"""Get unresolved folder by folder name.
Args:
db: Database session
folder_name: Filesystem folder name to look up
Returns:
UnresolvedFolder instance or None if not found
"""
result = await db.execute(
select(UnresolvedFolder).where(
UnresolvedFolder.folder_name == folder_name
)
)
return result.scalar_one_or_none()
@staticmethod
async def get_all_unresolved(
db: AsyncSession,
) -> list[UnresolvedFolder]:
"""Get all unresolved folders that haven't been resolved yet.
Args:
db: Database session
Returns:
List of unresolved UnresolvedFolder instances
"""
result = await db.execute(
select(UnresolvedFolder)
.where(UnresolvedFolder.provider_key.is_(None))
.order_by(UnresolvedFolder.created_at)
)
return list(result.scalars().all())
@staticmethod
async def resolve(
db: AsyncSession,
folder_name: str,
provider_key: str,
) -> Optional[UnresolvedFolder]:
"""Mark an unresolved folder as resolved with the given provider key.
Args:
db: Database session
folder_name: Filesystem folder name to resolve
provider_key: Provider key to associate with this folder
Returns:
Updated UnresolvedFolder instance or None if not found
"""
from datetime import datetime, timezone
folder = await UnresolvedFolderService.get_by_folder_name(db, folder_name)
if not folder:
return None
folder.provider_key = provider_key
folder.resolved_at = datetime.now(timezone.utc)
await db.flush()
await db.refresh(folder)
logger.info(
"Resolved unresolved folder: %s -> key=%s",
folder_name, provider_key
)
return folder
@staticmethod
async def delete(
db: AsyncSession,
folder_name: str,
) -> bool:
"""Delete an unresolved folder record (e.g., after manual add).
Args:
db: Database session
folder_name: Filesystem folder name to delete
Returns:
True if deleted, False if not found
"""
folder = await UnresolvedFolderService.get_by_folder_name(db, folder_name)
if not folder:
return False
await db.delete(folder)
await db.flush()
return True
@staticmethod
async def update_search_result(
db: AsyncSession,
folder_name: str,
search_result: str,
) -> Optional[UnresolvedFolder]:
"""Update the cached search result for an unresolved folder.
Args:
db: Database session
folder_name: Filesystem folder name to update
search_result: JSON string of search results
Returns:
Updated UnresolvedFolder instance or None if not found
"""
folder = await UnresolvedFolderService.get_by_folder_name(db, folder_name)
if not folder:
return None
folder.search_attempts += 1
folder.last_search_result = search_result
await db.flush()
await db.refresh(folder)
return folder

View File

@@ -125,6 +125,66 @@ class SystemSettingsService:
settings = await SystemSettingsService.get_or_create(db)
return settings.initial_media_scan_completed
@staticmethod
async def is_migration_legacy_files_completed(db: AsyncSession) -> bool:
"""Check if legacy key/data file migration has been completed.
Args:
db: Database session
Returns:
True if legacy migration is completed, False otherwise
"""
settings = await SystemSettingsService.get_or_create(db)
return settings.migration_legacy_files_completed
@staticmethod
async def mark_migration_legacy_files_completed(
db: AsyncSession,
timestamp: Optional[datetime] = None
) -> None:
"""Mark the legacy key/data file migration as completed.
Args:
db: Database session
timestamp: Optional timestamp to set, defaults to current time
"""
settings = await SystemSettingsService.get_or_create(db)
settings.migration_legacy_files_completed = True
settings.last_scan_timestamp = timestamp or datetime.now(timezone.utc)
await db.commit()
logger.info("Marked legacy files migration as completed")
@staticmethod
async def is_legacy_key_cleanup_completed(db: AsyncSession) -> bool:
"""Check if legacy key file cleanup has been completed.
Args:
db: Database session
Returns:
True if cleanup is completed, False otherwise
"""
settings = await SystemSettingsService.get_or_create(db)
return settings.legacy_key_cleanup_completed
@staticmethod
async def mark_legacy_key_cleanup_completed(
db: AsyncSession,
timestamp: Optional[datetime] = None
) -> None:
"""Mark the legacy key file cleanup as completed.
Args:
db: Database session
timestamp: Optional timestamp to set, defaults to current time
"""
settings = await SystemSettingsService.get_or_create(db)
settings.legacy_key_cleanup_completed = True
settings.last_scan_timestamp = timestamp or datetime.now(timezone.utc)
await db.commit()
logger.info("Marked legacy key file cleanup as completed")
@staticmethod
async def mark_initial_media_scan_completed(
db: AsyncSession,
@@ -154,6 +214,8 @@ class SystemSettingsService:
settings.initial_scan_completed = False
settings.initial_nfo_scan_completed = False
settings.initial_media_scan_completed = False
settings.migration_legacy_files_completed = False
settings.legacy_key_cleanup_completed = False
settings.last_scan_timestamp = None
await db.commit()
logger.info("Reset all scan completion flags")

View File

@@ -7,7 +7,7 @@ errors in provider operations with automatic retry mechanisms.
import functools
import logging
from typing import Any, Callable, TypeVar
from typing import Any, Callable, Optional, TypeVar
logger = logging.getLogger(__name__)
@@ -42,41 +42,97 @@ class DownloadError(Exception):
class RecoveryStrategies:
"""Strategies for handling errors and recovering from failures."""
def __init__(
self,
max_retries: int = 3,
base_delay: float = 1.0,
max_delay: float = 60.0,
exponential_base: float = 2.0,
) -> None:
"""Initialize recovery strategies.
Args:
max_retries: Maximum number of retry attempts.
base_delay: Initial delay between retries in seconds.
max_delay: Maximum delay between retries in seconds.
exponential_base: Base for exponential backoff multiplier.
"""
self.max_retries = max_retries
self.base_delay = base_delay
self.max_delay = max_delay
self.exponential_base = exponential_base
def _calculate_delay(self, attempt: int) -> float:
"""Calculate delay for given retry attempt using exponential backoff.
Args:
attempt: Zero-based retry attempt number.
Returns:
Delay in seconds before next retry.
"""
delay = self.base_delay * (self.exponential_base ** attempt)
return min(delay, self.max_delay)
@staticmethod
def handle_network_failure(
func: Callable, *args: Any, **kwargs: Any
) -> Any:
"""Handle network failures with basic retry logic."""
"""Handle network failures with exponential backoff retry logic."""
last_error: Optional[Exception] = None
max_retries = 3
base_delay = 1.0
max_delay = 60.0
exponential_base = 2.0
for attempt in range(max_retries):
try:
return func(*args, **kwargs)
except (NetworkError, ConnectionError):
if attempt == max_retries - 1:
raise
logger.warning(
"Network error on attempt %d, retrying...",
attempt + 1,
)
continue
except (NetworkError, ConnectionError, TimeoutError) as exc:
last_error = exc
if attempt < max_retries - 1:
delay = base_delay * (exponential_base ** attempt)
delay = min(delay, max_delay)
logger.warning(
"Network error on attempt %d/%d, retrying in %.1fs: %s",
attempt + 1, max_retries, delay, exc
)
import time
time.sleep(delay)
continue
if last_error:
raise last_error
raise NetworkError("Network failure after retries")
@staticmethod
def handle_download_failure(
func: Callable, *args: Any, **kwargs: Any
) -> Any:
"""Handle download failures with retry logic."""
"""Handle download failures with exponential backoff retry logic."""
last_error: Optional[Exception] = None
max_retries = 2
base_delay = 1.0
max_delay = 60.0
exponential_base = 2.0
for attempt in range(max_retries):
try:
return func(*args, **kwargs)
except DownloadError:
if attempt == max_retries - 1:
raise
logger.warning(
"Download error on attempt %d, retrying...",
attempt + 1,
)
continue
except DownloadError as exc:
last_error = exc
if attempt < max_retries - 1:
delay = base_delay * (exponential_base ** attempt)
delay = min(delay, max_delay)
logger.warning(
"Download error on attempt %d/%d, retrying in %.1fs: %s",
attempt + 1, max_retries, delay, exc
)
import time
time.sleep(delay)
continue
if last_error:
raise last_error
raise DownloadError("Download failed after retries")
class FileCorruptionDetector:

View File

@@ -0,0 +1,3 @@
from src.server.exceptions.exceptions.Exceptions import MatchNotFoundError, NoKeyFoundException
__all__ = ["MatchNotFoundError", "NoKeyFoundException"]

View File

@@ -27,6 +27,7 @@ from src.server.api.health import router as health_router
from src.server.api.logging import router as logging_router
from src.server.api.nfo import router as nfo_router
from src.server.api.scheduler import router as scheduler_router
from src.server.api.setup_endpoints import router as setup_router
from src.server.api.websocket import router as websocket_router
from src.server.controllers.error_controller import (
not_found_handler,
@@ -38,7 +39,6 @@ from src.server.controllers.page_controller import router as page_router
from src.server.middleware.auth import AuthMiddleware
from src.server.middleware.error_handler import register_exception_handlers
from src.server.middleware.setup_redirect import SetupRedirectMiddleware
from src.server.services.anime_service import sync_series_from_data_files
from src.server.services.progress_service import get_progress_service
from src.server.services.websocket_service import get_websocket_service
@@ -104,6 +104,107 @@ async def _check_incomplete_series_on_startup(background_loader) -> None:
logger.exception("Failed to check incomplete series on startup")
async def _run_startup_health_checks(logger) -> dict:
"""Run startup health checks for critical dependencies.
Checks:
- ffmpeg availability
- DNS resolution for aniworld.to and api.themoviedb.org
- anime_directory configuration and writability
Args:
logger: Logger instance for recording check results.
Returns:
dict: Health check results with status and details for each check.
"""
import asyncio
import shutil
import socket
from typing import Any, Dict
checks: Dict[str, Any] = {
"ffmpeg": {"status": "unknown", "message": None},
"dns_aniworld": {"status": "unknown", "message": None},
"dns_tmdb": {"status": "unknown", "message": None},
"anime_directory": {"status": "unknown", "message": None, "path": None},
}
# Check ffmpeg availability
try:
ffmpeg_path = shutil.which("ffmpeg")
if ffmpeg_path:
checks["ffmpeg"]["status"] = "ok"
checks["ffmpeg"]["message"] = f"Found at {ffmpeg_path}"
logger.debug("ffmpeg health check passed: %s", ffmpeg_path)
else:
checks["ffmpeg"]["status"] = "warning"
checks["ffmpeg"]["message"] = "ffmpeg not found in PATH"
logger.warning("ffmpeg health check failed: not in PATH")
except Exception as e:
checks["ffmpeg"]["status"] = "error"
checks["ffmpeg"]["message"] = str(e)
logger.warning("Could not check ffmpeg: %s", e)
# Check DNS resolution for aniworld.to
try:
socket.gethostbyname("aniworld.to")
checks["dns_aniworld"]["status"] = "ok"
checks["dns_aniworld"]["message"] = "Resolved successfully"
logger.debug("DNS health check passed for aniworld.to")
except socket.gaierror as e:
checks["dns_aniworld"]["status"] = "warning"
checks["dns_aniworld"]["message"] = f"DNS resolution failed: {e}"
logger.warning("DNS health check failed for aniworld.to: %s", e)
except Exception as e:
checks["dns_aniworld"]["status"] = "warning"
checks["dns_aniworld"]["message"] = f"Unexpected error: {e}"
logger.warning("Unexpected DNS error for aniworld.to: %s", e)
# Check DNS resolution for api.themoviedb.org
try:
socket.gethostbyname("api.themoviedb.org")
checks["dns_tmdb"]["status"] = "ok"
checks["dns_tmdb"]["message"] = "Resolved successfully"
logger.debug("DNS health check passed for api.themoviedb.org")
except socket.gaierror as e:
checks["dns_tmdb"]["status"] = "warning"
checks["dns_tmdb"]["message"] = f"DNS resolution failed: {e}"
logger.warning("DNS health check failed for api.themoviedb.org: %s", e)
except Exception as e:
checks["dns_tmdb"]["status"] = "warning"
checks["dns_tmdb"]["message"] = f"Unexpected error: {e}"
logger.warning("Unexpected DNS error for api.themoviedb.org: %s", e)
# Check anime_directory configuration and writability
from src.config.settings import settings
anime_dir = settings.anime_directory
if not anime_dir:
checks["anime_directory"]["status"] = "error"
checks["anime_directory"]["message"] = "anime_directory not configured"
checks["anime_directory"]["path"] = None
logger.error("anime_directory health check failed: not configured")
else:
import os
checks["anime_directory"]["path"] = anime_dir
if not os.path.isdir(anime_dir):
checks["anime_directory"]["status"] = "error"
checks["anime_directory"]["message"] = f"Directory does not exist: {anime_dir}"
logger.error("anime_directory health check failed: %s does not exist", anime_dir)
elif not os.access(anime_dir, os.W_OK):
checks["anime_directory"]["status"] = "error"
checks["anime_directory"]["message"] = f"Directory not writable: {anime_dir}"
logger.error("anime_directory health check failed: %s not writable", anime_dir)
else:
checks["anime_directory"]["status"] = "ok"
checks["anime_directory"]["message"] = f"Directory exists and is writable: {anime_dir}"
logger.debug("anime_directory health check passed: %s", anime_dir)
return checks
@asynccontextmanager
async def lifespan(_application: FastAPI):
"""Manage application lifespan (startup and shutdown).
@@ -114,6 +215,7 @@ async def lifespan(_application: FastAPI):
"""
# Setup logging first with INFO level
logger = setup_logging(log_level="INFO")
logger.info("Starting FastAPI application v%s", APP_VERSION)
# Track successful initialization steps
initialized = {
@@ -242,7 +344,6 @@ async def lifespan(_application: FastAPI):
from src.server.services.initialization_service import (
perform_initial_setup,
perform_media_scan_if_needed,
perform_nfo_scan_if_needed,
)
try:
@@ -271,9 +372,6 @@ async def lifespan(_application: FastAPI):
"exist yet): %s", e
)
# Run NFO scan only on first run (if configured)
await perform_nfo_scan_if_needed()
# Initialize download service
try:
from src.server.utils.dependencies import get_download_service
@@ -297,19 +395,6 @@ async def lifespan(_application: FastAPI):
except Exception as e:
logger.warning("Failed to start background loader service: %s", e)
# Initialize and start scheduler service
try:
from src.server.services.scheduler_service import (
get_scheduler_service,
)
scheduler_service = get_scheduler_service()
await scheduler_service.start()
initialized['scheduler'] = True
logger.info("Scheduler service started")
except Exception as e:
logger.warning("Failed to start scheduler service: %s", e)
# Continue - scheduler is optional
# Run media scan only on first run
await perform_media_scan_if_needed(background_loader)
else:
@@ -317,6 +402,22 @@ async def lifespan(_application: FastAPI):
"Download service initialization skipped - "
"anime directory not configured"
)
# Initialize and start scheduler service (independent of anime_directory)
# The scheduler loads its own config from config.json and the
# anime_directory may be configured there even if the env var is empty.
try:
logger.info("Initializing scheduler service...")
from src.server.services.scheduler.scheduler_service import (
get_scheduler_service,
)
scheduler_service = get_scheduler_service()
logger.info("Scheduler service instance obtained, starting...")
await scheduler_service.start()
initialized['scheduler'] = True
logger.info("Scheduler service started successfully")
except Exception as e:
logger.warning("Failed to start scheduler service: %s", e)
except (OSError, RuntimeError, ValueError) as e:
logger.warning("Failed to initialize services: %s", e)
# Continue startup - services can be initialized later
@@ -329,6 +430,27 @@ async def lifespan(_application: FastAPI):
logger.info(
"API documentation available at http://127.0.0.1:8000/api/docs"
)
# Check for ffmpeg availability and warn if missing
try:
import shutil as _shutil
if _shutil.which("ffmpeg") is None:
logger.warning(
"ffmpeg not found in PATH. HLS streams may fail to download. "
"Install ffmpeg to enable HLS support."
)
else:
logger.debug("ffmpeg found at: %s", _shutil.which("ffmpeg"))
except Exception as _exc:
logger.warning("Could not check for ffmpeg: %s", _exc)
# Run startup health checks and store results for /health endpoint
try:
startup_checks = await _run_startup_health_checks(logger)
app.state.startup_checks = startup_checks
except Exception as _exc:
logger.warning("Could not run startup health checks: %s", _exc)
app.state.startup_checks = {}
except Exception as e:
logger.error("Error during startup: %s", e, exc_info=True)
startup_error = e
@@ -373,7 +495,9 @@ async def lifespan(_application: FastAPI):
# 1. Stop scheduler service (only if initialized)
if initialized['scheduler']:
try:
from src.server.services.scheduler_service import get_scheduler_service
from src.server.services.scheduler.scheduler_service import (
get_scheduler_service,
)
scheduler_service = get_scheduler_service()
logger.info("Stopping scheduler service...")
await asyncio.wait_for(
@@ -418,8 +542,8 @@ async def lifespan(_application: FastAPI):
# 4. Shutdown download service and persist active downloads
try:
from src.server.services.download_service import ( # noqa: E501
_download_service_instance,
from src.server.services.download_service import (
_download_service_instance, # noqa: E501
)
if _download_service_instance is not None:
logger.info("Stopping download service...")
@@ -476,11 +600,13 @@ async def lifespan(_application: FastAPI):
raise startup_error
from src.server.utils.version import APP_VERSION
# Initialize FastAPI app with lifespan
app = FastAPI(
title="Aniworld Download Manager",
description="Modern web interface for Aniworld anime download management",
version="1.0.1",
version=APP_VERSION,
docs_url="/api/docs",
redoc_url="/api/redoc",
lifespan=lifespan
@@ -508,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)
@@ -519,6 +650,7 @@ app.include_router(scheduler_router)
app.include_router(anime_router)
app.include_router(download_router)
app.include_router(nfo_router)
app.include_router(setup_router)
app.include_router(logging_router)
app.include_router(websocket_router)

View File

@@ -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"
)
},
)

View File

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

View File

@@ -32,10 +32,12 @@ class SetupRedirectMiddleware(BaseHTTPMiddleware):
# Paths that should always be accessible, even without setup
EXEMPT_PATHS = {
"/setup", # Setup page itself
"/setup/unresolved", # Unresolved folders page (after setup)
"/loading", # Loading page (initialization progress)
"/login", # Login page (needs to be accessible after setup)
"/queue", # Queue page (for initial load)
"/api/auth/", # All auth endpoints (setup, login, logout, register)
"/api/setup/", # Setup API (unresolved folders, etc.)
"/ws/connect", # WebSocket connection (needed for loading page)
"/api/queue/", # Queue API endpoints
"/api/downloads/", # Download API endpoints
@@ -79,30 +81,63 @@ 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:
"""Check if the unresolved phase has been completed.
Returns:
True if unresolved phase is complete, False otherwise
"""
try:
config_service = get_config_service()
config = config_service.load_config()
other = config.other or {}
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
) -> Response:
@@ -116,31 +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"):
if path in ("/setup", "/loading", "/setup/unresolved"):
if not self._needs_setup():
# Setup is complete, check loading status
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?phase=nfo", status_code=302)
elif path == "/loading":
# Check if initialization is complete
try:
from src.server.database.connection import get_db_session
from src.server.database.system_settings_service import (
SystemSettingsService,
)
async with get_db_session() as db:
is_complete = await SystemSettingsService.is_initial_scan_completed(db)
if is_complete:
# Initialization complete, redirect to login
return RedirectResponse(url="/login", status_code=302)
except Exception:
# If we can't check, allow access to loading page
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):

View File

@@ -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."""
@@ -116,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
@@ -127,3 +128,170 @@ 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",
)

View File

@@ -73,9 +73,6 @@ class SetupRequest(BaseModel):
scheduler_auto_download_after_rescan: Optional[bool] = Field(
default=False, description="Auto-download missing episodes after rescan"
)
scheduler_folder_scan_enabled: Optional[bool] = Field(
default=False, description="Run folder maintenance during scheduled run"
)
# Logging configuration
logging_level: Optional[str] = Field(

View File

@@ -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"]
@@ -39,11 +41,32 @@ class SchedulerConfig(BaseModel):
description="Automatically queue and start downloads for all missing "
"episodes after a scheduled rescan completes.",
)
folder_scan_enabled: bool = Field(
default=False,
description="Run folder maintenance (NFO repair, folder renaming, "
"poster checks) during the scheduled run.",
nfo_scan_after_rescan: bool = Field(
default=True,
description="Run NFO validation and creation after a scheduled rescan "
"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")
def __init__(self, **data):
super().__init__(**data)
# Map legacy keys to primary fields only when primary key absent from data.
# "key in data" checks for explicit presence (even False/None), not just truthiness.
if self.auto_download is not None and "auto_download_after_rescan" not in data:
object.__setattr__(self, "auto_download_after_rescan", self.auto_download)
@field_validator("schedule_time")
@classmethod
@@ -57,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]:
@@ -69,6 +126,22 @@ class SchedulerConfig(BaseModel):
)
return v
def model_dump(self, **kwargs) -> Dict[str, object]:
"""Serialize, excluding legacy alias fields when they are None.
The alias fields (auto_download, folder_scan) must not be written to
config.json as null entries, otherwise a roundtrip load sees the key
present (哪怕 value is None) and skips the alias-to-primary mapping.
"""
data = super().model_dump(**kwargs)
# Drop None alias fields so they don't pollute config.json.
# They are still settable via the constructor for backward compatibility.
if data.get("auto_download") is None:
data.pop("auto_download", None)
if data.get("folder_scan") is None:
data.pop("folder_scan", None)
return data
class BackupConfig(BaseModel):
"""Configuration for automatic backups of application data."""
@@ -94,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")
@@ -171,6 +244,12 @@ class AppConfig(BaseModel):
logging: LoggingConfig = Field(default_factory=LoggingConfig)
backup: BackupConfig = Field(default_factory=BackupConfig)
nfo: NFOConfig = Field(default_factory=NFOConfig)
scan_key_overrides: Dict[str, str] = Field(
default_factory=dict,
description="Map of folder names to provider keys for scan overrides. "
"Used when auto-generated keys from folder names are incorrect. "
"Format: {\"Folder Name\": \"actual-provider-key\"}"
)
other: Dict[str, object] = Field(
default_factory=dict, description="Arbitrary other settings"
)
@@ -205,11 +284,67 @@ class AppConfig(BaseModel):
class ConfigUpdate(BaseModel):
scheduler: Optional[SchedulerConfig] = None
logging: Optional[LoggingConfig] = None
backup: Optional[BackupConfig] = None
nfo: Optional[NFOConfig] = None
other: Optional[Dict[str, object]] = 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, 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.
@@ -217,16 +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)

View File

@@ -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
@@ -22,6 +24,7 @@ class DownloadStatus(str, Enum):
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
PERMANENTLY_FAILED = "permanently_failed"
class DownloadPriority(str, Enum):
@@ -41,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."""
@@ -217,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):

View File

@@ -355,3 +355,43 @@ class NFOMissingResponse(BaseModel):
...,
description="List of series missing NFO"
)
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")
missing_tags: List[str] = Field(
default_factory=list,
description="List of missing required tag names"
)
required_tags: List[str] = Field(
default_factory=list,
description="All required tag names for reference"
)
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."""
success: bool = Field(..., description="Whether repair succeeded")
message: str = Field(..., description="Human-readable result message")
repaired_tags: List[str] = Field(
default_factory=list,
description="Tags that were missing before repair"
)

View File

@@ -0,0 +1,9 @@
"""NFO package - TV show metadata generation for Kodi/XBMC.
Re-exports the public API for the nfo package.
"""
from src.server.nfo.nfo_models import TVShowNFO
from src.server.nfo.tmdb_client import TMDBClient, TMDBAPIError
from src.server.nfo.nfo_generator import generate_tvshow_nfo
from src.server.nfo.nfo_mapper import tmdb_to_nfo_model

View File

@@ -4,7 +4,7 @@ This module provides functions to generate tvshow.nfo XML files from
TVShowNFO Pydantic models, adapted from the scraper project.
Example:
>>> from src.core.entities.nfo_models import TVShowNFO
>>> from src.server.nfo.nfo_models import TVShowNFO
>>> nfo = TVShowNFO(title="Test Show", year=2020, tmdbid=12345)
>>> xml_string = generate_tvshow_nfo(nfo)
"""
@@ -15,7 +15,7 @@ from typing import Optional
from lxml import etree
from src.config.settings import settings
from src.core.entities.nfo_models import TVShowNFO
from src.server.nfo.nfo_models import TVShowNFO
logger = logging.getLogger(__name__)

View File

@@ -11,7 +11,7 @@ import logging
from datetime import datetime
from typing import Any, Callable, Dict, List, Optional
from src.core.entities.nfo_models import (
from src.server.nfo.nfo_models import (
ActorInfo,
ImageInfo,
NamedSeason,

View File

@@ -0,0 +1,335 @@
"""Pydantic models for NFO metadata based on Kodi/XBMC standard.
This module provides data models for tvshow.nfo files that are compatible
with media center applications like Kodi, Plex, and Jellyfin.
Example:
>>> nfo = TVShowNFO(
... title="Attack on Titan",
... year=2013,
... tmdbid=1429
... )
>>> nfo.premiered = "2013-04-07"
"""
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field, HttpUrl, field_validator
class RatingInfo(BaseModel):
"""Rating information from various sources.
Attributes:
name: Source of the rating (e.g., 'themoviedb', 'imdb')
value: Rating value (typically 0-10)
votes: Number of votes
max_rating: Maximum possible rating (default: 10)
default: Whether this is the default rating to display
"""
name: str = Field(..., description="Rating source name")
value: float = Field(..., ge=0, description="Rating value")
votes: Optional[int] = Field(None, ge=0, description="Number of votes")
max_rating: int = Field(10, ge=1, description="Maximum rating value")
default: bool = Field(False, description="Is this the default rating")
@field_validator('value')
@classmethod
def validate_value(cls, v: float, info) -> float:
"""Ensure rating value doesn't exceed max_rating."""
# Note: max_rating is not available yet during validation,
# so we use a reasonable default check
if v > 10:
raise ValueError("Rating value cannot exceed 10")
return v
class ActorInfo(BaseModel):
"""Actor/cast member information.
Attributes:
name: Actor's name
role: Character name/role
thumb: URL to actor's photo
profile: URL to actor's profile page
tmdbid: TMDB ID for the actor
"""
name: str = Field(..., description="Actor's name")
role: Optional[str] = Field(None, description="Character role")
thumb: Optional[HttpUrl] = Field(None, description="Actor photo URL")
profile: Optional[HttpUrl] = Field(None, description="Actor profile URL")
tmdbid: Optional[int] = Field(None, description="TMDB actor ID")
class ImageInfo(BaseModel):
"""Image information for posters, fanart, and logos.
Attributes:
url: URL to the image
aspect: Image aspect/type (e.g., 'poster', 'clearlogo', 'logo')
season: Season number for season-specific images
type: Image type (e.g., 'season')
"""
url: HttpUrl = Field(..., description="Image URL")
aspect: Optional[str] = Field(
None,
description="Image aspect (poster, clearlogo, logo)"
)
season: Optional[int] = Field(None, ge=-1, description="Season number")
type: Optional[str] = Field(None, description="Image type")
class NamedSeason(BaseModel):
"""Named season information.
Attributes:
number: Season number
name: Season name/title
"""
number: int = Field(..., ge=0, description="Season number")
name: str = Field(..., description="Season name")
class UniqueID(BaseModel):
"""Unique identifier from various sources.
Attributes:
type: ID source type (tmdb, imdb, tvdb)
value: The ID value
default: Whether this is the default ID
"""
type: str = Field(..., description="ID type (tmdb, imdb, tvdb)")
value: str = Field(..., description="ID value")
default: bool = Field(False, description="Is default ID")
class TVShowNFO(BaseModel):
"""Main tvshow.nfo structure following Kodi/XBMC standard.
This model represents the complete metadata for a TV show that can be
serialized to XML for use with media center applications.
Attributes:
title: Main title of the show
originaltitle: Original title (e.g., in original language)
showtitle: Show title (often same as title)
sorttitle: Title used for sorting
year: Release year
plot: Full plot description
outline: Short plot summary
tagline: Show tagline/slogan
runtime: Episode runtime in minutes
mpaa: Content rating (e.g., TV-14, TV-MA)
certification: Additional certification info
premiered: Premiere date (YYYY-MM-DD format)
status: Show status (e.g., 'Continuing', 'Ended')
studio: List of production studios
genre: List of genres
country: List of countries
tag: List of tags/keywords
ratings: List of ratings from various sources
userrating: User's personal rating
watched: Whether the show has been watched
playcount: Number of times watched
tmdbid: TMDB ID
imdbid: IMDB ID
tvdbid: TVDB ID
uniqueid: List of unique IDs
thumb: List of thumbnail/poster images
fanart: List of fanart/backdrop images
actors: List of cast members
namedseason: List of named seasons
trailer: Trailer URL
dateadded: Date when added to library
"""
# Required fields
title: str = Field(..., description="Show title", min_length=1)
# Basic information (optional)
originaltitle: Optional[str] = Field(None, description="Original title")
showtitle: Optional[str] = Field(None, description="Show title")
sorttitle: Optional[str] = Field(None, description="Sort title")
year: Optional[int] = Field(
None,
ge=1900,
le=2100,
description="Release year"
)
# Plot and description
plot: Optional[str] = Field(None, description="Full plot description")
outline: Optional[str] = Field(None, description="Short plot summary")
tagline: Optional[str] = Field(None, description="Show tagline")
# Technical details
runtime: Optional[int] = Field(
None,
ge=0,
description="Episode runtime in minutes"
)
mpaa: Optional[str] = Field(None, description="Content rating")
fsk: Optional[str] = Field(
None,
description="German FSK rating (e.g., 'FSK 12', 'FSK 16')"
)
certification: Optional[str] = Field(
None,
description="Certification info"
)
# Status and dates
premiered: Optional[str] = Field(
None,
description="Premiere date (YYYY-MM-DD)"
)
status: Optional[str] = Field(None, description="Show status")
dateadded: Optional[str] = Field(
None,
description="Date added to library"
)
# Multi-value fields
studio: List[str] = Field(
default_factory=list,
description="Production studios"
)
genre: List[str] = Field(
default_factory=list,
description="Genres"
)
country: List[str] = Field(
default_factory=list,
description="Countries"
)
tag: List[str] = Field(
default_factory=list,
description="Tags/keywords"
)
# IDs
tmdbid: Optional[int] = Field(None, description="TMDB ID")
imdbid: Optional[str] = Field(None, description="IMDB ID")
tvdbid: Optional[int] = Field(None, description="TVDB ID")
uniqueid: List[UniqueID] = Field(
default_factory=list,
description="Unique IDs"
)
# Ratings and viewing info
ratings: List[RatingInfo] = Field(
default_factory=list,
description="Ratings"
)
userrating: Optional[float] = Field(
None,
ge=0,
le=10,
description="User rating"
)
watched: bool = Field(False, description="Watched status")
playcount: Optional[int] = Field(
None,
ge=0,
description="Play count"
)
# Media
thumb: List[ImageInfo] = Field(
default_factory=list,
description="Thumbnail images"
)
fanart: List[ImageInfo] = Field(
default_factory=list,
description="Fanart images"
)
# Cast and crew
actors: List[ActorInfo] = Field(
default_factory=list,
description="Cast members"
)
# Seasons
namedseason: List[NamedSeason] = Field(
default_factory=list,
description="Named seasons"
)
# Additional
trailer: Optional[HttpUrl] = Field(None, description="Trailer URL")
@field_validator('premiered')
@classmethod
def validate_premiered_date(cls, v: Optional[str]) -> Optional[str]:
"""Validate premiered date format (YYYY-MM-DD)."""
if v is None:
return v
# Check format strictly: YYYY-MM-DD
if len(v) != 10 or v[4] != '-' or v[7] != '-':
raise ValueError(
"Premiered date must be in YYYY-MM-DD format"
)
try:
datetime.strptime(v, '%Y-%m-%d')
except ValueError as exc:
raise ValueError(
"Premiered date must be in YYYY-MM-DD format"
) from exc
return v
@field_validator('dateadded')
@classmethod
def validate_dateadded(cls, v: Optional[str]) -> Optional[str]:
"""Validate dateadded format (YYYY-MM-DD HH:MM:SS)."""
if v is None:
return v
# Check format strictly: YYYY-MM-DD HH:MM:SS
if len(v) != 19 or v[4] != '-' or v[7] != '-' or v[10] != ' ' or v[13] != ':' or v[16] != ':':
raise ValueError(
"Dateadded must be in YYYY-MM-DD HH:MM:SS format"
)
try:
datetime.strptime(v, '%Y-%m-%d %H:%M:%S')
except ValueError as exc:
raise ValueError(
"Dateadded must be in YYYY-MM-DD HH:MM:SS format"
) from exc
return v
@field_validator('imdbid')
@classmethod
def validate_imdbid(cls, v: Optional[str]) -> Optional[str]:
"""Validate IMDB ID format (should start with 'tt')."""
if v is None:
return v
if not v.startswith('tt'):
raise ValueError("IMDB ID must start with 'tt'")
if not v[2:].isdigit():
raise ValueError("IMDB ID must be 'tt' followed by digits")
return v
def model_post_init(self, __context) -> None:
"""Set default values after initialization."""
# Set showtitle to title if not provided
if self.showtitle is None:
self.showtitle = self.title
# Set originaltitle to title if not provided
if self.originaltitle is None:
self.originaltitle = self.title

View File

@@ -39,6 +39,7 @@ class TMDBClient:
DEFAULT_BASE_URL = "https://api.themoviedb.org/3"
DEFAULT_IMAGE_BASE_URL = "https://image.tmdb.org/t/p"
NEGATIVE_CACHE_TTL = 86400 # 24 hours
def __init__(
self,
@@ -64,6 +65,7 @@ class TMDBClient:
self.max_connections = max_connections
self.session: Optional[aiohttp.ClientSession] = None
self._cache: Dict[str, Any] = {}
self._negative_cache: Dict[str, float] = {} # query -> timestamp when cached
# TMDB allows ~40 req/s; use 30 concurrent + per-second throttle to stay safe
self._semaphore = asyncio.Semaphore(30)
self._rate_limit_lock = asyncio.Lock()
@@ -116,7 +118,17 @@ class TMDBClient:
logger.debug("Cache hit for %s", endpoint)
return self._cache[cache_key]
delay = 2
# Check negative cache (cached empty results)
negative_cache_key = f"{endpoint}:{str(sorted(params.items()))}"
if negative_cache_key in self._negative_cache:
if time.monotonic() - self._negative_cache[negative_cache_key] < self.NEGATIVE_CACHE_TTL:
logger.debug("Negative cache hit for %s (cached empty result)", endpoint)
return {"results": []}
else:
# Expired negative cache entry
del self._negative_cache[negative_cache_key]
delay = 1
last_error = None
# Rate limiting: ensure we don't exceed ~35 requests/second
@@ -150,7 +162,7 @@ class TMDBClient:
raise TMDBAPIError(f"Resource not found: {endpoint}")
elif resp.status == 429:
# Rate limit - wait longer with exponential backoff
retry_after = int(resp.headers.get('Retry-After', max(delay * 2, 10)))
retry_after = int(resp.headers.get('Retry-After', max(delay * 2, 2)))
logger.warning("Rate limited, waiting %ss", retry_after)
await asyncio.sleep(retry_after)
continue
@@ -158,6 +170,10 @@ class TMDBClient:
resp.raise_for_status()
data = await resp.json()
self._cache[cache_key] = data
# Cache negative result if empty
if endpoint.startswith("search/") and not data.get("results"):
self._negative_cache[negative_cache_key] = time.monotonic()
logger.debug("Cached negative result for %s", endpoint)
return data
except asyncio.TimeoutError as e:
@@ -165,7 +181,7 @@ class TMDBClient:
if attempt < max_retries - 1:
logger.warning("Request timeout (attempt %s), retrying in %ss", attempt + 1, delay)
await asyncio.sleep(delay)
delay = min(delay * 2, 30)
delay *= 2
else:
logger.error("Request timed out after %s attempts", max_retries)
@@ -173,7 +189,11 @@ class TMDBClient:
last_error = e
# If connector/session was closed, try to recreate it
if "Connector is closed" in str(e) or isinstance(e, AttributeError):
logger.warning("Session issue detected, recreating session: %s", e)
logger.warning(
"Session issue detected, recreating session: %s",
e,
exc_info=True,
)
self.session = None
await self._ensure_session()
@@ -189,7 +209,7 @@ class TMDBClient:
if attempt < max_retries - 1:
logger.warning("Request failed (attempt %s): %s, retrying in %ss", attempt + 1, e, delay)
await asyncio.sleep(delay)
delay = min(delay * 2, 30)
delay *= 2
else:
logger.error("Request failed after %s attempts: %s", max_retries, e)
@@ -220,6 +240,34 @@ class TMDBClient:
{"query": query, "language": language, "page": page}
)
async def search_multi(
self,
query: str,
language: str = "en-US",
page: int = 1
) -> Dict[str, Any]:
"""Search for movies and TV shows by name using TMDB multi search.
Multi search returns both movies and TV shows, useful for anime
that might be indexed as movies on TMDB.
Args:
query: Search query (show name)
language: Language for results (default: English)
page: Page number for pagination
Returns:
Search results with list of movies and TV shows
Example:
>>> results = await client.search_multi("Suzume no Tojimari")
>>> shows = [r for r in results["results"] if r["media_type"] == "tv"]
"""
return await self._request(
"search/multi",
{"query": query, "language": language, "page": page}
)
async def get_tv_show_details(
self,
tv_id: int,
@@ -339,8 +387,67 @@ class TMDBClient:
await self.session.close()
self.session = None
logger.debug("TMDB client session closed")
def __del__(self):
"""Warn if session is unclosed during garbage collection."""
if self.session is not None and not self.session.closed:
logger.warning(
"TMDBClient: unclosed session detected. "
"Use 'async with TMDBClient(...)' or call close() explicitly."
)
def clear_cache(self):
"""Clear the request cache."""
self._cache.clear()
logger.debug("TMDB client cache cleared")
def clear_negative_cache(self):
"""Clear the negative result cache."""
self._negative_cache.clear()
logger.debug("TMDB negative cache cleared")
def cleanup_expired_negative_cache(self) -> int:
"""Remove expired entries from negative cache.
Returns:
Number of entries removed
"""
now = time.monotonic()
expired_keys = [
key for key, timestamp in self._negative_cache.items()
if now - timestamp >= self.NEGATIVE_CACHE_TTL
]
for key in expired_keys:
del self._negative_cache[key]
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/"

View File

View File

@@ -5,10 +5,12 @@ import logging
import os
import re
import shutil
import time
import threading
from pathlib import Path
from urllib.parse import quote
import chardet
import requests
from bs4 import BeautifulSoup
from events import Events
@@ -80,6 +82,37 @@ if not download_error_logger.handlers:
noKeyFound_logger = logging.getLogger()
def _decode_html_content(content: bytes) -> str:
"""Decode HTML content with encoding detection.
Uses chardet to detect the actual encoding of the content,
falling back to utf-8 with replacement error handling.
Args:
content: Raw HTML bytes from the response
Returns:
Decoded string content
"""
detected = chardet.detect(content)
encoding = detected.get('encoding', 'utf-8')
confidence = detected.get('confidence', 0)
if confidence < 0.7:
logger.debug(
"Low encoding confidence (%.2f) for detected encoding '%s', using utf-8",
confidence,
encoding
)
encoding = 'utf-8'
try:
return content.decode(encoding, errors='replace')
except Exception as exc:
logger.warning("Failed to decode content with %s: %s, using utf-8 replace", encoding, exc)
return content.decode('utf-8', errors='replace')
class AniworldLoader(Loader):
def __init__(self) -> None:
self.SUPPORTED_PROVIDERS = DEFAULT_PROVIDERS
@@ -90,7 +123,10 @@ class AniworldLoader(Loader):
self.LULUVDO_USER_AGENT = LULUVDO_USER_AGENT
self.PROVIDER_HEADERS = {
ProviderType.VIDMOLY.value: ['Referer: "https://vidmoly.to"'],
ProviderType.DOODSTREAM.value: ['Referer: "https://dood.li/"'],
ProviderType.DOODSTREAM.value: [
'Referer: "https://dood.li/"',
'Referer: "https://playmogo.com/"',
],
ProviderType.VOE.value: [f"User-Agent: {self.RANDOM_USER_AGENT}"],
ProviderType.LULUVDO.value: [
f"User-Agent: {self.LULUVDO_USER_AGENT}",
@@ -123,6 +159,7 @@ class AniworldLoader(Loader):
self._KeyHTMLDict = {}
self._EpisodeHTMLDict = {}
self._YearDict = {}
self.Providers = Providers()
# Events: download_progress is triggered with progress dict
@@ -231,7 +268,7 @@ class AniworldLoader(Loader):
language_code = self._get_language_key(language)
episode_soup = BeautifulSoup(
self._get_episode_html(season, episode, key).content,
_decode_html_content(self._get_episode_html(season, episode, key).content),
'html.parser'
)
change_language_box_div = episode_soup.find(
@@ -249,6 +286,150 @@ class AniworldLoader(Loader):
logger.debug("Available languages for S%02dE%03d: %s, requested: %s, available: %s", season, episode, languages, language_code, is_available)
return is_available
def _check_url_alive(
self,
url: str,
headers: dict | None = None,
timeout: int = 10,
) -> bool:
"""Probe a provider URL with HEAD before committing to yt-dlp.
Skips dead providers quickly so the failover loop never blocks
waiting for yt-dlp to fail on a 404. Falls back to a streaming
GET when HEAD is not allowed by the upstream server.
Args:
url: URL to probe.
headers: Optional headers to forward with the probe.
timeout: Per-request timeout (seconds).
Returns:
True when the URL responds with a non-4xx status, else False.
"""
try:
response = self.session.head(
url,
headers=headers,
timeout=timeout,
allow_redirects=True,
)
if response.status_code == 405:
response = self.session.get(
url,
headers=headers,
timeout=timeout,
stream=True,
allow_redirects=True,
)
response.close()
if 400 <= response.status_code < 500:
logger.warning(
"Provider URL returned HTTP %s: %s",
response.status_code, url
)
return False
return True
except requests.RequestException as exc:
logger.warning("Provider URL unreachable %s: %s", url, exc)
return False
def _try_direct_stream(
self,
link: str,
output_path: str,
headers: dict | None,
timeout: int,
) -> bool:
"""Stream a direct video URL to disk without yt-dlp.
Used as a fast-path when the resolved provider link already points
at a downloadable video file (``Content-Type: video/*`` or
``application/octet-stream``). HLS and other non-video payloads
are rejected so the caller can fall back to yt-dlp.
Args:
link: Direct download URL.
output_path: Destination file path.
headers: Optional HTTP headers.
timeout: Per-request timeout (seconds).
Returns:
True on a successful save, False when the link is not a
direct video or the download fails.
"""
try:
with self.session.get(
link,
headers=headers,
timeout=timeout,
stream=True,
) as response:
if not response.ok:
logger.debug(
"Direct stream HEAD returned %s for %s",
response.status_code, link[:80]
)
return False
content_type = response.headers.get("Content-Type", "")
if not (
content_type.startswith("video/")
or content_type == "application/octet-stream"
):
logger.debug(
"Direct stream skipped, Content-Type=%s",
content_type
)
return False
logger.info(
"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():
logger.info(
"Cancellation detected during direct stream"
)
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)
return False
def download(
self,
base_directory: str,
@@ -259,7 +440,12 @@ class AniworldLoader(Loader):
language: str = "German Dub"
) -> bool:
"""Download episode to specified directory.
Iterates the providers actually advertised on the episode page
(ordered by SUPPORTED_PROVIDERS preference), probing each URL
before attempting an extraction so dead providers are skipped
immediately instead of stalling yt-dlp on a 404.
Args:
base_directory: Base download directory path
serie_folder: Filesystem folder name (metadata only, used for
@@ -308,12 +494,78 @@ class AniworldLoader(Loader):
temp_path = os.path.join(temp_dir, output_file)
logger.debug("Temporary path: %s", temp_path)
for provider in self.SUPPORTED_PROVIDERS:
logger.debug("Attempting download with provider: %s", provider)
link, header = self._get_direct_link_from_provider(
candidate_providers = self._select_providers_for_episode(
season, episode, key, language
)
if not candidate_providers:
logger.error(
"No providers advertised for S%02dE%03d (%s) in %s",
season, episode, key, language
)
logger.debug("Direct link obtained from provider")
self.clear_cache()
return False
tried: list[str] = []
for provider_name, redirect_url in candidate_providers:
tried.append(provider_name)
logger.debug("Attempting download with provider: %s", provider_name)
probe_headers = {"User-Agent": self.RANDOM_USER_AGENT}
if not self._check_url_alive(
redirect_url,
headers=probe_headers,
timeout=self.DEFAULT_REQUEST_TIMEOUT,
):
logger.info(
"Skipping provider %s, redirect URL not reachable",
provider_name
)
continue
try:
resolved = self._resolve_direct_link(
redirect_url, provider_name
)
except Exception as exc:
logger.warning(
"Provider %s link resolution failed: %s: %s",
provider_name, type(exc).__name__, exc
)
continue
if resolved is None:
logger.info(
"Provider %s returned no direct link", provider_name
)
continue
link, header = resolved
if self._cancel_flag.is_set():
logger.info("Cancellation requested before download start")
_cleanup_temp_file(temp_path)
self.clear_cache()
return False
if self._try_direct_stream(
link,
temp_path,
header,
self.DEFAULT_REQUEST_TIMEOUT,
) and os.path.exists(temp_path):
logger.debug(
"Direct stream succeeded with provider %s", provider_name
)
shutil.copyfile(temp_path, output_path)
os.remove(temp_path)
logger.info(
"Download completed successfully (direct): %s",
output_file
)
self.clear_cache()
return True
_cleanup_temp_file(temp_path)
cancel_flag = self._cancel_flag
@@ -321,10 +573,10 @@ class AniworldLoader(Loader):
if cancel_flag.is_set():
logger.info("Cancellation detected in progress hook")
raise DownloadCancelled("Download cancelled by user")
# Fire the event for progress
self.events.download_progress(d)
ydl_opts = {
'downloader': 'ffmpeg', # Use ffmpeg for proper progress reporting
'fragment_retries': float('inf'),
'outtmpl': temp_path,
'quiet': True,
@@ -333,6 +585,10 @@ class AniworldLoader(Loader):
'nocheckcertificate': True,
'logger': logger,
'progress_hooks': [events_progress_hook],
# yt-dlp defaults to native HLS downloader which warns about
# "Live HLS streams are not supported" - disable to go
# straight to ffmpeg, avoiding the warning
'hls_prefer_native': False,
}
if header:
@@ -340,9 +596,11 @@ class AniworldLoader(Loader):
logger.debug("Using custom headers for download")
try:
logger.info("Starting download: %s", output_file)
logger.info(
"Starting yt-dlp download with %s: %s",
provider_name, output_file
)
logger.debug("Download link: %s...", link[:100])
logger.debug("YDL options: %s", ydl_opts)
with YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(link, download=True)
@@ -353,39 +611,185 @@ class AniworldLoader(Loader):
if os.path.exists(temp_path):
logger.debug("Moving file from temp to final destination")
# Use copyfile instead of copy to avoid metadata permission issues
shutil.copyfile(temp_path, output_path)
os.remove(temp_path)
logger.info("Download completed successfully: %s", output_file)
logger.info(
"Download completed successfully: %s", output_file
)
self.clear_cache()
return True
else:
logger.error("Download failed: temp file not found at %s", temp_path)
self.clear_cache()
return False
except BrokenPipeError as e:
logger.error(
"Broken pipe error with provider %s: %s. "
"This usually means the stream connection was closed.",
provider, e
"Download failed: temp file not found at %s", temp_path
)
except DownloadCancelled:
logger.info("Download cancelled by user")
_cleanup_temp_file(temp_path)
self.clear_cache()
return False
except BrokenPipeError as exc:
logger.error(
"Broken pipe error with provider %s: %s",
provider_name, exc
)
_cleanup_temp_file(temp_path)
continue
except Exception as e:
except Exception as exc:
# Check if this is an HLS-related failure that might succeed
# with additional ffmpeg options
exc_str = str(exc).lower()
is_hls_related = (
'hls' in exc_str or
'live' in exc_str or
'native downloader' in exc_str
)
if is_hls_related and 'ffmpeg' not in str(ydl_opts.get('downloader', '')):
logger.info(
"HLS stream detected, retrying with ffmpeg options: %s",
output_file
)
# Retry with ffmpeg explicitly set
retry_opts = ydl_opts.copy()
retry_opts['downloader'] = 'ffmpeg'
retry_opts['hls_use_mpegts'] = True
try:
with YoutubeDL(retry_opts) as ydl:
info = ydl.extract_info(link, download=True)
if os.path.exists(temp_path):
shutil.copyfile(temp_path, output_path)
os.remove(temp_path)
logger.info(
"Download completed successfully (retry): %s",
output_file
)
self.clear_cache()
return True
except Exception:
_cleanup_temp_file(temp_path)
# Continue to next provider if retry also fails
continue
logger.error(
"YoutubeDL download failed with provider %s: %s: %s",
provider, type(e).__name__, e
provider_name, type(exc).__name__, exc
)
_cleanup_temp_file(temp_path)
continue
break
# If we get here, all providers failed
logger.error("All download providers failed")
logger.error(
"All download providers failed for S%02dE%03d (%s) in %s. "
"Tried: %s. Episode may be unavailable on the source site.",
season, episode, key, language, ", ".join(tried) or "none"
)
download_error_logger.error(
"All providers failed for %s S%02dE%03d (%s); tried=%s",
key, season, episode, language, tried
)
_cleanup_temp_file(temp_path)
self.clear_cache()
return False
def _select_providers_for_episode(
self,
season: int,
episode: int,
key: str,
language: str,
) -> list[tuple[str, str]]:
"""Return ``[(provider_name, redirect_url), ...]`` for an episode.
Filters by requested language and orders results by
``SUPPORTED_PROVIDERS`` preference so the failover chain matches
operator expectations. Returns an empty list when nothing is
advertised on the page.
"""
if not self.is_language(season, episode, key, language):
logger.warning(
"Language %s not advertised for S%02dE%03d (%s)",
language, season, episode, key
)
return []
language_code = self._get_language_key(language)
providers = self._get_provider_from_html(season, episode, key)
ordered: list[tuple[str, str]] = []
preferred = list(self.SUPPORTED_PROVIDERS)
for name in preferred:
lang_map = providers.get(name)
if lang_map and language_code in lang_map:
ordered.append((name, lang_map[language_code]))
for name, lang_map in providers.items():
if name in preferred:
continue
if language_code in lang_map:
ordered.append((name, lang_map[language_code]))
return ordered
def _resolve_direct_link(
self,
redirect_url: str,
provider_name: str,
) -> tuple[str, dict] | None:
"""Resolve a provider redirect URL into a direct stream link.
Follows the redirect to the embedded player, then delegates to a
provider-specific extractor (when registered) or returns the
embed URL itself so yt-dlp can attempt extraction.
Args:
redirect_url: AniWorld redirect URL.
provider_name: Provider key (e.g. ``"VOE"``).
Returns:
``(direct_link, headers)`` tuple or None when extraction fails.
"""
try:
embedded = self.session.get(
redirect_url,
timeout=self.DEFAULT_REQUEST_TIMEOUT,
headers={"User-Agent": self.RANDOM_USER_AGENT},
allow_redirects=True,
).url
except requests.RequestException as exc:
logger.warning(
"Failed resolving redirect for %s: %s", provider_name, exc
)
return None
try:
extractor = self.Providers.GetProvider(provider_name)
except (KeyError, AttributeError):
extractor = None
if extractor is not None:
try:
return extractor.get_link(
embedded, self.DEFAULT_REQUEST_TIMEOUT
)
except Exception as exc:
logger.warning(
"Custom extractor %s failed: %s",
provider_name, exc
)
return None
header_list = self.PROVIDER_HEADERS.get(provider_name)
header_dict = self._parse_provider_headers(header_list)
return embedded, header_dict
@staticmethod
def _parse_provider_headers(
header_list: list | None,
) -> dict[str, str]:
"""Convert legacy ``"Name: value"`` header strings to a dict."""
if not header_list:
return {}
parsed: dict[str, str] = {}
for entry in header_list:
if not isinstance(entry, str) or ":" not in entry:
continue
name, _, value = entry.partition(":")
parsed[name.strip()] = value.strip().strip('"')
return parsed
def get_site_key(self) -> str:
"""Get the site key for this provider."""
return "aniworld.to"
@@ -394,7 +798,7 @@ class AniworldLoader(Loader):
"""Get anime title from series key."""
logger.debug("Getting title for key: %s", key)
soup = BeautifulSoup(
self._get_key_html(key).content,
_decode_html_content(self._get_key_html(key).content),
'html.parser'
)
title_div = soup.find('div', class_='series-title')
@@ -405,55 +809,81 @@ class AniworldLoader(Loader):
if span_tag:
title = span_tag.text
logger.debug("Found title: %s", title)
# Also try to extract year from sibling p tag "Jahr: {year}"
# Year is typically right after title in the HTML structure
year = self._extract_year_from_soup(soup)
if year is not None:
self._YearDict[key] = year
logger.debug("Cached year %d for key: %s", year, key)
return title
logger.warning("No title found for key: %s", key)
return ""
def _extract_year_from_soup(self, soup: BeautifulSoup) -> int | None:
"""Extract year from BeautifulSoup object.
Looks for 'Jahr: {year}' pattern in p tags adjacent to series-title.
Args:
soup: Parsed BeautifulSoup object
Returns:
Year as int or None if not found
"""
# Try to find year in metadata
for p_tag in soup.find_all('p'):
text = p_tag.get_text()
if 'Jahr:' in text or 'Year:' in text:
match = re.search(r'(\d{4})', text)
if match:
return int(match.group(1))
# Fallback: look in series-info div
info_div = soup.find('div', class_='series-info')
if info_div:
text = info_div.get_text()
match = re.search(r'\b(19\d{2}|20\d{2})\b', text)
if match:
return int(match.group(1))
return None
def get_year(self, key: str) -> int | None:
"""Get anime release year from series key.
Attempts to extract the year from the series page metadata.
Returns None if year cannot be determined.
Uses cached year from get_title if available,
otherwise extracts and caches it.
Args:
key: Series identifier
Returns:
int or None: Release year if found, None otherwise
Release year or None if not found
"""
logger.debug("Getting year for key: %s", key)
# Check cache first
if key in self._YearDict:
logger.debug("Using cached year %d for key: %s", self._YearDict[key], key)
return self._YearDict[key]
# Not cached - extract from HTML
try:
soup = BeautifulSoup(
self._get_key_html(key).content,
_decode_html_content(self._get_key_html(key).content),
'html.parser'
)
# Try to find year in metadata
# Check for "Jahr:" or similar metadata fields
for p_tag in soup.find_all('p'):
text = p_tag.get_text()
if 'Jahr:' in text or 'Year:' in text:
# Extract year from text like "Jahr: 2025"
match = re.search(r'(\d{4})', text)
if match:
year = int(match.group(1))
logger.debug("Found year in metadata: %s", year)
return year
# Try alternative: look for year in genre/info section
info_div = soup.find('div', class_='series-info')
if info_div:
text = info_div.get_text()
match = re.search(r'\b(19\d{2}|20\d{2})\b', text)
if match:
year = int(match.group(1))
logger.debug("Found year in info section: %s", year)
return year
logger.debug("No year found for key: %s", key)
return None
year = self._extract_year_from_soup(soup)
if year is not None:
self._YearDict[key] = year
logger.debug("Found and cached year %d for key: %s", year, key)
return year
except Exception as e:
logger.warning("Error extracting year for key %s: %s", key, e)
return None
@@ -539,7 +969,7 @@ class AniworldLoader(Loader):
"""
logger.debug("Extracting providers from HTML for S%02dE%03d (%s)", season, episode, key)
soup = BeautifulSoup(
self._get_episode_html(season, episode, key).content,
_decode_html_content(self._get_episode_html(season, episode, key).content),
'html.parser'
)
providers: dict[str, dict[int, str]] = {}
@@ -662,7 +1092,7 @@ class AniworldLoader(Loader):
base_url = f"{self.ANIWORLD_TO}/anime/stream/{safe_slug}/"
logger.debug("Base URL: %s", base_url)
response = requests.get(base_url, timeout=self.DEFAULT_REQUEST_TIMEOUT)
soup = BeautifulSoup(response.content, 'html.parser')
soup = BeautifulSoup(_decode_html_content(response.content), 'html.parser')
season_meta = soup.find('meta', itemprop='numberOfSeasons')
number_of_seasons = int(season_meta['content']) if season_meta else 0
@@ -677,7 +1107,7 @@ class AniworldLoader(Loader):
season_url,
timeout=self.DEFAULT_REQUEST_TIMEOUT,
)
soup = BeautifulSoup(response.content, 'html.parser')
soup = BeautifulSoup(_decode_html_content(response.content), 'html.parser')
episode_links = soup.find_all('a', href=True)
unique_links = set(

View File

@@ -91,6 +91,17 @@ class Loader(ABC):
Series title string
"""
@abstractmethod
def get_year(self, key: str) -> int | None:
"""Get the release year of a series.
Args:
key: Unique series identifier/key
Returns:
Release year as integer, or None if year cannot be determined
"""
@abstractmethod
def get_season_episode_count(self, slug: str) -> Dict[int, int]:
"""Get season and episode counts for a series.

View File

@@ -88,7 +88,10 @@ class EnhancedAniWorldLoader(Loader):
self.PROVIDER_HEADERS = {
ProviderType.VIDMOLY.value: ['Referer: "https://vidmoly.to"'],
ProviderType.DOODSTREAM.value: ['Referer: "https://dood.li/"'],
ProviderType.DOODSTREAM.value: [
'Referer: "https://dood.li/"',
'Referer: "https://playmogo.com/"',
],
ProviderType.VOE.value: [f'User-Agent: {self.RANDOM_USER_AGENT}'],
ProviderType.LULUVDO.value: [
f'User-Agent: {self.LULUVDO_USER_AGENT}',
@@ -107,6 +110,7 @@ class EnhancedAniWorldLoader(Loader):
# Cache dictionaries
self._KeyHTMLDict = {}
self._EpisodeHTMLDict = {}
self._YearDict = {}
# Provider manager
self.Providers = Providers()
@@ -567,6 +571,9 @@ class EnhancedAniWorldLoader(Loader):
"socket_timeout": self.download_timeout,
"http_chunk_size": 1024 * 1024, # 1MB chunks
"logger": self.logger,
# Use ffmpeg for HLS streams and transport stream format
"downloader": "ffmpeg",
"hls_use_mpegts": True,
}
if headers:
ydl_opts['http_headers'] = headers
@@ -660,6 +667,10 @@ class EnhancedAniWorldLoader(Loader):
if title_span:
span = title_span.find('span')
if span:
# Extract and cache year from soup if available
year = self._ExtractYearFromSoup(soup)
if year is not None:
self._YearDict[key] = year
return span.text.strip()
self.logger.warning("Could not extract title for key: %s", key)
@@ -668,7 +679,62 @@ class EnhancedAniWorldLoader(Loader):
except Exception as e:
self.logger.error("Failed to get title for key %s: %s", key, e)
raise RetryableError(f"Title extraction failed: {e}") from e
def _ExtractYearFromSoup(self, soup: BeautifulSoup) -> int | None:
"""Extract year from parsed BeautifulSoup.
Looks for 'Jahr: {year}' pattern in p tags.
Args:
soup: Parsed BeautifulSoup object
Returns:
Year as int or None if not found
"""
for p_tag in soup.find_all('p'):
text = p_tag.get_text()
if 'Jahr:' in text or 'Year:' in text:
match = re.search(r'(\d{4})', text)
if match:
return int(match.group(1))
info_div = soup.find('div', class_='series-info')
if info_div:
text = info_div.get_text()
match = re.search(r'\b(19\d{2}|20\d{2})\b', text)
if match:
return int(match.group(1))
return None
def GetYear(self, key: str) -> int | None:
"""Get anime release year from series key.
Uses cached year from GetTitle if available,
otherwise extracts and caches it.
Args:
key: Series identifier
Returns:
Release year or None if not found
"""
# Check cache first
if key in self._YearDict:
return self._YearDict[key]
# Not cached - extract from HTML
try:
soup = BeautifulSoup(self._GetKeyHTML(key).content, 'html.parser')
year = self._ExtractYearFromSoup(soup)
if year is not None:
self._YearDict[key] = year
return year
except Exception as e:
self.logger.warning("Error extracting year for key %s: %s", key, e)
return None
def GetSiteKey(self) -> str:
"""Get site identifier."""
return "aniworld.to"

View File

@@ -8,8 +8,8 @@ import asyncio
import logging
from typing import Any, Callable, Dict, List, Optional, TypeVar
from src.core.providers.health_monitor import get_health_monitor
from src.core.providers.provider_config import DEFAULT_PROVIDERS
from src.server.providers.health_monitor import get_health_monitor
from src.server.providers.provider_config import DEFAULT_PROVIDERS
logger = logging.getLogger(__name__)

View File

@@ -7,8 +7,8 @@ import logging
import time
from typing import Any, Callable, Dict, List, Optional
from src.core.providers.base_provider import Loader
from src.core.providers.health_monitor import get_health_monitor
from src.server.providers.base_provider import Loader
from src.server.providers.health_monitor import get_health_monitor
logger = logging.getLogger(__name__)

View File

@@ -1,14 +1,13 @@
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.core.SeriesApp import SeriesApp
from src.server.SeriesApp import SeriesApp
from src.server.services.progress_service import (
ProgressService,
ProgressType,
@@ -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(
@@ -498,13 +508,19 @@ class AnimeService:
logger.info("No series found in SeriesApp")
return []
# Build NFO metadata map and filter data from database
nfo_map = {}
series_with_no_episodes = set()
# Build NFO metadata map, episode dict, and filter data from database.
# Using DB as authoritative source for episodeDict ensures that
# episodes marked is_downloaded=True are never shown as missing,
# even if the in-memory state is stale.
nfo_map: dict = {}
db_episode_dict_map: dict[str, dict[int, list[int]]] = {}
series_with_no_episodes: set = set()
async with get_db_session() as db:
# Get all series NFO metadata using service layer
db_series_list = await AnimeSeriesService.get_all(db)
# Single query: load all series with their episodes eagerly
db_series_list = await AnimeSeriesService.get_all(
db, with_episodes=True
)
for db_series in db_series_list:
nfo_created = (
@@ -522,7 +538,23 @@ class AnimeService:
"tmdb_id": db_series.tmdb_id,
"tvdb_id": db_series.tvdb_id,
"series_id": db_series.id,
"loading_status": db_series.loading_status,
"loading_error": db_series.loading_error,
}
# Build episodeDict from DB, skipping is_downloaded=True
# episodes so they are never shown as missing in the UI.
ep_dict: dict[int, list[int]] = {}
if db_series.episodes:
for ep in db_series.episodes:
if ep.is_downloaded:
continue
if ep.season not in ep_dict:
ep_dict[ep.season] = []
ep_dict[ep.season].append(ep.episode_number)
for s in ep_dict:
ep_dict[s].sort()
db_episode_dict_map[db_series.folder] = ep_dict
# If filter is "missing_episodes", get series with any missing episodes
if filter_type == "missing_episodes":
@@ -545,7 +577,12 @@ class AnimeService:
name = getattr(serie, "name", "")
site = getattr(serie, "site", "")
folder = getattr(serie, "folder", "")
episode_dict = getattr(serie, "episodeDict", {}) or {}
# Use DB-backed episodeDict (is_downloaded=True already filtered out)
# with in-memory episodeDict as fallback if the series isn't in DB yet.
episode_dict = db_episode_dict_map.get(
folder,
getattr(serie, "episodeDict", {}) or {}
)
# Apply filter if specified
if filter_type == "missing_episodes":
@@ -571,20 +608,21 @@ class AnimeService:
"tmdb_id": nfo_data.get("tmdb_id"),
"tvdb_id": nfo_data.get("tvdb_id"),
"series_id": nfo_data.get("series_id"),
"loading_status": nfo_data.get("loading_status"),
"loading_error": nfo_data.get("loading_error"),
}
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
@@ -606,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:
@@ -626,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
@@ -658,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:
@@ -815,18 +862,24 @@ class AnimeService:
- Adds new missing episodes that are not in the database
- Removes episodes from database that are no longer missing
(i.e., the file has been added to the filesystem)
- Preserves episodes marked as downloaded (is_downloaded=True)
so download history is not lost
"""
from src.server.database.service import AnimeSeriesService, EpisodeService
# Get existing episodes from database
# Get existing episodes from database (all episodes, including downloaded)
existing_episodes = await EpisodeService.get_by_series(db, existing.id)
# Build dict of existing episodes: {season: {ep_num: episode_id}}
# and track which ones are already downloaded
existing_dict: dict[int, dict[int, int]] = {}
downloaded_set: set[tuple[int, int]] = set()
for ep in existing_episodes:
if ep.season not in existing_dict:
existing_dict[ep.season] = {}
existing_dict[ep.season][ep.episode_number] = ep.id
if ep.is_downloaded:
downloaded_set.add((ep.season, ep.episode_number))
# Get new missing episodes from scan
new_dict = serie.episodeDict or {}
@@ -857,9 +910,22 @@ class AnimeService:
# Remove episodes from database that are no longer missing
# (i.e., the episode file now exists on the filesystem)
# BUT: preserve episodes that are already downloaded (is_downloaded=True)
# so we don't lose download history
for season, eps_dict in existing_dict.items():
for ep_num, episode_id in eps_dict.items():
if (season, ep_num) not in new_missing_set:
# Skip already-downloaded episodes — they should stay in DB
# with is_downloaded=True to preserve download history
if (season, ep_num) in downloaded_set:
logger.debug(
"Preserving downloaded episode in database: "
"%s S%02dE%02d",
serie.key,
season,
ep_num
)
continue
await EpisodeService.delete(db, episode_id)
logger.info(
"Removed episode from database (no longer missing): "
@@ -889,42 +955,21 @@ class AnimeService:
This method is called during initialization and after rescans
to ensure the in-memory series list is in sync with the database.
Only episodes where is_downloaded=False are loaded into the
in-memory episodeDict, so downloaded episodes are not shown
as missing.
"""
from src.core.entities.series import Serie
from src.server.database.connection import get_db_session
from src.server.database.service import AnimeSeriesService
async with get_db_session() as db:
anime_series_list = await AnimeSeriesService.get_all(
db, with_episodes=True
)
# Convert to Serie objects
series_list = []
for anime_series in anime_series_list:
# Build episode_dict from episodes relationship
episode_dict: dict[int, list[int]] = {}
if anime_series.episodes:
for episode in anime_series.episodes:
season = episode.season
if season not in episode_dict:
episode_dict[season] = []
episode_dict[season].append(episode.episode_number)
# Sort episode numbers
for season in episode_dict:
episode_dict[season].sort()
serie = Serie(
key=anime_series.key,
name=anime_series.name,
site=anime_series.site,
folder=anime_series.folder,
episodeDict=episode_dict
)
series_list.append(serie)
# Load into SeriesApp
self._app.load_series_from_list(series_list)
# Load AnimeSeries objects directly into SeriesApp
self._app.load_series_from_list(anime_series_list)
async def sync_episodes_to_db(self, series_key: str) -> int:
"""
@@ -962,23 +1007,39 @@ class AnimeService:
logger.warning("Series not found in database: %s", series_key)
return 0
# Get existing episodes from database
# Get existing episodes from database (all, including downloaded)
existing_episodes = await EpisodeService.get_by_series(db, series_db.id)
# Build dict of existing episodes: {season: {ep_num: episode_id}}
# and track which ones are already downloaded
existing_dict: dict[int, dict[int, int]] = {}
downloaded_set: set[tuple[int, int]] = set()
for ep in existing_episodes:
if ep.season not in existing_dict:
existing_dict[ep.season] = {}
existing_dict[ep.season][ep.episode_number] = ep.id
if ep.is_downloaded:
downloaded_set.add((ep.season, ep.episode_number))
# Get new missing episodes from in-memory serie
new_dict = serie.episodeDict or {}
# Add new missing episodes that are not in the database
# Skip episodes that are already downloaded (is_downloaded=True)
# so we don't re-add them as missing after they've been downloaded
for season, episode_numbers in new_dict.items():
existing_season_eps = existing_dict.get(season, {})
for ep_num in episode_numbers:
# Skip if already downloaded — don't re-add as missing
if (season, ep_num) in downloaded_set:
logger.debug(
"Skipping already-downloaded episode: "
"%s S%02dE%02d",
series_key,
season,
ep_num,
)
continue
if ep_num not in existing_season_eps:
await EpisodeService.create(
db=db,
@@ -1014,20 +1075,23 @@ class AnimeService:
if hasattr(self._app, 'list') and hasattr(self._app.list, 'keyDict'):
serie = self._app.list.keyDict.get(series_key)
if serie:
# Convert episode dict keys to strings for JSON
missing_episodes = {str(k): v for k, v in (serie.episodeDict or {}).items()}
total_missing = sum(len(eps) for eps in missing_episodes.values())
# Fetch NFO metadata from database
# Fetch NFO metadata and episodes from database.
# Using DB as the authoritative source for missing_episodes
# ensures that episodes marked is_downloaded=True are never
# broadcast as missing, even if in-memory state is stale.
has_nfo = False
nfo_created_at = None
nfo_updated_at = None
tmdb_id = None
tvdb_id = None
missing_episodes: dict[str, list] = {}
try:
from src.server.database.connection import get_db_session
from src.server.database.service import AnimeSeriesService
from src.server.database.service import (
AnimeSeriesService,
EpisodeService,
)
async with get_db_session() as db:
db_series = await AnimeSeriesService.get_by_key(db, series_key)
@@ -1043,12 +1107,31 @@ class AnimeService:
)
tmdb_id = db_series.tmdb_id
tvdb_id = db_series.tvdb_id
# Build missing_episodes from DB, skipping is_downloaded=True
db_episodes = await EpisodeService.get_by_series(
db, db_series.id, only_missing=True
)
for ep in db_episodes:
key_str = str(ep.season)
if key_str not in missing_episodes:
missing_episodes[key_str] = []
missing_episodes[key_str].append(ep.episode_number)
for s in missing_episodes:
missing_episodes[s].sort()
except Exception as e:
logger.warning(
"Could not fetch NFO data for %s: %s",
"Could not fetch series data for %s from DB: %s",
series_key,
str(e)
)
# Fallback to in-memory state
missing_episodes = {
str(k): v
for k, v in (serie.episodeDict or {}).items()
}
total_missing = sum(len(eps) for eps in missing_episodes.values())
series_data = {
"key": serie.key,
@@ -1082,17 +1165,17 @@ class AnimeService:
async def add_series_to_db(
self,
serie,
anime,
db
):
"""
Add a series to the database if it doesn't already exist.
Uses serie.key for identification. Creates a new AnimeSeries
Uses anime.key for identification. Creates a new AnimeSeries
record in the database if it doesn't already exist.
Args:
serie: The Serie instance to add
anime: The AnimeSeries instance to add
db: Database session for async operations
Returns:
@@ -1101,45 +1184,139 @@ class AnimeService:
from src.server.database.service import AnimeSeriesService, EpisodeService
# Check if series already exists in DB
existing = await AnimeSeriesService.get_by_key(db, serie.key)
existing = await AnimeSeriesService.get_by_key(db, anime.key)
if existing:
logger.debug(
"Series already exists in database: %s (key=%s)",
serie.name,
serie.key
anime.name,
anime.key
)
return None
# 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,
key=anime.key,
name=anime.name,
site=anime.site,
folder=anime.folder,
year=anime.year if hasattr(anime, '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,
)
# Create Episode records for each episode in episodes relationship
if anime.episodes:
for episode in anime.episodes:
await EpisodeService.create(
db=db,
series_id=anime_series.id,
season=episode.season,
episode_number=episode.episode_number,
)
logger.info(
"Added series to database: %s (key=%s, year=%s)",
serie.name,
serie.key,
serie.year if hasattr(serie, 'year') else None
anime.name,
anime.key,
anime.year if hasattr(anime, 'year') else None
)
return anime_series
async def rename_folder_if_needed(
self,
key: str,
current_folder: str,
target_folder: str,
db: Optional[AsyncSession] = None,
) -> bool:
"""Rename anime folder if current and target folders differ.
Compares current_folder with target_folder, and if different,
renames the folder on disk using shutil.move. Updates the DB
record and in-memory cache if rename succeeds.
Args:
key: Series unique identifier
current_folder: Current folder name (metadata from DB)
target_folder: Desired folder name (computed with year)
db: Optional database session for updating DB record
Returns:
True if rename was performed, False if no rename needed or failed
"""
import os
import shutil
if current_folder == target_folder:
logger.debug(
"Folder rename not needed for %s: same folder name",
key
)
return False
current_path = self._directory / current_folder
target_path = self._directory / target_folder
if not current_path.exists():
logger.debug(
"Folder rename not needed for %s: current folder does not exist on disk",
key
)
return False
if target_path.exists():
logger.warning(
"Cannot rename folder for %s: target path already exists: %s",
key,
target_path
)
return False
try:
# Rename folder on disk
shutil.move(str(current_path), str(target_path))
logger.info(
"Renamed folder for %s: %s -> %s",
key,
current_folder,
target_folder
)
# Update in-memory cache
if key in self._app.list.keyDict:
self._app.list.keyDict[key].folder = target_folder
logger.debug(
"Updated in-memory cache folder for %s: %s",
key,
target_folder
)
# Update database if session provided
if db is not None:
from src.server.database.service import AnimeSeriesService
# Look up series by key to get database ID
series = await AnimeSeriesService.get_by_key(db, key)
if series:
await AnimeSeriesService.update(db, series_id=series.id, folder=target_folder)
logger.debug(
"Updated DB folder for %s: %s",
key,
target_folder
)
return True
except Exception as e:
logger.error(
"Failed to rename folder for %s: %s -> %s: %s",
key,
current_folder,
target_folder,
str(e)
)
return False
async def contains_in_db(self, key: str, db) -> bool:
"""
Check if a series with the given key exists in the database.
@@ -1207,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(
@@ -1308,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
@@ -1387,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
@@ -1432,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
@@ -1449,146 +1626,15 @@ 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
def get_anime_service(series_app: SeriesApp) -> AnimeService:
"""Factory used for creating AnimeService with a SeriesApp instance."""
return AnimeService(series_app)
async def sync_series_from_data_files(
anime_directory: str,
log_instance=None # pylint: disable=unused-argument
) -> int:
"""
Sync series from data files to the database.
Scans the anime directory for data files and adds any new series
to the database. Existing series are skipped (no duplicates).
This function is typically called during application startup to ensure
series metadata stored in filesystem data files is available in the
database.
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__)
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,
)
# 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

Some files were not shown because too many files have changed in this diff Show More