From 23939cccdd7379c32f648fe23184c6d5db965085 Mon Sep 17 00:00:00 2001 From: Arnaud_Cayrol Date: Mon, 7 Apr 2025 20:23:09 +0200 Subject: [PATCH] Add api cred validation, cancel button, ffmpeg timelapse and date range --- main.py | 98 +++++++++++++--- templates/index.html | 274 +++++++++++++++++++++++++++++++++++++------ timelapse.py | 73 +++++++++++- 3 files changed, 392 insertions(+), 53 deletions(-) diff --git a/main.py b/main.py index d794aeb..3bbb68a 100644 --- a/main.py +++ b/main.py @@ -1,8 +1,9 @@ import os import multiprocessing import threading -from flask import Flask, request, render_template, jsonify -from timelapse import process_faces, ProcessConfig +import subprocess +from flask import Flask, request, render_template, jsonify, redirect, url_for +from timelapse import process_faces, ProcessConfig, validate_immich_connection app = Flask(__name__) @@ -20,6 +21,8 @@ AVAILABLE_CORES = multiprocessing.cpu_count() # Global progress dictionary – only one job at a time is assumed here progress_info = {"completed": 0, "total": 0, "status": "idle"} +# Global processing thread reference +processing_thread = None def update_progress(current, total): @@ -35,7 +38,8 @@ def update_progress(current, total): progress_info["status"] = "running" if current < total else "done" -def background_process(person_id, padding_percent, resize_size, face_resolution_threshold, pose_threshold, max_workers): +def background_process(person_id, padding_percent, resize_size, face_resolution_threshold, pose_threshold, max_workers, + date_from, date_to, compile_video, framerate): """ Background process that creates a configuration object and calls process_faces. @@ -46,6 +50,10 @@ def background_process(person_id, padding_percent, resize_size, face_resolution_ face_resolution_threshold (int): Minimum required face resolution. pose_threshold (float): Maximum allowed head pose deviation. max_workers (int): Number of concurrent worker processes. + date_from (str): Start date for asset filtering. + date_to (str): End date for asset filtering. + compile_video (bool): Whether to compile the images into a video. + framerate (int): Frames per second for the output video. """ try: progress_info["status"] = "running" @@ -65,11 +73,31 @@ def background_process(person_id, padding_percent, resize_size, face_resolution_ face_detect_model_path=FACE_DETECT_MODEL, landmark_model_path=LANDMARK_MODEL ) - process_faces(config, max_workers=max_workers, progress_callback=update_progress) + processed_files = process_faces(config, max_workers=max_workers, progress_callback=update_progress, + date_from=date_from, date_to=date_to) + + # Compile video if requested and there are processed files + if compile_video and processed_files: + output_video = os.path.join(OUTPUT_FOLDER, "timelapse.mp4") + ffmpeg_command = [ + "ffmpeg", "-y", + "-framerate", str(framerate), + "-pattern_type", "glob", + "-i", os.path.join(OUTPUT_FOLDER, "*.jpg"), + "-c:v", "libx264", + "-pix_fmt", "yuv420p", + output_video + ] + try: + subprocess.run(ffmpeg_command, check=True) + progress_info["video_path"] = output_video + progress_info["status"] = "video_done" + except subprocess.CalledProcessError as e: + progress_info["status"] = f"Video compilation failed: {e}" + else: + progress_info["status"] = "done" except Exception as e: progress_info["status"] = f"error: {e}" - else: - progress_info["status"] = "done" @app.route("/progress") @@ -80,15 +108,45 @@ def progress(): return jsonify(progress_info) +@app.route("/check-connection") +def check_connection(): + """ + Endpoint to check the Immich server connection. + """ + is_valid, message = validate_immich_connection(API_KEY, BASE_URL) + return jsonify({"valid": is_valid, "message": message}) + + +@app.route("/cancel", methods=["POST"]) +def cancel(): + """ + Endpoint to cancel the current processing job. + """ + global processing_thread + if processing_thread and processing_thread.is_alive(): + # We can't truly kill a thread in Python, but we can signal it to stop + progress_info["status"] = "cancelled" + return jsonify({"success": True, "message": "Processing cancelled."}) + else: + return jsonify({"success": False, "message": "No active processing to cancel."}) + + @app.route("/", methods=["GET", "POST"]) def index(): """ Index route that displays the form and starts processing in a background thread on POST. """ + global processing_thread + result = None error = None - # Create max_workers_options as a list from 1 to AVAILABLE_CORES - max_workers_options = list(range(1, AVAILABLE_CORES + 1)) + + # Check if Immich server connection is valid + is_valid, message = validate_immich_connection(API_KEY, BASE_URL) + if not is_valid and request.method == "POST": + error = f"Immich server connection error: {message}" + return render_template("index.html", error=error, max_workers_options=list(range(1, AVAILABLE_CORES + 1))) + if request.method == "POST": try: person_id = request.form["person_id"] @@ -96,23 +154,35 @@ def index(): resize_size = int(request.form.get("resize_size", 512)) face_resolution_threshold = int(request.form.get("face_resolution_threshold", 128)) pose_threshold = float(request.form.get("pose_threshold", 25)) - max_workers = int(request.form.get("max_workers", 1)) # default is 1 + max_workers = int(request.form.get("max_workers", 1)) + + # Date ranges are optional + date_from = request.form.get("date_from") or None + date_to = request.form.get("date_to") or None + + compile_video = request.form.get("compile_video") == "on" + framerate = int(request.form.get("framerate", 24)) # Reset progress info before starting progress_info["completed"] = 0 progress_info["total"] = 0 progress_info["status"] = "idle" + progress_info.pop("video_path", None) # Start the processing in a background thread - threading.Thread( + processing_thread = threading.Thread( target=background_process, - args=(person_id, padding_percent, resize_size, face_resolution_threshold, pose_threshold, max_workers) - ).start() + args=(person_id, padding_percent, resize_size, face_resolution_threshold, pose_threshold, max_workers, + date_from, date_to, compile_video, framerate) + ) + processing_thread.start() result = "Processing started. Please wait and watch the progress bar below." except Exception as e: error = f"Error processing request: {e}" - return render_template("index.html", result=result, error=error, max_workers_options=max_workers_options) + + return render_template("index.html", result=result, error=error, + max_workers_options=list(range(1, AVAILABLE_CORES + 1))) if __name__ == "__main__": - app.run(host="0.0.0.0", port=5000) + app.run(host="0.0.0.0", port=5000) \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index 9e9d241..b950195 100644 --- a/templates/index.html +++ b/templates/index.html @@ -22,9 +22,26 @@ text-align: center; color: #333; } + .connection-status { + text-align: center; + padding: 10px; + margin: 15px 0; + border-radius: 4px; + } + .connected { + background-color: #d4edda; + color: #155724; + } + .disconnected { + background-color: #f8d7da; + color: #721c24; + } form { margin-top: 20px; } + .form-group { + margin-bottom: 15px; + } label { display: block; margin-bottom: 10px; @@ -32,6 +49,7 @@ } input[type="text"], input[type="number"], + input[type="date"], select { width: calc(100% - 20px); padding: 8px 10px; @@ -40,7 +58,16 @@ border-radius: 4px; font-size: 14px; } - button { + .checkbox-container { + display: flex; + align-items: center; + margin-top: 10px; + } + .checkbox-container label { + margin-left: 10px; + margin-bottom: 0; + } + .button-primary { background: #28a745; color: #fff; padding: 10px 20px; @@ -51,9 +78,27 @@ display: block; margin: 20px auto 0; } - button:hover { + .button-primary:hover { background: #218838; } + .button-primary:disabled { + background: #6c757d; + cursor: not-allowed; + } + .button-danger { + background: #dc3545; + color: #fff; + padding: 10px 20px; + font-size: 16px; + border: none; + border-radius: 4px; + cursor: pointer; + display: block; + margin: 10px auto 0; + } + .button-danger:hover { + background: #c82333; + } .result, .error { text-align: center; margin-top: 20px; @@ -73,44 +118,114 @@ width: 100%; height: 25px; } + .section-header { + margin-top: 25px; + border-bottom: 1px solid #eee; + padding-bottom: 5px; + font-weight: bold; + } + .flex-row { + display: flex; + gap: 15px; + } + .flex-row > div { + flex: 1; + } + .hidden { + display: none; + }

Immich Selfie Timelapse Tool

+ + +
+ Checking Immich server connection... +
+ {% if error %}

{{ error }}

{% endif %} -
- - - - - - - + + +
Person Selection
+
+ +
+ +
Date Range (Optional)
+
+
+ +
+
+ +
+
+ +
Face Processing Settings
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ +
Video Output
+
+
+ + +
+
+ + + +
{% if result %}

{{ result }}

@@ -119,26 +234,115 @@

0%

+
- + \ No newline at end of file diff --git a/timelapse.py b/timelapse.py index 372b88f..666b8f0 100644 --- a/timelapse.py +++ b/timelapse.py @@ -55,6 +55,44 @@ class ProcessConfig: landmark_model_path: str = "shape_predictor_68_face_landmarks.dat" +def validate_immich_connection(api_key, base_url): + """ + Validates that the provided Immich API key and base URL are working. + + Args: + api_key (str): API key for authentication. + base_url (str): Base URL of the API. + + Returns: + tuple: (bool, str) - (is_valid, error_message) + """ + if not api_key or not base_url: + return False, "API key and base URL are required." + + try: + headers = { + 'Accept': 'application/json', + 'x-api-key': api_key, + } + # Try a simple ping to the server via the user endpoint + url = f"{base_url}/server/about" + response = requests.get(url, headers=headers, timeout=5) + + if response.status_code == 200: + return True, "Connection successful." + elif response.status_code == 401: + return False, "Authentication failed. Invalid API key." + else: + return False, f"Server error: Status code {response.status_code}" + + except requests.exceptions.ConnectionError: + return False, "Connection error. Check the base URL." + except requests.exceptions.Timeout: + return False, "Connection timed out. Server might be down." + except Exception as e: + return False, f"Unexpected error: {str(e)}" + + def initialize_worker(face_detect_model_path, landmark_model_path): """ Initializes the face detector and predictor in each worker process. @@ -64,7 +102,7 @@ def initialize_worker(face_detect_model_path, landmark_model_path): face_predictor = dlib.shape_predictor(landmark_model_path) -def get_assets_with_person(api_key, base_url, person_id): +def get_assets_with_person(api_key, base_url, person_id, date_from=None, date_to=None): """ Retrieve all image assets containing the specified person by querying the API. @@ -72,6 +110,8 @@ def get_assets_with_person(api_key, base_url, person_id): api_key (str): API key for authentication. base_url (str): Base URL of the API. person_id (str): ID of the person to search for. + date_from (str, optional): Start date in ISO format (YYYY-MM-DD). + date_to (str, optional): End date in ISO format (YYYY-MM-DD). Returns: list: List of asset dictionaries. @@ -93,6 +133,16 @@ def get_assets_with_person(api_key, base_url, person_id): "withPeople": True, "withStacked": True, } + + # Add date filters if provided + if date_from: + payload["dateFilter"] = payload.get("dateFilter", {}) + payload["dateFilter"]["from"] = f"{date_from}T00:00:00.000Z" + + if date_to: + payload["dateFilter"] = payload.get("dateFilter", {}) + payload["dateFilter"]["to"] = f"{date_to}T23:59:59.999Z" + while payload["page"] is not None: response = requests.post(url, headers=headers, json=payload) if response.status_code != 200: @@ -106,7 +156,6 @@ def get_assets_with_person(api_key, base_url, person_id): payload["page"] = data['assets'].get('nextPage') return all_assets - def download_asset(api_key, base_url, asset_id): """ Downloads the original image asset from the API. @@ -343,7 +392,7 @@ def process_asset_wrapper(args): return process_asset_worker(asset, config) -def process_faces(config: ProcessConfig, max_workers=1, progress_callback=None): +def process_faces(config: ProcessConfig, max_workers=1, progress_callback=None, date_from=None, date_to=None): """ Processes assets containing the person and saves aligned face images. @@ -354,12 +403,21 @@ def process_faces(config: ProcessConfig, max_workers=1, progress_callback=None): config (ProcessConfig): Configuration parameters. max_workers (int): Number of worker processes. progress_callback (callable, optional): A callback function for progress updates. + date_from (str, optional): Start date for filtering assets. + date_to (str, optional): End date for filtering assets. Returns: list: A list of file paths of the saved images. """ os.makedirs(config.output_folder, exist_ok=True) - assets = get_assets_with_person(config.api_key, config.base_url, config.person_id) + + # Check if we need to stop processing + if progress_callback and hasattr(progress_callback, "__self__") and getattr(progress_callback.__self__, "status", + "") == "cancelled": + logger.info("Processing was cancelled.") + return [] + + assets = get_assets_with_person(config.api_key, config.base_url, config.person_id, date_from, date_to) logger.info(f"Found {len(assets)} assets containing the person.") total_assets = len(assets) if progress_callback: @@ -375,6 +433,13 @@ def process_faces(config: ProcessConfig, max_workers=1, progress_callback=None): # Pack arguments for each asset tasks = ((asset, config) for asset in assets) for result in tqdm(executor.map(process_asset_wrapper, tasks), total=total_assets): + # Check if we need to stop processing + if progress_callback and hasattr(progress_callback, "__self__") and getattr(progress_callback.__self__, + "status", "") == "cancelled": + logger.info("Processing was cancelled.") + executor.shutdown(wait=False) + break + if result is not None: processed_files.append(result) if progress_callback: