Render photos from local immich.
authorScott Gasch <[email protected]>
Wed, 1 Apr 2026 23:02:13 +0000 (16:02 -0700)
committerScott Gasch <[email protected]>
Wed, 1 Apr 2026 23:02:13 +0000 (16:02 -0700)
immich_photo_renderer.py [new file with mode: 0644]

diff --git a/immich_photo_renderer.py b/immich_photo_renderer.py
new file mode 100644 (file)
index 0000000..5f6fb7b
--- /dev/null
@@ -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"""
+<style>
+  body{{background-color:#303030;}}
+  div#time{{color:#dddddd;}}
+  div#date{{color:#dddddd;}}
+</style>
+<table>
+<tr>
+<td width=80%>
+  <center>
+  <img src="/kiosk/mfs/current.jpeg"
+       style="display:block;max-width=800;max-height:600;width:auto;height:auto">
+  </center>
+</td>
+<td valign="middle">
+  <font size=+1 color="#cccccc">
+  {descr}<br>
+  <HR>
+  {location}
+  <HR>
+  {date.strftime("%B %-d, %Y")}<br>
+  </font>
+</td>
+</tr>
+</table>
+""")
+        return True
+
+
+# Test code
+#x = immich_photo_renderer({"Fetch Photos": (60 * 60 * 12),
+#                           "Rotate Current Photo": (1)})
+#x.index_photos()
+#x.choose_photo()