Several improvements to the photo renderer.
authorScott Gasch <[email protected]>
Tue, 7 Apr 2026 02:28:57 +0000 (19:28 -0700)
committerScott Gasch <[email protected]>
Tue, 7 Apr 2026 02:28:57 +0000 (19:28 -0700)
immich_photo_renderer.py

index 5f6fb7b6b5933478b6d4e6c396ea8285cb90bf27..be6e5dba669c10d4e12c2ea9d11026ea16eb895e 100644 (file)
@@ -4,7 +4,7 @@ import datetime
 import os
 import random
 import re
-from typing import Dict, List, Set
+from typing import Any, Dict, List, Optional, Set
 
 from scottutilz import immich
 
@@ -47,21 +47,51 @@ def natural_join(items: List[str], use_oxford_comma: bool = True) -> str:
     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"""
+def parse_exif_date(exif_data: dict[str, Any]) -> Optional[datetime.datetime]:
+    date_str: Optional[str] = exif_data.get('dateTimeOriginal')
+
+    if not date_str:
+        return None
+
+    try:
+        # Attempt 1: Standard ISO format
+        return datetime.datetime.fromisoformat(date_str)
+    except ValueError:
+        try:
+            # Attempt 2: Handle common EXIF format 'YYYY:MM:DD HH:MM:SS'
+            clean_date = date_str.split('+')[0].split('-')[0]
+            return datetime.datetime.strptime(date_str, "%Y-%m-%dT%H:%M:%S.%f%z")
+        except ValueError:
+            # Final Fallback: Return current time or a 'null' date to prevent crash
+            print(f"Warning: Could not parse date string: {date_str}")
+            return None
+
+def get_layout_class(asset: dict[str, Any]) -> str:
+    exif = asset.get('exifInfo', {})
+    width: Optional[int] = exif.get('exifImageWidth')
+    height: Optional[int] = exif.get('exifImageHeight')
+
+    if not width or not height:
+        return 'standard_mode'
+
+    # Use the actual orientation if the EXIF swap is happening
+    # Panorama strictly means "Much wider than it is tall"
+    aspect_ratio = float(width) / float(height)
 
-    album_whitelist = frozenset(
-        [
-            'Family, ALL',
-            'Street Art, ALL',
-            'Artsy',
-            'Louisville, 2025: Google Folks',
-            'France / Spain ALL, 2025',
-            'Friends, ALL',
-            'Spain and Portugal ALL, 2024',
-        ]
-    )
+    # Figure out 90 degree rotations...
+    orientation = exif.get('orientation')
+    if orientation:
+        orientation = int(orientation)
+        if orientation in [6, 8]:
+            aspect_ratio = float(1) / aspect_ratio
 
+    if aspect_ratio > 1.5:
+        return 'panorama_mode'
+    return 'standard_mode'
+
+
+class immich_photo_renderer(renderer.abstaining_renderer):
+    """A renderer that uses immich photos"""
     def __init__(self, name_to_timeout_dict: Dict[str, int]) -> None:
         super().__init__(name_to_timeout_dict)
         self.immich_cli = immich.ImmichSession(
@@ -81,25 +111,34 @@ class immich_photo_renderer(renderer.abstaining_renderer):
             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)
+        all_assets = (
+            self.immich_cli.search_all_assets(
+                tag_id=["0ded3e16-2a34-4785-9681-f5f49ed6b4d8"]
+            ) +
+            self.immich_cli.search_all_assets(
+                tag_id=["a28a3f61-773d-4d5c-8569-9f255d40a7f8"]
+            )
+        )
+        print(f"{len(all_assets)} kiosk assets.")
+        for lite_asset in all_assets:
+            asset_uuid = lite_asset.get('id')
+            if asset_uuid:
+                self.candidate_photo_uuids.add(asset_uuid)
         return True
 
+    @staticmethod
+    def _highlight_hashtags(text: Any, color: str = "#888888") -> str:
+        # Regex breakdown:
+        # (#           -> Start a capture group beginning with #
+        #  [^#]+       -> Match one or more characters that are NOT a #
+        # )            -> End capture group
+        # (?=#|$)      -> Lookahead: ensure the next char is a # OR the end of string
+        return re.sub(
+            r"(#[^#]+)(?=#|$)",
+            lambda m: f'<span style="color: {color};">{m.group(1).strip()}</span> ',
+            text
+        ).strip()
+
     def choose_photo(self):
         """Pick one of the cached URLs and build a page."""
         if len(self.candidate_photo_uuids) == 0:
@@ -110,18 +149,31 @@ class immich_photo_renderer(renderer.abstaining_renderer):
         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?!")
+            print("Failed to get image!")
             return False
 
+        # Simple aspect ratio check for layout decision.
+        layout_class = get_layout_class(asset)
+
+        # Albums list...
+        try:
+            albums = self.immich_cli.get_asset_albums(selected_uuid)
+        except Exception:
+            print("Failed to get albums!")
+            albums = []
+
+        # Location, description, etc...
         exif = asset.get('exifInfo', {})
         location = "Unknown location"
+        descr = ''
         if exif:
-            date = datetime.datetime.fromisoformat(exif.get('dateTimeOriginal', None))
+            date = parse_exif_date(exif)
             city = exif.get("city", "")
             state = exif.get("state", "")
             country = exif.get("country", "")
@@ -145,46 +197,264 @@ class immich_photo_renderer(renderer.abstaining_renderer):
                 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:
+                descr = "No description data available.  #needs description"
+        descr = self._highlight_hashtags(descr)
+
+        album_html = ''
+        for album in albums:
+            album_name = album.get('albumName')
+            album_cover_uuid = album.get('albumThumbnailAssetId')
+            album_cover = self.immich_cli.get_asset_thumbnail(
+                album_cover_uuid, size="thumbnail"
+            )
+            with open(f"/var/www/kiosk/pages/mfs/{album_cover_uuid}.jpeg", "wb") as f:
+                f.write(album_cover)
+            album_html += f"""
+<div class="album-card">
+   <img src="/kiosk/mfs/{album_cover_uuid}.jpeg" class="album-thumb" alt="Album Art">
+   <span class="album-name">{album_name}</span>
+</div>
+"""
+        with file_writer.file_writer("photo_33_3600.html") as f:
             f.write(
                 f"""
 <style>
-  body{{background-color:#303030;}}
   div#time{{color:#dddddd;}}
   div#date{{color:#dddddd;}}
+  :root {{
+        --bg-color: #1a1a1a;
+        --text-main: #ffffff;
+        --text-dim: #b0b0b0;
+        --accent: #4a90e2;
+        --sidebar-w: 25vw;
+        --bottom-h: 28vh;
+  }}
+  body, html {{
+        margin: 0;
+        padding: 0;
+        height: 100%;
+        width: 100%;
+        background-color: var(--bg-color);
+        color: var(--text-main);
+        font-family: system-ui, -apple-system, sans-serif;
+        overflow: hidden;
+  }}
+  .kiosk-container {{
+        display: flex;
+        height: 100vh;
+        width: 100vw;
+  }}
+
+  /* --- Photo Pane Logic --- */
+  .photo-pane {{
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        background: #000;
+        padding: 20px;
+        box-sizing: border-box;
+  }}
+  .standard_mode .photo-pane {{
+        flex: 1;
+        max-width: 80vw; /* Limit width to 80% */
+  }}
+  .panorama_mode .photo-pane {{
+        flex: 1;
+        height: 70vh; /* Limit height to 70% */
+        width: 100vw;
+        min-height: 0;
+  }}
+  .photo-pane img {{
+        max-width: 100%;
+        max-height: 100%;
+        object-fit: contain;
+        box-shadow: 0 0 50px rgba(0,0,0,0.5);
+        border-radius: 4px;
+  }}
+
+  /* --- Info Pane Logic --- */
+  .info-pane {{
+        display: flex;
+        background: linear-gradient(180deg, #252525 0%, #1a1a1a 100%);
+        box-sizing: border-box;
+        padding: 3vh 2vw;
+  }}
+  /* SIDEBAR MODE */
+  .standard_mode {{
+        flex-direction: row;
+  }}
+  .standard_mode .info-pane {{
+        flex: 0 0 var(--sidebar-w);
+        flex-direction: column;
+        border-left: 2px solid #333;
+        overflow-y: auto;
+  }}
+  /* BOTTOM BAR MODE */
+  .panorama_mode {{
+        flex-direction: column;
+  }}
+  .panorama_mode .info-pane {{
+        flex: 0 0 var(--bottom-h);
+        flex-direction: row;
+        align-items: center;
+        border-top: 2px solid #333;
+        gap: 2vw; /* Use relative gap */
+        padding: 0 3vw;
+  }}
+
+  /* --- Typography & Elements --- */
+  .description {{
+        font-weight: 600;
+        line-height: 1.2;
+        color: var(--text-main);
+        white-space: normal;
+  }}
+  .standard_mode .description {{
+        font-size: clamp(1.5rem, 3vh, 3rem);
+        margin-bottom: 2vh;
+  }}
+  .panorama_mode .description {{
+        flex: 1;
+        font-size: 1.8rem;
+        margin: 0;
+        font-size: clamp(1.2rem, 2.5vh, 2.2rem);
+        padding-right: 2vw;
+        min-width: 0; /* Allows text to truncate/wrap properly */
+  }}
+
+  .divider {{
+        background: var(--accent);
+  }}
+  .standard_mode .divider {{
+         height: 2px;
+         width: 40px;
+         margin: 2vh 0;
+  }}
+  .panorama_mode .divider {{
+         width: 2px;
+         height: 50px;
+         margin: 0;
+  }}
+
+  /* Metadata section: Constrained to 20% width */
+  .panorama_mode .meta-group {{
+        flex: 0 0 20%;
+        display: flex;
+        flex-direction: column;
+        justify-content: center;
+        padding: 0 1.5vw;
+        min-width: 0; /* Crucial for internal text wrapping */
+  }}
+
+  .location {{
+        font-size: 0.9rem;
+        color: var(--text-dim);
+        text-transform: uppercase;
+        letter-spacing: 1px;
+        overflow-wrap: break-word;
+        word-break: break-word;
+  }}
+  .date {{ font-size: 1rem;
+        color: var(--accent);
+        font-weight: bold;
+        font-size: 1rem;
+        margin-top: 4px;
+  }}
+
+  /* --- Album List --- */
+  .album-section-title {{
+        font-size: 0.7rem;
+        text-transform: uppercase;
+        letter-spacing: 2px;
+        color: var(--text-dim);
+  }}
+  .standard_mode .album-section-title {{
+        margin: 4vh 0 2vh;
+        border-bottom: 1px solid #333;
+        padding-bottom: 5px;
+  }}
+  .panorama_mode .album-section-title {{
+        writing-mode: vertical-lr;
+        transform: rotate(180deg);
+        margin: 0;
+  }}
+
+  .album-grid {{
+        display: flex;
+        gap: 15px;
+  }}
+  .standard_mode .album-grid {{
+        display: grid;
+        grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
+  }}
+  .panorama_mode .album-grid {{
+        flex: 1;
+        flex-direction: row;
+        justify-content: flex-start;
+  }}
+  .album-card {{
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        width: 80px;
+  }}
+  .album-thumb {{
+        width: 100%;
+        aspect-ratio: 1/1;
+        border-radius: 6px;
+        object-fit: cover;
+        background: #333;
+  }}
+  .album-name {{
+        font-size: 0.6rem;
+        color: var(--text-main);
+        margin-top: 5px;
+        text-align: center;
+        width: 100%;
+/*        white-space: nowrap;
+        overflow: hidden;
+        text-overflow: ellipsis; */
+  }}
 </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>
-""")
+<div class="kiosk-container {layout_class}">
+    <!-- Photo Section -->
+    <div class="photo-pane">
+        <img src="/kiosk/mfs/current.jpeg" alt="Current kiosk photo">
+    </div>
+
+    <!-- Metadata Section -->
+    <div class="info-pane">
+        <div class="description">
+            {descr}
+        </div>
+        <div class="divider"></div>
+        <div class="meta-group">
+            <div class="location">
+                {location}
+            </div>
+            <div class="date">
+                {date.strftime("%B %-d, %Y")}
+            </div>
+        </div>
+        <div class="album-section-title">Appears In</div>
+        <div class="album-grid">
+            {album_html}
+        </div>
+    </div>
+</div>
+                """)
         return True
 
 
 # Test code
-#x = immich_photo_renderer({"Fetch Photos": (60 * 60 * 12),
-#                           "Rotate Current Photo": (1)})
-#x.index_photos()
-#x.choose_photo()
+x = immich_photo_renderer({"Fetch Photos": (60 * 60 * 12),
+                           "Rotate Current Photo": (1)})
+x.index_photos()
+x.choose_photo()