diff --git a/app/app.py b/app/app.py
index 99fd509..ea8a252 100644
--- a/app/app.py
+++ b/app/app.py
@@ -1,115 +1,30 @@
+# app.py
import streamlit as st
-import db
-from models import Video
-from views import show_video_thumbnail
-from controllers import label_widget
-from datetime import date
+from views.label_views import video_filter_sidebar, video_list_view
+from playlists import playlist_page
+# ==========================
+# 🧭 Configuration
+# ==========================
st.set_page_config(page_title="Dance Video Manager", layout="wide")
-st.title("💃 Dance Video Explorer")
+st.sidebar.title("💃 Menu principal")
-# --- 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"""
-
-""", 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
+page = st.sidebar.radio(
+ "Navigation",
+ ["Vidéos", "Playlists"],
+ key="nav_main"
)
-difficulty_filter = st.sidebar.selectbox(
- "Niveau de difficulté",
- ["Tous"] + unique_difficulties
-)
+# ==========================
+# 🎬 PAGE : VIDÉOS
+# ==========================
+if page == "Vidéos":
+ st.title("🎬 Gestion et annotation des vidéos")
+ filters = video_filter_sidebar()
+ video_list_view(filters)
-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)
- with col3:
- 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.")
+# ==========================
+# 🎵 PAGE : PLAYLISTS
+# ==========================
+elif page == "Playlists":
+ playlist_page.main()
diff --git a/app/controllers/__init__.py b/app/controllers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/controllers.py b/app/controllers/label_controller.py
similarity index 100%
rename from app/controllers.py
rename to app/controllers/label_controller.py
diff --git a/app/db.py b/app/db.py
index 0ab4057..b8b975d 100644
--- a/app/db.py
+++ b/app/db.py
@@ -7,6 +7,7 @@ DB_PATH = Path.home() / "Documents/.DanceVideos/db.sqlite"
def get_conn():
conn = sqlite3.connect(DB_PATH, timeout=30, check_same_thread=False)
+ conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON;")
return conn
@@ -101,52 +102,111 @@ def search_videos(
address_keyword=None,
start_date=None,
end_date=None,
- difficulty=None
+ difficulty=None,
+ label_logic="OR",
):
"""
Retourne une DataFrame filtrée selon les critères fournis.
+ label_logic: "OR" (au moins un label) ou "AND" (tous les labels).
"""
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)
+ base_query = """
+ SELECT DISTINCT v.*
+ FROM videos v
+ WHERE 1=1
+ """
+
+ # 🔖 Filtres par label
if label_names:
- placeholders = ",".join("?" for _ in label_names)
- query += f" AND l.name IN ({placeholders})"
- params += label_names
+ if label_logic == "AND":
+ # Toutes les étiquettes doivent être présentes
+ placeholders = ",".join("?" * len(label_names))
+ base_query += f"""
+ AND v.file_name IN (
+ SELECT vl.video_file_name
+ FROM video_labels vl
+ JOIN labels l ON l.id = vl.label_id
+ WHERE l.name IN ({placeholders})
+ GROUP BY vl.video_file_name
+ HAVING COUNT(DISTINCT l.name) = {len(label_names)}
+ )
+ """
+ params.extend(label_names)
+ else:
+ # OR : au moins une étiquette correspond
+ placeholders = ",".join("?" * len(label_names))
+ base_query += f"""
+ AND v.file_name IN (
+ SELECT vl.video_file_name
+ FROM video_labels vl
+ JOIN labels l ON l.id = vl.label_id
+ WHERE l.name IN ({placeholders})
+ )
+ """
+ params.extend(label_names)
# 📆 Jour de la semaine
if day_of_week:
- query += " AND v.day_of_week = ?"
+ base_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 ?"
+ base_query += " AND v.address NOT LIKE '%unknown%' AND v.address LIKE ?"
params.append(f"%{address_keyword}%")
- # 📅 Filtre par date
+ # 📅 Plage de dates
if start_date:
- query += " AND v.record_datetime >= ?"
+ base_query += " AND v.record_datetime >= ?"
params.append(start_date)
if end_date:
- query += " AND v.record_datetime <= ?"
+ base_query += " AND v.record_datetime <= ?"
params.append(end_date)
# 💪 Niveau de difficulté
if difficulty and difficulty != "Tous":
- query += " AND v.difficulty_level = ?"
+ base_query += " AND v.difficulty_level = ?"
params.append(difficulty)
- query += " ORDER BY v.record_datetime DESC"
+ # 🔽 Tri
+ base_query += " ORDER BY v.record_datetime DESC"
with get_conn() as conn:
- return pd.read_sql_query(query, conn, params=params)
+ return pd.read_sql_query(base_query, conn, params=params)
+
+
+def get_video_playlists(file_name):
+ with get_conn() as conn:
+ query = """
+ SELECT p.name
+ FROM playlists p
+ JOIN video_playlists vp ON vp.playlist_id = p.id
+ WHERE vp.video_file_name = ?
+ """
+ return [row[0] for row in conn.execute(query, (file_name,))]
+
+def get_videos_in_playlist(playlist_id):
+ with get_conn() as conn:
+ query = """
+ SELECT v.*
+ FROM videos v
+ JOIN video_playlists vp ON vp.video_file_name = v.file_name
+ WHERE vp.playlist_id = ?
+ ORDER BY vp.position
+ """
+ return conn.execute(query, (playlist_id,)).fetchall()
+
+def add_video_to_playlist(playlist_id, file_name):
+ with get_conn() as conn:
+ conn.execute("""
+ INSERT OR IGNORE INTO video_playlists (video_file_name, playlist_id, position)
+ VALUES (?, ?, COALESCE((SELECT MAX(position)+1 FROM video_playlists WHERE playlist_id=?), 0))
+ """, (file_name, playlist_id, playlist_id))
+ conn.commit()
+
+def remove_video_from_playlist(playlist_id, file_name):
+ with get_conn() as conn:
+ conn.execute("DELETE FROM video_playlists WHERE playlist_id=? AND video_file_name=?", (playlist_id, file_name))
+ conn.commit()
diff --git a/app/playlists/__init__.py b/app/playlists/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/playlists/playlist_controller.py b/app/playlists/playlist_controller.py
new file mode 100644
index 0000000..382bb94
--- /dev/null
+++ b/app/playlists/playlist_controller.py
@@ -0,0 +1,68 @@
+# playlists/playlist_controller.py
+import streamlit as st
+import db
+from playlists.playlist_model import Playlist, RuleSet
+from playlists import playlist_db
+from views.label_views import video_filter_sidebar, video_list_view
+from models import Video
+
+def playlist_manual_editor(playlist: Playlist):
+ """Permet de gérer les vidéos d'une playlist manuelle."""
+ st.subheader(f"🎞️ Édition manuelle : {playlist.name}")
+ filters = video_filter_sidebar()
+ videos = video_list_view(filters, editable_labels=False, editable_difficulty=False)
+
+ existing_videos = db.get_videos_in_playlist(playlist.id)
+ existing_names = [v["file_name"] for v in existing_videos]
+
+ for video in videos:
+ in_playlist = video.file_name in existing_names
+ button_label = "➕ Ajouter" if not in_playlist else "❌ Retirer"
+ if st.button(button_label, key=f"toggle_{playlist.id}_{video.file_name}"):
+ if in_playlist:
+ db.remove_video_from_playlist(playlist.id, video.file_name)
+ st.success(f"Supprimée de {playlist.name}")
+ else:
+ db.add_video_to_playlist(playlist.id, video.file_name)
+ st.success(f"Ajoutée à {playlist.name}")
+ st.rerun()
+
+def playlist_dynamic_editor(playlist: Playlist):
+ """Édite les règles d'une playlist dynamique et affiche le rendu en temps réel."""
+ st.subheader(f"⚙️ Playlist dynamique : {playlist.name}")
+
+ rules = playlist.rules or RuleSet()
+ labels = db.load_labels()
+ playlists = [p.name for p in playlist_db.load_all_playlists()]
+
+ rules.include_labels = st.multiselect("Inclure labels", labels, default=rules.include_labels)
+ rules.exclude_labels = st.multiselect("Exclure labels", labels, default=rules.exclude_labels)
+ rules.include_playlists = st.multiselect("Inclure playlists", playlists, default=rules.include_playlists)
+ rules.exclude_playlists = st.multiselect("Exclure playlists", playlists, default=rules.exclude_playlists)
+
+ col1, col2 = st.columns(2)
+ with col1:
+ rules.date_after = st.date_input("📅 Après le", value=rules.date_after or None)
+ with col2:
+ rules.date_before = st.date_input("📅 Avant le", value=rules.date_before or None)
+
+ rules.logic = st.radio("Logique de combinaison", ["AND", "OR"], index=0 if rules.logic == "AND" else 1)
+
+ if st.button("💾 Enregistrer les règles"):
+ playlist.rules = rules
+ playlist.save()
+ st.success("Règles mises à jour ✅")
+ st.rerun()
+
+ st.markdown("---")
+ st.subheader("🧩 Rendu de la playlist")
+ rows = playlist_db.get_videos_for_playlist(playlist)
+ if not rows:
+ st.info("Aucune vidéo ne correspond aux règles actuelles.")
+ return
+
+ videos = [Video(**row) for row in rows]
+ st.write(f"{len(videos)} vidéo(s) trouvée(s).")
+ from views.video_views import show_video_row
+ for v in videos[:30]:
+ show_video_row(v, db.load_video_labels(v.file_name), editable_labels=False, editable_difficulty=False)
diff --git a/app/playlists/playlist_db.py b/app/playlists/playlist_db.py
new file mode 100644
index 0000000..e76c46a
--- /dev/null
+++ b/app/playlists/playlist_db.py
@@ -0,0 +1,95 @@
+# playlists/playlist_db.py
+import db
+import json
+from playlists.playlist_model import Playlist, RuleSet
+
+def load_all_playlists():
+ """Retourne une liste de Playlist (Pydantic) — tolérant aux rules_json nuls / vides."""
+ with db.get_conn() as conn:
+ # s'assurer que les lignes sont accessibles par nom (si get_conn ne l'a pas fait)
+ try:
+ conn.row_factory = conn.row_factory # no-op si déjà réglé
+ except Exception:
+ pass
+
+ rows = conn.execute("SELECT * FROM playlists ORDER BY created_at DESC").fetchall()
+ playlists = []
+ for row in rows:
+ # si row est sqlite3.Row, on peut accéder par nom, sinon c'est un tuple et on mappe par index
+ if hasattr(row, "__getitem__") and isinstance(row, dict) is False and getattr(row, "keys", None):
+ # sqlite3.Row behaves like mapping
+ row_dict = {k: row[k] for k in row.keys()}
+ elif isinstance(row, dict):
+ row_dict = row
+ else:
+ # fallback: convert tuple -> dict using cursor.description
+ # but here we assume conn.row_factory set in db.get_conn; keep robust fallback
+ cols = [d[0] for d in conn.execute("PRAGMA table_info(playlists)").fetchall()]
+ row_dict = dict(zip(cols, row))
+
+ try:
+ pl = Playlist(
+ id=row_dict.get("id"),
+ name=row_dict.get("name") or "",
+ description=row_dict.get("description") or "",
+ type=row_dict.get("type") or "manual",
+ rules=row_dict.get("rules_json")
+ )
+ playlists.append(pl)
+ except Exception as e:
+ # Ne bloque pas toute la lecture : logue et passe à la suivante
+ print(f"⚠️ Ignored invalid playlist row (id={row_dict.get('id')}, name={row_dict.get('name')}): {e}")
+ return playlists
+
+def delete_playlist(playlist_id):
+ with db.get_conn() as conn:
+ conn.execute("DELETE FROM playlists WHERE id = ?", (playlist_id,))
+ conn.commit()
+
+def get_videos_for_playlist(playlist: Playlist):
+ """Retourne les vidéos selon le type"""
+ with db.get_conn() as conn:
+ if playlist.type == "manual":
+ q = """
+ SELECT v.*
+ FROM videos v
+ JOIN video_playlists vp ON vp.video_file_name = v.file_name
+ WHERE vp.playlist_id = ?
+ ORDER BY vp.position
+ """
+ return conn.execute(q, (playlist.id,)).fetchall()
+ else:
+ rules = playlist.rules
+ clauses = []
+ params = []
+
+ if rules.include_labels:
+ placeholders = ",".join("?" * len(rules.include_labels))
+ clauses.append(f"v.file_name IN (SELECT vl.video_file_name FROM video_labels vl JOIN labels l ON l.id=vl.label_id WHERE l.name IN ({placeholders}))")
+ params += rules.include_labels
+
+ if rules.exclude_labels:
+ placeholders = ",".join("?" * len(rules.exclude_labels))
+ clauses.append(f"v.file_name NOT IN (SELECT vl.video_file_name FROM video_labels vl JOIN labels l ON l.id=vl.label_id WHERE l.name IN ({placeholders}))")
+ params += rules.exclude_labels
+
+ if rules.include_playlists:
+ placeholders = ",".join("?" * len(rules.include_playlists))
+ clauses.append(f"v.file_name IN (SELECT vp.video_file_name FROM video_playlists vp JOIN playlists p ON vp.playlist_id=p.id WHERE p.name IN ({placeholders}))")
+ params += rules.include_playlists
+
+ if rules.exclude_playlists:
+ placeholders = ",".join("?" * len(rules.exclude_playlists))
+ clauses.append(f"v.file_name NOT IN (SELECT vp.video_file_name FROM video_playlists vp JOIN playlists p ON vp.playlist_id=p.id WHERE p.name IN ({placeholders}))")
+ params += rules.exclude_playlists
+
+ if rules.date_after:
+ clauses.append("v.record_datetime >= ?")
+ params.append(rules.date_after)
+ if rules.date_before:
+ clauses.append("v.record_datetime <= ?")
+ params.append(rules.date_before)
+
+ where_clause = f" {' AND ' if rules.logic == 'AND' else ' OR '} ".join(clauses) if clauses else "1=1"
+ q = f"SELECT v.* FROM videos v WHERE {where_clause} ORDER BY v.record_datetime DESC"
+ return conn.execute(q, params).fetchall()
diff --git a/app/playlists/playlist_model.py b/app/playlists/playlist_model.py
new file mode 100644
index 0000000..6df0bb6
--- /dev/null
+++ b/app/playlists/playlist_model.py
@@ -0,0 +1,63 @@
+# playlists/playlist_model.py
+from pydantic import BaseModel, Field, validator
+from typing import List, Optional, Literal
+import json
+import db
+
+
+class RuleSet(BaseModel):
+ include_labels: List[str] = []
+ exclude_labels: List[str] = []
+ include_playlists: List[str] = []
+ exclude_playlists: List[str] = []
+ date_after: Optional[str] = None
+ date_before: Optional[str] = None
+ logic: Literal["AND", "OR"] = "AND"
+
+ def to_json(self) -> str:
+ return json.dumps(self.dict(), ensure_ascii=False, indent=2)
+
+ @classmethod
+ def from_json(cls, raw):
+ """Accepte None, str vide ou dict JSON."""
+ if not raw:
+ return cls()
+ if isinstance(raw, dict):
+ return cls(**raw)
+ try:
+ return cls(**json.loads(raw))
+ except Exception:
+ return cls()
+
+
+class Playlist(BaseModel):
+ id: Optional[int] = None
+ name: str
+ description: Optional[str] = ""
+ type: Literal["manual", "dynamic"] = "manual"
+ rules: RuleSet = Field(default_factory=RuleSet)
+
+ @validator("rules", pre=True, always=True)
+ def ensure_rules(cls, v):
+ """Transforme la valeur de rules_json (None, str ou dict) en RuleSet."""
+ if isinstance(v, RuleSet):
+ return v
+ return RuleSet.from_json(v)
+
+ def save(self):
+ """Insert or update playlist"""
+ with db.get_conn() as conn:
+ cur = conn.cursor()
+ if self.id:
+ cur.execute("""
+ UPDATE playlists
+ SET name=?, description=?, type=?, rules_json=?, updated_at=CURRENT_TIMESTAMP
+ WHERE id=?
+ """, (self.name, self.description, self.type, self.rules.to_json(), self.id))
+ else:
+ cur.execute("""
+ INSERT INTO playlists (name, description, type, rules_json)
+ VALUES (?, ?, ?, ?)
+ """, (self.name, self.description, self.type, self.rules.to_json()))
+ self.id = cur.lastrowid
+ conn.commit()
diff --git a/app/playlists/playlist_page.py b/app/playlists/playlist_page.py
new file mode 100644
index 0000000..ff8787e
--- /dev/null
+++ b/app/playlists/playlist_page.py
@@ -0,0 +1,35 @@
+# playlists/playlist_page.py
+import streamlit as st
+from playlists import playlist_db
+from playlists.playlist_model import Playlist
+from playlists.playlist_controller import playlist_manual_editor, playlist_dynamic_editor
+from playlists.playlist_controller import RuleSet
+
+def main():
+ st.title("🎵 Gestion des Playlists")
+
+ playlists = playlist_db.load_all_playlists()
+ selected = st.selectbox("Playlists existantes", ["(Nouvelle)"] + [p.name for p in playlists])
+
+ # playlists/playlist_page.py (partie création)
+ if selected == "(Nouvelle)":
+ st.subheader("➕ Nouvelle playlist")
+ name = st.text_input("Nom")
+ desc = st.text_area("Description")
+ type_choice = st.radio("Type", ["manual", "dynamic"])
+ if st.button("Créer"):
+ if not name.strip():
+ st.error("Le nom ne peut pas être vide.")
+ else:
+ pl = Playlist(name=name.strip(), description=desc or "", type=type_choice, rules=RuleSet())
+ pl.save()
+ st.success("Playlist créée ✅")
+ # Recharge immédiate : rerun force tout à re-exécuter
+ st.rerun()
+
+ else:
+ current = next(p for p in playlists if p.name == selected)
+ if current.type == "manual":
+ playlist_manual_editor(current)
+ else:
+ playlist_dynamic_editor(current)
diff --git a/app/playlists/playlist_views.py b/app/playlists/playlist_views.py
new file mode 100644
index 0000000..91b3067
--- /dev/null
+++ b/app/playlists/playlist_views.py
@@ -0,0 +1,15 @@
+# playlists/playlist_views.py
+import streamlit as st
+from playlists import playlist_db
+from models import Video
+from views import show_video_thumbnail
+
+def preview_playlist(playlist):
+ st.subheader(f"🎬 Aperçu de la playlist : {playlist.name}")
+ rows = playlist_db.get_videos_for_playlist(playlist)
+ videos = [Video(**row) for row in rows]
+ if not videos:
+ st.info("Aucune vidéo correspondante.")
+ return
+ for video in videos[:30]: # limiter pour performance
+ show_video_thumbnail(video)
diff --git a/app/views/__init__.py b/app/views/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/views/label_views.py b/app/views/label_views.py
new file mode 100644
index 0000000..eb7e549
--- /dev/null
+++ b/app/views/label_views.py
@@ -0,0 +1,107 @@
+import streamlit as st
+import db
+from models import Video
+from views.video_views import show_video_row
+from controllers.label_controller import label_widget
+
+def video_filter_sidebar():
+ """Affiche les filtres dans la barre latérale et renvoie les paramètres de recherche."""
+ st.sidebar.header("⚙️ Filtres et affichage")
+ max_height = st.sidebar.slider("Hauteur max (px)", 100, 800, 300, 50)
+
+ 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)
+ label_logic = st.sidebar.radio(
+ "Logique entre labels",
+ ["OR", "AND"],
+ help="Détermine si la vidéo doit contenir tous les labels sélectionnés (AND) ou au moins un (OR)"
+ )
+ day_filter = st.sidebar.selectbox("Jour de la semaine", ["Tous"] + unique_days)
+ difficulty_filter = st.sidebar.selectbox("Niveau de difficulté", ["Tous"] + unique_difficulties)
+ 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")
+
+ return dict(
+ max_height=max_height,
+ selected_labels=selected_labels,
+ label_logic=label_logic,
+ day_filter=None if day_filter == "Tous" else day_filter,
+ difficulty_filter=difficulty_filter,
+ 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,
+ show_unlabeled_only=show_unlabeled_only
+ )
+
+def video_list_view(filters: dict, editable_labels=True, editable_difficulty=True):
+ """Affiche les vidéos selon les filtres fournis."""
+ st.markdown(f"""
+
+ """, unsafe_allow_html=True)
+
+ df_videos = db.search_videos(
+ label_names=filters["selected_labels"],
+ label_logic=filters["label_logic"],
+ day_of_week=filters["day_filter"],
+ address_keyword=filters["address_keyword"],
+ start_date=filters["start_date"],
+ end_date=filters["end_date"],
+ difficulty=filters["difficulty_filter"]
+ )
+
+ if df_videos.empty:
+ st.warning("Aucune vidéo trouvée avec ces critères.")
+ return []
+
+ # préchargement des labels
+ video_labels_map = {row["file_name"]: db.load_video_labels(row["file_name"]) for _, row in df_videos.iterrows()}
+ if filters["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
+ st.session_state.setdefault("video_page", 1)
+ start = 0
+ end = st.session_state.video_page * page_size
+ subset = videos[start:end]
+
+ # affichage
+ for video in subset:
+ 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)
+ show_video_row(video, preselected, editable_labels, editable_difficulty)
+ st.markdown("
", unsafe_allow_html=True)
+
+ # lazy loading bouton
+ 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.")
+
+ return subset
diff --git a/app/views/video_views.py b/app/views/video_views.py
new file mode 100644
index 0000000..48ca638
--- /dev/null
+++ b/app/views/video_views.py
@@ -0,0 +1,32 @@
+import streamlit as st
+from controllers.label_controller import label_widget
+import db
+
+def show_video_row(video, preselected_labels, editable_labels=True, editable_difficulty=True):
+ col1, col2, col3 = st.columns([1, 3, 2])
+ with col1:
+ if video.thumbnail_file:
+ st.image(video.thumbnail_file)
+ st.caption(video.mp4_file_name)
+
+ with col2:
+ st.markdown(f"**📅 {video.record_datetime or ''}** — {video.day_of_week or ''}")
+ st.text(f"🏷️ Labels: {', '.join(preselected_labels) or 'Aucun'}")
+ playlists = db.get_video_playlists(video.file_name)
+ if playlists:
+ st.text(f"🎵 Playlists: {', '.join(playlists)}")
+
+ with col3:
+ if editable_labels:
+ label_widget(video, preselected=preselected_labels)
+ if editable_difficulty:
+ levels = ["Tout niveau", "Débutant", "Intermédiaire", "Avancé", "Star"]
+ new_level = st.selectbox(
+ "🎚 Niveau",
+ levels,
+ index=levels.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}")
diff --git a/model/videos_playlists.sql b/model/videos_playlists.sql
index 43ab178..975cbbb 100644
--- a/model/videos_playlists.sql
+++ b/model/videos_playlists.sql
@@ -1,17 +1,30 @@
--- Table des playlists
+-- =========================================================
+-- Table principale des playlists
+-- =========================================================
CREATE TABLE IF NOT EXISTS playlists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
- name VARCHAR(100) UNIQUE NOT NULL,
+ name TEXT UNIQUE NOT NULL,
description TEXT,
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ type TEXT CHECK (type IN ('manual', 'dynamic')) NOT NULL DEFAULT 'manual',
+ rules_json TEXT, -- JSON décrivant les règles pour les playlists dynamiques
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
--- Table d'association entre vidéos et playlists (relation many-to-many)
+-- =========================================================
+-- Table d’association vidéos ↔ playlists (manuelles uniquement)
+-- =========================================================
CREATE TABLE IF NOT EXISTS video_playlists (
- video_file_name VARCHAR(255),
- playlist_id INTEGER,
- position INTEGER, -- pour gérer l’ordre dans la playlist
+ video_file_name TEXT NOT NULL,
+ playlist_id INTEGER NOT NULL,
+ position INTEGER DEFAULT 0, -- ordre dans la playlist
PRIMARY KEY (video_file_name, playlist_id),
FOREIGN KEY (video_file_name) REFERENCES videos(file_name) ON DELETE CASCADE,
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE
-);
\ No newline at end of file
+);
+
+-- =========================================================
+-- Index pour accélérer les recherches
+-- =========================================================
+CREATE INDEX IF NOT EXISTS idx_playlist_type ON playlists(type);
+CREATE INDEX IF NOT EXISTS idx_video_playlists_playlist ON video_playlists(playlist_id);
diff --git a/model/videos_summary.sql b/model/videos_summary.sql
new file mode 100644
index 0000000..7619530
--- /dev/null
+++ b/model/videos_summary.sql
@@ -0,0 +1,10 @@
+CREATE TABLE IF NOT EXISTS video_summary AS
+SELECT v.file_name,
+ GROUP_CONCAT(DISTINCT l.name) AS labels,
+ GROUP_CONCAT(DISTINCT p.name) AS playlists
+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
+LEFT JOIN video_playlists vp ON vp.video_file_name = v.file_name
+LEFT JOIN playlists p ON p.id = vp.playlist_id
+GROUP BY v.file_name;