Fix race condition?
authorScott Gasch <[email protected]>
Fri, 15 May 2026 16:48:18 +0000 (09:48 -0700)
committerScott Gasch <[email protected]>
Fri, 15 May 2026 16:48:18 +0000 (09:48 -0700)
immich_photo_renderer.py

index 1f2fea4773ba321538e2fa38b48dedb4b7b903a7..2c18773dce6bfff85ff6c3c3b44adfae6e183751 100644 (file)
@@ -60,7 +60,6 @@ def parse_exif_date(exif_data: dict[str, Any]) -> Optional[datetime.datetime]:
     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
@@ -108,7 +107,9 @@ class immich_photo_renderer(renderer.abstaining_renderer):
         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")
 
@@ -122,10 +123,13 @@ class immich_photo_renderer(renderer.abstaining_renderer):
             )
         )
         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
@@ -141,319 +145,330 @@ class immich_photo_renderer(renderer.abstaining_renderer):
             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),