Simplify and caption

This commit is contained in:
Andras Schmelczer 2026-04-26 15:49:14 +01:00
parent de65fbee9f
commit 84f8456fff
7 changed files with 551 additions and 596 deletions

View file

@ -1,5 +1,4 @@
#!/usr/bin/env python3
import hashlib
import json
import random
import tempfile
@ -7,38 +6,17 @@ import time
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from urllib.error import URLError
from urllib.request import Request, urlopen
from urllib.request import Request
from progress import ProgressBar
from net import urlopen_with_retry
HISTORY_FILE = Path(__file__).parent.parent / "photo_history.json"
HISTORY_MAX_AGE_DAYS = 7
CACHE_DIR = Path(tempfile.gettempdir()) / "frame_cache"
CACHE_TTL_SECONDS = 3600
RETRY_DELAYS = (3, 10)
def _urlopen_with_retry(req: Request, timeout: int = 30):
"""urlopen wrapper that retries transient network failures."""
last_err: Exception | None = None
for attempt in range(len(RETRY_DELAYS) + 1):
try:
return urlopen(req, timeout=timeout)
except (URLError, TimeoutError) as e:
last_err = e
if attempt < len(RETRY_DELAYS):
time.sleep(RETRY_DELAYS[attempt])
raise last_err
def _cache_get(key: str) -> list[dict] | None:
path = CACHE_DIR / f"{key}.json"
if not path.exists():
return None
if time.time() - path.stat().st_mtime > CACHE_TTL_SECONDS:
if not path.exists() or time.time() - path.stat().st_mtime > 3600:
return None
try:
return json.loads(path.read_text())
@ -57,30 +35,26 @@ class PhotoHistory:
def __init__(self, path: Path = HISTORY_FILE):
self.path = path
self.displayed: set[str] = set()
self.created_at: datetime | None = None
self.created_at = datetime.now(timezone.utc)
self._load()
def _load(self) -> None:
if not self.path.exists():
self._reset()
self._save()
return
try:
data = json.loads(self.path.read_text())
self.created_at = datetime.fromisoformat(data.get("created_at", ""))
self.created_at = datetime.fromisoformat(data["created_at"])
if self.created_at.tzinfo is None:
self.created_at = self.created_at.replace(tzinfo=timezone.utc)
if datetime.now(timezone.utc) - self.created_at > timedelta(days=HISTORY_MAX_AGE_DAYS):
print(f"Photo history expired (>{HISTORY_MAX_AGE_DAYS} days), clearing...")
self._reset()
if datetime.now(timezone.utc) - self.created_at > timedelta(days=7):
print("Photo history expired (>7 days), clearing...")
self.created_at = datetime.now(timezone.utc)
self._save()
else:
self.displayed = set(data.get("displayed", []))
except (json.JSONDecodeError, ValueError, KeyError):
self._reset()
def _reset(self) -> None:
self.displayed = set()
self.created_at = datetime.now(timezone.utc)
self._save()
self._save()
def _save(self) -> None:
self.path.write_text(json.dumps({
@ -96,24 +70,16 @@ class PhotoHistory:
return [a for a in assets if a.get("id") not in self.displayed]
_history: PhotoHistory | None = None
def get_history() -> PhotoHistory:
global _history
if _history is None:
_history = PhotoHistory()
return _history
@dataclass
class ImmichClient:
base_url: str
api_key: str
def _request(self, method: str, endpoint: str, data: dict | None = None,
show_progress: bool = False, progress_desc: str = "Fetching") -> dict:
url = f"{self.base_url.rstrip('/')}/api/{endpoint.lstrip('/')}"
def __post_init__(self):
self.base_url = self.base_url.rstrip("/")
def _request(self, method: str, endpoint: str, data: dict | None = None) -> dict:
url = f"{self.base_url}/api/{endpoint.lstrip('/')}"
headers = {"x-api-key": self.api_key}
body = None
if data is not None:
@ -121,30 +87,17 @@ class ImmichClient:
body = json.dumps(data).encode()
req = Request(url, data=body, headers=headers, method=method)
with _urlopen_with_retry(req, timeout=30) as resp:
total_size = resp.headers.get('Content-Length')
if total_size and show_progress:
total_size = int(total_size)
progress = ProgressBar(total_size, desc=progress_desc)
chunks = bytearray()
while chunk := resp.read(8192):
chunks.extend(chunk)
progress.update(len(chunk))
progress.finish()
return json.loads(chunks.decode())
with urlopen_with_retry(req, timeout=30) as resp:
return json.loads(resp.read().decode())
def get_people(self) -> list[dict]:
return self._request("GET", "/people")["people"]
def get_person_id(self, name: str) -> str | None:
for person in self.get_people():
for person in self._request("GET", "/people")["people"]:
if person["name"].lower() == name.lower():
return person["id"]
return None
def search_assets_by_people(self, person_ids: list[str]) -> list[dict]:
key = "people_" + hashlib.md5("_".join(sorted(person_ids)).encode()).hexdigest()
key = "people_" + "_".join(sorted(person_ids))
cached = _cache_get(key)
if cached is not None:
return cached
@ -167,22 +120,11 @@ class ImmichClient:
_cache_set(key, items)
return items
def download_asset(self, asset_id: str, dest: Path, show_progress: bool = True) -> Path:
url = f"{self.base_url.rstrip('/')}/api/assets/{asset_id}/thumbnail?size=preview"
def download_asset(self, asset_id: str, dest: Path) -> Path:
url = f"{self.base_url}/api/assets/{asset_id}/thumbnail?size=preview"
req = Request(url, headers={"x-api-key": self.api_key})
with _urlopen_with_retry(req, timeout=30) as resp:
total_size = resp.headers.get('Content-Length')
if total_size and show_progress:
total_size = int(total_size)
progress = ProgressBar(total_size, desc="Downloading")
data = bytearray()
while chunk := resp.read(8192):
data.extend(chunk)
progress.update(len(chunk))
progress.finish()
dest.write_bytes(bytes(data))
else:
dest.write_bytes(resp.read())
with urlopen_with_retry(req, timeout=30) as resp:
dest.write_bytes(resp.read())
return dest
def get_album_id(self, name: str) -> str | None:
@ -191,107 +133,85 @@ class ImmichClient:
return album["id"]
return None
def get_album_assets(self, album_id: str, show_progress: bool = False) -> list[dict]:
def get_album_assets(self, album_id: str) -> list[dict]:
key = f"album_{album_id}"
cached = _cache_get(key)
if cached is not None:
return cached
album = self._request("GET", f"/albums/{album_id}",
show_progress=show_progress, progress_desc="Fetching album")
assets = album.get("assets", [])
assets = self._request("GET", f"/albums/{album_id}").get("assets", [])
_cache_set(key, assets)
return assets
def _is_portrait(asset: dict) -> bool | None:
"""Check if asset displays as portrait, accounting for EXIF orientation."""
exif = asset.get("exifInfo") or {}
width = exif.get("exifImageWidth") or 0
height = exif.get("exifImageHeight") or 0
if not (width and height):
return None
# EXIF orientation 6 and 8 mean 90° rotation (swap dimensions)
orientation = str(exif.get("orientation") or "1")
if orientation in ("6", "8"):
width, height = height, width
return height > width
def _filter_by_orientation(assets: list[dict], portrait: bool) -> list[dict]:
"""Filter assets by orientation, accounting for EXIF rotation."""
filtered = []
no_dimensions = 0
for asset in assets:
is_portrait = _is_portrait(asset)
if is_portrait is not None:
if is_portrait == portrait:
filtered.append(asset)
else:
no_dimensions += 1
if no_dimensions:
print(f"Note: {no_dimensions}/{len(assets)} photos missing dimension data")
return filtered
"""Keep assets matching the requested orientation. Skips assets without EXIF dimensions."""
out = []
for a in assets:
exif = a.get("exifInfo") or {}
w = exif.get("exifImageWidth") or 0
h = exif.get("exifImageHeight") or 0
if not (w and h):
continue
if exif.get("orientation") in (6, 8, "6", "8"):
w, h = h, w
if (h > w) == portrait:
out.append(a)
return out
def _pick_weighted_random(assets: list[dict]) -> dict:
"""Pick random asset, biased towards favorites (20%) and recently added photos (50%)."""
if not assets:
raise ValueError("No assets to choose from")
"""Pick random asset, biased towards favorites and recently added photos."""
cutoff = datetime.now(timezone.utc) - timedelta(days=30)
favorites = [a for a in assets if a.get("isFavorite")]
recent = []
for asset in assets:
date_str = asset.get("createdAt", "")
for a in assets:
try:
if datetime.fromisoformat(date_str.replace("Z", "+00:00")) >= cutoff:
recent.append(asset)
if datetime.fromisoformat(a.get("createdAt", "").replace("Z", "+00:00")) >= cutoff:
recent.append(a)
except (ValueError, AttributeError):
pass
if favorites and random.random() < 0.2:
return random.choice(favorites)
if recent and random.random() < 0.5:
return random.choice(recent)
return random.choice(assets)
candidates = [(favorites, 0.2), (recent, 0.4), (assets, 0.4)]
pools, weights = zip(*[(p, w) for p, w in candidates if p])
pool = random.choices(pools, weights=weights)[0]
return random.choice(pool)
def _download_random_asset(client: ImmichClient, assets: list[dict]) -> Path:
history = get_history()
new_assets = history.filter_new(assets)
def _pick_and_download(client: ImmichClient, assets: list[dict],
orientation: int, source_label: str) -> tuple[Path, dict]:
portrait = orientation in (90, 270)
filtered = _filter_by_orientation(assets, portrait)
if not filtered:
raise ValueError(f"No {'portrait' if portrait else 'landscape'} photos in {source_label}")
if new_assets:
print(f"Photos: {len(new_assets)} new / {len(assets)} total")
asset = _pick_weighted_random(new_assets)
history = PhotoHistory()
candidates = history.filter_new(filtered)
if not candidates:
print(f"All {len(filtered)} photos shown, picking from full list")
candidates = filtered
else:
print(f"All {len(assets)} photos shown, picking from full list")
asset = _pick_weighted_random(assets)
print(f"Photos: {len(candidates)} new / {len(filtered)} total")
asset = _pick_weighted_random(candidates)
dest = Path(tempfile.gettempdir()) / "immich_photo.jpg"
path = client.download_asset(asset["id"], dest)
history.mark_displayed(asset["id"])
return path
return path, asset
def get_random_photo_of_people(client: ImmichClient, names: list[str], orientation: int = 0) -> Path:
def get_random_photo_of_people(client: ImmichClient, names: list[str], orientation: int = 0) -> tuple[Path, dict]:
person_ids = [pid for name in names if (pid := client.get_person_id(name))]
if not person_ids:
raise ValueError(f"No people found: {names}")
assets = client.search_assets_by_people(person_ids)
if not assets:
raise ValueError(f"No photos found for: {names}")
portrait = orientation in (90, 270)
filtered = _filter_by_orientation(assets, portrait)
if not filtered:
raise ValueError(f"No {'portrait' if portrait else 'landscape'} photos available")
return _download_random_asset(client, filtered)
return _pick_and_download(client, assets, orientation, f"photos for {', '.join(names)}")
def get_random_photo_from_album(client: ImmichClient, album_name: str, orientation: int = 0) -> Path:
def get_random_photo_from_album(client: ImmichClient, album_name: str, orientation: int = 0) -> tuple[Path, dict]:
album_id = client.get_album_id(album_name)
if not album_id:
raise ValueError(f"Album not found: {album_name}")
@ -300,8 +220,4 @@ def get_random_photo_from_album(client: ImmichClient, album_name: str, orientati
if not assets:
raise ValueError(f"No photos in album: {album_name}")
portrait = orientation in (90, 270)
filtered = _filter_by_orientation(assets, portrait)
if not filtered:
raise ValueError(f"No {'portrait' if portrait else 'landscape'} photos in album: {album_name}")
return _download_random_asset(client, filtered)
return _pick_and_download(client, assets, orientation, f"album: {album_name}")