bug: renaming the action_types only changed code — a db already seeded under the
old names kept the stale rows (the seeder only CREATES missing ones, never removes
or renames). so on restart you got BOTH 'Auto-Download Movie Wishlist' (orphaned,
dead action) AND 'Auto-Process Movie Wishlist'.
add _fix_wishlist_processor_rename (runs in ensure_system_automations like the
other _fix_* migrations): deletes the orphaned video_download_movie/episode_wishlist
system rows (clearing is_system first, since delete_automation guards system rows)
and renames the youtube row in place (its action_type was unchanged). idempotent.
tested.
Switch the two deep-scan system automations from a rolling 7-day interval to
weekly_time at 02:00 server-local — TV Mondays, Movies Tuesdays. Different days
means they never overlap, and a fixed wall-clock time doesn't drift with restarts.
Drop initial_delay (the seeder arms timed system triggers). _fix_deep_scan_schedules
migrates the original interval rows to the weekly schedule (the seeder only creates
rows, never updates a drifted trigger); it skips once trigger_type is weekly_time so
a hand-tuned day/time sticks. Idempotent.
The job shipped as a 24h 'schedule' because the system-automation seeder only armed
next_run for interval specs — a 'daily_time' spec sat idle and never fired. The
interval fired reliably but drifted with every restart (5min after startup, then
+24h) instead of a fixed wall-clock time, which is worse for 'today's airings' (you
want it queued overnight).
Fix, the robust way:
- Seeder now arms timed system triggers (daily/weekly/monthly) via next_run_at, not
just interval ones. Event-based triggers still return None and are left alone.
- Spec -> daily_time {time:'01:00'} for fresh installs.
- _fix_airing_automation_schedule migrates the existing 24h-interval row to daily
01:00 (the seeder only creates rows, never updates a drifted trigger). Idempotent.
_finish_run already reschedules daily_time to the next 1am, so it stays pinned.
_fix_video_scan_default set its 'done' flag even on runs where it deleted
nothing, so once the flag latched True the standalone 'Scan Video Library'
system automation survived forever (the row the post-download chain replaced).
Drop the flag entirely — get_system_automation_by_action already matches only
the is_system-seeded row, so the cleanup is safe to run every startup and
no-ops once the row is gone.
Superseded by the post-download chain (Auto-Scan Video After Downloads → Auto-Update
Video Database After Scan), which keeps the library fresh without a separate scheduled
scan. Two small changes: drop it from SYSTEM_AUTOMATIONS (no longer seeded), and a
one-time flag-guarded cleanup (_fix_video_scan_default, v3) that DELETES the existing
system row — so it actually stops running, not just hidden. The video_scan_library
action/handler/block stay, so a custom scan automation can still be built later.
Engine seed test updated. 66 automation tests pass.
The scheduled 'Scan Video Library' defaulted to a FULL scan (re-reads everything +
prunes) that fired ~5 min after every app start. A recurring scan should be light:
switched the seed to INCREMENTAL (newest-only, no prune) and pushed the first run a
full interval out so it runs on its 6h cadence rather than right after startup.
One-time migration (_fix_video_scan_default, flag-guarded) corrects existing rows that
still carry the old full+startup default, without overriding a deliberate user choice.
The video side gets its OWN automations at music-side parity, kept separate so
nothing on the music side breaks. First twin: Scan Video Library — tells the media
server to rescan the user's SELECTED video sections (movies/TV, never music), then
reads the result into video.db so freshly-downloaded media shows as owned.
Architecture (scope tags + video twins on the shared engine):
- Handler core/automation/handlers/video_scan_library.py — pure function with
injected I/O (server_refresh / run_video_scan); production lazily binds
refresh_video_server_sections() + the video scanner. Owns its own progress.
Lives on the SHARED automation side so it may import core.video (isolation only
forbids core/video & api/video from importing music, not the reverse).
- blocks.py gains a 'scope' tag ('both' generic / 'video' video-only / absent=music)
+ blocks_for_scope(). The music /api/automations/blocks now filters out video
blocks; new isolated /api/video/automations/blocks serves the video palette.
- automation_engine seeds 'Scan Video Library' (owned_by='video', schedule 6h) so
it appears ONLY on the video Automations page; ensure_system_automations now
honours owned_by + action_config. Music page excludes owned_by='video' rows.
kettui: seam-level tests for every handler path (happy/no-server/scan-error/never-
raises/mode), scope filtering (music excludes video, video gets generics, music
parity preserved), seeding (owned_by + mode), registration drift guard. 39 new
tests; full automation suite (288) + isolation guards green.
PR 1 (commit 6ad85e27) shipped the ``next_run_at`` pure function as
foundation plumbing. PR 2 wires the engine through it and adds
``monthly_time`` as a real registered trigger type. After this PR
``core/automation_engine.py`` no longer has its own datetime
arithmetic for daily / weekly schedules — every next-run computation
flows through one function with one set of defensive fallbacks.
Net user-visible change: zero (no UI surface for monthly_time yet —
that's PR 3). New ``monthly_time`` trigger is reachable only via
direct API for now.
**Engine refactor:**
- ``_finish_run`` — collapsed three inline branches (daily_time
arithmetic, weekly_time arithmetic, fallback schedule arithmetic)
into a single ``next_run_at(...)`` call with ``_dt_to_db_str``
normalising the aware-UTC result to the engine's naive-UTC string
convention. Retry-delay short-circuit preserved. Exception
swallowing preserved (logged at debug, writes None next_run).
- ``_setup_daily_time_trigger`` + ``_setup_weekly_time_trigger`` +
new ``_setup_monthly_time_trigger`` — three near-identical methods
collapsed into one ``_setup_timed_trigger`` skeleton. Each public
method is now a one-line dispatch passing trigger_type to the
shared helper with a human-readable label for the debug log.
- Existing ``_next_weekly_occurrence`` deleted — its logic now lives
in ``core/automation/schedule.py:_next_weekly`` (lifted in PR 1).
- New ``_dt_to_db_str(dt)`` module-level helper normalises aware-UTC
→ naive-UTC string. Centralised so a tz mistake here surfaces in
one place. Aware non-UTC datetimes converted to UTC first
(defensive against a future bug that passes the wrong tz).
- New ``_resolve_system_default_tz()`` reads the server's local IANA
tz via ``tzlocal``. Cached at module import (the host's tz doesn't
change while the process runs). Falls back to UTC when ``tzlocal``
is missing — defensive for minimal Docker images.
- New ``self._default_tz`` engine attribute reads from
``automation.default_timezone`` config first, falls back to the
system-detected IANA name. Override path lets users on weird
setups pin a specific tz without touching env vars.
**Convergence fix (intentional behaviour change):**
Old ``_setup_daily_time_trigger`` / ``_setup_weekly_time_trigger``
didn't check the DB for an existing future ``next_run`` — they'd
recompute from scratch on every engine startup, overwriting manual
edits or pending retries. The interval path (``_setup_schedule_trigger``)
already had this check. The new shared ``_setup_timed_trigger``
brings daily / weekly in line: existing-future next_run wins over
freshly-computed delay. Treat this as a correctness fix, not a
breaking change — the old behaviour was an inconsistency, not a
deliberate choice.
**Backward-compat:**
- Existing ``schedule`` / ``daily_time`` / ``weekly_time`` rows
continue to work unchanged. The ``_trigger_handlers`` registry
keeps every historic key.
- Existing rows without an explicit ``tz`` field use
``self._default_tz`` (server-local IANA via ``tzlocal``) —
preserves "every Monday 09:00 server-local" behaviour on
non-UTC servers. Pre-fix the engine used naive
``datetime.now()`` which is also server-local; net effect is
identical wall-clock time, just routed through a tz-aware
pipeline that handles DST correctly (the May 2026 "next in 8h"
bug fix class).
- Engine boots even when ``tzlocal`` is missing — the resolver
falls back to UTC silently. Existing tests would catch a hard
dependency on tzlocal here.
**``tzlocal>=5.0`` added to requirements.txt** alongside
``tzdata>=2024.1`` from PR 1. Both libraries are small and stable;
``tzlocal`` returns a clean IANA name across Windows / Linux /
Docker, sidestepping the platform-specific tz detection mess.
**Tests:** 20 new in ``tests/automation/test_engine_schedule_integration.py``:
- ``_dt_to_db_str`` x3 (aware UTC, aware non-UTC converted to UTC,
naive assumed UTC)
- ``_resolve_system_default_tz`` x2 (returns IANA string, falls back
to UTC without tzlocal)
- ``_finish_run`` dispatch through next_run_at for each trigger type
(schedule, daily_time, weekly_time, monthly_time)
- Retry-delay short-circuits next_run_at
- next_run_at returns None → DB next_run cleared
- next_run_at raises → engine swallows + writes None
- Event triggers skipped (no scheduled next-run)
- ``self._default_tz`` passed through to next_run_at
- monthly_time registered in _trigger_handlers
- All historic trigger types kept registered
- ``_setup_monthly_time_trigger`` arms timer + writes DB
- ``_setup_timed_trigger`` honours existing future DB next_run
- Skip-with-log when next_run_at returns None
- End-to-end no-mock smoke for monthly_time
260 automation suite tests pass; the 240 from PR 1's branch plus 20
new integration tests. Ruff clean.
No WHATS_NEW entry — UI doesn't expose monthly_time yet (PR 3),
and the backward-compat path preserves existing daily/weekly
schedule timing.