Sync alternativer
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
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]
|
||||
@@ -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", "")
|
||||
|
||||
345
linedance-app/ui/alt_dance_picker_dialog.py
Normal file
345
linedance-app/ui/alt_dance_picker_dialog.py
Normal 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
|
||||
@@ -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)
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user