├── logs ├── log_app.txt ├── slideshow_stderr.log └── slideshow_stdout.log ├── text_drawer.py ├── utils ├── babel.cfg ├── __pycache__ │ ├── auth.cpython-311.pyc │ ├── auth.cpython-39.pyc │ ├── exif.cpython-311.pyc │ ├── config.cpython-311.pyc │ ├── config.cpython-39.pyc │ ├── pan_zoom.cpython-39.pyc │ ├── text_drawer.cpython-311.pyc │ ├── archive_manager.cpython-39.pyc │ ├── download_album.cpython-311.pyc │ ├── download_album.cpython-39.pyc │ ├── image_filters.cpython-311.pyc │ ├── import_samba.cpython-311.pyc │ ├── wifi_manager.cpython-311.pyc │ ├── archive_manager.cpython-311.pyc │ ├── import_usb_photos.cpython-311.pyc │ ├── prepare_all_photos.cpython-311.pyc │ └── slideshow_manager.cpython-311.pyc ├── auth.py ├── config.py ├── exif.py ├── expand_filesystem.sh ├── playlist_manager.py ├── text_drawer.py ├── display_message.py ├── archive_manager.py ├── auth_manager.py ├── network_manager.py ├── create_initial_user.py ├── translate_po.py ├── voice_control_manager.py ├── wifi_manager.py ├── import os.py ├── config_manager.py ├── download_album.py ├── display_manager.py ├── import_samba.py ├── slideshow_manager.py ├── import_usb_photos.py ├── telegram_bot.py ├── prepare_all_photos.py └── image_filters.py ├── static ├── logo.png ├── favicon.ico ├── gotenash.jpg ├── background.png ├── logo_gadgeto.png ├── pimmich_logo.png ├── backgroundnold.png ├── backgroundold.png ├── icons │ ├── envelope.png │ ├── thumbtack_red.png │ └── thumbtack_yellow.png ├── stamps │ ├── stamp1.png │ ├── stamp2.png │ ├── stamp3.png │ └── stamp4.png ├── sounds │ ├── listening.wav │ ├── command_next.wav │ ├── command_play.wav │ ├── notification.wav │ ├── command_pause.wav │ ├── command_sleep.wav │ ├── command_wakeup.wav │ ├── not_understood.wav │ ├── command_playlist.wav │ ├── command_previous.wav │ ├── command_shutdown.wav │ ├── command_back_to_main.wav │ ├── command_show_postcards.wav │ └── command_source_toggle.wav ├── weather_icons │ ├── 01d.png │ ├── 01n.png │ ├── 02d.png │ ├── 03d.png │ ├── 04d.png │ ├── 04n.png │ ├── 10d.png │ └── README.md ├── fonts │ └── Caveat-Regular.ttf ├── backgrounds │ └── cork_background.jpg └── styles.css ├── procedure_trad.pdf ├── babel.cfg ├── config ├── import_status.json └── config.json ├── translations ├── en │ └── LC_MESSAGES │ │ └── messages.mo ├── es │ └── LC_MESSAGES │ │ └── messages.mo └── fr │ └── LC_MESSAGES │ └── messages.mo ├── voice_models └── cadre-magique_raspberry-pi.ppn ├── .gitignore ├── requirements.txt ├── LICENSE ├── update_script.sh ├── templates ├── slideshow.html ├── upload.html └── login.html ├── start_pimmich.sh ├── procedure_trad.md ├── import time.py ├── FAQ_en.md ├── README_en.md ├── FAQ.md ├── setup.sh └── README.md /logs/log_app.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /text_drawer.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /utils/babel.cfg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /logs/slideshow_stderr.log: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /logs/slideshow_stdout.log: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/static/logo.png -------------------------------------------------------------------------------- /procedure_trad.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/procedure_trad.pdf -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/static/favicon.ico -------------------------------------------------------------------------------- /babel.cfg: -------------------------------------------------------------------------------- 1 | [python: **.py] 2 | [jinja2: **/templates/**.html] 3 | extensions=jinja2.ext.i18n -------------------------------------------------------------------------------- /static/gotenash.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/static/gotenash.jpg -------------------------------------------------------------------------------- /static/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/static/background.png -------------------------------------------------------------------------------- /static/logo_gadgeto.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/static/logo_gadgeto.png -------------------------------------------------------------------------------- /static/pimmich_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/static/pimmich_logo.png -------------------------------------------------------------------------------- /static/backgroundnold.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/static/backgroundnold.png -------------------------------------------------------------------------------- /static/backgroundold.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/static/backgroundold.png -------------------------------------------------------------------------------- /static/icons/envelope.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/static/icons/envelope.png -------------------------------------------------------------------------------- /static/stamps/stamp1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/static/stamps/stamp1.png -------------------------------------------------------------------------------- /static/stamps/stamp2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/static/stamps/stamp2.png -------------------------------------------------------------------------------- /static/stamps/stamp3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/static/stamps/stamp3.png -------------------------------------------------------------------------------- /static/stamps/stamp4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/static/stamps/stamp4.png -------------------------------------------------------------------------------- /static/sounds/listening.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/static/sounds/listening.wav -------------------------------------------------------------------------------- /static/weather_icons/01d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/static/weather_icons/01d.png -------------------------------------------------------------------------------- /static/weather_icons/01n.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/static/weather_icons/01n.png -------------------------------------------------------------------------------- /static/weather_icons/02d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/static/weather_icons/02d.png -------------------------------------------------------------------------------- /static/weather_icons/03d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/static/weather_icons/03d.png -------------------------------------------------------------------------------- /static/weather_icons/04d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/static/weather_icons/04d.png -------------------------------------------------------------------------------- /static/weather_icons/04n.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/static/weather_icons/04n.png -------------------------------------------------------------------------------- /static/weather_icons/10d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/static/weather_icons/10d.png -------------------------------------------------------------------------------- /static/icons/thumbtack_red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/static/icons/thumbtack_red.png -------------------------------------------------------------------------------- /static/sounds/command_next.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/static/sounds/command_next.wav -------------------------------------------------------------------------------- /static/sounds/command_play.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/static/sounds/command_play.wav -------------------------------------------------------------------------------- /static/sounds/notification.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/static/sounds/notification.wav -------------------------------------------------------------------------------- /config/import_status.json: -------------------------------------------------------------------------------- 1 | {"progress": 100, "status": "Import et pr\u00e9paration termin\u00e9s avec succ\u00e8s."} -------------------------------------------------------------------------------- /static/fonts/Caveat-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/static/fonts/Caveat-Regular.ttf -------------------------------------------------------------------------------- /static/icons/thumbtack_yellow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/static/icons/thumbtack_yellow.png -------------------------------------------------------------------------------- /static/sounds/command_pause.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/static/sounds/command_pause.wav -------------------------------------------------------------------------------- /static/sounds/command_sleep.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/static/sounds/command_sleep.wav -------------------------------------------------------------------------------- /static/sounds/command_wakeup.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/static/sounds/command_wakeup.wav -------------------------------------------------------------------------------- /static/sounds/not_understood.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/static/sounds/not_understood.wav -------------------------------------------------------------------------------- /static/sounds/command_playlist.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/static/sounds/command_playlist.wav -------------------------------------------------------------------------------- /static/sounds/command_previous.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/static/sounds/command_previous.wav -------------------------------------------------------------------------------- /static/sounds/command_shutdown.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/static/sounds/command_shutdown.wav -------------------------------------------------------------------------------- /static/backgrounds/cork_background.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/static/backgrounds/cork_background.jpg -------------------------------------------------------------------------------- /static/sounds/command_back_to_main.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/static/sounds/command_back_to_main.wav -------------------------------------------------------------------------------- /utils/__pycache__/auth.cpython-311.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/utils/__pycache__/auth.cpython-311.pyc -------------------------------------------------------------------------------- /utils/__pycache__/auth.cpython-39.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/utils/__pycache__/auth.cpython-39.pyc -------------------------------------------------------------------------------- /utils/__pycache__/exif.cpython-311.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/utils/__pycache__/exif.cpython-311.pyc -------------------------------------------------------------------------------- /static/sounds/command_show_postcards.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/static/sounds/command_show_postcards.wav -------------------------------------------------------------------------------- /static/sounds/command_source_toggle.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/static/sounds/command_source_toggle.wav -------------------------------------------------------------------------------- /translations/en/LC_MESSAGES/messages.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/translations/en/LC_MESSAGES/messages.mo -------------------------------------------------------------------------------- /translations/es/LC_MESSAGES/messages.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/translations/es/LC_MESSAGES/messages.mo -------------------------------------------------------------------------------- /translations/fr/LC_MESSAGES/messages.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/translations/fr/LC_MESSAGES/messages.mo -------------------------------------------------------------------------------- /utils/__pycache__/config.cpython-311.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/utils/__pycache__/config.cpython-311.pyc -------------------------------------------------------------------------------- /utils/__pycache__/config.cpython-39.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/utils/__pycache__/config.cpython-39.pyc -------------------------------------------------------------------------------- /utils/__pycache__/pan_zoom.cpython-39.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/utils/__pycache__/pan_zoom.cpython-39.pyc -------------------------------------------------------------------------------- /voice_models/cadre-magique_raspberry-pi.ppn: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/voice_models/cadre-magique_raspberry-pi.ppn -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | static/.backups/immich/100_0247.jpg 2 | static/.backups/immich/100_0550.jpg 3 | static/.backups/immich/IMG_20200705_135052.jpg 4 | -------------------------------------------------------------------------------- /utils/__pycache__/text_drawer.cpython-311.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/utils/__pycache__/text_drawer.cpython-311.pyc -------------------------------------------------------------------------------- /utils/__pycache__/archive_manager.cpython-39.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/utils/__pycache__/archive_manager.cpython-39.pyc -------------------------------------------------------------------------------- /utils/__pycache__/download_album.cpython-311.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/utils/__pycache__/download_album.cpython-311.pyc -------------------------------------------------------------------------------- /utils/__pycache__/download_album.cpython-39.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/utils/__pycache__/download_album.cpython-39.pyc -------------------------------------------------------------------------------- /utils/__pycache__/image_filters.cpython-311.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/utils/__pycache__/image_filters.cpython-311.pyc -------------------------------------------------------------------------------- /utils/__pycache__/import_samba.cpython-311.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/utils/__pycache__/import_samba.cpython-311.pyc -------------------------------------------------------------------------------- /utils/__pycache__/wifi_manager.cpython-311.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/utils/__pycache__/wifi_manager.cpython-311.pyc -------------------------------------------------------------------------------- /utils/__pycache__/archive_manager.cpython-311.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/utils/__pycache__/archive_manager.cpython-311.pyc -------------------------------------------------------------------------------- /utils/__pycache__/import_usb_photos.cpython-311.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/utils/__pycache__/import_usb_photos.cpython-311.pyc -------------------------------------------------------------------------------- /utils/__pycache__/prepare_all_photos.cpython-311.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/utils/__pycache__/prepare_all_photos.cpython-311.pyc -------------------------------------------------------------------------------- /utils/__pycache__/slideshow_manager.cpython-311.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotenash/Pimmich/HEAD/utils/__pycache__/slideshow_manager.cpython-311.pyc -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==2.3.2 2 | requests==2.31.0 3 | Pillow==10.1.0 4 | pygame 5 | psutil 6 | pillow-heif 7 | smbprotocol 8 | werkzeug 9 | Flask-Babel 10 | piexif 11 | qrcode[pil] 12 | pvporcupine 13 | vosk 14 | sounddevice 15 | numpy 16 | resampy 17 | thefuzz 18 | python-Levenshtein 19 | deep-translator 20 | polib -------------------------------------------------------------------------------- /utils/auth.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from flask import session, redirect, url_for 3 | 4 | def login_required(f): 5 | @wraps(f) 6 | def decorated_function(*args, **kwargs): 7 | if not session.get('logged_in'): 8 | return redirect(url_for('login')) 9 | return f(*args, **kwargs) 10 | return decorated_function 11 | -------------------------------------------------------------------------------- /utils/config.py: -------------------------------------------------------------------------------- 1 | # utils/config.py 2 | 3 | import json 4 | import os 5 | 6 | CONFIG_PATH = os.path.join("config", "config.json") 7 | 8 | def load_config(): 9 | if not os.path.exists(CONFIG_PATH): 10 | return {} 11 | with open(CONFIG_PATH, "r") as f: 12 | return json.load(f) 13 | 14 | def get_album_id_by_name(name): 15 | # Cette fonction n’est plus utilisée si tu récupères directement les albums dans `download_album.py`. 16 | return None 17 | -------------------------------------------------------------------------------- /utils/exif.py: -------------------------------------------------------------------------------- 1 | from PIL import Image, ExifTags 2 | 3 | def get_rotation_angle(image): 4 | try: 5 | exif = image._getexif() 6 | if not exif: 7 | return 0 8 | 9 | for tag, value in exif.items(): 10 | decoded = ExifTags.TAGS.get(tag, tag) 11 | if decoded == "Orientation": 12 | if value == 3: 13 | return 180 14 | elif value == 6: 15 | return 270 16 | elif value == 8: 17 | return 90 18 | return 0 19 | except Exception as e: 20 | print(f"Erreur lors de la lecture EXIF : {e}") 21 | return 0 22 | -------------------------------------------------------------------------------- /utils/expand_filesystem.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Ce script est conçu pour être exécuté par l'application web Pimmich. 4 | # Il utilise l'outil raspi-config pour étendre la partition racine afin 5 | # qu'elle utilise tout l'espace disponible sur la carte SD. 6 | 7 | set -e # Arrête le script si une commande échoue 8 | 9 | echo "STEP:START:--- Lancement de l'extension du système de fichiers ---" 10 | echo "STEP:INFO:Utilisation de raspi-config pour étendre la partition racine..." 11 | sudo raspi-config --expand-rootfs 12 | echo "STEP:DONE:La modification a été programmée. L'extension sera finalisée au prochain redémarrage du système. Veuillez redémarrer le Raspberry Pi depuis l'interface pour appliquer les changements." -------------------------------------------------------------------------------- /utils/playlist_manager.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | from pathlib import Path 4 | 5 | BASE_DIR = Path(__file__).resolve().parent.parent 6 | PLAYLISTS_PATH = BASE_DIR / 'config' / 'playlists.json' 7 | 8 | def load_playlists(): 9 | """Charge la liste des playlists depuis le fichier JSON.""" 10 | if not PLAYLISTS_PATH.exists(): 11 | return [] 12 | try: 13 | with open(PLAYLISTS_PATH, 'r', encoding='utf-8') as f: 14 | return json.load(f) 15 | except (json.JSONDecodeError, IOError): 16 | return [] 17 | 18 | def save_playlists(playlists_data): 19 | """Sauvegarde la liste complète des playlists dans le fichier JSON.""" 20 | with open(PLAYLISTS_PATH, 'w', encoding='utf-8') as f: 21 | json.dump(playlists_data, f, indent=2) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 gotenash 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /utils/text_drawer.py: -------------------------------------------------------------------------------- 1 | import pygame 2 | 3 | def draw_text_with_outline(screen, text, font, text_color, outline_color, pos, anchor="center"): 4 | """ 5 | Dessine du texte sur une surface Pygame avec un contour pour une meilleure lisibilité. 6 | 7 | Args: 8 | screen (pygame.Surface): La surface sur laquelle dessiner. 9 | text (str): Le texte à afficher. 10 | font (pygame.font.Font): La police à utiliser. 11 | text_color (tuple): La couleur du texte (R, G, B). 12 | outline_color (tuple): La couleur du contour (R, G, B). 13 | pos (tuple): La position (x, y) du texte. 14 | anchor (str): Le point d'ancrage pour la position ('center', 'topleft', etc.). 15 | """ 16 | # Rendu des surfaces une seule fois 17 | text_surface = font.render(text, True, text_color) 18 | outline_surface = font.render(text, True, outline_color) 19 | 20 | # Obtenir le rectangle pour le positionnement 21 | text_rect = text_surface.get_rect(**{anchor: pos}) 22 | 23 | # Dessiner le contour en décalant la surface 24 | offsets = [(-2, -2), (-2, 2), (2, -2), (2, 2)] 25 | for ox, oy in offsets: 26 | screen.blit(outline_surface, text_rect.move(ox, oy)) 27 | 28 | # Dessiner le texte principal 29 | screen.blit(text_surface, text_rect) -------------------------------------------------------------------------------- /update_script.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Ce script est exécuté par l'application web pour se mettre à jour. 3 | # Il ne doit pas redémarrer de service lui-même, car l'application web 4 | # se chargera de se terminer pour être relancée par son superviseur (systemd, etc.). 5 | 6 | set -e # Arrête le script si une commande échoue 7 | 8 | # Se placer dans le répertoire racine de Pimmich 9 | cd "$(dirname "$0")" 10 | 11 | echo "STEP:PULL:--- Étape 1/2: Téléchargement des mises à jour (git) ---" 12 | # Utilise une méthode robuste qui évite les blocages dus aux conflits locaux. 13 | git fetch --all 14 | git reset --hard origin/main 15 | 16 | echo "STEP:PIP:--- Étape 2/2: Mise à jour des dépendances Python ---" 17 | source venv/bin/activate 18 | 19 | # Rediriger la sortie de pip vers un fichier de log pour le débogage 20 | # et vérifier le code de retour manuellement pour ne pas bloquer le redémarrage. 21 | if ! pip install -r requirements.txt > logs/update_pip.log 2>&1; then 22 | echo "STEP:WARNING:--- AVERTISSEMENT: La mise à jour des dépendances a échoué. ---" 23 | echo "STEP:WARNING:L'application va quand même redémarrer, mais pourrait être instable." 24 | echo "STEP:WARNING:Consultez le fichier 'logs/update_pip.log' depuis l'onglet Système pour les détails." 25 | fi 26 | 27 | echo "STEP:RESTART:Mise à jour des fichiers terminée. L'application va redémarrer." -------------------------------------------------------------------------------- /templates/slideshow.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Diaporama - Pimmich 5 | 21 | 22 | 23 | Diaporama 24 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /utils/display_message.py: -------------------------------------------------------------------------------- 1 | import pygame 2 | import sys 3 | import os 4 | 5 | def draw_text(surface, text, font, color, center_pos): 6 | """Dessine du texte centré sur une position donnée.""" 7 | text_surface = font.render(text, True, color) 8 | text_rect = text_surface.get_rect(center=center_pos) 9 | surface.blit(text_surface, text_rect) 10 | 11 | def main(): 12 | """Affiche un message centré sur l'écran pendant un court instant.""" 13 | if len(sys.argv) < 2: 14 | print("Usage: python display_message.py \"Votre message\"") 15 | return 16 | 17 | message = sys.argv[1] 18 | 19 | # Assurer que l'environnement d'affichage est correctement configuré 20 | if "SWAYSOCK" not in os.environ: 21 | # Logique de fallback si la variable n'est pas définie 22 | # (peut être nécessaire si appelé depuis un contexte inattendu) 23 | user_id = os.getuid() 24 | sock_path_pattern = f"/run/user/{user_id}/sway-ipc.*" 25 | import glob 26 | socks = glob.glob(sock_path_pattern) 27 | if socks: 28 | os.environ["SWAYSOCK"] = socks[0] 29 | 30 | pygame.init() 31 | 32 | info = pygame.display.Info() 33 | screen = pygame.display.set_mode((info.current_w, info.current_h), pygame.FULLSCREEN) 34 | screen.fill((0, 0, 0)) # Fond noir 35 | 36 | font = pygame.font.Font(None, 60) # Police par défaut, taille 60 37 | draw_text(screen, message, font, (255, 255, 255), screen.get_rect().center) 38 | pygame.display.flip() 39 | 40 | if __name__ == "__main__": 41 | main() -------------------------------------------------------------------------------- /static/weather_icons/README.md: -------------------------------------------------------------------------------- 1 | # Icônes Météo pour Pimmich 2 | 3 | Ce dossier est destiné à contenir les icônes météo utilisées par le diaporama. 4 | 5 | ## Comment obtenir les icônes ? 6 | 7 | Pimmich utilise les codes d'icônes de l'API OpenWeatherMap. Vous devez télécharger les images correspondantes et les placer dans ce dossier. 8 | 9 | 1. **Rendez-vous sur la page des conditions météorologiques d'OpenWeatherMap :** 10 | [https://openweathermap.org/weather-conditions](https://openweathermap.org/weather-conditions) 11 | 12 | 2. **Téléchargez les icônes.** Pour chaque code d'icône (par exemple, `01d`, `10n`), vous pouvez télécharger l'image correspondante. Le format de l'URL est : 13 | `https://openweathermap.org/img/wn/CODE@2x.png` 14 | 15 | Remplacez `CODE` par le code de l'icône. Par exemple, pour `10d` (pluie de jour), l'URL est : 16 | https://openweathermap.org/img/wn/10d@2x.png 17 | 18 | 3. **Enregistrez les images dans ce dossier** (`static/weather_icons/`) en les nommant d'après leur code, suivi de `.png`. 19 | - `10d@2x.png` doit être renommé en `10d.png`. 20 | - `01n@2x.png` doit être renommé en `01n.png`. 21 | - etc. 22 | 23 | ### Liste des icônes courantes à télécharger : 24 | 25 | * `01d.png` (ciel dégagé, jour) & `01n.png` (nuit) 26 | * `02d.png` (quelques nuages, jour) & `02n.png` (nuit) 27 | * `03d.png` & `03n.png` (nuages épars) 28 | * `04d.png` & `04n.png` (nuages fragmentés) 29 | * `09d.png` & `09n.png` (averse de pluie) 30 | * `10d.png` (pluie, jour) & `10n.png` (nuit) 31 | * `11d.png` & `11n.png` (orage) 32 | * `13d.png` & `13n.png` (neige) 33 | * `50d.png` & `50n.png` (brouillard) 34 | 35 | Téléchargez au minimum celles que vous rencontrez le plus souvent dans votre région. Si une icône est manquante, seul le texte s'affichera. -------------------------------------------------------------------------------- /config/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "photo_source": "immich", 3 | "display_duration": 7, 4 | "active_start": "07:00", 5 | "active_end": "23:45", 6 | "screen_height_percent": "70", 7 | "immich_url": "", 8 | "immich_token": "", 9 | "album_name": "", 10 | "display_width": 1280, 11 | "display_height": 1024, 12 | "pan_zoom_factor": 1.15, 13 | "immich_auto_update": true, 14 | "immich_update_interval_hours": 6, 15 | "pan_zoom_enabled": false, 16 | "transition_enabled": true, 17 | "transition_type": "fade", 18 | "transition_duration": 0.5, 19 | "smb_host": "", 20 | "smb_share": "", 21 | "smb_user": "", 22 | "smb_password": "", 23 | "smb_path": "/", 24 | "smb_auto_update": true, 25 | "smb_update_interval_hours": 6, 26 | "display_sources": [ 27 | "immich", 28 | "usb", 29 | "samba" 30 | ], 31 | "show_clock": true, 32 | "clock_format": "%H:%M", 33 | "clock_color": "#ffffff", 34 | "clock_outline_color": "#000000", 35 | "clock_font_size": 23, 36 | "clock_font_path": "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 37 | "clock_offset_x": 90, 38 | "clock_offset_y": 140, 39 | "clock_background_enabled": true, 40 | "clock_background_color": "#00000080", 41 | "show_date": true, 42 | "date_format": "%d/%m/%Y", 43 | "show_weather": true, 44 | "weather_api_key": "", 45 | "weather_city": "", 46 | "wifi_ssid": "", 47 | "wifi_password": "", 48 | "weather_units": "metric", 49 | "skip_initial_auto_import": "on", 50 | "show_tides": true, 51 | "tide_latitude": "", 52 | "tide_longitude": "", 53 | "stormglass_api_key": "", 54 | "weather_update_interval_minutes": 60, 55 | "info_display_duration": 5, 56 | "clock_position": "left" 57 | } -------------------------------------------------------------------------------- /utils/archive_manager.py: -------------------------------------------------------------------------------- 1 | import os 2 | import zipfile 3 | import requests 4 | 5 | def download_album_archive(server_url, api_key, asset_ids, zip_path): 6 | url = f"{server_url}/api/download/archive" 7 | headers = { 8 | "x-api-key": api_key, 9 | "Content-Type": "application/json" 10 | } 11 | data = {"assetIds": asset_ids} 12 | 13 | # Utilisation du streaming pour éviter de charger toute l'archive en mémoire. 14 | # Ajout d'un timeout généreux car la création de l'archive peut être longue. 15 | try: 16 | response = requests.post(url, json=data, headers=headers, stream=True, timeout=(10, 300)) # 10s connect, 5min read 17 | 18 | if response.status_code != 200: 19 | print(f"Erreur Immich API: {response.status_code} - {response.text}") 20 | return False 21 | 22 | # Écrire le contenu dans le fichier par morceaux (chunks) 23 | with open(zip_path, "wb") as f: 24 | for chunk in response.iter_content(chunk_size=8192): 25 | f.write(chunk) 26 | return True 27 | except requests.exceptions.RequestException as e: 28 | print(f"Erreur réseau lors du téléchargement de l'archive : {e}") 29 | return False 30 | 31 | def unzip_archive(zip_path, extract_to): 32 | try: 33 | with zipfile.ZipFile(zip_path, 'r') as zip_ref: 34 | zip_ref.extractall(extract_to) 35 | print("Archive extraite avec succes.") 36 | except Exception as e: 37 | print(f"Erreur lors de l'extraction : {e}") 38 | 39 | def clean_archive(zip_path): 40 | try: 41 | if os.path.exists(zip_path): 42 | os.remove(zip_path) 43 | print("Archive supprimee apres extraction.") 44 | except Exception as e: 45 | print(f"Erreur lors de la suppression de l'archive : {e}") 46 | -------------------------------------------------------------------------------- /utils/auth_manager.py: -------------------------------------------------------------------------------- 1 | import json 2 | import subprocess 3 | from werkzeug.security import generate_password_hash 4 | 5 | CREDENTIALS_PATH = '/boot/firmware/credentials.json' 6 | 7 | def change_password(new_password: str): 8 | """ 9 | Met à jour le mot de passe dans le fichier credentials.json en le hachant. 10 | Nécessite des droits sudo pour écrire dans /boot/firmware/. 11 | """ 12 | if not new_password: 13 | raise ValueError("Le nouveau mot de passe ne peut pas être vide.") 14 | 15 | try: 16 | # Lire le contenu actuel pour conserver le nom d'utilisateur 17 | with open(CREDENTIALS_PATH, 'r') as f: 18 | credentials = json.load(f) 19 | except (FileNotFoundError, json.JSONDecodeError) as e: 20 | # Si le fichier n'existe pas ou est corrompu, on en crée un nouveau avec l'utilisateur par défaut 'admin' 21 | print(f"Avertissement: Impossible de lire {CREDENTIALS_PATH} ({e}). Un nouveau sera créé.") 22 | credentials = {'username': 'admin'} 23 | 24 | # Mettre à jour le mot de passe avec un hash sécurisé 25 | credentials['password_hash'] = generate_password_hash(new_password) 26 | # Supprimer l'ancien mot de passe en clair s'il existe 27 | credentials.pop('password', None) 28 | 29 | # Préparer le contenu à écrire 30 | new_content = json.dumps(credentials, indent=2) 31 | 32 | # Utiliser 'sudo tee' pour écrire le fichier avec les permissions root 33 | try: 34 | subprocess.run( 35 | ['sudo', '/usr/bin/tee', CREDENTIALS_PATH], 36 | input=new_content, 37 | text=True, 38 | capture_output=True, 39 | check=True, 40 | timeout=10 41 | ) 42 | print(f"Mot de passe mis à jour avec succès dans {CREDENTIALS_PATH}") 43 | except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired) as e: 44 | error_message = f"Impossible d'écrire dans {CREDENTIALS_PATH}: {e}" 45 | print(error_message) 46 | raise Exception(error_message) -------------------------------------------------------------------------------- /utils/network_manager.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import re 3 | 4 | def get_interface_status(interface_name: str): 5 | """ 6 | Récupère l'état (UP/DOWN) et l'adresse IP d'une interface réseau. 7 | """ 8 | status = {"name": interface_name, "state": "DOWN", "ip_address": "N/A"} 9 | try: 10 | # Commande pour obtenir les détails de l'interface 11 | ip_addr_output = subprocess.check_output( 12 | ['/usr/bin/ip', 'addr', 'show', interface_name], 13 | text=True, 14 | stderr=subprocess.DEVNULL 15 | ).strip() 16 | 17 | if "state UP" in ip_addr_output: 18 | status["state"] = "UP" 19 | 20 | # Extraire l'adresse IP si elle existe 21 | ip_match = re.search(r"inet (\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})", ip_addr_output) 22 | if ip_match: 23 | status["ip_address"] = ip_match.group(1) 24 | 25 | except (subprocess.CalledProcessError, FileNotFoundError): 26 | # L'interface n'existe probablement pas ou la commande a échoué 27 | status["state"] = "NOT_FOUND" 28 | 29 | return status 30 | 31 | def set_interface_state(interface_name: str, state: str): 32 | """ 33 | Active ('connect') ou désactive ('disconnect') une interface réseau via NetworkManager. 34 | """ 35 | # 'up' devient 'connect', 'down' devient 'disconnect' pour nmcli 36 | action = 'connect' if state == 'up' else 'disconnect' 37 | 38 | try: 39 | # Utiliser nmcli pour gérer l'état de l'interface 40 | subprocess.run( 41 | ['sudo', '/usr/bin/nmcli', 'device', action, interface_name], 42 | check=True, 43 | capture_output=True, 44 | text=True, 45 | timeout=10 46 | ) 47 | print(f"Interface {interface_name} passée à l'état '{action}'.") 48 | except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired) as e: 49 | stderr = getattr(e, 'stderr', str(e)) 50 | error_message = f"Impossible de changer l'état de l'interface {interface_name}: {stderr}" 51 | print(error_message) 52 | raise Exception(error_message) -------------------------------------------------------------------------------- /start_pimmich.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Ce script est le point d'entrée principal de Pimmich, géré par Sway. 4 | # Il assure qu'une seule instance de l'application est en cours et gère les redémarrages. 5 | 6 | # Code de sortie spécial pour demander un redémarrage 7 | RESTART_CODE=42 8 | 9 | # Se placer dans le répertoire du script pour que les chemins relatifs fonctionnent 10 | cd "$(dirname "$0")" || exit 1 11 | 12 | cleanup() { 13 | echo "[start_pimmich] Nettoyage des processus Pimmich existants..." 14 | # Tuer le processus de contrôle vocal s'il est en cours 15 | if [ -f /tmp/pimmich_voice_control.pid ]; then 16 | pkill -F /tmp/pimmich_voice_control.pid || true 17 | rm -f /tmp/pimmich_voice_control.pid 18 | fi 19 | # Tuer le processus du diaporama s'il est en cours 20 | if [ -f /tmp/pimmich_slideshow.pid ]; then 21 | pkill -F /tmp/pimmich_slideshow.pid || true 22 | rm -f /tmp/pimmich_slideshow.pid 23 | fi 24 | # Tuer toute instance précédente de l'application Flask (app.py) 25 | # Utilise pgrep pour trouver le PID et le tuer, plus sûr que pkill -f 26 | PID_TO_KILL=$(pgrep -f "python3 app.py") 27 | if [ -n "$PID_TO_KILL" ]; then 28 | echo "[start_pimmich] Ancien processus app.py trouvé (PID: $PID_TO_KILL). Arrêt..." 29 | kill "$PID_TO_KILL" 30 | fi 31 | sleep 1 # Laisser le temps aux processus de se terminer 32 | } 33 | 34 | while true; do 35 | # Nettoyer avant chaque lancement 36 | cleanup 37 | 38 | echo "[start_pimmich] Lancement de l'application Pimmich..." 39 | # Activer l'environnement virtuel et lancer l'application 40 | # Rediriger la sortie vers le fichier de log, en l'écrasant à chaque nouveau démarrage 41 | # pour éviter qu'il ne grossisse indéfiniment. 42 | source venv/bin/activate 43 | python3 -u app.py > logs/log_app.txt 2>&1 44 | 45 | exit_code=$? 46 | echo "[start_pimmich] L'application s'est terminée avec le code de sortie : $exit_code" >> logs/log_app.txt 47 | 48 | if [ $exit_code -ne $RESTART_CODE ]; then 49 | echo "[start_pimmich] Code de sortie non-redémarrage. Arrêt du script." >> logs/log_app.txt 50 | break # Sortir de la boucle si ce n'est pas un redémarrage demandé 51 | fi 52 | 53 | echo "[start_pimmich] Redémarrage demandé. Relance de l'application dans 2 secondes..." >> logs/log_app.txt 54 | sleep 2 55 | done 56 | -------------------------------------------------------------------------------- /utils/create_initial_user.py: -------------------------------------------------------------------------------- 1 | import json 2 | import secrets 3 | from werkzeug.security import generate_password_hash 4 | import argparse 5 | import sys 6 | 7 | def generate_random_password(length=12): 8 | """Génère un mot de passe aléatoire sécurisé et facile à taper.""" 9 | # Alphabet sans caractères ambigus (I, l, 1, O, 0) et sans ponctuation pour faciliter la saisie 10 | alphabet = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789" 11 | password = ''.join(secrets.choice(alphabet) for i in range(length)) 12 | return password 13 | 14 | def create_credentials_file(output_path, username="admin"): 15 | """Génère un nouveau mot de passe, le hache et crée le fichier credentials.json.""" 16 | password = generate_random_password() 17 | flask_secret_key = secrets.token_hex(24) # Génère une clé sécurisée de 24 octets pour Flask 18 | 19 | # Hacher le mot de passe en utilisant une méthode sécurisée. 20 | # Werkzeug gère automatiquement le salage (salt). 21 | password_hash = generate_password_hash(password) 22 | 23 | credentials = { 24 | "username": username, 25 | "password_hash": password_hash, 26 | "flask_secret_key": flask_secret_key 27 | } 28 | 29 | try: 30 | with open(output_path, 'w') as f: 31 | json.dump(credentials, f, indent=2) 32 | 33 | # Afficher les identifiants pour que l'utilisateur les note. 34 | # C'est la seule fois où le mot de passe en clair sera affiché. 35 | print("\n" + "="*60, file=sys.stderr) 36 | print("✅ Fichier d'identification sécurisé créé.", file=sys.stderr) 37 | print("\n" + "-"*60, file=sys.stderr) 38 | print("⚠️ NOTEZ CES IDENTIFIANTS, ILS NE SERONT PLUS AFFICHÉS ⚠️", file=sys.stderr) 39 | print(f" Utilisateur : {username}", file=sys.stderr) 40 | print(f" Mot de passe: {password}", file=sys.stderr) 41 | print("="*60 + "\n", file=sys.stderr) 42 | 43 | except Exception as e: 44 | print(f"ERREUR: Impossible de créer le fichier d'identification à '{output_path}'.", file=sys.stderr) 45 | print(f"Détails: {e}", file=sys.stderr) 46 | sys.exit(1) 47 | 48 | if __name__ == "__main__": 49 | parser = argparse.ArgumentParser(description="Crée un fichier credentials.json sécurisé avec un mot de passe aléatoire.") 50 | parser.add_argument( 51 | '--output', 52 | required=True, 53 | help="Chemin complet du fichier credentials.json à créer." 54 | ) 55 | parser.add_argument( 56 | '--username', 57 | default='admin', 58 | help="Nom de l'utilisateur à créer (par défaut: admin)." 59 | ) 60 | 61 | args = parser.parse_args() 62 | 63 | create_credentials_file(args.output, args.username) -------------------------------------------------------------------------------- /utils/translate_po.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import time 3 | import re 4 | from polib import pofile 5 | from deep_translator import GoogleTranslator 6 | 7 | def protect_placeholders(text): 8 | """Remplace les placeholders par des balises non traduisibles.""" 9 | placeholders = re.findall(r'%\([a-zA-Z0-9_]+\)s|%s|%d', text) 10 | protected_text = text 11 | for i, p in enumerate(placeholders): 12 | protected_text = protected_text.replace(p, f'{i}', 1) 13 | return protected_text, placeholders 14 | 15 | def restore_placeholders(text, placeholders): 16 | """Restaure les placeholders originaux.""" 17 | for i, p in enumerate(placeholders): 18 | text = re.sub(r'\s*' + str(i) + r'\s*', p, text, 1) 19 | return text 20 | 21 | def translate_po_file(file_path, target_lang): 22 | """ 23 | Traduit les entrées non traduites dans un fichier .po en utilisant Google Translate. 24 | """ 25 | try: 26 | po = pofile(file_path) 27 | untranslated_entries = [e for e in po if not e.translated()] 28 | 29 | if not untranslated_entries: 30 | print(f"✅ Aucune nouvelle entrée à traduire dans {file_path}.") 31 | return 32 | 33 | print(f"Trouvé {len(untranslated_entries)} entrées à traduire vers '{target_lang}'...") 34 | 35 | for i, entry in enumerate(untranslated_entries): 36 | try: 37 | # Protéger les placeholders 38 | protected_text, placeholders = protect_placeholders(entry.msgid) 39 | 40 | # Traduire le texte protégé 41 | translated_protected_text = GoogleTranslator(source='auto', target=target_lang).translate(protected_text) 42 | 43 | # Restaurer les placeholders 44 | final_translated_text = restore_placeholders(translated_protected_text, placeholders) 45 | 46 | entry.msgstr = final_translated_text 47 | print(f" ({i+1}/{len(untranslated_entries)}) '{entry.msgid[:30]}...' -> '{final_translated_text[:30]}...'") 48 | time.sleep(0.5) # Délai pour respecter l'API 49 | except Exception as e: 50 | print(f" ❌ Impossible de traduire l'entrée : '{entry.msgid}'. Erreur : {e}") 51 | 52 | print(f"Sauvegarde du fichier traduit dans {file_path}...") 53 | po.save() 54 | print("✅ Traduction terminée.") 55 | 56 | except Exception as e: 57 | print(f"Une erreur est survenue : {e}") 58 | sys.exit(1) 59 | 60 | if __name__ == "__main__": 61 | if len(sys.argv) != 3: 62 | print("Usage: python utils/translate_po.py ") 63 | print("Exemple: python utils/translate_po.py translations/es/LC_MESSAGES/messages.po es") 64 | sys.exit(1) 65 | 66 | po_file_path = sys.argv[1] 67 | lang_code = sys.argv[2] 68 | 69 | translate_po_file(po_file_path, lang_code) -------------------------------------------------------------------------------- /utils/voice_control_manager.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import sys 4 | import signal 5 | import psutil 6 | import json 7 | 8 | PID_FILE = "/tmp/pimmich_voice_control.pid" 9 | STATUS_FILE = "logs/voice_control_status.json" 10 | _log_files = {} 11 | 12 | def update_status_file(status_dict): 13 | """Met à jour le fichier de statut JSON.""" 14 | try: 15 | os.makedirs("logs", exist_ok=True) 16 | with open(STATUS_FILE, "w") as f: 17 | json.dump(status_dict, f) 18 | except IOError as e: 19 | # Cette erreur sera visible dans les logs de app.py 20 | print(f"[VoiceManager] Erreur écriture fichier statut : {e}") 21 | 22 | def is_voice_control_running(): 23 | """Vérifie si le processus de contrôle vocal est en cours d'exécution.""" 24 | if not os.path.exists(PID_FILE): 25 | return False 26 | try: 27 | with open(PID_FILE, "r") as f: 28 | pid = int(f.read().strip()) 29 | if not psutil.pid_exists(pid): 30 | return False 31 | p = psutil.Process(pid) 32 | # Vérifie que le processus existe et que la ligne de commande correspond 33 | return p.is_running() and any("voice_control.py" in s for s in p.cmdline()) 34 | except (psutil.NoSuchProcess, FileNotFoundError, ValueError): 35 | return False 36 | 37 | def start_voice_control(): 38 | """Démarre le script de contrôle vocal et redirige sa sortie vers les logs.""" 39 | if is_voice_control_running(): 40 | print("Le contrôle vocal est déjà en cours.") 41 | return 42 | 43 | print("Démarrage du service de contrôle vocal...") 44 | python_executable = sys.executable 45 | # Ajout du flag -u pour un output non bufferisé, crucial pour les logs en temps réel 46 | command = [python_executable, "-u", "voice_control.py"] 47 | 48 | os.makedirs("logs", exist_ok=True) 49 | try: 50 | # Ouvre les fichiers de log en mode 'write' pour effacer les anciens logs à chaque démarrage 51 | stdout_log = open("logs/voice_control_stdout.log", "w") 52 | stderr_log = open("logs/voice_control_stderr.log", "w") 53 | 54 | proc = subprocess.Popen(command, stdout=stdout_log, stderr=stderr_log) 55 | 56 | with open(PID_FILE, "w") as f: 57 | f.write(str(proc.pid)) 58 | 59 | print(f"Service de contrôle vocal démarré avec PID {proc.pid}.") 60 | except Exception as e: 61 | print(f"Erreur lors du démarrage du contrôle vocal : {e}") 62 | 63 | def stop_voice_control(): 64 | """Arrête le processus de contrôle vocal.""" 65 | if not is_voice_control_running(): 66 | return 67 | try: 68 | with open(PID_FILE, "r") as f: 69 | pid = int(f.read().strip()) 70 | p = psutil.Process(pid) 71 | p.terminate() 72 | p.wait(timeout=3) 73 | except (psutil.NoSuchProcess, psutil.TimeoutExpired, FileNotFoundError, ValueError, IOError) as e: 74 | print(f"Avertissement lors de l'arrêt du contrôle vocal : {e}") 75 | finally: 76 | if os.path.exists(PID_FILE): 77 | os.remove(PID_FILE) 78 | if os.path.exists(STATUS_FILE): 79 | os.remove(STATUS_FILE) -------------------------------------------------------------------------------- /procedure_trad.md: -------------------------------------------------------------------------------- 1 | # Procédure de Mise à Jour des Traductions pour Pimmich 2 | 3 | Ce document décrit la procédure complète pour mettre à jour les fichiers de traduction de l'application Pimmich, en utilisant un script personnalisé et la bibliothèque `deep-translator`. 4 | 5 | --- 6 | 7 | ## 1. Prérequis 8 | 9 | Avant de commencer, assurez-vous que les outils suivants sont installés dans votre environnement virtuel. 10 | 11 | ### a. Activer l'environnement virtuel 12 | 13 | Toutes les commandes doivent être lancées depuis le dossier racine du projet (`/home/pi/pimmich`) avec l'environnement virtuel activé : 14 | ```bash 15 | source venv/bin/activate 16 | ``` 17 | 18 | ### b. Installer les outils nécessaires 19 | 20 | Si ce n'est pas déjà fait, installez les bibliothèques nécessaires (qui devraient être incluses dans `requirements.txt`) : 21 | ```bash 22 | pip install deep-translator polib 23 | ``` 24 | 25 | ### c. Fichier de configuration `babel.cfg` 26 | 27 | Assurez-vous que le fichier `babel.cfg` existe à la racine du projet avec le contenu suivant. Ce fichier indique à l'outil où trouver les textes à traduire. 28 | 29 | ```ini 30 | [python: **.py] 31 | [jinja2: **/templates/**.html] 32 | extensions=jinja2.ext.i18n 33 | ``` 34 | 35 | --- 36 | 37 | ## 2. Procédure de Mise à Jour 38 | 39 | Le processus se déroule en 4 étapes principales. 40 | 41 | ### Étape 1 : Extraction des textes à traduire 42 | 43 | Cette commande scanne tous les fichiers `.py` et `.html` du projet, recherche les textes marqués pour la traduction (par exemple, `_('Mon texte')`), et crée/met à jour un fichier modèle `messages.pot`. 44 | 45 | ```bash 46 | pybabel extract -F babel.cfg -o translations/messages.pot . 47 | ``` 48 | **Note :** N'oubliez pas le `.` à la fin de la commande, qui signifie "analyser le dossier courant". 49 | 50 | ### Étape 2 : Mise à jour des fichiers de langue 51 | 52 | Cette commande compare le fichier modèle (`messages.pot`) avec les fichiers de traduction de chaque langue (`.po`) et y ajoute les nouveaux textes à traduire. 53 | 54 | ```bash 55 | pybabel update -i translations/messages.pot -d translations 56 | ``` 57 | 58 | ### Étape 3 : Traduction automatique 59 | 60 | C'est ici que notre script personnalisé `utils/translate_po.py` intervient. Il utilise une bibliothèque fiable pour traduire les textes manquants. Lancez les commandes suivantes pour chaque langue. 61 | 62 | ```bash 63 | # Traduire le fichier anglais 64 | python utils/translate_po.py translations/en/LC_MESSAGES/messages.po en 65 | 66 | # Traduire le fichier espagnol 67 | python utils/translate_po.py translations/es/LC_MESSAGES/messages.po es 68 | ``` 69 | **Conseil :** Après cette étape, il est recommandé d'ouvrir les fichiers `.po` avec un éditeur comme Poedit pour relire rapidement les traductions automatiques et corriger d'éventuels contresens. 70 | 71 | ### Étape 4 : Compilation des traductions 72 | 73 | Cette dernière commande transforme les fichiers texte `.po` (lisibles par l'homme) en fichiers binaires optimisés `.mo` que l'application utilise pour afficher les traductions. 74 | 75 | ```bash 76 | pybabel compile -d translations 77 | ``` 78 | 79 | --- 80 | 81 | Une fois ces étapes terminées, redémarrez l'application Pimmich pour que les nouvelles traductions soient prises en compte. 82 | 83 | --- 84 | 85 | ## 3. Comment convertir ce document en PDF 86 | 87 | * **Avec VS Code :** Installez une extension comme "Markdown PDF" et faites un clic droit sur le fichier `.md` pour l'exporter. 88 | * **En ligne :** Utilisez un site web comme md2pdf.netlify.app en y copiant-collant ce texte. 89 | * **En ligne de commande :** Si vous avez `pandoc` d'installé, utilisez la commande : `pandoc procedure_traduction.md -o procedure.pdf`. 90 | -------------------------------------------------------------------------------- /utils/wifi_manager.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import time 3 | import re 4 | 5 | def get_wifi_status(): 6 | """ 7 | Récupère l'état actuel de la connexion Wi-Fi (SSID et adresse IP). 8 | Retourne un dictionnaire avec les informations. 9 | """ 10 | status = {"ssid": "Non connecté", "ip_address": "N/A", "is_connected": False} 11 | try: 12 | # Utiliser nmcli pour obtenir un statut fiable 13 | cmd = ['/usr/bin/nmcli', '-t', '-f', 'GENERAL.STATE,GENERAL.CONNECTION,IP4.ADDRESS', 'dev', 'show', 'wlan0'] 14 | output = subprocess.check_output(cmd, text=True, stderr=subprocess.DEVNULL).strip() 15 | 16 | lines = output.split('\n') 17 | state_line = lines[0] 18 | conn_line = lines[1] 19 | ip_line = lines[2] 20 | 21 | if '100 (connecté)' in state_line or 'connected' in state_line: 22 | status["is_connected"] = True 23 | # Extraire le SSID 24 | ssid_match = re.search(r'GENERAL.CONNECTION:(.*)', conn_line) 25 | if ssid_match: 26 | status["ssid"] = ssid_match.group(1) 27 | 28 | # Extraire l'IP 29 | ip_match = re.search(r'IP4.ADDRESS\[1\]:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})', ip_line) 30 | if ip_match: 31 | status["ip_address"] = ip_match.group(1) 32 | 33 | except (subprocess.CalledProcessError, FileNotFoundError): 34 | pass # Les commandes échouent si non connecté, le statut par défaut est correct. 35 | return status 36 | 37 | def set_wifi_config(ssid: str, password: str, country_code: str = "FR"): 38 | """ 39 | Configure le pays du Wi-Fi, met à jour les identifiants dans wpa_supplicant.conf, 40 | et se connecte en utilisant nmcli (NetworkManager). 41 | """ 42 | if not country_code: 43 | raise ValueError("Le code pays est obligatoire.") 44 | 45 | # 1. Définir le pays du Wi-Fi via raspi-config 46 | try: 47 | print(f"Définition du pays Wi-Fi sur : {country_code}") 48 | subprocess.run( 49 | ['sudo', '/usr/bin/raspi-config', 'nonint', 'do_wifi_country', country_code], 50 | check=True, capture_output=True, text=True, timeout=15 51 | ) 52 | except subprocess.CalledProcessError as e: 53 | raise Exception(f"Impossible de définir le pays Wi-Fi : {e.stderr}") 54 | except subprocess.TimeoutExpired: 55 | raise Exception("La commande raspi-config a expiré.") 56 | except FileNotFoundError: 57 | raise Exception("La commande 'raspi-config' est introuvable. Ce script est-il exécuté sur un Raspberry Pi OS?") 58 | 59 | # 2. Se connecter en utilisant nmcli 60 | try: 61 | print(f"Tentative de connexion au Wi-Fi '{ssid}' via nmcli...") 62 | 63 | # Supprimer l'ancienne connexion si elle existe pour forcer une nouvelle configuration 64 | # Le `|| true` à la fin évite une erreur si la connexion n'existe pas. 65 | subprocess.run(f"sudo nmcli connection delete '{ssid}' || true", shell=True, capture_output=True) 66 | 67 | # Créer la nouvelle connexion 68 | cmd = ['sudo', '/usr/bin/nmcli', 'device', 'wifi', 'connect', ssid] 69 | if password: 70 | cmd.extend(['password', password]) 71 | 72 | result = subprocess.run(cmd, check=True, capture_output=True, text=True, timeout=30) 73 | print(f"Connexion Wi-Fi réussie : {result.stdout}") 74 | 75 | except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired) as e: 76 | stderr = getattr(e, 'stderr', str(e)) 77 | print(f"Erreur lors de la connexion via nmcli : {stderr}") 78 | raise Exception(f"La connexion Wi-Fi a échoué. Détails: {stderr}") -------------------------------------------------------------------------------- /templates/upload.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ _('Envoyer des photos') }} - Pimmich 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | {% with messages = get_flashed_messages(with_categories=true) %} 14 | {% if messages %} 15 |
16 | {% for category, message in messages %} 17 |
28 | {{ message }} 29 |
30 | {% endfor %} 31 |
32 | 42 | {% endif %} 43 | {% endwith %} 44 | 45 |
46 |
47 | Logo Pimmich 48 |

{{ _('Envoyer des photos pour le cadre') }}

49 |

{{ _('Les photos seront soumises à validation avant d\'être affichées.') }}

50 | 51 |
52 |
53 | 54 |
55 | 56 |
57 | 60 |
61 |
62 | 65 |
66 | {{ _('Langue') }}: 67 | {% set current_lang = get_locale() %} 68 | FR | 69 | EN | 70 | ES 71 |
72 |
73 |
74 | 75 | 76 | -------------------------------------------------------------------------------- /utils/import os.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | from pathlib import Path 4 | from smbclient import register_session, listdir, open_file, remove_session, path as smb_path 5 | from smbprotocol.exceptions import SMBException 6 | 7 | # Destination folder for photos before preparation 8 | PHOTOS_DIR = Path("static/photos") 9 | # Supported image extensions 10 | SUPPORTED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.heic', '.heif'] 11 | 12 | def import_samba_photos(config): 13 | """ 14 | Connects to an SMB share, lists, and copies image files to a local directory. 15 | This is a generator function that yields progress updates. 16 | """ 17 | host = config.get("smb_host") 18 | share = config.get("smb_share") 19 | user = config.get("smb_user") 20 | password = config.get("smb_password") 21 | remote_path_str = config.get("smb_path", "/").strip("/") 22 | 23 | if not all([host, share]): 24 | yield {"type": "error", "message": "Configuration SMB incomplète : Hôte ou Partage manquant."} 25 | return 26 | 27 | # Construct the full UNC path 28 | full_remote_path_unc = f"\\\\{host}\\{share}" 29 | if remote_path_str: 30 | full_remote_path_unc += f"\\{remote_path_str.replace('/', '\\')}" 31 | 32 | yield {"type": "progress", "stage": "CONNECTING", "percent": 5, "message": f"Connexion à {full_remote_path_unc}"} 33 | 34 | session_registered = False 35 | try: 36 | # Register session if credentials are provided 37 | if user and password: 38 | register_session(host, username=user, password=password) 39 | session_registered = True 40 | 41 | if not smb_path.exists(full_remote_path_unc): 42 | yield {"type": "error", "message": f"Le chemin distant est introuvable : {full_remote_path_unc}"} 43 | return 44 | 45 | yield {"type": "progress", "stage": "LISTING", "percent": 10, "message": "Liste des fichiers distants..."} 46 | 47 | remote_files = listdir(full_remote_path_unc) 48 | image_files = [f for f in remote_files if Path(f).suffix.lower() in SUPPORTED_EXTENSIONS and not smb_path.isdir(os.path.join(full_remote_path_unc, f))] 49 | 50 | if not image_files: 51 | yield {"type": "warning", "message": f"Aucune photo trouvée dans le dossier : {full_remote_path_unc}"} 52 | yield {"type": "done", "percent": 100, "message": "Aucune nouvelle photo à importer."} 53 | return 54 | 55 | total_files = len(image_files) 56 | yield {"type": "progress", "stage": "PREPARING_IMPORT", "percent": 20, "message": f"{total_files} photos trouvées. Nettoyage du dossier local..."} 57 | 58 | PHOTOS_DIR.mkdir(exist_ok=True) 59 | for f in PHOTOS_DIR.iterdir(): 60 | if f.is_file(): 61 | f.unlink() 62 | 63 | yield {"type": "progress", "stage": "COPYING", "percent": 20, "message": f"Début de la copie de {total_files} photos..."} 64 | for i, filename in enumerate(image_files, start=1): 65 | remote_file_path = os.path.join(full_remote_path_unc, filename) 66 | local_file_path = PHOTOS_DIR / filename 67 | percent = 20 + int((i / total_files) * 60) 68 | 69 | with open_file(remote_file_path, mode='rb') as remote_f, open(local_file_path, 'wb') as local_f: 70 | shutil.copyfileobj(remote_f, local_f) 71 | yield {"type": "progress", "stage": "COPYING", "percent": percent, "message": f"Copie de {filename} ({i}/{total_files})"} 72 | 73 | yield {"type": "done", "percent": 80, "message": f"Copie terminée. {total_files} photos importées."} 74 | 75 | except SMBException as e: 76 | yield {"type": "error", "message": f"Erreur SMB : {e}"} 77 | except Exception as e: 78 | yield {"type": "error", "message": f"Erreur inattendue : {e}"} 79 | finally: 80 | if session_registered: 81 | try: 82 | remove_session(host) 83 | except Exception: 84 | pass -------------------------------------------------------------------------------- /utils/config_manager.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | CONFIG_PATH = os.path.join(os.path.dirname(__file__), '..', 'config', 'config.json') 5 | 6 | def create_default_config(): 7 | """Crée et retourne un dictionnaire de configuration par défaut. Source unique de vérité.""" 8 | return { 9 | "display_duration": 10, 10 | "active_start": "07:00", 11 | "active_end": "22:00", 12 | "immich_url": "", 13 | "immich_token": "", 14 | "album_name": "", 15 | "display_width": 1920, 16 | "display_height": 1080, 17 | "pan_zoom_factor": 1.15, 18 | "immich_auto_update": False, 19 | "immich_update_interval_hours": 24, 20 | "pan_zoom_enabled": False, 21 | "transition_enabled": True, 22 | "transition_type": "fade", 23 | "transition_duration": 1.0, 24 | "video_audio_enabled": False, 25 | "video_audio_output": "auto", 26 | "video_audio_volume": 100, 27 | "smb_host": "", 28 | "smb_share": "", 29 | "smb_user": "", 30 | "smb_password": "", 31 | "smb_path": "/", 32 | "smb_auto_update": False, 33 | "smb_update_interval_hours": 24, 34 | "display_sources": ["immich"], 35 | "show_clock": True, 36 | "clock_format": "%H:%M", 37 | "clock_color": "#FFFFFF", 38 | "clock_outline_color": "#000000", 39 | "clock_font_size": 72, 40 | "clock_font_path": "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 41 | "clock_offset_x": 0, 42 | "clock_offset_y": 0, 43 | "clock_position": "center", 44 | "clock_background_enabled": False, 45 | "clock_background_color": "#00000080", 46 | "show_date": True, 47 | "date_format": "%A %d %B %Y", 48 | "show_weather": True, 49 | "weather_api_key": "", 50 | "weather_city": "Paris", 51 | "weather_units": "metric", 52 | "weather_update_interval_minutes": 60, 53 | "show_tides": False, 54 | "tide_latitude": "", 55 | "tide_longitude": "", 56 | "stormglass_api_key": "", 57 | "tide_offset_x": 0, 58 | "tide_offset_y": 0, 59 | "wifi_ssid": "", 60 | "wifi_country": "FR", 61 | "wifi_password": "", 62 | "skip_initial_auto_import": False, 63 | "info_display_duration": 5, 64 | "screen_height_percent": 100, 65 | "favorite_boost_factor": 2, 66 | "video_hwdec_enabled": False, 67 | "telegram_bot_enabled": False, 68 | "telegram_bot_token": "", 69 | "telegram_authorized_users": "", 70 | "voice_control_enabled": False, 71 | "voice_control_language": "fr", 72 | "porcupine_access_key": "", 73 | "voice_control_device_index": "", 74 | "notification_sound_volume": 80, # NOUVEAU: Volume pour les sons de notification 75 | } 76 | 77 | def load_config(): 78 | """Charge la configuration depuis le fichier et la fusionne avec les valeurs par défaut.""" 79 | default_config = create_default_config() 80 | 81 | if not os.path.exists(CONFIG_PATH): 82 | # Si le fichier de config n'existe pas, on le crée avec les valeurs par défaut. 83 | save_config(default_config) 84 | return default_config 85 | 86 | try: 87 | with open(CONFIG_PATH, 'r', encoding='utf-8') as f: 88 | user_config = json.load(f) 89 | 90 | # Fusionne la configuration utilisateur avec la configuration par défaut. 91 | # Cela garantit que les nouvelles clés de configuration sont ajoutées 92 | # sans écraser les réglages existants de l'utilisateur. 93 | merged_config = default_config.copy() 94 | merged_config.update(user_config) 95 | return merged_config 96 | except (json.JSONDecodeError, IOError) as e: 97 | # En cas de fichier corrompu ou illisible, on retourne la config par défaut 98 | # pour éviter un crash de l'application. 99 | print(f"Avertissement: Impossible de charger {CONFIG_PATH} ({e}). Utilisation de la configuration par défaut.") 100 | return default_config 101 | 102 | def save_config(config): 103 | """Sauvegarde la configuration dans un fichier JSON.""" 104 | os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True) 105 | with open(CONFIG_PATH, 'w', encoding='utf-8') as f: 106 | json.dump(config, f, indent=4, ensure_ascii=False) -------------------------------------------------------------------------------- /import time.py: -------------------------------------------------------------------------------- 1 | import time 2 | from datetime import datetime, timedelta 3 | import threading 4 | import os 5 | import json 6 | from utils.config import load_config 7 | from utils.download_album import download_and_extract_album 8 | from utils.prepare_all_photos import prepare_all_photos_with_progress 9 | 10 | # Chemin vers un fichier pour stocker les horodatages des dernières mises à jour 11 | LAST_UPDATE_FILE = os.path.join("config", "last_updates.json") 12 | 13 | def read_last_updates(): 14 | """Lit les horodatages des dernières mises à jour depuis un fichier JSON.""" 15 | if not os.path.exists(LAST_UPDATE_FILE): 16 | return {} 17 | try: 18 | with open(LAST_UPDATE_FILE, "r") as f: 19 | return json.load(f) 20 | except (json.JSONDecodeError, IOError): 21 | return {} 22 | 23 | def write_last_update(source_name): 24 | """Écrit l'horodatage actuel pour une source donnée.""" 25 | updates = read_last_updates() 26 | updates[source_name] = datetime.now().isoformat() 27 | try: 28 | with open(LAST_UPDATE_FILE, "w") as f: 29 | json.dump(updates, f, indent=2) 30 | except IOError as e: 31 | print(f"[Updater] Erreur lors de l'écriture de l'heure de mise à jour : {e}") 32 | 33 | def immich_update_task(): 34 | """Vérifie si une mise à jour Immich est nécessaire et la lance.""" 35 | print("[Updater] Vérification des mises à jour Immich...") 36 | config = load_config() 37 | 38 | if not config.get("immich_auto_update", False): 39 | return 40 | 41 | interval_hours = int(config.get("immich_update_interval_hours", 24)) 42 | last_updates = read_last_updates() 43 | last_update_str = last_updates.get("immich") 44 | 45 | if last_update_str: 46 | last_update_time = datetime.fromisoformat(last_update_str) 47 | if datetime.now() < last_update_time + timedelta(hours=interval_hours): 48 | return 49 | 50 | print("[Updater] Mise à jour pour Immich nécessaire. Lancement...") 51 | try: 52 | download_generator = download_and_extract_album(config) 53 | download_complete = False 54 | for update in download_generator: 55 | print(f"[Updater] Immich Download: {update.get('message')}") 56 | if update.get('type') == 'error': 57 | print(f"[Updater] Erreur pendant le téléchargement Immich : {update.get('message')}") 58 | return 59 | if update.get('stage') == 'DOWNLOAD_COMPLETE': 60 | download_complete = True 61 | 62 | if not download_complete: 63 | return 64 | 65 | print("[Updater] Lancement de la préparation des photos pour Immich...") 66 | prepare_generator = prepare_all_photos_with_progress(source_type="immich") 67 | for update in prepare_generator: 68 | print(f"[Updater] Préparation Photo: {update.get('message')}") 69 | if update.get('type') == 'error': 70 | print(f"[Updater] Erreur pendant la préparation des photos : {update.get('message')}") 71 | return 72 | 73 | print("[Updater] Mise à jour et préparation Immich terminées.") 74 | write_last_update("immich") 75 | 76 | except Exception as e: 77 | print(f"[Updater] Erreur inattendue pendant la mise à jour Immich : {e}") 78 | 79 | def smb_update_task(): 80 | """Placeholder pour la logique de mise à jour SMB.""" 81 | config = load_config() 82 | if config.get("smb_auto_update", False): 83 | print("[Updater] La logique de mise à jour auto pour SMB n'est pas encore implémentée.") 84 | 85 | def background_updater_loop(): 86 | """Boucle principale pour le thread de mise à jour.""" 87 | check_interval_seconds = 3600 # Vérifier toutes les heures 88 | 89 | while True: 90 | print("\n[Updater] Lancement des vérifications périodiques...") 91 | 92 | immich_update_task() 93 | smb_update_task() 94 | 95 | print(f"[Updater] Vérifications terminées. Prochaine vérification dans {check_interval_seconds / 3600} heure(s).") 96 | time.sleep(check_interval_seconds) 97 | 98 | def start_background_updater(): 99 | """Démarre le processus de mise à jour en arrière-plan dans un thread séparé.""" 100 | updater_thread = threading.Thread(target=background_updater_loop, daemon=True) 101 | updater_thread.start() 102 | print("Le thread de mise à jour en arrière-plan a démarré.") -------------------------------------------------------------------------------- /FAQ_en.md: -------------------------------------------------------------------------------- 1 | # ❓ Frequently Asked Questions (FAQ) - Pimmich 2 | 3 | Here is a list of frequently asked questions to help you use and troubleshoot Pimmich. 4 | 5 | --- 6 | 7 | ### General Questions 8 | 9 | **Q: What is Pimmich?** 10 | 11 | **A:** Pimmich is software that turns a Raspberry Pi into a smart digital photo frame. It can display photos from an [Immich](https://immich.app/) server, a USB stick, a network share (Samba/Windows), a smartphone, or even via the Telegram messaging app. 12 | 13 | **Q: What do I need to use Pimmich?** 14 | 15 | **A:** You will need: 16 | - A Raspberry Pi (model 3, 4, or 5 recommended) with its power supply. 17 | - An SD card with Raspberry Pi OS (64-bit) installed. 18 | - A screen. 19 | - An internet connection (Wi-Fi or Ethernet). 20 | 21 | --- 22 | 23 | ### Installation and Configuration 24 | 25 | **Q: How do I install Pimmich?** 26 | 27 | **A:** The installation is designed to be simple: 28 | 1. Clone the GitHub repository: `git clone https://github.com/gotenash/pimmich.git` 29 | 2. Go into the directory: `cd pimmich` 30 | 3. Make the installation script executable: `chmod +x setup.sh` 31 | 4. Run the script with administrator rights: `sudo ./setup.sh` 32 | The script takes care of installing all dependencies and configuring the system for automatic startup. 33 | 34 | **Q: How do I access the configuration interface?** 35 | 36 | **A:** Once the Raspberry Pi has started, open a web browser on another device (computer, smartphone) connected to the same network and simply type in your Raspberry Pi's IP address. For example: `http://192.168.1.25`. If you don't know the IP, it is often displayed on the frame's screen on the first boot or if no photos are found. 37 | 38 | **Q: I forgot my password for the web interface. How can I reset it?** 39 | 40 | **A:** The initial password is stored in the `/boot/firmware/credentials.json` file. You can connect to your Raspberry Pi via SSH to read this file. If you changed it through the interface and forgot it, you will need to delete this file and reboot the Pi for it to generate a new one (warning: this will reset the user). 41 | 42 | **Q: How do I get an Immich API Token?** 43 | 44 | **A:** 45 | 1. Log in to your Immich web interface. 46 | 2. Go to "Account Settings" (via your profile icon). 47 | 3. In the "API Keys" section, click "Generate New API Key". 48 | 4. Give it a name (e.g., "Pimmich") and copy the generated key. 49 | 50 | *Tip:* For better security, create a dedicated Immich user for the frame with limited access to a single shared album. 51 | 52 | --- 53 | 54 | ### Features 55 | 56 | **Q: How does the Telegram feature work?** 57 | 58 | **A:** It allows you and your guests to send photos directly to the frame. 59 | 1. **Create a bot** on Telegram by talking to `@BotFather`. It will give you a **Token**. 60 | 2. **Get your user ID** on Telegram by talking to a bot like `@userinfobot`. 61 | 3. Enter both pieces of information in the "Telegram" tab in Pimmich. 62 | 4. You can then create secure, temporary invitation links for your loved ones. 63 | 64 | **Q: What is the "Favorites" tab for?** 65 | 66 | **A:** By marking a photo as a favorite (using the star icon in the "Preview" tab), you increase its display frequency in the slideshow. You can adjust the "boost factor" in the "Display" tab to make them appear more or less often. 67 | 68 | **Q: What is the "Postcard" effect?** 69 | 70 | **A:** It's a filter that adds a white border and a space for a caption (if you add one via the interface) to your photos, giving them a postcard look. Photos sent via Telegram use this effect by default for a more personal and warm touch. 71 | 72 | --- 73 | 74 | ### Troubleshooting 75 | 76 | **Q: I have a problem, where can I find help?** 77 | 78 | **A:** The **System** tab is the best place to start. It contains a **Logs** section. 79 | - `app.py` contains the logs for the web server (configuration interface). 80 | - `local_slideshow_stdout` and `local_slideshow_stderr` contain the logs for the slideshow itself. Errors will most often appear in `stderr`. 81 | 82 | **Q: Videos are not smooth or don't display. What should I do?** 83 | 84 | **A:** In the **Display** tab, try enabling the "**Enable hardware video decoding**" option. It is much more performant, especially on a Raspberry Pi. If it causes issues (black/blue screen after a video), disable it. 85 | 86 | **Q: The Wi-Fi won't connect, but Ethernet (cable) works. Why?** 87 | 88 | **A:** Sometimes, if an Ethernet cable is plugged in, the system gives it priority. In the **System** tab, you can try to temporarily disable the "**Wired Interface (eth0)**" to force the system to use Wi-Fi exclusively. 89 | 90 | **Q: How do I update Pimmich?** 91 | 92 | **A:** Go to the **System** tab and click the "**Check for updates**" button. Pimmich will download the latest version from GitHub and restart automatically. 93 | 94 | **Q: I changed a setting, but nothing changes on the slideshow.** 95 | 96 | **A:** Some changes, especially those related to display (font, weather, etc.), require the slideshow to be restarted to take effect. You can do this from the **Actions** tab by clicking "Stop" and then "Start Slideshow". For more significant changes, a restart of the web application or the system (from the **System** tab) may be necessary. -------------------------------------------------------------------------------- /utils/download_album.py: -------------------------------------------------------------------------------- 1 | import os 2 | import requests 3 | import shutil 4 | import time 5 | import json 6 | from pathlib import Path 7 | from utils.archive_manager import download_album_archive, unzip_archive, clean_archive 8 | 9 | # Définir le chemin du cache pour le mappage des descriptions 10 | CACHE_DIR = Path("cache") 11 | CACHE_DIR.mkdir(exist_ok=True) 12 | DESCRIPTION_MAP_CACHE_FILE = CACHE_DIR / "immich_description_map.json" 13 | 14 | def download_and_extract_album(config): 15 | server_url = config.get("immich_url") 16 | api_key = config.get("immich_token") 17 | album_name = config.get("album_name") 18 | 19 | if not all([server_url, api_key, album_name]): 20 | yield {"type": "error", "message": "Configuration incomplète : serveur, token ou nom d'album manquant."} 21 | return 22 | 23 | yield {"type": "progress", "stage": "CONNECTING", "percent": 5, "message": "Connexion à Immich..."} 24 | time.sleep(0.5) 25 | 26 | headers = { "x-api-key": api_key } 27 | album_list_url = f"{server_url}/api/albums" 28 | 29 | try: 30 | response = requests.get(album_list_url, headers=headers, timeout=10) 31 | response.raise_for_status() 32 | except requests.exceptions.RequestException as e: 33 | yield {"type": "error", "message": f"Impossible de se connecter au serveur Immich : {str(e)}"} 34 | return 35 | 36 | yield {"type": "progress", "stage": "SEARCHING", "percent": 10, "message": "Recherche de l'album..."} 37 | 38 | albums = response.json() 39 | album_id = next((album["id"] for album in albums if album["albumName"] == album_name), None) 40 | if not album_id: 41 | available_albums = [album["albumName"] for album in albums[:5]] # Limiter à 5 pour l'affichage 42 | yield {"type": "error", "message": f"Album '{album_name}' introuvable. Albums disponibles : {', '.join(available_albums)}"} 43 | return 44 | 45 | yield {"type": "progress", "stage": "FETCHING_ASSETS", "percent": 15, "message": "Récupération de la liste des photos..."} 46 | 47 | assets_url = f"{server_url}/api/albums/{album_id}" 48 | response = requests.get(assets_url, headers=headers, timeout=30) 49 | response.raise_for_status() 50 | 51 | album_data = response.json() 52 | assets = album_data.get("assets", []) 53 | asset_ids = [asset["id"] for asset in assets] 54 | 55 | if not asset_ids: 56 | yield {"type": "error", "message": "L'album est vide ou ne contient aucune photo accessible."} 57 | return 58 | 59 | # Créer un mappage nom de fichier -> description 60 | filename_to_description_map = {} 61 | for asset in assets: 62 | original_filename = asset.get("originalFileName") 63 | # La description est dans exifInfo 64 | description = asset.get("exifInfo", {}).get("description") 65 | if original_filename and description: 66 | filename_to_description_map[original_filename] = description 67 | 68 | # Sauvegarder le mappage dans un fichier cache pour que l'étape de préparation puisse l'utiliser. 69 | try: 70 | with open(DESCRIPTION_MAP_CACHE_FILE, 'w', encoding='utf-8') as f: 71 | json.dump(filename_to_description_map, f, ensure_ascii=False, indent=2) 72 | print(f"[Immich Import] Mappage des descriptions sauvegardé dans {DESCRIPTION_MAP_CACHE_FILE}") 73 | except Exception as e: 74 | # On ne bloque pas le processus, on affiche juste un avertissement. 75 | yield {"type": "warning", "message": f"Avertissement : Impossible de sauvegarder le mappage des descriptions : {e}"} 76 | 77 | nb_photos = len(asset_ids) 78 | yield {"type": "progress", "stage": "DOWNLOADING", "percent": 25, "message": f"Téléchargement de l'archive ({nb_photos} photos)..."} 79 | 80 | zip_path = "temp_album.zip" 81 | 82 | try: 83 | if not download_album_archive(server_url, api_key, asset_ids, zip_path): 84 | yield {"type": "error", "message": "Échec du téléchargement de l'archive."} 85 | return 86 | except Exception as e: 87 | yield {"type": "error", "message": f"Erreur critique lors du téléchargement : {str(e)}"} 88 | return 89 | 90 | yield {"type": "progress", "stage": "EXTRACTING", "percent": 60, "message": "Extraction des photos..."} 91 | photos_folder = os.path.join("static", "photos", "immich") 92 | prepared_folder = os.path.join("static", "prepared", "immich") 93 | 94 | # Vider les dossiers de destination (source et préparé) avant l'import pour éviter les mélanges. 95 | if os.path.exists(photos_folder): 96 | shutil.rmtree(photos_folder) 97 | os.makedirs(photos_folder, exist_ok=True) 98 | 99 | if os.path.exists(prepared_folder): 100 | shutil.rmtree(prepared_folder) 101 | os.makedirs(prepared_folder, exist_ok=True) 102 | 103 | try: 104 | unzip_archive(zip_path, photos_folder) 105 | clean_archive(zip_path) 106 | except Exception as e: 107 | yield {"type": "error", "message": f"Erreur lors de l'extraction : {str(e)}"} 108 | return 109 | 110 | yield { "type": "done", "stage": "DOWNLOAD_COMPLETE", "percent": 80, "message": f"{nb_photos} photos prêtes pour préparation.", "total_downloaded": nb_photos } 111 | -------------------------------------------------------------------------------- /utils/display_manager.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import time 4 | import json 5 | import glob 6 | import requests 7 | from pathlib import Path 8 | from .config_manager import load_config 9 | 10 | def get_display_output_name(): 11 | """ 12 | Trouve le nom de la sortie d'affichage principale (celle qui est active ou a un mode). 13 | """ 14 | try: 15 | if "SWAYSOCK" not in os.environ: 16 | user_id = os.getuid() 17 | sock_path_pattern = f"/run/user/{user_id}/sway-ipc.*" 18 | socks = glob.glob(sock_path_pattern) 19 | if socks: 20 | os.environ["SWAYSOCK"] = socks[0] 21 | else: 22 | return None 23 | 24 | result = subprocess.run(['swaymsg', '-t', 'get_outputs'], capture_output=True, text=True, check=True, env=os.environ) 25 | outputs = json.loads(result.stdout) 26 | 27 | for output in outputs: 28 | if output.get('active', False) or output.get('current_mode'): 29 | return output.get('name') 30 | return None 31 | except Exception as e: 32 | print(f"Erreur lors de la récupération du nom de la sortie d'affichage : {e}") 33 | return None 34 | 35 | def _send_smart_plug_command(url): 36 | """Envoie une requête HTTP à l'URL de la prise connectée.""" 37 | if not url: 38 | return False, "URL de la prise connectée non configurée." 39 | try: 40 | # Utiliser un timeout court pour ne pas bloquer le système 41 | response = requests.post(url, timeout=5) 42 | # On considère que c'est un succès si la requête aboutit (code 2xx) 43 | if 200 <= response.status_code < 300: 44 | print(f"Commande prise connectée envoyée avec succès à {url}") 45 | return True, "Commande envoyée à la prise." 46 | else: 47 | error_msg = f"La prise connectée a répondu avec une erreur {response.status_code}." 48 | print(error_msg) 49 | return False, error_msg 50 | except requests.RequestException as e: 51 | error_msg = f"Erreur de communication avec la prise connectée : {e}" 52 | print(error_msg) 53 | return False, error_msg 54 | 55 | def set_display_power(on=True): 56 | """ 57 | Allume ou éteint l'écran, en utilisant une prise connectée si configurée, 58 | sinon en utilisant la commande logicielle (swaymsg). 59 | """ 60 | config = load_config() 61 | 62 | if config.get("smart_plug_enabled"): 63 | # --- Logique de la prise connectée --- 64 | if on: 65 | # --- SÉQUENCE D'ALLUMAGE AVEC REDÉMARRAGE SYSTÈME --- 66 | print("[Display Manager] Allumage de la prise connectée...") 67 | on_url = config.get("smart_plug_on_url") 68 | success, message = _send_smart_plug_command(on_url) 69 | if not success: 70 | return False, f"Échec de l'allumage de la prise : {message}" 71 | 72 | # Attendre que l'écran s'allume avant de redémarrer 73 | delay = int(config.get("smart_plug_on_delay", 5)) 74 | print(f"Attente de {delay} secondes pour l'initialisation de l'écran...") 75 | time.sleep(delay) 76 | 77 | # Créer un fichier drapeau pour indiquer qu'un redémarrage est intentionnel 78 | # pour éviter une boucle de redémarrage. 79 | # On le place dans le dossier 'cache' pour qu'il persiste après le redémarrage. 80 | flag_path = Path(__file__).resolve().parent.parent / 'cache' / 'pimmich_reboot_flag.tmp' 81 | flag_path.parent.mkdir(exist_ok=True) # S'assurer que le dossier cache existe 82 | flag_path.touch() 83 | 84 | print("[Display Manager] Lancement du redémarrage du système pour garantir la bonne résolution...") 85 | os.system('sudo reboot') 86 | # Le script s'arrêtera ici car le système redémarre. 87 | return True, "Redémarrage système initié." 88 | else: 89 | # --- SÉQUENCE D'EXTINCTION --- 90 | # 1. Mettre l'écran en veille logicielle d'abord 91 | _set_software_display_power(on=False) 92 | time.sleep(1) # Petite pause 93 | # 2. Couper l'alimentation de la prise 94 | off_url = config.get("smart_plug_off_url") 95 | return _send_smart_plug_command(off_url) 96 | else: 97 | # --- Logique logicielle par défaut --- 98 | return _set_software_display_power(on) 99 | 100 | def _set_software_display_power(on=True): 101 | """Allume ou éteint l'écran en utilisant swaymsg (contrôle logiciel).""" 102 | output_name = get_display_output_name() 103 | if not output_name: 104 | return False, "Aucune sortie d'affichage principale trouvée." 105 | 106 | state = "on" if on else "off" 107 | try: 108 | subprocess.run(['swaymsg', 'output', output_name, 'dpms', state], check=True, capture_output=True, text=True) 109 | print(f"Écran '{output_name}' passé en mode DPMS '{state}'.") 110 | return True, f"Écran passé en mode {state}." 111 | except Exception as e: 112 | error_message = f"Erreur lors du changement d'état de l'écran : {e}" 113 | print(error_message) 114 | return False, error_message -------------------------------------------------------------------------------- /README_en.md: -------------------------------------------------------------------------------- 1 | # 🖼️ Pimmich – Smart Connected Photo Frame 2 | 3 | Pimmich is a Python application designed to turn a Raspberry Pi into a smart and customizable digital photo frame. It can display photos from multiple sources, be controlled by voice, and much more. 4 | 5 | Pimmich Logo 6 | 7 | --- 8 | 9 | ## 📖 Table of Contents 10 | 11 | - ✨ Main Features 12 | - 🧰 Technologies Used 13 | - 🚀 Installation 14 | - 🔧 Configuration 15 | - 🗣️ Voice Control 16 | - ❓ Troubleshooting (FAQ) 17 | - 🛣️ Roadmap 18 | - June 2025 19 | - July 2025 20 | - August 2025 21 | - 💖 Credits 22 | 23 | --- 24 | 25 | ## ✨ Main Features 26 | 27 | Pimmich is packed with features to provide a complete and customizable experience: 28 | 29 | #### 🖼️ **Display & Slideshow** 30 | - **Multi-source:** Display photos from Immich, a network share (Samba/Windows), a USB drive, a smartphone, or via Telegram. 31 | - **Advanced Customization:** Set display duration, active hours, transitions (fade, slide), and enable a "Pan & Zoom" motion effect. 32 | - **Creative Filters:** Apply filters to your photos (Black & White, Sepia, Vintage) and unique effects like **Polaroid** or **Postcard**. 33 | - **Format Handling:** Smart support for portrait photos (blurred background) and videos (with sound and optional hardware acceleration). 34 | 35 | #### ⚙️ **Interface & Control** 36 | - **Comprehensive Web Interface:** A local configuration page, password-protected and organized into thematic groups and tabs for an intuitive navigation. 37 | - **Voice Control:** Control your frame with voice commands like *"Magic Frame, next photo"* or *"Magic Frame, play Vacation playlist"*. 38 | - **Content Management:** 39 | - **Playlists:** Create virtual albums, reorder photos with drag-and-drop, and launch themed slideshows with a dynamic title screen (photo jumble on a corkboard background). 40 | - **Favorites:** Mark your favorite photos to make them appear more often. 41 | - **Captions:** Add custom text to your photos and postcards. 42 | 43 | #### 🌐 **Connectivity & Interactions** 44 | - **Telegram:** Allow friends and family to send photos to the frame via a Telegram bot, with a secure and temporary invitation system. 45 | - **Wi-Fi & Network:** Configure Wi-Fi, scan for networks, and manage network interfaces directly from the interface. 46 | - **Smartphone Upload:** Import photos directly from your phone's browser. 47 | 48 | #### 🛠️ **Maintenance & Monitoring** 49 | - **Easy Updates:** Update Pimmich with a single click from the interface. 50 | - **Backup & Restore:** Back up and restore your entire configuration. 51 | - **System Monitoring:** Track real-time temperature, CPU, RAM, and disk usage with history graphs. 52 | - **Detailed Logs:** Access logs for each service (web server, slideshow, voice control) for easy troubleshooting. 53 | 54 | --- 55 | 56 | ## 🧰 Technologies Used 57 | 58 | - **Backend:** Python, Flask 59 | - **Frontend:** HTML, TailwindCSS, JavaScript 60 | - **Slideshow:** Pygame 61 | - **Image Processing:** Pillow 62 | - **Voice Control:** Picovoice Porcupine (wake word) & Vosk (recognition) 63 | - **Web Server:** NGINX (as a reverse proxy) 64 | 65 | --- 66 | 67 | ## 🚀 Installation 68 | 69 | There are two methods to install Pimmich. 70 | 71 | ### Method 1: Pre-configured Image (Recommended and easier) 72 | 73 | This method is ideal for a quick first-time installation. 74 | 75 | 1. **Download the current month's image** 76 | Go to the Pimmich Releases page and download the `.img` file of the latest version. 77 | 78 | 2. **Flash the image to an SD card** 79 | Use software like Raspberry Pi Imager or BalenaEtcher to write the image file you just downloaded to your microSD card. 80 | 81 | 3. **Start your Raspberry Pi** 82 | Insert the SD card into the Raspberry Pi, connect the screen and power supply. Pimmich will start automatically. 83 | 84 | ### Method 2: Manual Installation from Git Repository 85 | 86 | This method is for advanced users or those who want to follow development closely. 87 | 88 | #### ✅ Prerequisites 89 | 90 | - A Raspberry Pi (model 3B+, 4, or 5 recommended) with Raspberry Pi OS Desktop (64-bit). 91 | - An SD card, a power supply, a screen. 92 | - An Internet connection. 93 | 94 | #### 📝 Installation Steps 95 | 96 | 1. **Clone the repository** 97 | Open a terminal on your Raspberry Pi and run: 98 | ```bash 99 | git clone https://github.com/gotenash/pimmich.git 100 | cd pimmich 101 | ``` 102 | 103 | 2. **Run the installation script** 104 | This script installs all dependencies, configures the environment, and prepares for automatic startup. 105 | ```bash 106 | chmod +x setup.sh 107 | sudo ./setup.sh 108 | ``` 109 | 110 | This script installs system and Python dependencies, sets up the environment, and configures auto-start of the slideshow. 111 | 112 | --- 113 | 114 | ### 🔑 Get Your Immich API Token 115 | 116 | 1. Log into your Immich web interface 117 | 2. Go to **Account Settings** 118 | 119 | Click your profile icon (top-right), then choose **Account Settings**. 120 | 121 | 3. Generate a new API key 122 | In the **API Key** section, click **Generate new API Key** and give it a name (e.g., `PimmichFrame`). 123 | 124 | ⚠️ Once the token is shown, **copy it immediately** — you won’t be able to see it again. If lost, you’ll need to generate a new one. 125 | 126 | 🔒 We recommend creating a dedicated Immich account for the photo frame, with access to a single shared album. 127 | 128 | --- 129 | 130 | ### Connect to Pimmich 131 | 132 | From a web browser on another device, access: 133 | 134 | ``` 135 | http://:5000 136 | ``` 137 | 138 | --- 139 | 140 | ## ⚙️ Configuration Interface 141 | 142 | ### Slideshow Settings 143 | 144 | Here, you can set the display time per photo (minimum ~10s, due to background blur processing for portrait images) and define active hours for the slideshow. 145 | 146 | ### Photo Import Settings 147 | 148 | Choose between importing from an Immich album or a USB stick, and manage downloaded files. 149 | -------------------------------------------------------------------------------- /templates/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ _('Connexion') }} - Pimmich 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | {% with messages = get_flashed_messages(with_categories=true) %} 16 | {% if messages %} 17 |
18 | {% for category, message in messages %} 19 |
30 | {{ message }} 31 |
32 | {% endfor %} 33 |
34 | 44 | {% endif %} 45 | {% endwith %} 46 | 47 |
48 |
49 | Logo Pimmich 50 | 51 |
52 |
53 | 54 | 55 |
56 | 57 |
58 | 59 |
60 | 61 |
62 | 65 |
66 |
67 |
68 | 69 |
70 | 73 |
74 |
75 | 78 |
79 | {{ _('Langue') }}: 80 | {% set current_lang = get_locale() %} 81 | FR | 82 | EN | 83 | ES 84 |
85 |
86 |
87 | 88 | 112 | 113 | -------------------------------------------------------------------------------- /utils/import_samba.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | from pathlib import Path 4 | import smbclient 5 | from smbprotocol.exceptions import SMBException 6 | 7 | TARGET_DIR = Path("static/photos/samba") 8 | ALLOWED_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.heic', '.heif'} 9 | 10 | def is_image_file(filename): 11 | return Path(filename).suffix.lower() in ALLOWED_EXTENSIONS 12 | 13 | def import_samba_photos(config): 14 | """ 15 | Synchronise les photos depuis un partage Samba et retourne des objets structurés pour le suivi. 16 | Copie uniquement les fichiers nouveaux ou modifiés et supprime les fichiers locaux obsolètes. 17 | """ 18 | server = config.get("smb_host") 19 | share = config.get("smb_share") 20 | path = config.get("smb_path", "") 21 | user = config.get("smb_user") 22 | password = config.get("smb_password") 23 | 24 | if not all([server, share]): 25 | yield {"type": "error", "message": "Configuration Samba incomplète : serveur ou nom de partage manquant."} 26 | return 27 | 28 | # Construction robuste du chemin UNC pour éviter les problèmes de slashs finaux. 29 | path_in_share = path.strip('/') 30 | if path_in_share: 31 | full_samba_path = f"//{server}/{share}/{path_in_share}" 32 | else: 33 | full_samba_path = f"//{server}/{share}" 34 | yield {"type": "progress", "stage": "CONNECTING", "percent": 5, "message": f"Connexion à {full_samba_path}..."} 35 | 36 | try: 37 | # On ne gère plus les sessions manuellement pour être compatible avec d'anciennes versions de smbclient. 38 | # Les identifiants sont passés directement aux fonctions. 39 | if not smbclient.path.exists(full_samba_path, username=user, password=password, connection_timeout=15): 40 | yield {"type": "error", "message": f"Le chemin Samba est introuvable : {full_samba_path}"} 41 | return 42 | 43 | # --- Phase 1: Lister les fichiers distants et locaux --- 44 | yield {"type": "progress", "stage": "SCANNING", "percent": 10, "message": "Analyse des fichiers distants et locaux..."} 45 | 46 | # Vider les dossiers de destination (source et préparé) avant l'import pour éviter les mélanges. 47 | prepared_samba_dir = Path("static/prepared/samba") 48 | if TARGET_DIR.exists(): 49 | shutil.rmtree(TARGET_DIR) 50 | TARGET_DIR.mkdir(parents=True, exist_ok=True) 51 | if prepared_samba_dir.exists(): 52 | shutil.rmtree(prepared_samba_dir) 53 | prepared_samba_dir.mkdir(parents=True, exist_ok=True) 54 | 55 | # Récupérer les fichiers distants avec leur date de modification 56 | remote_files = {} 57 | for filename in smbclient.listdir(full_samba_path, username=user, password=password): 58 | if is_image_file(filename): 59 | try: 60 | remote_file_path = os.path.join(full_samba_path, filename) 61 | if smbclient.path.isfile(remote_file_path, username=user, password=password): 62 | stat_info = smbclient.stat(remote_file_path, username=user, password=password) 63 | remote_files[filename] = stat_info.st_mtime 64 | except Exception as e: 65 | yield {"type": "warning", "message": f"Impossible d'accéder aux informations de {filename}: {e}"} 66 | 67 | # Récupérer les fichiers locaux avec leur date de modification 68 | TARGET_DIR.mkdir(parents=True, exist_ok=True) 69 | local_files = {f.name: f.stat().st_mtime for f in TARGET_DIR.iterdir() if f.is_file()} 70 | 71 | # --- Phase 2: Déterminer les actions à effectuer --- 72 | files_to_copy = {f for f, mtime in remote_files.items() if f not in local_files or mtime > local_files.get(f, 0)} 73 | files_to_delete = {f for f in local_files if f not in remote_files} 74 | 75 | # --- Phase 3: Supprimer les fichiers locaux obsolètes --- 76 | if files_to_delete: 77 | yield {"type": "progress", "stage": "CLEANING", "percent": 15, "message": f"Suppression de {len(files_to_delete)} photos obsolètes..."} 78 | for filename in files_to_delete: 79 | try: 80 | (TARGET_DIR / filename).unlink() 81 | except OSError as e: 82 | yield {"type": "warning", "message": f"Impossible de supprimer {filename}: {e}"} 83 | 84 | # --- Phase 4: Copier les fichiers nouveaux ou modifiés --- 85 | total_to_copy = len(files_to_copy) 86 | if total_to_copy == 0: 87 | yield {"type": "info", "message": "Aucune nouvelle photo à importer. Le dossier est à jour."} 88 | yield {"type": "done", "stage": "IMPORT_COMPLETE", "percent": 100, "message": "Synchronisation terminée. Aucune nouvelle photo."} 89 | return 90 | 91 | yield {"type": "stats", "stage": "COPYING", "percent": 20, "message": f"Début de la copie de {total_to_copy} photos...", "total": total_to_copy} 92 | 93 | for i, filename in enumerate(sorted(list(files_to_copy)), 1): 94 | source_file = os.path.join(full_samba_path, filename) 95 | dest_file = TARGET_DIR / filename 96 | 97 | try: 98 | with smbclient.open_file(source_file, mode='rb', username=user, password=password) as remote_f: 99 | with open(dest_file, 'wb') as local_f: 100 | shutil.copyfileobj(remote_f, local_f) 101 | 102 | # Mettre à jour la date de modification du fichier local pour correspondre au distant 103 | os.utime(dest_file, (remote_files[filename], remote_files[filename])) 104 | 105 | percent = 20 + int((i / total_to_copy) * 60) # La copie représente 60% de la barre (de 20% à 80%) 106 | yield { 107 | "type": "progress", "stage": "COPYING", "percent": percent, 108 | "message": f"Copie en cours... ({i}/{total_to_copy})", 109 | "current": i, "total": total_to_copy 110 | } 111 | except Exception as e: 112 | yield {"type": "warning", "message": f"Impossible de copier {filename}: {str(e)}"} 113 | 114 | yield {"type": "done", "stage": "IMPORT_COMPLETE", "percent": 80, "message": f"{total_to_copy} photos synchronisées.", "total_imported": total_to_copy} 115 | 116 | except SMBException as e: 117 | yield {"type": "error", "message": f"Erreur Samba : {str(e)}"} 118 | except Exception as e: 119 | yield {"type": "error", "message": f"Erreur inattendue : {str(e)}"} -------------------------------------------------------------------------------- /utils/slideshow_manager.py: -------------------------------------------------------------------------------- 1 | import os 2 | import signal 3 | import psutil 4 | import subprocess, sys 5 | import time 6 | from .display_manager import set_display_power 7 | from .config_manager import load_config 8 | 9 | PID_FILE = "/tmp/pimmich_slideshow.pid" 10 | _log_files = {} # Dictionnaire pour garder les références aux fichiers de log ouverts 11 | 12 | def is_slideshow_running(): 13 | if not os.path.exists(PID_FILE): 14 | return False 15 | try: 16 | with open(PID_FILE, "r") as f: 17 | pid = int(f.read().strip()) 18 | if not psutil.pid_exists(pid): 19 | return False 20 | p = psutil.Process(pid) 21 | return p.is_running() and any("local_slideshow.py" in s for s in p.cmdline()) 22 | except (psutil.NoSuchProcess, FileNotFoundError, ValueError): 23 | return False 24 | 25 | def start_slideshow(): 26 | # Nettoyage si un ancien fichier PID existe sans process actif 27 | if os.path.exists(PID_FILE): 28 | try: 29 | with open(PID_FILE, "r") as f: 30 | pid = int(f.read()) 31 | if not psutil.pid_exists(pid): 32 | os.remove(PID_FILE) 33 | except Exception: 34 | pass 35 | 36 | # Vérifie si un slideshow est déjà en cours 37 | if is_slideshow_running(): 38 | return 39 | 40 | # Préparer l’environnement Wayland/Sway 41 | 42 | # Utiliser le même exécutable python que celui qui lance l'application web 43 | # pour garantir que le diaporama s'exécute dans le même environnement (venv). 44 | python_executable = sys.executable 45 | # Lance le diaporama 46 | stdout_log = open("logs/slideshow_stdout.log", "a") 47 | stderr_log = open("logs/slideshow_stderr.log", "a") 48 | proc = subprocess.Popen([python_executable, "-u", "local_slideshow.py"], stdout=stdout_log, stderr=stderr_log, env=os.environ.copy()) 49 | 50 | # Sauvegarde le PID du nouveau processus 51 | with open(PID_FILE, "w") as f: 52 | f.write(str(proc.pid)) 53 | 54 | # Garder une référence aux fichiers de log pour qu'ils ne soient pas fermés 55 | _log_files[proc.pid] = (stdout_log, stderr_log) 56 | 57 | def _stop_process_by_pid(pid): 58 | """Helper function to stop a process and close its log files.""" 59 | if psutil.pid_exists(pid): 60 | print(f"Arrêt du processus de diaporama {pid}...") 61 | p = psutil.Process(pid) 62 | p.terminate() 63 | try: 64 | p.wait(timeout=3) 65 | except psutil.TimeoutExpired: 66 | print(f"Le processus {pid} n'a pas répondu, forçage de l'arrêt.") 67 | p.kill() 68 | # Fermer et supprimer les références aux fichiers de log 69 | if pid in _log_files: 70 | for log_file in _log_files[pid]: 71 | if not log_file.closed: log_file.close() 72 | del _log_files[pid] 73 | 74 | def stop_slideshow(): 75 | """Arrête le processus du diaporama de manière robuste, en attendant sa terminaison.""" 76 | config = load_config() 77 | is_smart_plug_enabled = config.get("smart_plug_enabled", False) 78 | 79 | # Si une prise connectée est utilisée, on arrête d'abord le diaporama 80 | # avant de couper l'alimentation de l'écran. 81 | # Sinon, on arrête juste le diaporama et on laisse set_display_power gérer le DPMS. 82 | 83 | if os.path.exists(PID_FILE): 84 | try: 85 | with open(PID_FILE, "r") as f: 86 | pid = int(f.read().strip()) 87 | _stop_process_by_pid(pid) 88 | except (IOError, ValueError, psutil.NoSuchProcess) as e: 89 | print(f"Avertissement lors de l'arrêt du diaporama : {e}") 90 | finally: 91 | # S'assurer que le fichier PID est supprimé 92 | if os.path.exists(PID_FILE): 93 | os.remove(PID_FILE) 94 | 95 | # Double sécurité : tuer tous les processus restants qui pourraient être des zombies 96 | for proc in psutil.process_iter(attrs=["pid", "cmdline"]): 97 | try: 98 | if proc.info["cmdline"] and any("local_slideshow.py" in part for part in proc.info["cmdline"]): 99 | print(f"Nettoyage d'un processus de diaporama zombie trouvé (PID: {proc.pid}).") 100 | proc.kill() 101 | except (psutil.NoSuchProcess, psutil.AccessDenied): 102 | continue 103 | 104 | # Éteindre l’écran proprement (via prise ou DPMS) 105 | set_display_power(on=False) 106 | 107 | def restart_slideshow_for_update(): 108 | """ 109 | Redémarre le diaporama après une mise à jour de contenu, sans éteindre l'écran. 110 | C'est la fonction à utiliser par les workers de mise à jour automatique. 111 | """ 112 | print("[Slideshow Manager] Redémarrage du diaporama pour mise à jour de contenu.") 113 | 114 | # 1. Arrêter le processus existant (sans appeler set_display_power) 115 | if os.path.exists(PID_FILE): 116 | try: 117 | with open(PID_FILE, "r") as f: pid = int(f.read().strip()) 118 | _stop_process_by_pid(pid) 119 | if os.path.exists(PID_FILE): os.remove(PID_FILE) 120 | except Exception as e: 121 | print(f"Avertissement lors de l'arrêt pour mise à jour : {e}") 122 | 123 | # 2. Démarrer un nouveau processus 124 | start_slideshow() 125 | 126 | def restart_slideshow_process(): 127 | """ 128 | Redémarre uniquement le processus du diaporama, sans affecter l'alimentation de l'écran. 129 | Idéal pour appliquer les changements de configuration sans cycle de redémarrage complet. 130 | """ 131 | print("[Slideshow Manager] Redémarrage du processus de diaporama demandé.") 132 | 133 | # 1. Arrêter le processus existant (sans appeler set_display_power) 134 | if os.path.exists(PID_FILE): 135 | with open(PID_FILE, "r") as f: pid = int(f.read().strip()) 136 | _stop_process_by_pid(pid) 137 | if os.path.exists(PID_FILE): 138 | try: 139 | os.remove(PID_FILE) 140 | except OSError as e: 141 | print(f"Avertissement: Impossible de supprimer le fichier PID : {e}") 142 | 143 | # Afficher un message de redémarrage sur l'écran 144 | try: 145 | python_executable = sys.executable 146 | message = "Redémarrage du diaporama..." 147 | # Utiliser Popen pour ne pas bloquer, et s'assurer que l'environnement est correct 148 | env = os.environ.copy() 149 | if "SWAYSOCK" not in env: 150 | user_id = os.getuid() 151 | socks = glob.glob(f"/run/user/{user_id}/sway-ipc.*") 152 | if socks: env["SWAYSOCK"] = socks[0] 153 | subprocess.Popen([python_executable, "utils/display_message.py", message], env=env) 154 | time.sleep(1) # Laisser le temps au message de s'afficher 155 | except Exception as e: 156 | print(f"Avertissement: Impossible d'afficher le message de redémarrage : {e}") 157 | 158 | # 2. Démarrer un nouveau processus 159 | start_slideshow() 160 | -------------------------------------------------------------------------------- /static/styles.css: -------------------------------------------------------------------------------- 1 | /* styles.css - Version nettoyée */ 2 | 3 | /* --- Styles Généraux --- */ 4 | body { 5 | font-family: Arial, sans-serif; 6 | margin: 0; 7 | padding: 0; 8 | background: url('/static/background.png') no-repeat center center fixed; 9 | background-size: cover; 10 | } 11 | 12 | .container { 13 | max-width: 1200px; /* Largeur max pour desktop */ 14 | margin: 40px auto; 15 | padding: 2rem; 16 | background: rgba(255, 255, 255, 0.7); 17 | backdrop-filter: blur(10px); 18 | -webkit-backdrop-filter: blur(10px); 19 | border-radius: 16px; 20 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); 21 | position: relative; /* Pour le positionnement du lien de déconnexion */ 22 | } 23 | 24 | .logo { 25 | display: block; 26 | margin: 0 auto 20px; 27 | height: 80px; 28 | } 29 | 30 | h2 { 31 | text-align: center; 32 | margin-bottom: 30px; 33 | color: #333; 34 | } 35 | 36 | /* --- Formulaires --- */ 37 | form { 38 | display: flex; 39 | flex-direction: column; 40 | gap: 15px; 41 | } 42 | 43 | fieldset { 44 | border: 1px solid #ccc; 45 | border-radius: 8px; 46 | padding: 15px; 47 | margin-top: 20px; 48 | background-color: #f0f0f0; 49 | } 50 | 51 | legend { 52 | font-weight: 700; 53 | padding: 0 10px; 54 | color: #333; 55 | } 56 | 57 | 58 | .tabcontent input[type="number"], 59 | .tabcontent input[type="password"], 60 | .tabcontent input[type="text"], 61 | .tabcontent input[type="time"], 62 | .tabcontent input[type="url"], 63 | .tabcontent select { 64 | padding: 10px; 65 | border-radius: 5px; 66 | border: 1px solid #ccc; 67 | width: 100%; 68 | margin-top: 5px; 69 | box-sizing: border-box; 70 | font-size: 1rem; 71 | height: 40px; 72 | } 73 | 74 | button { 75 | margin-top: 15px; 76 | padding: 10px 15px; 77 | background-color: #2b2e83; 78 | color: #fff; 79 | font-weight: 700; 80 | border: none; 81 | border-radius: 6px; 82 | width: 100%; 83 | cursor: pointer; 84 | font-size: 1rem; 85 | transition: background-color 0.3s ease; 86 | } 87 | 88 | button:hover { 89 | background-color: #1c1f5e; 90 | } 91 | 92 | /* Styles spécifiques pour les boutons qui doivent prendre toute la largeur */ 93 | .save-button, 94 | .import-button, 95 | .system-button { 96 | width: 100%; 97 | display: block; /* S'assure qu'ils se comportent comme des éléments de bloc */ 98 | } 99 | 100 | /* Règle pour les boutons "Tout supprimer" pour qu'ils s'adaptent au contenu */ 101 | .delete-all-btn { 102 | width: auto; /* Revertir à la largeur naturelle du contenu */ 103 | display: inline-flex; /* Maintenir l'alignement de l'icône et du texte */ 104 | margin-top: 0; /* Supprimer la marge supérieure si elle est dans une ligne flex */ 105 | } 106 | .save-button { 107 | background-color: #c73680; 108 | } 109 | 110 | .save-button:hover { 111 | background-color: #a62864; 112 | } 113 | 114 | .logout-link { 115 | position: absolute; 116 | top: 20px; 117 | right: 30px; 118 | font-size: 0.9em; 119 | } 120 | 121 | .logout-link a { 122 | color: #888; 123 | text-decoration: none; 124 | } 125 | 126 | .logout-link a:hover { 127 | text-decoration: underline; 128 | color: #333; 129 | } 130 | 131 | /* --- Système d'onglets --- */ 132 | .tabs { 133 | display: flex; 134 | gap: 10px; 135 | margin-bottom: 20px; 136 | border-bottom: 2px solid #ccc; 137 | flex-wrap: nowrap; 138 | } 139 | 140 | .tablink { 141 | background-color: #0078d7; 142 | color: #fff; 143 | border: 1px solid #005a9e; 144 | border-bottom: none; 145 | padding: 10px 20px; 146 | cursor: pointer; 147 | font-weight: 700; 148 | border-radius: 10px 10px 0 0; 149 | outline: 0; 150 | transition: background-color 0.3s, box-shadow 0.3s; 151 | margin-top: 0; /* Annuler la marge héritée du sélecteur 'button' générique */ 152 | box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); 153 | } 154 | 155 | .tablink:hover { 156 | background-color: #005a9e; 157 | box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3); 158 | } 159 | 160 | .tablink.active { 161 | background-color: #fff; 162 | color: #0078d7; 163 | border-bottom: 2px solid #fff; 164 | box-shadow: none; 165 | } 166 | 167 | .tabcontent { 168 | display: none; 169 | padding: 15px; 170 | border: 1px solid #ccc; 171 | border-radius: 0 8px 8px 8px; 172 | background-color: #fff; 173 | } 174 | 175 | /* --- Aperçu des photos --- */ 176 | .photo-preview-section { 177 | border: 1px solid #e5e7eb; 178 | border-radius: 0.5rem; 179 | padding: 1rem; 180 | background: rgba(255, 255, 255, 0.8); 181 | backdrop-filter: blur(10px); 182 | } 183 | 184 | .photo-grid { 185 | display: grid; 186 | gap: 1rem; 187 | padding: 1rem; 188 | max-height: 70vh; 189 | overflow-y: auto; 190 | grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); 191 | } 192 | 193 | .photo-tile { 194 | position: relative; 195 | display: flex; 196 | flex-direction: column; 197 | align-items: center; 198 | justify-content: center; 199 | } 200 | 201 | .photo-tile img { 202 | width: 100%; 203 | height: auto; 204 | aspect-ratio: 1; 205 | object-fit: cover; 206 | border-radius: 0.5rem; 207 | transition: transform 0.2s ease; 208 | } 209 | 210 | .photo-tile img:hover { 211 | transform: scale(1.05); 212 | } 213 | 214 | /* --- Messages de statut (pour les imports) --- */ 215 | .status-message { 216 | padding: 0.5rem; 217 | margin: 0.25rem 0; 218 | border-radius: 0.375rem; 219 | font-weight: 500; 220 | display: flex; 221 | align-items: center; 222 | gap: 0.5rem; 223 | } 224 | 225 | .status-success { 226 | background-color: #dcfce7; 227 | color: #166534; 228 | border-left: 4px solid #22c55e; 229 | } 230 | 231 | .status-error { 232 | background-color: #fef2f2; 233 | color: #dc2626; 234 | border-left: 4px solid #ef4444; 235 | } 236 | 237 | .status-warning { 238 | background-color: #fef3c7; 239 | color: #d97706; 240 | border-left: 4px solid #f59e0b; 241 | } 242 | 243 | .status-info { 244 | background-color: #eff6ff; 245 | color: #2563eb; 246 | border-left: 4px solid #3b82f6; 247 | } 248 | 249 | /* --- GLightbox (visionneuse) --- */ 250 | .glightbox-container { 251 | position: fixed !important; 252 | top: 50% !important; 253 | left: 50% !important; 254 | transform: translate(-50%, -50%) !important; 255 | z-index: 9999 !important; 256 | } 257 | 258 | /* --- Responsive --- */ 259 | @media (max-width: 639px) { 260 | .container { 261 | width: 100%; 262 | max-width: 100%; 263 | padding: 1rem; /* Conserver un peu de padding intérieur */ 264 | margin-left: 0; 265 | margin-right: 0; 266 | border-radius: 0; /* Pas de coins arrondis en plein écran */ 267 | } 268 | 269 | fieldset { 270 | padding: 1rem; 271 | } 272 | 273 | input, select, button { 274 | width: 100%; 275 | margin-bottom: 0.5rem; 276 | } 277 | 278 | .tabs { 279 | flex-wrap: nowrap; /* Forcer une seule ligne pour permettre le défilement */ 280 | } 281 | 282 | .tablink { 283 | flex-shrink: 0; /* Empêcher les boutons de rétrécir pour le défilement */ 284 | font-size: 0.875rem; 285 | padding: 0.5rem 1rem; /* Ajuster le padding pour un meilleur espacement */ 286 | } 287 | 288 | .photo-preview-section { 289 | padding: 0.5rem; 290 | max-height: 60vh; 291 | } 292 | 293 | .photo-grid { 294 | padding: 0.5rem; 295 | gap: 0.75rem; 296 | grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); 297 | } 298 | } -------------------------------------------------------------------------------- /utils/import_usb_photos.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import json 4 | import subprocess 5 | from pathlib import Path 6 | import tempfile 7 | import time 8 | 9 | CANCEL_FLAG = Path('/tmp/pimmich_cancel_import.flag') 10 | TARGET_DIR = Path("static/photos/usb") 11 | ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif"} 12 | 13 | def is_image_file(filename): 14 | return Path(filename).suffix.lower() in ALLOWED_EXTENSIONS 15 | 16 | def find_and_mount_usb(): 17 | """ 18 | Recherche un périphérique USB. S'il est déjà monté, retourne le point de montage. 19 | Sinon, le monte dans un répertoire temporaire et retourne ce chemin. 20 | Retourne un tuple: (chemin_montage, chemin_périphérique, a_été_monté_par_script) 21 | """ 22 | try: 23 | # Utiliser lsblk pour une détection fiable, en demandant le chemin du périphérique (PATH) 24 | result = subprocess.run( 25 | ['/usr/bin/lsblk', '-J', '-o', 'NAME,MOUNTPOINT,TRAN,PATH'], 26 | capture_output=True, text=True, check=True, timeout=10 27 | ) 28 | data = json.loads(result.stdout) 29 | 30 | for device in data.get('blockdevices', []): 31 | if device.get('tran') == 'usb': 32 | # Parcourir les partitions du périphérique 33 | for partition in device.get('children', []): 34 | mountpoint = partition.get('mountpoint') 35 | device_path = partition.get('path') 36 | 37 | if not device_path: 38 | continue 39 | 40 | # Cas 1: Déjà monté par le système 41 | if mountpoint: 42 | print(f"Périphérique USB trouvé et déjà monté sur : {mountpoint}") 43 | return Path(mountpoint), device_path, False 44 | 45 | # Cas 2: Non monté, on le monte nous-mêmes 46 | print(f"Périphérique USB non monté détecté : {device_path}. Tentative de montage...") 47 | 48 | # Créer un répertoire de montage temporaire 49 | temp_mount_dir = tempfile.mkdtemp(prefix="pimmich_usb_") 50 | 51 | # Monter le périphérique avec les droits sudo 52 | mount_cmd = ['sudo', '/usr/bin/mount', device_path, temp_mount_dir] 53 | subprocess.run(mount_cmd, check=True, capture_output=True, text=True, timeout=15) 54 | 55 | print(f"Périphérique {device_path} monté avec succès sur {temp_mount_dir}") 56 | return Path(temp_mount_dir), device_path, True 57 | 58 | except (FileNotFoundError, subprocess.CalledProcessError, json.JSONDecodeError, subprocess.TimeoutExpired) as e: 59 | print(f"Erreur lors de la détection USB via lsblk : {e}. Assurez-vous que 'lsblk' est installé.") 60 | 61 | return None, None, False # Retourne None si rien n'est trouvé ou en cas d'erreur 62 | 63 | def import_usb_photos(): 64 | """Importe les photos depuis une clé USB, en la montant si nécessaire.""" 65 | yield {"type": "progress", "stage": "SEARCHING", "percent": 5, "message": "Recherche de clés USB connectées..."} 66 | time.sleep(1) 67 | 68 | mount_path = None 69 | device_path = None 70 | mounted_by_script = False 71 | 72 | try: 73 | mount_path, device_path, mounted_by_script = find_and_mount_usb() 74 | 75 | if not mount_path: 76 | yield {"type": "error", "message": "Aucune clé USB détectée. Vérifiez la connexion et le format de la clé (ex: FAT32, exFAT)."} 77 | return 78 | 79 | yield {"type": "progress", "stage": "DETECTED", "percent": 10, "message": f"Clé USB détectée : {mount_path}"} 80 | 81 | # Vider les dossiers de destination (source et préparé) avant l'import pour éviter les mélanges. 82 | prepared_usb_dir = Path("static/prepared/usb") 83 | if TARGET_DIR.exists(): 84 | shutil.rmtree(TARGET_DIR) 85 | TARGET_DIR.mkdir(parents=True, exist_ok=True) 86 | if prepared_usb_dir.exists(): 87 | shutil.rmtree(prepared_usb_dir) 88 | prepared_usb_dir.mkdir(parents=True, exist_ok=True) 89 | 90 | yield {"type": "progress", "stage": "SCANNING", "percent": 15, "message": "Analyse des images sur la clé USB..."} 91 | 92 | image_files = [] 93 | for root, dirs, files in os.walk(mount_path): 94 | for filename in files: 95 | if is_image_file(filename): 96 | image_files.append(Path(root) / filename) 97 | 98 | total = len(image_files) 99 | if total == 0: 100 | yield {"type": "error", "message": "Aucune image compatible trouvée sur la clé USB (formats supportés : JPG, JPEG, PNG, GIF)."} 101 | return 102 | 103 | yield {"type": "stats", "stage": "STATS", "percent": 20, "message": f"{total} images trouvées, début de l'import...", "total": total} 104 | 105 | for i, file_path in enumerate(image_files): 106 | # Vérifier si l'annulation a été demandée 107 | if CANCEL_FLAG.exists(): 108 | yield {"type": "warning", "message": "Importation annulée par l'utilisateur."} 109 | return 110 | 111 | try: 112 | dest_file = TARGET_DIR / file_path.name 113 | counter = 1 114 | original_dest = dest_file 115 | while dest_file.exists(): 116 | stem = original_dest.stem 117 | suffix = original_dest.suffix 118 | dest_file = TARGET_DIR / f"{stem}_{counter}{suffix}" 119 | counter += 1 120 | 121 | shutil.copy2(file_path, dest_file) 122 | 123 | percent = 20 + int(((i + 1) / total) * 60) 124 | yield { 125 | "type": "progress", "stage": "COPYING", "percent": percent, 126 | "message": f"Copie en cours... ({i + 1}/{total})", 127 | "current": i + 1, "total": total 128 | } 129 | except Exception as e: 130 | yield {"type": "warning", "message": f"Impossible de copier {file_path.name} : {str(e)}"} 131 | 132 | yield {"type": "done", "stage": "IMPORT_COMPLETE", "percent": 80, "message": f"{total} photos importées.", "total_imported": total} 133 | 134 | finally: 135 | # Cette section s'exécute toujours, même en cas d'erreur, pour nettoyer. 136 | if mounted_by_script and mount_path: 137 | yield {"type": "progress", "stage": "UNMOUNTING", "percent": 99, "message": "Démontage de la clé USB..."} 138 | time.sleep(1) 139 | try: 140 | umount_cmd = ['sudo', '/usr/bin/umount', str(mount_path)] 141 | subprocess.run(umount_cmd, check=True, capture_output=True, text=True, timeout=15) 142 | print(f"Démontage de {device_path} réussi.") 143 | # Supprimer le répertoire de montage temporaire 144 | shutil.rmtree(mount_path) 145 | print(f"Dossier de montage temporaire {mount_path} supprimé.") 146 | except Exception as e: 147 | yield {"type": "warning", "message": f"Avertissement : Impossible de démonter proprement la clé USB : {e}"} -------------------------------------------------------------------------------- /FAQ.md: -------------------------------------------------------------------------------- 1 | # ❓ Foire Aux Questions (FAQ) - Pimmich 2 | 3 | Voici une liste de questions fréquemment posées pour vous aider à utiliser et dépanner Pimmich. 4 | 5 | --- 6 | 7 | ### Questions Générales 8 | 9 | **Q : Qu'est-ce que Pimmich ?** 10 | 11 | **R :** Pimmich est un logiciel qui transforme un Raspberry Pi en un cadre photo numérique intelligent. Il peut afficher des photos depuis un serveur [Immich](https://immich.app/), une clé USB, un partage réseau (Samba/Windows), un smartphone ou même via l'application de messagerie Telegram. 12 | 13 | **Q : De quoi ai-je besoin pour utiliser Pimmich ?** 14 | 15 | **R :** Il vous faut : 16 | - Un Raspberry Pi (modèle 3, 4 ou 5 recommandé) avec son alimentation. 17 | - Une carte SD avec Raspberry Pi OS (64-bit) installé. 18 | - Un écran. 19 | - Une connexion Internet (Wi-Fi ou Ethernet). 20 | 21 | --- 22 | 23 | ### Installation et Configuration 24 | 25 | **Q : Comment installer Pimmich ?** 26 | 27 | **R :** L'installation est conçue pour être simple : 28 | 1. Clonez le dépôt GitHub : `git clone https://github.com/gotenash/pimmich.git` 29 | 2. Allez dans le dossier : `cd pimmich` 30 | 3. Rendez le script d'installation exécutable : `chmod +x setup.sh` 31 | 4. Lancez le script avec les droits d'administrateur : `sudo ./setup.sh` 32 | Le script s'occupe d'installer toutes les dépendances et de configurer le système pour un démarrage automatique. 33 | 34 | **Q : Comment accéder à l'interface de configuration ?** 35 | 36 | **R :** Une fois le Raspberry Pi démarré, ouvrez un navigateur web sur un autre appareil (ordinateur, smartphone) connecté au même réseau et tapez simplement l'adresse IP de votre Raspberry Pi. Par exemple : `http://192.168.1.25`. Si vous ne connaissez pas l'IP, elle est souvent affichée sur l'écran du cadre au premier démarrage ou si aucune photo n'est trouvée. 37 | 38 | **Q : J'ai oublié mon mot de passe pour l'interface web. Comment le réinitialiser ?** 39 | 40 | **R :** La méthode la plus simple pour regénérer un mot de passe est de relancer une partie du script d'installation. 41 | 1. Connectez-vous en SSH à votre Raspberry Pi. 42 | 2. Supprimez l'ancien fichier d'identifiants avec la commande : `sudo rm /boot/firmware/credentials.json` 43 | 3. Allez dans le dossier du projet : `cd pimmich` 44 | 4. Relancez le script d'installation : `sudo ./setup.sh` 45 | 46 | Le script est conçu pour être relancé sans risque. Il détectera l'absence du fichier d'identifiants et en créera un nouveau. Le nouveau mot de passe sera affiché directement dans le terminal à la fin du processus. Pensez à le noter ! 47 | 48 | **Q : Comment obtenir un Token API Immich ?** 49 | 50 | **R :** 51 | 1. Connectez-vous à votre interface web Immich. 52 | 2. Allez dans "Paramètres du compte" (via votre icône de profil). 53 | 3. Dans la section "Clés API", cliquez sur "Générer une nouvelle clé API". 54 | 4. Donnez-lui un nom (ex: "Pimmich") et copiez la clé générée. 55 | 56 | *Conseil :* Pour plus de sécurité, créez un utilisateur Immich dédié pour le cadre avec un accès limité à un seul album partagé. 57 | 58 | --- 59 | 60 | ### Fonctionnalités 61 | 62 | **Q : Comment fonctionne la fonction Telegram ?** 63 | 64 | **R :** Elle vous permet, ainsi qu'à des invités, d'envoyer des photos directement sur le cadre. 65 | 1. **Créez un bot** sur Telegram en parlant à `@BotFather`. Il vous donnera un **Token**. 66 | 2. **Obtenez votre ID utilisateur** Telegram en parlant à un bot comme `@userinfobot`. 67 | 3. Entrez ces deux informations dans l'onglet "Telegram" de Pimmich. 68 | 4. Vous pouvez ensuite créer des liens d'invitation sécurisés et temporaires pour vos proches. 69 | 70 | **Q : À quoi sert l'onglet "Favoris" ?** 71 | 72 | **R :** En marquant une photo comme favorite (via l'icône étoile dans l'onglet "Aperçu"), vous augmentez sa fréquence d'apparition dans le diaporama. Vous pouvez régler le "facteur de boost" dans l'onglet "Affichage" pour qu'elles apparaissent plus ou moins souvent. 73 | 74 | **Q : Qu'est-ce que l'effet "Carte Postale" ?** 75 | 76 | **R :** C'est un filtre qui ajoute un cadre blanc et un espace pour une légende (si vous en ajoutez une via l'interface) à vos photos, leur donnant un aspect de carte postale. Les photos envoyées via Telegram utilisent cet effet par défaut pour un rendu plus personnel et chaleureux. 77 | 78 | **Q : Quelles sont les commandes vocales disponibles ?** 79 | 80 | **R :** Une fois le contrôle vocal activé, commencez toutes vos commandes par le mot-clé **"Cadre Magique"**. Voici les commandes principales : 81 | 82 | * **Contrôle du Diaporama :** 83 | * *"photo suivante"* / *"photo précédente"* 84 | * *"pause"* / *"lecture"* (pour mettre en pause ou reprendre) 85 | * **Gestion de l'Affichage :** 86 | * *"règle la durée à 15 secondes"* 87 | * *"affiche pendant 30 secondes"* 88 | * **Gestion des Playlists & Sources :** 89 | * *"lance la playlist Vacances"* 90 | * *"afficher les cartes postales"* (lance un diaporama des photos Telegram) 91 | * *"activer la source Samba"* / *"désactiver la source USB"* 92 | * **Contrôle du Système :** 93 | * *"passer en mode veille"* (éteint l'écran) 94 | * *"réveiller le cadre"* (rallume l'écran) 95 | * *"éteindre le cadre"* (éteint complètement le Raspberry Pi) 96 | * *"revenir au diaporama principal"* (quitte une playlist en cours et relance le diaporama normal) 97 | 98 | --- 99 | 100 | ### Dépannage (Troubleshooting) 101 | 102 | **Q : J'ai un problème, où puis-je trouver de l'aide ?** 103 | 104 | **R :** L'onglet **Système** est le meilleur endroit pour commencer. Il contient une section **Logs**. 105 | - `app.py` contient les logs du serveur web (interface de configuration). 106 | - `local_slideshow_stdout` et `local_slideshow_stderr` contiennent les logs du diaporama lui-même. Les erreurs s'afficheront le plus souvent dans `stderr`. 107 | 108 | **Q : Mon écran ne s'éteint pas complètement, il reste rétro-éclairé. Que faire ?** 109 | 110 | **R :** Certains écrans ne répondent pas correctement à la commande de mise en veille logicielle (DPMS). La solution la plus fiable est d'utiliser une prise connectée Wi-Fi pour couper physiquement l'alimentation de l'écran. Pimmich intègre une fonctionnalité pour piloter ces prises. Allez dans l'onglet `Affichage`, activez l'option "Prise Connectée" et renseignez les URLs pour allumer et éteindre votre prise. De nombreuses prises (Tasmota, Shelly, Kasa, etc.) peuvent être contrôlées par de simples requêtes HTTP. 111 | 112 | **Q : Les vidéos ne sont pas fluides ou ne s'affichent pas. Que faire ?** 113 | 114 | **R :** Dans l'onglet **Affichage**, essayez d'activer l'option "**Activer le décodage vidéo matériel**". C'est beaucoup plus performant, surtout sur Raspberry Pi. Si cela cause des problèmes (écran noir/bleu après une vidéo), désactivez-la. 115 | 116 | **Q : Le Wi-Fi ne se connecte pas, mais l'Ethernet (câble) fonctionne. Pourquoi ?** 117 | 118 | **R :** Parfois, si un câble Ethernet est branché, le système lui donne la priorité. Dans l'onglet **Système**, vous pouvez essayer de désactiver temporairement l'interface "**Interface Filaire (eth0)**" pour forcer le système à utiliser exclusivement le Wi-Fi. 119 | 120 | **Q : Comment mettre à jour Pimmich ?** 121 | 122 | **R :** Allez dans l'onglet **Système** et cliquez sur le bouton "**Vérifier les mises à jour**". Pimmich se chargera de télécharger la dernière version depuis GitHub et de redémarrer automatiquement. 123 | 124 | **Q : J'ai modifié une configuration mais rien ne change sur le diaporama.** 125 | 126 | **R :** Certaines modifications, notamment celles liées à l'affichage (police, météo, etc.), nécessitent un redémarrage du diaporama pour être prises en compte. Vous pouvez le faire depuis l'onglet **Actions** en cliquant sur "Arrêter" puis "Démarrer le diaporama". Pour les changements plus profonds, un redémarrage de l'application web ou du système (depuis l'onglet **Système**) peut être nécessaire. -------------------------------------------------------------------------------- /utils/telegram_bot.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from pathlib import Path 3 | from telegram import Update 4 | from telegram.ext import Application, MessageHandler, filters, ContextTypes, CommandHandler 5 | from telegram import constants 6 | import asyncio 7 | import re 8 | 9 | # Configure logging 10 | logging.basicConfig( 11 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', 12 | level=logging.INFO 13 | ) 14 | logger = logging.getLogger(__name__) 15 | 16 | class PimmichBot: 17 | """ 18 | Encapsulates the Telegram bot logic for Pimmich. 19 | Handles user authorization, photo reception, and invitation codes. 20 | """ 21 | def __init__(self, token, authorized_users_str, guest_users, photo_callback, validation_callback): 22 | self.token = token 23 | try: 24 | self.authorized_users = {int(uid.strip()) for uid in authorized_users_str.split(',') if uid.strip().isdigit()} 25 | except (ValueError, TypeError): 26 | self.authorized_users = set() 27 | logger.warning("Could not parse telegram_authorized_users. No admin will be set.") 28 | 29 | self.guest_users = {int(uid): name for uid, name in guest_users.items()} 30 | self.photo_callback = photo_callback 31 | self.validation_callback = validation_callback 32 | 33 | # Build the application 34 | self.app = Application.builder().token(self.token).build() 35 | self._register_handlers() 36 | 37 | def _register_handlers(self): 38 | """Registers all the command and message handlers for the bot.""" 39 | self.app.add_handler(CommandHandler(["start", "help"], self.start_command_handler)) 40 | self.app.add_handler(MessageHandler(filters.PHOTO, self.photo_handler)) 41 | self.app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, self.text_message_handler)) 42 | # A fallback for any other message type 43 | self.app.add_handler(MessageHandler(~filters.COMMAND & ~filters.PHOTO & ~filters.TEXT, self.unsupported_message_handler)) 44 | 45 | def _is_user_authorized(self, user_id): 46 | """Checks if a user is either an admin or an authorized guest.""" 47 | return user_id in self.authorized_users or user_id in self.guest_users 48 | 49 | def _get_user_display_name(self, user): 50 | """Gets the display name for a user, preferring the guest name if available.""" 51 | if user.id in self.guest_users: 52 | return self.guest_users[user.id] 53 | return user.first_name 54 | 55 | async def start_command_handler(self, update: Update, context: ContextTypes.DEFAULT_TYPE): 56 | """Handles the /start and /help commands, including invitation code validation.""" 57 | user = update.effective_user 58 | 59 | # Case 1: The /start command includes an invitation code (e.g., /start MyCode123) 60 | if context.args: 61 | code = context.args[0] 62 | logger.info(f"User {user.id} ({user.first_name}) is attempting to use invitation code: {code}") 63 | # The validation callback is a sync function from app.py 64 | result = self.validation_callback(code, user.id, user.first_name) 65 | await update.message.reply_text(result['message']) 66 | # If validation was successful, update the bot's internal guest list 67 | if result.get('success'): 68 | self.guest_users[user.id] = result.get('guest_name', user.first_name) 69 | return 70 | 71 | # Case 2: The user is already authorized 72 | if self._is_user_authorized(user.id): 73 | display_name = self._get_user_display_name(user) 74 | help_text = ( 75 | f"👋 Bonjour {display_name} !\n\n" 76 | "Je suis le bot du cadre photo Pimmich. Voici comment m'utiliser :\n\n" 77 | "1️⃣ *Envoyez-moi une photo* pour l'afficher sur le cadre.\n" 78 | "2️⃣ *Ajoutez une légende* à votre photo pour qu'elle s'affiche en dessous.\n\n" 79 | "C'est tout ! Vos souvenirs apparaîtront comme par magie. ✨" 80 | ) 81 | await update.message.reply_text(help_text, parse_mode=constants.ParseMode.MARKDOWN) 82 | # Case 3: The user is not authorized and did not provide a code 83 | else: 84 | logger.warning(f"Unauthorized user {user.id} ({user.first_name}) tried to use the bot.") 85 | unauthorized_text = ( 86 | "🚫 Bonjour ! Pour envoyer des photos, vous avez besoin d'un code d'invitation.\n\n" 87 | "Veuillez demander le code à l'administrateur du cadre et envoyez-le moi directement, " 88 | "ou utilisez le lien d'invitation." 89 | ) 90 | await update.message.reply_text(unauthorized_text) 91 | 92 | async def photo_handler(self, update: Update, context: ContextTypes.DEFAULT_TYPE): 93 | """Handles photo messages from authorized users.""" 94 | user = update.effective_user 95 | 96 | if not self._is_user_authorized(user.id): 97 | logger.warning(f"Unauthorized user {user.id} ({user.first_name}) tried to send a photo.") 98 | await update.message.reply_text("🚫 Désolé, vous n'êtes pas autorisé à envoyer de photos.") 99 | return 100 | 101 | display_name = self._get_user_display_name(user) 102 | await update.message.reply_text("📬 Photo bien reçue ! Traitement en cours...") 103 | 104 | try: 105 | photo = update.message.photo[-1] # Highest resolution 106 | caption = update.message.caption or "" 107 | 108 | temp_dir = Path("cache/telegram_temp") 109 | temp_dir.mkdir(exist_ok=True) 110 | temp_photo_path = temp_dir / f"{user.id}_{photo.file_unique_id}.jpg" 111 | 112 | photo_file = await photo.get_file() 113 | await photo_file.download_to_drive(str(temp_photo_path)) 114 | logger.info(f"Photo from {display_name} ({user.id}) downloaded to {temp_photo_path}") 115 | 116 | # Call the synchronous callback in a separate thread to avoid blocking the event loop 117 | loop = asyncio.get_running_loop() 118 | await loop.run_in_executor(None, self.photo_callback, str(temp_photo_path), caption, display_name) 119 | 120 | await update.message.reply_text("✅ Votre photo a été ajoutée au cadre !") 121 | except Exception as e: 122 | logger.error(f"Error processing photo from {user.id}: {e}", exc_info=True) 123 | await update.message.reply_text(f"❌ Oups, une erreur est survenue lors du traitement de votre photo. L'administrateur a été notifié.") 124 | 125 | async def text_message_handler(self, update: Update, context: ContextTypes.DEFAULT_TYPE): 126 | """Handles plain text messages, checking for potential invitation codes.""" 127 | user = update.effective_user 128 | message_text = update.message.text.strip() 129 | 130 | # If user is already authorized, just give a helpful message 131 | if self._is_user_authorized(user.id): 132 | await update.message.reply_text("📷 Pour ajouter une photo, il suffit de me l'envoyer directement ! Vous pouvez y ajouter une légende.") 133 | return 134 | 135 | # If user is not authorized, treat the message as a potential invitation code 136 | logger.info(f"Potential invitation code '{message_text}' received from user {user.id} ({user.first_name}).") 137 | result = self.validation_callback(message_text, user.id, user.first_name) 138 | await update.message.reply_text(result['message']) 139 | 140 | # If validation was successful, update the bot's internal guest list 141 | if result.get('success'): 142 | self.guest_users[user.id] = result.get('guest_name', user.first_name) 143 | 144 | async def unsupported_message_handler(self, update: Update, context: ContextTypes.DEFAULT_TYPE): 145 | """Handles any message type that is not a command, photo, or text.""" 146 | user = update.effective_user 147 | if self._is_user_authorized(user.id): 148 | await update.message.reply_text("🤔 Je ne sais pas quoi faire avec ça. Essayez de m'envoyer une photo !") 149 | else: 150 | await update.message.reply_text("🚫 Pour commencer, veuillez m'envoyer votre code d'invitation.") 151 | 152 | def run(self): 153 | """ 154 | Lance le bot en mode polling. 155 | Désactive les gestionnaires de signaux (stop_signals=None) car le bot tourne 156 | dans un thread secondaire, ce qui est nécessaire pour éviter une erreur au démarrage. 157 | """ 158 | print("🤖 Bot Telegram Pimmich actif. En attente de messages...") 159 | self.app.run_polling(stop_signals=None) -------------------------------------------------------------------------------- /setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "=== [1/12] Mise à jour de la liste des paquets (apt update) ===" 4 | sudo apt update 5 | 6 | 7 | echo "=== [2/12] Installation des dépendances ===" 8 | # Remplacement de libatlas-base-dev (obsolète sur Debian 12+) par libopenblas-dev pour la compilation de numpy. 9 | if ! sudo apt install -y sway xterm python3 python3-venv python3-pip libjpeg-dev libopenjp2-7-dev libtiff-dev libopenblas-dev ffmpeg git cifs-utils smbclient network-manager jq mpv gettext portaudio19-dev unzip; then 10 | echo "L'installation avec portaudio19-dev a échoué. Tentative avec libportaudio-dev..." 11 | # Fallback pour les futures versions de Debian/Raspberry Pi OS où le paquet pourrait être renommé 12 | sudo apt install -y sway xterm python3 python3-venv python3-pip libjpeg-dev libopenjp2-7-dev libtiff-dev libopenblas-dev ffmpeg git cifs-utils smbclient network-manager jq mpv gettext libportaudio-dev unzip 13 | fi 14 | 15 | 16 | echo "=== [3/12] Désactivation de l'économie d'énergie Wi-Fi ===" 17 | # Installation conditionnelle de RPi.GPIO (pour le ventilateur) 18 | if [ -f "/etc/os-release" ]; then 19 | source /etc/os-release 20 | if [[ "$ID" == "raspbian" || "$ID" == "debian" ]]; then 21 | echo "Détection de Raspberry Pi OS (ou Debian). Installation de RPi.GPIO..." 22 | sudo apt-get install -y python3-rpi.gpio 23 | fi 24 | fi 25 | 26 | CONF_FILE="/etc/NetworkManager/conf.d/99-disable-wifi-powersave.conf" 27 | if [ ! -f "$CONF_FILE" ]; then 28 | echo "Création du fichier de configuration pour désactiver l'économie d'énergie Wi-Fi..." 29 | sudo tee "$CONF_FILE" > /dev/null <<'EOL' 30 | [connection] 31 | wifi.powersave = 2 32 | EOL 33 | echo "✅ Économie d'énergie Wi-Fi désactivée. Redémarrage de NetworkManager..." 34 | sudo systemctl restart NetworkManager 35 | sleep 2 # Petite pause pour laisser le service redémarrer 36 | else 37 | echo "✅ L'économie d'énergie Wi-Fi est déjà désactivée." 38 | fi 39 | 40 | echo "=== [4/12] Configuration automatique du fuseau horaire ===" 41 | DETECTED_TIMEZONE="UTC" # Valeur par défaut 42 | TIMEZONE_SOURCE="fallback" # Source par défaut 43 | 44 | echo "Tentative de détection du fuseau horaire via l'adresse IP publique..." 45 | # Utilise l'API ip-api.com et l'outil jq pour extraire le fuseau horaire 46 | TIMEZONE_FROM_API=$(curl -s http://ip-api.com/json | jq -r '.timezone') 47 | 48 | # Vérifie si la détection a réussi 49 | if [ -n "$TIMEZONE_FROM_API" ] && [ "$TIMEZONE_FROM_API" != "null" ]; then 50 | echo "Fuseau horaire détecté : $TIMEZONE_FROM_API" 51 | sudo timedatectl set-timezone "$TIMEZONE_FROM_API" 52 | DETECTED_TIMEZONE="$TIMEZONE_FROM_API" 53 | TIMEZONE_SOURCE="auto" 54 | echo "✅ Fuseau horaire configuré automatiquement. Heure actuelle : $(date)" 55 | else 56 | echo "⚠️ La détection automatique a échoué. Le fuseau horaire sera réglé sur UTC par défaut." 57 | sudo timedatectl set-timezone UTC 58 | echo "Vous pourrez le changer manuellement plus tard avec : sudo timedatectl set-timezone Votre/Ville" 59 | fi 60 | 61 | echo "=== [5/12] Installation et configuration de NGINX pour redirection sans :5000 ===" 62 | # Installer NGINX si ce n'est pas déjà fait 63 | if ! command -v nginx &> /dev/null; then 64 | echo "Installation de NGINX..." 65 | sudo apt install -y nginx 66 | else 67 | echo "✅ NGINX est déjà installé" 68 | fi 69 | 70 | # Supprimer la config par défaut si elle existe 71 | if [ -f /etc/nginx/sites-enabled/default ]; then 72 | sudo rm /etc/nginx/sites-enabled/default 73 | echo "⛔ Fichier de config par défaut supprimé" 74 | fi 75 | 76 | # Créer une nouvelle config pour Pimmich 77 | sudo tee /etc/nginx/sites-available/pimmich > /dev/null <<'EOL' 78 | server { 79 | listen 80; 80 | server_name _; 81 | 82 | client_max_body_size 200M; 83 | 84 | location / { 85 | proxy_pass http://127.0.0.1:5000; 86 | proxy_set_header Host \$host; 87 | proxy_set_header X-Real-IP \$remote_addr; 88 | proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; 89 | proxy_set_header X-Forwarded-Proto \$scheme; 90 | 91 | # Augmenter les timeouts pour les opérations longues comme l'import de vidéos 92 | proxy_connect_timeout 600s; 93 | proxy_send_timeout 600s; 94 | proxy_read_timeout 600s; 95 | } 96 | } 97 | EOL 98 | 99 | # Activer la nouvelle config 100 | if [ ! -f /etc/nginx/sites-enabled/pimmich ]; then 101 | sudo ln -s /etc/nginx/sites-available/pimmich /etc/nginx/sites-enabled/ 102 | echo "✅ Configuration Pimmich activée dans NGINX" 103 | fi 104 | 105 | # Redémarrer NGINX 106 | sudo systemctl restart nginx 107 | echo "✅ NGINX redémarré et prêt" 108 | 109 | echo "=== [6/12] Création de l’environnement Python ===" 110 | cd "$(dirname "$0")" 111 | python3 -m venv venv 112 | source venv/bin/activate 113 | pip install -r requirements.txt 114 | echo "Installation de la bibliothèque pour les QR codes..." 115 | pip install "qrcode[pil]" 116 | echo "Mise à jour de la bibliothèque pour le bot Telegram..." 117 | pip install --upgrade python-telegram-bot 118 | echo "Installation des bibliothèques pour le contrôle vocal..." 119 | pip install vosk sounddevice numpy resampy thefuzz python-Levenshtein pygame num2words 120 | echo "Réinstallation forcée de pvporcupine pour inclure les modèles de langue..." 121 | pip install --force-reinstall --no-cache-dir pvporcupine 122 | echo "Vérification manuelle et téléchargement du modèle de langue français si manquant..." 123 | 124 | SITE_PACKAGES_DIR=$(python3 -c "import site; print(site.getsitepackages()[0])") 125 | FR_MODEL_PATH="$SITE_PACKAGES_DIR/pvporcupine/lib/common/porcupine_params_fr.pv" 126 | 127 | if [ ! -f "$FR_MODEL_PATH" ]; then 128 | echo "⚠️ Le modèle de langue français est manquant. Tentative de téléchargement manuel..." 129 | FR_MODEL_URL="https://github.com/Picovoice/porcupine/raw/master/lib/common/porcupine_params_fr.pv" 130 | DEST_DIR=$(dirname "$FR_MODEL_PATH") 131 | mkdir -p "$DEST_DIR" 132 | if ! wget -q -O "$FR_MODEL_PATH" "$FR_MODEL_URL"; then 133 | echo "❌ ERREUR: Le téléchargement manuel du modèle de langue a échoué. Le contrôle vocal ne fonctionnera pas." 134 | else 135 | echo "✅ Modèle de langue français téléchargé et installé manuellement." 136 | fi 137 | else 138 | echo "✅ Le modèle de langue français est déjà présent." 139 | fi 140 | 141 | echo "=== [NOUVEAU] Téléchargement du son de notification vocale ===" 142 | SOUNDS_DIR="static/sounds" 143 | NOTIFICATION_SOUND="$SOUNDS_DIR/ding.wav" 144 | if [ ! -f "$NOTIFICATION_SOUND" ]; then 145 | echo "Son de notification non trouvé. Téléchargement..." 146 | mkdir -p "$SOUNDS_DIR" 147 | wget -q -O "$NOTIFICATION_SOUND" "https://github.com/actions/sounds/raw/main/sounds/notification.wav" 148 | echo "✅ Son de notification installé." 149 | else 150 | echo "✅ Le son de notification est déjà présent." 151 | fi 152 | echo "=== [NOUVEAU] Téléchargement du modèle de reconnaissance vocale (Vosk) ===" 153 | MODELS_DIR="models" 154 | VOSK_MODEL_DIR="$MODELS_DIR/vosk-model-small-fr-0.22" 155 | VOSK_ZIP_FILE="$MODELS_DIR/vosk-model-fr.zip" 156 | 157 | if [ ! -d "$VOSK_MODEL_DIR" ]; then 158 | echo "Le modèle Vosk n'est pas trouvé. Téléchargement..." 159 | mkdir -p "$MODELS_DIR" 160 | wget -q --show-progress -O "$VOSK_ZIP_FILE" "https://alphacephei.com/vosk/models/vosk-model-small-fr-0.22.zip" 161 | unzip -o "$VOSK_ZIP_FILE" -d "$MODELS_DIR" 162 | rm "$VOSK_ZIP_FILE" 163 | echo "✅ Modèle Vosk français installé." 164 | else 165 | echo "✅ Le modèle Vosk français est déjà présent." 166 | fi 167 | echo "=== [NOUVEAU] Téléchargement du modèle de reconnaissance vocale Anglais (Vosk) ===" 168 | VOSK_MODEL_EN_DIR="$MODELS_DIR/vosk-model-small-en-us-0.15" 169 | VOSK_ZIP_EN_FILE="$MODELS_DIR/vosk-model-en.zip" 170 | 171 | if [ ! -d "$VOSK_MODEL_EN_DIR" ]; then 172 | echo "Le modèle Vosk Anglais n'est pas trouvé. Téléchargement..." 173 | mkdir -p "$MODELS_DIR" 174 | wget -q --show-progress -O "$VOSK_ZIP_EN_FILE" "https://alphacephei.com/vosk/models/vosk-model-small-en-us-0.15.zip" 175 | unzip -o "$VOSK_ZIP_EN_FILE" -d "$MODELS_DIR" 176 | rm "$VOSK_ZIP_EN_FILE" 177 | echo "✅ Modèle Vosk Anglais installé." 178 | else 179 | echo "✅ Le modèle Vosk Anglais est déjà présent." 180 | fi 181 | 182 | echo "=== [7/12] Création de l'arborescence des dossiers nécessaires ===" 183 | mkdir -p logs 184 | mkdir -p cache 185 | mkdir -p static/photos 186 | mkdir -p static/prepared 187 | mkdir -p static/pending_uploads 188 | mkdir -p config 189 | echo "✅ Arborescence des dossiers créée." 190 | 191 | # Corriger les permissions même si script lancé avec sudo 192 | REAL_USER=$(logname) 193 | sudo chown -R "$REAL_USER:$REAL_USER" static logs cache config 194 | chmod -R u+rwX static logs cache config 195 | 196 | echo "=== [8/12] Création du fichier d'identification sécurisé ===" 197 | CREDENTIALS_FILE="/boot/firmware/credentials.json" 198 | 199 | if [ ! -f "$CREDENTIALS_FILE" ]; then 200 | echo "Génération d'un mot de passe aléatoire et création du fichier d'identification..." 201 | # On utilise sudo pour exécuter le script python qui a besoin des droits pour écrire dans /boot/firmware 202 | # Le script python est exécuté via l'interpréteur de l'environnement virtuel pour avoir accès à werkzeug. 203 | # Le script affichera lui-même le mot de passe généré. 204 | sudo venv/bin/python3 utils/create_initial_user.py --output "$CREDENTIALS_FILE" 205 | else 206 | echo "✅ Le fichier d'identification $CREDENTIALS_FILE existe déjà. Aucune modification." 207 | fi 208 | 209 | echo "=== [9/12] Configuration du démarrage en mode console (CLI) ===" 210 | sudo raspi-config nonint do_boot_behaviour B2 211 | echo "✅ Système configuré pour démarrer en mode console avec auto-login." 212 | 213 | 214 | echo "=== [10/12] Configuration du lancement automatique de Sway ===" 215 | BASH_PROFILE="/home/pi/.bash_profile" 216 | if ! grep -q 'exec sway' "$BASH_PROFILE"; then 217 | echo 'if [[ -z $DISPLAY ]] && [[ $(tty) = /dev/tty1 ]]; then' >> "$BASH_PROFILE" 218 | echo ' exec sway' >> "$BASH_PROFILE" 219 | echo 'fi' >> "$BASH_PROFILE" 220 | echo "Ajout du démarrage automatique de Sway dans $BASH_PROFILE" 221 | else 222 | echo "✅ Sway déjà configuré pour se lancer automatiquement" 223 | fi 224 | 225 | echo "=== [11/12] Configuration du lancement automatique de Pimmich dans Sway ===" 226 | SWAY_CONFIG_DIR="/home/pi/.config/sway" 227 | SWAY_CONFIG_FILE="$SWAY_CONFIG_DIR/config" 228 | mkdir -p "$SWAY_CONFIG_DIR" 229 | 230 | # Rendre exécutable 231 | chmod +x /home/pi/pimmich/start_pimmich.sh 232 | 233 | # Ajout de l'exec_always si absent 234 | if ! grep -q 'start_pimmich.sh' "$SWAY_CONFIG_FILE" 2>/dev/null; then 235 | echo 'exec_always --no-startup-id /home/pi/pimmich/start_pimmich.sh' >> "$SWAY_CONFIG_FILE" 236 | echo "Ajout de start_pimmich.sh dans la config sway" 237 | else 238 | echo "✅ start_pimmich.sh déjà présent dans la config sway" 239 | fi 240 | 241 | echo "=== [12/12] Installation terminée ===" 242 | echo "✅ Installation terminée. Redémarrez pour lancer automatiquement Sway + Pimmich." 243 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🖼️ Pimmich – Cadre photo connecté intelligent 2 | 3 | Pimmich est une application Python conçue pour transformer un Raspberry Pi en un cadre photo numérique intelligent et personnalisable. Il peut afficher des photos depuis de multiples sources, être contrôlé à la voix, et bien plus encore. 4 | 5 | Pimmich Logo 6 | 7 | --- 8 | 9 | ## 📖 Table des matières 10 | 11 | - [✨ Fonctionnalités Principales](#-fonctionnalités-principales) 12 | - [🧰 Technologies utilisées](#-technologies-utilisées) 13 | - [🚀 Installation](#-installation) 14 | - [🔧 Configuration](#-configuration) 15 | - [🗣️ Contrôle Vocal](#️-contrôle-vocal) 16 | - [❓ Dépannage (FAQ)](#-dépannage-faq) 17 | - [🛣️ Feuille de Route](#️-feuille-de-route) 18 | - [Juin 2025](#-juin-2025-) 19 | - [Juillet 2025](#-juillet-2025---version-actuelle) 20 | - [Août 2025](#️-août-2025----sortie-prévue-le-15-aoüt) 21 | - [💖 Crédits](#-crédits) 22 | 23 | --- 24 | 25 | ## ✨ Fonctionnalités Principales 26 | 27 | Pimmich est riche en fonctionnalités pour offrir une expérience complète et personnalisable : 28 | 29 | #### 🖼️ **Affichage & Diaporama** 30 | - **Multi-sources :** Affichez des photos depuis [Immich](https://immich.app/), un partage réseau (Samba/Windows), une clé USB, un smartphone ou via Telegram. 31 | - **Personnalisation avancée :** Réglez la durée d'affichage, les heures d'activité, les transitions (fondu, glissement), et activez un effet de mouvement "Pan & Zoom". 32 | - **Filtres créatifs :** Appliquez des filtres à vos photos (Noir & Blanc, Sépia, Vintage) et des effets uniques comme le **Polaroid** ou la **Carte Postale**. 33 | - **Gestion des formats :** Prise en charge intelligente des photos portraits (fond flou) et des vidéos (avec son et accélération matérielle optionnelle). 34 | 35 | #### ⚙️ **Interface & Contrôle** 36 | - **Interface Web complète :** Une page de configuration locale, sécurisée par mot de passe et organisée en onglets clairs (Diaporama, Contenu, Interactions, Maintenance). 37 | - **Contrôle Vocal :** Pilotez votre cadre avec des commandes vocales comme *"Cadre Magique, photo suivante"* ou *"Cadre Magique, lance la playlist Vacances"*. 38 | - **Gestion de contenu :** 39 | - **Playlists :** Créez des albums virtuels, réorganisez les photos par glisser-déposer et lancez des diaporamas thématiques. 40 | - **Favoris :** Marquez vos photos préférées pour qu'elles apparaissent plus souvent. 41 | - **Légendes :** Ajoutez du texte personnalisé sur vos photos et cartes postales. 42 | 43 | #### 🌐 **Connectivité & Interactions** 44 | - **Telegram :** Permettez à vos proches d'envoyer des photos sur le cadre via un bot Telegram, avec un système d'invitations sécurisé et temporaire. 45 | - **Wi-Fi & Réseau :** Configurez le Wi-Fi, scannez les réseaux et gérez les interfaces réseau directement depuis l'interface. 46 | - **Envoi depuis Smartphone :** Importez des photos directement depuis le navigateur de votre téléphone. 47 | 48 | #### 🛠️ **Maintenance & Monitoring** 49 | - **Mise à jour facile :** Mettez à jour Pimmich en un clic depuis l'interface. 50 | - **Sauvegarde & Restauration :** Sauvegardez et restaurez l'ensemble de votre configuration. 51 | - **Monitoring système :** Suivez en temps réel la température, l'utilisation du CPU, de la RAM et du disque avec des graphiques d'historique. 52 | - **Logs détaillés :** Accédez aux journaux de chaque service (serveur web, diaporama, contrôle vocal) pour un dépannage facile. 53 | 54 | --- 55 | 56 | ## 🧰 Technologies utilisées 57 | 58 | - **Backend :** Python, Flask 59 | - **Frontend :** HTML, TailwindCSS, JavaScript 60 | - **Diaporama :** Pygame 61 | - **Traitement d'image :** Pillow 62 | - **Contrôle Vocal :** Picovoice Porcupine (mot-clé) & Vosk (reconnaissance) 63 | - **Serveur Web :** NGINX (en tant que reverse proxy) 64 | 65 | --- 66 | 67 | ## 🚀 Installation 68 | 69 | Il existe deux méthodes pour installer Pimmich. 70 | 71 | ### Méthode 1 : Image pré-configurée (Recommandée et plus simple) 72 | 73 | Cette méthode est idéale pour une première installation rapide. 74 | 75 | 1. **Téléchargez l'image du mois en cours** 76 | Rendez-vous sur la page des [Releases de Pimmich](https://github.com/gotenash/pimmich/releases) et téléchargez le fichier `.img` de la dernière version. 77 | 78 | 2. **Flashez l'image sur une carte SD** 79 | Utilisez un logiciel comme Raspberry Pi Imager ou BalenaEtcher pour écrire le fichier image que vous venez de télécharger sur votre carte microSD. 80 | 81 | 3. **Démarrez votre Raspberry Pi** 82 | Insérez la carte SD dans le Raspberry Pi, branchez l'écran et l'alimentation. Pimmich démarrera automatiquement. 83 | 84 | ### Méthode 2 : Installation manuelle depuis le dépôt Git 85 | 86 | Cette méthode est destinée aux utilisateurs avancés ou à ceux qui souhaitent suivre le développement de près. 87 | 88 | #### ✅ Pré-requis 89 | 90 | - Un Raspberry Pi (modèle 3B+, 4 ou 5 recommandé) avec Raspberry Pi OS Desktop (64-bit). 91 | - Une carte SD, une alimentation, un écran. 92 | - Une connexion Internet. 93 | 94 | #### 📝 Étapes d'installation 95 | 96 | 1. **Clonez le dépôt** 97 | Ouvrez un terminal sur votre Raspberry Pi et exécutez : 98 | ```bash 99 | git clone https://github.com/gotenash/pimmich.git 100 | cd pimmich 101 | ``` 102 | 103 | 2. **Lancez le script d'installation** 104 | Ce script installe toutes les dépendances, configure l'environnement et prépare le démarrage automatique. 105 | ```bash 106 | chmod +x setup.sh 107 | sudo ./setup.sh 108 | ``` 109 | 110 | 3. **Redémarrez** 111 | Une fois le script terminé, un redémarrage est nécessaire pour que tous les services se lancent correctement. 112 | ```bash 113 | sudo reboot 114 | ``` 115 | 116 | --- 117 | 118 | ## 🔧 Configuration 119 | 120 | ### 1. Première Connexion 121 | 122 | - Au redémarrage, le cadre affichera un QR Code et l'adresse IP du Raspberry Pi. 123 | - Scannez le QR Code ou ouvrez un navigateur sur un autre appareil (PC, smartphone) sur le même réseau et entrez l'adresse IP (ex: `http://192.168.1.XX`). 124 | - Le mot de passe initial est généré aléatoirement lors de l'installation et est stocké dans le fichier `/boot/firmware/credentials.json`. Il est fortement recommandé de le changer via l'interface (onglet `Système` > `Sécurité`). 125 | 126 | ### 2. Configuration des Sources 127 | 128 | - **Immich :** Pour connecter votre serveur Immich, vous aurez besoin de : 129 | 1. **L'URL de votre serveur** (ex: `http://192.168.1.YY:2283`). 130 | 2. **Un Token API :** Dans Immich, allez dans `Paramètres du compte` > `Clés API` > `Générer une nouvelle clé`. 131 | 3. **Le nom technique de l'album** que vous souhaitez afficher. 132 | - **Samba / Partage Windows :** Renseignez les informations de votre partage réseau. 133 | - **USB :** Branchez une clé USB et utilisez le bouton d'import dans l'onglet `Actions`. 134 | 135 | ### 3. Personnalisation 136 | 137 | Explorez les différents onglets pour personnaliser votre cadre : 138 | - **Diaporama > Affichage :** Réglez les durées, les transitions, les effets... 139 | - **Diaporama > Horloge & Infos :** Activez et configurez l'affichage de l'heure, de la météo ou des marées. 140 | - **Contenu > Sources :** Cochez les sources que vous souhaitez voir dans le diaporama. 141 | 142 | --- 143 | 144 | ## 🗣️ Contrôle Vocal 145 | 146 | Pimmich supporte le contrôle vocal en **Français** et en **Anglais**. 147 | 148 | Pour utiliser un mot-clé personnalisé ("Cadre Magique", "Magic Frame"...), une étape manuelle est requise : 149 | 1. Créez un compte gratuit sur la Picovoice Console. 150 | 2. Allez dans la section "Porcupine" et créez votre mot-clé personnalisé. 151 | 3. Téléchargez le modèle pour la plateforme **Raspberry Pi**. 152 | 4. Renommez le fichier `.ppn` téléchargé en `cadre-magique_raspberry-pi.ppn` (pour le français) ou `magic-frame_raspberry-pi_en.ppn` (pour l'anglais, exemple). 153 | 5. Placez ce fichier dans le dossier `voice_models` à la racine du projet Pimmich. 154 | 6. Dans l'interface Pimmich, allez dans l'onglet `Vocal`, sélectionnez la langue, entrez votre "Access Key" Picovoice et activez le service. 155 | 156 | ### Commandes Disponibles 157 | 158 | Une fois le contrôle vocal activé, commencez toutes vos commandes par le mot-clé **"Cadre Magique"**. 159 | 160 | **Contrôle du Diaporama :** 161 | - *"photo suivante"* 162 | - *"photo précédente"* 163 | - *"pause"* / *"lecture"* (pour mettre en pause ou reprendre) 164 | 165 | **Gestion de l'Affichage :** 166 | - *"règle la durée à 15 secondes"* 167 | - *"affiche pendant 30 secondes"* 168 | 169 | **Gestion des Playlists & Sources :** 170 | - *"lance la playlist Vacances"* 171 | - *"afficher les cartes postales"* (lance un diaporama des photos Telegram) 172 | - *"activer la source Samba"* 173 | - *"désactiver la source USB"* 174 | 175 | **Contrôle du Système :** 176 | - *"passer en mode veille"* (éteint l'écran) 177 | - *"réveiller le cadre"* (rallume l'écran) 178 | - *"éteindre le cadre"* (éteint complètement le Raspberry Pi) 179 | - *"revenir au diaporama principal"* (quitte une playlist et relance le diaporama normal) 180 | 181 | --- 182 | 183 | ## ❓ Dépannage (FAQ) 184 | 185 | Pour toute question ou problème, consultez notre **Foire Aux Questions (FAQ.md)**. Vous y trouverez des solutions aux problèmes courants (Wi-Fi, affichage, etc.) et des astuces pour utiliser Pimmich au mieux. 186 | 187 | --- 188 | 189 | ## 🛣️ Feuille de Route 190 | 191 | Voici un aperçu des fonctionnalités à venir : 192 | 193 | - **Octobre 2025 :** 194 | - 📱 Création d’une application Android "télécommande" pour contrôler le cadre. 195 | - 🔘 Gestion d'un bouton physique pour interagir avec le diaporama. 196 | 197 | - **Idées pour le futur :** 198 | - 📺 Version pour Android TV. 199 | - ☁️ Import depuis d'autres services comme Google Photos ou PhotoPrism. 200 | 201 | --- 202 | 203 | ## 💖 Crédits 204 | 205 | - **Auteurs :** Gotenash et Shenron 206 | - **Projet du :** Gadgetaulab 207 | 208 |

209 | 210 | Logo Gadgetaulab 211 | 212 |

213 | 214 | 215 | 216 | > 🗓️ À partir de juin 2025 — Une version majeure chaque mois 217 | 218 | ## 🗓️ Octobre 2025 - (En cours de développement) 219 | - 📱 Création d’une APK Android pour contrôler le cadre (Pimmich télécommande) 220 | - 🗣️ Ajout de la commande vocale ("Cadre Magique") pour piloter le cadre (photo suivante, pause, etc.). 221 | - 🔘 Gestion d'un bouton physique pour démarrer/arrêter le diaporama. 222 | - 🗂️ Gestion des albums directement depuis l'interface Pimmich (créer, renommer, etc.). 223 | 224 | ## ✅ Septembre 2025 - (Sortie prévue le 15 septembre) 225 | - 🎨 **Refonte de l'Interface :** Nouvelle navigation par groupes et onglets pour une expérience plus claire et intuitive. 226 | - 🎵 **Gestion de Playlists Améliorée :** 227 | - ✅ Nouvel écran de lancement dynamique avec un pêle-mêle de photos sur un fond en liège. 228 | - ✅ Créez des albums virtuels, visualisez leur contenu, renommez-les et lancez des diaporamas thématiques. 229 | - 🗣️ **Amélioration du Contrôle Vocal :** 230 | - ✅ Ajout de la commande "Revenir au diaporama principal". 231 | - ✅ Fiabilisation du lancement des playlists par la voix. 232 | - 📊 **Monitoring Avancé :** Ajout de graphiques d'historique pour la température, l'utilisation du CPU, de la RAM et du disque. 233 | - 🖥️ **Gestion de l'Affichage :** Possibilité de lister et de forcer une résolution d'écran spécifique directement depuis l'interface. 234 | - 💾 **Extension du Stockage :** Ajout d'un outil dans l'interface pour étendre facilement le système de fichiers. 235 | - 🚀 **Optimisations et Stabilité :** 236 | - ✅ Amélioration de la réactivité de l'onglet "Système" grâce à une lecture optimisée des logs. 237 | - ✅ Fiabilisation du script de mise à jour pour éviter les blocages. 238 | - ✅ Nombreuses corrections de bugs pour une meilleure stabilité générale. 239 | 240 | ## 🛠️✅ Août 2025 - (Version précédente) 241 | 242 | - ✅ Gestion des vidéos 243 | - ✅ Ajout d'une vignette lors de lecture de la vidéo (onglet Actions) 244 | - ✅ Ajout de la fonction "Carte Postle" par telegram 245 | - ✅ sécurisation par lien d'invitation 246 | - ✅ Gestion accélération matérielle pour Pi3 247 | - ✅ Ajout traduction de l'application (Anglais et Espagnol) 248 | - ✅ Ajout QR Code première connexion 249 | - ✅ Ajout de l'effet "Carte postale" pour toutes les sources de photos 250 | - ✅ Ajout de la fonctionnalité "Ajout de texte" 251 | - ✅ Ajout d'un bouton "Redémarrer l'appli Web" 252 | - ✅ Ajout de l'onglet Favoris (augmentation fréquence d'affichage d'une photo) 253 | - ✅ Modification météo et marées pour afficher 3 jours de prévisions 254 | - ✅ Corrections des bugs 255 | - ✅ Heure début d'affichage 256 | - ✅ Effacement des logs dans l'onglet système sans problème de container 257 | 258 | 259 | ## ✅ Juillet 2025 - Version actuelle 260 | 261 | - ✅ 🧭 Ajout de la configuration Wi-Fi depuis l’interface Web 262 | - ✅ 🗂️ Réorganisation de la page de configuration en onglets 263 | - ✅ 🔁 Mise à jour automatique périodique de l’album Immich 264 | - ✅ 📁 Support du protocole SMB pour accéder à des photos partagées en réseau 265 | - ✅ ⏰🌤️ Affichage de l’heure et de la météo sur l’écran 266 | - ✅ Ajout NGINX, plus besoin de mettre le numéro du port (50000) 267 | - ✅ Ajout des filtres (NB, Sépia, Polaroid ...) 268 | - ✅ Ajout des différents boutons supprimer 269 | - ✅ Ajout d'une option de sauvegarde de la configuration 270 | - ✅ Ajout d'un menu changement du mot de passe 271 | - ✅ Ajout de la création du credenrials.json pendant le setup 272 | - ✅ Ajout effet de transition 273 | - ✅ Ajout détection automatique de la résolution 274 | - ✅ Ajout de l'import à partir d'un smartphone (en admin et mode invité) 275 | - ✅ Interface de validation des photos proposées en mode invité 276 | - ✅ Ajout des logs dans l'onglet Système 277 | - ✅ Ajout des stats du Raspberry (température, stockage Ram, charge processeur) 278 | 279 | ## ✅ Juin 2025 – 280 | 281 | - ✅ Aperçus des photos avec suppression possible 282 | - ✅ Véritable mise en veille de l’écran (gestion via `wlr-randr`) 283 | - ✅ Paramètre de hauteur utile de l’écran (% d’écran utilisé) 284 | - ✅ Correction de l’orientation via EXIF et préparation des images 285 | 286 | 287 | 288 | 289 | ## 💡 Idées pour les versions suivantes 290 | 291 | - 📱 Création d’une APK Android pour contrôler le cadre 292 | - Pimmich télécomande 293 | - Pimmich Android TV 294 | - Import Google Photos 295 | - Import PhotoPrism 296 | 297 | 298 | 299 | ### Récupérer la Clef API (Token Immich) 300 | 301 | 🧭 1. Se connecter à l'interface web d’Immich 302 | 303 | ⚙️ 2. Accéder à la page "Paramètres du compte" 304 | Une fois connecté : 305 | 306 | Clique sur l’icône de profil (en haut à droite) ou ton nom d'utilisateur. 307 | 308 | ![Paramètre du compte](https://drive.google.com/uc?id=1_c12UZ7g8IwsL99xP55eB4qqacGAY8Kc) 309 | 310 | 311 | Sélectionne “Account settings” ou “Paramètres du compte”. 312 | 313 | ![Menu Clef API](https://drive.google.com/uc?id=1rofAi6HNhvJbBh2D_AUsedj3HwSrQHjP) 314 | 315 | 316 | 🧪 3. Générer un nouveau token API 317 | Dans la section "API Key" ou "Clés API" : 318 | 319 | ![Menu Clef API](https://drive.google.com/uc?id=1HrBVgvR4UXdkhLj-4KDohufr5nt57t2G) 320 | 321 | Clique sur “Generate new API Key” ou “Générer une nouvelle clé API”. 322 | ![Menu Clef API](https://drive.google.com/uc?id=1dRBQMs0dsdM7vKlEuUzBnMmzzH3RNplc) 323 | 324 | 325 | 326 | Donne un nom à ta clé, par exemple : 327 | PimmichFrame 328 | 329 | ✅ Une fois générée, une clé s'affiche. C’est le token à copier. 330 | 331 | ![Menu Clef API](https://drive.google.com/uc?id=1hyt14hFPN3XEBu_0rh9XYIgLdXJau22y) 332 | 333 | ⚠️ Attention 334 | 335 | Tu ne pourras plus voir cette clé après avoir quitté la page. Si tu la perds, il faudra en générer une nouvelle. 336 | 337 | Ne partage jamais ce token publiquement. Il donne un accès total à tes albums Immich. 338 | 339 | Le mieux est de créer un compte Immich réservé au cadre photo avec accès à un seul album que tu pourras alimenter à partir d'un autre compte. 340 | 341 | ### Se connecter à Pimmich 342 | 343 | Dans un navigateur taper l'adresse ip du raspberry : http://xxx.xxx.xxx.xxx:5000 344 | 345 | ![Menu Clef API](https://drive.google.com/uc?id=1VynC6umiYqPaln_kAb_DDd990YUkbT88) 346 | 347 | 348 | ### Page de configuration 349 | 350 | ## configuration du diaporama 351 | 352 | Dans ce cadre vous pourrez régler le temps d'affichage de chaque photo (pour l'instant on ne peut descendre en dessous de 10 secondes surement du temps de traitement des photos porttrait pour générer le fond flou). Vous pouvez aussi défoinir les heures où le diaporama fonctionnera. 353 | 354 | ![Menu Clef API](https://drive.google.com/uc?id=1t_7MCKNNfHfTi5Pjc7_hDxbDzU18UvO7) 355 | 356 | ## configuration de l'import des photos 357 | 358 | ![Menu Clef API](https://drive.google.com/uc?id=1AwUgYbzGcdskt99q32VlaOc7jM303Tbd) 359 | -------------------------------------------------------------------------------- /utils/prepare_all_photos.py: -------------------------------------------------------------------------------- 1 | import os 2 | from PIL import Image, ImageFilter, ImageDraw, ImageFont 3 | import json 4 | import pillow_heif 5 | import subprocess, sys 6 | from utils.config import load_config # Import load_config to get screen_height_percent 7 | import piexif 8 | from utils.image_filters import create_polaroid_effect, create_postcard_effect 9 | from utils.exif import get_rotation_angle # Import get_rotation_angle for EXIF rotation 10 | import re 11 | from pathlib import Path 12 | 13 | # Configuration 14 | SOURCE_DIR = "static/photos" 15 | # Default target output resolution for prepared images (fallback if screen resolution cannot be detected) 16 | DEFAULT_OUTPUT_WIDTH = 1920 17 | DEFAULT_OUTPUT_HEIGHT = 1080 18 | VIDEO_EXTENSIONS = ('.mp4', '.mov', '.avi', '.mkv') 19 | 20 | # Chemin vers le cache du mappage des descriptions (utilisé par l'import Immich) 21 | DESCRIPTION_MAP_CACHE_FILE = Path("cache") / "immich_description_map.json" 22 | # NOUVEAU: Chemin vers le cache des textes saisis par l'utilisateur 23 | USER_TEXT_MAP_CACHE_FILE = Path("cache") / "user_texts.json" 24 | CANCEL_FLAG = Path('/tmp/pimmich_cancel_import.flag') 25 | 26 | def prepare_photo(source_path, dest_path, output_width, output_height, source_type=None, caption=None): 27 | """Prépare une photo pour l'affichage avec redimensionnement, rotation EXIF et fond flou.""" 28 | config = load_config() 29 | # Get screen_height_percent from config, default to 100 if not found or invalid 30 | screen_height_percent = int(config.get("screen_height_percent", "100")) 31 | 32 | # Calculate the effective height for the photo content within the output resolution 33 | effective_photo_height = int(output_height * (screen_height_percent / 100)) 34 | 35 | try: 36 | # Gestion des fichiers HEIF/HEIC 37 | if source_path.lower().endswith(('.heic', '.heif')): 38 | pillow_heif.register_heif_opener() 39 | 40 | img = Image.open(source_path) 41 | 42 | # 1. Handle EXIF Orientation 43 | rotation_angle = get_rotation_angle(img) 44 | if rotation_angle != 0: 45 | img = img.rotate(rotation_angle, expand=True) 46 | 47 | # Remove EXIF data after rotation to prevent re-interpretation by viewers 48 | if "exif" in img.info: 49 | img.info.pop("exif") 50 | if "icc_profile" in img.info: # Also remove ICC profile as it can sometimes cause issues after rotation 51 | img.info.pop("icc_profile") 52 | 53 | # Convert to RGB if necessary (for consistency and compatibility with JPEG saving) 54 | if img.mode in ('RGBA', 'LA', 'P'): 55 | img = img.convert('RGB') 56 | 57 | # Determine if the image is "portrait-like" relative to the effective display area. 58 | # This means its aspect ratio is smaller than the display area's aspect ratio. 59 | img_aspect_ratio = img.width / img.height 60 | display_area_aspect_ratio = output_width / effective_photo_height 61 | 62 | if img_aspect_ratio < display_area_aspect_ratio: 63 | # This is a portrait-oriented photo relative to the display area. 64 | # We will fit its height to effective_photo_height and add a blurred background. 65 | 66 | # Resize the original image to fit within the effective photo height, maintaining aspect ratio 67 | img_content = img.copy() 68 | img_content.thumbnail((output_width, effective_photo_height), Image.Resampling.LANCZOS) 69 | 70 | # Create the blurred background image 71 | # First, resize the original image to fill the entire output_width x output_height area 72 | # This ensures the blur covers the whole background without black bars from the blur itself 73 | bg_img = img.copy() 74 | bg_img.thumbnail((output_width, output_height), Image.Resampling.LANCZOS) 75 | 76 | # If the background image is still smaller than the full output size after thumbnail, 77 | # expand it to fill the full output size (this might crop parts of the image) 78 | # This is to ensure the blur covers the entire screen. 79 | if bg_img.width < output_width or bg_img.height < output_height: 80 | bg_img = bg_img.resize((output_width, output_height), Image.Resampling.LANCZOS) 81 | 82 | # Apply Gaussian blur 83 | bg_img = bg_img.filter(ImageFilter.GaussianBlur(radius=50)) # Radius can be adjusted 84 | 85 | # Create a new blank image for the final output (full screen size) 86 | final_img = Image.new('RGB', (output_width, output_height), (0, 0, 0)) 87 | 88 | # Paste the blurred background onto the final image 89 | final_img.paste(bg_img, (0, 0)) 90 | 91 | # Calculate position to center the resized original image (img_content) 92 | # horizontally and vertically on the final canvas. 93 | x_offset = (output_width - img_content.width) // 2 94 | y_offset = (output_height - img_content.height) // 2 95 | final_img.paste(img_content, (x_offset, y_offset)) 96 | 97 | else: 98 | # Landscape or square photo relative to the display area. 99 | # Fit its width to OUTPUT_WIDTH or height to effective_photo_height, no blur background, center on black. 100 | img_content = img.copy() 101 | img_content.thumbnail((output_width, effective_photo_height), Image.Resampling.LANCZOS) 102 | final_img = Image.new('RGB', (output_width, output_height), (0, 0, 0)) 103 | 104 | # Calculate position to center the resized image on the final canvas. 105 | x_offset = (output_width - img_content.width) // 2 106 | y_offset = (output_height - img_content.height) // 2 107 | final_img.paste(img_content, (x_offset, y_offset)) 108 | 109 | # --- Logique commune pour la création des métadonnées et des versions alternatives --- 110 | 111 | # 1. Préparer les métadonnées EXIF avec les coordonnées du contenu 112 | exif_bytes_to_add = None 113 | try: 114 | exif_dict = {"0th": {}, "Exif": {}, "GPS": {}, "1st": {}, "thumbnail": None} 115 | bbox_str = f"pimmich_bbox:{x_offset},{y_offset},{img_content.width},{img_content.height}" 116 | exif_dict["Exif"][piexif.ExifIFD.UserComment] = bbox_str.encode('ascii') 117 | exif_bytes_to_add = piexif.dump(exif_dict) 118 | except Exception as exif_e: 119 | print(f"[EXIF] Avertissement: Impossible de créer les métadonnées pour {os.path.basename(source_path)}: {exif_e}") 120 | 121 | # 2. Générer et sauvegarder la version Polaroid (pour toutes les orientations) 122 | try: 123 | polaroid_content = create_polaroid_effect(img_content.copy()) 124 | polaroid_final_img = final_img.copy() 125 | polaroid_final_img.paste(polaroid_content, (x_offset, y_offset)) 126 | dest_path_obj = Path(dest_path) 127 | polaroid_dest_path = dest_path_obj.with_name(f"{dest_path_obj.stem}_polaroid.jpg") 128 | polaroid_final_img.save(polaroid_dest_path, 'JPEG', quality=90, optimize=True, exif=exif_bytes_to_add) 129 | except Exception as polaroid_e: 130 | print(f"[Polaroid] Avertissement: Impossible de créer la version Polaroid pour {os.path.basename(source_path)}: {polaroid_e}") 131 | 132 | # Générer et sauvegarder la version Carte Postale. 133 | # La légende (caption) est utilisée si elle est fournie (ex: depuis Immich ou Telegram). 134 | # Si cette étape échoue, la photo principale sera quand même disponible. 135 | try: 136 | # --- Réduction de la taille de l'image pour la carte postale --- 137 | # On crée une copie plus petite de l'image de contenu pour s'assurer 138 | # que la carte postale finale ne sera pas trop grande et laissera des marges 139 | # pour l'affichage de l'heure et des marées. 140 | # On la réduit à 85% de la taille de l'écran. 141 | postcard_img_content = img_content.copy() 142 | scale_factor = 0.85 143 | postcard_img_content.thumbnail( 144 | (int(output_width * scale_factor), int(output_height * scale_factor)), 145 | Image.Resampling.LANCZOS 146 | ) 147 | 148 | postcard_content = create_postcard_effect(postcard_img_content, caption=caption) 149 | # Utiliser une copie de l'image finale (avec son fond flou ou noir) comme base 150 | # pour la carte postale, au lieu d'un fond noir uni. 151 | postcard_final_img = final_img.copy() 152 | postcard_x_offset = (output_width - postcard_content.width) // 2 153 | postcard_y_offset = (output_height - postcard_content.height) // 2 154 | postcard_final_img.paste(postcard_content, (postcard_x_offset, postcard_y_offset), postcard_content) 155 | 156 | dest_path_obj = Path(dest_path) 157 | postcard_dest_path = dest_path_obj.with_name(f"{dest_path_obj.stem}_postcard.jpg") 158 | postcard_final_img.save(postcard_dest_path, 'JPEG', quality=90, optimize=True, exif=exif_bytes_to_add) 159 | except Exception as postcard_e: 160 | # Rendre l'erreur plus visible dans les logs pour le débogage. 161 | # L'importation des autres photos continuera. 162 | print(f"--- ERREUR CRÉATION CARTE POSTALE pour {os.path.basename(source_path)} ---") 163 | print(f"Détails de l'erreur : {postcard_e}") 164 | print("Vérifiez que les polices (static/fonts) et les timbres (static/stamps) sont présents et accessibles.") 165 | print("--------------------------------------------------------------------") 166 | 167 | # 3. Sauvegarder l'image principale préparée (avec fond flou ou noir) 168 | img_to_save = final_img 169 | if exif_bytes_to_add: 170 | img_to_save.save(dest_path, 'JPEG', quality=85, optimize=True, exif=exif_bytes_to_add) 171 | else: 172 | img_to_save.save(dest_path, 'JPEG', quality=85, optimize=True) 173 | 174 | except Exception as e: 175 | # Re-raise with more context 176 | raise Exception(f"Erreur lors du traitement de l'image '{os.path.basename(source_path)}': {e}") 177 | 178 | def prepare_video(source_path, dest_path, output_width, output_height): 179 | """Prépare une vidéo pour l'affichage et génère sa vignette.""" 180 | 181 | # --- 1. Préparer la vidéo --- 182 | try: 183 | # --- Détection intelligente de l'encodeur matériel --- 184 | encoder = 'libx264' # Encodeur logiciel par défaut (fallback) 185 | encoder_params = ['-preset', 'veryfast', '-crf', '23'] 186 | 187 | try: 188 | result = subprocess.run(['ffmpeg', '-encoders'], capture_output=True, text=True, check=True, timeout=5) 189 | available_encoders = result.stdout 190 | 191 | if 'h264_v4l2m2m' in available_encoders: 192 | encoder = 'h264_v4l2m2m' # Encodeur optimal pour Pi 4/5 (64-bit) 193 | encoder_params = ['-b:v', '4M'] 194 | print("[Video Prep] Utilisation de l'encodeur matériel optimisé : h264_v4l2m2m") 195 | elif 'h264_omx' in available_encoders: 196 | encoder = 'h264_omx' # Encodeur pour Pi 3 et anciens modèles 197 | encoder_params = ['-b:v', '4M'] 198 | print("[Video Prep] Utilisation de l'encodeur matériel : h264_omx") 199 | else: 200 | print("[Video Prep] Aucun encodeur matériel trouvé, utilisation de l'encodeur logiciel (plus lent) : libx264") 201 | # Forcer un profil compatible avec les décodeurs matériels des RPi (notamment Pi 3) 202 | encoder_params.extend(['-profile:v', 'high', '-level', '4.0']) 203 | except Exception as e: 204 | print(f"[Video Prep] Avertissement : Impossible de détecter les encodeurs ({e}). Utilisation de l'encodeur logiciel par défaut.") 205 | 206 | command = [ 207 | 'ffmpeg', '-i', source_path, '-vf', f"scale='min({output_width},iw)':'min({output_height},ih)':force_original_aspect_ratio=decrease,pad={output_width}:{output_height}:(ow-iw)/2:(oh-ih)/2", 208 | '-c:v', encoder, *encoder_params, 209 | '-c:a', 'aac', '-b:a', '128k', # Conserver et ré-encoder l'audio 210 | '-y', dest_path 211 | ] 212 | subprocess.run(command, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) 213 | except Exception as video_e: 214 | raise Exception(f"Erreur lors du traitement de la vidéo '{os.path.basename(source_path)}' avec ffmpeg: {video_e}") 215 | 216 | # --- 2. Générer la vignette à partir de la vidéo source --- 217 | thumbnail_path = None 218 | try: 219 | dest_path_obj = Path(dest_path) 220 | thumbnail_path = dest_path_obj.with_name(f"{dest_path_obj.stem}_thumbnail.jpg") 221 | 222 | # Commande ffmpeg pour extraire une image à la 1ère seconde 223 | thumb_command = [ 224 | 'ffmpeg', '-i', source_path, '-ss', '00:00:01.000', '-vframes', '1', '-q:v', '2', str(thumbnail_path), '-y' 225 | ] 226 | subprocess.run(thumb_command, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) 227 | except Exception as thumb_e: 228 | # Ne pas bloquer tout le processus si la vignette échoue, juste afficher un avertissement. 229 | print(f"[Vignette] Avertissement: Impossible de créer la vignette pour {os.path.basename(source_path)}: {thumb_e}") 230 | 231 | def prepare_all_photos_with_progress(screen_width=None, screen_height=None, source_type="unknown", description_map=None): 232 | """Prépare les photos et retourne des objets structurés (dict) pour le suivi.""" 233 | 234 | # S'assurer que description_map est un dictionnaire pour éviter les erreurs 235 | if description_map is None: 236 | description_map = {} 237 | 238 | # --- NOUVEAU: Charger les textes saisis par l'utilisateur --- 239 | user_text_map = {} 240 | if USER_TEXT_MAP_CACHE_FILE.exists(): 241 | try: 242 | with open(USER_TEXT_MAP_CACHE_FILE, 'r', encoding='utf-8') as f: 243 | user_text_map = json.load(f) 244 | except (json.JSONDecodeError, IOError): 245 | print(f"Avertissement: Impossible de charger le fichier de textes utilisateur {USER_TEXT_MAP_CACHE_FILE}") 246 | 247 | # Déterminer la résolution de sortie réelle, en utilisant les valeurs par défaut si non fournies 248 | actual_output_width = screen_width if screen_width is not None else DEFAULT_OUTPUT_WIDTH 249 | actual_output_height = screen_height if screen_height is not None else DEFAULT_OUTPUT_HEIGHT 250 | 251 | # --- NOUVEAU: Définir les chemins source et préparé dynamiquement --- 252 | SOURCE_DIR_FOR_PREP = Path("static") / "photos" / source_type 253 | PREPARED_SOURCE_DIR = Path("static") / "prepared" / source_type 254 | 255 | if not SOURCE_DIR_FOR_PREP.is_dir(): 256 | yield {"type": "error", "message": f"Le dossier source '{SOURCE_DIR_FOR_PREP}' n'existe pas."} 257 | return 258 | 259 | PREPARED_SOURCE_DIR.mkdir(parents=True, exist_ok=True) 260 | 261 | # --- Nouvelle logique de synchronisation intelligente --- 262 | 263 | # 1. Obtenir les noms de base des médias sources (sans extension) 264 | source_files = {f: os.path.splitext(f)[0] for f in os.listdir(SOURCE_DIR_FOR_PREP) if f.lower().endswith(('.jpg', '.jpeg', '.png', '.heic', '.heif') + VIDEO_EXTENSIONS)} 265 | source_basenames = set(source_files.values()) 266 | 267 | # 2. Obtenir les noms de base des médias déjà préparés 268 | # Cette logique est plus robuste car elle gère les images, les vidéos et leurs variantes (_polaroid, _thumbnail, etc.) 269 | prepared_basenames = set() 270 | if PREPARED_SOURCE_DIR.exists(): 271 | for f in PREPARED_SOURCE_DIR.iterdir(): 272 | if f.is_file(): 273 | # Enlève les suffixes connus pour obtenir le vrai nom de base. 274 | # ex: 'photo1_polaroid' -> 'photo1', 'video1_thumbnail' -> 'video1', 'photo2' -> 'photo2' 275 | base = re.sub(r'(_polaroid|_postcard|_thumbnail)$', '', f.stem) 276 | prepared_basenames.add(base) 277 | 278 | # 3. Déterminer les médias à supprimer (obsolètes) 279 | # On ne supprime les médias obsolètes que pour les sources qui sont synchronisées (pas pour le smartphone qui est additif) 280 | basenames_to_delete = set() 281 | if source_type != "smartphone": 282 | basenames_to_delete = prepared_basenames - source_basenames 283 | 284 | if basenames_to_delete: 285 | yield {"type": "progress", "stage": "CLEANING", "message": f"Suppression de {len(basenames_to_delete)} médias obsolètes..."} 286 | for basename in basenames_to_delete: 287 | # Supprimer tous les fichiers associés à ce nom de base dans le dossier préparé 288 | for file_to_delete in PREPARED_SOURCE_DIR.glob(f"{basename}*"): 289 | try: 290 | if file_to_delete.is_file(): 291 | file_to_delete.unlink() 292 | except OSError as e: 293 | yield {"type": "warning", "message": f"Impossible de supprimer {file_to_delete.name} : {e}"} 294 | 295 | # Nettoyer également la sauvegarde correspondante 296 | backup_dir = PREPARED_SOURCE_DIR.parent.parent / '.backups' / source_type 297 | if backup_dir.exists(): 298 | for backup_file_to_delete in backup_dir.glob(f"{basename}*"): 299 | if backup_file_to_delete.is_file(): backup_file_to_delete.unlink() 300 | 301 | # 4. Déterminer les médias à préparer (nouveaux) 302 | basenames_to_prepare = source_basenames - prepared_basenames 303 | files_to_prepare = [f for f, basename in source_files.items() if basename in basenames_to_prepare] 304 | 305 | total = len(files_to_prepare) 306 | 307 | if total == 0: 308 | yield {"type": "info", "message": "Aucune nouvelle photo à préparer. Le dossier est à jour."} 309 | yield {"type": "done", "stage": "PREPARING_COMPLETE", "percent": 100, "message": "Aucune nouvelle photo à préparer."} 310 | return 311 | 312 | yield {"type": "stats", "stage": "PREPARING_START", "message": f"Début de la préparation de {total} nouvelles photos...", "total": total} 313 | 314 | for i, filename in enumerate(files_to_prepare, start=1): 315 | # Vérifier si l'annulation a été demandée 316 | if CANCEL_FLAG.exists(): 317 | yield {"type": "warning", "message": "Préparation annulée par l'utilisateur."} 318 | return 319 | 320 | src_path = os.path.join(SOURCE_DIR_FOR_PREP, filename) 321 | 322 | try: 323 | base_name, extension = os.path.splitext(filename) 324 | 325 | if extension.lower() in VIDEO_EXTENSIONS: 326 | # C'est une vidéo 327 | dest_filename = f"{base_name}.mp4" 328 | dest_path = PREPARED_SOURCE_DIR / dest_filename 329 | prepare_video(src_path, str(dest_path), actual_output_width, actual_output_height) 330 | message_type = "vidéo" 331 | else: 332 | # C'est une image 333 | dest_filename = f"{base_name}.jpg" 334 | dest_path = PREPARED_SOURCE_DIR / dest_filename 335 | 336 | # --- MODIFIÉ: Logique de priorité pour la légende --- 337 | # La clé est le chemin relatif depuis 'prepared', ex: 'immich/photo.jpg' 338 | relative_path_key = f"{source_type}/{dest_filename}" 339 | 340 | # Priorité 1: Texte de l'utilisateur 341 | caption = user_text_map.get(relative_path_key) 342 | 343 | # Priorité 2: Description d'Immich (si aucun texte utilisateur) 344 | if caption is None: 345 | caption = description_map.get(filename) 346 | 347 | prepare_photo(src_path, str(dest_path), actual_output_width, actual_output_height, source_type=source_type, caption=caption) 348 | message_type = "photo" 349 | 350 | percent = int((i / total) * 100) 351 | yield { 352 | "type": "progress", "stage": "PREPARING_PHOTO", "percent": percent, "message": f"Nouveau média préparé ({message_type}) : {filename} ({i}/{total})", 353 | "current": i, "total": total 354 | } 355 | except Exception as e: 356 | yield {"type": "warning", "message": f"Erreur lors de la préparation de {filename}: {e}"} 357 | 358 | yield {"type": "done", "stage": "PREPARING_COMPLETE", "percent": 100, "message": "Préparation des nouvelles photos terminée."} 359 | 360 | def prepare_all_photos(status_callback=None): 361 | """Version originale avec callback pour compatibilité""" 362 | for update in prepare_all_photos_with_progress(): 363 | message = update.get("message", str(update)) 364 | if status_callback: 365 | status_callback(message) 366 | else: 367 | print(message) 368 | 369 | # Fonction principale pour tests 370 | if __name__ == "__main__": 371 | print("Test de préparation des photos...") 372 | prepare_all_photos() 373 | print("Terminé !") -------------------------------------------------------------------------------- /utils/image_filters.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | from pathlib import Path 4 | from PIL import Image, ImageEnhance, ImageOps, ImageFilter, ImageDraw, ImageFont 5 | import random 6 | import piexif 7 | import json 8 | from datetime import date 9 | 10 | POLAROID_FONT_PATH = Path(__file__).parent.parent / "static" / "fonts" / "Caveat-Regular.ttf" 11 | # Chemin vers le cache des textes saisis par l'utilisateur 12 | USER_TEXT_MAP_CACHE_FILE = Path(__file__).parent.parent / "cache" / "user_texts.json" 13 | 14 | def _load_user_texts(): 15 | """Charge le dictionnaire des textes utilisateur depuis le cache.""" 16 | if not USER_TEXT_MAP_CACHE_FILE.exists(): 17 | return {} 18 | try: 19 | with open(USER_TEXT_MAP_CACHE_FILE, 'r', encoding='utf-8') as f: 20 | return json.load(f) 21 | except (json.JSONDecodeError, IOError): 22 | return {} 23 | 24 | def _save_user_texts(texts_map): 25 | """Sauvegarde le dictionnaire des textes utilisateur dans le cache.""" 26 | USER_TEXT_MAP_CACHE_FILE.parent.mkdir(exist_ok=True) 27 | with open(USER_TEXT_MAP_CACHE_FILE, 'w', encoding='utf-8') as f: 28 | json.dump(texts_map, f, indent=2, ensure_ascii=False) 29 | 30 | def _get_content_bbox(image_with_exif): 31 | """Lit les métadonnées EXIF pour trouver la 'bounding box' du contenu.""" 32 | try: 33 | exif_dict = piexif.load(image_with_exif.info.get('exif', b'')) 34 | user_comment_bytes = exif_dict.get("Exif", {}).get(piexif.ExifIFD.UserComment, b'') 35 | user_comment = user_comment_bytes.decode('ascii', errors='ignore') 36 | 37 | if user_comment.startswith("pimmich_bbox:"): 38 | coords_str = user_comment.split(":")[1] 39 | x, y, w, h = map(int, coords_str.split(',')) 40 | return (x, y, x + w, y + h) # Retourne un tuple (left, top, right, bottom) 41 | except Exception: 42 | pass # Si erreur de lecture ou format invalide, on retourne None 43 | return None 44 | 45 | def _draw_rounded_rectangle(draw, xy, corner_radius, fill=None): 46 | """ 47 | Dessine un rectangle avec des coins arrondis. 48 | """ 49 | x1, y1, x2, y2 = xy 50 | # Corps du rectangle 51 | draw.rectangle((x1 + corner_radius, y1, x2 - corner_radius, y2), fill=fill) 52 | draw.rectangle((x1, y1 + corner_radius, x2, y2 - corner_radius), fill=fill) 53 | # Coins arrondis 54 | draw.pieslice((x1, y1, x1 + corner_radius * 2, y1 + corner_radius * 2), 180, 270, fill=fill) 55 | draw.pieslice((x2 - corner_radius * 2, y1, x2, y1 + corner_radius * 2), 270, 360, fill=fill) 56 | draw.pieslice((x1, y2 - corner_radius * 2, x1 + corner_radius * 2, y2), 90, 180, fill=fill) 57 | draw.pieslice((x2 - corner_radius * 2, y2 - corner_radius * 2, x2, y2), 0, 90, fill=fill) 58 | 59 | def add_stamp_and_postmark(card_image): 60 | """ 61 | Ajoute un timbre et une oblitération aléatoires sur une image de carte. 62 | Prend une image PIL (la carte) et retourne la carte modifiée. 63 | """ 64 | try: 65 | stamps_dir = Path('static/stamps') 66 | if not stamps_dir.is_dir(): 67 | print("[Stamp] Le dossier 'static/stamps' n'existe pas.") 68 | return card_image 69 | 70 | stamps = [f for f in stamps_dir.iterdir() if f.suffix.lower() == '.png'] 71 | if not stamps: 72 | print("[Stamp] Aucun timbre (.png) trouvé dans le dossier 'static/stamps'.") 73 | return card_image 74 | 75 | postcard = card_image.copy() 76 | random_stamp_path = random.choice(stamps) 77 | stamp = Image.open(random_stamp_path).convert("RGBA") 78 | 79 | stamp.thumbnail((160, 160), Image.Resampling.LANCZOS) 80 | 81 | draw = ImageDraw.Draw(stamp) 82 | try: 83 | font = ImageFont.truetype("arial.ttf", 24) 84 | except IOError: 85 | font = ImageFont.load_default() 86 | 87 | # Dessiner des lignes ondulées pour l'oblitération 88 | for i in range(0, stamp.height, 15): 89 | draw.line([(0, i), (stamp.width, i + 10)], fill=(0, 0, 0, 100), width=2) 90 | draw.line([(0, i+5), (stamp.width, i - 5)], fill=(0, 0, 0, 100), width=2) 91 | 92 | # Dessiner un cercle pour la date 93 | circle_pos = (stamp.width // 4, stamp.height // 4, stamp.width * 3 // 4, stamp.height * 3 // 4) 94 | draw.ellipse(circle_pos, outline=(0, 0, 0, 150), width=3) 95 | 96 | # Ajouter la date 97 | date_text = date.today().strftime("%d %b\n%Y").upper() 98 | draw.multiline_text((stamp.width/2, stamp.height/2), date_text, font=font, fill=(0,0,0,180), anchor="mm", align="center") 99 | 100 | # Coller le timbre sur la carte, avec une marge par rapport au bord de la carte 101 | margin = 35 # Augmentation de la marge pour plus d'espace 102 | position = (postcard.width - stamp.width - margin, margin) # Position en haut à droite 103 | postcard.paste(stamp, position, stamp) 104 | 105 | return postcard 106 | 107 | except Exception as e: 108 | print(f"[Stamp] Erreur lors de l'ajout du timbre : {e}") 109 | return card_image 110 | 111 | def create_postcard_effect(img_content, caption=None): 112 | """Crée un effet de carte postale inclinée avec bordure et ombre.""" 113 | # Définir les paramètres de l'effet 114 | border_size = 35 # Bordure blanche principale augmentée pour un texte plus grand 115 | 116 | # --- MODIFICATION DE L'ANGLE --- 117 | # Inclinaison aléatoire réduite pour une meilleure lisibilité, entre 5 et 10 degrés 118 | angle = random.uniform(5, 10) 119 | rotation_angle = random.choice([-1, 1]) * angle 120 | 121 | shadow_offset = (15, 15) 122 | shadow_blur_radius = 25 123 | shadow_color = (0, 0, 0) 124 | 125 | # --- AJOUT D'UNE BORDURE INTÉRIEURE SUBTILE --- 126 | # Pour mieux délimiter la photo du cadre blanc, créant un effet de "bord" 127 | draw = ImageDraw.Draw(img_content) 128 | draw.rectangle( 129 | [(0, 0), (img_content.width - 1, img_content.height - 1)], 130 | outline=(220, 220, 220), # Gris très clair 131 | width=1 132 | ) 133 | 134 | # --- NOUVELLE LOGIQUE : AJOUT DE LA LÉGENDE DIRECTEMENT SUR LA PHOTO --- 135 | # Cela garantit que le texte est toujours lisible, quelle que soit l'inclinaison. 136 | if caption and caption.strip(): 137 | # On travaille sur une copie pour pouvoir utiliser alpha_composite pour la transparence 138 | img_with_text = img_content.copy() 139 | if img_with_text.mode != 'RGBA': 140 | img_with_text = img_with_text.convert('RGBA') 141 | 142 | # Créer une couche transparente pour le bandeau et le texte 143 | overlay = Image.new('RGBA', img_with_text.size, (0, 0, 0, 0)) 144 | draw = ImageDraw.Draw(overlay) 145 | 146 | try: 147 | # Taille de police relative à la hauteur de l'image pour la robustesse 148 | font_size = int(img_with_text.height * 0.06) 149 | font = ImageFont.truetype(str(POLAROID_FONT_PATH), font_size) 150 | except IOError: 151 | font = ImageFont.load_default() 152 | 153 | # Calculer la hauteur du bandeau en fonction de la taille du texte 154 | _, text_top, _, text_bottom = font.getbbox(caption) 155 | text_height = text_bottom - text_top 156 | band_padding = int(font_size * 0.3) 157 | band_height = text_height + (band_padding * 2) 158 | 159 | # Positionner le bandeau en bas de l'image 160 | band_y0 = img_with_text.height - band_height 161 | draw.rectangle([(0, band_y0), (img_with_text.width, img_with_text.height)], fill=(0, 0, 0, 128)) # Noir semi-transparent (50% opacité) 162 | 163 | # Positionner et dessiner le texte en blanc, centré dans le bandeau 164 | text_x = img_with_text.width / 2 165 | text_y = band_y0 + (band_height / 2) 166 | draw.text((text_x, text_y), caption, font=font, fill=(255, 255, 255), anchor="mm") 167 | 168 | # Combiner l'image et l'overlay, puis reconvertir en RGB 169 | img_content = Image.alpha_composite(img_with_text, overlay).convert('RGB') 170 | 171 | # 1. Créer la carte avec sa bordure blanche 172 | card_size = (img_content.width + 2 * border_size, img_content.height + 2 * border_size) 173 | card = Image.new('RGBA', card_size, (255, 255, 255, 255)) 174 | card.paste(img_content, (border_size, border_size)) 175 | 176 | # Ajouter le timbre sur la carte avant de la faire pivoter 177 | card = add_stamp_and_postmark(card) 178 | 179 | # 2. Incliner la carte 180 | rotated_card = card.rotate(rotation_angle, expand=True, resample=Image.BICUBIC) 181 | 182 | # 3. Créer l'ombre 183 | shadow_layer = Image.new('RGBA', rotated_card.size, (0, 0, 0, 0)) 184 | # Créer une forme noire de la même taille que la carte tournée pour l'ombre 185 | shadow_draw = ImageDraw.Draw(shadow_layer) 186 | shadow_draw.bitmap((0,0), rotated_card.split()[3], fill=shadow_color) # Utiliser le canal alpha de la carte tournée comme masque 187 | shadow_layer = shadow_layer.filter(ImageFilter.GaussianBlur(radius=shadow_blur_radius)) 188 | 189 | # 4. Assembler l'ombre et la carte sur une image finale transparente 190 | final_size = (shadow_layer.width + abs(shadow_offset[0]), shadow_layer.height + abs(shadow_offset[1])) 191 | final_image = Image.new('RGBA', final_size, (0, 0, 0, 0)) 192 | final_image.paste(shadow_layer, shadow_offset, shadow_layer) 193 | final_image.paste(rotated_card, (0, 0), rotated_card) 194 | 195 | return final_image 196 | 197 | def create_polaroid_effect(image_content): 198 | """ 199 | Applique un effet de couleur et un cadre Polaroid à une image donnée. 200 | Prend une image PIL et retourne une nouvelle image PIL. 201 | """ 202 | # On travaille directement sur une copie de l'image originale pour ne pas modifier les couleurs. 203 | content_with_frame = image_content.copy() 204 | 205 | # 2. Dessiner le cadre à l'intérieur de l'image 206 | draw = ImageDraw.Draw(content_with_frame) 207 | width, height = content_with_frame.size 208 | frame_color = (255, 253, 248) 209 | padding_top = int(height * 0.05) 210 | padding_sides = int(height * 0.05) 211 | padding_bottom = int(height * 0.18) 212 | draw.rectangle([0, 0, width, padding_top], fill=frame_color) 213 | draw.rectangle([0, height - padding_bottom, width, height], fill=frame_color) 214 | draw.rectangle([0, padding_top, padding_sides, height - padding_bottom], fill=frame_color) 215 | draw.rectangle([width - padding_sides, padding_top, width, height - padding_bottom], fill=frame_color) 216 | 217 | return content_with_frame 218 | 219 | def apply_filter_to_image(image_path_str, filter_name): 220 | """ 221 | Applique un filtre à une image et la sauvegarde. 222 | Utilise un système de backup pour ne pas appliquer de filtres sur une image déjà filtrée. 223 | """ 224 | image_path = Path(image_path_str) 225 | 226 | # Détermine le chemin de la sauvegarde. Ex: static/prepared/samba/img.jpg -> static/.backups/samba/img.jpg 227 | try: 228 | prepared_index = image_path.parts.index('prepared') 229 | backup_base_path = Path(*image_path.parts[:prepared_index]) / '.backups' 230 | backup_path = backup_base_path.joinpath(*image_path.parts[prepared_index+1:]) 231 | except ValueError: 232 | raise ValueError("Le chemin de la photo ne semble pas être dans un dossier 'prepared'.") 233 | 234 | # S'assure que le dossier de backup existe 235 | backup_path.parent.mkdir(parents=True, exist_ok=True) 236 | 237 | # Crée une sauvegarde si elle n'existe pas 238 | if not backup_path.exists(): 239 | shutil.copy2(image_path, backup_path) 240 | 241 | # Le traitement se fait toujours à partir de la sauvegarde (l'original préparé) 242 | source_for_processing = backup_path 243 | 244 | try: 245 | img = Image.open(source_for_processing) 246 | # Conserver les métadonnées EXIF pour les réinjecter à la sauvegarde 247 | exif_bytes = img.info.get('exif') 248 | if img.mode != 'RGB': 249 | img = img.convert('RGB') 250 | except FileNotFoundError: 251 | raise ValueError(f"Image source introuvable : {source_for_processing}") 252 | 253 | # Applique le filtre sélectionné 254 | if filter_name == 'original': 255 | img_to_save = img 256 | elif filter_name == 'grayscale': 257 | img_to_save = ImageOps.grayscale(img).convert('RGB') 258 | elif filter_name == 'sepia': 259 | grayscale = ImageOps.grayscale(img) 260 | # Créer une palette sépia. La palette doit être une liste plate de 768 entiers (256 * RGB). 261 | sepia_palette = [] 262 | for i in range(256): 263 | r, g, b = int(i * 1.07), int(i * 0.74), int(i * 0.43) 264 | sepia_palette.extend([min(255, r), min(255, g), min(255, b)]) 265 | grayscale.putpalette(sepia_palette) 266 | img_to_save = grayscale.convert('RGB') 267 | elif filter_name == 'vignette': 268 | img_to_save = img.copy() 269 | width, height = img_to_save.size 270 | mask = Image.new('L', (width, height), 0) 271 | draw = ImageDraw.Draw(mask) 272 | draw.ellipse((width * 0.05, height * 0.05, width * 0.95, height * 0.95), fill=255) 273 | mask = mask.filter(ImageFilter.GaussianBlur(radius=max(width, height) // 7)) 274 | mask = ImageOps.invert(mask) 275 | black_layer = Image.new('RGB', img.size, (0, 0, 0)) 276 | img_to_save = Image.composite(img, black_layer, mask) 277 | elif filter_name == 'vintage': 278 | # 1. Réduire la saturation des couleurs pour un look délavé 279 | enhancer = ImageEnhance.Color(img) 280 | img_vintage = enhancer.enhance(0.5) # 0.0: N&B, 1.0: original 281 | 282 | # 2. Appliquer une teinte jaune/orangée pour simuler le vieillissement du papier 283 | sepia_tint = Image.new('RGB', img_vintage.size, (255, 240, 192)) # Teinte sépia clair 284 | img_to_save = Image.blend(img_vintage, sepia_tint, alpha=0.3) # alpha contrôle l'intensité 285 | elif filter_name == 'polaroid_vintage': 286 | # --- NOUVELLE LOGIQUE POUR CIBLER LE CONTENU DE L'IMAGE --- 287 | content_bbox = _get_content_bbox(img) 288 | if not content_bbox: 289 | print(f"Avertissement: Bounding box non trouvée pour {image_path_str}. Le filtre Polaroid Vintage ne peut être appliqué correctement.") 290 | img_to_save = img # On ne fait rien pour éviter un résultat incorrect. 291 | else: 292 | # 1. Isoler le contenu de l'image 293 | content_img = img.crop(content_bbox) 294 | 295 | # 2. Appliquer les filtres de couleur vintage au contenu. 296 | # MODIFIEZ LES VALEURS CI-DESSOUS POUR AJUSTER L'INTENSITÉ. 297 | 298 | # Contraste (1.0 = original, < 1.0 = moins de contraste). Exemple plus léger : 0.9 299 | content_filtered = ImageEnhance.Contrast(content_img).enhance(0.8) 300 | 301 | # Teinte jaune (alpha de 0.0 à 1.0, 0.0 = pas de teinte). Exemple plus léger : 0.15 302 | yellow_overlay = Image.new('RGB', content_filtered.size, (255, 248, 220)) 303 | content_filtered = Image.blend(content_filtered, yellow_overlay, 0.25) 304 | 305 | # Luminosité (1.0 = original, > 1.0 = plus lumineux). Exemple plus léger : 1.05 306 | content_filtered = ImageEnhance.Brightness(content_filtered).enhance(1.1) 307 | 308 | # Teinte bleue dans les ombres (alpha de 0.0 à 1.0). Exemple plus léger : 0.05 309 | blue_overlay = Image.new('RGB', content_filtered.size, (0, 100, 120)) 310 | content_filtered = Image.blend(content_filtered, blue_overlay, 0.08) 311 | 312 | # 3. Dessiner le cadre Polaroid sur le contenu filtré 313 | draw = ImageDraw.Draw(content_filtered) 314 | width, height = content_filtered.size 315 | frame_color = (255, 253, 248) 316 | padding_top = int(height * 0.05) 317 | padding_sides = int(height * 0.05) 318 | padding_bottom = int(height * 0.18) 319 | draw.rectangle([0, 0, width, padding_top], fill=frame_color) 320 | draw.rectangle([0, height - padding_bottom, width, height], fill=frame_color) 321 | draw.rectangle([0, padding_top, padding_sides, height - padding_bottom], fill=frame_color) 322 | draw.rectangle([width - padding_sides, padding_top, width, height - padding_bottom], fill=frame_color) 323 | 324 | # 4. Replacer le contenu modifié dans l'image complète 325 | img_to_save = img.copy() 326 | img_to_save.paste(content_filtered, content_bbox) 327 | else: 328 | raise ValueError(f"Filtre inconnu : '{filter_name}'") 329 | 330 | # Sauvegarde l'image modifiée dans le dossier 'prepared' 331 | if exif_bytes: 332 | img_to_save.save(image_path, 'JPEG', quality=90, optimize=True, exif=exif_bytes) 333 | else: 334 | img_to_save.save(image_path, 'JPEG', quality=90, optimize=True) 335 | 336 | def add_text_to_image(image_path_str, text): 337 | """ 338 | Met à jour le texte pour une image, le sauvegarde, et régénère l'image de base 339 | et sa version carte postale si elle existe. 340 | """ 341 | image_path = Path(image_path_str) 342 | 343 | # --- 1. Mettre à jour le fichier de cache des textes --- 344 | try: 345 | # La clé est le chemin relatif depuis 'prepared', ex: 'immich/photo.jpg' 346 | relative_path_key = '/'.join(image_path.parts[-2:]) 347 | user_texts = _load_user_texts() 348 | 349 | if text and text.strip(): 350 | user_texts[relative_path_key] = text 351 | else: 352 | # Si le texte est vide, on le supprime du cache 353 | user_texts.pop(relative_path_key, None) 354 | 355 | _save_user_texts(user_texts) 356 | except Exception as e: 357 | print(f"Erreur lors de la mise à jour du cache de texte : {e}") 358 | # On ne s'arrête pas, on essaie quand même de régénérer l'image 359 | 360 | # --- 2. Régénérer les images (base et carte postale) --- 361 | 362 | # Logique de backup 363 | try: 364 | prepared_index = image_path.parts.index('prepared') 365 | backup_base_path = Path(*image_path.parts[:prepared_index]) / '.backups' 366 | backup_path = backup_base_path.joinpath(*image_path.parts[prepared_index+1:]) 367 | except ValueError: 368 | raise ValueError("Le chemin de la photo ne semble pas être dans un dossier 'prepared'.") 369 | 370 | if not backup_path.exists(): 371 | # Si pas de backup, l'image actuelle est la source. On en crée un. 372 | backup_path.parent.mkdir(parents=True, exist_ok=True) 373 | shutil.copy2(image_path, backup_path) 374 | 375 | # Toujours partir de la version propre (le backup) 376 | base_image_from_backup = Image.open(backup_path) 377 | exif_bytes = base_image_from_backup.info.get('exif') 378 | 379 | # --- 2a. Régénérer la carte postale --- 380 | postcard_path = image_path.with_name(f"{image_path.stem}_postcard.jpg") 381 | if postcard_path.exists(): 382 | try: 383 | background_img = base_image_from_backup.copy() 384 | if background_img.mode != 'RGB': 385 | background_img = background_img.convert('RGB') 386 | 387 | content_bbox = _get_content_bbox(background_img) 388 | if not content_bbox: 389 | raise ValueError("Bounding box non trouvée pour régénérer la carte postale.") 390 | 391 | content_img = background_img.crop(content_bbox) 392 | output_width, output_height = background_img.size 393 | 394 | postcard_img_content = content_img.copy() 395 | scale_factor = 0.85 396 | postcard_img_content.thumbnail( 397 | (int(output_width * scale_factor), int(output_height * scale_factor)), 398 | Image.Resampling.LANCZOS 399 | ) 400 | 401 | postcard_effect_img = create_postcard_effect(postcard_img_content, caption=text) 402 | 403 | final_postcard_img = background_img.copy() 404 | postcard_x_offset = (output_width - postcard_effect_img.width) // 2 405 | postcard_y_offset = (output_height - postcard_effect_img.height) // 2 406 | final_postcard_img.paste(postcard_effect_img, (postcard_x_offset, postcard_y_offset), postcard_effect_img) 407 | 408 | final_postcard_img.save(postcard_path, 'JPEG', quality=90, optimize=True, exif=exif_bytes) 409 | print(f"[Text Update] Carte postale mise à jour pour {image_path.name}") 410 | 411 | except Exception as e: 412 | print(f"--- ERREUR MISE À JOUR CARTE POSTALE pour {image_path.name}: {e} ---") 413 | 414 | # --- 2b. Mettre à jour l'image de base --- 415 | if not text or not text.strip(): 416 | img_to_save = base_image_from_backup.convert('RGB') 417 | else: 418 | img_rgba = base_image_from_backup.copy() 419 | if img_rgba.mode != 'RGBA': 420 | img_rgba = img_rgba.convert('RGBA') 421 | 422 | overlay = Image.new('RGBA', img_rgba.size, (255, 255, 255, 0)) 423 | draw = ImageDraw.Draw(overlay) 424 | width, height = img_rgba.size 425 | 426 | try: 427 | font_path = str(POLAROID_FONT_PATH) 428 | font_size = int(height * 0.05) 429 | font = ImageFont.truetype(font_path, font_size) 430 | except IOError: 431 | font = ImageFont.load_default() 432 | 433 | text_width = font.getlength(text) 434 | _, text_top, _, text_bottom = font.getbbox(text) 435 | text_height = text_bottom - text_top 436 | 437 | rect_padding = int(font_size * 0.4) 438 | rect_height = text_height + (rect_padding * 2) 439 | rect_width = text_width + (rect_padding * 2) 440 | rect_x1 = (width - rect_width) / 2 441 | rect_y1 = height - rect_height - (height * 0.03) 442 | rect_x2 = rect_x1 + rect_width 443 | rect_y2 = rect_y1 + rect_height 444 | 445 | _draw_rounded_rectangle(draw, (rect_x1, rect_y1, rect_x2, rect_y2), int(font_size * 0.3), fill=(255, 255, 255, 180)) 446 | draw.text((rect_x1 + rect_width / 2, rect_y1 + rect_height / 2), text, font=font, fill=(0, 0, 0, 255), anchor="mm") 447 | 448 | img_composited = Image.alpha_composite(img_rgba, overlay) 449 | img_to_save = img_composited.convert('RGB') 450 | 451 | if exif_bytes: 452 | img_to_save.save(image_path, 'JPEG', quality=90, optimize=True, exif=exif_bytes) 453 | else: 454 | img_to_save.save(image_path, 'JPEG', quality=90, optimize=True) 455 | 456 | def add_text_to_polaroid(polaroid_path_str, text): 457 | """ 458 | Ajoute ou met à jour un texte sur une image Polaroid existante. 459 | """ 460 | polaroid_path = Path(polaroid_path_str) 461 | if not polaroid_path.exists(): 462 | raise FileNotFoundError(f"L'image Polaroid n'existe pas : {polaroid_path}") 463 | 464 | img = Image.open(polaroid_path) 465 | # Conserver les métadonnées EXIF pour les réinjecter à la sauvegarde 466 | exif_bytes = img.info.get('exif') 467 | 468 | draw = ImageDraw.Draw(img) 469 | 470 | # --- Utiliser la 'bounding box' du contenu pour un positionnement correct --- 471 | content_bbox = _get_content_bbox(img) 472 | if not content_bbox: 473 | print(f"Avertissement: Impossible de trouver la 'bounding box' du contenu pour {polaroid_path}. Le texte ne sera pas ajouté.") 474 | # On ne fait rien si on ne sait pas où est le contenu. 475 | return 476 | 477 | # Coordonnées du contenu (left, top, right, bottom) 478 | content_x, content_y, content_right, content_bottom = content_bbox 479 | content_width = content_right - content_x 480 | content_height = content_bottom - content_y 481 | 482 | # Définir la couleur du cadre et les dimensions de la marge inférieure 483 | frame_color = (255, 253, 248) # Doit correspondre à la couleur du cadre 484 | # Les paddings sont relatifs à la taille du *contenu* polaroid, pas de l'écran 485 | padding_bottom = int(content_height * 0.18) 486 | padding_sides = int(content_height * 0.05) 487 | 488 | # 1. Effacer l'ancien texte en redessinant la marge du bas du polaroid 489 | draw.rectangle([content_x, content_bottom - padding_bottom, content_right, content_bottom], fill=frame_color) 490 | 491 | # 2. Ajouter le nouveau texte si fourni 492 | if text and text.strip(): 493 | try: 494 | # La taille de la police est relative à la taille de la marge 495 | font_size = int(padding_bottom * 0.4) 496 | font = ImageFont.truetype(str(POLAROID_FONT_PATH), font_size) 497 | 498 | # Calculer le centre de la zone de texte du polaroid 499 | text_area_center_x = (content_x + content_right) / 2 500 | text_area_center_y = content_bottom - (padding_bottom / 2) 501 | # Dessiner le texte en utilisant l'ancre 'mm' pour un centrage parfait 502 | draw.text((text_area_center_x, text_area_center_y), text, font=font, fill=(80, 80, 80), anchor="mm") 503 | except IOError: 504 | print(f"Avertissement: Police non trouvée à {POLAROID_FONT_PATH}. Le texte ne sera pas ajouté.") 505 | # 3. Sauvegarder l'image modifiée en conservant les EXIF 506 | if exif_bytes: 507 | img.save(polaroid_path, 'JPEG', quality=95, optimize=True, exif=exif_bytes) 508 | else: 509 | img.save(polaroid_path, 'JPEG', quality=95, optimize=True) 510 | --------------------------------------------------------------------------------