correct application of playlists rules

This commit is contained in:
Gabriel Radureau
2025-10-16 17:18:58 +02:00
parent d2e2028610
commit 78313ffbef
17 changed files with 1098 additions and 247 deletions

View File

@@ -3,6 +3,13 @@ import streamlit as st
from views.label_views import video_filter_sidebar, video_list_view
from playlists import playlist_page
import argparse
# --- Parse arguments (avant tout Streamlit UI) ---
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument("--unlabeled", action="store_true", help="Afficher uniquement les vidéos sans labels")
args, _ = parser.parse_known_args()
# ==========================
# 🧭 Configuration
# ==========================
@@ -20,7 +27,7 @@ page = st.sidebar.radio(
# ==========================
if page == "Vidéos":
st.title("🎬 Gestion et annotation des vidéos")
filters = video_filter_sidebar()
filters = video_filter_sidebar(unlabeled=args.unlabeled)
video_list_view(filters)
# ==========================

View File

@@ -1,26 +1,9 @@
# cache/video_summary.py
import db
from playlists import playlist_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.execute("DELETE FROM video_summary_materialized;")
conn.execute("CREATE TABLE IF NOT EXISTS video_summary_materialized AS SELECT * FROM video_summary;")
conn.commit()

View File

@@ -104,12 +104,19 @@ def search_videos(
end_date=None,
difficulty=None,
label_logic="OR",
include_playlists=None,
exclude_playlists=None,
logic="OR", # logique entre playlists incluses
**kwargs,
):
"""
Retourne une DataFrame filtrée selon les critères fournis.
label_logic: "OR" (au moins un label) ou "AND" (tous les labels).
label_logic: "OR" (au moins un label) ou "AND" (tous les labels)
logic: "OR" ou "AND" pour la combinaison de playlists incluses
"""
label_names = label_names or []
include_playlists = include_playlists or []
exclude_playlists = exclude_playlists or []
params = []
base_query = """
@@ -121,7 +128,6 @@ def search_videos(
# 🔖 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 (
@@ -135,7 +141,6 @@ def search_videos(
"""
params.extend(label_names)
else:
# OR : au moins une étiquette correspond
placeholders = ",".join("?" * len(label_names))
base_query += f"""
AND v.file_name IN (
@@ -147,6 +152,47 @@ def search_videos(
"""
params.extend(label_names)
# 🎵 Filtres par playlists incluses
if include_playlists:
placeholders = ",".join("?" * len(include_playlists))
if logic == "AND":
# Vidéos présentes dans TOUTES les playlists
base_query += f"""
AND v.file_name IN (
SELECT vp.video_file_name
FROM video_playlists vp
JOIN playlists p ON p.id = vp.playlist_id
WHERE p.id IN ({placeholders})
GROUP BY vp.video_file_name
HAVING COUNT(DISTINCT p.id) = {len(include_playlists)}
)
"""
params.extend(include_playlists)
else:
# Vidéos présentes dans AU MOINS une playlist
base_query += f"""
AND v.file_name IN (
SELECT vp.video_file_name
FROM video_playlists vp
JOIN playlists p ON p.id = vp.playlist_id
WHERE p.id IN ({placeholders})
)
"""
params.extend(include_playlists)
# ❌ Filtres par playlists exclues
if exclude_playlists:
placeholders = ",".join("?" * len(exclude_playlists))
base_query += f"""
AND v.file_name NOT IN (
SELECT vp.video_file_name
FROM video_playlists vp
JOIN playlists p ON p.id = vp.playlist_id
WHERE p.id IN ({placeholders})
)
"""
params.extend(exclude_playlists)
# 📆 Jour de la semaine
if day_of_week:
base_query += " AND v.day_of_week = ?"
@@ -177,6 +223,7 @@ def search_videos(
return pd.read_sql_query(base_query, conn, params=params)
def get_video_playlists(file_name):
with get_conn() as conn:
query = """
@@ -187,18 +234,41 @@ def get_video_playlists(file_name):
"""
return [row[0] for row in conn.execute(query, (file_name,))]
def get_videos_in_playlist(playlist_id):
def get_video_file_names_in_playlist(playlist_id):
"""Retourne un set de file_name pour une playlist donnée."""
with get_conn() as conn:
query = """
rows = conn.execute(
# "SELECT video_file_name FROM video_playlists WHERE playlist_id = ?",
"SELECT video_file_name FROM playlist_videos WHERE playlist_id = ?",
(playlist_id,)
).fetchall()
names = []
for r in rows:
if hasattr(r, "keys"):
names.append(r["video_file_name"])
else:
names.append(r[0])
return set(names)
def get_videos_in_playlist(playlist_id):
"""Retourne une liste d'objets Video complets pour une playlist donnée."""
import pandas as pd
from models import Video
with get_conn() as conn:
df = pd.read_sql_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()
ORDER BY vp.position ASC
""", conn, params=(playlist_id,))
return [Video(**row) for _, row in df.iterrows()]
def add_video_to_playlist(playlist_id, file_name):
print(dict(playlist_id=playlist_id, video_file_name=file_name),flush=True,)
with get_conn() as conn:
conn.execute("""
INSERT OR IGNORE INTO video_playlists (video_file_name, playlist_id, position)

View File

@@ -4,65 +4,135 @@ 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 views.video_views import show_video_row
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}")
# Filtres standard pour explorer les vidéos
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]
# Précharge les vidéos déjà incluses dans la playlist
playlist_video_ids = db.get_video_file_names_in_playlist(playlist.id)
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}")
# Récupère les vidéos filtrées
df_videos = db.search_videos(
label_names=filters["selected_labels"],
day_of_week=filters["day_filter"],
difficulty=filters["difficulty_filter"],
**filters
)
if df_videos.empty:
st.info("Aucune vidéo trouvée avec ces filtres.")
return
videos = [Video(**row) for _, row in df_videos.iterrows()]
st.write(f"🎬 {len(videos)} vidéo(s) disponibles.")
# Affiche les vidéos avec boutons dajout/retrait à droite
summary_map = summary_map = get_video_summary_cached()
for video in videos[:50]: # limite de sécurité
summary = summary_map.get(video.file_name, {"labels": [], "playlists": []})
preselected = summary["labels"]
playlists = summary["playlists"]
show_video_row(
video,
preselected_labels=preselected,
editable_labels=False,
editable_difficulty=False,
playlist=playlist,
playlist_video_ids=playlist_video_ids,
video_playlists=playlists
)
if st.button("📦 Charger plus"):
st.session_state.video_page += 1
st.rerun()
@st.cache_data(ttl=30)
def get_video_summary_cached():
return playlist_db.load_video_summary_map()
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)
all_playlists = playlist_db.load_all_playlists()
name_to_id = {p.name: p.id for p in all_playlists}
id_to_name = {p.id: p.name for p in all_playlists}
default_include = [id_to_name.get(pid) for pid in rules.include_playlists if pid in id_to_name]
default_exclude = [id_to_name.get(pid) for pid in rules.exclude_playlists if pid in id_to_name]
selected_includes = st.multiselect("Inclure playlists", id_to_name.values(), default=default_include)
selected_excludes = st.multiselect("Exclure playlists", id_to_name.values(), default=default_exclude)
rules.include_playlists = [name_to_id[name] for name in selected_includes]
rules.exclude_playlists = [name_to_id[name] for name in selected_excludes]
col1, col2 = st.columns(2)
with col1:
use_delta = st.checkbox("⏱️ Utiliser un delta de jours", value=bool(rules.date_delta_days))
if use_delta:
rules.date_delta_days = st.number_input(
"Nombre de jours depuis aujourdhui (négatif pour passé)",
value=rules.date_delta_days or -15
)
rules.date_after = None
rules.date_before = None
else:
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.date_delta_days = None
with col2:
rules.logic = st.radio("Logique de combinaison", ["AND", "OR"], index=0 if rules.logic == "AND" else 1)
rules.label_logic = st.radio("Logique de combinaison entre labels", ["AND", "OR"], index=0 if rules.label_logic == "AND" else 1)
# --- Enregistrement ---
if st.button("💾 Enregistrer les règles"):
playlist.rules = rules
playlist.save()
st.success("Règles mises à jour ✅")
st.rerun()
(st.rerun if hasattr(st, "rerun") else st.experimental_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)
st.write(f"🎬 {len(videos)} vidéo(s) trouvée(s).")
playlist_video_ids = db.get_video_file_names_in_playlist(playlist.id)
summary_map = summary_map = get_video_summary_cached()
for v in videos[:50]:
summary = summary_map.get(v.file_name, {"labels": [], "playlists": []})
preselected = summary["labels"]
playlists = summary["playlists"]
show_video_row(
v,
preselected_labels=preselected,
editable_labels=False,
editable_difficulty=False,
playlist=playlist,
playlist_video_ids=playlist_video_ids,
video_playlists=playlists
)
if st.button("📦 Charger plus"):
st.session_state.video_page += 1
st.rerun()

View File

@@ -1,7 +1,9 @@
# playlists/playlist_db.py
import db
import json
import pandas as pd
from playlists.playlist_model import Playlist, RuleSet
from playlists.sql_builder import build_sql_from_rules
def load_all_playlists():
"""Retourne une liste de Playlist (Pydantic) — tolérant aux rules_json nuls / vides."""
@@ -28,12 +30,16 @@ def load_all_playlists():
row_dict = dict(zip(cols, row))
try:
created_at = str(row["created_at"]) if row["created_at"] is not None else None
updated_at = str(row["updated_at"]) if row["updated_at"] is not None else None
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")
id=row["id"],
name=row["name"],
description=row["description"],
type=row["type"],
rules=row["rules_json"],
created_at=created_at,
updated_at=updated_at
)
playlists.append(pl)
except Exception as e:
@@ -47,50 +53,31 @@ def delete_playlist(playlist_id: int):
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:
def get_videos_for_playlist(playlist):
"""Retourne les vidéos correspondant aux règles d'une playlist dynamique."""
if playlist.type == "manual":
q = """
with db.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(q, (playlist.id,)).fetchall()
return conn.execute(query, (playlist.id,)).fetchall()
else:
rules = playlist.rules
clauses = []
params = []
sql, params = build_sql_from_rules(playlist.rules)
with db.get_conn() as conn:
return conn.execute(sql, params).fetchall()
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()
def load_video_summary_map():
"""Retourne un dict {file_name: {'labels': [...], 'playlists': [...]}} depuis la vue video_summary."""
with db.get_conn() as conn:
df = pd.read_sql_query("SELECT file_name, labels, playlists FROM video_summary", conn)
summary = {}
for _, row in df.iterrows():
summary[row["file_name"]] = {
"labels": row["labels"].split(",") if row["labels"] else [],
"playlists": row["playlists"].split(",") if row["playlists"] else [],
}
return summary

View File

@@ -1,25 +1,67 @@
# playlists/playlist_model.py
from pydantic import BaseModel, Field, validator
from pydantic import BaseModel, Field, validator, field_validator
from typing import List, Optional, Literal
from datetime import date, datetime
import json
import db
from typing import Optional, List, Literal
from pydantic import BaseModel, Field
import json
class RuleSet(BaseModel):
include_labels: List[str] = []
exclude_labels: List[str] = []
include_playlists: List[str] = []
exclude_playlists: List[str] = []
include_playlists: List[int] = []
exclude_playlists: List[int] = []
date_after: Optional[str] = None
date_before: Optional[str] = None
date_delta_days: Optional[int] = None
difficulty: Optional[str] = None
day_of_week: Optional[str] = None
address_keyword: Optional[str] = None
label_logic: Literal["AND", "OR"] = "AND"
logic: Literal["AND", "OR"] = "AND"
# --- Normalisation des dates (entrée) ---
@field_validator("date_after", "date_before", mode="before")
def normalize_date(cls, v):
"""Convertit automatiquement date/datetime en str ISO avant stockage."""
if isinstance(v, (date, datetime)):
return v.isoformat()
return v
def model_dump(self, *args, **kwargs):
"""Force la sortie JSON-safe (dates converties en str)."""
data = super().model_dump(*args, **kwargs)
for key in ["date_after", "date_before"]:
v = data.get(key)
if isinstance(v, (date, datetime)):
data[key] = v.isoformat()
return data
@validator("date_after", "date_before", pre=True, always=True)
def convert_date(cls, v):
"""Convertit automatiquement les objets date/datetime en ISO string."""
if isinstance(v, (date, datetime)):
return v.isoformat()
return v
def dict(self, *args, **kwargs):
"""Sassure que toutes les dates sont des chaînes sérialisables."""
data = super().dict(*args, **kwargs)
for key in ["date_after", "date_before"]:
v = data.get(key)
if isinstance(v, (date, datetime)):
data[key] = v.isoformat()
return data
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):

View File

@@ -21,14 +21,31 @@ def main():
# Appliquer les filtres
filtered = []
for p in playlists:
# filtrage texte
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()):
# filtrage date de création
if date_filter:
created = p.created_at
if isinstance(created, datetime):
created_dt = created
elif isinstance(created, (str, bytes)):
try:
created_dt = datetime.fromisoformat(created)
except ValueError:
continue # ignore invalid date
else:
continue
if created_dt < 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]
if "new_playlist_name" in st.session_state:
st.session_state["playlist_select"] = st.session_state.pop("new_playlist_name")
selected_name = st.selectbox("Sélectionnez une playlist", names, key="playlist_select")
# --- Création ---
@@ -43,11 +60,8 @@ def main():
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.session_state["new_playlist_name"] = pl.name # on stocke temporairement
st.rerun()
else:
st.experimental_rerun()
return
# --- Mode édition ---

View File

@@ -0,0 +1,109 @@
# playlists/sql_builder.py
from typing import Tuple, List
from playlists.playlist_model import RuleSet
from datetime import datetime, timedelta
def build_sql_from_rules(rules: RuleSet) -> Tuple[str, List]:
"""
Construit une requête SQL complète (SELECT * FROM videos ...)
en fonction d'un RuleSet.
Retourne (sql_query, params).
"""
where = ["1=1"]
params = []
# --- DATES (delta prioritaire) ---
if rules.date_delta_days is not None:
try:
delta_days = int(rules.date_delta_days)
date_after = (datetime.now() + timedelta(days=delta_days)).strftime("%Y-%m-%d")
where.append("record_datetime >= ?")
params.append(date_after)
except (ValueError, TypeError) as e:
print(f"⚠️ [SQL Builder] Delta invalide ({rules.date_delta_days!r}) : {e}")
else:
if rules.date_after:
where.append("record_datetime >= ?")
params.append(rules.date_after)
if rules.date_before:
where.append("record_datetime <= ?")
params.append(rules.date_before)
# --- LABELS ---
if rules.include_labels:
placeholders = ",".join("?" * len(rules.include_labels))
if rules.label_logic == "AND":
where.append(f"""
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(rules.include_labels)}
)
""")
else:
where.append(f"""
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(rules.include_labels)
if rules.exclude_labels:
placeholders = ",".join("?" * len(rules.exclude_labels))
where.append(f"""
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.extend(rules.exclude_labels)
# --- PLAYLISTS ---
if rules.include_playlists:
placeholders = ",".join("?" * len(rules.include_playlists))
where.append(f"""
file_name IN (
SELECT vp.video_file_name
FROM video_playlists vp
JOIN playlists p ON p.id = vp.playlist_id
WHERE p.name IN ({placeholders})
)
""")
params.extend(rules.include_playlists)
if rules.exclude_playlists:
placeholders = ",".join("?" * len(rules.exclude_playlists))
where.append(f"""
file_name NOT IN (
SELECT vp.video_file_name
FROM video_playlists vp
JOIN playlists p ON p.id = vp.playlist_id
WHERE p.name IN ({placeholders})
)
""")
params.extend(rules.exclude_playlists)
# --- DIFFICULTÉ ---
if rules.difficulty and rules.difficulty != "Tous":
where.append("difficulty_level = ?")
params.append(rules.difficulty)
# --- JOUR ---
if rules.day_of_week:
where.append("day_of_week = ?")
params.append(rules.day_of_week)
# --- ADRESSE ---
if rules.address_keyword:
where.append("address NOT LIKE '%unknown%' AND address LIKE ?")
params.append(f"%{rules.address_keyword}%")
sql = f"SELECT * FROM videos WHERE {' AND '.join(where)} ORDER BY record_datetime DESC"
return sql, params

View File

@@ -4,7 +4,7 @@ from models import Video
from views.video_views import show_video_row
from controllers.label_controller import label_widget
def video_filter_sidebar():
def video_filter_sidebar(unlabeled=False):
"""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)
@@ -25,7 +25,7 @@ def video_filter_sidebar():
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")
show_unlabeled_only = st.sidebar.checkbox("Afficher uniquement les vidéos sans labels", value=unlabeled)
return dict(
max_height=max_height,
@@ -39,7 +39,7 @@ def video_filter_sidebar():
show_unlabeled_only=show_unlabeled_only
)
def video_list_view(filters: dict, editable_labels=True, editable_difficulty=True):
def video_list_view(filters: dict, editable_labels=True, editable_difficulty=True, playlist=None):
"""Affiche les vidéos selon les filtres fournis."""
st.markdown(f"""
<style>
@@ -93,7 +93,7 @@ def video_list_view(filters: dict, editable_labels=True, editable_difficulty=Tru
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)
show_video_row(video, preselected, editable_labels, editable_difficulty, playlist)
st.markdown("</div>", unsafe_allow_html=True)
# lazy loading bouton

View File

@@ -1,32 +1,129 @@
# video_views.py
import os
import streamlit as st
from controllers.label_controller import label_widget
import db
from models import Video
from controllers.label_controller import label_widget
def show_video_row(video, preselected_labels, editable_labels=True, editable_difficulty=True):
def show_video_row(
video: Video,
preselected_labels,
editable_labels=True,
editable_difficulty=True,
playlist=None,
playlist_video_ids=None,
video_playlists=None,
):
"""
Affiche une ligne Streamlit pour une vidéo :
- miniature + lecture conditionnelle
- métadonnées et labels
- édition des labels et difficulté
- (optionnel) ajout/retrait d'une playlist
"""
# --- Vérifie si la vidéo est dans la playlist ---
in_playlist = False
if playlist and playlist_video_ids is not None:
in_playlist = video.file_name in playlist_video_ids
elif playlist:
playlist_video_ids = db.get_video_file_names_in_playlist(playlist.id)
in_playlist = video.file_name in playlist_video_ids
# --- Layout : miniature / infos / édition / action ---
if playlist:
col1, col2, col3, col4 = st.columns([1, 3, 2, 0.8])
else:
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)
col4 = None
# --- Colonne 1 : miniature + boutons Play/Stop ---
play_key = f"playing_{video.file_name}"
st.session_state.setdefault(play_key, False)
with col1:
if getattr(video, "thumbnail_file", None) and os.path.exists(video.thumbnail_file):
st.image(video.thumbnail_file)
else:
st.caption("Pas de miniature")
st.caption(video.file_name or video.mp4_file_name)
c1, c2 = st.columns(2)
with c1:
if st.button("▶️ Lire", key=f"play_{video.file_name}"):
st.session_state[play_key] = True
(st.rerun if hasattr(st, "rerun") else st.experimental_rerun)()
with c2:
if st.button("⏸️ Stop", key=f"stop_{video.file_name}"):
st.session_state[play_key] = False
(st.rerun if hasattr(st, "rerun") else st.experimental_rerun)()
if st.session_state[play_key]:
mp4_path = getattr(video, "mp4_file", None)
if mp4_path and os.path.exists(mp4_path):
st.video(mp4_path)
else:
st.warning("Fichier vidéo introuvable.")
# --- Colonne 2 : métadonnées ---
with col2:
st.markdown(f"**📅 {video.record_datetime or ''}** — {video.day_of_week or ''}")
st.write(f"📍 {video.address or 'Inconnue'}")
st.write(f"💪 Difficulté : {video.difficulty_display}")
st.text(f"🏷️ Labels: {', '.join(preselected_labels) or 'Aucun'}")
if video_playlists:
st.text(f"🎵 Playlists: {', '.join(video_playlists)}")
else:
playlists = db.get_video_playlists(video.file_name)
if playlists:
st.text(f"🎵 Playlists: {', '.join(playlists)}")
# --- Colonne 3 : édition ---
with col3:
if editable_labels:
label_widget(video, preselected=preselected_labels)
if editable_difficulty:
levels = ["Tout niveau", "Débutant", "Intermédiaire", "Avancé", "Star"]
try:
idx = levels.index(video.difficulty_display)
except ValueError:
idx = 0
new_level = st.selectbox(
"🎚 Niveau",
levels,
index=levels.index(video.difficulty_display),
index=idx,
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}")
# --- Colonne 4 : action playlist ---
if col4 and playlist:
with col4:
key_toggle = f"toggle_{playlist.id}_{video.file_name}"
prev_state_key = f"{key_toggle}_prev"
prev_state = st.session_state.get(prev_state_key, in_playlist)
toggled = st.toggle(
"🎵",
value=in_playlist,
key=key_toggle,
help="Inclure dans la playlist"
)
if toggled != prev_state:
if toggled:
db.add_video_to_playlist(file_name=video.file_name, playlist_id=playlist.id)
st.toast(f"{video.file_name} ajouté à {playlist.name}")
else:
db.remove_video_from_playlist(file_name=video.file_name, playlist_id=playlist.id)
st.toast(f"🗑️ {video.file_name} retiré de {playlist.name}")
st.session_state[prev_state_key] = toggled
(st.rerun if hasattr(st, "rerun") else st.experimental_rerun)()
else:
st.session_state[prev_state_key] = toggled

View File

@@ -22,6 +22,6 @@ register_video() {
fi
local mp4_file_name=$(basename $(dirname $mp4_file))
sqlite3 $DANCE_VIDEOS_DB "INSERT OR REPLACE INTO videos (file_name, raw_file, duration, mp4_file, mp4_file_name, rotated_file, thumbnail_file, record_datetime, day_of_week, lat, long, address) VALUES('$file_name','$raw_file', '$duration', '$mp4_file', '$mp4_file_name', '$rotated_file', '$thumbnail_file', $record_datetime, '$day_of_week', $lat, $long, '$address')"
sqlite3 $DANCE_VIDEOS_DB "PRAGMA busy_timeout = 10000; INSERT OR REPLACE INTO videos (file_name, raw_file, duration, mp4_file, mp4_file_name, rotated_file, thumbnail_file, record_datetime, day_of_week, lat, long, address) VALUES('$file_name','$raw_file', '$duration', '$mp4_file', '$mp4_file_name', '$rotated_file', '$thumbnail_file', $record_datetime, '$day_of_week', $lat, $long, '$address');"
}
export -f register_video

View File

@@ -1,4 +1,229 @@
CREATE TABLE IF NOT EXISTS video_summary AS
-- ============================================================================
-- VIEW : playlist_videos
-- ============================================================================
DROP VIEW IF EXISTS playlist_videos;
CREATE VIEW playlist_videos AS
WITH
-- 1⃣ Playlists manuelles
manual_playlists_videos AS (
SELECT
p.id AS playlist_id,
p.name AS playlist_name,
'manual' AS playlist_type,
vp.video_file_name
FROM playlists p
JOIN video_playlists vp ON p.id = vp.playlist_id
WHERE p.type = 'manual'
),
-- 2⃣ Dynamiques sans include/exclude
dynamic_playlist_videos_base AS (
SELECT DISTINCT
p.id AS playlist_id,
p.name AS playlist_name,
'dynamic' AS playlist_type,
v.file_name AS video_file_name
FROM playlists p
JOIN videos v
WHERE p.type = 'dynamic'
-- Labels inclus (AND/OR)
AND (
json_array_length(json_extract(p.rules_json, '$.include_labels')) = 0
OR (
json_extract(p.rules_json, '$.label_logic') = 'OR'
AND EXISTS (
SELECT 1 FROM json_each(json_extract(p.rules_json, '$.include_labels')) jl
JOIN labels l ON l.name = jl.value
JOIN video_labels vl ON vl.label_id = l.id AND vl.video_file_name = v.file_name
)
)
OR (
json_extract(p.rules_json, '$.label_logic') = 'AND'
AND NOT EXISTS (
SELECT 1
FROM json_each(json_extract(p.rules_json, '$.include_labels')) jl
WHERE jl.value NOT IN (
SELECT l2.name
FROM labels l2
JOIN video_labels vl2 ON l2.id = vl2.label_id
WHERE vl2.video_file_name = v.file_name
)
)
)
)
-- Labels exclus
AND NOT EXISTS (
SELECT 1 FROM json_each(json_extract(p.rules_json, '$.exclude_labels')) je
JOIN labels lx ON lx.name = je.value
JOIN video_labels vlx ON vlx.label_id = lx.id AND vlx.video_file_name = v.file_name
)
-- Dates
AND (
json_extract(p.rules_json, '$.date_after') IS NULL
OR v.record_datetime >= json_extract(p.rules_json, '$.date_after')
OR (
json_extract(p.rules_json, '$.date_delta_days') IS NOT NULL
AND v.record_datetime >= date('now', (json_extract(p.rules_json, '$.date_delta_days') || ' days'))
)
)
AND (
json_extract(p.rules_json, '$.date_before') IS NULL
OR v.record_datetime <= json_extract(p.rules_json, '$.date_before')
)
-- Difficulty / day / address
AND (
json_extract(p.rules_json, '$.difficulty') IS NULL
OR v.difficulty_level = json_extract(p.rules_json, '$.difficulty')
)
AND (
json_extract(p.rules_json, '$.day_of_week') IS NULL
OR v.day_of_week = json_extract(p.rules_json, '$.day_of_week')
)
AND (
json_extract(p.rules_json, '$.address_keyword') IS NULL
OR (
v.address NOT LIKE '%unknown%'
AND v.address LIKE '%' || json_extract(p.rules_json, '$.address_keyword') || '%'
)
)
),
-- 3⃣ Inclusions directes (parent → enfant)
playlist_direct_includes AS (
SELECT
p.id AS parent_playlist_id,
CAST(inc.value AS INTEGER) AS child_playlist_id
FROM playlists p
JOIN json_each(json_extract(p.rules_json, '$.include_playlists')) inc
ON json_type(inc.value) IN ('integer', 'text')
WHERE json_array_length(json_extract(p.rules_json, '$.include_playlists')) > 0
),
-- 4⃣ Récursion : parent → descendant (transitif)
playlist_includes_recursive AS (
WITH RECURSIVE rec(parent_playlist_id, child_playlist_id) AS (
SELECT parent_playlist_id, child_playlist_id FROM playlist_direct_includes
UNION ALL
SELECT d.parent_playlist_id, di.child_playlist_id
FROM playlist_direct_includes d
JOIN rec di ON di.parent_playlist_id = d.child_playlist_id
)
SELECT DISTINCT parent_playlist_id AS parent_id, child_playlist_id AS child_id FROM rec
),
-- 5⃣ Exclusions directes et récursives
playlist_direct_excludes AS (
SELECT
p.id AS parent_playlist_id,
CAST(exc.value AS INTEGER) AS child_playlist_id
FROM playlists p
JOIN json_each(json_extract(p.rules_json, '$.exclude_playlists')) exc
ON json_type(exc.value) IN ('integer', 'text')
WHERE json_array_length(json_extract(p.rules_json, '$.exclude_playlists')) > 0
),
playlist_excludes_recursive AS (
WITH RECURSIVE rec_ex(parent_playlist_id, child_playlist_id) AS (
SELECT parent_playlist_id, child_playlist_id FROM playlist_direct_excludes
UNION ALL
SELECT d.parent_playlist_id, di.child_playlist_id
FROM playlist_direct_excludes d
JOIN rec_ex di ON di.parent_playlist_id = d.child_playlist_id
)
SELECT DISTINCT parent_playlist_id AS parent_id, child_playlist_id AS child_id FROM rec_ex
),
-- 6⃣ Vidéos issues des playlists incluses
playlist_included_videos AS (
SELECT pir.parent_id AS parent_playlist_id, mpv.video_file_name
FROM playlist_includes_recursive pir
JOIN manual_playlists_videos mpv ON mpv.playlist_id = pir.child_id
UNION
SELECT pir.parent_id AS parent_playlist_id, dpb.video_file_name
FROM playlist_includes_recursive pir
JOIN dynamic_playlist_videos_base dpb ON dpb.playlist_id = pir.child_id
),
-- 7⃣ Inclusion logique OR
playlist_includes_union AS (
SELECT DISTINCT db.playlist_id, db.playlist_name, db.playlist_type, db.video_file_name
FROM dynamic_playlist_videos_base db
JOIN playlists p ON p.id = db.playlist_id
WHERE json_extract(p.rules_json, '$.logic') = 'OR'
UNION
SELECT DISTINCT iv.parent_playlist_id AS playlist_id,
(SELECT name FROM playlists WHERE id = iv.parent_playlist_id) AS playlist_name,
'dynamic' AS playlist_type,
iv.video_file_name
FROM playlist_included_videos iv
JOIN playlists p ON p.id = iv.parent_playlist_id
WHERE json_extract(p.rules_json, '$.logic') = 'OR'
),
-- 8⃣ Inclusion logique AND (+ fallback si 0 include)
playlist_includes_intersection AS (
SELECT DISTINCT db.playlist_id, db.playlist_name, db.playlist_type, db.video_file_name
FROM dynamic_playlist_videos_base db
JOIN playlists p ON p.id = db.playlist_id
WHERE json_extract(p.rules_json, '$.logic') = 'AND'
AND json_array_length(json_extract(p.rules_json, '$.include_playlists')) > 0
AND db.video_file_name IN (
SELECT iv.video_file_name
FROM playlist_included_videos iv
WHERE iv.parent_playlist_id = db.playlist_id
)
UNION ALL
SELECT DISTINCT db.playlist_id, db.playlist_name, db.playlist_type, db.video_file_name
FROM dynamic_playlist_videos_base db
JOIN playlists p ON p.id = db.playlist_id
WHERE json_extract(p.rules_json, '$.logic') = 'AND'
AND (
json_array_length(json_extract(p.rules_json, '$.include_playlists')) IS NULL
OR json_array_length(json_extract(p.rules_json, '$.include_playlists')) = 0
)
),
-- 9⃣ Fusion des deux logiques dinclusion
playlist_after_includes AS (
SELECT * FROM playlist_includes_union
UNION ALL
SELECT * FROM playlist_includes_intersection
),
-- 🔟 Vidéos exclues (via exclude_playlists récursif)
playlist_excluded_videos AS (
SELECT per.parent_id AS parent_playlist_id, mpv.video_file_name
FROM playlist_excludes_recursive per
JOIN manual_playlists_videos mpv ON mpv.playlist_id = per.child_id
UNION
SELECT per.parent_id AS parent_playlist_id, dpb.video_file_name
FROM playlist_excludes_recursive per
JOIN dynamic_playlist_videos_base dpb ON dpb.playlist_id = per.child_id
),
-- 1⃣1⃣ Application des exclusions
playlist_after_excludes AS (
SELECT pai.playlist_id, pai.playlist_name, pai.playlist_type, pai.video_file_name
FROM playlist_after_includes pai
LEFT JOIN playlist_excluded_videos pev
ON pev.parent_playlist_id = pai.playlist_id AND pev.video_file_name = pai.video_file_name
WHERE pev.parent_playlist_id IS NULL
)
-- 1⃣2⃣ Résultat final : union manuelles + dynamiques
SELECT playlist_id, playlist_name, playlist_type, video_file_name
FROM manual_playlists_videos
UNION ALL
SELECT playlist_id, playlist_name, playlist_type, video_file_name
FROM playlist_after_excludes;
-- ============================================================================
-- TABLE AGRÉGÉE : video_summary
-- ============================================================================
DROP VIEW IF EXISTS video_summary;
CREATE VIEW video_summary AS
SELECT
v.file_name,
v.raw_file,
@@ -8,129 +233,12 @@ SELECT
v.difficulty_level,
v.address,
GROUP_CONCAT(DISTINCT l.name) AS labels,
GROUP_CONCAT(DISTINCT p.name) AS playlists
GROUP_CONCAT(DISTINCT pv.playlist_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
LEFT JOIN playlist_videos pv ON pv.video_file_name = v.file_name
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;
CREATE TABLE IF NOT EXISTS video_summary_materialized AS
SELECT * FROM video_summary;

236
model/views.sql Normal file
View File

@@ -0,0 +1,236 @@
-- 1) Vidéos manuelles (source unique, simple)
DROP VIEW IF EXISTS manual_playlists_videos;
CREATE VIEW manual_playlists_videos AS
SELECT
p.id AS playlist_id,
p.name AS playlist_name,
'manual' AS playlist_type,
vp.video_file_name
FROM playlists p
JOIN video_playlists vp ON p.id = vp.playlist_id
WHERE p.type = 'manual';
-- 2) Vidéos dynamiques : tout sauf include_playlists / exclude_playlists
-- (ceci applique labels, date_after/date_before/date_delta_days, difficulty, day_of_week, address_keyword)
DROP VIEW IF EXISTS dynamic_playlist_videos_base;
CREATE VIEW dynamic_playlist_videos_base AS
SELECT DISTINCT
p.id AS playlist_id,
p.name AS playlist_name,
'dynamic' AS playlist_type,
v.file_name AS video_file_name
FROM playlists p
JOIN videos v
WHERE p.type = 'dynamic'
/* --- include_labels (AND/OR) --- */
AND (
json_array_length(json_extract(p.rules_json, '$.include_labels')) = 0
OR (
json_extract(p.rules_json, '$.label_logic') = 'OR'
AND EXISTS (
SELECT 1 FROM json_each(json_extract(p.rules_json, '$.include_labels')) jl
JOIN labels l ON l.name = jl.value
JOIN video_labels vl ON vl.label_id = l.id AND vl.video_file_name = v.file_name
)
)
OR (
json_extract(p.rules_json, '$.label_logic') = 'AND'
AND NOT EXISTS (
-- il existe un label requis qui n'est pas présent pour la video
SELECT 1
FROM json_each(json_extract(p.rules_json, '$.include_labels')) jl
WHERE jl.value NOT IN (
SELECT l2.name
FROM labels l2
JOIN video_labels vl2 ON l2.id = vl2.label_id
WHERE vl2.video_file_name = v.file_name
)
)
)
)
/* --- exclude_labels --- */
AND NOT EXISTS (
SELECT 1 FROM json_each(json_extract(p.rules_json, '$.exclude_labels')) je
JOIN labels lx ON lx.name = je.value
JOIN video_labels vlx ON vlx.label_id = lx.id AND vlx.video_file_name = v.file_name
)
/* --- date_after / date_delta_days / date_before --- */
AND (
json_extract(p.rules_json, '$.date_after') IS NULL
OR v.record_datetime >= json_extract(p.rules_json, '$.date_after')
OR (
json_extract(p.rules_json, '$.date_delta_days') IS NOT NULL
AND v.record_datetime >= date('now', (json_extract(p.rules_json, '$.date_delta_days') || ' days'))
)
)
AND (
json_extract(p.rules_json, '$.date_before') IS NULL
OR v.record_datetime <= json_extract(p.rules_json, '$.date_before')
)
/* --- difficulty, day_of_week, address_keyword --- */
AND (
json_extract(p.rules_json, '$.difficulty') IS NULL
OR v.difficulty_level = json_extract(p.rules_json, '$.difficulty')
)
AND (
json_extract(p.rules_json, '$.day_of_week') IS NULL
OR v.day_of_week = json_extract(p.rules_json, '$.day_of_week')
)
AND (
json_extract(p.rules_json, '$.address_keyword') IS NULL
OR (
v.address NOT LIKE '%unknown%'
AND v.address LIKE '%' || json_extract(p.rules_json, '$.address_keyword') || '%'
)
);
-- 3) Mapping direct parent -> child playlist ids (extrait JSON include_playlists)
DROP VIEW IF EXISTS playlist_direct_includes;
CREATE VIEW playlist_direct_includes AS
SELECT
p.id AS parent_playlist_id,
CAST(inc.value AS INTEGER) AS child_playlist_id
FROM playlists p
JOIN json_each(json_extract(p.rules_json, '$.include_playlists')) inc
ON json_type(inc.value) IN ('integer', 'text')
WHERE json_array_length(json_extract(p.rules_json, '$.include_playlists')) > 0;
-- 4) Fermeture transitive des inclusions : parent -> descendant child
DROP VIEW IF EXISTS playlist_includes_recursive;
CREATE VIEW playlist_includes_recursive AS
WITH RECURSIVE rec(parent_playlist_id, child_playlist_id) AS (
-- base : les inclusions directes
SELECT parent_playlist_id, child_playlist_id FROM playlist_direct_includes
UNION ALL
-- récursion : si A inclut B et B inclut C, alors A inclut C
SELECT d.parent_playlist_id, di.child_playlist_id
FROM playlist_direct_includes d
JOIN rec di ON di.parent_playlist_id = d.child_playlist_id
)
SELECT DISTINCT parent_playlist_id AS parent_id, child_playlist_id AS child_id FROM rec;
-- 5) De même pour exclusions directes et récursives
DROP VIEW IF EXISTS playlist_direct_excludes;
CREATE VIEW playlist_direct_excludes AS
SELECT
p.id AS parent_playlist_id,
CAST(exc.value AS INTEGER) AS child_playlist_id
FROM playlists p
JOIN json_each(json_extract(p.rules_json, '$.exclude_playlists')) exc
ON json_type(exc.value) IN ('integer', 'text')
WHERE json_array_length(json_extract(p.rules_json, '$.exclude_playlists')) > 0;
DROP VIEW IF EXISTS playlist_excludes_recursive;
CREATE VIEW playlist_excludes_recursive AS
WITH RECURSIVE rec_ex(parent_playlist_id, child_playlist_id) AS (
SELECT parent_playlist_id, child_playlist_id FROM playlist_direct_excludes
UNION ALL
SELECT d.parent_playlist_id, di.child_playlist_id
FROM playlist_direct_excludes d
JOIN rec_ex di ON di.parent_playlist_id = d.child_playlist_id
)
SELECT DISTINCT parent_playlist_id AS parent_id, child_playlist_id AS child_id FROM rec_ex;
-- 6) vidéos apportées par les playlists incluses (manuelles + dynamic_base)
-- For each parent, collect videos that belong to any included child playlist (direct or transitive).
-- union logique (rules.logic = 'OR')
DROP VIEW IF EXISTS playlist_includes_union;
CREATE VIEW playlist_includes_union AS
-- union de base + vidéos incluses
SELECT DISTINCT db.playlist_id, db.playlist_name, db.playlist_type, db.video_file_name
FROM dynamic_playlist_videos_base db
WHERE
json_extract((SELECT rules_json FROM playlists WHERE id = db.playlist_id), '$.logic') = 'OR'
UNION
SELECT DISTINCT iv.parent_playlist_id AS playlist_id,
(SELECT name FROM playlists WHERE id = iv.parent_playlist_id) AS playlist_name,
'dynamic' AS playlist_type,
iv.video_file_name
FROM playlist_included_videos iv
JOIN playlists p ON p.id = iv.parent_playlist_id
WHERE json_extract(p.rules_json, '$.logic') = 'OR';
-- 7) intersection logique (rules.logic = 'AND')
DROP VIEW IF EXISTS playlist_includes_intersection;
CREATE VIEW playlist_includes_intersection AS
-- Cas 1⃣ : AND + playlists incluses => intersection stricte
SELECT DISTINCT db.playlist_id, db.playlist_name, db.playlist_type, db.video_file_name
FROM dynamic_playlist_videos_base db
WHERE json_extract((SELECT rules_json FROM playlists WHERE id = db.playlist_id), '$.logic') = 'AND'
AND json_array_length(json_extract((SELECT rules_json FROM playlists WHERE id = db.playlist_id), '$.include_playlists')) > 0
AND db.video_file_name IN (
SELECT iv.video_file_name
FROM playlist_included_videos iv
WHERE iv.parent_playlist_id = db.playlist_id
)
UNION ALL
-- Cas 2⃣ : AND + aucune playlist incluse => garder la base telle quelle
SELECT DISTINCT db.playlist_id, db.playlist_name, db.playlist_type, db.video_file_name
FROM dynamic_playlist_videos_base db
WHERE json_extract((SELECT rules_json FROM playlists WHERE id = db.playlist_id), '$.logic') = 'AND'
AND (
json_array_length(json_extract((SELECT rules_json FROM playlists WHERE id = db.playlist_id), '$.include_playlists')) IS NULL
OR json_array_length(json_extract((SELECT rules_json FROM playlists WHERE id = db.playlist_id), '$.include_playlists')) = 0
);
-- 8) regroupement des deux logiques
DROP VIEW IF EXISTS playlist_after_includes;
CREATE VIEW playlist_after_includes AS
SELECT * FROM playlist_includes_union
UNION ALL
SELECT * FROM playlist_includes_intersection;
-- 9) exclusions
DROP VIEW IF EXISTS playlist_excluded_videos;
CREATE VIEW playlist_excluded_videos AS
SELECT per.parent_id AS parent_playlist_id, mpv.video_file_name
FROM playlist_excludes_recursive per
JOIN manual_playlists_videos mpv ON mpv.playlist_id = per.child_id
UNION
SELECT per.parent_id AS parent_playlist_id, dpb.video_file_name
FROM playlist_excludes_recursive per
JOIN dynamic_playlist_videos_base dpb ON dpb.playlist_id = per.child_id;
-- 10) Appliquer exclusions
DROP VIEW IF EXISTS playlist_after_excludes;
CREATE VIEW playlist_after_excludes AS
SELECT pai.playlist_id, pai.playlist_name, pai.playlist_type, pai.video_file_name
FROM playlist_after_includes pai
LEFT JOIN playlist_excluded_videos pev
ON pev.parent_playlist_id = pai.playlist_id AND pev.video_file_name = pai.video_file_name
WHERE pev.parent_playlist_id IS NULL;
-- 11) résultat final « flat »
DROP VIEW IF EXISTS playlist_videos_flat;
CREATE VIEW playlist_videos_flat AS
SELECT playlist_id, playlist_name, playlist_type, video_file_name
FROM manual_playlists_videos
UNION ALL
SELECT playlist_id, playlist_name, playlist_type, video_file_name
FROM playlist_after_excludes;

View File

@@ -0,0 +1,50 @@
# Fonction pour ajouter une ligne à un fichier avec verrouillage (bloquant + timeout + gestion des signaux)
append_with_lock() {
local line="$1"
local file="$2"
local lock_dir="/tmp/dancevideos_moved_files.lock"
local timeout=30 # Timeout en secondes (ajustable)
local start_time=$(date +%s)
local got_lock=false
# Fonction de nettoyage du verrou (appelée en cas de signal ou de sortie)
cleanup_lock() {
if [ -d "$lock_dir" ]; then
rmdir "$lock_dir" 2>/dev/null
fi
}
# Piège les signaux d'interruption pour libérer le verrou
trap cleanup_lock INT TERM EXIT
# Attendre le verrou avec timeout
while true; do
# Vérifier le timeout
local current_time=$(date +%s)
if [ $((current_time - start_time)) -ge $timeout ]; then
echo "Timeout atteint pour l'obtention du verrou sur $file" >&2
trap - INT TERM EXIT # Désactive le piège pour éviter un nettoyage double
return 1
fi
# Tentative de verrouillage
if mkdir "$lock_dir" 2>/dev/null; then
got_lock=true
break
else
sleep 0.1 # Attendre avant de réessayer
fi
done
# Écriture dans le fichier (si verrou obtenu)
if $got_lock; then
set -x
echo "$line" >> "$file"
echo "$line" >> "$file" # write them twice because of bug where a line is skipped
set +x
# Libérer le verrou
rmdir "$lock_dir"
trap - INT TERM EXIT # Désactive le piège après utilisation
fi
}
export -f append_with_lock

View File

@@ -2,11 +2,49 @@
set -euo pipefail
IFS=$'\n\t'
# === Gestion des arguments ===
PROCESS_ALL=false
PRINT_ERR=false
# Fonction pour afficher l'aide
usage() {
echo "Usage: $0 [--all|-a]"
echo "Options:"
echo " --all, -a Traiter tous les fichiers (ignore la liste de fichiers)"
echo " --help, -h Afficher cette aide"
exit 1
}
# Traitement des arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--all|-a)
PROCESS_ALL=true
shift
;;
--help|-h)
usage
;;
--print-err)
PRINT_ERR=true
shift
;;
*)
echo "Option inconnue : $1" >&2
usage
;;
esac
done
SCRIPTS_DIR=$(dirname `realpath ${BASH_SOURCE[0]}`)
# === CONFIGURATION ===
export DOSSIER_SOURCE="${HOME}/Downloads"
export DOSSIER_DESTINATION_RAW="${HOME}/Documents/.DanceVideos/raw"
export TEMP_FILE="/tmp/dancevideos_moved_files.txt"
# Initialiser le fichier temporaire
> "$TEMP_FILE"
sanitize_name() {
local name="$1"
@@ -17,6 +55,8 @@ sanitize_name() {
}
export -f sanitize_name
source $SCRIPTS_DIR/append_with_lock.sh
# Fonction pour vérifier et déplacer un fichier
check_and_move() {
local fichier="$1"
@@ -28,6 +68,9 @@ check_and_move() {
if [ "$taille1" -eq "$taille2" ] && [ "$taille2" -gt 0 ]; then
echo "Déplacement de $(basename "$fichier")"
rsync -av --remove-source-files "$fichier" "$DOSSIER_DESTINATION_RAW/$(sanitize_name "$(basename "$fichier")")"
if ! append_with_lock "$DOSSIER_DESTINATION_RAW/$(sanitize_name "$(basename "$fichier")")" "$TEMP_FILE"; then
echo "Échec de l'écriture dans $TEMP_FILE (timeout)" >&2
fi
else
echo "Fichier $(basename "$fichier") encore en cours de réception."
fi
@@ -90,6 +133,10 @@ process_raw_file() {
local ct weekday duration lat lon address
IFS="|" read -r ct weekday duration lat lon address <<<"$(process_video "$raw")"
# récupérer les infos eventuelles dans la BDD
# si fichier videos existent toujours ingorer, sinon écraser
# TODO
local dir=$(mp4_dir $raw $weekday "$address")
local thumbnail=${dir}/thumbnail.jpg
@@ -125,7 +172,8 @@ export -f whatsapp_video
convert_raws() {
# ##_##.mov
if $PROCESS_ALL; then
find "$DOSSIER_DESTINATION_RAW" -type f \
-name "*.mov" \
-print0 | parallel -0 -j 4 iphone_video
@@ -137,6 +185,39 @@ convert_raws() {
find "$DOSSIER_DESTINATION_RAW" -type f \
-name '*[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]*.mp4' \
-print0 | parallel -0 -j 4 whatsapp_video
else
if [ -f "$TEMP_FILE" ]; then
while IFS= read -r file || [ -n "$file" ]; do
file=$(echo "$file" | tr -d '\r' | xargs)
# Ignorer les lignes vides
if [ -z "$file" ]; then
continue
fi
echo "Dealing with $file"
local filename=$(basename "$file")
if [[ "$filename" == *.mov ]]; then
iphone_video "$file"
elif [[ "$filename" == screenrecording*.mp4 ]]; then
screen_video "$file"
elif [[ "$filename" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.mp4$ ]]; then
whatsapp_video "$file"
else
echo "$file didn't match any pattern"
fi
done < "$TEMP_FILE"
fi
fi
}
$PRINT_ERR || exec 2> /tmp/DanceVideos.stderr
convert_raws
set -x
if [ 0 -lt $(wc -l /tmp/dancevideos_moved_files.txt | awk '{print $1}') ]; then
STREAMLIT_PID=$(ps aux | grep streamlit | grep -v 'grep' | awk '{print $2}'); [ ! -z "$STREAMLIT_PID" ] && kill $STREAMLIT_PID
(cd $SCRIPTS_DIR/..; source .venv/bin/activate; streamlit run app/app.py -- --unlabeled)
fi

View File

@@ -2,25 +2,21 @@ get_rotation_filter() {
local f="$1"
local rotation
rotation=$(exiftool -Rotation -n "$f" | awk '{print $3}')
# echo $rotation $f >> "rotations.txt"
case "$rotation" in
# 90) echo "transpose=1" ;;
# 270) echo "transpose=2" ;;
# 180) echo "hflip,vflip" ;;
# *) echo "" ;;
*) echo "transpose=1" ;;
esac
}
export -f get_rotation_filter
reencode_with_rotation() {
set -x
local src="$1"
local dst="$2"
local filter
filter="$(get_rotation_filter "$src")"
if [ -n "$filter" ]; then
echo " Correction dorientation (rotation=${filter})"
#echo " Correction dorientation (rotation=${filter})"
if ffmpeg -encoders 2>/dev/null | grep -q 'h264_videotoolbox'; then
ffmpeg -nostdin -i "$src" -vf "$filter" \
-c:v h264_videotoolbox -b:v 5M -c:a aac -map_metadata -1 -y "$dst"
@@ -42,5 +38,6 @@ reencode_with_rotation() {
fi
fi
fi
set +x
}
export -f reencode_with_rotation

View File

@@ -18,9 +18,9 @@ mkdir -p "$DOSSIER_PLAYLIST"
# 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;
SELECT pv.playlist_id, pv.playlist_name, pv.video_file_name, v.rotated_file
FROM playlist_videos pv JOIN videos v ON pv.video_file_name=v.file_name
WHERE v.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