From 0fa5a308099319a0faf6fb90a2ef18f9d29ac6e6 Mon Sep 17 00:00:00 2001 From: Gabriel Radureau Date: Mon, 13 Oct 2025 15:05:54 +0200 Subject: [PATCH] infinite scroll --- app/app.py | 113 ++++++++++++++++++++++++----- app/app.py.old | 155 --------------------------------------- app/controllers.py | 29 +++----- app/db.py | 156 +++++++++++++++++++++++++--------------- app/models.py | 55 ++++++++++++++ app/requirements.txt | 4 ++ app/views.py | 16 ++--- model/register_video.sh | 1 + model/videos.sql | 6 +- program1/program1.sh | 2 - 10 files changed, 274 insertions(+), 263 deletions(-) delete mode 100644 app/app.py.old create mode 100644 app/models.py diff --git a/app/app.py b/app/app.py index a05c3f1..99fd509 100644 --- a/app/app.py +++ b/app/app.py @@ -1,38 +1,115 @@ import streamlit as st -import os import db +from models import Video from views import show_video_thumbnail from controllers import label_widget +from datetime import date st.set_page_config(page_title="Dance Video Manager", layout="wide") -st.title("💃 Dance Video Manager") +st.title("💃 Dance Video Explorer") -# --- Barre latérale : hauteur max --- -st.sidebar.header("⚙️ Apparence") +# --- Barre latérale : filtres dynamiques --- +st.sidebar.header("⚙️ Filtres et affichage") max_height = st.sidebar.slider("Hauteur maximale (px)", 100, 800, 300, 50) -st.markdown( - f""" +st.markdown(f""" - """, - unsafe_allow_html=True +""", unsafe_allow_html=True) + +all_labels = db.load_labels() +unique_days = db.get_unique_days() +unique_difficulties = db.get_unique_difficulties() +unique_addresses = db.get_unique_addresses() + +selected_labels = st.sidebar.multiselect("Filtrer par labels", all_labels) + +day_filter = st.sidebar.selectbox( + "Jour de la semaine", + ["Tous"] + unique_days ) -# --- Charger données --- -videos = db.load_videos() -labels = db.load_labels() +difficulty_filter = st.sidebar.selectbox( + "Niveau de difficulté", + ["Tous"] + unique_difficulties +) -if videos.empty: - st.warning("Aucune vidéo trouvée dans la base.") -else: - for _, video in videos.iterrows(): +address_keyword = st.sidebar.selectbox( + "Adresse (mot-clé)", + [""] + unique_addresses +) + +start_date = st.sidebar.date_input("Date de début", value=None) +end_date = st.sidebar.date_input("Date de fin", value=None) +show_unlabeled_only = st.sidebar.checkbox("Afficher uniquement les vidéos sans labels") + +# --- Filtrage SQL --- +day_value = None if day_filter == "Tous" else day_filter +df_videos = db.search_videos( + label_names=selected_labels, + day_of_week=day_value, + address_keyword=address_keyword if address_keyword else None, + start_date=start_date.isoformat() if start_date else None, + end_date=end_date.isoformat() if end_date else None, + difficulty=difficulty_filter +) + +if df_videos.empty: + st.warning("Aucune vidéo trouvée avec ces critères.") + st.stop() + +# --- Chargement des labels et affichage lazy --- +video_labels_map = {row["file_name"]: db.load_video_labels(row["file_name"]) for _, row in df_videos.iterrows()} +if show_unlabeled_only: + df_videos = df_videos[df_videos["file_name"].apply(lambda fn: not video_labels_map.get(fn))] + +videos = [Video(**row) for _, row in df_videos.iterrows()] + +# --- Lazy loading --- +page_size = 20 +if "video_page" not in st.session_state: + st.session_state.video_page = 1 + +start = 0 +end = st.session_state.video_page * page_size +visible_videos = videos[start:end] + +for video in visible_videos: + preselected = video_labels_map.get(video.file_name, []) + css_class = "unlabeled" if not preselected else "" + with st.container(): + st.markdown(f"
", unsafe_allow_html=True) col3 = show_video_thumbnail(video) - preselected = db.load_video_labels(video["mp4_file_name"]) with col3: - label_widget(video["mp4_file_name"], preselected=preselected) + label_widget(video, preselected=preselected) + # --- Sélecteur de difficulté --- + new_level = st.selectbox( + "🎚 Niveau de difficulté", + ["Tout niveau", "Débutant", "Intermédiaire", "Avancé", "Star"], + index=["Tout niveau", "Débutant", "Intermédiaire", "Avancé", "Star"].index(video.difficulty_display), + key=f"diff_{video.file_name}" + ) + if new_level != video.difficulty_display: + db.update_video_difficulty(video.file_name, new_level) + st.success(f"Niveau mis à jour pour {video.file_name}") + st.markdown("
", unsafe_allow_html=True) + +# --- Bouton "Voir plus" --- +if end < len(videos): + if st.button("📦 Charger plus de vidéos"): + st.session_state.video_page += 1 + st.rerun() +else: + st.info("✅ Toutes les vidéos sont affichées.") diff --git a/app/app.py.old b/app/app.py.old deleted file mode 100644 index 9f42a4f..0000000 --- a/app/app.py.old +++ /dev/null @@ -1,155 +0,0 @@ -import os -import base64 -import sqlite3 -import streamlit as st -import pandas as pd -from glob import glob - -# --- chemins --- -ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) -MODEL_DIR = os.path.join(ROOT_DIR, "model") -DB_PATH = os.path.expanduser("~/Documents/.DanceVideos/db.sqlite") -VIDEO_DIR = os.path.expanduser("~/Documents/.DanceVideos/videos") - -# --- initialisation de la base --- -def init_db(): - if not os.path.exists(DB_PATH): - st.warning("⚠️ Base de données non trouvée, création d'une nouvelle...") - conn = sqlite3.connect(DB_PATH) - sql_files = sorted(glob(os.path.join(MODEL_DIR, "*.sql"))) - for sql_file in sql_files: - with open(sql_file, "r", encoding="utf-8") as f: - sql = f.read() - try: - conn.executescript(sql) - # st.info(f"✅ Exécuté : {os.path.basename(sql_file)}") - except sqlite3.Error as e: - st.error(f"Erreur dans {sql_file} : {e}") - conn.commit() - conn.close() - -# --- utilitaires base --- -def get_conn(): - return sqlite3.connect(DB_PATH, check_same_thread=False) - -# --- chargement des vidéos --- -def load_videos(): - conn = get_conn() - try: - return pd.read_sql_query("SELECT * FROM videos", conn) - except Exception as e: - st.error(f"Erreur chargement vidéos : {e}") - return pd.DataFrame() - finally: - conn.close() - - - -# --- interface principale --- -def main(): - st.set_page_config(page_title="Dance Video Manager", layout="wide") - st.title("💃 Dance Video Manager") - - # --- Barre latérale : réglage utilisateur --- - st.sidebar.header("⚙️ Apparence") - max_height = st.sidebar.slider("Hauteur maximale (px)", 100, 800, 300, 50) - st.markdown( - f""" - - """, - unsafe_allow_html=True - ) - - # if st.button("🔄 Initialiser / synchroniser la base"): - init_db() - - st.sidebar.header("Navigation") - page = st.sidebar.radio("Choisir une vue :", ["Vidéos", "Playlists", "Labels"]) - - videos = load_videos() - if videos.empty: - st.warning("Aucune vidéo trouvée dans la base.") - return - - if page == "Vidéos": - conn = get_conn() - try: - # Charger tous les labels existants - existing_labels = pd.read_sql_query("SELECT name FROM labels ORDER BY name", conn)["name"].tolist() - - for _, row in videos.iterrows(): - col1, col2, col3 = st.columns([1, 2, 1]) - - with col1: - thumb = row["thumbnail_file"] - if os.path.exists(thumb): - st.image(thumb, width="stretch") - st.caption(row["file_name"]) - - with col2: - mp4 = row["mp4_file"] - video_name = row["file_name"] - if os.path.exists(mp4): - if st.button(f"▶️ 📅 {row.get('record_datetime', '')} — 🕒 {row.get('day_of_week', '')}", key=f"play_{video_name}"): - st.video(mp4) - - with col3: - # Sélecteur de label - label_selected = st.selectbox( - "Label", - options=existing_labels + ["Autre…"], - key=f"label_select_{video_name}" - ) - - # Création d’un label personnalisé - if label_selected == "Autre…": - label_selected = st.text_input( - "Entrer un label personnalisé", - value="", - key=f"label_input_{video_name}" - ) - - # Sauvegarde - if label_selected: - if st.button("💾 Sauvegarder", key=f"save_label_{video_name}"): - cursor = conn.cursor() - # Crée le label s'il n'existe pas - cursor.execute("INSERT OR IGNORE INTO labels (name) VALUES (?)", (label_selected,)) - # Récupère l'ID - cursor.execute("SELECT id FROM labels WHERE name=?", (label_selected,)) - label_id = cursor.fetchone()[0] - # Associe à la vidéo - cursor.execute(""" - INSERT OR REPLACE INTO video_labels (video_file_name, label_id) - VALUES (?, ?) - """, (video_name, label_id)) - conn.commit() - st.success(f"Label '{label_selected}' enregistré pour {video_name}") - - finally: - conn.close() - - elif page == "Playlists": - st.subheader("📜 Gestion des playlists") - conn = get_conn() - playlists = pd.read_sql_query("SELECT * FROM playlists", conn) - st.dataframe(playlists) - conn.close() - - elif page == "Labels": - st.subheader("🏷️ Gestion des labels") - conn = get_conn() - labels = pd.read_sql_query("SELECT * FROM labels", conn) - st.dataframe(labels) - conn.close() - - -if __name__ == "__main__": - main() diff --git a/app/controllers.py b/app/controllers.py index 584f11f..dfc8361 100644 --- a/app/controllers.py +++ b/app/controllers.py @@ -1,45 +1,38 @@ +# controllers.py import streamlit as st import db -def label_widget(video_name, preselected=None): - """Widget multiselect labels avec création dynamique et réactivité.""" +def label_widget(video, preselected=None): + """Widget multiselect pour labels, avec création dynamique.""" preselected = preselected or [] + key_multiselect = f"labels_{video.id}" + key_input = f"new_label_{video.id}" - key_multiselect = f"labels_{video_name}" - key_input = f"new_label_{video_name}" - - # --- Charger labels depuis DB --- labels = db.load_labels() - # --- Initialisation session_state --- if key_multiselect not in st.session_state: st.session_state[key_multiselect] = preselected - # --- Si "Autre…" dans sélection, afficher input --- current_selected = st.session_state[key_multiselect] + + # Si "Autre…" dans sélection, afficher input if "Autre…" in current_selected: new_label = st.text_input("Entrer un label personnalisé", value="", key=key_input) if new_label.strip(): - # Ajouter le nouveau label à la DB db.create_labels([new_label.strip()]) - # Mettre à jour options avant de créer le multiselect labels = db.load_labels() - # Remplacer "Autre…" par le nouveau label dans selection current_selected = [l for l in current_selected if l != "Autre…"] + [new_label.strip()] - # MAINTENANT c'est sûr de passer current_selected comme default st.session_state[key_multiselect] = current_selected - # --- Multiselect --- selected = st.multiselect( "Labels", options=labels + ["Autre…"], default=st.session_state[key_multiselect], - key=key_multiselect + key=key_multiselect, ) - # --- Sauvegarde --- - if st.button("💾 Sauvegarder labels", key=f"save_{video_name}"): - db.save_video_labels(video_name, selected) - st.success(f"{len(selected)} label(s) enregistré(s) pour {video_name}") + if st.button("💾 Sauvegarder labels", key=f"save_{video.id}"): + video.save_labels(selected) + st.success(f"{len(selected)} label(s) enregistré(s) pour {video.mp4_file_name}") return selected diff --git a/app/db.py b/app/db.py index 5e00281..0ab4057 100644 --- a/app/db.py +++ b/app/db.py @@ -1,53 +1,36 @@ +# db.py (modifié) import sqlite3 from pathlib import Path import pandas as pd DB_PATH = Path.home() / "Documents/.DanceVideos/db.sqlite" -# --- Connexion SQLite avec timeout --- def get_conn(): - """ - Retourne une connexion SQLite avec timeout de 30s. - Utiliser un context manager pour garantir la fermeture. - """ - return sqlite3.connect(DB_PATH, timeout=30, check_same_thread=False) + conn = sqlite3.connect(DB_PATH, timeout=30, check_same_thread=False) + conn.execute("PRAGMA foreign_keys = ON;") + return conn + -# --- Vidéos --- def load_videos(): - """ - Retourne toutes les vidéos dans un DataFrame pandas. - """ with get_conn() as conn: return pd.read_sql_query("SELECT * FROM videos ORDER BY record_datetime DESC", conn) -# --- Labels --- def load_labels(): - """ - Retourne tous les labels existants dans une liste. - """ with get_conn() as conn: df = pd.read_sql_query("SELECT name FROM labels ORDER BY name", conn) return df["name"].tolist() def create_labels(label_names): - """ - Crée tous les labels de la liste s'ils n'existent pas. - """ if not label_names: return - with get_conn() as conn: - cursor = conn.cursor() - cursor.executemany( + conn.executemany( "INSERT OR IGNORE INTO labels (name) VALUES (?)", [(name,) for name in label_names] ) conn.commit() def get_label_ids(label_names): - """ - Retourne un dictionnaire {label_name: label_id}. - """ label_ids = {} with get_conn() as conn: cursor = conn.cursor() @@ -58,11 +41,7 @@ def get_label_ids(label_names): label_ids[name] = row[0] return label_ids -# --- Vidéo / Labels --- -def load_video_labels(video_file_name): - """ - Retourne la liste des labels associés à une vidéo. - """ +def load_video_labels(file_name): with get_conn() as conn: cursor = conn.cursor() query = """ @@ -71,44 +50,103 @@ def load_video_labels(video_file_name): JOIN video_labels vl ON l.id = vl.label_id WHERE vl.video_file_name = ? """ - return [row[0] for row in cursor.execute(query, (video_file_name,))] + return [row[0] for row in cursor.execute(query, (file_name,))] -def save_video_labels(video_file_name, label_names): - """ - Associe une vidéo à plusieurs labels. - Supprime d'abord les labels précédents pour cette vidéo. - """ +def save_video_labels(file_name, label_names): if label_names is None: label_names = [] - - # 1️⃣ Créer tous les labels create_labels(label_names) - - # 2️⃣ Récupérer les IDs label_ids = get_label_ids(label_names) - - # 3️⃣ Associer la vidéo aux labels dans une seule transaction with get_conn() as conn: cursor = conn.cursor() - - # 🔹 Supprimer les anciennes associations - cursor.execute( - "DELETE FROM video_labels WHERE video_file_name = ?", - (video_file_name,) - ) - - # 🔹 Ajouter les nouvelles associations + cursor.execute("DELETE FROM video_labels WHERE video_file_name = ?", (file_name,)) for lid in label_ids.values(): - cursor.execute(""" - INSERT INTO video_labels (video_file_name, label_id) - VALUES (?, ?) - """, (video_file_name, lid)) + cursor.execute( + "INSERT OR REPLACE INTO video_labels (video_file_name, label_id) VALUES (?, ?)", + (file_name, lid), + ) + cursor.execute(""" + DELETE FROM labels + WHERE id NOT IN (SELECT DISTINCT label_id FROM video_labels) + """) + conn.commit() +def update_video_difficulty(file_name, level): + with get_conn() as conn: + conn.execute("UPDATE videos SET difficulty_level = ? WHERE file_name = ?", (level, file_name)) conn.commit() - cursor = conn.cursor() - for lid in label_ids.values(): - cursor.execute(""" - INSERT OR REPLACE INTO video_labels (video_file_name, label_id) - VALUES (?, ?) - """, (video_file_name, lid)) - conn.commit() + +def get_unique_days(): + """Retourne la liste unique des jours de la semaine enregistrés dans la base.""" + with get_conn() as conn: + df = pd.read_sql_query("SELECT DISTINCT day_of_week FROM videos WHERE day_of_week IS NOT NULL ORDER BY day_of_week", conn) + return [d for d in df["day_of_week"].dropna().tolist() if d.strip()] + +def get_unique_difficulties(): + """Retourne la liste des niveaux de difficulté existants.""" + with get_conn() as conn: + df = pd.read_sql_query("SELECT DISTINCT difficulty_level FROM videos WHERE difficulty_level IS NOT NULL ORDER BY difficulty_level", conn) + return [d for d in df["difficulty_level"].dropna().tolist() if d.strip()] + +def get_unique_addresses(): + """Retourne les adresses connues (exclut 'unknown').""" + with get_conn() as conn: + df = pd.read_sql_query("SELECT DISTINCT address FROM videos WHERE address NOT LIKE '%unknown%' ORDER BY address", conn) + return [a for a in df["address"].dropna().tolist() if a.strip()] + + +def search_videos( + label_names=None, + day_of_week=None, + address_keyword=None, + start_date=None, + end_date=None, + difficulty=None +): + """ + Retourne une DataFrame filtrée selon les critères fournis. + """ + label_names = label_names or [] + + query = """ + SELECT DISTINCT v.* + FROM videos v + LEFT JOIN video_labels vl ON vl.video_file_name = v.file_name + LEFT JOIN labels l ON l.id = vl.label_id + WHERE 1=1 + """ + params = [] + + # 🔖 Filtres par label (tous doivent être présents) + if label_names: + placeholders = ",".join("?" for _ in label_names) + query += f" AND l.name IN ({placeholders})" + params += label_names + + # 📆 Jour de la semaine + if day_of_week: + query += " AND v.day_of_week = ?" + params.append(day_of_week) + + # 🗺️ Mot-clé d'adresse (et exclusion unknown) + if address_keyword: + query += " AND v.address NOT LIKE '%unknown%' AND v.address LIKE ?" + params.append(f"%{address_keyword}%") + + # 📅 Filtre par date + if start_date: + query += " AND v.record_datetime >= ?" + params.append(start_date) + if end_date: + query += " AND v.record_datetime <= ?" + params.append(end_date) + + # 💪 Niveau de difficulté + if difficulty and difficulty != "Tous": + query += " AND v.difficulty_level = ?" + params.append(difficulty) + + query += " ORDER BY v.record_datetime DESC" + + with get_conn() as conn: + return pd.read_sql_query(query, conn, params=params) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..4df145a --- /dev/null +++ b/app/models.py @@ -0,0 +1,55 @@ +# models.py +from pydantic import BaseModel, Field, validator +from typing import Optional, List +import db + + +class Video(BaseModel): + file_name: str + raw_file: str = Field(..., description="Identifiant unique de la vidéo") + duration: Optional[float] = None + mp4_file: Optional[str] = None + mp4_file_name: Optional[str] = None + rotated_file: Optional[str] = None + thumbnail_file: Optional[str] = None + record_datetime: Optional[str] = None + day_of_week: Optional[str] = None + lat: Optional[float] = None + long: Optional[float] = None + address: Optional[str] = None + difficulty_level: Optional[str] = Field("Tout niveau", description="Niveau de difficulté") + + # --- propriétés pratiques --- + @property + def id(self) -> str: + """Identifiant unique (basé sur file_name).""" + return self.file_name + + @property + def title(self) -> str: + """Nom court à afficher.""" + return self.mp4_file_name or self.file_name + + @property + def difficulty_display(self) -> str: + return self.difficulty_level or "Tout niveau" + + # --- Méthodes métiers --- + def load_labels(self) -> List[str]: + return db.load_video_labels(self.file_name) + + def save_labels(self, label_names: List[str]): + db.save_video_labels(self.file_name, label_names) + + # --- Validation optionnelle --- + @validator("raw_file") + def validate_raw_file(cls, v): + if not v or not isinstance(v, str): + raise ValueError("raw_file doit être une chaîne non vide") + return v + + @validator("duration") + def validate_duration(cls, v): + if v is not None and v < 0: + raise ValueError("duration ne peut pas être négative") + return v diff --git a/app/requirements.txt b/app/requirements.txt index a956ea2..d94a820 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -1,4 +1,5 @@ altair==5.5.0 +annotated-types==0.7.0 attrs==25.4.0 blinker==1.9.0 cachetools==6.2.0 @@ -21,6 +22,8 @@ pillow==11.3.0 pluggy==1.6.0 protobuf==6.32.1 pyarrow==21.0.0 +pydantic==2.12.0 +pydantic-core==2.41.1 pydeck==0.9.1 python-dateutil==2.9.0.post0 pytz==2025.2 @@ -37,5 +40,6 @@ tenacity==9.1.2 toml==0.10.2 tornado==6.5.2 typing-extensions==4.15.0 +typing-inspection==0.4.2 tzdata==2025.2 urllib3==2.5.0 diff --git a/app/views.py b/app/views.py index 85c15b0..ed03e7f 100644 --- a/app/views.py +++ b/app/views.py @@ -1,20 +1,18 @@ +# views.py import streamlit as st import os def show_video_thumbnail(video): col1, col2, col3 = st.columns([1, 2, 1]) - video_name = video["mp4_file_name"] - thumb = video["thumbnail_file"] - mp4 = video["mp4_file"] with col1: - if os.path.exists(thumb): - st.image(thumb, width="content") - st.caption(video_name) + if video.thumbnail_file and os.path.exists(video.thumbnail_file): + st.image(video.thumbnail_file, width="content") + st.caption(video.mp4_file_name) with col2: - if os.path.exists(mp4): - if st.button(f"▶️ Lire 📅 {video.get('record_datetime', '')} — 🕒 {video.get('day_of_week', '')}", key=f"play_{video_name}"): - st.video(mp4) + if video.mp4_file and os.path.exists(video.mp4_file): + if st.button(f"▶️ Lire 📅 {video.record_datetime or ''} — 🕒 {video.day_of_week or ''} - 📍 {video.address or ''}", key=f"play_{video.id}"): + st.video(video.mp4_file) return col3 diff --git a/model/register_video.sh b/model/register_video.sh index 1c371f8..ec7bc86 100644 --- a/model/register_video.sh +++ b/model/register_video.sh @@ -15,6 +15,7 @@ register_video() { local lat=${9:-0.000000} local long=${10:-0.000000} local address=${11:-Unknown} + address=$(sed "s|'| |g" <<< $address) if [ -z "$raw_file" ] || [ -z "$mp4_file" ]; then echo "Error: raw_file and mp4_file are required" exit 1 diff --git a/model/videos.sql b/model/videos.sql index f937edb..b092bc6 100644 --- a/model/videos.sql +++ b/model/videos.sql @@ -11,5 +11,7 @@ CREATE TABLE IF NOT EXISTS videos ( record_time TIME GENERATED ALWAYS AS (TIME(record_datetime)) VIRTUAL, lat DECIMAL(10,6), long DECIMAL(11,7), - address VARCHAR(255) -); \ No newline at end of file + address VARCHAR(255), + difficulty_level VARCHAR(255) DEFAULT 'Tout niveau' +); + diff --git a/program1/program1.sh b/program1/program1.sh index d3cd49c..c0bf4ff 100755 --- a/program1/program1.sh +++ b/program1/program1.sh @@ -71,11 +71,9 @@ mp4_dir() { export -f mp4_dir write_thumbnail() { - set -x local raw="$1" local thumbnail="$2" ffmpeg -ss 00:00:03 -i $raw -vframes 1 $thumbnail 2>/dev/null - set +x } export -f write_thumbnail