Sync alternativer

This commit is contained in:
2026-04-22 10:00:12 +02:00
parent 37b49c1fed
commit b695a4858b
9 changed files with 778 additions and 43 deletions

View File

@@ -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

View File

@@ -10,6 +10,7 @@ from datetime import datetime, timezone
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional
from app.core.database import get_db from app.core.database import get_db
from app.core.security import get_current_user from app.core.security import get_current_user
@@ -53,6 +54,7 @@ class SongAltDanceData(BaseModel):
dance_name: str dance_name: str
level_name: str = "" level_name: str = ""
note: str = "" note: str = ""
user_rating: Optional[int] = None
class PlaylistSongData(BaseModel): class PlaylistSongData(BaseModel):
song_local_id: str song_local_id: str
@@ -224,6 +226,47 @@ def push(
dance_id = dance_id_map.get(key) dance_id = dance_id_map.get(key)
if not dance_id: if not dance_id:
continue 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( db.execute(_sa.text(
"INSERT IGNORE INTO song_alt_dances (id, song_id, dance_id, note) " "INSERT IGNORE INTO song_alt_dances (id, song_id, dance_id, note) "
"VALUES (:id, :song_id, :dance_id, :note)" "VALUES (:id, :song_id, :dance_id, :note)"
@@ -401,10 +444,30 @@ def pull(
"dance_order": sd.dance_order, "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 { return {
"levels": levels, "levels": levels,
"dances": dances, "dances": dances,
"shared": shared, "shared": shared,
"my_playlists": my_playlists, "my_playlists": my_playlists,
"song_tags": song_tags, "song_tags": song_tags,
"community_alts": community_alts,
} }

View File

@@ -219,11 +219,17 @@ def run_acoustid_scan(db_path: str, api_key: str = "", on_progress=None, stop_ev
(acoustid or None, row["id"]) (acoustid or None, row["id"])
) )
if mbid: if mbid:
conn.execute( try:
"UPDATE songs SET mbid=? WHERE id=? AND (mbid IS NULL OR mbid='')", conn.execute(
(mbid, row["id"]) "UPDATE songs SET mbid=? WHERE id=? AND (mbid IS NULL OR mbid='')",
) (mbid, row["id"])
conn.commit() )
conn.commit()
except Exception:
conn.rollback()
logger.debug(f"MBID {mbid[:8]} allerede i brug — springer over")
else:
conn.commit()
found += 1 found += 1
total_found += 1 total_found += 1
logger.info( logger.info(

View File

@@ -113,12 +113,13 @@ CREATE TABLE IF NOT EXISTS song_dances (
-- Alternativ-dans tags -- Alternativ-dans tags
CREATE TABLE IF NOT EXISTS song_alt_dances ( CREATE TABLE IF NOT EXISTS song_alt_dances (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
song_id TEXT NOT NULL REFERENCES songs(id) ON DELETE CASCADE, song_id TEXT NOT NULL REFERENCES songs(id) ON DELETE CASCADE,
dance_id INTEGER NOT NULL REFERENCES dances(id), dance_id INTEGER NOT NULL REFERENCES dances(id),
note TEXT NOT NULL DEFAULT '', note TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT 'local', user_rating INTEGER, -- 1-5 stjerner, NULL = ikke vurderet
created_at TEXT NOT NULL DEFAULT (datetime('now')), source TEXT NOT NULL DEFAULT 'local',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(song_id, dance_id) UNIQUE(song_id, dance_id)
); );
@@ -140,14 +141,15 @@ CREATE TABLE IF NOT EXISTS playlists (
-- Playliste-sange -- Playliste-sange
CREATE TABLE IF NOT EXISTS playlist_songs ( CREATE TABLE IF NOT EXISTS playlist_songs (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
song_id TEXT NOT NULL REFERENCES songs(id), song_id TEXT NOT NULL REFERENCES songs(id),
file_id TEXT REFERENCES files(id), file_id TEXT REFERENCES files(id),
position INTEGER NOT NULL, position INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending', status TEXT NOT NULL DEFAULT 'pending',
is_workshop INTEGER NOT NULL DEFAULT 0, is_workshop INTEGER NOT NULL DEFAULT 0,
dance_override TEXT NOT NULL DEFAULT '' 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); 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 (?,?,?)", "INSERT INTO dances (name, level_id, choreographer) VALUES (?,?,?)",
(name, level_id, choreo) (name, level_id, choreo)
) )
return cur.lastrowid 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]

View File

@@ -189,7 +189,7 @@ class SyncManager:
song_alts = [] song_alts = []
for row in conn.execute(""" for row in conn.execute("""
SELECT sad.song_id, d.name as dance_name, 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 FROM song_alt_dances sad
JOIN dances d ON d.id = sad.dance_id JOIN dances d ON d.id = sad.dance_id
LEFT JOIN dance_levels dl ON dl.id = d.level_id LEFT JOIN dance_levels dl ON dl.id = d.level_id
@@ -199,6 +199,7 @@ class SyncManager:
"dance_name": row["dance_name"], "dance_name": row["dance_name"],
"level_name": row["level_name"] or "", "level_name": row["level_name"] or "",
"note": row["note"] or "", "note": row["note"] or "",
"user_rating": row["user_rating"],
}) })
# Playlister — alle ikke-slettede # Playlister — alle ikke-slettede
@@ -480,6 +481,54 @@ class SyncManager:
song_data.get("dance_override","") or "")) song_data.get("dance_override","") or ""))
position += 1 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 # Importer sang-dans tags fra server
for st in data.get("song_tags", []): for st in data.get("song_tags", []):
server_song_id = st.get("song_id", "") server_song_id = st.get("song_id", "")

View File

@@ -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

View File

@@ -1,5 +1,6 @@
""" """
bpm_worker.py — QThread til BPM-analyse i baggrunden. 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 import sqlite3
from PyQt6.QtCore import QThread, pyqtSignal from PyQt6.QtCore import QThread, pyqtSignal
@@ -15,10 +16,10 @@ class BpmScanWorker(QThread):
self._library_id = library_id self._library_id = library_id
self._db_path = db_path self._db_path = db_path
self._scan_all = scan_all self._scan_all = scan_all
self._cancelled = False
def cancel(self): def cancel(self):
self.requestInterruption() self.requestInterruption()
# Afbryd hurtigt ved at sætte et flag
self._cancelled = True self._cancelled = True
def run(self): def run(self):
@@ -28,20 +29,34 @@ class BpmScanWorker(QThread):
from local.tag_reader import analyze_bpm from local.tag_reader import analyze_bpm
conn = sqlite3.connect(self._db_path) conn = sqlite3.connect(self._db_path)
conn.row_factory = sqlite3.Row 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: if self._scan_all:
songs = conn.execute( songs = conn.execute("""
"SELECT id, local_path FROM songs " SELECT s.id, f.local_path
"WHERE library_id=? AND file_missing=0", FROM songs s
(self._library_id,) JOIN files f ON f.song_id = s.id AND f.file_missing = 0
).fetchall() WHERE f.local_path LIKE ?
""", (lib_path + "%",)).fetchall()
else: else:
songs = conn.execute( songs = conn.execute("""
"SELECT id, local_path FROM songs " SELECT s.id, f.local_path
"WHERE library_id=? AND file_missing=0 " FROM songs s
"AND (bpm IS NULL OR bpm=0)", JOIN files f ON f.song_id = s.id AND f.file_missing = 0
(self._library_id,) WHERE f.local_path LIKE ?
).fetchall() AND (s.bpm IS NULL OR s.bpm = 0)
""", (lib_path + "%",)).fetchall()
total = len(songs) total = len(songs)
done = 0 done = 0
@@ -61,9 +76,9 @@ class BpmScanWorker(QThread):
pass pass
done += 1 done += 1
self.progress.emit(done, total) self.progress.emit(done, total)
time.sleep(0.01) # Yield så GUI ikke hænger time.sleep(0.01)
conn.close() conn.close()
self.finished.emit(done) self.finished.emit(done)
except Exception as e: except Exception:
self.finished.emit(0) self.finished.emit(0)

View File

@@ -804,9 +804,11 @@ class MainWindow(QMainWindow):
threading.Thread(target=_run, daemon=True).start() threading.Thread(target=_run, daemon=True).start()
def _on_playlist_changed(self): 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"): if hasattr(self, "_sync_debounce"):
self._sync_debounce.start() self._sync_debounce.start()
# Opdater storskærm med det samme
self._sync_event_status_to_playlist()
def _auto_sync(self): def _auto_sync(self):
"""Kør sync hvis vi er online — kaldes af debounce-timer.""" """Kør sync hvis vi er online — kaldes af debounce-timer."""

View File

@@ -463,6 +463,7 @@ class PlaylistPanel(QWidget):
"file_missing": file_missing, "file_missing": file_missing,
"dances": dance_names, "dances": dance_names,
"active_dance": active_dance, "active_dance": active_dance,
"alt_dance": row["alt_dance_override"] if "alt_dance_override" in row.keys() else "",
"is_workshop": bool(row["is_workshop"]), "is_workshop": bool(row["is_workshop"]),
}) })
statuses.append(row["status"] or "pending") statuses.append(row["status"] or "pending")
@@ -779,6 +780,84 @@ class PlaylistPanel(QWidget):
self._refresh() self._refresh()
self._sync_dance_to_db(idx, song) 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): def _sync_dance_to_db(self, idx: int, song: dict):
"""Gem dance_override til playlist_songs (midlertidigt valg).""" """Gem dance_override til playlist_songs (midlertidigt valg)."""
import logging import logging
@@ -1169,6 +1248,7 @@ class PlaylistPanel(QWidget):
act_played = menu.addAction("✓ Sæt til afspillet") act_played = menu.addAction("✓ Sæt til afspillet")
menu.addSeparator() menu.addSeparator()
act_dance = menu.addAction("💃 Vælg dans...") 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 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") act_ws = menu.addAction("🎓 Fjern workshop" if is_ws else "🎓 Markér som workshop")
menu.addSeparator() menu.addSeparator()
@@ -1199,6 +1279,8 @@ class PlaylistPanel(QWidget):
self._refresh(); self._trigger_autosave(); self._trigger_event_state_save() self._refresh(); self._trigger_autosave(); self._trigger_event_state_save()
elif action == act_dance and song: elif action == act_dance and song:
self._change_dance(idx, 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: elif action == act_ws and song:
song["is_workshop"] = not song.get("is_workshop", False) song["is_workshop"] = not song.get("is_workshop", False)
self._sync_ws_to_db(idx, song) self._sync_ws_to_db(idx, song)
@@ -1357,6 +1439,7 @@ class PlaylistPanel(QWidget):
if not active: if not active:
dances = song.get("dances", []) dances = song.get("dances", [])
active = dances[0] if dances else "— ingen dans —" active = dances[0] if dances else "— ingen dans —"
alt = song.get("alt_dance", "")
ws_tag = " 🎓" if song.get("is_workshop") else "" ws_tag = " 🎓" if song.get("is_workshop") else ""
# Tilgængeligheds-dot til højre — kun hvis tjekket (ikke yellow) # 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_color = {"green": "#27ae60", "red": "#e74c3c"}.get(avail, None)
avail_tip = {"green": "Tilgængelig lokalt", "red": "Ikke fundet lokalt"}.get(avail, "") 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','')}") f" {song.get('title','')} · {song.get('artist','')}")
item = QListWidgetItem(f"{icon} {text}") item = QListWidgetItem(f"{icon} {text}")
item.setData(Qt.ItemDataRole.UserRole, i) item.setData(Qt.ItemDataRole.UserRole, i)