"""Playlist construction: ordering and date filtering. Pure, dependency-free helpers so the camera and tests can share a single implementation. Operates on ``MediaItem``-like objects that expose ``captured_at`` / ``uploaded_at`` (epoch ms or ``None``). """ from __future__ import annotations from datetime import datetime, timedelta, timezone from typing import Iterable, TypeVar from .const import ( DATE_FILTER_LAST_7, DATE_FILTER_LAST_30, DATE_FILTER_LAST_365, DATE_FILTER_OFF, DATE_FILTER_ON_THIS_DAY, DATE_FILTER_THIS_MONTH, DATE_FILTER_THIS_YEAR, ORDER_ALBUM, ORDER_NEWEST_ADDED, ORDER_NEWEST_TAKEN, ORDER_OLDEST_ADDED, ORDER_OLDEST_TAKEN, ORDER_RANDOM, ) T = TypeVar("T") def order_items(items: list[T], order_mode: str) -> list[T]: """Return a new list ordered per ``order_mode``. ``random`` and ``album_order`` are no-ops here - random shuffling lives in the camera so it can dedupe recent slides; ``album_order`` keeps the source order untouched. The taken/added orderings are stable; items without the required timestamp keep their relative position at the end. """ if order_mode == ORDER_RANDOM or order_mode == ORDER_ALBUM: return list(items) key_attr, reverse = _order_key(order_mode) if key_attr is None: return list(items) with_ts: list[tuple[int, int, T]] = [] without_ts: list[tuple[int, T]] = [] for idx, it in enumerate(items): ts = getattr(it, key_attr, None) if isinstance(ts, int): with_ts.append((ts, idx, it)) else: without_ts.append((idx, it)) with_ts.sort(key=lambda t: (t[0], t[1]), reverse=reverse) return [it for _, _, it in with_ts] + [it for _, it in without_ts] def _order_key(order_mode: str) -> tuple[str | None, bool]: if order_mode == ORDER_NEWEST_TAKEN: return "captured_at", True if order_mode == ORDER_OLDEST_TAKEN: return "captured_at", False if order_mode == ORDER_NEWEST_ADDED: return "uploaded_at", True if order_mode == ORDER_OLDEST_ADDED: return "uploaded_at", False return None, False def filter_items( items: Iterable[T], *, mode: str, now: datetime | None = None, ) -> list[T]: """Filter items by ``captured_at`` according to ``mode``. Items with no ``captured_at`` are kept by default unless the mode is ``on_this_day`` (treated as a strict filter). ``now`` is overridable for deterministic tests. """ if not mode or mode == DATE_FILTER_OFF: return list(items) today_utc = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) pred, strict = _build_predicate(mode, today_utc) if pred is None: return list(items) out: list[T] = [] for it in items: ts = getattr(it, "captured_at", None) if not isinstance(ts, int): if not strict: out.append(it) continue if pred(ts): out.append(it) return out def _build_predicate( mode: str, today_utc: datetime, ): """Return (predicate, strict). ``strict`` drops items without timestamps.""" if mode == DATE_FILTER_LAST_7: cutoff = int((today_utc - timedelta(days=7)).timestamp() * 1000) return (lambda ts: ts >= cutoff), False if mode == DATE_FILTER_LAST_30: cutoff = int((today_utc - timedelta(days=30)).timestamp() * 1000) return (lambda ts: ts >= cutoff), False if mode == DATE_FILTER_LAST_365: cutoff = int((today_utc - timedelta(days=365)).timestamp() * 1000) return (lambda ts: ts >= cutoff), False if mode == DATE_FILTER_THIS_MONTH: start = today_utc.replace(day=1, hour=0, minute=0, second=0, microsecond=0) cutoff = int(start.timestamp() * 1000) return (lambda ts: ts >= cutoff), False if mode == DATE_FILTER_THIS_YEAR: start = today_utc.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0) cutoff = int(start.timestamp() * 1000) return (lambda ts: ts >= cutoff), False if mode == DATE_FILTER_ON_THIS_DAY: # Match items whose UTC month+day equals today's. Useful for daily # "memories"-style rotation across all years. today_md = (today_utc.month, today_utc.day) def _on_this_day(ts: int) -> bool: d = datetime.fromtimestamp(ts / 1000, tz=timezone.utc) return (d.month, d.day) == today_md return _on_this_day, True return None, False