playlist edition
This commit is contained in:
0
app/playlists/__init__.py
Normal file
0
app/playlists/__init__.py
Normal file
68
app/playlists/playlist_controller.py
Normal file
68
app/playlists/playlist_controller.py
Normal file
@@ -0,0 +1,68 @@
|
||||
# playlists/playlist_controller.py
|
||||
import streamlit as st
|
||||
import db
|
||||
from playlists.playlist_model import Playlist, RuleSet
|
||||
from playlists import playlist_db
|
||||
from views.label_views import video_filter_sidebar, video_list_view
|
||||
from models import Video
|
||||
|
||||
def playlist_manual_editor(playlist: Playlist):
|
||||
"""Permet de gérer les vidéos d'une playlist manuelle."""
|
||||
st.subheader(f"🎞️ Édition manuelle : {playlist.name}")
|
||||
filters = video_filter_sidebar()
|
||||
videos = video_list_view(filters, editable_labels=False, editable_difficulty=False)
|
||||
|
||||
existing_videos = db.get_videos_in_playlist(playlist.id)
|
||||
existing_names = [v["file_name"] for v in existing_videos]
|
||||
|
||||
for video in videos:
|
||||
in_playlist = video.file_name in existing_names
|
||||
button_label = "➕ Ajouter" if not in_playlist else "❌ Retirer"
|
||||
if st.button(button_label, key=f"toggle_{playlist.id}_{video.file_name}"):
|
||||
if in_playlist:
|
||||
db.remove_video_from_playlist(playlist.id, video.file_name)
|
||||
st.success(f"Supprimée de {playlist.name}")
|
||||
else:
|
||||
db.add_video_to_playlist(playlist.id, video.file_name)
|
||||
st.success(f"Ajoutée à {playlist.name}")
|
||||
st.rerun()
|
||||
|
||||
def playlist_dynamic_editor(playlist: Playlist):
|
||||
"""Édite les règles d'une playlist dynamique et affiche le rendu en temps réel."""
|
||||
st.subheader(f"⚙️ Playlist dynamique : {playlist.name}")
|
||||
|
||||
rules = playlist.rules or RuleSet()
|
||||
labels = db.load_labels()
|
||||
playlists = [p.name for p in playlist_db.load_all_playlists()]
|
||||
|
||||
rules.include_labels = st.multiselect("Inclure labels", labels, default=rules.include_labels)
|
||||
rules.exclude_labels = st.multiselect("Exclure labels", labels, default=rules.exclude_labels)
|
||||
rules.include_playlists = st.multiselect("Inclure playlists", playlists, default=rules.include_playlists)
|
||||
rules.exclude_playlists = st.multiselect("Exclure playlists", playlists, default=rules.exclude_playlists)
|
||||
|
||||
col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
rules.date_after = st.date_input("📅 Après le", value=rules.date_after or None)
|
||||
with col2:
|
||||
rules.date_before = st.date_input("📅 Avant le", value=rules.date_before or None)
|
||||
|
||||
rules.logic = st.radio("Logique de combinaison", ["AND", "OR"], index=0 if rules.logic == "AND" else 1)
|
||||
|
||||
if st.button("💾 Enregistrer les règles"):
|
||||
playlist.rules = rules
|
||||
playlist.save()
|
||||
st.success("Règles mises à jour ✅")
|
||||
st.rerun()
|
||||
|
||||
st.markdown("---")
|
||||
st.subheader("🧩 Rendu de la playlist")
|
||||
rows = playlist_db.get_videos_for_playlist(playlist)
|
||||
if not rows:
|
||||
st.info("Aucune vidéo ne correspond aux règles actuelles.")
|
||||
return
|
||||
|
||||
videos = [Video(**row) for row in rows]
|
||||
st.write(f"{len(videos)} vidéo(s) trouvée(s).")
|
||||
from views.video_views import show_video_row
|
||||
for v in videos[:30]:
|
||||
show_video_row(v, db.load_video_labels(v.file_name), editable_labels=False, editable_difficulty=False)
|
||||
95
app/playlists/playlist_db.py
Normal file
95
app/playlists/playlist_db.py
Normal file
@@ -0,0 +1,95 @@
|
||||
# playlists/playlist_db.py
|
||||
import db
|
||||
import json
|
||||
from playlists.playlist_model import Playlist, RuleSet
|
||||
|
||||
def load_all_playlists():
|
||||
"""Retourne une liste de Playlist (Pydantic) — tolérant aux rules_json nuls / vides."""
|
||||
with db.get_conn() as conn:
|
||||
# s'assurer que les lignes sont accessibles par nom (si get_conn ne l'a pas fait)
|
||||
try:
|
||||
conn.row_factory = conn.row_factory # no-op si déjà réglé
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
rows = conn.execute("SELECT * FROM playlists ORDER BY created_at DESC").fetchall()
|
||||
playlists = []
|
||||
for row in rows:
|
||||
# si row est sqlite3.Row, on peut accéder par nom, sinon c'est un tuple et on mappe par index
|
||||
if hasattr(row, "__getitem__") and isinstance(row, dict) is False and getattr(row, "keys", None):
|
||||
# sqlite3.Row behaves like mapping
|
||||
row_dict = {k: row[k] for k in row.keys()}
|
||||
elif isinstance(row, dict):
|
||||
row_dict = row
|
||||
else:
|
||||
# fallback: convert tuple -> dict using cursor.description
|
||||
# but here we assume conn.row_factory set in db.get_conn; keep robust fallback
|
||||
cols = [d[0] for d in conn.execute("PRAGMA table_info(playlists)").fetchall()]
|
||||
row_dict = dict(zip(cols, row))
|
||||
|
||||
try:
|
||||
pl = Playlist(
|
||||
id=row_dict.get("id"),
|
||||
name=row_dict.get("name") or "",
|
||||
description=row_dict.get("description") or "",
|
||||
type=row_dict.get("type") or "manual",
|
||||
rules=row_dict.get("rules_json")
|
||||
)
|
||||
playlists.append(pl)
|
||||
except Exception as e:
|
||||
# Ne bloque pas toute la lecture : logue et passe à la suivante
|
||||
print(f"⚠️ Ignored invalid playlist row (id={row_dict.get('id')}, name={row_dict.get('name')}): {e}")
|
||||
return playlists
|
||||
|
||||
def delete_playlist(playlist_id):
|
||||
with db.get_conn() as conn:
|
||||
conn.execute("DELETE FROM playlists WHERE id = ?", (playlist_id,))
|
||||
conn.commit()
|
||||
|
||||
def get_videos_for_playlist(playlist: Playlist):
|
||||
"""Retourne les vidéos selon le type"""
|
||||
with db.get_conn() as conn:
|
||||
if playlist.type == "manual":
|
||||
q = """
|
||||
SELECT v.*
|
||||
FROM videos v
|
||||
JOIN video_playlists vp ON vp.video_file_name = v.file_name
|
||||
WHERE vp.playlist_id = ?
|
||||
ORDER BY vp.position
|
||||
"""
|
||||
return conn.execute(q, (playlist.id,)).fetchall()
|
||||
else:
|
||||
rules = playlist.rules
|
||||
clauses = []
|
||||
params = []
|
||||
|
||||
if rules.include_labels:
|
||||
placeholders = ",".join("?" * len(rules.include_labels))
|
||||
clauses.append(f"v.file_name IN (SELECT vl.video_file_name FROM video_labels vl JOIN labels l ON l.id=vl.label_id WHERE l.name IN ({placeholders}))")
|
||||
params += rules.include_labels
|
||||
|
||||
if rules.exclude_labels:
|
||||
placeholders = ",".join("?" * len(rules.exclude_labels))
|
||||
clauses.append(f"v.file_name NOT IN (SELECT vl.video_file_name FROM video_labels vl JOIN labels l ON l.id=vl.label_id WHERE l.name IN ({placeholders}))")
|
||||
params += rules.exclude_labels
|
||||
|
||||
if rules.include_playlists:
|
||||
placeholders = ",".join("?" * len(rules.include_playlists))
|
||||
clauses.append(f"v.file_name IN (SELECT vp.video_file_name FROM video_playlists vp JOIN playlists p ON vp.playlist_id=p.id WHERE p.name IN ({placeholders}))")
|
||||
params += rules.include_playlists
|
||||
|
||||
if rules.exclude_playlists:
|
||||
placeholders = ",".join("?" * len(rules.exclude_playlists))
|
||||
clauses.append(f"v.file_name NOT IN (SELECT vp.video_file_name FROM video_playlists vp JOIN playlists p ON vp.playlist_id=p.id WHERE p.name IN ({placeholders}))")
|
||||
params += rules.exclude_playlists
|
||||
|
||||
if rules.date_after:
|
||||
clauses.append("v.record_datetime >= ?")
|
||||
params.append(rules.date_after)
|
||||
if rules.date_before:
|
||||
clauses.append("v.record_datetime <= ?")
|
||||
params.append(rules.date_before)
|
||||
|
||||
where_clause = f" {' AND ' if rules.logic == 'AND' else ' OR '} ".join(clauses) if clauses else "1=1"
|
||||
q = f"SELECT v.* FROM videos v WHERE {where_clause} ORDER BY v.record_datetime DESC"
|
||||
return conn.execute(q, params).fetchall()
|
||||
63
app/playlists/playlist_model.py
Normal file
63
app/playlists/playlist_model.py
Normal file
@@ -0,0 +1,63 @@
|
||||
# playlists/playlist_model.py
|
||||
from pydantic import BaseModel, Field, validator
|
||||
from typing import List, Optional, Literal
|
||||
import json
|
||||
import db
|
||||
|
||||
|
||||
class RuleSet(BaseModel):
|
||||
include_labels: List[str] = []
|
||||
exclude_labels: List[str] = []
|
||||
include_playlists: List[str] = []
|
||||
exclude_playlists: List[str] = []
|
||||
date_after: Optional[str] = None
|
||||
date_before: Optional[str] = None
|
||||
logic: Literal["AND", "OR"] = "AND"
|
||||
|
||||
def to_json(self) -> str:
|
||||
return json.dumps(self.dict(), ensure_ascii=False, indent=2)
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, raw):
|
||||
"""Accepte None, str vide ou dict JSON."""
|
||||
if not raw:
|
||||
return cls()
|
||||
if isinstance(raw, dict):
|
||||
return cls(**raw)
|
||||
try:
|
||||
return cls(**json.loads(raw))
|
||||
except Exception:
|
||||
return cls()
|
||||
|
||||
|
||||
class Playlist(BaseModel):
|
||||
id: Optional[int] = None
|
||||
name: str
|
||||
description: Optional[str] = ""
|
||||
type: Literal["manual", "dynamic"] = "manual"
|
||||
rules: RuleSet = Field(default_factory=RuleSet)
|
||||
|
||||
@validator("rules", pre=True, always=True)
|
||||
def ensure_rules(cls, v):
|
||||
"""Transforme la valeur de rules_json (None, str ou dict) en RuleSet."""
|
||||
if isinstance(v, RuleSet):
|
||||
return v
|
||||
return RuleSet.from_json(v)
|
||||
|
||||
def save(self):
|
||||
"""Insert or update playlist"""
|
||||
with db.get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
if self.id:
|
||||
cur.execute("""
|
||||
UPDATE playlists
|
||||
SET name=?, description=?, type=?, rules_json=?, updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=?
|
||||
""", (self.name, self.description, self.type, self.rules.to_json(), self.id))
|
||||
else:
|
||||
cur.execute("""
|
||||
INSERT INTO playlists (name, description, type, rules_json)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""", (self.name, self.description, self.type, self.rules.to_json()))
|
||||
self.id = cur.lastrowid
|
||||
conn.commit()
|
||||
35
app/playlists/playlist_page.py
Normal file
35
app/playlists/playlist_page.py
Normal file
@@ -0,0 +1,35 @@
|
||||
# playlists/playlist_page.py
|
||||
import streamlit as st
|
||||
from playlists import playlist_db
|
||||
from playlists.playlist_model import Playlist
|
||||
from playlists.playlist_controller import playlist_manual_editor, playlist_dynamic_editor
|
||||
from playlists.playlist_controller import RuleSet
|
||||
|
||||
def main():
|
||||
st.title("🎵 Gestion des Playlists")
|
||||
|
||||
playlists = playlist_db.load_all_playlists()
|
||||
selected = st.selectbox("Playlists existantes", ["(Nouvelle)"] + [p.name for p in playlists])
|
||||
|
||||
# playlists/playlist_page.py (partie création)
|
||||
if selected == "(Nouvelle)":
|
||||
st.subheader("➕ Nouvelle playlist")
|
||||
name = st.text_input("Nom")
|
||||
desc = st.text_area("Description")
|
||||
type_choice = st.radio("Type", ["manual", "dynamic"])
|
||||
if st.button("Créer"):
|
||||
if not name.strip():
|
||||
st.error("Le nom ne peut pas être vide.")
|
||||
else:
|
||||
pl = Playlist(name=name.strip(), description=desc or "", type=type_choice, rules=RuleSet())
|
||||
pl.save()
|
||||
st.success("Playlist créée ✅")
|
||||
# Recharge immédiate : rerun force tout à re-exécuter
|
||||
st.rerun()
|
||||
|
||||
else:
|
||||
current = next(p for p in playlists if p.name == selected)
|
||||
if current.type == "manual":
|
||||
playlist_manual_editor(current)
|
||||
else:
|
||||
playlist_dynamic_editor(current)
|
||||
15
app/playlists/playlist_views.py
Normal file
15
app/playlists/playlist_views.py
Normal file
@@ -0,0 +1,15 @@
|
||||
# playlists/playlist_views.py
|
||||
import streamlit as st
|
||||
from playlists import playlist_db
|
||||
from models import Video
|
||||
from views import show_video_thumbnail
|
||||
|
||||
def preview_playlist(playlist):
|
||||
st.subheader(f"🎬 Aperçu de la playlist : {playlist.name}")
|
||||
rows = playlist_db.get_videos_for_playlist(playlist)
|
||||
videos = [Video(**row) for row in rows]
|
||||
if not videos:
|
||||
st.info("Aucune vidéo correspondante.")
|
||||
return
|
||||
for video in videos[:30]: # limiter pour performance
|
||||
show_video_thumbnail(video)
|
||||
Reference in New Issue
Block a user