import os
import random
import re
-from typing import Dict, List, Set
+from typing import Any, Dict, List, Optional, Set
from scottutilz import immich
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(
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:
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", "")
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()