playlist edition
This commit is contained in:
131
app/app.py
131
app/app.py
@@ -1,115 +1,30 @@
|
|||||||
|
# app.py
|
||||||
import streamlit as st
|
import streamlit as st
|
||||||
import db
|
from views.label_views import video_filter_sidebar, video_list_view
|
||||||
from models import Video
|
from playlists import playlist_page
|
||||||
from views import show_video_thumbnail
|
|
||||||
from controllers import label_widget
|
|
||||||
from datetime import date
|
|
||||||
|
|
||||||
|
# ==========================
|
||||||
|
# 🧭 Configuration
|
||||||
|
# ==========================
|
||||||
st.set_page_config(page_title="Dance Video Manager", layout="wide")
|
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 ---
|
page = st.sidebar.radio(
|
||||||
st.sidebar.header("⚙️ Filtres et affichage")
|
"Navigation",
|
||||||
max_height = st.sidebar.slider("Hauteur maximale (px)", 100, 800, 300, 50)
|
["Vidéos", "Playlists"],
|
||||||
st.markdown(f"""
|
key="nav_main"
|
||||||
<style>
|
|
||||||
img, video {{
|
|
||||||
max-height: {max_height}px !important;
|
|
||||||
object-fit: contain;
|
|
||||||
border-radius: 8px;
|
|
||||||
transition: all 0.3s ease-in-out;
|
|
||||||
}}
|
|
||||||
.unlabeled {{
|
|
||||||
border: 3px solid #f39c12;
|
|
||||||
box-shadow: 0 0 10px #f39c12;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 5px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}}
|
|
||||||
</style>
|
|
||||||
""", 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
|
|
||||||
)
|
)
|
||||||
|
|
||||||
difficulty_filter = st.sidebar.selectbox(
|
# ==========================
|
||||||
"Niveau de difficulté",
|
# 🎬 PAGE : VIDÉOS
|
||||||
["Tous"] + unique_difficulties
|
# ==========================
|
||||||
)
|
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é)",
|
# 🎵 PAGE : PLAYLISTS
|
||||||
[""] + unique_addresses
|
# ==========================
|
||||||
)
|
elif page == "Playlists":
|
||||||
|
playlist_page.main()
|
||||||
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"<div class='{css_class}'>", 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("</div>", 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.")
|
|
||||||
|
|||||||
0
app/controllers/__init__.py
Normal file
0
app/controllers/__init__.py
Normal file
102
app/db.py
102
app/db.py
@@ -7,6 +7,7 @@ DB_PATH = Path.home() / "Documents/.DanceVideos/db.sqlite"
|
|||||||
|
|
||||||
def get_conn():
|
def get_conn():
|
||||||
conn = sqlite3.connect(DB_PATH, timeout=30, check_same_thread=False)
|
conn = sqlite3.connect(DB_PATH, timeout=30, check_same_thread=False)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
conn.execute("PRAGMA foreign_keys = ON;")
|
conn.execute("PRAGMA foreign_keys = ON;")
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
@@ -101,52 +102,111 @@ def search_videos(
|
|||||||
address_keyword=None,
|
address_keyword=None,
|
||||||
start_date=None,
|
start_date=None,
|
||||||
end_date=None,
|
end_date=None,
|
||||||
difficulty=None
|
difficulty=None,
|
||||||
|
label_logic="OR",
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Retourne une DataFrame filtrée selon les critères fournis.
|
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 []
|
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 = []
|
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:
|
if label_names:
|
||||||
placeholders = ",".join("?" for _ in label_names)
|
if label_logic == "AND":
|
||||||
query += f" AND l.name IN ({placeholders})"
|
# Toutes les étiquettes doivent être présentes
|
||||||
params += label_names
|
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
|
# 📆 Jour de la semaine
|
||||||
if day_of_week:
|
if day_of_week:
|
||||||
query += " AND v.day_of_week = ?"
|
base_query += " AND v.day_of_week = ?"
|
||||||
params.append(day_of_week)
|
params.append(day_of_week)
|
||||||
|
|
||||||
# 🗺️ Mot-clé d'adresse (et exclusion unknown)
|
# 🗺️ Mot-clé d'adresse (et exclusion unknown)
|
||||||
if address_keyword:
|
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}%")
|
params.append(f"%{address_keyword}%")
|
||||||
|
|
||||||
# 📅 Filtre par date
|
# 📅 Plage de dates
|
||||||
if start_date:
|
if start_date:
|
||||||
query += " AND v.record_datetime >= ?"
|
base_query += " AND v.record_datetime >= ?"
|
||||||
params.append(start_date)
|
params.append(start_date)
|
||||||
if end_date:
|
if end_date:
|
||||||
query += " AND v.record_datetime <= ?"
|
base_query += " AND v.record_datetime <= ?"
|
||||||
params.append(end_date)
|
params.append(end_date)
|
||||||
|
|
||||||
# 💪 Niveau de difficulté
|
# 💪 Niveau de difficulté
|
||||||
if difficulty and difficulty != "Tous":
|
if difficulty and difficulty != "Tous":
|
||||||
query += " AND v.difficulty_level = ?"
|
base_query += " AND v.difficulty_level = ?"
|
||||||
params.append(difficulty)
|
params.append(difficulty)
|
||||||
|
|
||||||
query += " ORDER BY v.record_datetime DESC"
|
# 🔽 Tri
|
||||||
|
base_query += " ORDER BY v.record_datetime DESC"
|
||||||
|
|
||||||
with get_conn() as conn:
|
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()
|
||||||
|
|||||||
0
app/playlists/__init__.py
Normal file
0
app/playlists/__init__.py
Normal file
68
app/playlists/playlist_controller.py
Normal file
68
app/playlists/playlist_controller.py
Normal file
@@ -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)
|
||||||
95
app/playlists/playlist_db.py
Normal file
95
app/playlists/playlist_db.py
Normal file
@@ -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()
|
||||||
63
app/playlists/playlist_model.py
Normal file
63
app/playlists/playlist_model.py
Normal file
@@ -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()
|
||||||
35
app/playlists/playlist_page.py
Normal file
35
app/playlists/playlist_page.py
Normal file
@@ -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)
|
||||||
15
app/playlists/playlist_views.py
Normal file
15
app/playlists/playlist_views.py
Normal file
@@ -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)
|
||||||
0
app/views/__init__.py
Normal file
0
app/views/__init__.py
Normal file
107
app/views/label_views.py
Normal file
107
app/views/label_views.py
Normal file
@@ -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"""
|
||||||
|
<style>
|
||||||
|
img, video {{
|
||||||
|
max-height: {filters["max_height"]}px !important;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.3s ease-in-out;
|
||||||
|
}}
|
||||||
|
.unlabeled {{
|
||||||
|
border: 3px solid #f39c12;
|
||||||
|
box-shadow: 0 0 10px #f39c12;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 5px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
""", 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"<div class='{css_class}'>", unsafe_allow_html=True)
|
||||||
|
show_video_row(video, preselected, editable_labels, editable_difficulty)
|
||||||
|
st.markdown("</div>", 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
|
||||||
32
app/views/video_views.py
Normal file
32
app/views/video_views.py
Normal file
@@ -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}")
|
||||||
@@ -1,17 +1,30 @@
|
|||||||
-- Table des playlists
|
-- =========================================================
|
||||||
|
-- Table principale des playlists
|
||||||
|
-- =========================================================
|
||||||
CREATE TABLE IF NOT EXISTS playlists (
|
CREATE TABLE IF NOT EXISTS playlists (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name VARCHAR(100) UNIQUE NOT NULL,
|
name TEXT UNIQUE NOT NULL,
|
||||||
description TEXT,
|
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 (
|
CREATE TABLE IF NOT EXISTS video_playlists (
|
||||||
video_file_name VARCHAR(255),
|
video_file_name TEXT NOT NULL,
|
||||||
playlist_id INTEGER,
|
playlist_id INTEGER NOT NULL,
|
||||||
position INTEGER, -- pour gérer l’ordre dans la playlist
|
position INTEGER DEFAULT 0, -- ordre dans la playlist
|
||||||
PRIMARY KEY (video_file_name, playlist_id),
|
PRIMARY KEY (video_file_name, playlist_id),
|
||||||
FOREIGN KEY (video_file_name) REFERENCES videos(file_name) ON DELETE CASCADE,
|
FOREIGN KEY (video_file_name) REFERENCES videos(file_name) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE
|
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- =========================================================
|
||||||
|
-- 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);
|
||||||
|
|||||||
10
model/videos_summary.sql
Normal file
10
model/videos_summary.sql
Normal file
@@ -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;
|
||||||
Reference in New Issue
Block a user