Compare commits

..

6 Commits

Author SHA1 Message Date
CI Bot
5f0311ffa5 model : ajoute beat_atoms (V0.10 atomes — voir video_analysis/adr/0018)
Table pour les atomes de mouvement par-beat (1 enregistrement par beat × danseur).
Modèle hybride à 3 niveaux : continu (foot_height/speed/stability) + soft
distribution (state_dist_json sommant à 1 sur planted/touch/lifted/transitioning)
+ argmax (state + confidence).

Implémenté dans pipeline/atoms.py côté video_analysis (commit 654320a).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 01:24:05 +02:00
Gabriel Radureau
73b2ccb917 added protection agains deletion 2025-10-21 19:14:17 +02:00
Gabriel Radureau
30e4a35362 set label logic default to 'AND' 2025-10-21 19:06:25 +02:00
Gabriel Radureau
225a1911c8 suppression de vidéos 2025-10-21 18:52:39 +02:00
Gabriel Radureau
fbe3c01de7 Give alias and random suffix for whatsapp videos without created timestamp 2025-10-21 17:35:01 +02:00
Gabriel Radureau
78313ffbef correct application of playlists rules 2025-10-16 17:18:58 +02:00
22 changed files with 1576 additions and 255 deletions

View File

@@ -82,4 +82,9 @@ flowchart TB
L_raw_pgrm1_0@{ animation: slow }
L_pgrm1_videos_0@{ animation: slow }
L_videos_playlists_0@{ animation: fast }
```
```
# TODO
- [ ] Pouvoir supprimer une vidéo / regénérer une vidéo

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

@@ -11,6 +11,10 @@ def get_conn():
conn.execute("PRAGMA foreign_keys = ON;")
return conn
def delete_video(file_name):
with get_conn() as conn:
conn.execute("DELETE FROM videos WHERE file_name = ?", (file_name,))
conn.commit()
def load_videos():
with get_conn() as conn:
@@ -77,6 +81,11 @@ def update_video_difficulty(file_name, level):
conn.execute("UPDATE videos SET difficulty_level = ? WHERE file_name = ?", (level, file_name))
conn.commit()
def update_video_alias(file_name, alias):
with get_conn() as conn:
conn.execute("UPDATE videos SET alias = ? WHERE file_name = ?", (alias, file_name))
conn.commit()
def get_unique_days():
"""Retourne la liste unique des jours de la semaine enregistrés dans la base."""
with get_conn() as conn:
@@ -104,12 +113,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 +137,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 +150,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 +161,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 +232,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 +243,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)
@@ -209,4 +288,4 @@ def add_video_to_playlist(playlist_id, file_name):
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()

View File

@@ -18,6 +18,7 @@ class Video(BaseModel):
long: Optional[float] = None
address: Optional[str] = None
difficulty_level: Optional[str] = Field("Tout niveau", description="Niveau de difficulté")
alias: Optional[str] = None
# --- propriétés pratiques ---
@property
@@ -28,7 +29,7 @@ class Video(BaseModel):
@property
def title(self) -> str:
"""Nom court à afficher."""
return self.mp4_file_name or self.file_name
return self.alias or self.mp4_file_name or self.file_name
@property
def difficulty_display(self) -> str:

View File

@@ -4,28 +4,58 @@ 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}")
st.rerun()
# 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,
editable_alias=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."""
@@ -33,36 +63,78 @@ def playlist_dynamic_editor(playlist: Playlist):
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:
rules.date_after = st.date_input("📅 Après le", value=rules.date_after or None)
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)
rules.date_before = st.date_input("📅 Avant le", value=rules.date_before or None)
rules.date_delta_days = 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)
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,
editable_alias=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:
if playlist.type == "manual":
q = """
def get_videos_for_playlist(playlist):
"""Retourne les vidéos correspondant aux règles d'une playlist dynamique."""
if playlist.type == "manual":
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()
else:
rules = playlist.rules
clauses = []
params = []
return conn.execute(query, (playlist.id,)).fetchall()
else:
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()):
continue
# 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.rerun()
else:
st.experimental_rerun()
st.session_state["new_playlist_name"] = pl.name # on stocke temporairement
st.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)
@@ -17,7 +17,7 @@ def video_filter_sidebar():
selected_labels = st.sidebar.multiselect("Filtrer par labels", all_labels)
label_logic = st.sidebar.radio(
"Logique entre labels",
["OR", "AND"],
["AND", "OR"],
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)
@@ -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, editable_alias=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, editable_alias, playlist)
st.markdown("</div>", unsafe_allow_html=True)
# lazy loading bouton

View File

@@ -1,32 +1,149 @@
# video_views.py
import os
import shutil
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: Video,
preselected_labels,
editable_labels=True,
editable_difficulty=True,
editable_alias=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, difficulté et alias
- (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])
col4 = None
# --- Colonne 1 : miniature + boutons Play/Stop ---
play_key = f"playing_{video.file_name}"
st.session_state.setdefault(play_key, False)
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:
if getattr(video, "thumbnail_file", None) and os.path.exists(video.thumbnail_file):
st.image(video.thumbnail_file)
st.caption(video.mp4_file_name)
else:
st.caption("Pas de miniature")
st.caption(video.file_name or video.mp4_file_name)
c1, c2, c3 = st.columns(3)
with c1:
if st.button("▶️", 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("⏸️", key=f"stop_{video.file_name}"):
st.session_state[play_key] = False
(st.rerun if hasattr(st, "rerun") else st.experimental_rerun)()
with c3:
if st.button("🗑️", key=f"del_{video.file_name}"):
if preselected_labels and len(preselected_labels) > 0:
st.warning("Vidéo labelisée.")
else:
st.session_state[play_key] = False
if os.path.exists(video.raw_file ):
os.remove(video.raw_file)
if os.path.exists(video.mp4_file):
shutil.rmtree(os.path.dirname(video.mp4_file))
db.delete_video(video.file_name)
st.warning("Vidéo supprimée.")
(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'}")
playlists = db.get_video_playlists(video.file_name)
if playlists:
st.text(f"🎵 Playlists: {', '.join(playlists)}")
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}")
if editable_alias:
alias = st.text_input("Alias (optionnel)", value=video.alias, key=f'new_alias_{video.id}')
if alias != video.alias:
db.update_video_alias(video.file_name, alias.strip())
st.success(f"Alias 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

@@ -0,0 +1,290 @@
-- Extension du schéma DanceVideos/ pour le projet video_analysis/
-- Voir adr/0004-db-extension-strategy.md
-- Convention héritée de DanceVideos : la PK de `videos` est `file_name VARCHAR(255)`,
-- les tables filles utilisent `video_file_name VARCHAR(255)` comme FK.
PRAGMA foreign_keys = ON;
-- Groupes de vidéos d'un même cours (V0.6, voir adr/0010)
CREATE TABLE IF NOT EXISTS video_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
label TEXT,
time_start DATETIME,
time_end DATETIME,
n_videos INTEGER,
style TEXT,
pipeline_version TEXT NOT NULL,
notes TEXT
);
CREATE TABLE IF NOT EXISTS video_group_members (
video_file_name VARCHAR(255) PRIMARY KEY,
group_id INTEGER NOT NULL,
role TEXT CHECK (role IN ('explicative', 'demonstration', 'mixte', 'unknown')),
seq_idx INTEGER,
FOREIGN KEY (video_file_name) REFERENCES videos(file_name) ON DELETE CASCADE,
FOREIGN KEY (group_id) REFERENCES video_groups(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_vgm_group ON video_group_members(group_id);
-- Une analyse par version de pipeline et par vidéo
CREATE TABLE IF NOT EXISTS analyses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
video_file_name VARCHAR(255) NOT NULL,
niveau TEXT NOT NULL, -- "audio_demix" | "beats" | "asr" | "pose" | "segment" | "label"
pipeline_version TEXT NOT NULL,
started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
finished_at DATETIME,
summary_json TEXT,
FOREIGN KEY (video_file_name) REFERENCES videos(file_name) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_analyses_video ON analyses(video_file_name, niveau);
-- Segmentation audio (parole / musique / silence / mixed)
CREATE TABLE IF NOT EXISTS audio_segments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
video_file_name VARCHAR(255) NOT NULL,
start_s REAL NOT NULL,
end_s REAL NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('parole', 'musique', 'silence', 'mixed')),
speaker_id TEXT,
text TEXT,
confidence REAL,
FOREIGN KEY (video_file_name) REFERENCES videos(file_name) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_audio_segments_video ON audio_segments(video_file_name, start_s);
-- Beats / downbeats / tempo
CREATE TABLE IF NOT EXISTS beats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
video_file_name VARCHAR(255) NOT NULL,
t_s REAL NOT NULL,
beat_in_bar INTEGER NOT NULL, -- 1..4 typiquement
is_downbeat BOOLEAN NOT NULL,
bpm REAL,
FOREIGN KEY (video_file_name) REFERENCES videos(file_name) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_beats_video ON beats(video_file_name, t_s);
-- Régimes intra-vidéo (avec_compte / en_musique / mixte / explication / silence) — voir adr/0009
CREATE TABLE IF NOT EXISTS regime_segments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
video_file_name VARCHAR(255) NOT NULL,
start_s REAL NOT NULL,
end_s REAL NOT NULL,
regime TEXT NOT NULL CHECK (regime IN ('avec_compte', 'en_musique', 'mixte', 'explication', 'compte_avec_explication', 'silence')),
confidence REAL,
pipeline_version TEXT NOT NULL,
FOREIGN KEY (video_file_name) REFERENCES videos(file_name) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_regime_video_t ON regime_segments(video_file_name, start_s);
-- Transcription word-level (WhisperX) — voir adr/0008
CREATE TABLE IF NOT EXISTS transcription_words (
id INTEGER PRIMARY KEY AUTOINCREMENT,
video_file_name VARCHAR(255) NOT NULL,
audio_segment_id INTEGER,
t_start_s REAL NOT NULL,
t_end_s REAL NOT NULL,
word TEXT NOT NULL,
speaker_id TEXT,
confidence REAL,
pipeline_version TEXT NOT NULL,
FOREIGN KEY (video_file_name) REFERENCES videos(file_name) ON DELETE CASCADE,
FOREIGN KEY (audio_segment_id) REFERENCES audio_segments(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_words_video_t ON transcription_words(video_file_name, t_start_s);
CREATE INDEX IF NOT EXISTS idx_words_text ON transcription_words(word);
-- Labels sémantiques attachés aux dance_segments depuis l'ASR (V0.5.4)
CREATE TABLE IF NOT EXISTS dance_segment_labels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
segment_id TEXT NOT NULL,
label TEXT NOT NULL,
category TEXT, -- "body_part" | "movement" | "direction" | "figure" | "rhythm"
source TEXT NOT NULL CHECK (source IN ('asr-keyword', 'asr-figure', 'user', 'manual')),
t_offset_s REAL, -- décalage du keyword par rapport au début du segment (négatif = avant)
confidence REAL,
pipeline_version TEXT NOT NULL,
FOREIGN KEY (segment_id) REFERENCES dance_segments(segment_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_dsl_segment ON dance_segment_labels(segment_id);
CREATE INDEX IF NOT EXISTS idx_dsl_label ON dance_segment_labels(label);
-- Personnes détectées dans la vidéo (prof, partenaire, audience)
CREATE TABLE IF NOT EXISTS persons (
id INTEGER PRIMARY KEY AUTOINCREMENT,
video_file_name VARCHAR(255) NOT NULL,
person_id INTEGER NOT NULL, -- ID stable inter-frames (ByteTrack)
role TEXT NOT NULL CHECK (role IN ('prof', 'partner', 'audience', 'unknown')),
notes TEXT,
FOREIGN KEY (video_file_name) REFERENCES videos(file_name) ON DELETE CASCADE,
UNIQUE (video_file_name, person_id)
);
-- Pose keypoints (frame-decimated, 1/N frame typiquement)
CREATE TABLE IF NOT EXISTS pose_keypoints (
id INTEGER PRIMARY KEY AUTOINCREMENT,
video_file_name VARCHAR(255) NOT NULL,
frame INTEGER NOT NULL,
person_id INTEGER NOT NULL,
kp_json TEXT NOT NULL, -- JSON compressé : 133 keypoints {x, y, score}
FOREIGN KEY (video_file_name) REFERENCES videos(file_name) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_pose_kp_video_frame ON pose_keypoints(video_file_name, frame);
-- Segments hiérarchiques de chorégraphie (cœur de l'UX cumulation/réduction)
CREATE TABLE IF NOT EXISTS dance_segments (
segment_id TEXT PRIMARY KEY, -- ex. "v17:meso:0008"
video_file_name VARCHAR(255) NOT NULL,
start_s REAL NOT NULL,
end_s REAL NOT NULL,
level TEXT NOT NULL CHECK (level IN ('macro', 'meso', 'micro', 'nano')),
parent_segment_id TEXT,
label TEXT,
source TEXT NOT NULL CHECK (source IN ('auto-rules', 'auto-tcn', 'teacher-said', 'user')),
confidence REAL,
FOREIGN KEY (video_file_name) REFERENCES videos(file_name) ON DELETE CASCADE,
FOREIGN KEY (parent_segment_id) REFERENCES dance_segments(segment_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_dance_segments_video ON dance_segments(video_file_name, level, start_s);
CREATE INDEX IF NOT EXISTS idx_dance_segments_parent ON dance_segments(parent_segment_id);
-- Patterns nommés (mise en espagnol, dile que no, pas de bourré...)
CREATE TABLE IF NOT EXISTS patterns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
description TEXT,
style TEXT, -- "salsa cubaine" | "bachata" | "common" | ...
templates_json TEXT, -- liste d'occurrences (video_file_name + segment_id) servant de templates
occurrences_count INTEGER DEFAULT 0
);
-- Benchmarks — résultats d'évaluation par pipeline_version × méthode × tolérance × vidéo
-- (voir doc/observabilite-backoffice.md § 5, notebooks/02-segmentation-eval)
CREATE TABLE IF NOT EXISTS benchmarks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pipeline_version TEXT NOT NULL, -- ex. "v0.5.0", "v0.5.0-beat_this-raw-audio"
run_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
notebook TEXT, -- nom/chemin du notebook qui a produit la mesure
golden_set TEXT, -- ex. "decoupe/diegoRiviera.json" ou "decoupe/all"
video_file_name VARCHAR(255), -- vidéo ; nullable si métrique agrégée
method TEXT NOT NULL, -- "V0_meso" | "4bar_grid" | "uniform_5s" | ...
tol_s REAL NOT NULL, -- tolérance en secondes
precision_score REAL NOT NULL,
recall_score REAL NOT NULL,
f1_score REAL NOT NULL,
n_pred INTEGER NOT NULL,
n_gold INTEGER NOT NULL,
notes TEXT,
FOREIGN KEY (video_file_name) REFERENCES videos(file_name) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_benchmarks_version ON benchmarks(pipeline_version, method, tol_s);
CREATE INDEX IF NOT EXISTS idx_benchmarks_run ON benchmarks(run_at);
-- Journal d'éditions (audit trail + undo/redo unifiés) — voir adr/0013
CREATE TABLE IF NOT EXISTS edits_journal (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL DEFAULT 'default',
video_file_name VARCHAR(255),
transaction_id TEXT NOT NULL,
op_type TEXT NOT NULL,
target_table TEXT NOT NULL,
target_id TEXT NOT NULL,
before_json TEXT,
after_json TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
undone_at DATETIME,
redone_at DATETIME,
pipeline_version TEXT,
FOREIGN KEY (video_file_name) REFERENCES videos(file_name) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_edits_video_t ON edits_journal(video_file_name, created_at);
CREATE INDEX IF NOT EXISTS idx_edits_txn ON edits_journal(transaction_id);
CREATE INDEX IF NOT EXISTS idx_edits_active ON edits_journal(undone_at, created_at);
-- Handles d'ancrage cross-vidéo (paires _C_/_M_) — voir adr/0014
CREATE TABLE IF NOT EXISTS video_group_handles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER NOT NULL,
source_video VARCHAR(255) NOT NULL,
source_t_s REAL NOT NULL,
target_video VARCHAR(255) NOT NULL,
target_t_s REAL NOT NULL,
label TEXT,
created_by TEXT NOT NULL DEFAULT 'default',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
notes TEXT,
FOREIGN KEY (group_id) REFERENCES video_groups(id) ON DELETE CASCADE,
FOREIGN KEY (source_video) REFERENCES videos(file_name) ON DELETE CASCADE,
FOREIGN KEY (target_video) REFERENCES videos(file_name) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_handle_group ON video_group_handles(group_id);
CREATE INDEX IF NOT EXISTS idx_handle_pair ON video_group_handles(source_video, target_video);
-- Prédictions de style par classifieur RF (V0.6.1, voir adr/0011)
CREATE TABLE IF NOT EXISTS style_predictions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
file_name VARCHAR(255) NOT NULL,
style TEXT NOT NULL,
confidence REAL,
model_version TEXT NOT NULL,
predicted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (file_name) REFERENCES videos(file_name) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_stylepred_file ON style_predictions(file_name);
CREATE INDEX IF NOT EXISTS idx_stylepred_style ON style_predictions(style);
-- État de navigation persistant (UX cumulation/réduction)
CREATE TABLE IF NOT EXISTS nav_state (
id INTEGER PRIMARY KEY AUTOINCREMENT,
video_file_name VARCHAR(255) NOT NULL,
user_id TEXT NOT NULL DEFAULT 'default',
current_segment_id TEXT,
view_level TEXT CHECK (view_level IN ('macro', 'meso', 'micro', 'nano')),
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (video_file_name) REFERENCES videos(file_name) ON DELETE CASCADE,
FOREIGN KEY (current_segment_id) REFERENCES dance_segments(segment_id) ON DELETE SET NULL,
UNIQUE (video_file_name, user_id)
);
-- Atomes de mouvement (V0.10, voir adr/0018) — granularité beat / demi-beat.
-- Modèle hybride : continu + soft distribution + argmax + confidence.
CREATE TABLE IF NOT EXISTS beat_atoms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
video_file_name VARCHAR(255) NOT NULL,
person_id INTEGER NOT NULL DEFAULT 0,
t_s REAL NOT NULL,
beat_position REAL NOT NULL,
is_downbeat BOOLEAN NOT NULL,
-- Pied gauche
left_foot_height REAL,
left_foot_speed REAL,
left_foot_stability REAL,
left_foot_state VARCHAR(20),
left_foot_state_confidence REAL,
left_foot_state_dist_json TEXT,
-- Pied droit
right_foot_height REAL,
right_foot_speed REAL,
right_foot_stability REAL,
right_foot_state VARCHAR(20),
right_foot_state_confidence REAL,
right_foot_state_dist_json TEXT,
-- Transfert de poids
weight_ratio REAL,
weight_transfer_velocity REAL,
weight_on VARCHAR(10),
-- Cinématique bassin (en hip-widths)
pelvis_x REAL,
pelvis_y REAL,
pelvis_vel_x REAL,
pelvis_vel_y REAL,
-- Qualité globale
pose_confidence REAL NOT NULL,
pipeline_version TEXT NOT NULL,
computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (video_file_name) REFERENCES videos(file_name) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_atom_video_t ON beat_atoms(video_file_name, t_s);
CREATE INDEX IF NOT EXISTS idx_atom_video_beat ON beat_atoms(video_file_name, beat_position);

View File

@@ -12,6 +12,7 @@ CREATE TABLE IF NOT EXISTS videos (
lat DECIMAL(10,6),
long DECIMAL(11,7),
address VARCHAR(255),
difficulty_level VARCHAR(255) DEFAULT 'Tout niveau'
difficulty_level VARCHAR(255) DEFAULT 'Tout niveau',
alias VARCHAR(255) DEFAULT ''
);

View File

@@ -1,136 +1,244 @@
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;
-- ============================================================================
-- VIEW : playlist_videos
-- ============================================================================
DROP VIEW IF EXISTS playlist_videos;
DROP VIEW playlist_videos;
CREATE VIEW playlist_videos AS
WITH
-- Sous-requête pour les playlists manuelles
manual_playlist_videos AS (
-- 1⃣ Playlists manuelles
manual_playlists_videos AS (
SELECT
p.id AS playlist_id,
p.name AS playlist_name,
vp.video_file_name,
v.*,
'manual' AS playlist_type
'manual' AS playlist_type,
vp.video_file_name
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 (
-- 2⃣ Dynamiques sans include/exclude
dynamic_playlist_videos_base 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
'dynamic' AS playlist_type,
v.file_name AS video_file_name
FROM playlists p
JOIN videos v
WHERE p.type = 'dynamic'
-- Inclure les vidéos qui ont les labels requis
-- Labels inclus (AND/OR)
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 || ']'
)
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
)
)
)
)
-- Exclure les vidéos qui ont les labels exclus
-- 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 || ']'
)
)
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
)
-- 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
-- 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
)
-- Union des deux types de playlists
SELECT * FROM manual_playlist_videos
-- 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 * FROM dynamic_playlist_videos;
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,
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 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 playlist_videos pv ON pv.video_file_name = v.file_name
GROUP BY v.file_name;
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,48 @@
# Fonction pour ajouter une ligne à un fichier avec verrouillage (bloquant + timeout + gestion des signaux)
append_with_lock() {
local line="$2"
local file="$3"
local lock_dir="${file::-4}.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
echo "$line" >> "$file"
echo "$line" >> "$file" # write them twice because of bug where a line is skipped
# 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,55 @@
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 " --force, -f Traiter les fichiers ignorés"
echo " --help, -h Afficher cette aide"
echo " --print-err, -e Afficher les erreurs en stderr et non dans le fichier de logs"
exit 1
}
# Traitement des arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--all|-a)
PROCESS_ALL=true
shift
;;
--force|-f)
PROCESS_IGNORED_FILES=true
shift
;;
--help|-h)
usage
;;
--print-err|-e)
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_$RANDOM.txt"
# Initialiser le fichier temporaire
> "$TEMP_FILE"
sanitize_name() {
local name="$1"
@@ -17,6 +61,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 +74,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
@@ -65,7 +114,6 @@ mp4_dir() {
local dir=$DOSSIER_DESTINATION_MP4
dir=$dir/$(date -jf "%Y-%m-%dT%H:%M:%S" "$(get_creation_time $raw)" +%Y%m%d_%H%M%S)
dir=${dir}_${weekday}_${address}
mkdir -p $dir
echo $dir
}
export -f mp4_dir
@@ -84,13 +132,53 @@ write_mp4() {
}
export -f write_mp4
generate_funny_suffix() {
local file="$1"
# Grande liste de suffixes uniques et lisibles
local funny_names=(
"pamplemousse" "canard" "yolo" "flan" "tortue" "biscotte" "poney" "baguette"
"chaussette" "banane" "hippopotame" "tuba" "bretzel" "chocolatine" "carambar"
"frometon" "krokmou" "gnocchi" "clafoutis" "capybara" "choubidou" "pingouin"
"maracas" "raclette" "saucisson" "pistache" "chamallow" "boomerang" "pirouette"
"moustache" "abricot" "falafel" "pouet" "zouzou" "cornichon" "gnouf" "mouette"
"paprika" "crouton" "galipette" "gratin" "pouliche" "brocoli" "nugget"
)
# Calcul du hash MD5 compatible macOS
local hash_val
hash_val=$(md5 -q "$file" 2>/dev/null)
# Fallback si le hash échoue (ex: fichier non lisible)
if [[ -z "$hash_val" ]]; then
echo "default"
return
fi
# Extraire les 8 derniers caractères hexadécimaux pour faire un modulo fiable
local short_hex="${hash_val: -8}"
# Convertir en entier et choisir un mot dans la liste
local index=$(( 0x$short_hex % ${#funny_names[@]} ))
echo "${funny_names[$index]}"
}
export -f generate_funny_suffix
process_raw_file() {
local raw="$1"
local dir_suffixe="${2:-}"
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")
[ -n "$dir_suffixe" ] && dir=${dir}_${dir_suffixe} || :
mkdir -p $dir
local thumbnail=${dir}/thumbnail.jpg
$(write_thumbnail $raw $thumbnail)
@@ -119,24 +207,84 @@ export -f screen_video
whatsapp_video() {
local raw="$1"
echo whatsapp $raw
process_raw_file $raw
local suffixe=$(generate_funny_suffix $raw)
process_raw_file $raw $suffixe
}
export -f whatsapp_video
ignored_video() {
local raw="$1"
echo ignored "$raw"
local suffixe=$(generate_funny_suffix $raw)
process_raw_file $raw $suffixe
}
export -f ignored_video
convert_raws() {
if $PROCESS_ALL; then
# ##_##.mov
find "$DOSSIER_DESTINATION_RAW" -type f \
-name "*.mov" \
-print0 | parallel -0 -j 4 iphone_video
find "$DOSSIER_DESTINATION_RAW" -type f \
-name "*.mov" \
-print0 | parallel -0 -j 4 iphone_video
find "$DOSSIER_DESTINATION_RAW" -type f \
-name "screenrecording*.mp4" \
-print0 | parallel -0 -j 4 screen_video
find "$DOSSIER_DESTINATION_RAW" -type f \
-name "screenrecording*.mp4" \
-print0 | parallel -0 -j 4 screen_video
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
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
if [[ -n "$PROCESS_IGNORED_FILES" ]]; then
set -x
for f in $(find "$DOSSIER_DESTINATION_RAW" -maxdepth 1 -type f \( -iname "*.mp4" -o -iname "*.mov" \) -print); do
if [[ -z $(sqlite3 "$DANCE_VIDEOS_DB" "SELECT 1 FROM VIDEOS WHERE RAW_FILE='$f';") ]]; then
ignored_video "$f"
fi
done
set +x
fi
}
convert_raws
$PRINT_ERR || exec 2> /tmp/DanceVideos.stderr
convert_raws
set -x
if [ 0 -lt $(wc -l $TEMP_FILE | awk '{print $1}') ]; then
set +o pipefail
STREAMLIT_PID="$(ps aux | grep streamlit | grep -v 'grep' | awk '{print $2}')"
set -o pipefail
if [ ! -z "$STREAMLIT_PID" ]; then
kill $STREAMLIT_PID
fi
# (cd $SCRIPTS_DIR/..; source .venv/bin/activate; streamlit run app/app.py -- --unlabeled &)
ROOT=$(realpath $SCRIPTS_DIR/..)
source $ROOT/.venv/bin/activate
streamlit run $ROOT/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

34
program2/check.sh Executable file
View File

@@ -0,0 +1,34 @@
#!/bin/bash
set -euo pipefail
DANCE_VIDEOS_DB="${HOME}/Documents/.DanceVideos/db.sqlite"
RAW_VIDEOS_DIR="${HOME}/Documents/.DanceVideos/raw/"
check_registered_files_exist() {
sqlite3 -separator '|' "$DANCE_VIDEOS_DB" "
SELECT file_name, raw_file, mp4_file FROM videos;
" | while IFS='|' read -r file_name raw_file mp4_file; do
# Sauter l'en-tête
if [[ "$file_name" == "file_name" ]]; then
continue
fi
# Ignorer les lignes vides
if [[ ! -e "$raw_file" || ! -e "$mp4_file" ]]; then
echo "$raw_file KO : $mp4_file" >&2
else
echo "$raw_file OK"
fi
done
}
check_files_are_registered() {
for f in $(find "$RAW_VIDEOS_DIR" -maxdepth 1 -type f \( -iname "*.mp4" -o -iname "*.mov" \) -print); do
if [[ -z $(sqlite3 "$DANCE_VIDEOS_DB" "SELECT 1 FROM VIDEOS WHERE RAW_FILE='$f';") ]]; then
echo "$f not registered" >&2
# open $f
fi
done
}
check_files_are_registered

View File

@@ -16,12 +16,50 @@ mkdir -p "$DOSSIER_PLAYLIST"
# 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;")
video_filename () {
local rotated_file=$1
local alias=${2:-}
local count_or_music=${3:-}
name_part="$(basename "$(dirname "$rotated_file")")"
# Décomposer le name_part pour insérer l'alias après la date
# On suppose un schéma de départ du type YYYYMMDD_HHMMSS...
if [[ "$name_part" =~ ^([0-9]{8})_([0-9]{6}.*)$ ]]; then
date_part="${BASH_REMATCH[1]}"
rest_part="${BASH_REMATCH[2]}"
else
echo unexpected format >&2
exit 2
fi
output_name="${date_part:2}" #ignore the 20 from 20xx years
if [[ -n "$alias" ]]; then
# Nettoyer l'alias pour le rendre sûr dans le nom de fichier
safe_alias="$(echo "$alias" | tr ' /' '_' | tr -dc '[:alnum:]_')"
output_name="${output_name}_${safe_alias}"
fi
if [[ -n "$count_or_music" ]]; then
output_name="${output_name}_${count_or_music}"
fi
echo "${output_name}_${rest_part}.mp4"
}
# Pour chaque playlist, créer un dossier et ajouter les liens symboliques
sqlite3 -separator '|' "$DANCE_VIDEOS_DB" "
SELECT playlist_id, playlist_name, video_file_name, rotated_file
FROM playlist_videos
WHERE rotated_file IS NOT NULL;
" | while IFS='|' read -r playlist_id playlist_name video_file_name rotated_file; do
SELECT pv.playlist_id, pv.playlist_name, pv.video_file_name, v.rotated_file, v.alias, COALESCE( count_or_music_labels.count_or_music , '') count_or_music
FROM playlist_videos pv JOIN videos v ON pv.video_file_name=v.file_name
LEFT JOIN (
select * from video_labels
natural join (
select id as label_id, CASE WHEN name='comptes' THEN 'C' ELSE 'M' END as count_or_music
from labels where name in ('comptes','en musique')
)
) count_or_music_labels ON v.file_name=count_or_music_labels.video_file_name
WHERE v.rotated_file IS NOT NULL;
" | while IFS='|' read -r playlist_id playlist_name video_file_name rotated_file alias count_or_music; do
# Sauter l'en-tête
if [[ "$playlist_id" == "playlist_id" ]]; then
continue
@@ -37,6 +75,11 @@ sqlite3 -separator '|' "$DANCE_VIDEOS_DB" "
mkdir -p "$PLAYLIST_DIR"
# Créer le lien symbolique
ln -sf "$rotated_file" "$PLAYLIST_DIR/$(basename "$(dirname "$rotated_file")").mp4"
# ln -sf "$rotated_file" "$PLAYLIST_DIR/$(basename "$(dirname "$rotated_file")").mp4"
ln -sf "$rotated_file" "$PLAYLIST_DIR/$(video_filename $rotated_file $alias $count_or_music)"
set +x
done