Compare commits

...

173 Commits

Author SHA1 Message Date
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
263 changed files with 21059 additions and 28765 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

@@ -1 +1 @@
v1.3.3
v1.5.0

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

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

@@ -81,6 +81,7 @@ src/server/
| +-- websocket_service.py# WebSocket broadcasting
| +-- queue_repository.py # Database persistence
| +-- nfo_service.py # NFO metadata management
| +-- setup_service.py # Series key resolution from folder names
| +-- folder_scan_service.py # Daily folder maintenance scan
+-- models/ # Pydantic models
| +-- auth.py # Auth request/response models

View File

@@ -37,6 +37,83 @@ 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
- **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

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

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

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

View File

@@ -24,7 +24,7 @@ Console.CancelKeyPress += (_, e) =>
// ── Paths ─────────────────────────────────────────────────────────────────────
var repoRoot = Directory.GetCurrentDirectory();
var tasksFile = Path.Combine(repoRoot, "Docs", "Tasks.md");
var tasksFile = Path.Combine(repoRoot, "Docs", "tasks.md");
if (!File.Exists(tasksFile))
{
@@ -102,7 +102,7 @@ for (int i = 0; i < items.Count; i++)
// Step 1 — run the task prompt
await RunCopilot(Enumerable.Empty<string>(), $"/caveman full");
await RunCopilot(new[] { "--continue" }, $"read ./Docs/instructions.md. {item}");
await RunCopilot(new[] { "--continue" }, $"{item}");
if (cts.IsCancellationRequested) break;
// Step 2 — confirm completion in the same chat session

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,111 +0,0 @@
# Migration Guide: File-Based to Database Storage
## Overview
This guide covers the transition from file-based series metadata storage to the new database-backed system introduced in v2.0.
## What Changed
**Before v2.0**: Series metadata stored in `key` and `data` files alongside anime folders.
**After v2.0**: All metadata stored in SQLite database (`aniworld.db`). Files are deprecated but still supported for backward compatibility during migration.
## Automated Migration
The application automatically migrates on first startup:
1. Scans anime directory for `key` and `data` files
2. Parses legacy files into `AnimeSeries` and `Episode` records
3. Loads series into in-memory cache
4. Logs migration results
**No manual action required.**
## Manual Verification
After first startup with the new version:
1. **Check logs** for: `"Migrated X series from files to DB"`
2. **Verify series count**: UI shows same number of series as before
3. **Confirm episodes**: Episode counts match expected totals
```bash
# Check migration log
grep "Migrated" logs/app.log
# Verify series via API
curl http://localhost:8000/api/anime | jq '.total'
```
## After Migration
### Safe to Delete
Once verified, these files can be removed:
```
<anime_folder>/
├── Attack on Titan (2013)/
│ ├── key # ❌ Can delete
│ ├── data # ❌ Can delete
│ └── Season 1/
│ └── ...
```
**Deleting these files does not affect the database.** The metadata now lives in `aniworld.db`.
### Backup (Recommended)
Before deleting, backup the files:
```bash
# Create backup directory
mkdir -p backup/legacy_series_files
# Copy all key and data files
find /path/to/anime -name "key" -o -name "data" | while read f; do
cp "$f" "backup/legacy_series_files/"
done
```
## Reverting (Not Recommended)
If you must revert to file-based storage:
1. **Restore from database backup** (if available)
2. **Export manually** (no export script exists)
**Warning**: File-based storage is deprecated and will be removed in v3.0.0.
## Troubleshooting
### Series Not Appearing After Migration
1. Check logs for migration errors: `grep -i error logs/app.log`
2. Verify `key` and `data` files exist and are readable
3. Manually trigger rescan: `POST /api/scheduler/trigger-rescan`
### Duplicate Series
1. Check for duplicate `key` files (same series in multiple folders)
2. Verify series key uniqueness in database:
```bash
sqlite3 aniworld.db "SELECT key, COUNT(*) FROM anime_series GROUP BY key HAVING COUNT(*) > 1;"
```
### Missing Episodes
1. Trigger targeted scan for affected series
2. Check episode sync logs
3. Verify file permissions on anime directory
## Deprecation Timeline
| Version | Status |
|---------|--------|
| v2.0.x | Legacy files supported, migration automated |
| v2.1.x | Legacy files still supported, warnings in logs |
| v3.0.0 | **Legacy files removed** - database only |
Upgrade to v3.0.0 before legacy file support ends.

View File

@@ -1,155 +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
### Mocking the Download Queue
Use `MockQueueRepository` for testing download queue functionality:
```python
from src.server.models.download import DownloadItem, EpisodeIdentifier
class MockQueueRepository:
def __init__(self):
self._items: Dict[str, DownloadItem] = {}
async def save_item(self, item: DownloadItem) -> DownloadItem:
self._items[item.id] = item
return item
async def get_item(self, item_id: str) -> Optional[DownloadItem]:
return self._items.get(item_id)
async def get_all_items(self) -> List[DownloadItem]:
return list(self._items.values())
async def set_error(self, item_id: str, error: str) -> bool:
if item_id in self._items:
self._items[item_id].error = error
return True
return False
async def delete_item(self, item_id: str) -> bool:
if item_id in self._items:
del self._items[item_id]
return True
return False
async def clear_all(self) -> int:
count = len(self._items)
self._items.clear()
return count
```
**Key points:**
- The mock uses in-memory storage, no database required
- All async methods are implemented (even if just pass-through)
- `save_item` uses `item.id` as key (must be set before calling)
- Suitable for unit tests only (no persistence)
### 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

@@ -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.3.3",
"version": "1.5.0",
"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

@@ -25,4 +25,10 @@ beautifulsoup4>=4.12.0
chardet>=5.2.0
fake-useragent>=1.4.0
yt-dlp>=2024.1.0
urllib3>=2.0.0
urllib3>=2.0.0
# Robot Framework testing dependencies
robotframework>=7.0
robotframework-browser>=18.0
robotframework-requests>=0.9
robotframework-jsonlibrary>=0.5

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,531 +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 supports loading from both filesystem (legacy) and
database (primary).
Note:
This module is part of the core domain layer. Database operations
are handled by the service layer via add_to_db().
"""
from __future__ import annotations
import logging
import os
import warnings
from json import JSONDecodeError
from typing import Dict, Iterable, List, Optional
from src.config.settings import settings
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
async def add_to_db(self, serie: Serie) -> bool:
"""Persist a new series to the database.
Creates the filesystem folder using serie.folder, then persists
the series metadata to the database.
Args:
serie: The Serie 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 = serie.folder
anime_path = os.path.join(self.directory, folder_name)
os.makedirs(anime_path, exist_ok=True)
session_factory = get_async_session_factory()
db = session_factory()
try:
existing = await AnimeSeriesService.get_by_key(db, serie.key)
if existing:
logger.debug(
"Series '%s' (key=%s) already exists in DB, skipping",
serie.name, serie.key
)
return True
anime_series = await AnimeSeriesService.create(
db=db,
key=serie.key,
name=serie.name,
site=serie.site,
folder=folder_name,
year=serie.year
)
for season, eps in serie.episodeDict.items():
for ep in eps:
await EpisodeService.create(
db=db,
series_id=anime_series.id,
season=season,
episode_number=ep
)
await db.commit()
self.keyDict[serie.key] = serie
logger.info(
"Persisted series '%s' to database",
serie.name
)
return True
except Exception as e:
await db.rollback()
logger.error(
"Failed to persist series '%s' to DB: %s",
serie.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",
serie.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 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:
if settings.should_ignore_folder(anime_folder):
logger.debug("Skipping ignored folder: %s", anime_folder)
continue
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
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.
This method replaces file-based loading. Use after initialization
when database is ready.
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:
episode_dict: Dict[int, List[int]] = {}
if anime_series.episodes:
for ep in anime_series.episodes:
if ep.season not in episode_dict:
episode_dict[ep.season] = []
episode_dict[ep.season].append(ep.episode_number)
serie = Serie(
key=anime_series.key,
name=anime_series.name,
site=anime_series.site,
folder=anime_series.folder,
episodeDict=episode_dict,
year=anime_series.year
)
self.keyDict[serie.key] = serie
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[Serie]:
"""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:
Serie 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
episode_dict: Dict[int, List[int]] = {}
if anime_series.episodes:
for ep in anime_series.episodes:
if ep.season not in episode_dict:
episode_dict[ep.season] = []
episode_dict[ep.season].append(ep.episode_number)
serie = Serie(
key=anime_series.key,
name=anime_series.name,
site=anime_series.site,
folder=anime_series.folder,
episodeDict=episode_dict,
year=anime_series.year
)
self.keyDict[serie.key] = serie
logger.debug(
"Loaded series '%s' (key=%s) from DB",
serie.name, serie.key
)
return serie
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")
def reload(self) -> None:
"""Reload series from filesystem (legacy mode).
Warning:
This method uses file-based loading and should only be
used as fallback when database is not available.
"""
self.load_series()

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,228 +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
from src.core.services.tmdb_client import TMDBAPIError
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),
)
try:
await self._nfo_service.update_tvshow_nfo(
series_name,
download_media=False,
)
except TMDBAPIError as e:
if "No TMDB ID found" in str(e):
# No TMDB ID in existing NFO — create new one via search
logger.info(
"NFO has no TMDB ID, creating new NFO via TMDB search"
)
await self._nfo_service.create_tvshow_nfo(
serie_name=series_name,
serie_folder=series_name,
download_poster=False,
download_logo=False,
download_fanart=False,
)
else:
raise
logger.info("NFO repair completed: %s", series_name)
return True

View File

@@ -1,891 +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
import unicodedata
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
from src.core.entities.nfo_models import TVShowNFO
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
async def __aenter__(self) -> "NFOService":
"""Enter async context manager."""
await self.tmdb_client.__aenter__()
await self.image_downloader.__aenter__()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Exit async context manager and cleanup resources."""
await self.tmdb_client.close()
await self.image_downloader.close()
return False
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,
alt_titles: Optional[List[str]] = None
) -> 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
alt_titles: Alternative titles (e.g., Japanese title) for fallback search
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)
# Check for existing NFO with TMDB ID to skip search
nfo_path = folder_path / "tvshow.nfo"
existing_ids = None
if nfo_path.exists():
try:
existing_ids = self.parse_nfo_ids(nfo_path)
if existing_ids.get("tmdb_id"):
logger.info(
"Found existing TMDB ID %s in NFO, using directly",
existing_ids["tmdb_id"]
)
except Exception as e:
logger.debug("Could not parse existing NFO IDs: %s", e)
try:
await self.tmdb_client._ensure_session()
# Use existing TMDB ID if found, otherwise search
if existing_ids and existing_ids.get("tmdb_id"):
tv_id = existing_ids["tmdb_id"]
logger.info("Fetching details directly for TMDB ID: %s", tv_id)
details = await self.tmdb_client.get_tv_show_details(
tv_id,
append_to_response="credits,external_ids,images"
)
content_ratings = await self.tmdb_client.get_tv_show_content_ratings(tv_id)
tv_show = {"id": tv_id, "name": details.get("name", serie_name)}
search_source = "nfo_override"
else:
# Search for TV show - try multiple strategies
tv_show, search_source = await self._search_with_fallback(
search_name, year, alt_titles
)
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
# Skip if we already fetched details via nfo_override
if search_source != "nfo_override":
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
)
else:
# When using nfo_override, content_ratings already fetched
pass
# 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
def parse_nfo_year(self, nfo_path: Path) -> Optional[int]:
"""Parse year from an existing NFO file.
Extracts year from <year> or <premiered> elements.
Args:
nfo_path: Path to tvshow.nfo file
Returns:
Year as integer if found, None otherwise.
Example:
>>> year = nfo_service.parse_nfo_year(Path("/anime/series/tvshow.nfo"))
>>> print(year)
2013
"""
if not nfo_path.exists():
logger.debug("NFO file not found: %s", nfo_path)
return None
try:
tree = etree.parse(str(nfo_path))
root = tree.getroot()
# Try <year> element first
year_elem = root.find(".//year")
if year_elem is not None and year_elem.text:
try:
year = int(year_elem.text)
if 1900 <= year <= 2100:
logger.debug("Found year in NFO: %d", year)
return year
except ValueError:
pass
# Fallback: try <premiered> element (format: YYYY-MM-DD)
premiered_elem = root.find(".//premiered")
if premiered_elem is not None and premiered_elem.text:
if premiered_elem.text and len(premiered_elem.text) >= 4:
try:
year = int(premiered_elem.text[:4])
if 1900 <= year <= 2100:
logger.debug("Found year from premiered in NFO: %d", year)
return year
except ValueError:
pass
logger.debug("No year found in NFO: %s", nfo_path)
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 year from NFO file %s: %s", nfo_path, e)
return None
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 _search_with_fallback(
self,
primary_query: str,
year: Optional[int],
alt_titles: Optional[List[str]] = None
) -> Tuple[Dict[str, Any], str]:
"""Search TMDB with fallback strategies.
Tries multiple search strategies in order:
1. Primary query with year filter
2. Alternative titles (e.g., Japanese name)
3. Multi-language search (en-US)
4. Search without year constraint
5. Punctuation-normalized search
Args:
primary_query: Primary search term
year: Release year for filtering
alt_titles: Alternative titles to try if primary fails
Returns:
Tuple of (matched TV show dict, source description string)
Raises:
TMDBAPIError: If all search strategies fail
"""
search_strategies = [
# Strategy 1: Primary query as-is
{"query": primary_query, "year": year, "lang": "de-DE", "desc": "primary"},
]
# Strategy 2: Try alt titles (typically Japanese)
if alt_titles:
for alt in alt_titles:
if alt != primary_query:
search_strategies.append(
{"query": alt, "year": year, "lang": "ja-JP", "desc": f"alt_title:{alt}"}
)
search_strategies.append(
{"query": alt, "year": year, "lang": "en-US", "desc": f"alt_title:{alt}"}
)
# Strategy 3: Try English search
search_strategies.append(
{"query": primary_query, "year": year, "lang": "en-US", "desc": "english"}
)
# Strategy 4: Try without year constraint
if year:
search_strategies.append(
{"query": primary_query, "year": None, "lang": "de-DE", "desc": "no_year"}
)
# Strategy 5: Normalize punctuation
normalized = self._normalize_query_for_search(primary_query)
if normalized != primary_query:
search_strategies.append(
{"query": normalized, "year": year, "lang": "de-DE", "desc": f"normalized:{normalized}"}
)
# Strategy 6: Try search/multi for series indexed as movies
search_strategies.append(
{"query": primary_query, "year": year, "lang": "en-US", "desc": "multi_search", "use_multi": True}
)
last_error = None
for strategy in search_strategies:
query = strategy["query"]
lang = strategy["lang"]
desc = strategy["desc"]
use_multi = strategy.get("use_multi", False)
try:
logger.debug(
"TMDB search attempt: query='%s', lang=%s, year=%s, strategy=%s",
query, lang, strategy["year"], desc
)
# Use search/multi for multi_search strategy
if use_multi:
search_results = await self.tmdb_client.search_multi(
query,
language=lang
)
# Filter for TV shows only
if search_results.get("results"):
tv_results = [
r for r in search_results["results"]
if r.get("media_type") == "tv"
]
if tv_results:
search_results["results"] = tv_results
else:
search_results["results"] = []
else:
search_results = await self.tmdb_client.search_tv_show(
query,
language=lang
)
if search_results.get("results"):
# Apply year filter if we have one
results = search_results["results"]
if strategy["year"]:
year_filtered = [
r for r in results
if r.get("first_air_date", "").startswith(str(strategy["year"]))
]
if year_filtered:
match = year_filtered[0]
else:
# Year didn't match, still use first result but log it
match = results[0]
logger.debug(
"Year %s not found in results for '%s', using: %s",
strategy["year"], query, match["name"]
)
else:
match = results[0]
logger.info(
"TMDB search succeeded: '%s' found via strategy '%s' (ID: %s)",
match["name"], desc, match["id"]
)
return match, desc
else:
logger.debug("No results for '%s' via %s", query, desc)
except TMDBAPIError as e:
last_error = e
logger.debug("Search strategy '%s' failed: %s", desc, e)
continue
# All strategies exhausted
raise TMDBAPIError(
f"No results found for: {primary_query} (tried {len(search_strategies)} strategies)"
)
def _normalize_query_for_search(self, query: str) -> str:
"""Normalize query by removing punctuation and special chars.
Args:
query: Original search query
Returns:
Query with punctuation removed
"""
# Remove common punctuation but keep CJK characters
normalized = unicodedata.normalize('NFKC', query)
# Remove punctuation but not CJK
normalized = re.sub(r'[^\w\s\u3000-\u9fff\u4e00-\u9faf]', '', normalized)
# Collapse multiple spaces
normalized = re.sub(r'\s+', ' ', normalized).strip()
return normalized
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()
await self.image_downloader.close()
async def create_minimal_nfo(
self,
serie_name: str,
serie_folder: str,
year: Optional[int] = None
) -> Path:
"""Create minimal tvshow.nfo when TMDB lookup fails.
Creates a basic NFO with just the title (and year if available)
so the series is tracked even without TMDB metadata.
Args:
serie_name: Name of the series (may include year in parentheses)
serie_folder: Series folder name
year: Optional release year
Returns:
Path to created NFO file
Raises:
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
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)
# Create minimal NFO model with just title and year
nfo_model = TVShowNFO(
title=clean_name,
year=year,
plot=f"No metadata available for {clean_name}. TMDB lookup failed."
)
# 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 minimal NFO (no TMDB): %s", nfo_path)
return nfo_path

View File

@@ -1,309 +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
nfo_exists = False
ids = {}
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})"
)
try:
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)
except TMDBAPIError as create_error:
# TMDB lookup failed, create minimal NFO to track the series
logger.warning(
"TMDB lookup failed for '%s', creating minimal NFO: %s",
serie_name, create_error
)
try:
await self.nfo_service.create_minimal_nfo(
serie_name=serie_name,
serie_folder=serie_folder,
year=year
)
logger.info("Created minimal NFO for '%s'", serie_name)
except Exception as minimal_error:
logger.error(
"Failed to create minimal NFO for '%s': %s",
serie_name, minimal_error
)
elif nfo_exists:
logger.debug(
f"NFO exists for '{serie_name}', skipping download"
)
except TMDBAPIError as e:
# Only log at ERROR if no NFO exists and we have no IDs
# If NFO exists with IDs, this is just a lookup failure, log at DEBUG
if nfo_exists and (ids.get("tmdb_id") or ids.get("tvdb_id")):
logger.debug(
"TMDB API lookup failed for '%s' (has NFO with IDs): %s",
serie_name, e
)
else:
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()

View File

@@ -21,10 +21,9 @@ from typing import Callable, Iterable, Iterator, Optional
from events import Events
from src.config.settings import settings
from src.core.entities.series import Serie
from src.core.exceptions.Exceptions import MatchNotFoundError, NoKeyFoundException
from src.core.providers.base_provider import Loader
from src.core.utils.key_utils import generate_key_from_folder
from src.server.database.models import AnimeSeries
from src.server.exceptions.exceptions.Exceptions import MatchNotFoundError
from src.server.providers.base_provider import Loader
from src.server.database.connection import get_sync_session
from src.server.database.service import AnimeSeriesService, EpisodeService
@@ -53,23 +52,12 @@ class SerieScanner:
# scan() detects running event loop and uses create_task()
# internally, so no special handling needed by caller.
# Results are in scanner.keyDict
# With DB lookup fallback:
scanner = SerieScanner("/path/to/anime", loader,
db_lookup=lambda folder: my_db.get_by_folder(folder))
# With scan key overrides:
overrides = {"Folder Name": "correct-provider-key"}
scanner = SerieScanner("/path/to/anime", loader,
scan_key_overrides=overrides)
"""
def __init__(
self,
basePath: str,
loader: Loader,
db_lookup: Optional[Callable[[str], Optional["Serie"]]] = None,
scan_key_overrides: Optional[dict[str, str]] = None,
) -> None:
"""
Initialize the SerieScanner.
@@ -77,15 +65,6 @@ class SerieScanner:
Args:
basePath: Base directory containing anime series
loader: Loader instance for fetching series information
db_lookup: Optional callable ``(folder_name) -> Serie | None``.
When provided, it is called as a fallback when neither a
``key`` file nor a ``data`` file is found in the folder.
This allows the database to supply the series key for
folders that have never had a local key file.
scan_key_overrides: Optional dict mapping folder names to provider
keys. When a folder name is found in this dict, the override
key is used instead of auto-generating from folder name.
Format: {"Folder Name": "actual-provider-key"}
Raises:
ValueError: If basePath is invalid or doesn't exist
@@ -102,10 +81,8 @@ class SerieScanner:
raise ValueError(f"Base path is not a directory: {abs_path}")
self.directory: str = abs_path
self.keyDict: dict[str, Serie] = {}
self.keyDict: dict[str, AnimeSeries] = {}
self.loader: Loader = loader
self._db_lookup: Optional[Callable[[str], Optional[Serie]]] = db_lookup
self._scan_key_overrides: Optional[dict[str, str]] = scan_key_overrides
self._current_operation_id: Optional[str] = None
self.events = Events()
@@ -242,64 +219,63 @@ class SerieScanner:
self.events.on_completion.remove(handler)
def reinit(self) -> None:
"""Reinitialize the series dictionary (keyed by serie.key)."""
self.keyDict: dict[str, Serie] = {}
"""Reinitialize the series dictionary (keyed by anime.key)."""
self.keyDict: dict[str, AnimeSeries] = {}
async def _persist_serie_to_db(self, serie: Serie) -> None:
"""Persist serie to database (create or update).
async def _persist_serie_to_db(self, anime: AnimeSeries) -> None:
"""Persist anime to database (create or update).
Args:
serie: Serie domain object to persist
anime: AnimeSeries model to persist
"""
try:
from src.server.database.connection import get_async_session_factory
db = get_async_session_factory()
try:
existing = await AnimeSeriesService.get_by_key(db, serie.key)
existing = await AnimeSeriesService.get_by_key(db, anime.key)
if existing:
await AnimeSeriesService.update(
db, existing.id,
name=serie.name,
folder=serie.folder,
year=serie.year
name=anime.name,
folder=anime.folder,
year=anime.year
)
await self._sync_episodes_to_db(db, existing.id, serie.episodeDict)
await self._sync_episodes_to_db(db, existing.id, anime.episodeDict)
else:
anime_series = await AnimeSeriesService.create(
db_anime = await AnimeSeriesService.create(
db=db,
key=serie.key,
name=serie.name,
site=serie.site,
folder=serie.folder,
year=serie.year
key=anime.key,
name=anime.name,
site=anime.site,
folder=anime.folder,
year=anime.year
)
for season, eps in serie.episodeDict.items():
for ep in eps:
await EpisodeService.create(
db=db,
series_id=anime_series.id,
season=season,
episode_number=ep
)
for ep in anime.episodes:
await EpisodeService.create(
db=db,
series_id=db_anime.id,
season=ep.season,
episode_number=ep.episode_number
)
await db.commit()
logger.debug(
"Persisted serie '%s' (key=%s) to database",
serie.name, serie.key
"Persisted anime '%s' (key=%s) to database",
anime.name, anime.key
)
except Exception as e:
await db.rollback()
logger.error(
"Failed to persist serie '%s' to DB: %s",
serie.key, e, exc_info=True
"Failed to persist anime '%s' to DB: %s",
anime.key, e, exc_info=True
)
raise
finally:
await db.close()
except Exception as e:
logger.error(
"Could not persist serie '%s' to DB (DB unavailable?): %s",
serie.key, e
"Could not persist anime '%s' to DB (DB unavailable?): %s",
anime.key, e
)
async def _sync_episodes_to_db(
@@ -419,59 +395,15 @@ class SerieScanner:
serie = self.__read_data_from_file(folder)
if serie is None or not serie.key or not serie.key.strip():
logger.warning(
"No key or data file found for folder '%s', skipping",
"No series found in DB for folder '%s', skipping",
folder,
)
continue
if (
serie is not None
and serie.key
and serie.key.strip()
):
# Try to extract year from folder name first
if not hasattr(serie, 'year') or not serie.year:
year_from_folder = self._extract_year_from_folder_name(folder)
if year_from_folder:
serie.year = year_from_folder
logger.info(
"Using year from folder name: %s (year=%d)",
folder,
year_from_folder
)
else:
# If not in folder name, fetch from provider
try:
serie.year = self.loader.get_year(serie.key)
if serie.year:
logger.info(
"Fetched year from provider: %s (year=%d)",
serie.key,
serie.year
)
except Exception as e:
logger.warning(
"Could not fetch year for %s: %s",
serie.key,
str(e)
)
# Fetch series name from provider if not already set
if not serie.name:
try:
fetched_name = self.loader.get_title(serie.key)
if fetched_name:
serie.name = fetched_name
logger.info(
"Fetched name from provider: %s (name=%s)",
serie.key,
serie.name
)
except Exception as e:
logger.warning(
"Could not fetch name for %s: %s",
serie.key,
str(e)
)
# Delegate the provider to compare local files with
# remote metadata, yielding missing episodes per
# season. Results are saved back to disk so that both
@@ -536,21 +468,6 @@ class SerieScanner:
"Saved Serie: '%s'", str(serie)
)
except NoKeyFoundException as nkfe:
# Log error and notify via callback
error_msg = f"Error processing folder '{folder}': {nkfe}"
logger.error(error_msg)
self._safe_call_event(
self.events.on_error,
{
"operation_id": self._current_operation_id,
"error": nkfe,
"message": error_msg,
"recoverable": True,
"metadata": {"folder": folder, "key": None}
}
)
except Exception as e:
# Log error and notify via callback
error_msg = (
@@ -639,49 +556,25 @@ class SerieScanner:
has_files = True
yield anime_name, mp4_files if has_files else []
def __read_data_from_file(self, folder_name: str) -> Optional[Serie]:
"""Load or discover a Serie for the given folder.
def __read_data_from_file(self, folder_name: str) -> Optional[AnimeSeries]:
"""Load or discover an AnimeSeries for the given folder.
Strategy:
1. Query DB by folder name
2. If found, return cached Serie object
3. If not in DB, fall back to provider search via _db_lookup callback
4. If still not found, try reading 'data' file for legacy deployments
5. Check user-provided key overrides in scan_key_overrides
6. Generate key from folder name as last resort
2. If not found in DB, return None (no file fallback)
Args:
folder_name: Filesystem folder name
Returns:
Serie object with valid key if found, None otherwise
Note:
DB is the source of truth. File-based lookups (data files)
are temporary backward compatibility for CLI-only deployments.
AnimeSeries object if found in DB, None otherwise
"""
# Step 1: Try DB lookup by folder name
try:
session = get_sync_session()
try:
anime_series = AnimeSeriesService.get_by_folder_sync(session, folder_name)
if anime_series:
# Reconstruct Serie from DB record
episode_dict: dict[int, list[int]] = {}
if anime_series.episodes:
for ep in anime_series.episodes:
season = ep.season or 1
if season not in episode_dict:
episode_dict[season] = []
episode_dict[season].append(ep.episode_number or ep.number or 0)
return Serie(
key=anime_series.key,
name=anime_series.name,
site=anime_series.site,
folder=anime_series.folder,
episodeDict=episode_dict,
year=anime_series.year
)
return anime_series
finally:
session.close()
except Exception as exc:
@@ -691,79 +584,6 @@ class SerieScanner:
exc
)
# Step 2: Fall back to provider search callback
if self._db_lookup is not None:
try:
serie = self._db_lookup(folder_name)
if serie and serie.key and serie.key.strip():
logger.info(
"Provider lookup resolved folder '%s' -> key='%s'",
folder_name,
serie.key
)
return serie
except Exception as exc:
logger.warning(
"Provider lookup failed for folder '%s': %s",
folder_name,
exc
)
# Step 3: Legacy data file fallback (CLI-only deployments)
folder_path = os.path.join(self.directory, folder_name)
serie_file = os.path.join(folder_path, 'data')
if os.path.exists(serie_file):
with open(serie_file, "rb") as file:
logger.info(
"load serie_file from '%s': %s",
folder_name,
serie_file
)
return Serie.load_from_file(serie_file)
# Step 4: Check for user-provided key overrides before generating
if self._scan_key_overrides and folder_name in self._scan_key_overrides:
override_key = self._scan_key_overrides[folder_name]
year_from_folder = self._extract_year_from_folder_name(folder_name)
logger.info(
"Using scan key override for folder '%s' -> key='%s'",
folder_name,
override_key
)
return Serie(
key=override_key,
name="", # Name will be fetched from provider if needed
site="aniworld.to",
folder=folder_name,
episodeDict=dict(),
year=year_from_folder
)
# Step 5: Generate key from folder name as last resort
# This handles edge cases like non-Latin characters or special symbols
try:
generated_key = generate_key_from_folder(folder_name)
year_from_folder = self._extract_year_from_folder_name(folder_name)
logger.info(
"Generated key for folder '%s' -> key='%s'",
folder_name,
generated_key
)
return Serie(
key=generated_key,
name="", # Name will be fetched from provider if needed
site="aniworld.to",
folder=folder_name,
episodeDict=dict(),
year=year_from_folder
)
except Exception as exc:
logger.warning(
"Failed to generate key for folder '%s': %s",
folder_name,
exc
)
return None
def __get_episode_and_season(self, filename: str) -> tuple[int, int]:
@@ -957,51 +777,38 @@ class SerieScanner:
}
)
# Create or update Serie in keyDict
# Create or update AnimeSeries in keyDict
if key in self.keyDict:
# Update existing serie
self.keyDict[key].episodeDict = missing_episodes
# Update existing anime - rebuild episodeDict from episodes
existing = self.keyDict[key]
existing_ep_dict = existing.episodeDict
# Merge missing episodes
for season, eps in missing_episodes.items():
if season not in existing_ep_dict:
existing_ep_dict[season] = []
existing_ep_dict[season].extend(eps)
logger.debug(
"Updated existing series %s with %d missing episodes",
key,
sum(len(eps) for eps in missing_episodes.values())
)
else:
# Try to extract year from folder name first
# Extract year from folder name if present, otherwise leave as None
year = self._extract_year_from_folder_name(folder)
if year:
logger.info(
"Using year from folder name: %s (year=%d)",
folder,
year
)
else:
# If not in folder name, fetch from provider
try:
year = self.loader.get_year(key)
if year:
logger.info(
"Fetched year from provider: %s (year=%d)",
key,
year
)
except Exception as e:
logger.warning(
"Could not fetch year for %s: %s",
key,
str(e)
)
# Create new serie entry
serie = Serie(
# Create new AnimeSeries entry (minimal, fields populated later)
from src.server.database.models import AnimeSeries
anime_series = AnimeSeries(
key=key,
name="", # Will be populated by caller if needed
name=folder, # Use folder as fallback name since we don't have actual name
site=site,
folder=folder,
episodeDict=missing_episodes,
year=year
)
self.keyDict[key] = serie
# Set episodeDict cache directly since AnimeSeries doesn't persist missing episodes
# (they get synced to DB via _persist_serie_to_db later)
anime_series._episode_dict_cache = missing_episodes.copy()
self.keyDict[key] = anime_series
logger.debug(
"Created new series entry for %s with %d missing episodes (year=%s)",
key,

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
@@ -168,33 +162,16 @@ class SeriesApp:
self.serie_scanner = SerieScanner(
directory_to_search,
self.loader,
db_lookup=db_lookup,
scan_key_overrides=settings.scan_key_overrides,
)
# 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,
@@ -356,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."""
@@ -765,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.
@@ -776,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
@@ -784,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",
@@ -825,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",
@@ -846,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,6 +1,7 @@
import logging
import os
import re
import warnings
from pathlib import Path
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
@@ -8,8 +9,7 @@ from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from src.config.settings import settings
from src.core.entities.series import Serie
from src.core.utils.key_utils import generate_key_from_folder, is_valid_key
from src.server.database.models import AnimeSeries
from src.server.database.service import AnimeSeriesService
from src.server.exceptions import (
BadRequestError,
@@ -17,10 +17,15 @@ from src.server.exceptions import (
ServerError,
ValidationError,
)
from src.server.models.anime import AnimeMetadataUpdate
from src.server.models.anime import (
AnimeDetailsResponse,
AnimeSettingsRegenerateNfoResponse,
AnimeSettingsResponse,
AnimeSettingsUpdateRequest,
TMDBSearchResult,
)
from src.server.services.anime_service import AnimeService, AnimeServiceError
from src.server.services.background_loader_service import BackgroundLoaderService
from src.server.services.folder_rename_service import _scan_for_pre_existing_duplicates
from src.server.utils.dependencies import (
get_anime_service,
get_background_loader_service,
@@ -29,7 +34,8 @@ from src.server.utils.dependencies import (
get_series_app,
require_auth,
)
from src.server.utils.filesystem import sanitize_folder_name
from src.server.utils.filesystem import is_safe_path, sanitize_folder_name
from src.server.utils.key_utils import generate_key_from_folder, is_valid_key
from src.server.utils.validators import validate_filter_value, validate_search_query
logger = logging.getLogger(__name__)
@@ -37,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),
@@ -77,26 +108,14 @@ async def get_anime_status(
class DuplicateFolderGroup(BaseModel):
"""A group of duplicate folders for the same series.
Attributes:
key: Series key (provider-assigned unique identifier)
folders: List of folder names that are duplicates
folder_count: Number of duplicate folders
"""
"""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):
"""Response model for duplicate folders listing.
Attributes:
total_groups: Total number of duplicate groups found
duplicate_groups: List of duplicate folder groups
message: Human-readable summary
"""
"""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"
@@ -110,64 +129,13 @@ async def get_duplicate_folders(
) -> DuplicateFoldersResponse:
"""List all pre-existing duplicate folder groups.
Scans the anime directory for folders with tvshow.nfo files that
map to the same series key. Returns groups of duplicates for
manual review and cleanup.
Returns:
DuplicateFoldersResponse with groups of duplicate folders
Note:
Not all duplicate folders are safe to merge - some may belong
to different releases (e.g., dubbed vs. subbed). Review carefully
before taking action.
Note: Duplicate folder scanning has been removed. Returns empty response.
"""
try:
if not settings.anime_directory:
return DuplicateFoldersResponse(
total_groups=0,
duplicate_groups=[],
message="Anime directory not configured",
)
anime_dir = Path(settings.anime_directory)
if not anime_dir.is_dir():
return DuplicateFoldersResponse(
total_groups=0,
duplicate_groups=[],
message=f"Anime directory not found: {anime_dir}",
)
duplicates = _scan_for_pre_existing_duplicates(anime_dir)
groups = [
DuplicateFolderGroup(
key=dup.key,
folders=dup.folders,
folder_count=dup.count,
)
for dup in duplicates
]
if groups:
message = (
f"Found {len(groups)} duplicate group(s). "
"Review carefully - some duplicates may be different releases "
"(e.g., dubbed vs. subbed)."
)
else:
message = "No duplicate folders found."
return DuplicateFoldersResponse(
total_groups=len(groups),
duplicate_groups=groups,
message=message,
)
except Exception as exc:
logger.error("Failed to scan for duplicate folders: %s", str(exc))
raise ServerError(
message=f"Failed to scan for duplicates: {str(exc)}"
) from exc
return DuplicateFoldersResponse(
total_groups=0,
duplicate_groups=[],
message="Duplicate folder scanning has been removed.",
)
class AnimeSummary(BaseModel):
@@ -487,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
@@ -828,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,
@@ -848,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)
@@ -894,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,
@@ -914,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,
@@ -935,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(
@@ -1097,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.
@@ -1164,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}")
@@ -1179,39 +1207,41 @@ async def get_anime(
except HTTPException:
raise
except Exception as exc:
logger.error(
"Failed to retrieve series details for '%s': %s",
anime_id,
exc,
exc_info=True,
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve series details",
) from exc
# Maximum allowed input size for security
MAX_INPUT_LENGTH = 100000 # 100KB
@router.put("/{anime_key}")
async def update_anime_metadata(
@router.get("/{anime_key}/details", response_model=AnimeDetailsResponse)
async def get_anime_details(
anime_key: str,
body: AnimeMetadataUpdate,
_auth: dict = Depends(require_auth),
db: AsyncSession = Depends(get_database_session),
) -> dict:
"""Update anime metadata (key, tmdb_id, tvdb_id).
) -> AnimeDetailsResponse:
"""Get detailed information about a specific anime series for the edit modal.
Returns comprehensive series metadata including TMDB/TVDB IDs, NFO status,
and other details needed to pre-fill the edit form.
Args:
anime_key: Current series key to update
body: Fields to update (all optional)
anime_key: Series key (primary identifier)
_auth: Authentication dependency
db: Database session
Returns:
Updated series metadata
AnimeDetailsResponse: Full series details for edit modal
Raises:
HTTPException 404: Series not found
HTTPException 409: Key conflict (new key already exists)
HTTPException 422: Validation error
"""
# Get series from database (authoritative source for IDs and NFO status)
series = await AnimeSeriesService.get_by_key(db, anime_key)
if not series:
raise HTTPException(
@@ -1219,44 +1249,509 @@ async def update_anime_metadata(
detail=f"Series with key '{anime_key}' not found",
)
updates = {}
# Format timestamps
nfo_created = None
nfo_updated = None
if series.nfo_created_at:
nfo_created = series.nfo_created_at.isoformat()
if series.nfo_updated_at:
nfo_updated = series.nfo_updated_at.isoformat()
if body.key is not None and body.key != anime_key:
existing = await AnimeSeriesService.get_by_key(db, body.key)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"A series with key '{body.key}' already exists",
)
updates["key"] = body.key
if body.tmdb_id is not None:
updates["tmdb_id"] = body.tmdb_id
if body.tvdb_id is not None:
updates["tvdb_id"] = body.tvdb_id
if not updates:
return {
"key": series.key,
"tmdb_id": series.tmdb_id,
"tvdb_id": series.tvdb_id,
"message": "No changes",
}
updated = await AnimeSeriesService.update(db, series.id, **updates)
await db.commit()
logger.info(
"Updated metadata for '%s': %s",
anime_key,
updates,
return AnimeDetailsResponse(
key=series.key,
name=series.name,
folder=series.folder,
year=series.year,
status=None, # Status not stored in DB, only in NFO/TMDB
plot=None, # Plot not stored in DB, only in NFO/TMDB
genres=[],
studio=[],
premiered=None,
rating=None,
rating_votes=None,
tmdb_id=series.tmdb_id,
tvdb_id=series.tvdb_id,
has_nfo=series.has_nfo,
nfo_created_at=nfo_created,
nfo_updated_at=nfo_updated,
)
return {
"key": updated.key,
"tmdb_id": updated.tmdb_id,
"tvdb_id": updated.tvdb_id,
"message": "Metadata updated successfully",
}
@router.get("/{anime_key}/tmdb-search", response_model=List[TMDBSearchResult])
async def search_tmdb_for_series(
anime_key: str,
_auth: dict = Depends(require_auth),
db: AsyncSession = Depends(get_database_session),
) -> List[TMDBSearchResult]:
"""Search TMDB for a series by its name to find matching metadata.
Used by the edit modal's "Fetch from TMDB" feature when no TMDB ID
is set. Searches TMDB using the series name and returns matches.
Args:
anime_key: Series key to look up
_auth: Authentication dependency
db: Database session
Returns:
List[TMDBSearchResult]: Matching TMDB results
Raises:
HTTPException 404: Series not found
HTTPException 400: TMDB not configured
"""
from src.server.nfo.tmdb_client import TMDBClient
# Get series from database
series = await AnimeSeriesService.get_by_key(db, anime_key)
if not series:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Series with key '{anime_key}' not found",
)
# Check if TMDB is configured
if not settings.tmdb_api_key:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="TMDB API key not configured",
)
# Search TMDB by series name
tmdb_client = TMDBClient(api_key=settings.tmdb_api_key)
results = await tmdb_client.search_tv_series(series.name)
return [
TMDBSearchResult(
tmdb_id=r["id"],
title=r.get("name", ""),
year=int(r.get("first_air_date", "0000")[:4]) if r.get("first_air_date") else None,
overview=r.get("overview"),
vote_average=r.get("vote_average"),
)
for r in results
]
# ============================================================================
# Anime Settings endpoints (rename of NFO Diagnostic page)
# ============================================================================
async def _build_anime_settings_payload(
anime_key: str,
db: AsyncSession,
anime_service: AnimeService,
) -> AnimeSettingsResponse:
"""Build the AnimeSettingsResponse payload for a given series.
Combines data from the in-memory SeriesApp (folder/name/site/year) with
the authoritative database row (tmdb_id, tvdb_id, has_nfo, nfo_*,
loading_status) and episode counts.
Args:
anime_key: Series unique key
db: Database session
anime_service: AnimeService for in-memory fallback
Returns:
AnimeSettingsResponse with all editable fields populated
Raises:
HTTPException 404: If series not found
"""
from src.server.database.service import AnimeSeriesService, EpisodeService
db_series = await AnimeSeriesService.get_by_key(db, anime_key)
if not db_series:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Series not found: {anime_key}",
)
# Episode counts (authoritative DB source)
episodes = await EpisodeService.get_by_series(db, db_series.id)
episode_count = len(episodes)
missing_episode_count = sum(
1 for ep in episodes if not ep.is_downloaded
)
# In-memory fallback for folder/name/site/year (DB is authoritative)
name = db_series.name
site = db_series.site
folder = db_series.folder
year = db_series.year
if hasattr(anime_service, "_app") and hasattr(anime_service._app, "list"):
try:
for serie in anime_service._app.list.GetList():
if getattr(serie, "key", None) == anime_key:
name = getattr(serie, "name", name) or name
site = getattr(serie, "site", site) or site
folder = getattr(serie, "folder", folder) or folder
year = getattr(serie, "year", year) or year
break
except Exception:
pass
nfo_created = (
db_series.nfo_created_at.isoformat()
if db_series.nfo_created_at else None
)
nfo_updated = (
db_series.nfo_updated_at.isoformat()
if db_series.nfo_updated_at else None
)
return AnimeSettingsResponse(
key=db_series.key,
name=name,
site=site,
folder=folder,
year=year,
tmdb_id=db_series.tmdb_id,
tvdb_id=db_series.tvdb_id,
has_nfo=bool(db_series.has_nfo),
nfo_path=db_series.nfo_path,
nfo_created_at=nfo_created,
nfo_updated_at=nfo_updated,
loading_status=db_series.loading_status,
episode_count=episode_count,
missing_episode_count=missing_episode_count,
)
@router.get("/{anime_key}/settings", response_model=AnimeSettingsResponse)
async def get_anime_settings(
anime_key: str,
_auth: dict = Depends(require_auth),
db: AsyncSession = Depends(get_database_session),
anime_service: AnimeService = Depends(get_anime_service),
) -> AnimeSettingsResponse:
"""Return the full Anime Settings payload for a single series.
Powers the per-anime settings page reached from the right-click context
menu. Returns every field the user can view or edit, plus episode counts.
Args:
anime_key: Series unique key
_auth: Authentication dependency
db: Database session
anime_service: AnimeService dependency
Returns:
AnimeSettingsResponse with key, name, site, folder, year, tmdb_id,
tvdb_id, NFO status and episode counts.
Raises:
HTTPException 404: If series not found.
"""
return await _build_anime_settings_payload(anime_key, db, anime_service)
def _validate_folder_value(folder: str, anime_dir: Optional[str]) -> str:
"""Validate and sanitize a folder name.
Raises HTTPException(422) on empty / invalid folder, 422 on path
traversal, 422 if folder escapes anime_dir.
"""
if not folder or not folder.strip():
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Folder cannot be empty",
)
try:
sanitized = sanitize_folder_name(folder)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid folder name: {exc}",
)
if anime_dir:
full_path = os.path.join(anime_dir, sanitized)
if not is_safe_path(anime_dir, full_path):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Folder path is not safe",
)
return sanitized
def _validate_tmdb_id(tmdb_id: Optional[int]) -> None:
"""Validate TMDB ID is positive and within 10 digits."""
if tmdb_id is None:
return
if tmdb_id <= 0:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="TMDB ID must be a positive integer",
)
if tmdb_id > 9999999999:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="TMDB ID exceeds maximum length (10 digits)",
)
def _validate_tvdb_id(tvdb_id: Optional[int]) -> None:
"""Validate TVDB ID is positive and within 10 digits."""
if tvdb_id is None:
return
if tvdb_id <= 0:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="TVDB ID must be a positive integer",
)
if tvdb_id > 9999999999:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="TVDB ID exceeds maximum length (10 digits)",
)
@router.put("/{anime_key}/settings", response_model=AnimeSettingsResponse)
async def update_anime_settings(
anime_key: str,
request: AnimeSettingsUpdateRequest,
_auth: dict = Depends(require_auth),
db: AsyncSession = Depends(get_database_session),
anime_service: AnimeService = Depends(get_anime_service),
) -> AnimeSettingsResponse:
"""Update editable fields for a single anime series.
Performs validation on each supplied field, writes the changes to the
database (and optionally to tvshow.nfo when ``apply_to_nfo`` is true),
then returns the fresh payload.
Args:
anime_key: Series unique key (path param)
request: Update payload. All fields optional except as documented
in AnimeSettingsUpdateRequest.
_auth: Authentication dependency
db: Database session
anime_service: AnimeService for disk rename + NFO regeneration
Returns:
AnimeSettingsResponse: Updated payload reflecting new values.
Raises:
HTTPException 404: Series not found.
HTTPException 422: Validation failure (empty name, invalid folder,
non-positive tmdb_id/tvdb_id, oversized id, path traversal).
"""
from src.server.database.service import AnimeSeriesService
db_series = await AnimeSeriesService.get_by_key(db, anime_key)
if not db_series:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Series not found: {anime_key}",
)
# Field-level validation
anime_dir = (
settings.anime_directory
if hasattr(settings, "anime_directory") else None
)
update_fields: dict = {}
if request.name is not None:
new_name = request.name.strip()
if not new_name:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Name cannot be empty",
)
if len(new_name) > 500:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Name exceeds 500 characters",
)
update_fields["name"] = new_name
if request.folder is not None:
update_fields["folder"] = _validate_folder_value(
request.folder, anime_dir
)
_validate_tmdb_id(request.tmdb_id)
if request.tmdb_id is not None:
update_fields["tmdb_id"] = request.tmdb_id
_validate_tvdb_id(request.tvdb_id)
if request.tvdb_id is not None:
update_fields["tvdb_id"] = request.tvdb_id
if request.site is not None:
update_fields["site"] = request.site
if not update_fields and not request.apply_to_nfo and not request.rename_disk:
# Nothing to do — return current state
return await _build_anime_settings_payload(anime_key, db, anime_service)
# Apply DB update
if update_fields:
await AnimeSeriesService.update(
db, db_series.id, **update_fields
)
await db.commit()
await db.refresh(db_series)
logger.info(
"Updated anime settings for %s: %s",
anime_key,
sorted(update_fields.keys()),
)
# Update in-memory SerieList so the UI sees the changes immediately
if hasattr(anime_service, "_app") and hasattr(anime_service._app, "list"):
try:
in_mem = anime_service._app.list.keyDict.get(anime_key)
if in_mem is not None:
if "name" in update_fields:
in_mem.name = update_fields["name"]
if "folder" in update_fields:
in_mem.folder = update_fields["folder"]
if "site" in update_fields:
in_mem.site = update_fields["site"]
except Exception as exc:
logger.debug("Could not update in-memory serie: %s", exc)
# Optionally rename the on-disk folder
if request.rename_disk and "folder" in update_fields:
try:
await anime_service.rename_folder_if_needed(
key=anime_key,
current_folder=db_series.folder,
target_folder=update_fields["folder"],
db=db,
)
except Exception as exc:
logger.warning(
"Folder rename failed for %s: %s",
anime_key,
exc,
)
# Optionally regenerate tvshow.nfo with the new values
if request.apply_to_nfo:
if not db_series.tmdb_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"Cannot regenerate NFO without a TMDB ID. "
"Set tmdb_id first or use the Repair flow."
),
)
try:
# Lazy-import to avoid heavy deps when not used
from src.server.api.nfo import _create_or_update_nfo
series_data = {
"key": anime_key,
"name": db_series.name,
"folder": db_series.folder,
"tmdb_id": db_series.tmdb_id,
}
await _create_or_update_nfo(
key=anime_key,
folder=db_series.folder,
tmdb_id=db_series.tmdb_id,
series_data=series_data,
)
except HTTPException:
raise
except Exception as exc:
logger.error(
"NFO regeneration failed for %s: %s",
anime_key,
exc,
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"NFO regeneration failed: {exc}",
)
return await _build_anime_settings_payload(anime_key, db, anime_service)
@router.post(
"/{anime_key}/regenerate-nfo",
response_model=AnimeSettingsRegenerateNfoResponse,
)
async def regenerate_anime_nfo(
anime_key: str,
_auth: dict = Depends(require_auth),
db: AsyncSession = Depends(get_database_session),
anime_service: AnimeService = Depends(get_anime_service),
) -> AnimeSettingsRegenerateNfoResponse:
"""Regenerate tvshow.nfo for a single anime using TMDB.
Thin wrapper around the existing NFO repair flow, exposed under
/api/anime/{key}/ for symmetry with the settings page UI.
Args:
anime_key: Series unique key
_auth: Authentication dependency
db: Database session
anime_service: AnimeService dependency
Returns:
AnimeSettingsRegenerateNfoResponse with success flag, message,
regenerated nfo_path and the tags that were missing before.
Raises:
HTTPException 404: Series not found.
HTTPException 400: No TMDB ID configured.
HTTPException 500: TMDB / NFO regeneration failure.
"""
from src.server.database.service import AnimeSeriesService
db_series = await AnimeSeriesService.get_by_key(db, anime_key)
if not db_series:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Series not found: {anime_key}",
)
tmdb_id = db_series.tmdb_id
if not tmdb_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Series has no TMDB ID — set one before regenerating NFO",
)
try:
from src.server.api.nfo import _create_or_update_nfo
series_data = {
"key": anime_key,
"name": db_series.name,
"folder": db_series.folder,
"tmdb_id": tmdb_id,
}
repaired_tags = await _create_or_update_nfo(
key=anime_key,
folder=db_series.folder,
tmdb_id=tmdb_id,
series_data=series_data,
)
except HTTPException:
raise
except Exception as exc:
logger.error("NFO regeneration failed for %s: %s", anime_key, exc)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"NFO regeneration failed: {exc}",
)
return AnimeSettingsRegenerateNfoResponse(
success=True,
message=(
f"NFO regenerated. {len(repaired_tags)} tags updated."
if repaired_tags else "NFO already complete."
),
nfo_path=db_series.nfo_path,
repaired_tags=repaired_tags,
)

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,12 +163,9 @@ 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_service import (
from src.server.services.scheduler.scheduler_service import (
get_scheduler_service,
)
@@ -180,7 +180,6 @@ async def setup_auth(req: SetupRequest):
# 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,
@@ -196,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,
@@ -214,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.
@@ -246,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

@@ -47,8 +47,19 @@ async def update_config(
from src.config.settings import settings as app_settings
anime_dir_changed = False
if update.other and update.other.get("anime_directory"):
anime_dir = update.other.get("anime_directory")
other_data = update.other
if isinstance(other_data, str):
try:
import ast
other_data = ast.literal_eval(other_data)
except (ValueError, SyntaxError):
try:
import json
other_data = json.loads(other_data)
except (ValueError, json.JSONDecodeError):
other_data = None
if other_data and other_data.get("anime_directory"):
anime_dir = other_data.get("anime_directory")
if anime_dir and not app_settings.anime_directory:
app_settings.anime_directory = str(anime_dir)
anime_dir_changed = True
@@ -57,7 +68,7 @@ async def update_config(
# Start scheduler if anime_directory was just configured
if anime_dir_changed:
try:
from src.server.services.scheduler_service import (
from src.server.services.scheduler.scheduler_service import (
get_scheduler_service,
)
@@ -108,17 +119,18 @@ def validate_config(
) from e
@router.get("/backups", response_model=List[Dict[str, object]])
@router.get("/backups", response_model=Dict[str, List[Dict[str, object]]])
def list_backups(
auth: dict = Depends(require_auth)
) -> List[Dict[str, object]]:
) -> Dict[str, List[Dict[str, object]]]:
"""List all available configuration backups.
Returns list of backup metadata including name, size, and created time.
"""
try:
config_service = get_config_service()
return config_service.list_backups()
backups = config_service.list_backups()
return {"backups": backups}
except ConfigServiceError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -126,7 +138,7 @@ def list_backups(
) from e
@router.post("/backups", response_model=Dict[str, str])
@router.post("/backups", response_model=Dict[str, str], status_code=status.HTTP_201_CREATED)
def create_backup(
name: Optional[str] = None, auth: dict = Depends(require_auth)
) -> Dict[str, str]:
@@ -279,30 +291,15 @@ async def update_directory(
config_service.save_config(app_config)
# Sync series from data files to database
sync_count = 0
try:
import structlog
from src.server.services.anime_service import sync_legacy_series_to_db
logger = structlog.get_logger(__name__)
sync_count = await sync_legacy_series_to_db(directory, logger)
logger.info(
"Directory updated: synced series from data files",
directory=directory,
count=sync_count
)
except Exception as e:
# Log but don't fail the directory update if sync fails
import structlog
structlog.get_logger(__name__).warning(
"Failed to sync series after directory update",
error=str(e)
)
# Series are now loaded directly from database, no sync needed
logger.info(
"Directory updated successfully",
directory=directory
)
response: Dict[str, Any] = {
"message": "Anime directory updated successfully",
"synced_series": sync_count
"synced_series": 0
}
return response
@@ -412,6 +409,44 @@ def reset_config(
) from e
# Unauthenticated admin reset endpoint for test isolation
@router.post("/admin/reset", response_model=Dict[str, str])
def admin_reset_config() -> Dict[str, str]:
"""Reset application to unconfigured state.
WARNING: This endpoint has no authentication and should only be used
for testing. It clears the master password hash and resets auth state.
Returns:
Success message
"""
try:
config_service = get_config_service()
# Load current config
config = config_service.load_config()
# Clear master password hash from other
if "master_password_hash" in config.other:
del config.other["master_password_hash"]
# Save config
config_service.save_config(config)
# Reset auth service in-memory state
from src.server.services.auth_service import auth_service
auth_service.reset()
return {
"message": "Application reset to unconfigured state successfully"
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to reset application: {e}"
) from e
@router.post("/tmdb/validate", response_model=Dict[str, Any])
async def validate_tmdb_key(
api_key_data: Dict[str, str], auth: dict = Depends(require_auth)

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

@@ -69,6 +69,7 @@ class DetailedHealthStatus(BaseModel):
version: str = APP_VERSION
dependencies: DependencyHealth
startup_time: datetime
uptime: str
# Global startup time
@@ -195,7 +196,9 @@ async def basic_health_check(request: Request) -> HealthStatus:
# Get scheduler status for health monitoring
scheduler_status: dict = {}
try:
from src.server.services.scheduler_service import get_scheduler_service
from src.server.services.scheduler.scheduler_service import (
get_scheduler_service,
)
scheduler_status = get_scheduler_service().get_status()
except Exception:
pass
@@ -296,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,7 +13,7 @@ from __future__ import annotations
from datetime import datetime, timezone
from enum import Enum
from typing import List, Optional
from typing import Any, Dict, List, Optional
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
@@ -190,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.
@@ -578,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.

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

@@ -74,22 +74,28 @@ class RecoveryStrategies:
delay = self.base_delay * (self.exponential_base ** attempt)
return min(delay, self.max_delay)
@staticmethod
def handle_network_failure(
self,
func: Callable, *args: Any, **kwargs: Any
) -> Any:
"""Handle network failures with exponential backoff retry logic."""
last_error: Optional[Exception] = None
for attempt in range(self.max_retries):
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, TimeoutError) as exc:
last_error = exc
if attempt < self.max_retries - 1:
delay = self._calculate_delay(attempt)
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, self.max_retries, delay, exc
attempt + 1, max_retries, delay, exc
)
import time
time.sleep(delay)
@@ -98,22 +104,28 @@ class RecoveryStrategies:
raise last_error
raise NetworkError("Network failure after retries")
@staticmethod
def handle_download_failure(
self,
func: Callable, *args: Any, **kwargs: Any
) -> Any:
"""Handle download failures with exponential backoff retry logic."""
last_error: Optional[Exception] = None
for attempt in range(self.max_retries):
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 as exc:
last_error = exc
if attempt < self.max_retries - 1:
delay = self._calculate_delay(attempt)
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, self.max_retries, delay, exc
attempt + 1, max_retries, delay, exc
)
import time
time.sleep(delay)

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,
@@ -343,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:
@@ -372,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
@@ -411,7 +408,9 @@ async def lifespan(_application: FastAPI):
# anime_directory may be configured there even if the env var is empty.
try:
logger.info("Initializing scheduler service...")
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("Scheduler service instance obtained, starting...")
await scheduler_service.start()
@@ -496,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(
@@ -633,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)
@@ -644,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."""
@@ -83,30 +79,6 @@ class AnimeSeriesResponse(BaseModel):
return v
class AnimeMetadataUpdate(BaseModel):
"""Request model for updating anime metadata (key, tmdb_id, tvdb_id)."""
key: Optional[str] = Field(None, description="New series key (URL-safe, lowercase)")
tmdb_id: Optional[int] = Field(None, ge=1, description="TMDB ID (positive integer)")
tvdb_id: Optional[int] = Field(None, ge=1, description="TVDB ID (positive integer)")
@field_validator('key', mode='before')
@classmethod
def validate_key_format(cls, v: Optional[str]) -> Optional[str]:
"""Validate key is URL-safe lowercase with hyphens only."""
if v is None:
return v
v = v.strip().lower()
if not v:
raise ValueError("Key cannot be empty")
if not KEY_PATTERN.match(v):
raise ValueError(
"Key must contain only lowercase letters, numbers, and hyphens. "
"Cannot start or end with a hyphen."
)
return v
class SearchRequest(BaseModel):
"""Request payload for searching series."""
@@ -140,9 +112,14 @@ class SearchResult(BaseModel):
"(e.g., 'Attack on Titan (2013)'). For display/filesystem ops only."
)
)
snippet: Optional[str] = Field(None, description="Short description or snippet")
thumbnail: Optional[HttpUrl] = Field(None, description="Thumbnail image URL")
score: Optional[float] = Field(None, ge=0.0, le=1.0, description="Search relevance score (0-1)")
snippet: Optional[str] = Field(
None,
description="Search result snippet or description"
)
score: Optional[float] = Field(
None,
description="Search relevance score (0.0 to 1.0)"
)
@field_validator('key', mode='before')
@classmethod
@@ -151,3 +128,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,14 +41,25 @@ 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")
folder_scan: Optional[bool] = Field(default=None, alias="folder_scan")
def __init__(self, **data):
super().__init__(**data)
@@ -54,8 +67,6 @@ class SchedulerConfig(BaseModel):
# "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)
if self.folder_scan is not None and "folder_scan_enabled" not in data:
object.__setattr__(self, "folder_scan_enabled", self.folder_scan)
@field_validator("schedule_time")
@classmethod
@@ -69,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]:
@@ -122,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")
@@ -239,12 +284,67 @@ class AppConfig(BaseModel):
class ConfigUpdate(BaseModel):
scheduler: Optional[SchedulerConfig] = None
logging: Optional[LoggingConfig] = None
backup: Optional[BackupConfig] = None
nfo: Optional[NFOConfig] = None
name: Optional[str] = None
data_dir: Optional[str] = None
scheduler: Optional[Dict[str, Any]] = None
logging: Optional[Dict[str, Any]] = None
backup: Optional[Dict[str, Any]] = None
nfo: Optional[Dict[str, Any]] = None
scan_key_overrides: Optional[Dict[str, str]] = None
other: Optional[Dict[str, object]] = None
other: Optional[Dict[str, Any]] = None
@classmethod
def _parse_dict_field(cls, v):
"""Parse a field that may arrive as a malformed string from Robot Framework.
Robot Framework's Create Dictionary converts Python-style nested dicts
like {'enabled': False} into their string representation. Handle that here
before Pydantic's type validation runs. Also handles Pydantic models being
passed directly (from unit tests).
"""
# Pydantic model - convert to dict first
if hasattr(v, 'model_dump'):
return v.model_dump()
if hasattr(v, 'dict'):
return v.dict()
# Already a dict
if isinstance(v, dict):
return v
# String - try parsing
if isinstance(v, str):
for parse_fn in (json.loads, ast.literal_eval):
try:
parsed = parse_fn(v)
if isinstance(parsed, dict):
return parsed
except Exception:
pass
return v
@model_validator(mode="before")
@classmethod
def _pre_validate(cls, data):
"""Handle malformed dict strings from Robot Framework and Pydantic models passed directly.
Robot Framework's Create Dictionary converts Python-style nested dicts
like {'enabled': False} into their string representation.
Unit tests may pass Pydantic model instances directly.
Both cases need conversion before type validation.
"""
if isinstance(data, dict):
data = dict(data) # make mutable
for field in ("name", "data_dir", "scheduler", "logging", "backup", "nfo", "scan_key_overrides", "other"):
if field in data:
v = data[field]
# Pydantic model - convert to dict
if hasattr(v, "model_dump"):
data[field] = v.model_dump()
# String from Robot Framework - try parsing
elif isinstance(v, str):
parsed = cls._parse_dict_field(v)
if isinstance(parsed, dict):
data[field] = parsed
return data
def apply_to(self, current: AppConfig) -> AppConfig:
"""Return a new AppConfig with updates applied to the current config.
@@ -252,18 +352,64 @@ class ConfigUpdate(BaseModel):
Performs a shallow merge for `other`.
"""
data = current.model_dump()
if self.name is not None:
data["name"] = self.name
if self.data_dir is not None:
data["data_dir"] = self.data_dir
if self.scheduler is not None:
data["scheduler"] = self.scheduler.model_dump()
scheduler_data = self.scheduler
if isinstance(scheduler_data, str):
try:
scheduler_data = json.loads(scheduler_data)
except json.JSONDecodeError:
scheduler_data = ast.literal_eval(scheduler_data)
if isinstance(scheduler_data, dict):
try:
scheduler_data = SchedulerConfig(**scheduler_data)
except ValidationError:
raise
data["scheduler"] = scheduler_data.model_dump()
if self.logging is not None:
data["logging"] = self.logging.model_dump()
logging_data = self.logging
if isinstance(logging_data, str):
try:
logging_data = json.loads(logging_data)
except json.JSONDecodeError:
logging_data = ast.literal_eval(logging_data)
if isinstance(logging_data, dict):
logging_data = LoggingConfig(**logging_data)
data["logging"] = logging_data.model_dump()
if self.backup is not None:
data["backup"] = self.backup.model_dump()
backup_data = self.backup
if isinstance(backup_data, str):
try:
backup_data = json.loads(backup_data)
except json.JSONDecodeError:
backup_data = ast.literal_eval(backup_data)
if isinstance(backup_data, dict):
backup_data = BackupConfig(**backup_data)
data["backup"] = backup_data.model_dump()
if self.nfo is not None:
data["nfo"] = self.nfo.model_dump()
nfo_data = self.nfo
if isinstance(nfo_data, str):
try:
nfo_data = json.loads(nfo_data)
except json.JSONDecodeError:
nfo_data = ast.literal_eval(nfo_data)
if isinstance(nfo_data, dict):
nfo_data = NFOConfig(**nfo_data)
data["nfo"] = nfo_data.model_dump()
if self.scan_key_overrides is not None:
data["scan_key_overrides"] = self.scan_key_overrides
if self.other is not None:
merged = dict(current.other or {})
merged.update(self.other)
other_data = self.other
if isinstance(other_data, str):
try:
other_data = json.loads(other_data)
except json.JSONDecodeError:
other_data = ast.literal_eval(other_data)
if isinstance(other_data, dict):
merged.update(other_data)
data["other"] = merged
return AppConfig(**data)

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
@@ -42,6 +44,48 @@ class EpisodeIdentifier(BaseModel):
)
title: Optional[str] = Field(None, description="Episode title if known")
@field_validator("season", mode="before")
@classmethod
def parse_season(cls, v):
"""Parse season from string JSON if needed."""
if isinstance(v, str):
try:
return int(v)
except ValueError:
try:
parsed = json.loads(v)
return parsed.get("season") if isinstance(parsed, dict) else v
except (json.JSONDecodeError, TypeError):
return v
return v
@field_validator("episode", mode="before")
@classmethod
def parse_episode(cls, v):
"""Parse episode from string JSON if needed."""
if isinstance(v, str):
try:
return int(v)
except ValueError:
try:
parsed = json.loads(v)
return parsed.get("episode") if isinstance(parsed, dict) else v
except (json.JSONDecodeError, TypeError):
return v
return v
@field_validator("title", mode="before")
@classmethod
def parse_title(cls, v):
"""Parse title from string JSON if needed."""
if isinstance(v, str):
try:
parsed = json.loads(v)
return parsed.get("title") if isinstance(parsed, dict) else v
except (json.JSONDecodeError, TypeError):
return v
return v
class DownloadProgress(BaseModel):
"""Real-time progress information for an active download."""
@@ -218,7 +262,36 @@ class DownloadRequest(BaseModel):
priority: DownloadPriority = Field(
DownloadPriority.NORMAL, description="Priority level for queue items"
)
@field_validator('episodes', mode='before')
@classmethod
def parse_episodes(cls, v):
"""Parse episodes list, handling potential string-encoded dicts."""
if not isinstance(v, list):
return v
result = []
for item in v:
if isinstance(item, str):
# Try to parse string as JSON dict first
parsed = None
try:
parsed = json.loads(item)
except (json.JSONDecodeError, TypeError):
pass
# If JSON failed, try Python dict string syntax
if parsed is None:
try:
parsed = ast.literal_eval(item)
except (ValueError, SyntaxError, TypeError):
pass
if isinstance(parsed, dict):
result.append(parsed)
else:
result.append(item)
else:
result.append(item)
return result
@field_validator('priority', mode='before')
@classmethod
def normalize_priority(cls, v):

View File

@@ -357,8 +357,8 @@ class NFOMissingResponse(BaseModel):
)
class NfoDiagnosticsResponse(BaseModel):
"""Response for NFO diagnostics showing missing required tags."""
class NfoSettingsResponse(BaseModel):
"""Response for NFO settings inspection showing missing required tags."""
has_nfo: bool = Field(..., description="Whether tvshow.nfo exists")
nfo_path: Optional[str] = Field(None, description="Path to NFO file if exists")
@@ -372,6 +372,20 @@ class NfoDiagnosticsResponse(BaseModel):
)
class NfoSeriesSettings(BaseModel):
"""Settings summary for a single series in the needs-repair list."""
key: str = Field(..., description="Series unique key")
name: str = Field(..., description="Series display name")
folder: str = Field(..., description="Series folder name")
has_nfo: bool = Field(..., description="Whether tvshow.nfo exists")
missing_tags: List[str] = Field(
default_factory=list,
description="List of missing required tag names"
)
tmdb_id: Optional[int] = Field(None, description="TMDB ID if available")
class NfoRepairResponse(BaseModel):
"""Response after NFO repair attempt."""

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

@@ -422,3 +422,32 @@ class TMDBClient:
if expired_keys:
logger.debug("Removed %d expired negative cache entries", len(expired_keys))
return len(expired_keys)
def get_tmdb_client() -> TMDBClient:
"""Factory function to create a TMDBClient with settings configuration.
Returns:
TMDBClient instance configured with settings.tmdb_api_key
Raises:
ValueError: If TMDB API key is not configured
"""
from src.config.settings import settings
if not settings.tmdb_api_key:
raise ValueError("TMDB API key is not configured")
return TMDBClient(api_key=settings.tmdb_api_key)
def get_tmdb_image_base_url(tmdb_id: int) -> str:
"""Get the base URL for TMDB images.
Args:
tmdb_id: TMDB show ID (used for account-specific URLs)
Returns:
Base URL string for TMDB images
"""
return "https://image.tmdb.org/t/p/"

View File

View File

@@ -158,6 +158,7 @@ class AniworldLoader(Loader):
self._KeyHTMLDict = {}
self._EpisodeHTMLDict = {}
self._YearDict = {}
self.Providers = Providers()
# Events: download_progress is triggered with progress dict
@@ -542,6 +543,7 @@ class AniworldLoader(Loader):
self.events.download_progress(d)
ydl_opts = {
'downloader': 'ffmpeg', # Use ffmpeg for proper progress reporting
'fragment_retries': float('inf'),
'outtmpl': temp_path,
'quiet': True,
@@ -774,55 +776,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(
_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

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

@@ -110,6 +110,7 @@ class EnhancedAniWorldLoader(Loader):
# Cache dictionaries
self._KeyHTMLDict = {}
self._EpisodeHTMLDict = {}
self._YearDict = {}
# Provider manager
self.Providers = Providers()
@@ -666,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)
@@ -674,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(
@@ -604,16 +614,15 @@ class AnimeService:
result_list.append(series_dict)
logger.info(
"Listed series with filters",
total_count=len(result_list),
filter_type=filter_type
"Listed series with filters total=%d filter_type=%s",
len(result_list), filter_type
)
return result_list
except AnimeServiceError:
raise
except Exception as exc:
logger.exception("list_series_with_filters failed")
logger.error("list_series_with_filters failed: %s", str(exc))
raise AnimeServiceError(
"Failed to list series with metadata"
) from exc
@@ -635,7 +644,7 @@ class AnimeService:
result = await self._app.search(query)
return result
except Exception as exc:
logger.exception("search failed")
logger.error("search failed: %s", str(exc))
raise AnimeServiceError("Search failed") from exc
async def rescan(self) -> None:
@@ -655,30 +664,36 @@ class AnimeService:
progress, this method returns immediately without starting
a new scan.
"""
import logging
_rescan_logger = logging.getLogger(__name__)
# Check if a scan is already running (non-blocking)
if self._scan_lock.locked():
logger.info("Rescan already in progress, ignoring request")
_rescan_logger.info("Rescan already in progress, ignoring request")
return
async with self._scan_lock:
try:
# Store event loop for event handlers
self._event_loop = asyncio.get_running_loop()
logger.info(
"Rescan started, event loop stored",
loop_id=id(self._event_loop),
series_app_id=id(self._app),
scan_handler=str(self._app.scan_status),
_rescan_logger.info(
"Rescan started, event loop stored. loop_id=%d series_app_id=%d",
id(self._event_loop),
id(self._app),
)
# SeriesApp.rescan returns scanned series list
_rescan_logger.info("Calling _app.rescan()")
scanned_series = await self._app.rescan()
_rescan_logger.info("Rescan completed, found %d series", len(scanned_series) if scanned_series else 0)
# Persist scan results to database
if scanned_series:
_rescan_logger.info("Saving %d series to database", len(scanned_series))
await self._save_scan_results_to_db(scanned_series)
# Reload series from database to ensure consistency
_rescan_logger.info("Loading series from database")
await self._load_series_from_db()
# invalidate cache
@@ -687,8 +702,11 @@ class AnimeService:
except Exception: # pylint: disable=broad-except
pass
except AnimeServiceError:
# Re-raise AnimeServiceError without wrapping
raise
except Exception as exc: # pylint: disable=broad-except
logger.exception("rescan failed")
_rescan_logger.error("Rescan failed: %s", str(exc))
raise AnimeServiceError("Rescan failed") from exc
async def sync_single_series_after_scan(self, series_key: str) -> None:
@@ -942,47 +960,16 @@ class AnimeService:
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
# Only include episodes that are NOT downloaded (is_downloaded=False)
# so the missing-episode list stays accurate
episode_dict: dict[int, list[int]] = {}
if anime_series.episodes:
for episode in anime_series.episodes:
# Skip downloaded episodes — they are not missing
if episode.is_downloaded:
continue
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,
year=anime_series.year
)
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:
"""
@@ -1178,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:
@@ -1197,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.
@@ -1303,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(
@@ -1404,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
@@ -1483,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
@@ -1528,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
@@ -1545,150 +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_legacy_series_to_db(
anime_directory: str,
log_instance=None # pylint: disable=unused-argument
) -> int:
"""
One-time legacy sync: import any series from 'data' files
not already in the database.
Deprecated: Series are now loaded directly from the database.
This function remains for backwards compatibility with legacy
file-based data during migration.
Args:
anime_directory: Path to the anime directory with data files
log_instance: Optional logger instance (unused, kept for API
compatibility). This function always uses structlog internally.
Returns:
Number of new series added to the database
"""
# Always use structlog for structured logging with keyword arguments
log = structlog.get_logger(__name__)
log.warning(
"sync_legacy_series_to_db is deprecated. "
"Series are now loaded directly from database."
)
try:
from src.server.database.connection import get_db_session
from src.server.database.service import AnimeSeriesService, EpisodeService
log.info(
"Starting data file to database sync",
directory=anime_directory
)
# Get all series from data files using SeriesApp
series_app = SeriesApp(anime_directory)
all_series = await asyncio.to_thread(
series_app.get_all_series_from_data_files
)
if not all_series:
log.info("No series found in data files to sync")
return 0
log.info(
"Found series in data files, syncing to database",
count=len(all_series)
)
async with get_db_session() as db:
added_count = 0
skipped_count = 0
for serie in all_series:
# Handle series with empty name - use folder as fallback
if not serie.name or not serie.name.strip():
if serie.folder and serie.folder.strip():
serie.name = serie.folder.strip()
log.debug(
"Using folder as name fallback",
key=serie.key,
folder=serie.folder
)
else:
log.warning(
"Skipping series with empty name and folder",
key=serie.key
)
skipped_count += 1
continue
try:
# Check if series already exists in DB
existing = await AnimeSeriesService.get_by_key(db, serie.key)
if existing:
log.debug(
"Series already exists in database",
name=serie.name,
key=serie.key
)
continue
# Create new series in database
anime_series = await AnimeSeriesService.create(
db=db,
key=serie.key,
name=serie.name,
site=serie.site,
folder=serie.folder,
year=serie.year if hasattr(serie, 'year') else None,
)
# Create Episode records for each episode in episodeDict
if serie.episodeDict:
for season, episode_numbers in serie.episodeDict.items():
for episode_number in episode_numbers:
await EpisodeService.create(
db=db,
series_id=anime_series.id,
season=season,
episode_number=episode_number,
)
added_count += 1
log.debug(
"Added series to database",
name=serie.name,
key=serie.key
)
except Exception as e: # pylint: disable=broad-except
log.warning(
"Failed to add series to database",
key=serie.key,
name=serie.name,
error=str(e)
)
skipped_count += 1
log.info(
"Data file sync complete",
added=added_count,
skipped=len(all_series) - added_count
)
return added_count
except Exception as e: # pylint: disable=broad-except
log.warning(
"Failed to sync series to database",
error=str(e),
exc_info=True
)
return 0

View File

@@ -12,6 +12,7 @@ can call it from async routes via threadpool if needed.
from __future__ import annotations
import hashlib
import os
from datetime import datetime, timedelta, timezone
from typing import Dict, Optional
@@ -88,6 +89,8 @@ class AuthService:
self.lockout_seconds = 300 # 5 minutes
self.token_expiry_hours = settings.token_expiry_hours or 24
self.secret = settings.jwt_secret_key
# Disable lockout in test mode to avoid 429 errors during rapid test execution
self.disable_lockout = os.getenv("ANIWORLD_TESTING") == "1"
# --- password helpers ---
def _hash_password(self, password: str) -> str:
@@ -109,7 +112,22 @@ class AuthService:
return False
def is_configured(self) -> bool:
return bool(self._hash)
# Always re-read from config to detect if reset happened
hash_val = None
try:
from src.server.services.config_service import get_config_service
config_service = get_config_service()
config = config_service.load_config()
hash_val = config.other.get('master_password_hash')
except Exception:
pass
if isinstance(hash_val, str):
self._hash = hash_val
return True
# No hash in config - clear any stale in-memory hash
self._hash = None
return False
def setup_master_password(self, password: str) -> str:
"""Set the master password (hash and store in memory/settings).
@@ -173,6 +191,8 @@ class AuthService:
)
def _record_failure(self, identifier: str) -> None:
if self.disable_lockout:
return
rec = self._get_fail_record(identifier)
rec["count"] += 1
rec["last"] = datetime.now(timezone.utc)
@@ -186,19 +206,14 @@ class AuthService:
self._failed.pop(identifier, None)
def _check_locked(self, identifier: str) -> None:
if self.disable_lockout:
return
rec = self._get_fail_record(identifier)
lu = rec.get("locked_until")
if lu and datetime.now(timezone.utc) < lu:
raise LockedOutError(
"Too many failed attempts - temporarily locked out"
)
if lu and datetime.now(timezone.utc) >= lu:
# lock expired, reset
self._failed[identifier] = {
"count": 0,
"last": None,
"locked_until": None,
}
# --- authentication ---
def validate_master_password(
@@ -266,6 +281,15 @@ class AuthService:
# to a revocation list.
return None
def reset(self) -> None:
"""Reset authentication state to unconfigured.
Clears the in-memory hash. Does NOT persist - caller should also
clear the config file if persistent reset is needed.
"""
self._hash = None
self._failed.clear()
# Singleton service instance for import convenience
auth_service = AuthService()

View File

@@ -14,18 +14,16 @@ Key Features:
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from pathlib import Path
from typing import Any, Dict, List, Optional
import structlog
from src.core.services.nfo_factory import get_nfo_factory
from src.server.services.websocket_service import WebSocketService
logger = structlog.get_logger(__name__)
logger = logging.getLogger(__name__)
class LoadingStatus(str, Enum):
@@ -122,8 +120,8 @@ class BackgroundLoaderService:
self._shutdown = False
logger.info(
"BackgroundLoaderService initialized",
extra={"max_concurrent_loads": max_concurrent_loads}
"BackgroundLoaderService initialized max_concurrent_loads=%s",
max_concurrent_loads
)
async def start(self) -> None:
@@ -141,8 +139,8 @@ class BackgroundLoaderService:
self.worker_tasks.append(worker)
logger.info(
"Background workers started",
extra={"num_workers": len(self.worker_tasks)}
"Background workers started num_workers=%s",
len(self.worker_tasks)
)
async def stop(self) -> None:
@@ -165,8 +163,8 @@ class BackgroundLoaderService:
for i, result in enumerate(results):
if isinstance(result, Exception) and not isinstance(result, asyncio.CancelledError):
logger.error(
f"Worker {i} stopped with exception",
extra={"exception": str(result)}
"Worker %s stopped with exception exception=%s",
i, str(result)
)
self.worker_tasks = []
@@ -203,10 +201,15 @@ class BackgroundLoaderService:
self.active_tasks[key] = task
await self.task_queue.put(task)
logger.info("Added loading task for series: %s", key)
import logging
_task_logger = logging.getLogger(__name__)
_task_logger.info("Added loading task for series: %s", key)
# Broadcast initial status
await self._broadcast_status(task)
try:
await self._broadcast_status(task)
except Exception as e:
_task_logger.warning("Failed to broadcast initial status: %s", e)
async def check_missing_data(
self,
@@ -289,7 +292,8 @@ class BackgroundLoaderService:
)
logger.info(
f"Worker {worker_id} processing loading task for series: {task.key}"
"Worker %s processing loading task for series: %s",
worker_id, task.key
)
# Process the task
@@ -305,7 +309,10 @@ class BackgroundLoaderService:
logger.info("Worker %s task cancelled", worker_id)
break
except Exception as e:
logger.exception("Error in background worker %s: %s", worker_id, e)
logger.error(
"Error in background worker %s: %s",
worker_id, str(e)
)
# Continue processing other tasks
continue
@@ -497,109 +504,53 @@ class BackgroundLoaderService:
raise
async def _load_nfo_and_images(self, task: SeriesLoadingTask, db: Any) -> bool:
"""Load NFO file and images for a series by reusing NFOService.
"""Load NFO file and images for a series.
Downloads poster.jpg, fanart.jpg, and logo.png from TMDB
using the ImageLoadingService.
Args:
task: The loading task
db: Database session
Returns:
bool: True if NFO was created, False if it already existed or failed
bool: True if any images were loaded, False otherwise
"""
task.status = LoadingStatus.LOADING_NFO
await self._broadcast_status(task, "Checking NFO file...")
await self._broadcast_status(task, "Loading images...")
try:
# Check if NFOService is available
if not self.series_app.nfo_service:
logger.warning(
f"NFOService not available, skipping NFO/images for {task.key}"
)
task.progress["nfo"] = False
task.progress["logo"] = False
task.progress["images"] = False
return False
# Check if NFO already exists
if self.series_app.nfo_service.has_nfo(task.folder):
logger.info("NFO already exists for %s, skipping creation", task.key)
# Update task progress
task.progress["nfo"] = True
task.progress["logo"] = True # Assume logo exists if NFO exists
task.progress["images"] = True # Assume images exist if NFO exists
# Update database with existing NFO info
from src.server.database.service import AnimeSeriesService
series_db = await AnimeSeriesService.get_by_key(db, task.key)
if series_db:
# Only update if not already marked
if not series_db.has_nfo:
series_db.has_nfo = True
series_db.nfo_created_at = datetime.now(timezone.utc)
logger.info("Updated database with existing NFO for %s", task.key)
if not series_db.logo_loaded:
series_db.logo_loaded = True
if not series_db.images_loaded:
series_db.images_loaded = True
await db.commit()
logger.info("Existing NFO found and database updated for series: %s", task.key)
return False
# NFO doesn't exist, create it
await self._broadcast_status(task, "Generating NFO file...")
logger.info("Creating new NFO for %s", task.key)
# Create a fresh NFOService for this task to avoid shared TMDB session closure
try:
factory = get_nfo_factory()
nfo_service = factory.create()
except ValueError:
logger.warning(
"NFOService unavailable for %s, skipping NFO/images",
task.key
)
task.progress["nfo"] = False
task.progress["logo"] = False
task.progress["images"] = False
return False
from src.server.nfo.tmdb_client import get_tmdb_client
from src.server.services.image_loading_service import (
init_image_loading_service,
)
try:
nfo_path = await nfo_service.create_tvshow_nfo(
serie_name=task.name,
serie_folder=task.folder,
year=task.year,
download_poster=True,
download_logo=True,
download_fanart=True
)
finally:
await nfo_service.close()
tmdb_client = get_tmdb_client()
image_service = init_image_loading_service(tmdb_client)
# Update task progress
task.progress["nfo"] = True
task.progress["logo"] = True
task.progress["images"] = True
# Update database
from src.server.database.service import AnimeSeriesService
series_db = await AnimeSeriesService.get_by_key(db, task.key)
if series_db:
series_db.has_nfo = True
series_db.nfo_created_at = datetime.now(timezone.utc)
series_db.logo_loaded = True
series_db.images_loaded = True
series_db.loading_status = "loading_nfo"
await db.commit()
logger.info("NFO and images created and loaded for series: %s", task.key)
result = await image_service.load_series_images(
key=task.key,
folder=task.folder,
anime_directory=self.series_app.directory_to_search,
db=db,
)
task.progress["nfo"] = True # NFO was already created earlier in the flow
task.progress["logo"] = result.get("logo", False)
task.progress["images"] = result.get("poster", False) and result.get("fanart", False)
logger.info(
"Images loaded for series %s: poster=%s fanart=%s logo=%s",
task.key,
result.get("poster", False),
result.get("fanart", False),
result.get("logo", False),
)
return True
except Exception as e:
logger.exception("Failed to load NFO/images for %s: %s", task.key, e)
# Don't fail the entire task if NFO fails
task.progress["nfo"] = False
logger.warning("Failed to load images for series %s: %s", task.key, e)
task.progress["nfo"] = True
task.progress["logo"] = False
task.progress["images"] = False
return False

View File

@@ -532,6 +532,15 @@ class DownloadService:
"Queue progress already initialized by concurrent task"
)
self._queue_progress_initialized = True
# Handle broken pipe / connection errors from WebSocket broadcast
# These are non-fatal and should not fail the queue operation
elif isinstance(e, OSError) and e.errno == 32:
logger.warning(
"Queue progress broadcast failed (broken pipe) - "
"continuing without progress tracking: %s",
e,
)
self._queue_progress_initialized = True
else:
logger.error("Failed to initialize queue progress: %s", e)
@@ -674,17 +683,10 @@ class DownloadService:
episode=episode.episode,
)
# Notify via progress service
queue_status = await self.get_queue_status()
await self._progress_service.update_progress(
progress_id="download_queue",
message=f"Added {len(created_ids)} items to queue",
metadata={
"action": "items_added",
"added_ids": created_ids,
"queue_status": queue_status.model_dump(mode="json"),
},
force_broadcast=True,
logger.info(
"Added items to queue",
count=len(created_ids),
serie_key=serie_id,
)
return created_ids
@@ -731,9 +733,7 @@ class DownloadService:
# Delete from database
await self._delete_from_database(item_id)
removed_ids.append(item_id)
logger.info(
"Removed from pending queue", item_id=item_id
)
logger.info("Removed from pending queue item_id=%s", item_id)
if removed_ids:
# Notify via progress service
@@ -803,7 +803,7 @@ class DownloadService:
force_broadcast=True,
)
logger.info("Queue reordered", reordered_count=len(item_ids))
logger.info("Queue reordered reordered_count=%s", len(item_ids))
except Exception as e:
logger.error("Failed to reorder queue: %s", e)
@@ -828,8 +828,8 @@ class DownloadService:
# Initialize queue progress tracking if not already done
await self._init_queue_progress()
# Check if download already active
if self._active_download:
# Check if queue is already running
if not self._is_stopped:
raise DownloadServiceError(
"Queue processing is already active"
)
@@ -1036,7 +1036,7 @@ class DownloadService:
"""
count = len(self._completed_items)
self._completed_items.clear()
logger.info("Cleared completed items", count=count)
logger.info("Cleared completed items count=%s", count)
# Notify via progress service
if count > 0:
@@ -1062,7 +1062,7 @@ class DownloadService:
"""
count = len(self._failed_items)
self._failed_items.clear()
logger.info("Cleared failed items", count=count)
logger.info("Cleared failed items count=%s", count)
# Notify via progress service
if count > 0:
@@ -1088,27 +1088,17 @@ class DownloadService:
"""
count = len(self._pending_queue)
# Delete all pending items from database
for item_id in list(self._pending_items_by_id.keys()):
await self._delete_from_database(item_id)
# Only try to delete from DB if there are items
if count > 0:
for item_id in list(self._pending_items_by_id.keys()):
try:
await self._delete_from_database(item_id)
except Exception as e:
logger.warning("Failed to delete item %s: %s", item_id, e)
self._pending_queue.clear()
self._pending_items_by_id.clear()
logger.info("Cleared pending items", count=count)
# Notify via progress service
if count > 0:
queue_status = await self.get_queue_status()
await self._progress_service.update_progress(
progress_id="download_queue",
message=f"Cleared {count} pending items",
metadata={
"action": "pending_cleared",
"cleared_count": count,
"queue_status": queue_status.model_dump(mode="json"),
},
force_broadcast=True,
)
self._pending_by_episode.clear()
return count

View File

@@ -0,0 +1,205 @@
"""Folder naming service for fixing missing years in anime folder names."""
from __future__ import annotations
import asyncio
import os
import re
import shutil
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
import structlog
from src.config.settings import settings
from src.server.database.connection import get_db_session as _get_db_session
from src.server.database.service import AnimeSeriesService
logger = structlog.get_logger(__name__)
@dataclass
class FolderRenameResult:
key: str
old_folder: str
new_folder: Optional[str]
success: bool
skipped: bool = False
reason: Optional[str] = None
@dataclass
class FolderNamingReport:
total: int
renamed: int
skipped: int
errors: int
results: List[FolderRenameResult]
def to_dict(self) -> Dict[str, Any]:
return {
"total": self.total,
"renamed": self.renamed,
"skipped": self.skipped,
"errors": self.errors,
"results": [
{
"key": r.key,
"old_folder": r.old_folder,
"new_folder": r.new_folder,
"success": r.success,
"skipped": r.skipped,
"reason": r.reason,
}
for r in self.results
],
}
class FolderNamingService:
def __init__(self) -> None:
self._is_running = False
self._lock = asyncio.Lock()
async def run(self) -> FolderNamingReport:
async with self._lock:
if self._is_running:
logger.warning("FolderNamingService.run() called while already running")
return FolderNamingReport(total=0, renamed=0, skipped=0, errors=0, results=[])
self._is_running = True
try:
logger.info("FolderNamingService: starting folder naming scan")
results: List[FolderRenameResult] = []
async with _get_db_session() as db:
all_series = await AnimeSeriesService.get_all(db)
for series in all_series:
result = await self._process_series(series)
results.append(result)
renamed = sum(1 for r in results if r.success and not r.skipped)
skipped = sum(1 for r in results if r.skipped)
errors = sum(1 for r in results if not r.skipped and not r.success)
report = FolderNamingReport(
total=len(results),
renamed=renamed,
skipped=skipped,
errors=errors,
results=results,
)
logger.info(
"FolderNamingService: scan complete — total=%d renamed=%d skipped=%d errors=%d",
report.total, report.renamed, report.skipped, report.errors,
)
return report
finally:
self._is_running = False
async def _process_series(self, series) -> FolderRenameResult:
key = series.key
folder = series.folder or ""
year = getattr(series, "year", None)
if year is None:
return FolderRenameResult(key=key, old_folder=folder, new_folder=None, success=True, skipped=True, reason="no year in DB record")
if self._folder_has_year(folder):
return FolderRenameResult(key=key, old_folder=folder, new_folder=None, success=True, skipped=True, reason="folder already has year")
target_folder = self._build_target_folder(folder, year)
# Safety: re-extract year from target to prevent double-year
if target_folder != folder:
extracted = self._extract_year_from_folder_name(target_folder)
if extracted != year:
logger.error("Safety guard for %s: target '%s' year=%s != DB year=%s — skipping", key, target_folder, extracted, year)
return FolderRenameResult(key=key, old_folder=folder, new_folder=None, success=False, skipped=True, reason="safety guard: target year mismatch")
return await self._execute_rename(series, folder, target_folder)
async def _execute_rename(self, series, old_folder: str, target_folder: str) -> FolderRenameResult:
key = series.key
if old_folder == target_folder:
return FolderRenameResult(key=key, old_folder=old_folder, new_folder=None, success=True, skipped=True, reason="same folder name")
anime_dir = settings.anime_directory
old_path = os.path.join(anime_dir, old_folder)
target_path = os.path.join(anime_dir, target_folder)
if not os.path.isdir(old_path):
return FolderRenameResult(key=key, old_folder=old_folder, new_folder=None, success=False, skipped=False, reason="source folder does not exist on disk")
if os.path.isdir(target_path):
return FolderRenameResult(key=key, old_folder=old_folder, new_folder=None, success=False, skipped=False, reason="target folder already exists on disk")
try:
shutil.move(old_path, target_path)
logger.info("Renamed folder %s -> %s for series %s", old_folder, target_folder, key)
# Update in-memory cache
try:
from src.server.SeriesApp import get_series_app
series_app = get_series_app()
if key in series_app.list.keyDict:
series_app.list.keyDict[key].folder = target_folder
except Exception as exc:
logger.warning("Failed to update in-memory cache for %s: %s", key, exc)
# Update database
async with _get_db_session() as db:
db_series = await AnimeSeriesService.get_by_key(db, key)
if db_series:
await AnimeSeriesService.update(db, series_id=db_series.id, folder=target_folder)
logger.debug("Updated DB folder for %s to %s", key, target_folder)
return FolderRenameResult(key=key, old_folder=old_folder, new_folder=target_folder, success=True, skipped=False)
except Exception as exc:
logger.error("Failed to rename folder for %s (%s -> %s): %s", key, old_folder, target_folder, exc)
return FolderRenameResult(key=key, old_folder=old_folder, new_folder=None, success=False, skipped=False, reason=str(exc))
# Static helpers — public for direct testing
@staticmethod
def _folder_has_year(folder_name: str) -> bool:
if not folder_name:
return False
return bool(re.search(r"\(\d{4}\)", folder_name))
@staticmethod
def _extract_year_from_folder_name(folder_name: str) -> Optional[int]:
if not folder_name:
return None
match = re.search(r"\((\d{4})\)", folder_name)
if match:
try:
year = int(match.group(1))
if 1900 <= year <= 2100:
return year
except ValueError:
pass
return None
@staticmethod
def _extract_title_from_folder_name(folder_name: str) -> str:
return re.sub(r"\s*\(\d{4}\)\s*$", "", folder_name).strip()
@staticmethod
def _build_target_folder(folder_name: str, year: int) -> str:
title = FolderNamingService._extract_title_from_folder_name(folder_name)
return f"{title} ({year})"
_folder_naming_service: Optional[FolderNamingService] = None
def get_folder_naming_service() -> FolderNamingService:
global _folder_naming_service
if _folder_naming_service is None:
_folder_naming_service = FolderNamingService()
return _folder_naming_service
def reset_folder_naming_service() -> None:
global _folder_naming_service
_folder_naming_service = None

View File

@@ -1,710 +0,0 @@
"""Folder rename service for validating and renaming series folders.
After NFO repair, this service iterates over every subfolder in
``settings.anime_directory`` that contains a ``tvshow.nfo``. For each
folder it parses the NFO to extract ``<title>`` and ``<year>``, computes
the expected folder name ``f"{title} ({year})"``, sanitises it for
filesystem safety, and renames the folder if the current name differs.
Database records (``AnimeSeries.folder``, ``Episode.file_path``,
``DownloadQueueItem.file_destination``) are updated atomically to
reflect the new paths.
"""
from __future__ import annotations
import logging
from collections import defaultdict
from pathlib import Path
from typing import Dict, List, Optional, Set, Tuple
from lxml import etree
from src.config.settings import settings
from src.server.database.connection import get_db_session
from src.server.database.service import (
AnimeSeriesService,
DownloadQueueService,
EpisodeService,
)
from src.server.utils.dependencies import get_download_service
from src.server.utils.filesystem import sanitize_folder_name
logger = logging.getLogger(__name__)
# Characters that are invalid in filesystem paths across platforms
INVALID_PATH_CHARS = '<>:"/\\|?*\x00'
class DuplicateGroup:
"""Represents a group of duplicate folders for the same series.
Attributes:
key: The series key (folder name before rename).
folders: List of folder paths that map to this series.
nfo_paths: List of corresponding NFO file paths.
"""
def __init__(self, key: str, folders: List[str], nfo_paths: List[Path]):
self.key = key
self.folders = folders
self.nfo_paths = nfo_paths
@property
def count(self) -> int:
return len(self.folders)
def __repr__(self) -> str:
return f"DuplicateGroup(key={self.key!r}, folders={self.folders})"
def _scan_for_pre_existing_duplicates(anime_dir: Path) -> List[DuplicateGroup]:
"""Scan anime directory for pre-existing duplicate folders.
Groups folders by the series key extracted from their NFO files.
Folders with the same title+year (same expected name) are flagged as duplicates.
Args:
anime_dir: Path to the anime directory to scan.
Returns:
List of DuplicateGroup objects, one per series with duplicate folders.
"""
# Group folders by their expected name (title+year from NFO)
groups: Dict[str, List[Tuple[str, Path]]] = defaultdict(list)
for series_dir in anime_dir.iterdir():
if not series_dir.is_dir():
continue
nfo_path = series_dir / "tvshow.nfo"
if not nfo_path.exists():
continue
title, year = _parse_nfo_title_and_year(nfo_path)
if not title or not year:
continue
expected_name = _compute_expected_folder_name(title, year)
groups[expected_name].append((series_dir.name, nfo_path))
# Filter to only groups with more than one folder
duplicates = []
for key, items in groups.items():
if len(items) > 1:
folders = [item[0] for item in items]
nfo_paths = [item[1] for item in items]
duplicates.append(DuplicateGroup(key=key, folders=folders, nfo_paths=nfo_paths))
return duplicates
def _try_merge_duplicate_group(group: DuplicateGroup, dry_run: bool = False) -> bool:
"""Attempt to merge a duplicate group automatically.
Uses the first folder as the canonical one and removes others if they are
empty or contain only symlinks.
Args:
group: The DuplicateGroup to merge.
dry_run: If True, only log actions without executing them.
Returns:
True if merge was successful, False otherwise.
"""
if len(group.folders) < 2:
return True
# Keep first folder as canonical, mark others for removal
canonical = group.folders[0]
to_remove = group.folders[1:]
for folder in to_remove:
folder_path = group.nfo_paths[0].parent.parent / folder # same parent dir
if not folder_path.exists():
continue
# Check if folder is empty or only has symlinks
try:
contents = list(folder_path.iterdir())
except PermissionError:
logger.warning("Permission denied accessing %s, skip merge", folder_path)
return False
except OSError:
return False
if not contents:
# Empty folder - safe to remove
if dry_run:
logger.info("[DRY-RUN] Would delete empty duplicate folder: %s", folder_path)
else:
try:
folder_path.rmdir()
logger.info("Deleted empty duplicate folder: %s", folder_path)
except OSError:
return False
continue
# Check if all contents are symlinks pointing to canonical
all_symlinks = all(
item.is_symlink() and item.resolve() == (folder_path.parent / canonical).resolve()
for item in contents
)
if all_symlinks:
if dry_run:
logger.info("[DRY-RUN] Would remove symlinks in duplicate folder: %s", folder_path)
else:
for item in contents:
item.unlink()
try:
folder_path.rmdir()
logger.info("Removed symlink-only duplicate folder: %s", folder_path)
except OSError:
return False
continue
# Cannot auto-merge - requires manual intervention
logger.warning(
"Cannot auto-merge duplicate folders for '%s': %s (manual merge required)",
group.key,
[canonical] + to_remove,
)
return False
return True
def _parse_nfo_title_and_year(nfo_path: Path) -> Tuple[Optional[str], Optional[str]]:
"""Parse a tvshow.nfo and return (title, year) text values.
Args:
nfo_path: Absolute path to the ``tvshow.nfo`` file.
Returns:
Tuple of (title, year) where either may be ``None`` if missing
or empty.
"""
try:
tree = etree.parse(str(nfo_path))
root = tree.getroot()
title_elem = root.find("./title")
year_elem = root.find("./year")
title = title_elem.text.strip() if title_elem is not None and title_elem.text and title_elem.text.strip() else None
year = year_elem.text.strip() if year_elem is not None and year_elem.text and year_elem.text.strip() else None
return title, year
except etree.XMLSyntaxError as exc:
logger.warning("Malformed XML in %s: %s", nfo_path, exc)
return None, None
except Exception as exc: # pylint: disable=broad-except
logger.warning("Unexpected error parsing %s: %s", nfo_path, exc)
return None, None
def _compute_expected_folder_name(title: str, year: str) -> str:
"""Compute the expected folder name from title and year.
Removes any existing year suffixes (e.g., "(2021)") before adding the
canonical one to prevent duplication across multiple folder rename runs.
Args:
title: Series title from NFO.
year: Release year from NFO.
Returns:
Sanitised folder name in the format ``"{title} ({year})"``.
"""
import re
# Remove all trailing year suffixes to prevent duplication.
# This handles cases where the title already contains one or more years.
# Regex pattern: matches one or more " (YYYY)" at the end of the string
clean_title = re.sub(r'(\s*\(\d{4}\))+\s*$', '', title).strip()
year_suffix = f" ({year})"
raw_name = f"{clean_title}{year_suffix}"
return sanitize_folder_name(raw_name)
def _is_series_being_downloaded(series_folder: str) -> bool:
"""Check whether the given series has an active or pending download.
Args:
series_folder: The series folder name (as stored in the DB).
Returns:
``True`` if the series appears in the active download or the
pending queue.
"""
try:
download_service = get_download_service()
active = download_service._active_download # pylint: disable=protected-access
if active and active.serie_folder == series_folder:
return True
for item in download_service._pending_queue: # pylint: disable=protected-access
if item.serie_folder == series_folder:
return True
return False
except Exception as exc: # pylint: disable=broad-except
logger.warning(
"Could not check download status for %s: %s", series_folder, exc
)
# Safer to skip renaming if we can't verify download status.
return True
def _cleanup_stale_files_after_rename(new_path: Path, new_name: str) -> None:
"""Remove legacy 'key' file after successful folder rename.
Also checks for orphaned folders with the same key that may have been
left behind from previous rename operations.
Args:
new_path: The new folder path after rename.
new_name: The new folder name.
"""
key_file = new_path / "key"
if key_file.exists():
try:
key_file.unlink()
logger.info(
"Removed legacy 'key' file after rename: %s", key_file
)
except OSError as exc:
logger.warning(
"Could not remove legacy 'key' file %s: %s", key_file, exc
)
def _cleanup_orphaned_folder(old_path: Path, new_path: Path, dry_run: bool = False) -> bool:
"""Clean up orphaned folder after successful rename.
After a folder is successfully renamed to new_path, this function checks
if the old_path still exists (orphaned folder) and removes it. If the
old folder contains files, they are moved to new_path before deletion.
Args:
old_path: The original folder path before rename.
new_path: The new folder path after rename.
dry_run: If True, only log actions without executing them.
Returns:
True if old folder was cleaned up (or would be in dry-run mode),
False if old folder does not exist or cleanup failed.
"""
if not old_path.exists():
logger.debug(
"Old folder does not exist, no cleanup needed: %s", old_path
)
return False
# Check if folder is empty
try:
contents = list(old_path.iterdir())
except PermissionError as exc:
logger.warning(
"Permission denied accessing old folder %s: %s", old_path, exc
)
return False
except OSError as exc:
logger.warning(
"OS error accessing old folder %s: %s", old_path, exc
)
return False
if not contents:
# Empty folder — delete it
if dry_run:
logger.info(
"[DRY-RUN] Would delete empty orphaned folder: %s", old_path
)
return True
try:
old_path.rmdir()
logger.info("Deleted empty orphaned folder: %s", old_path)
return True
except PermissionError as exc:
logger.warning(
"Permission denied deleting folder %s: %s", old_path, exc
)
return False
except OSError as exc:
logger.warning(
"OS error deleting folder %s: %s", old_path, exc
)
return False
# Folder has contents — move files to new_path then delete
if dry_run:
logger.info(
"[DRY-RUN] Would move %d files from orphaned folder %s to %s",
len(contents), old_path, new_path
)
for item in contents:
logger.info("[DRY-RUN] Would move: %s%s", item, new_path / item.name)
logger.info("[DRY-RUN] Would then delete orphaned folder: %s", old_path)
return True
files_moved = 0
errors = 0
for item in contents:
try:
dest = new_path / item.name
item.rename(dest)
logger.debug("Moved %s%s", item, dest)
files_moved += 1
except PermissionError as exc:
logger.warning(
"Permission denied moving %s: %s", item, exc
)
errors += 1
except OSError as exc:
logger.warning(
"OS error moving %s: %s", item, exc
)
errors += 1
if files_moved > 0:
logger.info(
"Moved %d files from orphaned folder to %s",
files_moved, new_path
)
# Delete the now-empty old folder
try:
old_path.rmdir()
logger.info("Deleted orphaned folder after moving contents: %s", old_path)
return errors == 0
except OSError as exc:
logger.warning(
"Could not delete orphaned folder %s (may not be empty): %s",
old_path, exc
)
return False
async def _update_database_paths(
old_folder: str,
new_folder: str,
anime_dir: Path,
) -> None:
"""Update all database records that reference the old folder path.
Updates:
- ``AnimeSeries.folder`` → ``new_folder``
- ``Episode.file_path`` → adjusted to new folder
- ``DownloadQueueItem.file_destination`` → adjusted to new folder
Args:
old_folder: Previous folder name.
new_folder: New folder name.
anime_dir: Root anime directory path.
"""
old_series_path = anime_dir / old_folder
new_series_path = anime_dir / new_folder
async with get_db_session() as db:
# 1. Update AnimeSeries.folder
series = await AnimeSeriesService.get_by_folder(db, old_folder)
if series is None:
# Fallback: try to find by folder name
all_series = await AnimeSeriesService.get_all(db)
for s in all_series:
if s.folder == old_folder:
series = s
break
if series is None:
logger.warning(
"No database record found for folder '%s', skipping DB update",
old_folder,
)
return
await AnimeSeriesService.update(db, series.id, folder=new_folder)
logger.info(
"Updated AnimeSeries.folder: %s%s (id=%s)",
old_folder,
new_folder,
series.id,
)
# 2. Update Episode.file_path for all episodes of this series
episodes = await EpisodeService.get_by_series(db, series.id)
for episode in episodes:
if episode.file_path:
old_file_path = Path(episode.file_path)
# Only update if the path is under the old series folder
try:
old_file_path.relative_to(old_series_path)
new_file_path = new_series_path / old_file_path.relative_to(
old_series_path
)
episode.file_path = str(new_file_path)
logger.debug(
"Updated Episode.file_path: %s%s",
old_file_path,
new_file_path,
)
except ValueError:
# Path is not under old_series_path, skip
pass
await db.flush()
# 3. Update DownloadQueueItem.file_destination for pending items
queue_items = await DownloadQueueService.get_all(db, with_series=True)
for item in queue_items:
if item.series_id == series.id and item.file_destination:
old_dest = Path(item.file_destination)
try:
old_dest.relative_to(old_series_path)
new_dest = new_series_path / old_dest.relative_to(
old_series_path
)
item.file_destination = str(new_dest)
logger.debug(
"Updated DownloadQueueItem.file_destination: %s%s",
old_dest,
new_dest,
)
except ValueError:
pass
await db.flush()
logger.info(
"Database paths updated for series '%s''%s'",
old_folder,
new_folder,
)
async def validate_and_rename_series_folders(dry_run: bool = False) -> Dict[str, int]:
"""Validate and rename series folders to match NFO metadata.
Iterates over every subfolder in ``settings.anime_directory`` that
contains a ``tvshow.nfo``. For each folder:
1. Parse the NFO to extract ``<title>`` and ``<year>``.
2. Compute the expected folder name: ``f"{title} ({year})"``.
3. Sanitise the expected name for filesystem safety.
4. Compare with the current folder name.
5. If different, rename the folder and update the database.
Skips folders where title or year is missing/empty. Logs every
rename action.
Args:
dry_run: If True, simulate rename operations without actually
moving folders or updating the database.
Returns:
Dictionary with counts:
- ``"scanned"``: total folders scanned
- ``"renamed"``: folders renamed
- ``"skipped"``: folders skipped (missing title/year)
- ``"errors"``: folders that caused an error
"""
if not settings.anime_directory:
logger.warning("Folder rename skipped — anime directory not configured")
return {"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0}
anime_dir = Path(settings.anime_directory)
if not anime_dir.is_dir():
logger.warning(
"Folder rename skipped — anime directory not found: %s", anime_dir
)
return {"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0}
if dry_run:
logger.info("Running in DRY-RUN mode — no changes will be made")
stats = {"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0}
# Detect pre-existing duplicates before rename loop
pre_existing_duplicates: Set[str] = set()
duplicates = _scan_for_pre_existing_duplicates(anime_dir)
for dup_group in duplicates:
# Try automatic merge first
if _try_merge_duplicate_group(dup_group, dry_run=dry_run):
logger.info(
"Auto-merged duplicate group for '%s' (%d folders)",
dup_group.key,
dup_group.count,
)
else:
# Flag all folders in this group as pre-existing duplicates
for folder in dup_group.folders:
pre_existing_duplicates.add(folder)
logger.warning(
"Duplicate folders detected for series '%s': %s"
"manual cleanup required (different releases or non-empty duplicates)",
dup_group.key,
dup_group.folders,
)
for series_dir in sorted(anime_dir.iterdir()):
if not series_dir.is_dir():
continue
nfo_path = series_dir / "tvshow.nfo"
if not nfo_path.exists():
continue
stats["scanned"] += 1
title, year = _parse_nfo_title_and_year(nfo_path)
if not title or not year:
logger.info(
"Skipping rename for '%s' — missing title or year in NFO",
series_dir.name,
)
stats["skipped"] += 1
continue
expected_name = _compute_expected_folder_name(title, year)
current_name = series_dir.name
if expected_name == current_name:
logger.debug(
"Folder name already correct: '%s'", current_name
)
continue
# Check for active downloads
if _is_series_being_downloaded(current_name):
logger.info(
"Skipping rename for '%s' — series has active or pending downloads",
current_name,
)
stats["skipped"] += 1
continue
expected_path = anime_dir / expected_name
# Check for pre-existing duplicate
if current_name in pre_existing_duplicates:
logger.warning(
"Skipping rename for '%s' — pre-existing duplicate folder detected",
current_name,
)
stats["errors"] += 1
continue
# Check for duplicate target
if expected_path.exists():
logger.warning(
"Cannot rename '%s''%s' — target already exists",
current_name,
expected_name,
)
# Target folder exists — remove source folder and delete its DB record
# (target folder's DB record survives, source folder's record must be removed
# to avoid orphaning episodes/downloads)
try:
import shutil
logger.warning(
"Removing source duplicate folder '%s' — target '%s' already exists",
current_name,
expected_name,
)
shutil.rmtree(series_dir)
logger.info(
"Removed source folder '%s' — series already exists at target",
current_name,
)
# Delete source DB record (cascades to episodes and download items)
async with get_db_session() as db:
source_series = await AnimeSeriesService.get_by_folder(db, current_name)
if source_series is None:
# Fallback: find by folder name
all_series = await AnimeSeriesService.get_all(db)
for s in all_series:
if s.folder == current_name:
source_series = s
break
if source_series is not None:
await AnimeSeriesService.delete(db, source_series.id)
logger.info(
"Deleted source DB record for '%s' (id=%s) — target folder '%s' retains DB record",
current_name,
source_series.id,
expected_name,
)
else:
logger.info(
"No DB record found for source folder '%s' — folder removed only",
current_name,
)
stats["renamed"] += 1
except OSError as exc:
logger.error(
"Failed to remove source folder '%s': %s",
current_name,
exc,
)
stats["errors"] += 1
continue
# Check path length limits
if len(str(expected_path)) > 4096:
logger.warning(
"Cannot rename '%s''%s' — path exceeds OS limit",
current_name,
expected_name,
)
stats["errors"] += 1
continue
if dry_run:
logger.info(
"[DRY-RUN] Would rename folder: '%s''%s'",
current_name,
expected_name,
)
stats["renamed"] += 1
continue
try:
old_path = series_dir
series_dir.rename(expected_path)
logger.info(
"Renamed folder: '%s''%s'", current_name, expected_name
)
stats["renamed"] += 1
# Update database records
await _update_database_paths(current_name, expected_name, anime_dir)
# Clean up stale/legacy files after successful rename
_cleanup_stale_files_after_rename(expected_path, expected_name)
# Clean up orphaned folder if old path still exists
_cleanup_orphaned_folder(old_path, expected_path, dry_run=False)
except PermissionError as exc:
logger.error(
"Permission denied renaming '%s''%s': %s",
current_name,
expected_name,
exc,
)
stats["errors"] += 1
except OSError as exc:
logger.error(
"OS error renaming '%s''%s': %s",
current_name,
expected_name,
exc,
)
stats["errors"] += 1
logger.info(
"Folder rename scan complete: scanned=%d, renamed=%d, skipped=%d, errors=%d",
stats["scanned"],
stats["renamed"],
stats["skipped"],
stats["errors"],
)
return stats

View File

@@ -1,428 +0,0 @@
"""Folder scan service for daily maintenance tasks.
Encapsulates the daily folder-scan logic (orphaned-file detection,
metadata refresh, and missing-episode queuing) so that the scheduler
remains clean and the scan can be tested independently.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Optional
import structlog
from lxml import etree
from src.config.settings import settings as _settings
from src.core.utils.image_downloader import ImageDownloader
logger = structlog.get_logger(__name__)
# Module-level semaphore to limit concurrent TMDB operations to 3.
_TMDB_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(3)
# Semaphore to limit concurrent poster image downloads to 3.
_POSTER_DOWNLOAD_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(3)
# Semaphore to limit concurrent NFO repair TMDB operations to 3.
_NFO_REPAIR_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(3)
async def _create_missing_nfo(series_dir: Path, series_name: str) -> None:
"""Create minimal NFO for series without one.
Creates a fresh :class:`NFOService` per invocation so concurrent
tasks cannot interfere with each other.
A module-level semaphore limits concurrent TMDB operations to 3.
Args:
series_dir: Absolute path to the series folder.
series_name: Human-readable series name for log messages.
"""
from src.core.services.nfo_factory import NFOServiceFactory
async with _NFO_REPAIR_SEMAPHORE:
try:
factory = NFOServiceFactory()
nfo_service = factory.create()
await nfo_service.create_minimal_nfo(
serie_name=series_name,
serie_folder=series_dir.name,
)
except Exception as exc: # pylint: disable=broad-except
logger.error(
"NFO creation failed for %s: %s",
series_name,
exc,
)
async def _repair_one_series(series_dir: Path, series_name: str) -> None:
"""Repair a single series NFO in isolation.
Creates a fresh :class:`NFOService` and :class:`NfoRepairService` per
invocation so that each repair owns its own ``aiohttp`` session/connector
and concurrent tasks cannot interfere with each other.
A module-level semaphore (``_NFO_REPAIR_SEMAPHORE``) limits the number of
simultaneous TMDB requests to avoid rate-limiting.
Any exception is caught and logged so the asyncio task never silently
drops an unhandled error.
Args:
series_dir: Absolute path to the series folder.
series_name: Human-readable series name for log messages.
"""
from src.core.services.nfo_factory import NFOServiceFactory
from src.core.services.nfo_repair_service import NfoRepairService
async with _NFO_REPAIR_SEMAPHORE:
try:
factory = NFOServiceFactory()
nfo_service = factory.create()
repair_service = NfoRepairService(nfo_service)
await repair_service.repair_series(series_dir, series_name)
except Exception as exc: # pylint: disable=broad-except
logger.error(
"NFO repair failed for %s: %s",
series_name,
exc,
)
async def perform_nfo_repair_scan(background_loader=None) -> None:
"""Scan all series folders, repair incomplete and create missing NFO files.
Called from ``FolderScanService.run_folder_scan()`` during the scheduled
daily folder scan (not on every startup). Checks each subfolder of
``settings.anime_directory`` for a ``tvshow.nfo``:
- Missing NFOs: creates minimal NFO via ``_create_missing_nfo``
- Incomplete NFOs: repairs via ``_repair_one_series``
Each repair 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.
The ``background_loader`` parameter is accepted for backwards-compatibility
but is no longer used.
Args:
background_loader: Unused. Kept to avoid breaking call-sites.
"""
from src.core.services.nfo_repair_service import nfo_needs_repair
if not _settings.tmdb_api_key:
logger.warning("NFO repair scan skipped — TMDB API key not configured")
return
if not _settings.anime_directory:
logger.warning("NFO repair scan skipped — anime directory not configured")
return
anime_dir = Path(_settings.anime_directory)
if not anime_dir.is_dir():
logger.warning("NFO repair scan skipped — anime directory not found: %s", anime_dir)
return
queued = 0
total = 0
missing_nfo_count = 0
repair_tasks: list[asyncio.Task] = []
for series_dir in sorted(anime_dir.iterdir()):
if not series_dir.is_dir():
continue
nfo_path = series_dir / "tvshow.nfo"
series_name = series_dir.name
if not nfo_path.exists():
# Create minimal NFO for series without one
missing_nfo_count += 1
repair_tasks.append(
asyncio.create_task(
_create_missing_nfo(series_dir, series_name),
name=f"nfo_create:{series_name}",
)
)
continue
total += 1
if nfo_needs_repair(nfo_path):
queued += 1
repair_tasks.append(
asyncio.create_task(
_repair_one_series(series_dir, series_name),
name=f"nfo_repair:{series_name}",
)
)
if repair_tasks:
logger.info(
"NFO repair scan: waiting for %d repair/create tasks to complete",
len(repair_tasks),
)
await asyncio.gather(*repair_tasks, return_exceptions=True)
logger.info("NFO repair scan tasks completed")
logger.info(
"NFO repair scan complete: %d of %d series queued for repair, %d missing NFOs queued for creation",
queued,
total,
missing_nfo_count,
)
class FolderScanServiceError(Exception):
"""Service-level exception for folder-scan operations."""
class FolderScanService:
"""Performs daily maintenance scans over the anime library folder.
The service is intentionally stateless; a new instance can be created
for every scheduled invocation or test case.
"""
async def run_folder_scan(self) -> None:
"""Execute the daily folder scan.
Checks prerequisites, logs progress, and delegates to sub-task
helpers. Any unhandled exception is caught and logged so the
scheduler task never crashes.
"""
logger.info("Folder scan started")
try:
if not self._prerequisites_met():
return
# 1.3 — Repair incomplete NFO files (synchronous, waits for completion).
logger.info("Starting NFO repair scan as part of folder scan")
await perform_nfo_repair_scan(background_loader=None)
logger.info("NFO repair scan complete")
# 1.4 — Validate and rename series folders after NFO repair.
logger.info("Starting folder rename validation")
from src.server.services.folder_rename_service import (
validate_and_rename_series_folders,
)
rename_stats = await validate_and_rename_series_folders()
logger.info(
"Folder rename validation complete",
scanned=rename_stats["scanned"],
renamed=rename_stats["renamed"],
skipped=rename_stats["skipped"],
errors=rename_stats["errors"],
)
# 1.5 — Check and download missing poster.jpg files.
logger.info("Starting poster check")
poster_stats = await self.check_and_download_missing_posters()
logger.info(
"Poster check complete",
scanned=poster_stats["scanned"],
downloaded=poster_stats["downloaded"],
skipped=poster_stats["skipped"],
errors=poster_stats["errors"],
)
logger.info("Folder scan completed")
except Exception as exc: # pylint: disable=broad-exception-caught
logger.error("Folder scan failed", error=str(exc), exc_info=True)
# ------------------------------------------------------------------
# Poster check helpers
# ------------------------------------------------------------------
async def check_and_download_missing_posters(self) -> dict[str, int]:
"""Iterate over series folders and download missing poster.jpg files.
For each folder containing a ``tvshow.nfo``:
1. Check if ``poster.jpg`` exists and is at least
:attr:`ImageDownloader.min_file_size` bytes.
2. If missing or too small, parse ``tvshow.nfo`` for a ``<thumb>``
URL (preferring ``aspect="poster"``).
3. Download the image via :class:`ImageDownloader` under a
semaphore that limits concurrency to 3.
Returns:
Dictionary with counts:
- ``"scanned"``: total folders scanned
- ``"downloaded"``: posters successfully downloaded
- ``"skipped"``: folders skipped (no NFO, no thumb URL,
or poster already valid)
- ``"errors"``: folders that caused a download error
"""
from src.config.settings import settings # noqa: PLC0415
stats = {"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0}
if not settings.anime_directory:
logger.warning("Poster check skipped — anime directory not configured")
return stats
anime_dir = Path(settings.anime_directory)
if not anime_dir.is_dir():
logger.warning(
"Poster check skipped — anime directory not found: %s", anime_dir
)
return stats
# Gather all series directories that contain a tvshow.nfo
series_dirs = [
d for d in anime_dir.iterdir()
if d.is_dir() and (d / "tvshow.nfo").exists()
]
if not series_dirs:
logger.debug("No series folders found for poster check")
return stats
# Process each series folder concurrently with semaphore
tasks = [
self._check_and_download_poster(series_dir, stats)
for series_dir in series_dirs
]
await asyncio.gather(*tasks, return_exceptions=True)
return stats
async def _check_and_download_poster(
self, series_dir: Path, stats: dict[str, int]
) -> None:
"""Check and download poster for a single series folder.
Args:
series_dir: Path to the series folder.
stats: Mutable stats dictionary to update.
"""
stats["scanned"] += 1
poster_path = series_dir / "poster.jpg"
# Check if poster already exists and is large enough
if poster_path.exists():
try:
# Default min_file_size from ImageDownloader is 1024 bytes (1 KB)
if poster_path.stat().st_size >= 1024:
logger.debug(
"Poster already valid for '%s'", series_dir.name
)
stats["skipped"] += 1
return
except OSError:
pass # Fall through to re-download
# Parse NFO for thumb URL
nfo_path = series_dir / "tvshow.nfo"
poster_url = self._extract_poster_url_from_nfo(nfo_path)
if not poster_url:
logger.info(
"No poster URL found in NFO for '%s', skipping",
series_dir.name,
)
stats["skipped"] += 1
return
# Respect the nfo_download_poster setting
from src.config.settings import settings as app_settings # noqa: PLC0415
if not app_settings.nfo_download_poster:
logger.debug(
"Poster download disabled by nfo_download_poster setting for '%s'",
series_dir.name,
)
stats["skipped"] += 1
return
# Download poster with semaphore
async with _POSTER_DOWNLOAD_SEMAPHORE:
try:
async with ImageDownloader() as downloader:
success = await downloader.download_poster(
poster_url, series_dir, skip_existing=False
)
if success:
logger.info(
"Downloaded poster for '%s'", series_dir.name
)
stats["downloaded"] += 1
else:
logger.warning(
"Failed to download poster for '%s'", series_dir.name
)
stats["errors"] += 1
except Exception as exc: # pylint: disable=broad-except
logger.error(
"Error downloading poster for '%s': %s",
series_dir.name,
exc,
)
stats["errors"] += 1
@staticmethod
def _extract_poster_url_from_nfo(nfo_path: Path) -> Optional[str]:
"""Parse tvshow.nfo and extract the poster thumb URL.
Prefers ``<thumb aspect="poster">``; falls back to the first
``<thumb>`` element if no aspect attribute is present.
Args:
nfo_path: Absolute path to the ``tvshow.nfo`` file.
Returns:
The poster URL string, or ``None`` if not found.
"""
if not nfo_path.exists():
return None
try:
tree = etree.parse(str(nfo_path))
root = tree.getroot()
# Prefer thumb with aspect="poster"
for thumb in root.findall(".//thumb"):
if thumb.get("aspect") == "poster" and thumb.text:
return thumb.text.strip()
# Fallback to first thumb with text
for thumb in root.findall(".//thumb"):
if thumb.text:
return thumb.text.strip()
return None
except etree.XMLSyntaxError:
logger.warning("Malformed XML in %s", nfo_path)
return None
except Exception: # pylint: disable=broad-except
return None
# ------------------------------------------------------------------
# Private helpers
# ------------------------------------------------------------------
def _prerequisites_met(self) -> bool:
"""Verify that the environment is ready for a folder scan.
Returns:
True when ``settings.anime_directory`` exists and
``settings.tmdb_api_key`` is configured.
"""
from src.config.settings import settings # noqa: PLC0415
if not settings.tmdb_api_key:
logger.warning("Folder scan skipped — TMDB API key not configured")
return False
if not settings.anime_directory:
logger.warning("Folder scan skipped — anime directory not configured")
return False
anime_dir = Path(settings.anime_directory)
if not anime_dir.is_dir():
logger.warning(
"Folder scan skipped — anime directory not found: %s", anime_dir
)
return False
return True

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