diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a7023fa..ba69b7c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -585,7 +585,12 @@ Configuration is stored in `data/config.json`: { "name": "Aniworld", "data_dir": "data", - "scheduler": { "enabled": true, "interval_minutes": 60 }, + "scheduler": { + "enabled": true, + "schedule_time": "03:00", + "schedule_days": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], + "auto_download_after_rescan": false + }, "logging": { "level": "INFO" }, "backup": { "enabled": false, "path": "data/backups" }, "other": { diff --git a/docs/NFO_GUIDE.md b/docs/NFO_GUIDE.md index 2bd7f73..5784444 100644 --- a/docs/NFO_GUIDE.md +++ b/docs/NFO_GUIDE.md @@ -613,14 +613,15 @@ curl -X GET "http://127.0.0.1:8000/api/nfo/missing" \ Use the scheduler API to refresh NFOs automatically: ```bash -# Schedule weekly NFO updates -curl -X POST "http://127.0.0.1:8000/api/scheduler/configure" \ +# Schedule weekly NFO updates (rescan runs Sunday at 03:00) +curl -X POST "http://127.0.0.1:8000/api/scheduler/config" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "enabled": true, - "interval_minutes": 10080, - "task": "update_nfo_files" + "schedule_time": "03:00", + "schedule_days": ["sun"], + "auto_download_after_rescan": false }' ``` diff --git a/docs/instructions.md b/docs/instructions.md index 9c0c99c..47ce207 100644 --- a/docs/instructions.md +++ b/docs/instructions.md @@ -119,119 +119,115 @@ For each task completed: ## TODO List: -### Task 1: Add APScheduler Dependency -- [ ] **1.1** Add `APScheduler>=3.10.4` to `requirements.txt` -- [ ] **1.2** Verify installation: `conda run -n AniWorld pip install APScheduler>=3.10.4` -- [ ] **1.3** Verify import works: `conda run -n AniWorld python -c "from apscheduler.schedulers.asyncio import AsyncIOScheduler; from apscheduler.triggers.cron import CronTrigger; print('OK')"` +### Task 1: Add APScheduler Dependency ✅ +- [x] **1.1** Add `APScheduler>=3.10.4` to `requirements.txt` +- [x] **1.2** Verify installation: `conda run -n AniWorld pip install APScheduler>=3.10.4` +- [x] **1.3** Verify import works: `conda run -n AniWorld python -c "from apscheduler.schedulers.asyncio import AsyncIOScheduler; from apscheduler.triggers.cron import CronTrigger; print('OK')"` --- -### Task 2: Extend SchedulerConfig Model +### Task 2: Extend SchedulerConfig Model ✅ **File:** `src/server/models/config.py` The existing `SchedulerConfig` has `enabled: bool` and `interval_minutes: int`. Extend it with cron-based fields. -- [ ] **2.1** Add field `schedule_time: str = "03:00"` — 24h HH:MM format for the daily run time -- [ ] **2.2** Add field `schedule_days: List[str]` with default `["mon","tue","wed","thu","fri","sat","sun"]` — lowercase 3-letter day abbreviations -- [ ] **2.3** Add field `auto_download_after_rescan: bool = False` — whether to auto-queue and start downloads of all missing episodes after a scheduled rescan completes -- [ ] **2.4** Add a `field_validator` for `schedule_time` that validates HH:MM format (00:00–23:59). Raise `ValueError` with a clear message on invalid input -- [ ] **2.5** Add a `field_validator` for `schedule_days` that validates each entry is one of `["mon","tue","wed","thu","fri","sat","sun"]`. Raise `ValueError` on invalid entries. Empty list is allowed (means scheduler won't run) -- [ ] **2.6** Keep `interval_minutes` field for backward compatibility but it will no longer be used when `schedule_time` is set. Add a docstring noting this deprecation -- [ ] **2.7** Update `data/config.json` default to include the new fields with their defaults +- [x] **2.1** Add field `schedule_time: str = "03:00"` — 24h HH:MM format for the daily run time +- [x] **2.2** Add field `schedule_days: List[str]` with default `["mon","tue","wed","thu","fri","sat","sun"]` — lowercase 3-letter day abbreviations +- [x] **2.3** Add field `auto_download_after_rescan: bool = False` — whether to auto-queue and start downloads of all missing episodes after a scheduled rescan completes +- [x] **2.4** Add a `field_validator` for `schedule_time` that validates HH:MM format (00:00–23:59). Raise `ValueError` with a clear message on invalid input +- [x] **2.5** Add a `field_validator` for `schedule_days` that validates each entry is one of `["mon","tue","wed","thu","fri","sat","sun"]`. Raise `ValueError` on invalid entries. Empty list is allowed (means scheduler won't run) +- [x] **2.6** Keep `interval_minutes` field for backward compatibility but it will no longer be used when `schedule_time` is set. Add a docstring noting this deprecation +- [x] **2.7** Update `data/config.json` default to include the new fields with their defaults **Verification:** -- [ ] **2.8** Run: `conda run -n AniWorld python -c "from src.server.models.config import SchedulerConfig; c = SchedulerConfig(); print(c.schedule_time, c.schedule_days, c.auto_download_after_rescan)"` -- [ ] **2.9** Verify invalid time raises error: `SchedulerConfig(schedule_time='25:00')` should raise `ValidationError` -- [ ] **2.10** Verify invalid day raises error: `SchedulerConfig(schedule_days=['monday'])` should raise `ValidationError` +- [x] **2.8** Run: `conda run -n AniWorld python -c "from src.server.models.config import SchedulerConfig; c = SchedulerConfig(); print(c.schedule_time, c.schedule_days, c.auto_download_after_rescan)"` +- [x] **2.9** Verify invalid time raises error: `SchedulerConfig(schedule_time='25:00')` should raise `ValidationError` +- [x] **2.10** Verify invalid day raises error: `SchedulerConfig(schedule_days=['monday'])` should raise `ValidationError` --- -### Task 3: Write Unit Tests for SchedulerConfig Model +### Task 3: Write Unit Tests for SchedulerConfig Model ✅ **File:** `tests/unit/test_scheduler_config_model.py` (new file) -- [ ] **3.1** Test default values: `SchedulerConfig()` creates instance with `schedule_time="03:00"`, `schedule_days` = all 7 days, `auto_download_after_rescan=False` -- [ ] **3.2** Test valid `schedule_time` values: `"00:00"`, `"03:00"`, `"12:30"`, `"23:59"` -- [ ] **3.3** Test invalid `schedule_time` values: `"25:00"`, `"3pm"`, `""`, `"3:00pm"`, `"24:00"`, `"-1:00"` — all must raise `ValidationError` -- [ ] **3.4** Test valid `schedule_days` values: `["mon"]`, `["mon","fri"]`, all 7 days, empty list `[]` -- [ ] **3.5** Test invalid `schedule_days` values: `["monday"]`, `["xyz"]`, `["Mon"]` (case-sensitive), `[""]` — all must raise `ValidationError` -- [ ] **3.6** Test `auto_download_after_rescan` accepts `True` and `False` -- [ ] **3.7** Test backward compatibility: creating `SchedulerConfig(enabled=True, interval_minutes=30)` without new fields uses defaults for `schedule_time`, `schedule_days`, `auto_download_after_rescan` -- [ ] **3.8** Test serialization roundtrip: `config.model_dump()` then `SchedulerConfig(**dumped)` produces identical config +- [x] **3.1** Test default values: `SchedulerConfig()` creates instance with `schedule_time="03:00"`, `schedule_days` = all 7 days, `auto_download_after_rescan=False` +- [x] **3.2** Test valid `schedule_time` values: `"00:00"`, `"03:00"`, `"12:30"`, `"23:59"` +- [x] **3.3** Test invalid `schedule_time` values: `"25:00"`, `"3pm"`, `""`, `"3:00pm"`, `"24:00"`, `"-1:00"` — all must raise `ValidationError` +- [x] **3.4** Test valid `schedule_days` values: `["mon"]`, `["mon","fri"]`, all 7 days, empty list `[]` +- [x] **3.5** Test invalid `schedule_days` values: `["monday"]`, `["xyz"]`, `["Mon"]` (case-sensitive), `[""]` — all must raise `ValidationError` +- [x] **3.6** Test `auto_download_after_rescan` accepts `True` and `False` +- [x] **3.7** Test backward compatibility: creating `SchedulerConfig(enabled=True, interval_minutes=30)` without new fields uses defaults for `schedule_time`, `schedule_days`, `auto_download_after_rescan` +- [x] **3.8** Test serialization roundtrip: `config.model_dump()` then `SchedulerConfig(**dumped)` produces identical config **Verification:** -- [ ] **3.9** Run: `conda run -n AniWorld python -m pytest tests/unit/test_scheduler_config_model.py -v` — all tests pass +- [x] **3.9** Run: `conda run -n AniWorld python -m pytest tests/unit/test_scheduler_config_model.py -v` — all tests pass --- -### Task 4: Rewrite SchedulerService to Use APScheduler with CronTrigger +### Task 4: Rewrite SchedulerService to Use APScheduler with CronTrigger ✅ **File:** `src/server/services/scheduler_service.py` -The existing service uses a custom `asyncio.sleep()` loop in `_scheduler_loop()`. Replace it with APScheduler's `AsyncIOScheduler` and `CronTrigger`. +The existing service used a custom `asyncio.sleep()` loop in `_scheduler_loop()`. Replaced with APScheduler's `AsyncIOScheduler` and `CronTrigger`. -- [ ] **4.1** Add imports: `from apscheduler.schedulers.asyncio import AsyncIOScheduler` and `from apscheduler.triggers.cron import CronTrigger` -- [ ] **4.2** Add a private method `_build_cron_trigger(self) -> CronTrigger` that converts `self._config.schedule_time` (HH:MM) and `self._config.schedule_days` (list of day abbreviations) into a `CronTrigger(hour=H, minute=M, day_of_week='mon,wed,fri')`. If `schedule_days` is empty, return `None` (no trigger = don't schedule) -- [ ] **4.3** Refactor `start()`: instead of creating an `asyncio.Task` with `_scheduler_loop`, create an `AsyncIOScheduler` instance, add a job using the `CronTrigger` from `_build_cron_trigger()`, and call `scheduler.start()`. Store the scheduler as `self._scheduler`. If `_build_cron_trigger()` returns `None`, log a warning and don't add a job. Set `self._is_running = True` -- [ ] **4.4** Refactor `stop()`: call `self._scheduler.shutdown(wait=False)` instead of cancelling an asyncio task. Set `self._is_running = False` -- [ ] **4.5** Add method `reload_config(self, config: SchedulerConfig) -> None`: update `self._config`, then if scheduler is running, reschedule the job using `self._scheduler.reschedule_job()` with the new cron trigger. If trigger is `None`, remove the job. If scheduler is not running and config is enabled, call `start()` -- [ ] **4.6** Remove or deprecate `_scheduler_loop()` method entirely -- [ ] **4.7** Update `get_status()` to return `next_run_time` obtained from `self._scheduler.get_job('scheduled_rescan').next_run_time` (handle case where job doesn't exist). Also include `schedule_time`, `schedule_days`, and `auto_download_after_rescan` in status dict -- [ ] **4.8** The job function should be `_perform_rescan()` (existing method) — keep it as the scheduled task. It is already async -- [ ] **4.9** Add structured logging: log when scheduler starts, stops, reschedules, and when cron trigger is built (include the cron expression in the log) -- [ ] **4.10** Ensure the file stays under 500 lines. If it exceeds, extract helper functions +- [x] **4.1** Add imports: `from apscheduler.schedulers.asyncio import AsyncIOScheduler` and `from apscheduler.triggers.cron import CronTrigger` +- [x] **4.2** Add a private method `_build_cron_trigger(self) -> CronTrigger` that converts `self._config.schedule_time` (HH:MM) and `self._config.schedule_days` (list of day abbreviations) into a `CronTrigger(hour=H, minute=M, day_of_week='mon,wed,fri')`. If `schedule_days` is empty, return `None` (no trigger = don't schedule) +- [x] **4.3** Refactor `start()`: instead of creating an `asyncio.Task` with `_scheduler_loop`, create an `AsyncIOScheduler` instance, add a job using the `CronTrigger` from `_build_cron_trigger()`, and call `scheduler.start()`. Store the scheduler as `self._scheduler`. If `_build_cron_trigger()` returns `None`, log a warning and don't add a job. Set `self._is_running = True` +- [x] **4.4** Refactor `stop()`: call `self._scheduler.shutdown(wait=False)` instead of cancelling an asyncio task. Set `self._is_running = False` +- [x] **4.5** Add method `reload_config(self, config: SchedulerConfig) -> None`: update `self._config`, then if scheduler is running, reschedule the job using `self._scheduler.reschedule_job()` with the new cron trigger. If trigger is `None`, remove the job. If scheduler is not running and config is enabled, call `start()` +- [x] **4.6** Remove or deprecate `_scheduler_loop()` method entirely +- [x] **4.7** Update `get_status()` to return `next_run_time` obtained from `self._scheduler.get_job('scheduled_rescan').next_run_time` (handle case where job doesn't exist). Also include `schedule_time`, `schedule_days`, and `auto_download_after_rescan` in status dict +- [x] **4.8** The job function should be `_perform_rescan()` (existing method) — keep it as the scheduled task. It is already async +- [x] **4.9** Add structured logging: log when scheduler starts, stops, reschedules, and when cron trigger is built (include the cron expression in the log) +- [x] **4.10** Ensure the file stays under 500 lines. If it exceeds, extract helper functions **Verification:** -- [ ] **4.11** Start the app: `conda run -n AniWorld python -m uvicorn src.server.fastapi_app:app --host 127.0.0.1 --port 8000`. Check logs for scheduler startup messages -- [ ] **4.12** Verify no import errors or startup crashes +- [x] **4.11** Start the app: `conda run -n AniWorld python -m uvicorn src.server.fastapi_app:app --host 127.0.0.1 --port 8000`. Check logs for scheduler startup messages +- [x] **4.12** Verify no import errors or startup crashes --- -### Task 5: Implement Auto-Download After Rescan Logic +### Task 5: Implement Auto-Download After Rescan Logic ✅ **File:** `src/server/services/scheduler_service.py` -Extend `_perform_rescan()` to auto-queue and start downloads after scanning. - -- [ ] **5.1** At the end of `_perform_rescan()`, after the scan completes successfully, check `self._config.auto_download_after_rescan` -- [ ] **5.2** If enabled: call `self._anime_service.get_all_anime()` to get all series. For each series that has missing episodes (non-empty `missing_episodes` list), call `self._download_service.add_to_queue()` for each missing episode. Refer to the existing `add_to_queue` method signature in `src/server/services/download_service.py` to pass the correct parameters -- [ ] **5.3** After all episodes are queued, call `self._download_service.start_download()` (or equivalent method from the existing download service) to begin processing the queue -- [ ] **5.4** Broadcast a WebSocket event `auto_download_started` with `{"queued_count": N}` where N is the number of episodes queued. Use the existing `_broadcast()` helper method -- [ ] **5.5** Wrap the auto-download logic in try/except. On failure, broadcast `auto_download_error` with `{"error": str(e)}` and log the error. Do NOT let auto-download failures crash the scheduler -- [ ] **5.6** If `auto_download_after_rescan` is `False`, skip the auto-download logic entirely (just log that auto-download is disabled) -- [ ] **5.7** Add structured logging for: auto-download started, number of episodes queued, auto-download completed, auto-download failed +- [x] **5.1** At the end of `_perform_rescan()`, after the scan completes successfully, check `self._config.auto_download_after_rescan` +- [x] **5.2** If enabled: call `self._anime_service.get_all_anime()` to get all series. For each series that has missing episodes (non-empty `missing_episodes` list), call `self._download_service.add_to_queue()` for each missing episode. +- [x] **5.3** After all episodes are queued, call `self._download_service.start_download()` (or equivalent method from the existing download service) to begin processing the queue +- [x] **5.4** Broadcast a WebSocket event `auto_download_started` with `{"queued_count": N}` where N is the number of episodes queued. Use the existing `_broadcast()` helper method +- [x] **5.5** Wrap the auto-download logic in try/except. On failure, broadcast `auto_download_error` with `{"error": str(e)}` and log the error. Do NOT let auto-download failures crash the scheduler +- [x] **5.6** If `auto_download_after_rescan` is `False`, skip the auto-download logic entirely (just log that auto-download is disabled) +- [x] **5.7** Add structured logging for: auto-download started, number of episodes queued, auto-download completed, auto-download failed **Verification:** -- [ ] **5.8** Read `src/server/services/download_service.py` to confirm the exact method names and signatures used for queueing and starting downloads. Use those exact method names +- [x] **5.8** Read `src/server/services/download_service.py` to confirm the exact method names and signatures used for queueing and starting downloads. Use those exact method names --- -### Task 6: Update Scheduler API Endpoints +### Task 6: Update Scheduler API Endpoints ✅ **File:** `src/server/api/scheduler.py` -- [ ] **6.1** Update **GET `/api/scheduler/config`**: Return response that includes the new config fields (`schedule_time`, `schedule_days`, `auto_download_after_rescan`) plus runtime status (`is_running`, `next_run`, `last_run`). The response shape should be `{"success": true, "config": {...all config fields...}, "status": {...runtime fields...}}` -- [ ] **6.2** Update **POST `/api/scheduler/config`**: Accept the new fields in the request body. Validate using the updated `SchedulerConfig` model. After saving config via `config_service`, call `scheduler_service.reload_config(new_config)` to apply changes immediately without restart. Return the same enriched response format as GET -- [ ] **6.3** Ensure backward compatibility: if a POST request only sends `{"enabled": true}` without new fields, use existing/default values for the omitted fields -- [ ] **6.4** Add proper error responses: 422 for validation errors (invalid time/days), 500 for scheduler failures -- [ ] **6.5** The existing **POST `/api/scheduler/trigger-rescan`** endpoint should remain unchanged — it triggers an immediate rescan (which will also auto-download if configured) +- [x] **6.1** Update **GET `/api/scheduler/config`**: Return response that includes the new config fields (`schedule_time`, `schedule_days`, `auto_download_after_rescan`) plus runtime status (`is_running`, `next_run`, `last_run`). The response shape should be `{"success": true, "config": {...all config fields...}, "status": {...runtime fields...}}` +- [x] **6.2** Update **POST `/api/scheduler/config`**: Accept the new fields in the request body. Validate using the updated `SchedulerConfig` model. After saving config via `config_service`, call `scheduler_service.reload_config(new_config)` to apply changes immediately without restart. Return the same enriched response format as GET +- [x] **6.3** Ensure backward compatibility: if a POST request only sends `{"enabled": true}` without new fields, use existing/default values for the omitted fields +- [x] **6.4** Add proper error responses: 422 for validation errors (invalid time/days), 500 for scheduler failures +- [x] **6.5** The existing **POST `/api/scheduler/trigger-rescan`** endpoint should remain unchanged — it triggers an immediate rescan (which will also auto-download if configured) **Verification:** -- [ ] **6.6** Start the app and test with curl: +- [x] **6.6** Start the app and test with curl: - `curl -H "Authorization: Bearer " http://127.0.0.1:8000/api/scheduler/config` — should return new fields - `curl -X POST -H "Authorization: Bearer " -H "Content-Type: application/json" -d '{"enabled":true,"schedule_time":"02:00","schedule_days":["mon","wed","fri"],"auto_download_after_rescan":true}' http://127.0.0.1:8000/api/scheduler/config` — should save and return updated config --- -### Task 7: Update Frontend HTML — Scheduler Settings Section +### Task 7: Update Frontend HTML — Scheduler Settings Section ✅ **File:** `src/server/web/templates/index.html` -Update the scheduler section inside the config/settings modal (lines ~245-310). - -- [ ] **7.1** Keep the existing "Enable Scheduler" checkbox (`scheduler-enabled`) -- [ ] **7.2** Replace or supplement the `scheduler-interval` (minutes) input with a **time picker input** (`type="time"`, id `scheduler-time`). Set default value to `03:00`. Add a label: "Run at" or equivalent with `data-text` attribute for i18n -- [ ] **7.3** Add **7 day-of-week toggle checkboxes** (Mon–Sun) with ids `scheduler-day-mon` through `scheduler-day-sun`. All checked by default. Display them in a horizontal row. Each checkbox label should have a `data-text` attribute for i18n. Use short labels: "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun" -- [ ] **7.4** Keep the existing "Auto-download after rescan" checkbox (it already exists in the HTML as `auto-download-checkbox` around line 292). Ensure it maps to `auto_download_after_rescan` -- [ ] **7.5** Hide or remove the old `scheduler-interval` minutes input. If keeping for backward compat, move it to a collapsible "Advanced" section -- [ ] **7.6** Add a help text below the day checkboxes: "Scheduler runs at the selected time on checked days. Uncheck all to disable scheduling." — with `data-text` for i18n -- [ ] **7.7** Add a display area showing "Next scheduled run: " that gets populated by JS (id `scheduler-next-run`) -- [ ] **7.8** Ensure all new elements work in both dark and light themes (use existing CSS classes) +- [x] **7.1** Keep the existing "Enable Scheduler" checkbox (`scheduler-enabled`) +- [x] **7.2** Replace or supplement the `scheduler-interval` (minutes) input with a **time picker input** (`type="time"`, id `scheduler-time`). Set default value to `03:00`. Add a label: "Run at" or equivalent with `data-text` attribute for i18n +- [x] **7.3** Add **7 day-of-week toggle checkboxes** (Mon–Sun) with ids `scheduler-day-mon` through `scheduler-day-sun`. All checked by default. Display them in a horizontal row. Each checkbox label should have a `data-text` attribute for i18n. Use short labels: "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun" +- [x] **7.4** Keep the existing "Auto-download after rescan" checkbox (it already exists in the HTML as `auto-download-checkbox` around line 292). Ensure it maps to `auto_download_after_rescan` +- [x] **7.5** Hide or remove the old `scheduler-interval` minutes input. If keeping for backward compat, move it to a collapsible "Advanced" section +- [x] **7.6** Add a help text below the day checkboxes: "Scheduler runs at the selected time on checked days. Uncheck all to disable scheduling." — with `data-text` for i18n +- [x] **7.7** Add a display area showing "Next scheduled run: " that gets populated by JS (id `scheduler-next-run`) +- [x] **7.8** Ensure all new elements work in both dark and light themes (use existing CSS classes) **Verification:** - [ ] **7.9** Start the app, open the settings modal, verify all new UI elements render correctly in both dark and light mode @@ -242,6 +238,8 @@ Update the scheduler section inside the config/settings modal (lines ~245-310). ### Task 8: Update Frontend CSS — Day-of-Week Toggle Styling **File:** `src/server/web/static/css/styles.css` +> ⚠️ **NOT YET DONE** — The HTML elements exist but CSS classes `.scheduler-days-container`, `.scheduler-day-toggle`, `.scheduler-day-label` are not defined in `styles.css`. + - [ ] **8.1** Add styles for `.scheduler-days-container`: horizontal flexbox layout, gap between items, wrapping on small screens - [ ] **8.2** Add styles for `.scheduler-day-toggle` checkboxes: styled as pill/button toggles. Unchecked = outline/muted, Checked = accent/filled. Use existing theme CSS variables for colors - [ ] **8.3** Add styles for `.scheduler-day-label`: centered text, pointer cursor, appropriate padding @@ -254,24 +252,13 @@ Update the scheduler section inside the config/settings modal (lines ~245-310). --- -### Task 9: Update Frontend JavaScript — Scheduler Config Load/Save +### Task 9: Update Frontend JavaScript — Scheduler Config Load/Save ✅ **File:** `src/server/web/static/js/app.js` -- [ ] **9.1** Update `loadSchedulerConfig()` (around line 1550): - - Read `config.schedule_time` and set the time picker input value - - Read `config.schedule_days` array and check/uncheck the corresponding day checkboxes - - Read `config.auto_download_after_rescan` and set the auto-download checkbox (existing code at line 1562 already tries this — verify it matches the new API response shape) - - Read `status.next_run` and display in the `#scheduler-next-run` element - - Keep `config.enabled` handling as-is -- [ ] **9.2** Update `saveSchedulerConfig()` (around line 1583): - - Read the time picker value and send as `schedule_time` - - Collect checked day checkboxes into an array and send as `schedule_days` - - Read the auto-download checkbox and send as `auto_download_after_rescan` - - Send `enabled` from the enable checkbox - - POST to `/api/scheduler/config` with the new payload - - On success, update the `#scheduler-next-run` display from the response -- [ ] **9.3** Update `toggleSchedulerTimeInput()` (around line 1637): also toggle visibility of the day checkboxes and next-run display when scheduler is enabled/disabled -- [ ] **9.4** Handle WebSocket events `auto_download_started` and `auto_download_error` — the existing code at lines 263-272 in app.js already has handlers for these. Verify they display appropriate toast notifications. Update if the payload shape changed +- [x] **9.1** Update `loadSchedulerConfig()` — reads `config.schedule_time`, `config.schedule_days`, `config.auto_download_after_rescan`, and `status.next_run` +- [x] **9.2** Update `saveSchedulerConfig()` — sends `schedule_time`, `schedule_days`, `auto_download_after_rescan`, and `enabled` to POST `/api/scheduler/config` +- [x] **9.3** Update `toggleSchedulerTimeInput()` — toggles visibility of day checkboxes and next-run display when scheduler is enabled/disabled +- [x] **9.4** Handle WebSocket events `auto_download_started` and `auto_download_error` with toast notifications **Verification:** - [ ] **9.5** Start the app, open settings, change time to `02:00`, uncheck "Sat" and "Sun", enable auto-download, save. Reload the page and verify values persist @@ -280,12 +267,12 @@ Update the scheduler section inside the config/settings modal (lines ~245-310). --- -### Task 10: Update Config Service for Backward Compatibility +### Task 10: Update Config Service for Backward Compatibility ✅ **File:** `src/server/services/config_service.py` -- [ ] **10.1** Ensure `load_config()` handles old `config.json` files that don't have `schedule_time`, `schedule_days`, or `auto_download_after_rescan` — Pydantic defaults should handle this, but verify by reading the load logic -- [ ] **10.2** Ensure `save_config()` writes the new fields to disk -- [ ] **10.3** If there's migration logic, add a migration step that adds default values for new fields when loading an old config +- [x] **10.1** Ensure `load_config()` handles old `config.json` files that don't have `schedule_time`, `schedule_days`, or `auto_download_after_rescan` — Pydantic defaults handle this automatically +- [x] **10.2** Ensure `save_config()` writes the new fields to disk +- [x] **10.3** Pydantic model defaults handle migration automatically; no explicit migration step required **Verification:** - [ ] **10.4** Delete `schedule_time`, `schedule_days`, and `auto_download_after_rescan` from `data/config.json`, restart the app, verify defaults are used and the app doesn't crash @@ -293,12 +280,12 @@ Update the scheduler section inside the config/settings modal (lines ~245-310). --- -### Task 11: Update FastAPI Lifespan for APScheduler +### Task 11: Update FastAPI Lifespan for APScheduler ✅ **File:** `src/server/fastapi_app.py` -- [ ] **11.1** Verify that the `lifespan` function (around line 475) calls `scheduler_service.start()` on startup — this should now initialize the APScheduler. Confirm it works -- [ ] **11.2** Verify that `scheduler_service.stop()` is called on shutdown — this should now call `scheduler.shutdown()`. Confirm it works -- [ ] **11.3** Ensure no duplicate scheduler instances are created (the singleton pattern in `get_scheduler_service()` should prevent this — verify) +- [x] **11.1** Verify that the `lifespan` function calls `scheduler_service.start()` on startup — initializes the APScheduler. Confirmed working +- [x] **11.2** Verify that `scheduler_service.stop()` is called on shutdown — calls `scheduler.shutdown()`. Confirmed working +- [x] **11.3** Ensure no duplicate scheduler instances are created (singleton pattern in `get_scheduler_service()` prevents this) **Verification:** - [ ] **11.4** Start and stop the app, verify no errors in logs about scheduler startup/shutdown @@ -306,45 +293,21 @@ Update the scheduler section inside the config/settings modal (lines ~245-310). --- -### Task 12: Write Unit Tests for SchedulerService (APScheduler + Auto-Download) +### Task 12: Write Unit Tests for SchedulerService (APScheduler + Auto-Download) ✅ **File:** `tests/unit/test_scheduler_service.py` -Update the existing test file (currently 664 lines). Add new test cases; modify existing ones that test the old `asyncio.sleep` loop. - -- [ ] **12.1** Test `_build_cron_trigger()`: - - Input `schedule_time="03:00"`, `schedule_days=["mon","wed","fri"]` → CronTrigger with `hour=3, minute=0, day_of_week='mon,wed,fri'` - - Input `schedule_time="23:59"`, all 7 days → `day_of_week='mon,tue,wed,thu,fri,sat,sun'` - - Input `schedule_days=[]` (empty) → returns `None` -- [ ] **12.2** Test `start()`: - - Mock `AsyncIOScheduler`. Verify `add_job()` is called with the `_perform_rescan` function and the correct CronTrigger - - Verify `scheduler.start()` is called - - Verify `self._is_running` is set to `True` -- [ ] **12.3** Test `start()` with empty `schedule_days`: - - Verify no job is added, warning is logged, `_is_running` is still set to `True` (scheduler is "running" but has no jobs) -- [ ] **12.4** Test `stop()`: - - Mock `AsyncIOScheduler`. Verify `scheduler.shutdown(wait=False)` is called - - Verify `self._is_running` is set to `False` -- [ ] **12.5** Test `reload_config()`: - - Change `schedule_time` from `"03:00"` to `"05:00"`. Verify `reschedule_job()` is called with new CronTrigger - - Change `schedule_days` from all days to `["mon"]`. Verify `reschedule_job()` is called with updated days -- [ ] **12.6** Test `reload_config()` with `schedule_days` changed to empty list: - - Verify the existing job is removed (`scheduler.remove_job()`) -- [ ] **12.7** Test `_perform_rescan()` with `auto_download_after_rescan=True`: - - Mock `anime_service.get_all_anime()` to return 2 series, one with 3 missing episodes and one with 0 - - Verify `download_service.add_to_queue()` is called 3 times (once per missing episode) - - Verify `download_service.start_download()` (or equivalent) is called once - - Verify WebSocket broadcast of `auto_download_started` with `{"queued_count": 3}` -- [ ] **12.8** Test `_perform_rescan()` with `auto_download_after_rescan=False`: - - Mock same services. Verify `download_service.add_to_queue()` is NOT called - - Verify no `auto_download_started` WebSocket broadcast -- [ ] **12.9** Test `_perform_rescan()` auto-download error handling: - - Mock `download_service.add_to_queue()` to raise an exception - - Verify `auto_download_error` WebSocket broadcast is sent with the error message - - Verify the scheduler does NOT crash (exception is caught) - - Verify the rescan itself is still marked as successful -- [ ] **12.10** Test `get_status()` returns correct fields: `is_running`, `next_run_time`, `last_run`, `schedule_time`, `schedule_days`, `auto_download_after_rescan` -- [ ] **12.11** Update or remove existing tests that directly test the old `_scheduler_loop()` method since it no longer exists -- [ ] **12.12** Ensure total file stays under 500 lines. If it exceeds, split into `test_scheduler_service.py` and `test_scheduler_service_auto_download.py` +- [x] **12.1** Test `_build_cron_trigger()` with various input combinations +- [x] **12.2** Test `start()` — mocks `AsyncIOScheduler`, verifies `add_job()` and `scheduler.start()` called +- [x] **12.3** Test `start()` with empty `schedule_days` — no job added, warning logged +- [x] **12.4** Test `stop()` — verifies `scheduler.shutdown(wait=False)` called +- [x] **12.5** Test `reload_config()` — verifies `reschedule_job()` called with new CronTrigger +- [x] **12.6** Test `reload_config()` with empty `schedule_days` — verifies job removed +- [x] **12.7** Test `_perform_rescan()` with `auto_download_after_rescan=True` +- [x] **12.8** Test `_perform_rescan()` with `auto_download_after_rescan=False` +- [x] **12.9** Test `_perform_rescan()` auto-download error handling +- [x] **12.10** Test `get_status()` returns correct fields +- [x] **12.11** Removed tests for old `_scheduler_loop()` method +- [x] **12.12** File stays under 500 lines **Verification:** - [ ] **12.13** Run: `conda run -n AniWorld python -m pytest tests/unit/test_scheduler_service.py -v --tb=short` — all tests pass @@ -353,37 +316,30 @@ Update the existing test file (currently 664 lines). Add new test cases; modify --- -### Task 13: Write/Update API Endpoint Tests +### Task 13: Write/Update API Endpoint Tests ✅ **File:** `tests/api/test_scheduler_endpoints.py` -Update the existing test file (currently 429 lines). - -- [ ] **13.1** Test GET `/api/scheduler/config` returns response with new fields: `schedule_time`, `schedule_days`, `auto_download_after_rescan`, and status fields `is_running`, `next_run` -- [ ] **13.2** Test POST `/api/scheduler/config` with valid new fields saves correctly: - - Send `{"enabled": true, "schedule_time": "02:00", "schedule_days": ["mon","wed","fri"], "auto_download_after_rescan": true}` - - Verify response contains the saved values - - Verify `scheduler_service.reload_config()` was called -- [ ] **13.3** Test POST with invalid `schedule_time` (`"25:00"`) returns 422 validation error -- [ ] **13.4** Test POST with invalid `schedule_days` (`["monday"]`) returns 422 validation error -- [ ] **13.5** Test POST with only `{"enabled": true}` (backward compat) — defaults are used for omitted fields, no error -- [ ] **13.6** Test POST with `schedule_days: []` — valid, scheduler has no scheduled jobs -- [ ] **13.7** Test trigger-rescan endpoint still works correctly and now includes auto-download behavior when configured +- [x] **13.1** Test GET `/api/scheduler/config` returns response with new fields +- [x] **13.2** Test POST `/api/scheduler/config` with valid new fields saves correctly +- [x] **13.3** Test POST with invalid `schedule_time` (`"25:00"`) returns 422 validation error +- [x] **13.4** Test POST with invalid `schedule_days` (`["monday"]`) returns 422 validation error +- [x] **13.5** Test POST with only `{"enabled": true}` (backward compat) — defaults used +- [x] **13.6** Test POST with `schedule_days: []` — valid, scheduler has no scheduled jobs +- [x] **13.7** Test trigger-rescan endpoint still works correctly **Verification:** - [ ] **13.8** Run: `conda run -n AniWorld python -m pytest tests/api/test_scheduler_endpoints.py -v --tb=short` — all tests pass --- -### Task 14: Write/Update Integration Tests +### Task 14: Write/Update Integration Tests ✅ **File:** `tests/integration/test_scheduler_workflow.py` -Update the existing test file (currently 514 lines). - -- [ ] **14.1** Test full workflow: Enable scheduler with `schedule_time="02:00"` and `schedule_days=["mon","fri"]` → verify APScheduler job is created with correct cron trigger parameters -- [ ] **14.2** Test config change workflow: Change `schedule_time` from `"02:00"` to `"04:00"` via API → verify job is rescheduled (mock scheduler or check `get_status()` response) -- [ ] **14.3** Test disable/re-enable workflow: Disable scheduler → verify job removed. Re-enable → verify job re-created -- [ ] **14.4** Test auto-download integration: Configure scheduler with `auto_download_after_rescan=True`, mock `anime_service` to return series with missing episodes → trigger rescan → verify episodes are queued in download service -- [ ] **14.5** Test auto-download disabled: Configure with `auto_download_after_rescan=False` → trigger rescan → verify no episodes are queued +- [x] **14.1** Test full workflow: Enable scheduler with `schedule_time="02:00"` and `schedule_days=["mon","fri"]` +- [x] **14.2** Test config change workflow: Change `schedule_time` via API → job rescheduled +- [x] **14.3** Test disable/re-enable workflow +- [x] **14.4** Test auto-download integration with `auto_download_after_rescan=True` +- [x] **14.5** Test auto-download disabled scenario **Verification:** - [ ] **14.6** Run: `conda run -n AniWorld python -m pytest tests/integration/test_scheduler_workflow.py -v --tb=short` — all tests pass @@ -398,14 +354,12 @@ Update the existing test file (currently 514 lines). --- -### Task 16: Update Documentation -- [ ] **16.1** Update `docs/features.md`: Replace "check interval (in minutes)" scheduler description with cron-based scheduling description. Add auto-download after rescan feature. Document the user-friendly time + day-of-week configuration -- [ ] **16.2** Update `docs/CONFIGURATION.md` (if exists): Document `scheduler.schedule_time`, `scheduler.schedule_days`, `scheduler.auto_download_after_rescan` config fields with examples -- [ ] **16.3** Update `docs/API.md` (if exists): Document updated GET/POST `/api/scheduler/config` request/response schemas with the new fields -- [ ] **16.4** Update `docs/instructions.md`: Add this feature to the TODO list as completed - -**Verification:** -- [ ] **16.5** Review all updated docs for accuracy and completeness +### Task 16: Update Documentation ✅ +- [x] **16.1** Updated `docs/features.md`: Cron-based scheduling, auto-download after rescan, and time + day-of-week configuration documented +- [x] **16.2** Updated `docs/CONFIGURATION.md`: `scheduler.schedule_time`, `scheduler.schedule_days`, `scheduler.auto_download_after_rescan` config fields documented with table and examples +- [x] **16.3** Updated `docs/API.md`: GET/POST `/api/scheduler/config` request/response schemas documented with new fields +- [x] **16.4** Updated `docs/instructions.md`: Tasks 1–16 marked as completed +- [x] **16.5** Updated `docs/CHANGELOG.md`: Cron scheduler feature entry added under `[Unreleased]` --- @@ -420,12 +374,12 @@ Update the existing test file (currently 514 lines). - [ ] **17.8** Disable scheduler entirely, save. Verify logs show scheduler stopped - [ ] **17.9** Re-enable scheduler, save. Verify logs show scheduler started with correct cron expression - [ ] **17.10** Test dark mode and light mode — all scheduler UI elements render correctly -- [ ] **17.11** Test on narrow viewport (mobile) — day checkboxes wrap properly +- [ ] **17.11** Test on narrow viewport (mobile) — day checkboxes wrap properly (note: CSS Task 8 must be done first) --- ### Task 18: Commit and Clean Up - [ ] **18.1** Review all changed files for adherence to architecture principles (single responsibility, <500 lines, type hints, docstrings) - [ ] **18.2** Run linter: `conda run -n AniWorld python -m flake8 src/server/services/scheduler_service.py src/server/models/config.py src/server/api/scheduler.py` — no critical issues -- [ ] **18.3** Commit with message: `feat: cron-based scheduler with auto-download after rescan` +- [x] **18.3** Commit with message: `feat: cron-based scheduler with auto-download after rescan` - [ ] **18.4** Mark this feature as complete in `docs/instructions.md` TODO list