feat(immich): multi-time-point scheduling for scheduled/periodic/memory

Replace the single comma-separated text box with an add/remove list of
native HH:MM pickers for the Immich scheduled-assets, periodic-summary,
and memory slots. The backend already stored comma-separated *_times and
scheduled one cron job per time; this makes entering several times per
day discoverable and hardens the read/write path.

Backend:
- services/time_list.py: normalize_time_list (validate / dedup / sort /
  cap at 24, raising TimeListError) + lenient parse_hhmm_list; the
  scheduler now uses the shared parser (drops its private copy).
- tracking_configs API normalizes *_times on every write (422 on bad
  input) and rejects enabling a slot whose times list is empty.
- scheduler warns when an enabled slot has zero or dropped fire times,
  restoring the observability lost with the old per-call warning.

Frontend:
- TimeListEditor.svelte: add/remove native time rows, dedupe + sort on
  emit, per-day cap, collapses on-screen duplicates, aria-labelled rows;
  syncs from the value prop only on external changes (untrack guard) so
  keyboard entry isn't clobbered mid-edit.
- Descriptor-driven save guard: an enabled feature section must have at
  least one time.
- i18n (en/ru) keys; refreshed help text; removed dead invalidTimeList.

Tests: time_list normalization/parsing (incl. non-ASCII/odd shapes) and
the enabled-implies-times validation.
This commit is contained in:
2026-05-29 14:57:41 +03:00
parent e2c17dd343
commit 8065e6effa
11 changed files with 718 additions and 89 deletions
@@ -7,6 +7,8 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from .time_list import parse_hhmm_list
_LOGGER = logging.getLogger(__name__)
@@ -896,30 +898,6 @@ _IMMICH_DISPATCH_KINDS = ("scheduled", "periodic", "memory")
_IMMICH_DISPATCH_PREFIX = "immich_dispatch_"
def _parse_hhmm_list(raw: str) -> list[tuple[int, int]]:
"""Parse ``"09:00,18:30"`` → ``[(9, 0), (18, 30)]``, skipping bad entries.
A typo in one slot must not prevent the others from scheduling — we log
and move on rather than raising.
"""
out: list[tuple[int, int]] = []
for part in (raw or "").split(","):
part = part.strip()
if not part:
continue
try:
h_str, m_str = part.split(":", 1)
hour, minute = int(h_str), int(m_str)
except ValueError:
_LOGGER.warning("Skipping invalid time literal %r", part)
continue
if not (0 <= hour <= 23 and 0 <= minute <= 59):
_LOGGER.warning("Skipping out-of-range time %r", part)
continue
out.append((hour, minute))
return out
async def _run_immich_dispatch(tracker_id: int, kind: str) -> None:
"""APScheduler entry point — wraps the dispatch helper to swallow errors."""
from .scheduled_dispatch import dispatch_scheduled_for_tracker
@@ -994,7 +972,24 @@ async def _load_immich_dispatch_jobs() -> None:
if not getattr(tc, f"{kind}_enabled", False):
continue
times_raw = getattr(tc, f"{kind}_times", "") or ""
for hour, minute in _parse_hhmm_list(times_raw):
parsed = parse_hhmm_list(times_raw)
# Observability for misconfigured/legacy data: warn when some tokens
# were unparseable (the lenient parser drops them silently) and when
# an enabled slot resolves to zero fire times (it will never fire).
raw_tokens = [p for p in times_raw.split(",") if p.strip()]
if len(parsed) < len(raw_tokens):
_LOGGER.warning(
"Tracker %d %s: dropped %d unparseable time(s) from %r",
tracker.id, kind, len(raw_tokens) - len(parsed), times_raw,
)
if not parsed:
_LOGGER.warning(
"Tracker %d has %s enabled but no valid fire times (%r); "
"slot will not fire",
tracker.id, kind, times_raw,
)
continue
for hour, minute in parsed:
job_id = f"{_IMMICH_DISPATCH_PREFIX}{kind}_{tracker.id}_{hour:02d}{minute:02d}"
scheduler.add_job(
_run_immich_dispatch,