infinite scroll
This commit is contained in:
113
app/app.py
113
app/app.py
@@ -1,38 +1,115 @@
|
||||
import streamlit as st
|
||||
import os
|
||||
import db
|
||||
from models import Video
|
||||
from views import show_video_thumbnail
|
||||
from controllers import label_widget
|
||||
from datetime import date
|
||||
|
||||
st.set_page_config(page_title="Dance Video Manager", layout="wide")
|
||||
st.title("💃 Dance Video Manager")
|
||||
st.title("💃 Dance Video Explorer")
|
||||
|
||||
# --- Barre latérale : hauteur max ---
|
||||
st.sidebar.header("⚙️ Apparence")
|
||||
# --- Barre latérale : filtres dynamiques ---
|
||||
st.sidebar.header("⚙️ Filtres et affichage")
|
||||
max_height = st.sidebar.slider("Hauteur maximale (px)", 100, 800, 300, 50)
|
||||
st.markdown(
|
||||
f"""
|
||||
st.markdown(f"""
|
||||
<style>
|
||||
img, video {{
|
||||
max-height: {max_height}px !important;
|
||||
object-fit: contain;
|
||||
transition: all 0.3s ease-in-out;
|
||||
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
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
all_labels = db.load_labels()
|
||||
unique_days = db.get_unique_days()
|
||||
unique_difficulties = db.get_unique_difficulties()
|
||||
unique_addresses = db.get_unique_addresses()
|
||||
|
||||
selected_labels = st.sidebar.multiselect("Filtrer par labels", all_labels)
|
||||
|
||||
day_filter = st.sidebar.selectbox(
|
||||
"Jour de la semaine",
|
||||
["Tous"] + unique_days
|
||||
)
|
||||
|
||||
# --- Charger données ---
|
||||
videos = db.load_videos()
|
||||
labels = db.load_labels()
|
||||
difficulty_filter = st.sidebar.selectbox(
|
||||
"Niveau de difficulté",
|
||||
["Tous"] + unique_difficulties
|
||||
)
|
||||
|
||||
if videos.empty:
|
||||
st.warning("Aucune vidéo trouvée dans la base.")
|
||||
else:
|
||||
for _, video in videos.iterrows():
|
||||
address_keyword = st.sidebar.selectbox(
|
||||
"Adresse (mot-clé)",
|
||||
[""] + unique_addresses
|
||||
)
|
||||
|
||||
start_date = st.sidebar.date_input("Date de début", value=None)
|
||||
end_date = st.sidebar.date_input("Date de fin", value=None)
|
||||
show_unlabeled_only = st.sidebar.checkbox("Afficher uniquement les vidéos sans labels")
|
||||
|
||||
# --- Filtrage SQL ---
|
||||
day_value = None if day_filter == "Tous" else day_filter
|
||||
df_videos = db.search_videos(
|
||||
label_names=selected_labels,
|
||||
day_of_week=day_value,
|
||||
address_keyword=address_keyword if address_keyword else None,
|
||||
start_date=start_date.isoformat() if start_date else None,
|
||||
end_date=end_date.isoformat() if end_date else None,
|
||||
difficulty=difficulty_filter
|
||||
)
|
||||
|
||||
if df_videos.empty:
|
||||
st.warning("Aucune vidéo trouvée avec ces critères.")
|
||||
st.stop()
|
||||
|
||||
# --- Chargement des labels et affichage lazy ---
|
||||
video_labels_map = {row["file_name"]: db.load_video_labels(row["file_name"]) for _, row in df_videos.iterrows()}
|
||||
if show_unlabeled_only:
|
||||
df_videos = df_videos[df_videos["file_name"].apply(lambda fn: not video_labels_map.get(fn))]
|
||||
|
||||
videos = [Video(**row) for _, row in df_videos.iterrows()]
|
||||
|
||||
# --- Lazy loading ---
|
||||
page_size = 20
|
||||
if "video_page" not in st.session_state:
|
||||
st.session_state.video_page = 1
|
||||
|
||||
start = 0
|
||||
end = st.session_state.video_page * page_size
|
||||
visible_videos = videos[start:end]
|
||||
|
||||
for video in visible_videos:
|
||||
preselected = video_labels_map.get(video.file_name, [])
|
||||
css_class = "unlabeled" if not preselected else ""
|
||||
with st.container():
|
||||
st.markdown(f"<div class='{css_class}'>", unsafe_allow_html=True)
|
||||
col3 = show_video_thumbnail(video)
|
||||
preselected = db.load_video_labels(video["mp4_file_name"])
|
||||
with col3:
|
||||
label_widget(video["mp4_file_name"], preselected=preselected)
|
||||
label_widget(video, preselected=preselected)
|
||||
# --- Sélecteur de difficulté ---
|
||||
new_level = st.selectbox(
|
||||
"🎚 Niveau de difficulté",
|
||||
["Tout niveau", "Débutant", "Intermédiaire", "Avancé", "Star"],
|
||||
index=["Tout niveau", "Débutant", "Intermédiaire", "Avancé", "Star"].index(video.difficulty_display),
|
||||
key=f"diff_{video.file_name}"
|
||||
)
|
||||
if new_level != video.difficulty_display:
|
||||
db.update_video_difficulty(video.file_name, new_level)
|
||||
st.success(f"Niveau mis à jour pour {video.file_name}")
|
||||
st.markdown("</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.")
|
||||
|
||||
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()
|
||||
@@ -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
|
||||
|
||||
156
app/db.py
156
app/db.py
@@ -1,53 +1,36 @@
|
||||
# db.py (modifié)
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
import pandas as pd
|
||||
|
||||
DB_PATH = Path.home() / "Documents/.DanceVideos/db.sqlite"
|
||||
|
||||
# --- Connexion SQLite avec timeout ---
|
||||
def get_conn():
|
||||
"""
|
||||
Retourne une connexion SQLite avec timeout de 30s.
|
||||
Utiliser un context manager pour garantir la fermeture.
|
||||
"""
|
||||
return sqlite3.connect(DB_PATH, timeout=30, check_same_thread=False)
|
||||
conn = sqlite3.connect(DB_PATH, timeout=30, check_same_thread=False)
|
||||
conn.execute("PRAGMA foreign_keys = ON;")
|
||||
return conn
|
||||
|
||||
|
||||
# --- Vidéos ---
|
||||
def load_videos():
|
||||
"""
|
||||
Retourne toutes les vidéos dans un DataFrame pandas.
|
||||
"""
|
||||
with get_conn() as conn:
|
||||
return pd.read_sql_query("SELECT * FROM videos ORDER BY record_datetime DESC", conn)
|
||||
|
||||
# --- Labels ---
|
||||
def load_labels():
|
||||
"""
|
||||
Retourne tous les labels existants dans une liste.
|
||||
"""
|
||||
with get_conn() as conn:
|
||||
df = pd.read_sql_query("SELECT name FROM labels ORDER BY name", conn)
|
||||
return df["name"].tolist()
|
||||
|
||||
def create_labels(label_names):
|
||||
"""
|
||||
Crée tous les labels de la liste s'ils n'existent pas.
|
||||
"""
|
||||
if not label_names:
|
||||
return
|
||||
|
||||
with get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.executemany(
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO labels (name) VALUES (?)",
|
||||
[(name,) for name in label_names]
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def get_label_ids(label_names):
|
||||
"""
|
||||
Retourne un dictionnaire {label_name: label_id}.
|
||||
"""
|
||||
label_ids = {}
|
||||
with get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
@@ -58,11 +41,7 @@ def get_label_ids(label_names):
|
||||
label_ids[name] = row[0]
|
||||
return label_ids
|
||||
|
||||
# --- Vidéo / Labels ---
|
||||
def load_video_labels(video_file_name):
|
||||
"""
|
||||
Retourne la liste des labels associés à une vidéo.
|
||||
"""
|
||||
def load_video_labels(file_name):
|
||||
with get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
query = """
|
||||
@@ -71,44 +50,103 @@ def load_video_labels(video_file_name):
|
||||
JOIN video_labels vl ON l.id = vl.label_id
|
||||
WHERE vl.video_file_name = ?
|
||||
"""
|
||||
return [row[0] for row in cursor.execute(query, (video_file_name,))]
|
||||
return [row[0] for row in cursor.execute(query, (file_name,))]
|
||||
|
||||
def save_video_labels(video_file_name, label_names):
|
||||
"""
|
||||
Associe une vidéo à plusieurs labels.
|
||||
Supprime d'abord les labels précédents pour cette vidéo.
|
||||
"""
|
||||
def save_video_labels(file_name, label_names):
|
||||
if label_names is None:
|
||||
label_names = []
|
||||
|
||||
# 1️⃣ Créer tous les labels
|
||||
create_labels(label_names)
|
||||
|
||||
# 2️⃣ Récupérer les IDs
|
||||
label_ids = get_label_ids(label_names)
|
||||
|
||||
# 3️⃣ Associer la vidéo aux labels dans une seule transaction
|
||||
with get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 🔹 Supprimer les anciennes associations
|
||||
cursor.execute(
|
||||
"DELETE FROM video_labels WHERE video_file_name = ?",
|
||||
(video_file_name,)
|
||||
)
|
||||
|
||||
# 🔹 Ajouter les nouvelles associations
|
||||
cursor.execute("DELETE FROM video_labels WHERE video_file_name = ?", (file_name,))
|
||||
for lid in label_ids.values():
|
||||
cursor.execute("""
|
||||
INSERT INTO video_labels (video_file_name, label_id)
|
||||
VALUES (?, ?)
|
||||
""", (video_file_name, lid))
|
||||
cursor.execute(
|
||||
"INSERT OR REPLACE INTO video_labels (video_file_name, label_id) VALUES (?, ?)",
|
||||
(file_name, lid),
|
||||
)
|
||||
cursor.execute("""
|
||||
DELETE FROM labels
|
||||
WHERE id NOT IN (SELECT DISTINCT label_id FROM video_labels)
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
def update_video_difficulty(file_name, level):
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE videos SET difficulty_level = ? WHERE file_name = ?", (level, file_name))
|
||||
conn.commit()
|
||||
cursor = conn.cursor()
|
||||
for lid in label_ids.values():
|
||||
cursor.execute("""
|
||||
INSERT OR REPLACE INTO video_labels (video_file_name, label_id)
|
||||
VALUES (?, ?)
|
||||
""", (video_file_name, lid))
|
||||
conn.commit()
|
||||
|
||||
def get_unique_days():
|
||||
"""Retourne la liste unique des jours de la semaine enregistrés dans la base."""
|
||||
with get_conn() as conn:
|
||||
df = pd.read_sql_query("SELECT DISTINCT day_of_week FROM videos WHERE day_of_week IS NOT NULL ORDER BY day_of_week", conn)
|
||||
return [d for d in df["day_of_week"].dropna().tolist() if d.strip()]
|
||||
|
||||
def get_unique_difficulties():
|
||||
"""Retourne la liste des niveaux de difficulté existants."""
|
||||
with get_conn() as conn:
|
||||
df = pd.read_sql_query("SELECT DISTINCT difficulty_level FROM videos WHERE difficulty_level IS NOT NULL ORDER BY difficulty_level", conn)
|
||||
return [d for d in df["difficulty_level"].dropna().tolist() if d.strip()]
|
||||
|
||||
def get_unique_addresses():
|
||||
"""Retourne les adresses connues (exclut 'unknown')."""
|
||||
with get_conn() as conn:
|
||||
df = pd.read_sql_query("SELECT DISTINCT address FROM videos WHERE address NOT LIKE '%unknown%' ORDER BY address", conn)
|
||||
return [a for a in df["address"].dropna().tolist() if a.strip()]
|
||||
|
||||
|
||||
def search_videos(
|
||||
label_names=None,
|
||||
day_of_week=None,
|
||||
address_keyword=None,
|
||||
start_date=None,
|
||||
end_date=None,
|
||||
difficulty=None
|
||||
):
|
||||
"""
|
||||
Retourne une DataFrame filtrée selon les critères fournis.
|
||||
"""
|
||||
label_names = label_names or []
|
||||
|
||||
query = """
|
||||
SELECT DISTINCT v.*
|
||||
FROM videos v
|
||||
LEFT JOIN video_labels vl ON vl.video_file_name = v.file_name
|
||||
LEFT JOIN labels l ON l.id = vl.label_id
|
||||
WHERE 1=1
|
||||
"""
|
||||
params = []
|
||||
|
||||
# 🔖 Filtres par label (tous doivent être présents)
|
||||
if label_names:
|
||||
placeholders = ",".join("?" for _ in label_names)
|
||||
query += f" AND l.name IN ({placeholders})"
|
||||
params += label_names
|
||||
|
||||
# 📆 Jour de la semaine
|
||||
if day_of_week:
|
||||
query += " AND v.day_of_week = ?"
|
||||
params.append(day_of_week)
|
||||
|
||||
# 🗺️ Mot-clé d'adresse (et exclusion unknown)
|
||||
if address_keyword:
|
||||
query += " AND v.address NOT LIKE '%unknown%' AND v.address LIKE ?"
|
||||
params.append(f"%{address_keyword}%")
|
||||
|
||||
# 📅 Filtre par date
|
||||
if start_date:
|
||||
query += " AND v.record_datetime >= ?"
|
||||
params.append(start_date)
|
||||
if end_date:
|
||||
query += " AND v.record_datetime <= ?"
|
||||
params.append(end_date)
|
||||
|
||||
# 💪 Niveau de difficulté
|
||||
if difficulty and difficulty != "Tous":
|
||||
query += " AND v.difficulty_level = ?"
|
||||
params.append(difficulty)
|
||||
|
||||
query += " ORDER BY v.record_datetime DESC"
|
||||
|
||||
with get_conn() as conn:
|
||||
return pd.read_sql_query(query, conn, params=params)
|
||||
|
||||
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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user