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
if key == "Fetch Photos":
return self.index_photos()
elif key == "Rotate Current Photo":
- return self.choose_photo()
+ result = self.choose_photo()
+ self._cleanup_old_jpegs("/var/www/kiosk/pages/mfs")
+ return result
else:
raise Exception("Unexpected operation")
)
)
print(f"{len(all_assets)} kiosk assets.")
+
+ new_uuids = set()
for lite_asset in all_assets:
asset_uuid = lite_asset.get('id')
if asset_uuid:
- self.candidate_photo_uuids.add(asset_uuid)
+ new_uuids.add(asset_uuid)
+ self.candidate_photo_uuids = new_uuids
return True
@staticmethod
text
).strip()
-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
-
- # --- Fetch ALL network data first, before touching disk ---
- try:
- jpeg = self.immich_cli.get_asset_thumbnail(selected_uuid, size="fullsize")
- except Exception:
- print("Failed to get image!")
- return False
+ def _cleanup_old_jpegs(self, directory: str, max_age_seconds: int = 300) -> None:
+ now = time.time()
+ with os.scandir(directory) as entries:
+ for entry in entries:
+ if entry.name.endswith('.jpeg') and entry.is_file():
+ if now - entry.stat().st_mtime > max_age_seconds:
+ try:
+ os.remove(entry.path)
+ except FileNotFoundError:
+ pass
+
+ 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
+
+ # --- Fetch ALL network data first, before touching disk ---
+ try:
+ jpeg = self.immich_cli.get_asset_thumbnail(selected_uuid, size="fullsize")
+ except Exception:
+ print("Failed to get image!")
+ return False
- try:
- albums = self.immich_cli.get_asset_albums(selected_uuid)
- except Exception:
- print("Failed to get albums!")
- albums = []
-
- album_covers = {}
- for album in albums:
- album_cover_uuid = album.get('albumThumbnailAssetId')
- if album_cover_uuid:
- try:
- album_covers[album_cover_uuid] = self.immich_cli.get_asset_thumbnail(
- album_cover_uuid, size="thumbnail"
- )
- except Exception:
- print(f"Failed to get album cover for {album_cover_uuid}, skipping.")
-
- # --- All network calls done. Now compute derived values. ---
- layout_class = get_layout_class(asset)
- exif = asset.get('exifInfo', {})
- date = None
- location = "Unknown location"
- descr = ''
- if exif:
- date = parse_exif_date(exif)
- 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:
- location = f"{location}, {state}" if location else state
- if country:
- location = f"{location}, {country}" if location else country
- if lat and lon:
- location = f"{location} @ {format_coords(lat, lon)}" if location else format_coords(lat, lon)
- descr = exif.get("description", "")
-
- if not descr:
- people = asset.get('people', [])
- if people:
- descr = "A photo of " + natural_join([p.get("name", "") for p in people])
- else:
- descr = "No description data available. #needs description"
-
- descr = self._highlight_hashtags(descr)
- date_str = date.strftime("%B %-d, %Y") if date else "Unknown date"
- album_html = ''
- for album in albums:
- album_name = album.get('albumName')
- album_cover_uuid = album.get('albumThumbnailAssetId')
- if album_cover_uuid and album_cover_uuid in album_covers:
- 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>
-"""
-
- # --- All data ready. Now write to disk atomically. ---
- with open("/var/www/kiosk/pages/mfs/current.jpeg", "wb") as f:
- f.write(jpeg)
- f.flush()
- os.fsync(f.fileno())
-
- for cover_uuid, cover_data in album_covers.items():
- with open(f"/var/www/kiosk/pages/mfs/{cover_uuid}.jpeg", "wb") as f:
- f.write(cover_data)
-
- cache_buster = int(time.time())
- with file_writer.file_writer("photo_33_3600.html") as f:
- f.write(
- f"""
-<style>
- 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;
- }}
- .panorama_mode .photo-pane {{
- flex: 1 1 auto;
- max-height: 75vh;
- min-height: 0;
- width: 100vw;
- }}
- .photo-pane img {{
- max-width: 100%;
- max-height: 100%;
- object-fit: contain;
- box-shadow: 0 0 50px rgba(0,0,0,0.5);
- }}
- /* --- 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 1 auto;
- min-height: 20vh;
- display: flex;
- align-items: center;
- padding: 2vh 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.5;
- font-size: clamp(1.2rem, 2.8vh, 2.5rem);
- overflow: visible;
- margin: 0;
- padding-right: 2vw;
- min-width: 0;
- }}
- .divider {{
- background: var(--accent);
- }}
- .standard_mode .divider {{
- height: 2px;
- width: 40px;
- margin: 2vh 0;
- }}
- .panorama_mode .divider {{
- width: 2px;
- height: 50px;
- margin: 0;
- }}
- .panorama_mode .meta-group {{
- flex: 0 0 20%;
- display: flex;
- flex-direction: column;
- justify-content: center;
- padding: 0 1.5vw;
- min-width: 0;
- }}
- .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: 10px;
- }}
- .album-grid {{
- display: flex;
- gap: 15px;
- }}
- .standard_mode .album-grid {{
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
- justify-content: flex-end;
- }}
- .panorama_mode .album-grid {{
- flex: 1.3;
- flex-direction: row;
- justify-content: center;
- }}
- .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%;
- word-break: break-word;
- overflow-wrap: break-word;
- display: -webkit-box;
- -webkit-line-clamp: 2;
- -webkit-box-orient: vertical;
- overflow: hidden;
- }}
-</style>
-<div class="kiosk-container {layout_class}">
- <!-- Photo Section -->
- <div class="photo-pane">
- <img src="/kiosk/mfs/current.jpeg?v={cache_buster}" alt="Current kiosk photo">
+ try:
+ albums = self.immich_cli.get_asset_albums(selected_uuid)
+ except Exception:
+ print("Failed to get albums!")
+ albums = []
+
+ album_covers = {}
+ for album in albums:
+ album_cover_uuid = album.get('albumThumbnailAssetId')
+ if album_cover_uuid:
+ try:
+ album_covers[album_cover_uuid] = self.immich_cli.get_asset_thumbnail(
+ album_cover_uuid, size="thumbnail"
+ )
+ except Exception:
+ print(f"Failed to get album cover for {album_cover_uuid}, skipping.")
+
+ # --- All network calls done. Now compute derived values. ---
+ layout_class = get_layout_class(asset)
+ exif = asset.get('exifInfo', {})
+ date = None
+ location = "Unknown location"
+ descr = ''
+ if exif:
+ date = parse_exif_date(exif)
+ 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:
+ location = f"{location}, {state}" if location else state
+ if country:
+ location = f"{location}, {country}" if location else country
+ if lat and lon:
+ location = f"{location} @ {format_coords(lat, lon)}" if location else format_coords(lat, lon)
+ descr = exif.get("description", "")
+
+ if not descr:
+ people = asset.get('people', [])
+ if people:
+ descr = "A photo of " + natural_join([p.get("name", "") for p in people])
+ else:
+ descr = "No description data available. #needs description"
+
+ descr = self._highlight_hashtags(descr)
+ date_str = date.strftime("%B %-d, %Y") if date else "Unknown date"
+ album_html = ''
+ for album in albums:
+ album_name = album.get('albumName')
+ album_cover_uuid = album.get('albumThumbnailAssetId')
+ if album_cover_uuid and album_cover_uuid in album_covers:
+ 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>
- <!-- Metadata Section -->
- <div class="info-pane">
- <div class="description">
- {descr}
+ """
+
+ # --- All data ready. Now write to disk atomically. ---
+ with open(f"/var/www/kiosk/pages/mfs/{selected_uuid}.jpeg", "wb") as f:
+ f.write(jpeg)
+ f.flush()
+ os.fsync(f.fileno())
+
+ for cover_uuid, cover_data in album_covers.items():
+ with open(f"/var/www/kiosk/pages/mfs/{cover_uuid}.jpeg", "wb") as f:
+ f.write(cover_data)
+
+ cache_buster = int(time.time())
+ with file_writer.file_writer("photo_33_3600.html") as f:
+ f.write(
+ f"""
+ <style>
+ 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;
+ }}
+ .panorama_mode .photo-pane {{
+ flex: 1 1 auto;
+ max-height: 75vh;
+ min-height: 0;
+ width: 100vw;
+ }}
+ .photo-pane img {{
+ max-width: 100%;
+ max-height: 100%;
+ object-fit: contain;
+ box-shadow: 0 0 50px rgba(0,0,0,0.5);
+ }}
+ /* --- 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 1 auto;
+ min-height: 20vh;
+ display: flex;
+ align-items: center;
+ padding: 2vh 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.5;
+ font-size: clamp(1.2rem, 2.8vh, 2.5rem);
+ overflow: visible;
+ margin: 0;
+ padding-right: 2vw;
+ min-width: 0;
+ }}
+ .divider {{
+ background: var(--accent);
+ }}
+ .standard_mode .divider {{
+ height: 2px;
+ width: 40px;
+ margin: 2vh 0;
+ }}
+ .panorama_mode .divider {{
+ width: 2px;
+ height: 50px;
+ margin: 0;
+ }}
+ .panorama_mode .meta-group {{
+ flex: 0 0 20%;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ padding: 0 1.5vw;
+ min-width: 0;
+ }}
+ .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: 10px;
+ }}
+ .album-grid {{
+ display: flex;
+ gap: 15px;
+ }}
+ .standard_mode .album-grid {{
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
+ justify-content: flex-end;
+ }}
+ .panorama_mode .album-grid {{
+ flex: 1.3;
+ flex-direction: row;
+ justify-content: center;
+ }}
+ .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%;
+ word-break: break-word;
+ overflow-wrap: break-word;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ }}
+ </style>
+ <div class="kiosk-container {layout_class}">
+ <!-- Photo Section -->
+ <div class="photo-pane">
+ <img src="/kiosk/mfs/{selected_uuid}.jpeg?v={cache_buster}" alt="Current kiosk photo">
</div>
- <div class="divider"></div>
- <div class="meta-group">
- <div class="location">
- {location}
+ <!-- Metadata Section -->
+ <div class="info-pane">
+ <div class="description">
+ {descr}
</div>
- <div class="date">
- {date_str}
+ <div class="divider"></div>
+ <div class="meta-group">
+ <div class="location">
+ {location}
+ </div>
+ <div class="date">
+ {date_str}
+ </div>
+ </div>
+ <div class="album-section-title">Appears In</div>
+ <div class="album-grid">
+ {album_html}
</div>
- </div>
- <div class="album-section-title">Appears In</div>
- <div class="album-grid">
- {album_html}
</div>
</div>
-</div>
- """)
- return True
+ """)
+ return True
# Test code
x = immich_photo_renderer({"Fetch Photos": (60 * 60 * 12),