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;