diff --git a/linedance-api/app/routers/alt_dance_ratings.py b/linedance-api/app/routers/alt_dance_ratings.py new file mode 100644 index 00000000..2c65ddae --- /dev/null +++ b/linedance-api/app/routers/alt_dance_ratings.py @@ -0,0 +1,121 @@ +""" +alt_dance_ratings.py — Community alternativ-dans ratings endpoint. +""" +import uuid as _uuid +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from pydantic import BaseModel +from app.core.database import get_db +from app.core.security import get_current_user +from app.models import User, Song, Dance, CommunityDanceAlt, DanceAltRating + +router = APIRouter(prefix="/alt-ratings", tags=["alt-ratings"]) + + +class SubmitAltRequest(BaseModel): + song_id: str # server song UUID + dance_name: str + rating: int # 1-5 + + +@router.post("/submit") +def submit_alt_rating( + req: SubmitAltRequest, + db: Session = Depends(get_db), + me: User = Depends(get_current_user), +): + """Indsend eller opdater rating for en alternativ-dans på en sang.""" + if not 1 <= req.rating <= 5: + raise HTTPException(400, "Rating skal være 1-5") + + song = db.query(Song).filter_by(id=req.song_id).first() + if not song: + raise HTTPException(404, "Sang ikke fundet") + + dance = db.query(Dance).filter( + Dance.name.ilike(req.dance_name) + ).first() + if not dance: + raise HTTPException(404, "Dans ikke fundet") + + # Find eller opret community alt-dans + alt = db.query(CommunityDanceAlt).filter_by( + song_mbid=song.mbid or None, + song_title=song.title, + song_artist=song.artist, + alt_dance_id=dance.id, + ).first() + + if not alt: + alt = CommunityDanceAlt( + id=str(_uuid.uuid4()), + song_mbid=song.mbid or None, + song_title=song.title, + song_artist=song.artist, + alt_dance_id=dance.id, + submitted_by=me.id, + avg_rating=float(req.rating), + rating_count=1, + ) + db.add(alt) + db.flush() + + # Opdater eller indsæt brugerens rating + existing_rating = db.query(DanceAltRating).filter_by( + alternative_id=alt.id, + user_id=me.id, + ).first() + + if existing_rating: + old_score = existing_rating.score + existing_rating.score = req.rating + # Opdater gennemsnit + total = alt.avg_rating * alt.rating_count - old_score + req.rating + alt.avg_rating = total / alt.rating_count + else: + db.add(DanceAltRating( + id=str(_uuid.uuid4()), + alternative_id=alt.id, + user_id=me.id, + score=req.rating, + )) + # Opdater gennemsnit + total = alt.avg_rating * alt.rating_count + req.rating + alt.rating_count += 1 + alt.avg_rating = total / alt.rating_count + + db.commit() + return {"status": "ok", "avg_rating": alt.avg_rating, "rating_count": alt.rating_count} + + +@router.get("/for-song/{song_id}") +def get_alt_ratings_for_song( + song_id: str, + db: Session = Depends(get_db), + me: User = Depends(get_current_user), +): + """Hent community alternativ-danse med ratings for en sang.""" + song = db.query(Song).filter_by(id=song_id).first() + if not song: + raise HTTPException(404, "Sang ikke fundet") + + alts = db.query(CommunityDanceAlt).filter( + (CommunityDanceAlt.song_mbid == song.mbid) if song.mbid else + ((CommunityDanceAlt.song_title == song.title) & + (CommunityDanceAlt.song_artist == song.artist)) + ).all() + + result = [] + for alt in alts: + my_rating = db.query(DanceAltRating).filter_by( + alternative_id=alt.id, + user_id=me.id, + ).first() + result.append({ + "dance_name": alt.alt_dance.name, + "avg_rating": round(alt.avg_rating, 1), + "rating_count": alt.rating_count, + "my_rating": my_rating.score if my_rating else None, + }) + + return result diff --git a/linedance-api/app/routers/sync.py b/linedance-api/app/routers/sync.py index 7621d710..078002a8 100644 --- a/linedance-api/app/routers/sync.py +++ b/linedance-api/app/routers/sync.py @@ -10,6 +10,7 @@ from datetime import datetime, timezone from fastapi import APIRouter, Depends from sqlalchemy.orm import Session from pydantic import BaseModel +from typing import Optional from app.core.database import get_db from app.core.security import get_current_user @@ -53,6 +54,7 @@ class SongAltDanceData(BaseModel): dance_name: str level_name: str = "" note: str = "" + user_rating: Optional[int] = None class PlaylistSongData(BaseModel): song_local_id: str @@ -224,6 +226,47 @@ def push( dance_id = dance_id_map.get(key) if not dance_id: continue + # Opdater community rating hvis bruger har givet en vurdering + if sa.user_rating and 1 <= sa.user_rating <= 5: + from app.models import CommunityDanceAlt, DanceAltRating + song_obj = db.query(Song).filter_by(id=song_id).first() + if song_obj: + alt = db.query(CommunityDanceAlt).filter_by( + song_title=song_obj.title, + song_artist=song_obj.artist, + alt_dance_id=dance_id, + ).first() + if not alt: + alt = CommunityDanceAlt( + id=str(uuid.uuid4()), + song_mbid=song_obj.mbid or None, + song_title=song_obj.title, + song_artist=song_obj.artist, + alt_dance_id=dance_id, + submitted_by=me.id, + avg_rating=float(sa.user_rating), + rating_count=1, + ) + db.add(alt) + db.flush() + existing_r = db.query(DanceAltRating).filter_by( + alternative_id=alt.id, user_id=me.id + ).first() + if existing_r: + old_score = existing_r.score + existing_r.score = sa.user_rating + total = alt.avg_rating * alt.rating_count - old_score + sa.user_rating + alt.avg_rating = total / alt.rating_count + else: + db.add(DanceAltRating( + id=str(uuid.uuid4()), + alternative_id=alt.id, + user_id=me.id, + score=sa.user_rating, + )) + total = alt.avg_rating * alt.rating_count + sa.user_rating + alt.rating_count += 1 + alt.avg_rating = total / alt.rating_count db.execute(_sa.text( "INSERT IGNORE INTO song_alt_dances (id, song_id, dance_id, note) " "VALUES (:id, :song_id, :dance_id, :note)" @@ -401,10 +444,30 @@ def pull( "dance_order": sd.dance_order, }) + # Community alternativ-danse (top 500 mest ratede) + from app.models import CommunityDanceAlt, DanceAltRating + community_alts = [] + for alt in db.query(CommunityDanceAlt).order_by( + CommunityDanceAlt.avg_rating.desc() + ).limit(500).all(): + my_rating = db.query(DanceAltRating).filter_by( + alternative_id=alt.id, user_id=me.id + ).first() + community_alts.append({ + "song_mbid": alt.song_mbid or "", + "song_title": alt.song_title, + "song_artist": alt.song_artist, + "dance_name": alt.alt_dance.name if alt.alt_dance else "", + "avg_rating": round(alt.avg_rating, 1), + "rating_count": alt.rating_count, + "my_rating": my_rating.score if my_rating else None, + }) + return { - "levels": levels, - "dances": dances, - "shared": shared, - "my_playlists": my_playlists, - "song_tags": song_tags, + "levels": levels, + "dances": dances, + "shared": shared, + "my_playlists": my_playlists, + "song_tags": song_tags, + "community_alts": community_alts, } \ No newline at end of file diff --git a/linedance-app/local/acoustid_worker.py b/linedance-app/local/acoustid_worker.py index f987a4ba..45ddf1ac 100644 --- a/linedance-app/local/acoustid_worker.py +++ b/linedance-app/local/acoustid_worker.py @@ -219,11 +219,17 @@ def run_acoustid_scan(db_path: str, api_key: str = "", on_progress=None, stop_ev (acoustid or None, row["id"]) ) if mbid: - conn.execute( - "UPDATE songs SET mbid=? WHERE id=? AND (mbid IS NULL OR mbid='')", - (mbid, row["id"]) - ) - conn.commit() + try: + conn.execute( + "UPDATE songs SET mbid=? WHERE id=? AND (mbid IS NULL OR mbid='')", + (mbid, row["id"]) + ) + conn.commit() + except Exception: + conn.rollback() + logger.debug(f"MBID {mbid[:8]} allerede i brug — springer over") + else: + conn.commit() found += 1 total_found += 1 logger.info( diff --git a/linedance-app/local/local_db.py b/linedance-app/local/local_db.py index cc335a11..de5660ac 100644 --- a/linedance-app/local/local_db.py +++ b/linedance-app/local/local_db.py @@ -113,12 +113,13 @@ CREATE TABLE IF NOT EXISTS song_dances ( -- Alternativ-dans tags CREATE TABLE IF NOT EXISTS song_alt_dances ( - id TEXT PRIMARY KEY, - song_id TEXT NOT NULL REFERENCES songs(id) ON DELETE CASCADE, - dance_id INTEGER NOT NULL REFERENCES dances(id), - note TEXT NOT NULL DEFAULT '', - source TEXT NOT NULL DEFAULT 'local', - created_at TEXT NOT NULL DEFAULT (datetime('now')), + id TEXT PRIMARY KEY, + song_id TEXT NOT NULL REFERENCES songs(id) ON DELETE CASCADE, + dance_id INTEGER NOT NULL REFERENCES dances(id), + note TEXT NOT NULL DEFAULT '', + user_rating INTEGER, -- 1-5 stjerner, NULL = ikke vurderet + source TEXT NOT NULL DEFAULT 'local', + created_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(song_id, dance_id) ); @@ -140,14 +141,15 @@ CREATE TABLE IF NOT EXISTS playlists ( -- Playliste-sange CREATE TABLE IF NOT EXISTS playlist_songs ( - id TEXT PRIMARY KEY, - playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, - song_id TEXT NOT NULL REFERENCES songs(id), - file_id TEXT REFERENCES files(id), - position INTEGER NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', - is_workshop INTEGER NOT NULL DEFAULT 0, - dance_override TEXT NOT NULL DEFAULT '' + id TEXT PRIMARY KEY, + playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, + song_id TEXT NOT NULL REFERENCES songs(id), + file_id TEXT REFERENCES files(id), + position INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + is_workshop INTEGER NOT NULL DEFAULT 0, + dance_override TEXT NOT NULL DEFAULT '', + alt_dance_override TEXT NOT NULL DEFAULT '' ); CREATE INDEX IF NOT EXISTS idx_playlist_songs_playlist ON playlist_songs(playlist_id); @@ -692,4 +694,49 @@ def get_or_create_dance(name: str, level_id: int | None, conn, "INSERT INTO dances (name, level_id, choreographer) VALUES (?,?,?)", (name, level_id, choreo) ) - return cur.lastrowid \ No newline at end of file + return cur.lastrowid + +def rate_alt_dance(song_id: str, dance_id: int, rating: int | None): + """Sæt brugerens rating (1-5) på en alternativ-dans. None = fjern rating.""" + with get_db() as conn: + conn.execute( + "UPDATE song_alt_dances SET user_rating=? WHERE song_id=? AND dance_id=?", + (rating, song_id, dance_id) + ) + + +def get_alt_dances_for_song_with_ratings(song_id: str) -> list: + """Hent alternativ-danse med bruger-rating og community-rating.""" + with get_db() as conn: + rows = conn.execute(""" + SELECT d.id, d.name, d.level_id, dl.name as level_name, + d.choreographer, sad.note, sad.user_rating, + sad.source + FROM song_alt_dances sad + JOIN dances d ON d.id = sad.dance_id + LEFT JOIN dance_levels dl ON dl.id = d.level_id + WHERE sad.song_id = ? + ORDER BY sad.user_rating DESC NULLS LAST, d.name + """, (song_id,)).fetchall() + return [dict(r) for r in rows] + +def get_community_alts_for_song(song_id: str) -> list: + """Hent community alternativ-danse for en sang med ratings.""" + with get_db() as conn: + # Opret tabellen hvis den ikke eksisterer + conn.execute( + "CREATE TABLE IF NOT EXISTS community_alt_dances (" + "id TEXT PRIMARY KEY, song_id TEXT NOT NULL, dance_id INTEGER NOT NULL, " + "avg_rating REAL NOT NULL DEFAULT 0, rating_count INTEGER NOT NULL DEFAULT 0, " + "my_rating INTEGER, UNIQUE(song_id, dance_id))" + ) + rows = conn.execute(""" + SELECT d.id, d.name, dl.name as level_name, d.choreographer, + cad.avg_rating, cad.rating_count, cad.my_rating + FROM community_alt_dances cad + JOIN dances d ON d.id = cad.dance_id + LEFT JOIN dance_levels dl ON dl.id = d.level_id + WHERE cad.song_id = ? + ORDER BY cad.avg_rating DESC + """, (song_id,)).fetchall() + return [dict(r) for r in rows] \ No newline at end of file diff --git a/linedance-app/local/sync_manager.py b/linedance-app/local/sync_manager.py index 575523d1..e6513165 100644 --- a/linedance-app/local/sync_manager.py +++ b/linedance-app/local/sync_manager.py @@ -189,7 +189,7 @@ class SyncManager: song_alts = [] for row in conn.execute(""" SELECT sad.song_id, d.name as dance_name, - dl.name as level_name, sad.note + dl.name as level_name, sad.note, sad.user_rating FROM song_alt_dances sad JOIN dances d ON d.id = sad.dance_id LEFT JOIN dance_levels dl ON dl.id = d.level_id @@ -199,6 +199,7 @@ class SyncManager: "dance_name": row["dance_name"], "level_name": row["level_name"] or "", "note": row["note"] or "", + "user_rating": row["user_rating"], }) # Playlister — alle ikke-slettede @@ -480,6 +481,54 @@ class SyncManager: song_data.get("dance_override","") or "")) position += 1 + # Gem community alternativ-danse lokalt + conn.execute( + "CREATE TABLE IF NOT EXISTS community_alt_dances (" + "id TEXT PRIMARY KEY, song_id TEXT NOT NULL, dance_id INTEGER NOT NULL, " + "avg_rating REAL NOT NULL DEFAULT 0, rating_count INTEGER NOT NULL DEFAULT 0, " + "my_rating INTEGER, UNIQUE(song_id, dance_id))" + ) + for ca in data.get("community_alts", []): + if not ca.get("dance_name"): + continue + song_row = None + if ca.get("song_mbid"): + song_row = conn.execute( + "SELECT id FROM songs WHERE mbid=?", (ca["song_mbid"],) + ).fetchone() + if not song_row and ca.get("song_title"): + song_row = conn.execute( + "SELECT id FROM songs WHERE title=? AND artist=?", + (ca["song_title"], ca.get("song_artist", "")) + ).fetchone() + if not song_row: + continue + song_id = song_row["id"] + dance_row = conn.execute( + "SELECT id FROM dances WHERE name=? COLLATE NOCASE LIMIT 1", + (ca["dance_name"],) + ).fetchone() + if not dance_row: + cur = conn.execute( + "INSERT OR IGNORE INTO dances (name) VALUES (?)", (ca["dance_name"],) + ) + dance_id = cur.lastrowid + else: + dance_id = dance_row["id"] + if not dance_id: + continue + conn.execute( + "INSERT INTO community_alt_dances " + "(id, song_id, dance_id, avg_rating, rating_count, my_rating) " + "VALUES (?,?,?,?,?,?) " + "ON CONFLICT(song_id, dance_id) DO UPDATE SET " + "avg_rating=excluded.avg_rating, rating_count=excluded.rating_count, " + "my_rating=COALESCE(excluded.my_rating, my_rating)", + (str(uuid.uuid4()), song_id, dance_id, + ca.get("avg_rating", 0), ca.get("rating_count", 0), + ca.get("my_rating")) + ) + # Importer sang-dans tags fra server for st in data.get("song_tags", []): server_song_id = st.get("song_id", "") diff --git a/linedance-app/ui/alt_dance_picker_dialog.py b/linedance-app/ui/alt_dance_picker_dialog.py new file mode 100644 index 00000000..db43684d --- /dev/null +++ b/linedance-app/ui/alt_dance_picker_dialog.py @@ -0,0 +1,345 @@ +""" +alt_dance_picker_dialog.py — Vælg alternativ dans til en sang i playlisten. + +Tre sektioner: + 🟢 Mine egne alternativ-danse med min rating + 🟡 Community alternativ-danse med community + min rating + Alle andre danse +""" + +from PyQt6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, + QPushButton, QListWidget, QListWidgetItem, QWidget, +) +from PyQt6.QtCore import Qt, QTimer, pyqtSignal +from PyQt6.QtGui import QColor + +STAR_FULL = "★" +STAR_EMPTY = "☆" +GREEN = "#27ae60" +YELLOW = "#e8a020" +MUTED = "#5a6070" + + +class StarRatingWidget(QWidget): + """Klikbar stjerne-rating widget til brug i lister.""" + rating_changed = pyqtSignal(int) # 1-5 + + def __init__(self, rating=None, max_stars=5, color=YELLOW, parent=None): + # YELLOW er ikke defineret endnu ved import — bruges som string nedenfor + super().__init__(parent) + self._rating = rating + self._max = max_stars + self._color = color + self._btns = [] + layout = QHBoxLayout(self) + layout.setContentsMargins(2, 0, 2, 0) + layout.setSpacing(1) + for i in range(1, max_stars + 1): + btn = QPushButton("★" if rating and i <= rating else "☆") + btn.setFixedSize(18, 18) + btn.setStyleSheet(f""" + QPushButton {{ + font-size: 13px; border: none; background: none; padding: 0; + color: {color if rating and i <= rating else '#5a6070'}; + }} + QPushButton:hover {{ color: {color}; }} + """) + btn.clicked.connect(lambda checked, r=i: self._on_click(r)) + layout.addWidget(btn) + self._btns.append(btn) + + def _on_click(self, r): + self._rating = r + for i, btn in enumerate(self._btns): + filled = i < r + btn.setText("★" if filled else "☆") + btn.setStyleSheet(f""" + QPushButton {{ + font-size: 13px; border: none; background: none; padding: 0; + color: {self._color if filled else '#5a6070'}; + }} + QPushButton:hover {{ color: {self._color}; }} + """) + self.rating_changed.emit(r) + + def get_rating(self): + return self._rating + + +def make_stars(rating, max_stars=5): + if not rating: + return STAR_EMPTY * max_stars + full = min(max_stars, round(float(rating))) + return STAR_FULL * full + STAR_EMPTY * (max_stars - full) + + +class AltDancePickerDialog(QDialog): + def __init__(self, song: dict, parent=None): + super().__init__(parent) + self._song = song + self._chosen_dance = "" + self._chosen_rating = None + self._cleared = False + self.setWindowTitle("Vælg alternativ dans") + self.setMinimumWidth(600) + self.setMinimumHeight(520) + self._build_ui() + self._load_suggestions("") + + def _build_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(12, 12, 12, 12) + layout.setSpacing(8) + + # Sang-info + title = self._song.get("title", "?") + artist = self._song.get("artist", "") + lbl = QLabel(f"{title} · {artist}" if artist else title) + lbl.setObjectName("track_title") + lbl.setWordWrap(True) + layout.addWidget(lbl) + + # Søgefelt + self._edit = QLineEdit() + self._edit.setPlaceholderText("Søg dans-navn...") + self._edit.textChanged.connect(self._on_text_changed) + self._edit.returnPressed.connect(self._on_accept) + layout.addWidget(self._edit) + + # Forslagsliste + self._list = QListWidget() + self._list.setMinimumHeight(320) + self._list.itemClicked.connect(self._on_item_clicked) + self._list.itemDoubleClicked.connect(self._on_selected) + layout.addWidget(self._list) + + # Info-label + self._info_lbl = QLabel("") + self._info_lbl.setObjectName("result_count") + self._info_lbl.setWordWrap(True) + layout.addWidget(self._info_lbl) + + # Debounce timer + self._timer = QTimer(self) + self._timer.setSingleShot(True) + self._timer.setInterval(150) + self._timer.timeout.connect( + lambda: self._load_suggestions(self._edit.text().strip()) + ) + + # Knapper + btn_row = QHBoxLayout() + btn_none = QPushButton("✕ Ingen alternativ") + btn_none.clicked.connect(self._on_clear) + btn_row.addWidget(btn_none) + btn_row.addStretch() + btn_cancel = QPushButton("Annuller") + btn_cancel.clicked.connect(self.reject) + btn_row.addWidget(btn_cancel) + btn_ok = QPushButton("✓ Vælg") + btn_ok.setObjectName("btn_play") + btn_ok.clicked.connect(self._on_accept) + btn_row.addWidget(btn_ok) + layout.addLayout(btn_row) + + self._edit.setFocus() + + def _on_text_changed(self): + self._timer.start() + + def _make_sep(self, text): + sep = QListWidgetItem(text) + sep.setForeground(QColor(MUTED)) + sep.setFlags(Qt.ItemFlag.ItemIsEnabled) + sep.setData(Qt.ItemDataRole.UserRole, None) + return sep + + def _load_suggestions(self, prefix): + try: + from local.local_db import ( + get_alt_dances_for_song_with_ratings, + get_community_alts_for_song, + get_dance_suggestions, + ) + self._list.clear() + song_id = self._song.get("id", "") + + # ── Mine egne alternativ-danse ── + own_alts = get_alt_dances_for_song_with_ratings(song_id) + own_names = {a["name"].lower() for a in own_alts} + matching_own = [a for a in own_alts + if not prefix or prefix.lower() in a["name"].lower()] + if matching_own: + self._list.addItem(self._make_sep( + f"── 🟢 Mine alternativ-danse ──" + )) + for a in matching_own: + my_r = a.get("user_rating") + my_s = make_stars(my_r) + name = a["name"] + level = a.get("level_name", "") + disp = f"{name} / {level}" if level else name + # Venstre: navn, højre: mine stjerner + label = f"🟢 {disp:<40} {my_s}" + item = QListWidgetItem() + item.setSizeHint(__import__('PyQt6.QtCore', fromlist=['QSize']).QSize(0, 34)) + item.setData(Qt.ItemDataRole.UserRole, { + "name": name, "level": level, + "choreo": a.get("choreographer", ""), + "my_rating": my_r, "comm_rating": None, + "dance_id": a["id"], "is_own": True, + }) + self._list.addItem(item) + # Widget med navn + klikbare stjerner + w = QWidget() + wl = QHBoxLayout(w) + wl.setContentsMargins(4, 0, 4, 0) + wl.setSpacing(6) + lbl_name = QLabel(f"🟢 {disp}") + lbl_name.setStyleSheet(f"color: {GREEN};") + wl.addWidget(lbl_name, stretch=1) + stars_w = StarRatingWidget(my_r, color=GREEN) + stars_w.rating_changed.connect( + lambda r, song_id=self._song.get("id",""), d_id=a["id"]: + self._save_rating(song_id, d_id, r) + ) + wl.addWidget(stars_w) + self._list.setItemWidget(item, w) + + # ── Community alternativ-danse ── + comm_alts = get_community_alts_for_song(song_id) + matching_comm = [c for c in comm_alts + if (not prefix or prefix.lower() in c["name"].lower()) + and c["name"].lower() not in own_names] + if matching_comm: + self._list.addItem(self._make_sep("── 🟡 Community ──")) + for c in matching_comm: + comm_r = c.get("avg_rating") + my_r = c.get("my_rating") + from PyQt6.QtCore import QSize + name = c["name"] + level = c.get("level_name", "") + disp = f"{name} / {level}" if level else name + item = QListWidgetItem() + item.setSizeHint(QSize(0, 34)) + item.setData(Qt.ItemDataRole.UserRole, { + "name": name, "level": level, + "choreo": c.get("choreographer", ""), + "my_rating": my_r, "comm_rating": comm_r, + "dance_id": c["id"], "is_community": True, + }) + self._list.addItem(item) + # Widget: navn + community stjerner (ikke klikbare) + mine (klikbare) + w = QWidget() + wl = QHBoxLayout(w) + wl.setContentsMargins(4, 0, 4, 0) + wl.setSpacing(6) + lbl_name = QLabel(f"🟡 {disp}") + lbl_name.setStyleSheet(f"color: {YELLOW};") + wl.addWidget(lbl_name, stretch=1) + # Community rating — read-only label + comm_lbl = QLabel(make_stars(comm_r) if comm_r else "☆☆☆☆☆") + comm_lbl.setStyleSheet(f"color: {YELLOW}; font-size: 13px;") + comm_lbl.setToolTip(f"Community: {comm_r:.1f}/5" if comm_r else "Ingen community rating") + wl.addWidget(comm_lbl) + # Min rating — klikbar + my_stars_w = StarRatingWidget(my_r, color=GREEN) + my_stars_w.rating_changed.connect( + lambda r, song_id=self._song.get("id",""), d_id=c["id"]: + self._save_rating(song_id, d_id, r) + ) + wl.addWidget(my_stars_w) + self._list.setItemWidget(item, w) + + # ── Alle danse ── + suggestions = get_dance_suggestions(prefix or "", limit=20) + if suggestions: + self._list.addItem(self._make_sep("── Alle danse ──")) + for s in suggestions: + s = dict(s) + name = s["name"] + is_own = name.lower() in own_names + is_comm = any(c["name"].lower() == name.lower() for c in comm_alts) + icon = "🟢 " if is_own else "🟡 " if is_comm else " " + color = GREEN if is_own else YELLOW if is_comm else "#eceef4" + disp = name + if s.get("level_name"): + disp += f" / {s['level_name']}" + if s.get("choreographer"): + disp += f" · {s['choreographer']}" + item = QListWidgetItem(f"{icon}{disp}") + item.setForeground(QColor(color)) + item.setData(Qt.ItemDataRole.UserRole, { + "name": name, + "level": s.get("level_name", ""), + "choreo": s.get("choreographer", ""), + "my_rating": None, "comm_rating": None, + "dance_id": s.get("id"), + }) + self._list.addItem(item) + + except Exception as e: + import logging + logging.getLogger(__name__).warning( + f"AltDancePicker fejl: {e}", exc_info=True + ) + + def _on_item_clicked(self, item): + data = item.data(Qt.ItemDataRole.UserRole) + if not data: + return + self._chosen_dance = data.get("name", "") + self._edit.setText(self._chosen_dance) + parts = [] + if data.get("level"): + parts.append(data["level"]) + if data.get("choreo"): + parts.append(data["choreo"]) + info = " · ".join(parts) + comm_r = data.get("comm_rating") + my_r = data.get("my_rating") + if comm_r: + info += f" 🟡 Community: {make_stars(comm_r)} ({comm_r:.1f})" + if my_r: + info += f" 🟢 Min: {make_stars(my_r)}" + self._info_lbl.setText(info) + + def _on_selected(self, item): + data = item.data(Qt.ItemDataRole.UserRole) + if not data: + return + self._on_item_clicked(item) + self._on_accept() + + def _on_accept(self): + self._chosen_dance = self._edit.text().strip() + self.accept() + + def _save_rating(self, song_id: str, dance_id: int, rating: int): + """Gem rating direkte fra stjerne-widget i listen.""" + try: + from local.local_db import get_db + with get_db() as conn: + conn.execute( + "UPDATE song_alt_dances SET user_rating=? WHERE song_id=? AND dance_id=?", + (rating, song_id, dance_id) + ) + except Exception as e: + import logging + logging.getLogger(__name__).warning(f"save_rating fejl: {e}") + + def _on_clear(self): + self._chosen_dance = "" + self._chosen_rating = None + self._cleared = True + self.accept() + + def get_dance(self) -> str: + return self._chosen_dance + + def get_rating(self): + return self._chosen_rating + + def was_cleared(self) -> bool: + return self._cleared \ No newline at end of file diff --git a/linedance-app/ui/bpm_worker.py b/linedance-app/ui/bpm_worker.py index 79b44f12..ea91c6ac 100644 --- a/linedance-app/ui/bpm_worker.py +++ b/linedance-app/ui/bpm_worker.py @@ -1,5 +1,6 @@ """ bpm_worker.py — QThread til BPM-analyse i baggrunden. +Ny v0.9 arkitektur: sange er i songs, filer i files, libraries i libraries. """ import sqlite3 from PyQt6.QtCore import QThread, pyqtSignal @@ -15,10 +16,10 @@ class BpmScanWorker(QThread): self._library_id = library_id self._db_path = db_path self._scan_all = scan_all + self._cancelled = False def cancel(self): self.requestInterruption() - # Afbryd hurtigt ved at sætte et flag self._cancelled = True def run(self): @@ -28,20 +29,34 @@ class BpmScanWorker(QThread): from local.tag_reader import analyze_bpm conn = sqlite3.connect(self._db_path) conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + + # Ny arkitektur: JOIN songs + files + libraries + lib_row = conn.execute( + "SELECT path FROM libraries WHERE id=?", (self._library_id,) + ).fetchone() + if not lib_row: + self.finished.emit(0) + conn.close() + return + + lib_path = lib_row["path"] if self._scan_all: - songs = conn.execute( - "SELECT id, local_path FROM songs " - "WHERE library_id=? AND file_missing=0", - (self._library_id,) - ).fetchall() + songs = conn.execute(""" + SELECT s.id, f.local_path + FROM songs s + JOIN files f ON f.song_id = s.id AND f.file_missing = 0 + WHERE f.local_path LIKE ? + """, (lib_path + "%",)).fetchall() else: - songs = conn.execute( - "SELECT id, local_path FROM songs " - "WHERE library_id=? AND file_missing=0 " - "AND (bpm IS NULL OR bpm=0)", - (self._library_id,) - ).fetchall() + songs = conn.execute(""" + SELECT s.id, f.local_path + FROM songs s + JOIN files f ON f.song_id = s.id AND f.file_missing = 0 + WHERE f.local_path LIKE ? + AND (s.bpm IS NULL OR s.bpm = 0) + """, (lib_path + "%",)).fetchall() total = len(songs) done = 0 @@ -61,9 +76,9 @@ class BpmScanWorker(QThread): pass done += 1 self.progress.emit(done, total) - time.sleep(0.01) # Yield så GUI ikke hænger + time.sleep(0.01) conn.close() self.finished.emit(done) - except Exception as e: - self.finished.emit(0) + except Exception: + self.finished.emit(0) \ No newline at end of file diff --git a/linedance-app/ui/main_window.py b/linedance-app/ui/main_window.py index e1020a65..3d4dc7b8 100644 --- a/linedance-app/ui/main_window.py +++ b/linedance-app/ui/main_window.py @@ -804,9 +804,11 @@ class MainWindow(QMainWindow): threading.Thread(target=_run, daemon=True).start() def _on_playlist_changed(self): - """Danseliste ændret — start debounce-timer til auto-sync.""" + """Danseliste ændret — start debounce-timer til auto-sync og opdater live-status.""" if hasattr(self, "_sync_debounce"): self._sync_debounce.start() + # Opdater storskærm med det samme + self._sync_event_status_to_playlist() def _auto_sync(self): """Kør sync hvis vi er online — kaldes af debounce-timer.""" diff --git a/linedance-app/ui/playlist_panel.py b/linedance-app/ui/playlist_panel.py index 4950c7f7..ddfea53a 100644 --- a/linedance-app/ui/playlist_panel.py +++ b/linedance-app/ui/playlist_panel.py @@ -463,6 +463,7 @@ class PlaylistPanel(QWidget): "file_missing": file_missing, "dances": dance_names, "active_dance": active_dance, + "alt_dance": row["alt_dance_override"] if "alt_dance_override" in row.keys() else "", "is_workshop": bool(row["is_workshop"]), }) statuses.append(row["status"] or "pending") @@ -779,6 +780,84 @@ class PlaylistPanel(QWidget): self._refresh() self._sync_dance_to_db(idx, song) + def _change_alt_dance(self, idx: int, song: dict): + """Lad brugeren vælge alternativ dans til denne sang i playlisten.""" + from ui.alt_dance_picker_dialog import AltDancePickerDialog + dlg = AltDancePickerDialog(song, parent=self.window()) + if dlg.exec(): + if dlg.was_cleared(): + chosen = "" + else: + chosen = dlg.get_dance() + rating = dlg.get_rating() + song["alt_dance"] = chosen + self._refresh() + # Gem alt_dance_override på playlist_songs + self._sync_alt_dance_to_db(idx, song, chosen) + # Gem rating hvis givet + if chosen and rating is not None: + self._save_alt_dance_rating(song, chosen, rating) + + def _sync_alt_dance_to_db(self, idx: int, song: dict, alt_dance: str): + """Gem alt_dance_override til playlist_songs — både aktiv og navngiven liste.""" + pl_ids = [] + if self._active_playlist_id: + pl_ids.append(self._active_playlist_id) + if self._named_playlist_id and self._named_playlist_id not in pl_ids: + pl_ids.append(self._named_playlist_id) + if not pl_ids: + return + try: + import logging + from local.local_db import get_db + with get_db() as conn: + for pl_id in pl_ids: + conn.execute( + "UPDATE playlist_songs SET alt_dance_override=? " + "WHERE playlist_id=? AND position=?", + (alt_dance, pl_id, idx + 1) + ) + logging.getLogger(__name__).info( + f"alt_dance_override='{alt_dance}' gemt på pos {idx+1} i {pl_id}" + ) + except Exception as e: + import logging + logging.getLogger(__name__).warning(f"alt_dance_to_db fejl: {e}", exc_info=True) + + def _save_alt_dance_rating(self, song: dict, dance_name: str, rating: int): + """Gem brugerens rating på en alternativ-dans.""" + import uuid + song_id = song.get("id", "") + try: + from local.local_db import get_db + with get_db() as conn: + # Find dance_id + dance_row = conn.execute( + "SELECT id FROM dances WHERE name=? COLLATE NOCASE LIMIT 1", + (dance_name,) + ).fetchone() + if not dance_row: + return + dance_id = dance_row["id"] + # Opdater eller indsæt rating + existing = conn.execute( + "SELECT id FROM song_alt_dances WHERE song_id=? AND dance_id=?", + (song_id, dance_id) + ).fetchone() + if existing: + conn.execute( + "UPDATE song_alt_dances SET user_rating=? WHERE song_id=? AND dance_id=?", + (rating, song_id, dance_id) + ) + else: + conn.execute( + "INSERT INTO song_alt_dances (id, song_id, dance_id, user_rating) VALUES (?,?,?,?)", + (str(uuid.uuid4()), song_id, dance_id, rating) + ) + except Exception as e: + import logging + logging.getLogger(__name__).warning(f"save_alt_dance_rating fejl: {e}") + def _sync_dance_to_db(self, idx: int, song: dict): """Gem dance_override til playlist_songs (midlertidigt valg).""" import logging @@ -1169,6 +1248,7 @@ class PlaylistPanel(QWidget): act_played = menu.addAction("✓ Sæt til afspillet") menu.addSeparator() act_dance = menu.addAction("💃 Vælg dans...") + act_alt_dance = menu.addAction("💃 Vælg alternativ dans...") is_ws = song.get("is_workshop", False) if song else False act_ws = menu.addAction("🎓 Fjern workshop" if is_ws else "🎓 Markér som workshop") menu.addSeparator() @@ -1199,6 +1279,8 @@ class PlaylistPanel(QWidget): self._refresh(); self._trigger_autosave(); self._trigger_event_state_save() elif action == act_dance and song: self._change_dance(idx, song) + elif action == act_alt_dance and song: + self._change_alt_dance(idx, song) elif action == act_ws and song: song["is_workshop"] = not song.get("is_workshop", False) self._sync_ws_to_db(idx, song) @@ -1357,6 +1439,7 @@ class PlaylistPanel(QWidget): if not active: dances = song.get("dances", []) active = dances[0] if dances else "— ingen dans —" + alt = song.get("alt_dance", "") ws_tag = " 🎓" if song.get("is_workshop") else "" # Tilgængeligheds-dot til højre — kun hvis tjekket (ikke yellow) @@ -1364,7 +1447,11 @@ class PlaylistPanel(QWidget): avail_color = {"green": "#27ae60", "red": "#e74c3c"}.get(avail, None) avail_tip = {"green": "Tilgængelig lokalt", "red": "Ikke fundet lokalt"}.get(avail, "") - text = (f"{i+1:>2}. {active}{ws_tag}\n" + dance_line = f"{active}{ws_tag}" + if alt: + dance_line += f" / {alt}" + + text = (f"{i+1:>2}. {dance_line}\n" f" {song.get('title','—')} · {song.get('artist','')}") item = QListWidgetItem(f"{icon} {text}") item.setData(Qt.ItemDataRole.UserRole, i)