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 streamlit as st
|
||||||
import os
|
from views.label_views import video_filter_sidebar, video_list_view
|
||||||
import db
|
from playlists import playlist_page
|
||||||
from views import show_video_thumbnail
|
|
||||||
from controllers import label_widget
|
|
||||||
|
|
||||||
|
# ==========================
|
||||||
|
# 🧭 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 Manager")
|
st.sidebar.title("💃 Menu principal")
|
||||||
|
|
||||||
# --- Barre latérale : hauteur max ---
|
page = st.sidebar.radio(
|
||||||
st.sidebar.header("⚙️ Apparence")
|
"Navigation",
|
||||||
max_height = st.sidebar.slider("Hauteur maximale (px)", 100, 800, 300, 50)
|
["Vidéos", "Playlists"],
|
||||||
st.markdown(
|
key="nav_main"
|
||||||
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
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Charger données ---
|
# ==========================
|
||||||
videos = db.load_videos()
|
# 🎬 PAGE : VIDÉOS
|
||||||
labels = db.load_labels()
|
# ==========================
|
||||||
|
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.")
|
# 🎵 PAGE : PLAYLISTS
|
||||||
else:
|
# ==========================
|
||||||
for _, video in videos.iterrows():
|
elif page == "Playlists":
|
||||||
col3 = show_video_thumbnail(video)
|
playlist_page.main()
|
||||||
preselected = db.load_video_labels(video["mp4_file_name"])
|
|
||||||
with col3:
|
|
||||||
label_widget(video["mp4_file_name"], preselected=preselected)
|
|
||||||
|
|||||||
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 streamlit as st
|
||||||
import db
|
import db
|
||||||
|
|
||||||
def label_widget(video_name, preselected=None):
|
def label_widget(video, preselected=None):
|
||||||
"""Widget multiselect labels avec création dynamique et réactivité."""
|
"""Widget multiselect pour labels, avec création dynamique."""
|
||||||
preselected = preselected or []
|
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()
|
labels = db.load_labels()
|
||||||
|
|
||||||
# --- Initialisation session_state ---
|
|
||||||
if key_multiselect not in st.session_state:
|
if key_multiselect not in st.session_state:
|
||||||
st.session_state[key_multiselect] = preselected
|
st.session_state[key_multiselect] = preselected
|
||||||
|
|
||||||
# --- Si "Autre…" dans sélection, afficher input ---
|
|
||||||
current_selected = st.session_state[key_multiselect]
|
current_selected = st.session_state[key_multiselect]
|
||||||
|
|
||||||
|
# Si "Autre…" dans sélection, afficher input
|
||||||
if "Autre…" in current_selected:
|
if "Autre…" in current_selected:
|
||||||
new_label = st.text_input("Entrer un label personnalisé", value="", key=key_input)
|
new_label = st.text_input("Entrer un label personnalisé", value="", key=key_input)
|
||||||
if new_label.strip():
|
if new_label.strip():
|
||||||
# Ajouter le nouveau label à la DB
|
|
||||||
db.create_labels([new_label.strip()])
|
db.create_labels([new_label.strip()])
|
||||||
# Mettre à jour options avant de créer le multiselect
|
|
||||||
labels = db.load_labels()
|
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()]
|
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
|
st.session_state[key_multiselect] = current_selected
|
||||||
|
|
||||||
# --- Multiselect ---
|
|
||||||
selected = st.multiselect(
|
selected = st.multiselect(
|
||||||
"Labels",
|
"Labels",
|
||||||
options=labels + ["Autre…"],
|
options=labels + ["Autre…"],
|
||||||
default=st.session_state[key_multiselect],
|
default=st.session_state[key_multiselect],
|
||||||
key=key_multiselect
|
key=key_multiselect,
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Sauvegarde ---
|
if st.button("💾 Sauvegarder labels", key=f"save_{video.id}"):
|
||||||
if st.button("💾 Sauvegarder labels", key=f"save_{video_name}"):
|
video.save_labels(selected)
|
||||||
db.save_video_labels(video_name, selected)
|
st.success(f"{len(selected)} label(s) enregistré(s) pour {video.mp4_file_name}")
|
||||||
st.success(f"{len(selected)} label(s) enregistré(s) pour {video_name}")
|
|
||||||
|
|
||||||
return selected
|
return selected
|
||||||
214
app/db.py
214
app/db.py
@@ -1,53 +1,37 @@
|
|||||||
|
# db.py (modifié)
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
DB_PATH = Path.home() / "Documents/.DanceVideos/db.sqlite"
|
DB_PATH = Path.home() / "Documents/.DanceVideos/db.sqlite"
|
||||||
|
|
||||||
# --- Connexion SQLite avec timeout ---
|
|
||||||
def get_conn():
|
def get_conn():
|
||||||
"""
|
conn = sqlite3.connect(DB_PATH, timeout=30, check_same_thread=False)
|
||||||
Retourne une connexion SQLite avec timeout de 30s.
|
conn.row_factory = sqlite3.Row
|
||||||
Utiliser un context manager pour garantir la fermeture.
|
conn.execute("PRAGMA foreign_keys = ON;")
|
||||||
"""
|
return conn
|
||||||
return sqlite3.connect(DB_PATH, timeout=30, check_same_thread=False)
|
|
||||||
|
|
||||||
# --- Vidéos ---
|
|
||||||
def load_videos():
|
def load_videos():
|
||||||
"""
|
|
||||||
Retourne toutes les vidéos dans un DataFrame pandas.
|
|
||||||
"""
|
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
return pd.read_sql_query("SELECT * FROM videos ORDER BY record_datetime DESC", conn)
|
return pd.read_sql_query("SELECT * FROM videos ORDER BY record_datetime DESC", conn)
|
||||||
|
|
||||||
# --- Labels ---
|
|
||||||
def load_labels():
|
def load_labels():
|
||||||
"""
|
|
||||||
Retourne tous les labels existants dans une liste.
|
|
||||||
"""
|
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
df = pd.read_sql_query("SELECT name FROM labels ORDER BY name", conn)
|
df = pd.read_sql_query("SELECT name FROM labels ORDER BY name", conn)
|
||||||
return df["name"].tolist()
|
return df["name"].tolist()
|
||||||
|
|
||||||
def create_labels(label_names):
|
def create_labels(label_names):
|
||||||
"""
|
|
||||||
Crée tous les labels de la liste s'ils n'existent pas.
|
|
||||||
"""
|
|
||||||
if not label_names:
|
if not label_names:
|
||||||
return
|
return
|
||||||
|
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
cursor = conn.cursor()
|
conn.executemany(
|
||||||
cursor.executemany(
|
|
||||||
"INSERT OR IGNORE INTO labels (name) VALUES (?)",
|
"INSERT OR IGNORE INTO labels (name) VALUES (?)",
|
||||||
[(name,) for name in label_names]
|
[(name,) for name in label_names]
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
def get_label_ids(label_names):
|
def get_label_ids(label_names):
|
||||||
"""
|
|
||||||
Retourne un dictionnaire {label_name: label_id}.
|
|
||||||
"""
|
|
||||||
label_ids = {}
|
label_ids = {}
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
@@ -58,11 +42,7 @@ def get_label_ids(label_names):
|
|||||||
label_ids[name] = row[0]
|
label_ids[name] = row[0]
|
||||||
return label_ids
|
return label_ids
|
||||||
|
|
||||||
# --- Vidéo / Labels ---
|
def load_video_labels(file_name):
|
||||||
def load_video_labels(video_file_name):
|
|
||||||
"""
|
|
||||||
Retourne la liste des labels associés à une vidéo.
|
|
||||||
"""
|
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
query = """
|
query = """
|
||||||
@@ -71,44 +51,162 @@ def load_video_labels(video_file_name):
|
|||||||
JOIN video_labels vl ON l.id = vl.label_id
|
JOIN video_labels vl ON l.id = vl.label_id
|
||||||
WHERE vl.video_file_name = ?
|
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):
|
def save_video_labels(file_name, label_names):
|
||||||
"""
|
|
||||||
Associe une vidéo à plusieurs labels.
|
|
||||||
Supprime d'abord les labels précédents pour cette vidéo.
|
|
||||||
"""
|
|
||||||
if label_names is None:
|
if label_names is None:
|
||||||
label_names = []
|
label_names = []
|
||||||
|
|
||||||
# 1️⃣ Créer tous les labels
|
|
||||||
create_labels(label_names)
|
create_labels(label_names)
|
||||||
|
|
||||||
# 2️⃣ Récupérer les IDs
|
|
||||||
label_ids = get_label_ids(label_names)
|
label_ids = get_label_ids(label_names)
|
||||||
|
|
||||||
# 3️⃣ Associer la vidéo aux labels dans une seule transaction
|
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("DELETE FROM video_labels WHERE video_file_name = ?", (file_name,))
|
||||||
# 🔹 Supprimer les anciennes associations
|
|
||||||
cursor.execute(
|
|
||||||
"DELETE FROM video_labels WHERE video_file_name = ?",
|
|
||||||
(video_file_name,)
|
|
||||||
)
|
|
||||||
|
|
||||||
# 🔹 Ajouter les nouvelles associations
|
|
||||||
for lid in label_ids.values():
|
for lid in label_ids.values():
|
||||||
cursor.execute("""
|
cursor.execute(
|
||||||
INSERT INTO video_labels (video_file_name, label_id)
|
"INSERT OR REPLACE INTO video_labels (video_file_name, label_id) VALUES (?, ?)",
|
||||||
VALUES (?, ?)
|
(file_name, lid),
|
||||||
""", (video_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()
|
conn.commit()
|
||||||
cursor = conn.cursor()
|
|
||||||
for lid in label_ids.values():
|
def get_unique_days():
|
||||||
cursor.execute("""
|
"""Retourne la liste unique des jours de la semaine enregistrés dans la base."""
|
||||||
INSERT OR REPLACE INTO video_labels (video_file_name, label_id)
|
with get_conn() as conn:
|
||||||
VALUES (?, ?)
|
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)
|
||||||
""", (video_file_name, lid))
|
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()
|
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
|
altair==5.5.0
|
||||||
|
annotated-types==0.7.0
|
||||||
attrs==25.4.0
|
attrs==25.4.0
|
||||||
blinker==1.9.0
|
blinker==1.9.0
|
||||||
cachetools==6.2.0
|
cachetools==6.2.0
|
||||||
@@ -21,6 +22,8 @@ pillow==11.3.0
|
|||||||
pluggy==1.6.0
|
pluggy==1.6.0
|
||||||
protobuf==6.32.1
|
protobuf==6.32.1
|
||||||
pyarrow==21.0.0
|
pyarrow==21.0.0
|
||||||
|
pydantic==2.12.0
|
||||||
|
pydantic-core==2.41.1
|
||||||
pydeck==0.9.1
|
pydeck==0.9.1
|
||||||
python-dateutil==2.9.0.post0
|
python-dateutil==2.9.0.post0
|
||||||
pytz==2025.2
|
pytz==2025.2
|
||||||
@@ -37,5 +40,6 @@ tenacity==9.1.2
|
|||||||
toml==0.10.2
|
toml==0.10.2
|
||||||
tornado==6.5.2
|
tornado==6.5.2
|
||||||
typing-extensions==4.15.0
|
typing-extensions==4.15.0
|
||||||
|
typing-inspection==0.4.2
|
||||||
tzdata==2025.2
|
tzdata==2025.2
|
||||||
urllib3==2.5.0
|
urllib3==2.5.0
|
||||||
|
|||||||
16
app/views.py
16
app/views.py
@@ -1,20 +1,18 @@
|
|||||||
|
# views.py
|
||||||
import streamlit as st
|
import streamlit as st
|
||||||
import os
|
import os
|
||||||
|
|
||||||
def show_video_thumbnail(video):
|
def show_video_thumbnail(video):
|
||||||
col1, col2, col3 = st.columns([1, 2, 1])
|
col1, col2, col3 = st.columns([1, 2, 1])
|
||||||
video_name = video["mp4_file_name"]
|
|
||||||
thumb = video["thumbnail_file"]
|
|
||||||
mp4 = video["mp4_file"]
|
|
||||||
|
|
||||||
with col1:
|
with col1:
|
||||||
if os.path.exists(thumb):
|
if video.thumbnail_file and os.path.exists(video.thumbnail_file):
|
||||||
st.image(thumb, width="content")
|
st.image(video.thumbnail_file, width="content")
|
||||||
st.caption(video_name)
|
st.caption(video.mp4_file_name)
|
||||||
|
|
||||||
with col2:
|
with col2:
|
||||||
if os.path.exists(mp4):
|
if video.mp4_file and os.path.exists(video.mp4_file):
|
||||||
if st.button(f"▶️ Lire 📅 {video.get('record_datetime', '')} — 🕒 {video.get('day_of_week', '')}", key=f"play_{video_name}"):
|
if st.button(f"▶️ Lire 📅 {video.record_datetime or ''} — 🕒 {video.day_of_week or ''} - 📍 {video.address or ''}", key=f"play_{video.id}"):
|
||||||
st.video(mp4)
|
st.video(video.mp4_file)
|
||||||
|
|
||||||
return col3
|
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 lat=${9:-0.000000}
|
||||||
local long=${10:-0.000000}
|
local long=${10:-0.000000}
|
||||||
local address=${11:-Unknown}
|
local address=${11:-Unknown}
|
||||||
|
address=$(sed "s|'| |g" <<< $address)
|
||||||
if [ -z "$raw_file" ] || [ -z "$mp4_file" ]; then
|
if [ -z "$raw_file" ] || [ -z "$mp4_file" ]; then
|
||||||
echo "Error: raw_file and mp4_file are required"
|
echo "Error: raw_file and mp4_file are required"
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -11,5 +11,7 @@ CREATE TABLE IF NOT EXISTS videos (
|
|||||||
record_time TIME GENERATED ALWAYS AS (TIME(record_datetime)) VIRTUAL,
|
record_time TIME GENERATED ALWAYS AS (TIME(record_datetime)) VIRTUAL,
|
||||||
lat DECIMAL(10,6),
|
lat DECIMAL(10,6),
|
||||||
long DECIMAL(11,7),
|
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 (
|
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);
|
||||||
|
|||||||
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
|
export -f mp4_dir
|
||||||
|
|
||||||
write_thumbnail() {
|
write_thumbnail() {
|
||||||
set -x
|
|
||||||
local raw="$1"
|
local raw="$1"
|
||||||
local thumbnail="$2"
|
local thumbnail="$2"
|
||||||
ffmpeg -ss 00:00:03 -i $raw -vframes 1 $thumbnail 2>/dev/null
|
ffmpeg -ss 00:00:03 -i $raw -vframes 1 $thumbnail 2>/dev/null
|
||||||
set +x
|
|
||||||
}
|
}
|
||||||
export -f write_thumbnail
|
export -f write_thumbnail
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,42 @@
|
|||||||
|
#!/bin/bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
SCRIPTS_DIR=$(dirname `realpath ${BASH_SOURCE[0]}`)
|
|
||||||
|
|
||||||
export DANCE_VIDEOS_DB="${HOME}/Documents/.DanceVideos/db.sqlite"
|
SCRIPTS_DIR="$(dirname "$(realpath "${BASH_SOURCE[0]}")")"
|
||||||
export DOSSIER_PLAYLIST="$(dirname $DANCE_VIDEOS_DB)/playlists"
|
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
|
# Créer un dossier "all" avec toutes les vidéos
|
||||||
mkdir -p $PLAYLIST_ALL
|
# 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
|
# Pour chaque playlist, créer un dossier et ajouter les liens symboliques
|
||||||
ln -s $v $PLAYLIST_ALL/$(basename $(dirname $v)).mp4
|
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
|
done
|
||||||
Reference in New Issue
Block a user