From: Scott Gasch Date: Wed, 1 Apr 2026 23:02:13 +0000 (-0700) Subject: Render photos from local immich. X-Git-Url: https://git.acknak.org/gitweb/?a=commitdiff_plain;h=08e55eef735545d829c42d5cdd9252d77f5794b9;p=kiosk.git Render photos from local immich. --- diff --git a/immich_photo_renderer.py b/immich_photo_renderer.py new file mode 100644 index 0000000..5f6fb7b --- /dev/null +++ b/immich_photo_renderer.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 + +import datetime +import os +import random +import re +from typing import Dict, List, Set + +from scottutilz import immich + +import file_writer +import renderer +import kiosk_secrets + + +def format_coords(lat: float, lon: float) -> str: + """ + Renders lat/lon floats into a human-readable string: + '47.6101° N, 122.2015° W' + """ + lat_dir: str = 'N' if lat >= 0 else 'S' + lon_dir: str = 'E' if lon >= 0 else 'W' + + # Use 4 decimal places for ~11m precision + return f"{abs(lat):.4f}° {lat_dir}, {abs(lon):.4f}° {lon_dir}" + +def natural_join(items: List[str], use_oxford_comma: bool = True) -> str: + """ + Joins a list of strings into a natural language sentence fragment. + + Examples: + ['A'] -> "A" + ['A', 'B'] -> "A and B" + ['A', 'B', 'C'] -> "A, B, and C" + """ + count: int = len(items) + + if count == 0: + return "" + if count == 1: + return items[0] + if count == 2: + return f"{items[0]} and {items[1]}" + + # Handle 3 or more items + prefix: str = ", ".join(items[:-1]) + suffix: str = f", and {items[-1]}" if use_oxford_comma else f" and {items[-1]}" + return f"{prefix}{suffix}" + +class immich_photo_renderer(renderer.abstaining_renderer): + """A renderer that uses immich photos""" + + album_whitelist = frozenset( + [ + 'Family, ALL', + 'Street Art, ALL', + 'Artsy', + 'Louisville, 2025: Google Folks', + 'France / Spain ALL, 2025', + 'Friends, ALL', + 'Spain and Portugal ALL, 2024', + ] + ) + + def __init__(self, name_to_timeout_dict: Dict[str, int]) -> None: + super().__init__(name_to_timeout_dict) + self.immich_cli = immich.ImmichSession( + api_key=kiosk_secrets.scott_immich_key + ) + self.candidate_photo_uuids: Set[str] = set() + + def debug_prefix(self) -> str: + return "immich_photos" + + def periodic_render(self, key: str) -> bool: + if key == "Fetch Photos": + return self.index_photos() + elif key == "Rotate Current Photo": + return self.choose_photo() + else: + raise Exception("Unexpected operation") + + def index_photos(self) -> bool: + for album_name in self.album_whitelist: + album = self.immich_cli.search_album(album_name)[0] + if not album: + print(f"Album name '{album_name}' was not found, skipped...") + continue + album_uuid = album.get('id', None) + if not album_uuid: + print(f"Album {album} has no id?!?!") + continue + for photo in self.immich_cli.search_all_assets(album_id=[album_uuid], tag_id=["0ded3e16-2a34-4785-9681-f5f49ed6b4d8"]): + photo_uuid = photo.get('id', None) + if photo_uuid: + self.candidate_photo_uuids.add(photo_uuid) + for photo in self.immich_cli.search_all_assets(album_id=[album_uuid], tag_id=["a28a3f61-773d-4d5c-8569-9f255d40a7f8"]): + photo_uuid = photo.get('id', None) + if photo_uuid: + self.candidate_photo_uuids.add(photo_uuid) + return True + + def choose_photo(self): + """Pick one of the cached URLs and build a page.""" + if len(self.candidate_photo_uuids) == 0: + print("No photos!") + return False + selected_uuid = random.sample(list(self.candidate_photo_uuids), 1)[0] + asset = self.immich_cli.get_full_asset(selected_uuid) + if not asset: + print(f"Asset {selected_uuid} is unknown?!") + return False + try: + jpeg = self.immich_cli.get_asset_thumbnail(selected_uuid, size="fullsize") + with open("/var/www/kiosk/pages/mfs/current.jpeg", "wb") as f: + f.write(jpeg) + except Exception: + logger.exception("Can't fetch/write the photo?!") + return False + + exif = asset.get('exifInfo', {}) + location = "Unknown location" + if exif: + date = datetime.datetime.fromisoformat(exif.get('dateTimeOriginal', None)) + city = exif.get("city", "") + state = exif.get("state", "") + country = exif.get("country", "") + lat = exif.get("latitude", "") + lon = exif.get("longitude", "") + if city: + location = city + if state: + if location: + location += f", {state}" + else: + location = state + if country: + if location: + location += f", {country}" + else: + location = country + if lat and lon: + if location: + location += f" @ {format_coords(lat, lon)}" + else: + location = format_coords(lat, lon) + descr = exif.get("description", "") + if not descr: + people = asset.get('people', []) + if people: + descr = "A photo of " + descr += natural_join([p.get("name", "") for p in asset.get("people", [])]) + else: + descr = "No description data" + with file_writer.file_writer("photo_23_3600.html") as f: + f.write( + f""" + + + + + + +
+
+ +
+
+ + {descr}
+
+ {location} +
+ {date.strftime("%B %-d, %Y")}
+
+
+""") + return True + + +# Test code +#x = immich_photo_renderer({"Fetch Photos": (60 * 60 * 12), +# "Rotate Current Photo": (1)}) +#x.index_photos() +#x.choose_photo()