Compare commits
3 Commits
cc9fb9cede
...
d2e2028610
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2e2028610 | ||
|
|
65d63ec828 | ||
|
|
0fa5a30809 |
54
app/app.py
54
app/app.py
@@ -1,38 +1,30 @@
|
||||
# app.py
|
||||
import streamlit as st
|
||||
import os
|
||||
import db
|
||||
from views import show_video_thumbnail
|
||||
from controllers import label_widget
|
||||
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 Manager")
|
||||
st.sidebar.title("💃 Menu principal")
|
||||
|
||||
# --- Barre latérale : hauteur max ---
|
||||
st.sidebar.header("⚙️ Apparence")
|
||||
max_height = st.sidebar.slider("Hauteur maximale (px)", 100, 800, 300, 50)
|
||||
st.markdown(
|
||||
f"""
|
||||
<style>
|
||||
img, video {{
|
||||
max-height: {max_height}px !important;
|
||||
object-fit: contain;
|
||||
transition: all 0.3s ease-in-out;
|
||||
border-radius: 8px;
|
||||
}}
|
||||
</style>
|
||||
""",
|
||||
unsafe_allow_html=True
|
||||
page = st.sidebar.radio(
|
||||
"Navigation",
|
||||
["Vidéos", "Playlists"],
|
||||
key="nav_main"
|
||||
)
|
||||
|
||||
# --- Charger données ---
|
||||
videos = db.load_videos()
|
||||
labels = db.load_labels()
|
||||
# ==========================
|
||||
# 🎬 PAGE : VIDÉOS
|
||||
# ==========================
|
||||
if page == "Vidéos":
|
||||
st.title("🎬 Gestion et annotation des vidéos")
|
||||
filters = video_filter_sidebar()
|
||||
video_list_view(filters)
|
||||
|
||||
if videos.empty:
|
||||
st.warning("Aucune vidéo trouvée dans la base.")
|
||||
else:
|
||||
for _, video in videos.iterrows():
|
||||
col3 = show_video_thumbnail(video)
|
||||
preselected = db.load_video_labels(video["mp4_file_name"])
|
||||
with col3:
|
||||
label_widget(video["mp4_file_name"], preselected=preselected)
|
||||
# ==========================
|
||||
# 🎵 PAGE : PLAYLISTS
|
||||
# ==========================
|
||||
elif page == "Playlists":
|
||||
playlist_page.main()
|
||||
|
||||
155
app/app.py.old
155
app/app.py.old
@@ -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"""
|
||||
<style>
|
||||
/* Limite globale de hauteur */
|
||||
img, video {{
|
||||
max-height: {max_height}px !important;
|
||||
object-fit: contain;
|
||||
transition: all 0.3s ease-in-out;
|
||||
}}
|
||||
</style>
|
||||
""",
|
||||
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()
|
||||
26
app/cache/video_summary.py
vendored
Normal file
26
app/cache/video_summary.py
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
# cache/video_summary.py
|
||||
import db
|
||||
|
||||
def rebuild_video_summary():
|
||||
with db.get_conn() as conn:
|
||||
conn.execute("DROP TABLE IF EXISTS video_summary")
|
||||
conn.execute("""
|
||||
CREATE TABLE video_summary AS
|
||||
SELECT
|
||||
v.file_name,
|
||||
v.raw_file,
|
||||
v.mp4_file,
|
||||
v.record_datetime,
|
||||
v.day_of_week,
|
||||
v.difficulty_level,
|
||||
v.address,
|
||||
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
|
||||
""")
|
||||
conn.commit()
|
||||
0
app/controllers/__init__.py
Normal file
0
app/controllers/__init__.py
Normal file
@@ -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
|
||||
214
app/db.py
214
app/db.py
@@ -1,53 +1,37 @@
|
||||
# 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.row_factory = sqlite3.Row
|
||||
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 +42,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 +51,162 @@ 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))
|
||||
|
||||
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,
|
||||
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 []
|
||||
params = []
|
||||
|
||||
base_query = """
|
||||
SELECT DISTINCT v.*
|
||||
FROM videos v
|
||||
WHERE 1=1
|
||||
"""
|
||||
|
||||
# 🔖 Filtres par label
|
||||
if 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:
|
||||
base_query += " AND v.day_of_week = ?"
|
||||
params.append(day_of_week)
|
||||
|
||||
# 🗺️ Mot-clé d'adresse (et exclusion unknown)
|
||||
if address_keyword:
|
||||
base_query += " AND v.address NOT LIKE '%unknown%' AND v.address LIKE ?"
|
||||
params.append(f"%{address_keyword}%")
|
||||
|
||||
# 📅 Plage de dates
|
||||
if start_date:
|
||||
base_query += " AND v.record_datetime >= ?"
|
||||
params.append(start_date)
|
||||
if end_date:
|
||||
base_query += " AND v.record_datetime <= ?"
|
||||
params.append(end_date)
|
||||
|
||||
# 💪 Niveau de difficulté
|
||||
if difficulty and difficulty != "Tous":
|
||||
base_query += " AND v.difficulty_level = ?"
|
||||
params.append(difficulty)
|
||||
|
||||
# 🔽 Tri
|
||||
base_query += " ORDER BY v.record_datetime DESC"
|
||||
|
||||
with get_conn() as conn:
|
||||
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()
|
||||
|
||||
55
app/models.py
Normal file
55
app/models.py
Normal file
@@ -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
|
||||
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)
|
||||
96
app/playlists/playlist_db.py
Normal file
96
app/playlists/playlist_db.py
Normal file
@@ -0,0 +1,96 @@
|
||||
# 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: int):
|
||||
with db.get_conn() as conn:
|
||||
conn.execute("DELETE FROM playlists WHERE id = ?", (playlist_id,))
|
||||
conn.execute("DELETE FROM video_playlists WHERE playlist_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()
|
||||
65
app/playlists/playlist_model.py
Normal file
65
app/playlists/playlist_model.py
Normal file
@@ -0,0 +1,65 @@
|
||||
# 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)
|
||||
created_at: Optional[str] = None
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
@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()
|
||||
93
app/playlists/playlist_page.py
Normal file
93
app/playlists/playlist_page.py
Normal file
@@ -0,0 +1,93 @@
|
||||
import streamlit as st
|
||||
from playlists import playlist_db
|
||||
from playlists.playlist_model import Playlist, RuleSet
|
||||
from playlists.playlist_controller import playlist_manual_editor, playlist_dynamic_editor
|
||||
from datetime import datetime
|
||||
from cache.video_summary import rebuild_video_summary
|
||||
|
||||
def main():
|
||||
st.title("🎵 Gestion des Playlists")
|
||||
if st.button("🔁 Recalculer le cache vidéo"):
|
||||
rebuild_video_summary()
|
||||
st.success("Cache mis à jour !")
|
||||
|
||||
# --- Barre latérale : recherche & filtres ---
|
||||
st.sidebar.header("🔎 Recherche de playlists")
|
||||
search_term = st.sidebar.text_input("Filtrer par nom ou description")
|
||||
date_filter = st.sidebar.date_input("Créées après", value=None)
|
||||
|
||||
playlists = playlist_db.load_all_playlists()
|
||||
|
||||
# Appliquer les filtres
|
||||
filtered = []
|
||||
for p in playlists:
|
||||
if search_term.lower() not in p.name.lower() and search_term.lower() not in (p.description or "").lower():
|
||||
continue
|
||||
if date_filter and datetime.fromisoformat(p.created_at) < datetime.combine(date_filter, datetime.min.time()):
|
||||
continue
|
||||
filtered.append(p)
|
||||
|
||||
# --- Sélection ou création ---
|
||||
names = ["(➕ Nouvelle playlist)"] + [p.name for p in filtered]
|
||||
selected_name = st.selectbox("Sélectionnez une playlist", names, key="playlist_select")
|
||||
|
||||
# --- Création ---
|
||||
if selected_name == "(➕ Nouvelle playlist)":
|
||||
st.subheader("Créer une 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, type=type_choice, rules=RuleSet())
|
||||
pl.save()
|
||||
st.session_state["playlist_select"] = pl.name # ✅ sélectionne automatiquement
|
||||
if hasattr(st, "rerun"):
|
||||
st.rerun()
|
||||
else:
|
||||
st.experimental_rerun()
|
||||
return
|
||||
|
||||
# --- Mode édition ---
|
||||
current = next((p for p in playlists if p.name == selected_name), None)
|
||||
if not current:
|
||||
st.warning("Aucune playlist sélectionnée.")
|
||||
return
|
||||
|
||||
# --- Barre d’actions ---
|
||||
st.subheader(f"🎞️ Playlist : {current.name}")
|
||||
new_name = st.text_input("Renommer", value=current.name)
|
||||
new_desc = st.text_area("Description", value=current.description or "")
|
||||
if st.button("💾 Sauvegarder les métadonnées"):
|
||||
current.name = new_name
|
||||
current.description = new_desc
|
||||
current.save()
|
||||
st.success("Mise à jour enregistrée ✅")
|
||||
if hasattr(st, "rerun"):
|
||||
st.rerun()
|
||||
else:
|
||||
st.experimental_rerun()
|
||||
|
||||
col1, col2, col3 = st.columns([1, 1, 2])
|
||||
with col1:
|
||||
if st.button("🗑️ Supprimer"):
|
||||
playlist_db.delete_playlist(current.id)
|
||||
st.success("Playlist supprimée ✅")
|
||||
if hasattr(st, "rerun"):
|
||||
st.rerun()
|
||||
else:
|
||||
st.experimental_rerun()
|
||||
with col2:
|
||||
if st.button("⏪ Retour à la liste"):
|
||||
st.session_state.pop("playlist_select", None)
|
||||
st.rerun()
|
||||
|
||||
st.divider()
|
||||
|
||||
# --- Éditeur selon le type ---
|
||||
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)
|
||||
@@ -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
|
||||
|
||||
16
app/views.py
16
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
|
||||
|
||||
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}")
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
address VARCHAR(255),
|
||||
difficulty_level VARCHAR(255) DEFAULT 'Tout niveau'
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
-- =========================================================
|
||||
-- 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);
|
||||
|
||||
136
model/videos_summary.sql
Normal file
136
model/videos_summary.sql
Normal file
@@ -0,0 +1,136 @@
|
||||
CREATE TABLE IF NOT EXISTS video_summary AS
|
||||
SELECT
|
||||
v.file_name,
|
||||
v.raw_file,
|
||||
v.mp4_file,
|
||||
v.record_datetime,
|
||||
v.day_of_week,
|
||||
v.difficulty_level,
|
||||
v.address,
|
||||
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;
|
||||
|
||||
DROP VIEW playlist_videos;
|
||||
CREATE VIEW playlist_videos AS
|
||||
WITH
|
||||
-- Sous-requête pour les playlists manuelles
|
||||
manual_playlist_videos AS (
|
||||
SELECT
|
||||
p.id AS playlist_id,
|
||||
p.name AS playlist_name,
|
||||
vp.video_file_name,
|
||||
v.*,
|
||||
'manual' AS playlist_type
|
||||
FROM playlists p
|
||||
JOIN video_playlists vp ON p.id = vp.playlist_id
|
||||
JOIN videos v ON vp.video_file_name = v.file_name
|
||||
WHERE p.type = 'manual'
|
||||
),
|
||||
|
||||
-- Sous-requête pour les playlists dynamiques
|
||||
dynamic_playlist_videos AS (
|
||||
SELECT DISTINCT
|
||||
p.id AS playlist_id,
|
||||
p.name AS playlist_name,
|
||||
v.file_name AS video_file_name,
|
||||
v.*,
|
||||
'dynamic' AS playlist_type
|
||||
FROM playlists p
|
||||
JOIN videos v
|
||||
WHERE p.type = 'dynamic'
|
||||
-- Inclure les vidéos qui ont les labels requis
|
||||
AND (
|
||||
json_array_length(json_extract(p.rules_json, '$.include_labels')) = 0
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM labels l
|
||||
JOIN video_labels vl ON l.id = vl.label_id
|
||||
WHERE vl.video_file_name = v.file_name
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM (
|
||||
SELECT 0 AS n UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4
|
||||
-- Ajoute plus de lignes si tes tableaux JSON ont plus de 5 éléments
|
||||
) n
|
||||
WHERE n.n < json_array_length(json_extract(p.rules_json, '$.include_labels'))
|
||||
AND l.name = json_extract(
|
||||
json_extract(p.rules_json, '$.include_labels'),
|
||||
'$[' || n.n || ']'
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
-- Exclure les vidéos qui ont les labels exclus
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM labels l
|
||||
JOIN video_labels vl ON l.id = vl.label_id
|
||||
WHERE vl.video_file_name = v.file_name
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM (
|
||||
SELECT 0 AS n UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4
|
||||
) n
|
||||
WHERE n.n < json_array_length(json_extract(p.rules_json, '$.exclude_labels'))
|
||||
AND l.name = json_extract(
|
||||
json_extract(p.rules_json, '$.exclude_labels'),
|
||||
'$[' || n.n || ']'
|
||||
)
|
||||
)
|
||||
)
|
||||
-- Inclure les vidéos qui sont dans les playlists requises
|
||||
AND (
|
||||
json_array_length(json_extract(p.rules_json, '$.include_playlists')) = 0
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM playlists pl
|
||||
JOIN video_playlists vp ON pl.id = vp.playlist_id
|
||||
WHERE vp.video_file_name = v.file_name
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM (
|
||||
SELECT 0 AS n UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4
|
||||
) n
|
||||
WHERE n.n < json_array_length(json_extract(p.rules_json, '$.include_playlists'))
|
||||
AND pl.name = json_extract(
|
||||
json_extract(p.rules_json, '$.include_playlists'),
|
||||
'$[' || n.n || ']'
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
-- Exclure les vidéos qui sont dans les playlists exclues
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM playlists pl
|
||||
JOIN video_playlists vp ON pl.id = vp.playlist_id
|
||||
WHERE vp.video_file_name = v.file_name
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM (
|
||||
SELECT 0 AS n UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4
|
||||
) n
|
||||
WHERE n.n < json_array_length(json_extract(p.rules_json, '$.exclude_playlists'))
|
||||
AND pl.name = json_extract(
|
||||
json_extract(p.rules_json, '$.exclude_playlists'),
|
||||
'$[' || n.n || ']'
|
||||
)
|
||||
)
|
||||
)
|
||||
-- Filtrer par date si nécessaire
|
||||
AND (
|
||||
json_extract(p.rules_json, '$.date_after') IS NULL
|
||||
OR v.record_datetime >= json_extract(p.rules_json, '$.date_after')
|
||||
)
|
||||
AND (
|
||||
json_extract(p.rules_json, '$.date_before') IS NULL
|
||||
OR v.record_datetime <= json_extract(p.rules_json, '$.date_before')
|
||||
)
|
||||
)
|
||||
|
||||
-- Union des deux types de playlists
|
||||
SELECT * FROM manual_playlist_videos
|
||||
UNION ALL
|
||||
SELECT * FROM dynamic_playlist_videos;
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,14 +1,42 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
SCRIPTS_DIR=$(dirname `realpath ${BASH_SOURCE[0]}`)
|
||||
|
||||
export DANCE_VIDEOS_DB="${HOME}/Documents/.DanceVideos/db.sqlite"
|
||||
export DOSSIER_PLAYLIST="$(dirname $DANCE_VIDEOS_DB)/playlists"
|
||||
SCRIPTS_DIR="$(dirname "$(realpath "${BASH_SOURCE[0]}")")"
|
||||
DANCE_VIDEOS_DB="${HOME}/Documents/.DanceVideos/db.sqlite"
|
||||
DOSSIER_PLAYLIST="$(dirname "$DANCE_VIDEOS_DB")/playlists"
|
||||
|
||||
rm -rf $DOSSIER_PLAYLIST
|
||||
# Nettoyer et recréer le dossier des playlists
|
||||
rm -rf "$DOSSIER_PLAYLIST"
|
||||
mkdir -p "$DOSSIER_PLAYLIST"
|
||||
|
||||
export PLAYLIST_ALL=$DOSSIER_PLAYLIST/all
|
||||
mkdir -p $PLAYLIST_ALL
|
||||
# Créer un dossier "all" avec toutes les vidéos
|
||||
# PLAYLIST_ALL="$DOSSIER_PLAYLIST/all"
|
||||
# mkdir -p "$PLAYLIST_ALL"
|
||||
# while IFS= read -r v; do
|
||||
# ln -s "$v" "$PLAYLIST_ALL/$(basename "$(dirname "$v")").mp4"
|
||||
# done < <(sqlite3 "$DANCE_VIDEOS_DB" "SELECT rotated_file FROM videos WHERE rotated_file IS NOT NULL;")
|
||||
|
||||
for v in $(sqlite3 $DANCE_VIDEOS_DB "select rotated_file from videos"); do
|
||||
ln -s $v $PLAYLIST_ALL/$(basename $(dirname $v)).mp4
|
||||
# Pour chaque playlist, créer un dossier et ajouter les liens symboliques
|
||||
sqlite3 -separator '|' "$DANCE_VIDEOS_DB" "
|
||||
SELECT playlist_id, playlist_name, video_file_name, rotated_file
|
||||
FROM playlist_videos
|
||||
WHERE rotated_file IS NOT NULL;
|
||||
" | while IFS='|' read -r playlist_id playlist_name video_file_name rotated_file; do
|
||||
# Sauter l'en-tête
|
||||
if [[ "$playlist_id" == "playlist_id" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Ignorer les lignes vides
|
||||
if [[ -z "$playlist_id" || -z "$rotated_file" ]]; then
|
||||
continue
|
||||
fi
|
||||
set -x
|
||||
# Créer le dossier de la playlist
|
||||
PLAYLIST_DIR="$DOSSIER_PLAYLIST/$playlist_name"
|
||||
mkdir -p "$PLAYLIST_DIR"
|
||||
|
||||
# Créer le lien symbolique
|
||||
ln -sf "$rotated_file" "$PLAYLIST_DIR/$(basename "$(dirname "$rotated_file")").mp4"
|
||||
set +x
|
||||
done
|
||||
Reference in New Issue
Block a user