164 lines
5.5 KiB
Python
164 lines
5.5 KiB
Python
"""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,
|
|
DEFAULT_MISSING_DATE_MODE,
|
|
MISSING_DATE_EXCLUDE,
|
|
MISSING_DATE_USE_UPLOADED,
|
|
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,
|
|
missing_date: str = DEFAULT_MISSING_DATE_MODE,
|
|
now: datetime | None = None,
|
|
) -> list[T]:
|
|
"""Filter items by date according to ``mode``.
|
|
|
|
``missing_date`` decides what happens to photos that have no EXIF
|
|
``captured_at``:
|
|
|
|
- ``use_uploaded_at`` - fall back to ``uploaded_at`` for the comparison
|
|
so date windows stay meaningful. Photos with neither timestamp are
|
|
kept for window filters and dropped for the strict ``on_this_day``.
|
|
- ``include`` - keep undated photos for window filters (the legacy
|
|
behaviour); the strict ``on_this_day`` still drops them.
|
|
- ``exclude`` - drop undated photos entirely.
|
|
|
|
``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 isinstance(ts, int):
|
|
if pred(ts):
|
|
out.append(it)
|
|
continue
|
|
|
|
# No capture date: behaviour depends on ``missing_date``.
|
|
if missing_date == MISSING_DATE_EXCLUDE:
|
|
continue
|
|
if missing_date == MISSING_DATE_USE_UPLOADED:
|
|
up = getattr(it, "uploaded_at", None)
|
|
if isinstance(up, int):
|
|
if pred(up):
|
|
out.append(it)
|
|
continue
|
|
# No upload date either: fall through to the lenient default.
|
|
|
|
# ``include`` (or ``use_uploaded_at`` with no usable date): keep for
|
|
# window filters, drop for strict modes like ``on_this_day``.
|
|
if not strict:
|
|
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
|