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