From b8df4605b42d9a61bb4ae4731efabbdc38166063 Mon Sep 17 00:00:00 2001 From: Tim Keller Date: Mon, 16 Jun 2025 21:50:38 -0500 Subject: add config and add application thread manager --- .gitignore | 1 + flaskapi.py | 41 +++++++++++++++--------- immich.py | 20 ++++++++---- lazycachelist.py | 5 +-- manager.py | 97 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pix.py | 41 ++++++------------------ settings.py | 39 +++++++++++++++++++++++ window.py | 18 ++++++----- 8 files changed, 199 insertions(+), 63 deletions(-) create mode 100644 manager.py create mode 100644 settings.py diff --git a/.gitignore b/.gitignore index cf1238c..4197899 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ __pycache__ *.jpg +*.json diff --git a/flaskapi.py b/flaskapi.py index a537192..45616ec 100644 --- a/flaskapi.py +++ b/flaskapi.py @@ -2,8 +2,14 @@ from flask import Flask, Blueprint, request, send_from_directory, send_file, abo from flask_socketio import SocketIO, emit from flask_cors import CORS +from manager import PixMan + + app = Flask(__name__, static_folder="static/dist", static_url_path="/") -socketio = SocketIO(app, cors_allowed_origins="*") # TODO remove later +api = Blueprint("api", __name__) +socketio = SocketIO(app, cors_allowed_origins="*") # NOTE debug +CORS(api, origins="*") # NOTE debug + @app.route("/") @app.route("/slideshow") @@ -12,44 +18,49 @@ socketio = SocketIO(app, cors_allowed_origins="*") # TODO remove later def home(): return send_from_directory("static/public", "index.html") -api = Blueprint("api", __name__) -CORS(api, origins="*") # For debugging TODO remove later -#@api.route("/seek") @socketio.on("seek") def seek(increment): - pd = app.config["pix_display"] - - pd.queue.put(lambda: pd.seek(increment)) - while not pd.queue.empty(): + if not (display := PixMan().display): + return {} + display.queue.put(lambda: display.seek(increment)) + while not display.queue.empty(): pass - return { - "imageTime": pd.image_time, - "imageIndex": pd.current_texture_index, - } + return { "imageIndex": display.current_texture_index } + @api.route("/albums") def get_albums(): - ic = app.config["immich_connector"] + if not (ic := PixMan().immich_connector): + return {} keys = [ "albumName", "albumThumbnailAssetId", "id", "startDate", "endDate", "assetCount", "shared", ] return [{ key: album[key] for key in keys } for album in ic.load_all_albums() ] + @api.route("/asset//thumbnail", defaults={ "size": "thumbnail" }) @api.route("/asset/", defaults={ "size": "preview" }) def get_asset(key, size): + if not (ic := PixMan().immich_connector): + return {} # TODO ensure getting actual album thumb - ic = app.config["immich_connector"] image_data, mimetype = ic.load_image(key, size=size) if image_data is None: abort(400) return send_file(image_data, mimetype=mimetype) + @api.route("/redirect/") def immich_redirect(path): - ic = app.config["immich_connector"] + if not (ic := PixMan().immich_connector): + return {} return redirect(f"{ic.server_url}/{path}") +@api.route("/config/update", methods=["POST"]) +def config_update(): + return { "success": PixMan().update_config(request.json) } + + app.register_blueprint(api, url_prefix="/api") diff --git a/immich.py b/immich.py index e73b34e..ad39942 100644 --- a/immich.py +++ b/immich.py @@ -3,34 +3,38 @@ from io import BytesIO from queue import Queue from texture import ImageTexture +from manager import PixMan class ImmichConnector: - def __init__(self, server_url, api_key): - self.server_url = server_url.removesuffix("/") - self.api_key = api_key + def __init__(self): + config = PixMan().config + self.server_url = config.immich_url.removesuffix("/") + self.api_key = config.immich_api_key self.texture_load_queue = Queue() def _request(self, endpoint): + if not self.server_url: + return None return requests.get(f"{self.server_url}/api/{endpoint}", headers={ "x-api-key": self.api_key }) def load_all_albums(self): response = self._request("albums") - if response.status_code != 200: return + if not response or response.status_code != 200: return data = response.json() return data def load_album_assets(self, key): response = self._request(f"albums/{key}") - if response.status_code != 200: return + if not response or response.status_code != 200: return data = response.json() return data["assets"] def load_image(self, key, size="preview"): response = self._request(f"assets/{key}/thumbnail?size={size}") - if response.status_code != 200: return None, None + if not response or response.status_code != 200: return None, None image_data = BytesIO(response.content) mimetype = response.headers.get("Content-Type") @@ -47,3 +51,7 @@ class ImmichConnector: continue # Texture was never loaded so it doesn't need to be free'd image_data, _ = self.load_image(image_texture.asset_key, size) image_texture.initialize(image_data) + + def validate_connection(self): + # TODO + return True diff --git a/lazycachelist.py b/lazycachelist.py index b08b85d..3b2744d 100644 --- a/lazycachelist.py +++ b/lazycachelist.py @@ -1,6 +1,7 @@ from dataclasses import dataclass, asdict from texture import ImageTextureImmichAsset +from manager import PixMan @dataclass class Album: @@ -34,8 +35,8 @@ class CallbackStateData: class LazyCachingTextureList(): - def __init__(self, immich_connector, album_ids, max_cache_items=100, change_callback=None): - self.immich_connector = immich_connector + def __init__(self, album_ids, max_cache_items=100, change_callback=None): + self.immich_connector = PixMan().immich_connector assert max_cache_items >= 20, "Minimum cache items is 20" # Double small radius # Ring buffer diff --git a/manager.py b/manager.py new file mode 100644 index 0000000..a7d8ebd --- /dev/null +++ b/manager.py @@ -0,0 +1,97 @@ +import os +import sys +import signal +from threading import Thread +from OpenGL.GLUT import glutLeaveMainLoop + + +class PixMan: + _instance = None + _initialized = False + + @staticmethod + def handle_sigint(sig, frame): + try: + # Threads are started as daemons so will automatically shut down + glutLeaveMainLoop() + sys.exit(0) + except: + pass + finally: + print("Exiting...") + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super().__new__(cls) + else: + assert not args and not kwargs, f"Singleton {cls.__name__} cannot be called with arguments more than once" + return cls._instance + + def __init__(self): + pass + + @classmethod + def initialize(cls, configfile, app, socketio, host, port): + assert not cls._initialized, "Already initialized" + if cls._initialized: + return + cls._initialized = True + self = cls() + + self.app = app + self.socketio = socketio + self.immich_connector = None + self.texture_list = None + self.display = None + self.t_flask = None + self.t_idle_download = None + + signal.signal(signal.SIGINT, PixMan.handle_sigint) + + self.configfile = configfile + config = Config.load(self.configfile) if os.path.exists(self.configfile) else Config(file=self.configfile) + + self.init_web(host, port) + self.update_config(config) + + def init_web(self, host, port): + self.t_flask = Thread(target=self.app.run, kwargs={ "host": host, "port": port }) + self.t_flask.start() + + def init_window(self): + # Initialize immich connector + self.immich_connector = ImmichConnector() + if not self.immich_connector.validate_connection(): + self.immich_connector = None + return + + # Initialize texture list + change_callback = lambda d: self.socketio.emit("seek", d) + album_keys = [ "38617851-6b57-44f1-b5f7-82577606afc4" ] + self.texture_list = LazyCachingTextureList(album_keys, max_cache_items=self.config.max_cache_assets, change_callback=change_callback) + + # Begin downloading images + self.t_idle_download = Thread(target=self.immich_connector.idle, daemon=True) + self.t_idle_download.start() + + # Create display + self.display = PixDisplay(self.texture_list) + self.display.main({}) # TODO glut args + + def update_config(self, config): + #old_config = self.config + self.config = config + + # Initialize window if immich parameters are valid + if config.immich_url and config.immich_api_key and not self.display: + self.init_window() + + # If all goes well + config.save(self.configfile) + return True + + +from lazycachelist import LazyCachingTextureList +from window import PixDisplay +from immich import ImmichConnector +from settings import Config diff --git a/pix.py b/pix.py index 45e3f70..236d2f1 100644 --- a/pix.py +++ b/pix.py @@ -1,38 +1,15 @@ -import sys -import signal -from threading import Thread -from OpenGL.GLUT import glutLeaveMainLoop +import argparse -from lazycachelist import LazyCachingTextureList -from window import PixDisplay -from immich import ImmichConnector from flaskapi import app, socketio - - -def handle_sigint(sig, frame): - try: - glutLeaveMainLoop() - sys.exit(0) - except: - pass - finally: - print("Exiting on Ctrl+C") +from settings import Config +from manager import PixMan if __name__ == "__main__": - immich_connector = ImmichConnector("http://192.168.1.13", "m5nqOoBc4uhAba21gZdCP3z8D3JT4GPxDXL2psd52EA") - album_keys = [ "38617851-6b57-44f1-b5f7-82577606afc4" ] - lazy_texture_list = LazyCachingTextureList(immich_connector, album_keys, 30, lambda d: socketio.emit("seek", d)) - pd = PixDisplay(lazy_texture_list) - - t1 = Thread(target=immich_connector.idle, daemon=True) - t1.start() - - app.config["pix_display"] = pd - app.config["immich_connector"] = immich_connector - app.config["textures"] = lazy_texture_list - flask_thread = Thread(target=app.run, daemon=True, kwargs={ "port": 5000 }) - flask_thread.start() + p = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter) + p.add_argument("--config", type=str, help="set config file path", default="config.json") + p.add_argument("--host", type=str, help="set web interface host", default="0.0.0.0") + p.add_argument("--port", type=int, help="set web interface port", default=80) + args = p.parse_args() - signal.signal(signal.SIGINT, handle_sigint) - pd.main(sys.argv) + PixMan.initialize(args.config, app, socketio, args.host, args.port) diff --git a/settings.py b/settings.py new file mode 100644 index 0000000..43cb024 --- /dev/null +++ b/settings.py @@ -0,0 +1,39 @@ +from dataclasses import dataclass, asdict, field +import json + + +@dataclass +class AlbumList: + name: str + album_keys: list[str] + + +@dataclass +class Config: + # Immich server + immich_url: str = "" + immich_api_key: str = "" + # Display + image_duration: float = 10.0 + transition_duration: float = 0.5 + max_framerate: float = 30.0 + auto_transition: bool = True + display_size: str = "preview" # 'fullsize', 'preview', 'thumbnail' + # Cache + max_cache_assets: int = 100 + # Albums data + album_lists: list[AlbumList] = field(default_factory=list) + album_list_selected: int = None + + def __post_init__(self): + self.album_lists = [ AlbumList(*a) for a in self.album_lists ] + + @classmethod + def load(cls, filepath): + with open(filepath, "r") as fp: + return cls(**json.load(fp)) + + def save(self, filepath): + data = asdict(self) + with open(filepath, "w") as fp: + json.dump(data, fp, indent=2) diff --git a/window.py b/window.py index 1523f85..f422381 100644 --- a/window.py +++ b/window.py @@ -5,7 +5,7 @@ from time import time from queue import Queue from renderer import ImageRenderer, TransitionMix - +from manager import PixMan class PixDisplay: def __init__(self, textures): @@ -18,13 +18,14 @@ class PixDisplay: self.renderer = None self.win_w = 0 self.win_h = 0 - self.max_framerate = 30 - self.image_duration = 2.0 - self.transition_duration = 0.5 - self.auto_transition = True - self.transition_reverse = False + config = PixMan().config + self.max_framerate = config.max_framerate + self.image_duration = config.image_duration + self.transition_duration = config.transition_duration + self.auto_transition = config.auto_transition + self.transition_reverse = False self.text_prev = None self.tex = None @@ -33,12 +34,13 @@ class PixDisplay: @property def max_framerate(self): - return self._max_fps + #return int(1000/int(1000/self.frame_time)) + return self.frame_time @max_framerate.setter def max_framerate(self, max_fps): self._max_fps = max_fps # This is just for the getter since otherwise e.g. int(1000/int(1000/60)) would round to 62 - self.frame_time = int(1000 / 60) # In ms + self.frame_time = int(1000 / max_fps) # In ms def increment_texture_index(self, increment): self.transition_reverse = increment < 0 -- cgit v1.2.3