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
@@ -11,11 +11,66 @@ from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import TrackingConfig, User
from ..services.scheduler import reschedule_immich_dispatch_jobs
from ..services.time_list import TimeListError, normalize_time_list
_LOGGER = logging.getLogger(__name__)
router = APIRouter(prefix="/api/tracking-configs", tags=["tracking-configs"])
# Immich dispatch slots that fire on a wall-clock schedule. Each has a
# ``{kind}_enabled`` flag and a ``{kind}_times`` comma-separated HH:MM list.
_DISPATCH_KINDS = ("periodic", "scheduled", "memory")
# TrackingConfig fields holding comma-separated ``HH:MM`` dispatch schedules.
# Normalized (validated, de-duplicated, sorted, capped) on every write so the
# scheduler only ever reads clean values and cron jobs stay deterministic.
_TIME_LIST_FIELDS = tuple(f"{k}_times" for k in _DISPATCH_KINDS)
def _normalize_time_fields(values: dict) -> None:
"""Canonicalize any ``*_times`` keys present in ``values`` (in place).
Raises HTTP 422 with a field-scoped message when an entry is malformed or
the list exceeds the per-day cap, so the client surfaces exactly which slot
was rejected instead of the input being silently dropped at schedule time.
"""
for field in _TIME_LIST_FIELDS:
if values.get(field) is None:
continue
try:
values[field] = normalize_time_list(values[field])
except TimeListError as err:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"{field}: {err}",
) from err
def _validate_enabled_have_times(values: dict, existing: TrackingConfig | None) -> None:
"""Reject enabling a dispatch slot with no fire times.
An enabled slot whose ``{kind}_times`` normalizes to empty would save fine
but the scheduler creates zero cron jobs for it — a silently dead slot that
shows as "enabled" in the UI yet never fires. We fail the write with a 422
instead. Only kinds the request actually touches (enabled flag or times) are
checked, so unrelated edits to a pre-existing config aren't blocked; the
effective state is the request value merged over ``existing``.
"""
for kind in _DISPATCH_KINDS:
enabled_key, times_key = f"{kind}_enabled", f"{kind}_times"
if enabled_key not in values and times_key not in values:
continue
enabled = values.get(
enabled_key, getattr(existing, enabled_key, False) if existing else False
)
times = values.get(
times_key, getattr(existing, times_key, "") if existing else ""
)
if enabled and not (times or "").strip():
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"{times_key}: add at least one time when {kind} is enabled",
)
class TrackingConfigCreate(BaseModel):
provider_type: str
@@ -124,7 +179,10 @@ async def create_config(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
config = TrackingConfig(user_id=user.id, **body.model_dump())
data = body.model_dump()
_normalize_time_fields(data)
_validate_enabled_have_times(data, None)
config = TrackingConfig(user_id=user.id, **data)
session.add(config)
await session.commit()
await session.refresh(config)
@@ -150,7 +208,10 @@ async def update_config(
session: AsyncSession = Depends(get_session),
):
config = await _get(session, config_id, user.id)
for field, value in body.model_dump(exclude_unset=True).items():
updates = body.model_dump(exclude_unset=True)
_normalize_time_fields(updates)
_validate_enabled_have_times(updates, config)
for field, value in updates.items():
setattr(config, field, value)
session.add(config)
await session.commit()