En del opdateringer

This commit is contained in:
2026-04-19 00:58:48 +02:00
parent efe3739626
commit e4ab9caab6
14 changed files with 3412 additions and 189 deletions

View File

@@ -20,7 +20,7 @@ logger = logging.getLogger(__name__)
# AcoustID API nøgle — kan overskrives i Indstillinger → Afspilning
# Registrér din egen på https://acoustid.org/new-application
ACOUSTID_API_KEY = "71W9SJdajAI"
ACOUSTID_API_KEY = "6fd9DGNDqG"
ACOUSTID_API_URL = "https://api.acoustid.org/v2/lookup"
# Pause mellem API-kald — rolig baggrundskørsel
@@ -154,7 +154,8 @@ def run_acoustid_scan(db_path: str, api_key: str = "", on_progress=None, stop_ev
logger.info("AcoustID: stoppet af bruger")
break
conn = sqlite3.connect(db_path)
conn = sqlite3.connect(db_path, timeout=10)
conn.execute("PRAGMA journal_mode=WAL")
conn.row_factory = sqlite3.Row
rows = conn.execute("""
@@ -181,7 +182,8 @@ def run_acoustid_scan(db_path: str, api_key: str = "", on_progress=None, stop_ev
found = 0
logger.info(f"AcoustID: batch {batch_num}{total} sange")
conn = sqlite3.connect(db_path)
conn = sqlite3.connect(db_path, timeout=10)
conn.execute("PRAGMA journal_mode=WAL")
conn.row_factory = sqlite3.Row
for row in rows:

View File

@@ -35,9 +35,10 @@ def _get_conn() -> sqlite3.Connection:
def new_conn() -> sqlite3.Connection:
"""Åbn en frisk forbindelse til brug i tag_editor og dialogs."""
conn = sqlite3.connect(str(DB_PATH), check_same_thread=False)
conn = sqlite3.connect(str(DB_PATH), check_same_thread=False, timeout=10)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys=OFF") # FK checker forhindrer level_id gem
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=OFF")
return conn
@@ -186,11 +187,16 @@ def init_db():
count = conn.execute("SELECT COUNT(*) FROM dance_levels").fetchone()[0]
if count == 0:
defaults = [
(1, "Begynder", "Passer til alle"),
(2, "Let øvet", "Lidt erfaring kræves"),
(3, "Øvet", "Kræver regelmæssig træning"),
(4, "Erfaren", "For dedikerede dansere"),
(5, "Ekspert", "Konkurrenceniveau"),
(10, "Absolute Beginner", "Ingen tidligere danse-erfaring kræves"),
(20, "Beginner", "Lidt tidligere erfaring"),
(30, "High Beginner", "God begynder, klar til mere"),
(40, "Low Improver", "Begyndende øvet"),
(50, "Improver", "Grundlæggende færdigheder på plads"),
(60, "High Improver", "Stærk øvet, næsten intermediate"),
(70, "Low Intermediate", "Begyndende intermediate"),
(80, "Intermediate", "Erfaren danser"),
(90, "High Intermediate", "Stærk intermediate"),
(99, "Advanced", "Fuld beherskelse af trin og teknik"),
]
for row in defaults:
conn.execute(
@@ -261,6 +267,40 @@ MIGRATIONS: dict[int, list[str]] = {
"""ALTER TABLE songs ADD COLUMN mbid TEXT""",
"""ALTER TABLE songs ADD COLUMN acoustid TEXT""",
],
9: [
# Opdater niveau-navne til korrekte betegnelser i rigtig rækkefølge
"DELETE FROM dance_levels",
"INSERT INTO dance_levels (sort_order, name, description) VALUES (10, 'Absolute Beginner', 'Ingen tidligere danse-erfaring kræves')",
"INSERT INTO dance_levels (sort_order, name, description) VALUES (20, 'Beginner', 'Lidt tidligere erfaring')",
"INSERT INTO dance_levels (sort_order, name, description) VALUES (30, 'High Beginner', 'God begynder, klar til mere')",
"INSERT INTO dance_levels (sort_order, name, description) VALUES (40, 'Low Improver', 'Begyndende øvet')",
"INSERT INTO dance_levels (sort_order, name, description) VALUES (50, 'Improver', 'Grundlæggende færdigheder på plads')",
"INSERT INTO dance_levels (sort_order, name, description) VALUES (60, 'High Improver', 'Stærk øvet, næsten intermediate')",
"INSERT INTO dance_levels (sort_order, name, description) VALUES (70, 'Low Intermediate', 'Begyndende intermediate')",
"INSERT INTO dance_levels (sort_order, name, description) VALUES (80, 'Intermediate', 'Erfaren danser')",
"INSERT INTO dance_levels (sort_order, name, description) VALUES (90, 'High Intermediate', 'Stærk intermediate')",
"INSERT INTO dance_levels (sort_order, name, description) VALUES (99, 'Advanced', 'Fuld beherskelse af trin og teknik')",
],
10: [
# Ret stavefejl i eksisterende data
"UPDATE dance_levels SET name='Low Intermediate' WHERE name='Low Intermidiate' OR name='Low Intermidiat'",
"UPDATE dance_levels SET name='Intermediate' WHERE name='Intermidiate' OR name='Intermidate'",
"UPDATE dance_levels SET name='High Intermediate' WHERE name='High Intermidiate' OR name='High Intermidiat'",
],
11: [
# Genopret dance_levels med korrekte navne og rækkefølge
"DELETE FROM dance_levels",
"INSERT INTO dance_levels (sort_order, name, description) VALUES (10, 'Absolute Beginner', 'Ingen tidligere danse-erfaring kræves')",
"INSERT INTO dance_levels (sort_order, name, description) VALUES (20, 'Beginner', 'Lidt tidligere erfaring')",
"INSERT INTO dance_levels (sort_order, name, description) VALUES (30, 'High Beginner', 'God begynder, klar til mere')",
"INSERT INTO dance_levels (sort_order, name, description) VALUES (40, 'Low Improver', 'Begyndende øvet')",
"INSERT INTO dance_levels (sort_order, name, description) VALUES (50, 'Improver', 'Grundlæggende færdigheder på plads')",
"INSERT INTO dance_levels (sort_order, name, description) VALUES (60, 'High Improver', 'Stærk øvet, næsten intermediate')",
"INSERT INTO dance_levels (sort_order, name, description) VALUES (70, 'Low Intermediate', 'Begyndende intermediate')",
"INSERT INTO dance_levels (sort_order, name, description) VALUES (80, 'Intermediate', 'Erfaren danser')",
"INSERT INTO dance_levels (sort_order, name, description) VALUES (90, 'High Intermediate', 'Stærk intermediate')",
"INSERT INTO dance_levels (sort_order, name, description) VALUES (99, 'Advanced', 'Fuld beherskelse af trin og teknik')",
],
}
@@ -442,24 +482,31 @@ def get_song_by_path(local_path: str) -> sqlite3.Row | None:
def search_songs(query: str, limit: int = 50) -> list[sqlite3.Row]:
"""Søg i alle tags — titel, artist, album, danse og alle øvrige tags."""
"""Søg i titel, artist, album, dans, koreograf, niveau og øvrige tags."""
import logging as _log
_log.getLogger(__name__).info(f"search_songs: '{query}'")
pattern = f"%{query}%"
with get_db() as conn:
return conn.execute("""
rows = conn.execute("""
SELECT DISTINCT s.* FROM songs s
LEFT JOIN song_dances sd ON sd.song_id = s.id
LEFT JOIN dances d ON d.id = sd.dance_id
LEFT JOIN dance_levels dl ON dl.id = d.level_id
WHERE s.file_missing = 0
AND (
s.title LIKE ? OR
s.artist LIKE ? OR
s.album LIKE ? OR
d.name LIKE ? OR
s.extra_tags LIKE ?
s.title LIKE ? OR
s.artist LIKE ? OR
s.album LIKE ? OR
d.name LIKE ? OR
d.choreographer LIKE ? OR
dl.name LIKE ? OR
s.extra_tags LIKE ?
)
ORDER BY s.artist, s.title
LIMIT ?
""", (pattern, pattern, pattern, pattern, pattern, limit)).fetchall()
""", (pattern,)*7 + (limit,)).fetchall()
_log.getLogger(__name__).info(f"search_songs: '{query}'{len(rows)} resultater")
return rows
def get_songs_for_library(library_id: int) -> list[sqlite3.Row]:
@@ -672,10 +719,11 @@ def update_dance_info(dance_id: int, choreographer: str = "",
def get_or_create_dance(name: str, level_id: int | None,
conn=None) -> int:
conn=None, choreographer: str = "") -> int:
"""Find eller opret en dans (name + level_id kombination).
Returnerer dance_id. conn er valgfri — bruges ved nested kald."""
name = name.strip()
name = name.strip()
choreo = choreographer.strip()
close = False
if conn is None:
conn = new_conn()
@@ -687,13 +735,15 @@ def get_or_create_dance(name: str, level_id: int | None,
).fetchone()
if existing:
conn.execute(
"UPDATE dances SET use_count=use_count+1 WHERE id=?",
(existing["id"],)
"UPDATE dances SET use_count=use_count+1"
+ (", choreographer=?" if choreo else "") +
" WHERE id=?",
((choreo, existing["id"]) if choreo else (existing["id"],))
)
return existing["id"]
conn.execute(
"INSERT INTO dances (name, level_id, use_count, source) VALUES (?,?,1,'local')",
(name, level_id)
"INSERT INTO dances (name, level_id, choreographer, use_count, source) VALUES (?,?,?,1,'local')",
(name, level_id, choreo)
)
return conn.execute(
"SELECT id FROM dances WHERE name=? COLLATE NOCASE AND level_id IS ?",
@@ -705,19 +755,34 @@ def get_or_create_dance(name: str, level_id: int | None,
conn.close()
def get_choreographer_suggestions(prefix: str, limit: int = 20) -> list[str]:
"""Returnerer koreografer der starter med prefix, sorteret alfabetisk."""
with get_db() as conn:
rows = conn.execute("""
SELECT DISTINCT choreographer
FROM dances
WHERE choreographer LIKE ? COLLATE NOCASE
AND choreographer != ''
ORDER BY choreographer
LIMIT ?
""", (f"{prefix}%", limit)).fetchall()
return [r["choreographer"] for r in rows]
def get_dance_suggestions(prefix: str, limit: int = 20) -> list[dict]:
"""Returnerer danse der starter med prefix som {id, name, level_id, level_name}.
"""Returnerer danse der matcher prefix i navn ELLER koreograf.
Sorteret efter popularitet — bruges til autoudfyld."""
with get_db() as conn:
rows = conn.execute("""
SELECT d.id, d.name, d.level_id, d.use_count,
SELECT d.id, d.name, d.level_id, d.use_count, d.choreographer,
dl.name as level_name, dl.sort_order
FROM dances d
LEFT JOIN dance_levels dl ON dl.id = d.level_id
WHERE d.name LIKE ? COLLATE NOCASE
OR d.choreographer LIKE ? COLLATE NOCASE
ORDER BY d.use_count DESC, dl.sort_order, d.name
LIMIT ?
""", (f"{prefix}%", limit)).fetchall()
""", (f"%{prefix}%", f"%{prefix}%", limit)).fetchall()
return [dict(r) for r in rows]
@@ -725,7 +790,7 @@ def get_dances_for_song(song_id: str) -> list[dict]:
"""Hent hoveddanse for en sang med niveau-info og workshop-flag."""
with get_db() as conn:
rows = conn.execute("""
SELECT d.id as dance_id, d.name, d.level_id,
SELECT d.id as dance_id, d.name, d.level_id, d.choreographer,
dl.name as level_name, sd.dance_order,
sd.id as song_dance_id, sd.is_workshop
FROM song_dances sd

View File

@@ -224,7 +224,7 @@ def _read_vorbis(audio, result: dict):
result["dances"] = [d.strip() for d in tags[VORBIS_DANCE_KEY][0].split(",") if d.strip()]
else:
result["dances"] = [dances[k] for k in sorted(dances.keys())]
# Alle øvrige tags som extra_tags
# Øvrige tags
skip = {"title", "artist", "album", "bpm", VORBIS_DANCE_KEY}
extra = {}
for key, values in tags.items():
@@ -243,6 +243,10 @@ def _read_vorbis(audio, result: dict):
except Exception:
pass
break
def _read_m4a(audio, result: dict):
"""M4A/AAC/MP4 — iTunes atoms."""
tags = audio.tags
if not tags:
return
@@ -262,7 +266,6 @@ def _read_vorbis(audio, result: dict):
v.decode("utf-8") if isinstance(v, (bytes, MP4FreeForm)) else str(v)
for v in tags[M4A_DANCE_FREEFORM]
]
# Menneskelige navne til M4A-nøgler
M4A_NAMES = {
"\xa9nam": "titel", "\xa9ART": "artist", "\xa9alb": "album",
"\xa9day": "år", "\xa9gen": "genre", "\xa9wrt": "komponist",
@@ -284,7 +287,7 @@ def _read_vorbis(audio, result: dict):
except Exception:
pass
result["extra_tags"] = extra
# MBID — gemmes som freeform atom ----:com.apple.iTunes:MusicBrainz Recording Id
# MBID
for key in tags:
if "musicbrainz" in key.lower() and "recording" in key.lower():
try:
@@ -295,14 +298,35 @@ def _read_vorbis(audio, result: dict):
except Exception:
pass
break
def _read_generic(audio, result: dict):
"""Generisk læsning for WMA, AIFF og andre formater via easy tags."""
try:
easy = MutagenFile(result["local_path"], easy=True)
if easy and easy.tags:
result["title"] = easy.tags.get("title", [result["title"]])[0]
result["artist"] = easy.tags.get("artist", [""])[0]
result["album"] = easy.tags.get("album", [""])[0]
from mutagen import File as MutagenFileEasy
local_path = result.get("local_path", "")
if local_path:
easy = MutagenFileEasy(local_path, easy=True)
if easy and easy.tags:
result["title"] = easy.tags.get("title", [result["title"]])[0]
result["artist"] = easy.tags.get("artist", [""])[0]
result["album"] = easy.tags.get("album", [""])[0]
except Exception:
pass
# Fallback: læs direkte fra audio-objektet
try:
tags = audio.tags
if hasattr(tags, "items"):
for key, val in tags.items():
k = str(key).lower()
v = str(val[0]) if hasattr(val, "__iter__") and not isinstance(val, str) else str(val)
if "title" in k and not result["title"]:
result["title"] = v
elif "artist" in k and not result["artist"]:
result["artist"] = v
elif "album" in k and not result["album"]:
result["album"] = v
except Exception:
pass
# ── Skrivning ─────────────────────────────────────────────────────────────────

View File

@@ -8,6 +8,8 @@ Start:
import sys
import os
APP_VERSION = "0.8.2"
sys.path.insert(0, os.path.dirname(__file__))
from app_logger import setup_logging

View File

@@ -1,5 +1,5 @@
"""
dance_picker_dialog.py — Dialog til at vælge eller skrive en dans med autoudfyld.
dance_picker_dialog.py — Dialog til at vælge dans og koreograf med autoudfyld.
"""
from PyQt6.QtWidgets import (
@@ -10,16 +10,18 @@ from PyQt6.QtCore import Qt, QTimer
class DancePickerDialog(QDialog):
def __init__(self, current_dance: str = "", song_title: str = "", parent=None):
def __init__(self, current_dance: str = "", current_choreo: str = "",
song_title: str = "", parent=None):
super().__init__(parent)
self._chosen = current_dance
self._chosen_dance = current_dance
self._chosen_choreo = current_choreo
self.setWindowTitle("Vælg dans")
self.setMinimumWidth(380)
self.setFixedWidth(420)
self._build_ui(current_dance, song_title)
self._load_suggestions("")
self.setMinimumWidth(400)
self.setFixedWidth(440)
self._build_ui(current_dance, current_choreo, song_title)
self._load_dance_suggestions("")
def _build_ui(self, current_dance: str, song_title: str):
def _build_ui(self, current_dance: str, current_choreo: str, song_title: str):
layout = QVBoxLayout(self)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(8)
@@ -30,37 +32,65 @@ class DancePickerDialog(QDialog):
lbl.setWordWrap(True)
layout.addWidget(lbl)
lbl2 = QLabel("Vælg eller skriv dans-navn:")
# ── Dans ──────────────────────────────────────────────────────────────
lbl2 = QLabel("Dans:")
lbl2.setObjectName("track_meta")
layout.addWidget(lbl2)
# Søgefelt med autoudfyld
self._edit = QLineEdit()
self._edit.setText(current_dance)
self._edit.setPlaceholderText("Skriv dans-navn...")
self._edit.selectAll()
self._edit.textChanged.connect(self._on_text_changed)
self._edit.returnPressed.connect(self._on_accept)
layout.addWidget(self._edit)
self._edit_dance = QLineEdit()
self._edit_dance.setText(current_dance)
self._edit_dance.setPlaceholderText("Skriv dans-navn...")
self._edit_dance.selectAll()
self._edit_dance.textChanged.connect(self._on_dance_text_changed)
self._edit_dance.returnPressed.connect(lambda: self._edit_choreo.setFocus())
layout.addWidget(self._edit_dance)
# Liste med forslag
self._suggestion_list = QListWidget()
self._suggestion_list.setMaximumHeight(180)
self._suggestion_list.itemDoubleClicked.connect(self._on_item_selected)
self._suggestion_list.itemClicked.connect(
lambda item: self._edit.setText(item.text())
self._dance_list = QListWidget()
self._dance_list.setMaximumHeight(160)
self._dance_list.itemDoubleClicked.connect(self._on_dance_selected)
self._dance_list.itemClicked.connect(
lambda item: self._edit_dance.setText(
item.data(Qt.ItemDataRole.UserRole) or item.text().split(" / ")[0]
)
)
layout.addWidget(self._suggestion_list)
layout.addWidget(self._dance_list)
# Debounce timer
self._timer = QTimer(self)
self._timer.setSingleShot(True)
self._timer.setInterval(200)
self._timer.timeout.connect(
lambda: self._load_suggestions(self._edit.text().strip())
# ── Koreograf ─────────────────────────────────────────────────────────
lbl3 = QLabel("Koreograf (valgfri):")
lbl3.setObjectName("track_meta")
layout.addWidget(lbl3)
self._edit_choreo = QLineEdit()
self._edit_choreo.setText(current_choreo)
self._edit_choreo.setPlaceholderText("Koreografens navn...")
self._edit_choreo.textChanged.connect(self._on_choreo_text_changed)
self._edit_choreo.returnPressed.connect(self._on_accept)
layout.addWidget(self._edit_choreo)
self._choreo_list = QListWidget()
self._choreo_list.setMaximumHeight(100)
self._choreo_list.itemDoubleClicked.connect(self._on_choreo_selected)
self._choreo_list.itemClicked.connect(
lambda item: self._edit_choreo.setText(item.text())
)
layout.addWidget(self._choreo_list)
# ── Debounce timere ───────────────────────────────────────────────────
self._dance_timer = QTimer(self)
self._dance_timer.setSingleShot(True)
self._dance_timer.setInterval(200)
self._dance_timer.timeout.connect(
lambda: self._load_dance_suggestions(self._edit_dance.text().strip())
)
# Knapper
self._choreo_timer = QTimer(self)
self._choreo_timer.setSingleShot(True)
self._choreo_timer.setInterval(200)
self._choreo_timer.timeout.connect(
lambda: self._load_choreo_suggestions(self._edit_choreo.text().strip())
)
# ── Knapper ───────────────────────────────────────────────────────────
btn_row = QHBoxLayout()
btn_row.addStretch()
btn_cancel = QPushButton("Annuller")
@@ -72,34 +102,62 @@ class DancePickerDialog(QDialog):
btn_row.addWidget(btn_ok)
layout.addLayout(btn_row)
self._edit.setFocus()
self._edit_dance.setFocus()
def _on_text_changed(self, text: str):
self._timer.start()
def _on_dance_text_changed(self):
self._dance_timer.start()
def _load_suggestions(self, prefix: str):
def _on_choreo_text_changed(self):
self._choreo_timer.start()
def _load_dance_suggestions(self, prefix: str):
try:
from local.local_db import get_dance_suggestions
suggestions = get_dance_suggestions(prefix or "", limit=20)
self._suggestion_list.clear()
self._dance_list.clear()
for s in suggestions:
label = f"{s['name']} / {s['level_name']}" if s.get("level_name") else s["name"]
if s.get("choreographer"):
label += f" ({s['choreographer']})"
item = QListWidgetItem(label)
item.setData(Qt.ItemDataRole.UserRole, s["name"])
self._suggestion_list.addItem(item)
item.setData(Qt.ItemDataRole.UserRole + 1, s.get("choreographer", ""))
self._dance_list.addItem(item)
except Exception:
pass
def _on_item_selected(self, item: QListWidgetItem):
name = item.data(Qt.ItemDataRole.UserRole) or item.text().split(" / ")[0]
self._edit.setText(name)
self._chosen = name
def _load_choreo_suggestions(self, prefix: str):
try:
from local.local_db import get_choreographer_suggestions
suggestions = get_choreographer_suggestions(prefix or "", limit=15)
self._choreo_list.clear()
for name in suggestions:
self._choreo_list.addItem(QListWidgetItem(name))
except Exception:
pass
def _on_dance_selected(self, item: QListWidgetItem):
name = item.data(Qt.ItemDataRole.UserRole) or item.text().split(" / ")[0]
choreo = item.data(Qt.ItemDataRole.UserRole + 1) or ""
self._edit_dance.setText(name)
if choreo and not self._edit_choreo.text().strip():
self._edit_choreo.setText(choreo)
self._chosen_dance = name
self._chosen_choreo = self._edit_choreo.text().strip()
self.accept()
def _on_choreo_selected(self, item: QListWidgetItem):
self._edit_choreo.setText(item.text())
self._choreo_list.clear()
def _on_accept(self):
self._chosen = self._edit.text().strip()
if self._chosen:
self._chosen_dance = self._edit_dance.text().strip()
self._chosen_choreo = self._edit_choreo.text().strip()
if self._chosen_dance:
self.accept()
def get_dance(self) -> str:
return self._chosen
return self._chosen_dance
def get_choreo(self) -> str:
return self._chosen_choreo

View File

@@ -292,12 +292,16 @@ class LibraryPanel(QWidget):
def _matches(self, song: dict, q: str, incl_alt: bool = False) -> bool:
fields = [
song.get("title", ""), song.get("artist", ""),
song.get("album", ""), song.get("file_format", ""),
] + song.get("dances", [])
song.get("title", ""),
song.get("artist", ""),
song.get("album", ""),
song.get("file_format", ""),
] + song.get("dances", []) \
+ song.get("dance_choreographers", []) \
+ song.get("dance_levels", [])
if incl_alt:
fields += song.get("alt_dances", [])
return any(q in f.lower() for f in fields)
return any(q in f.lower() for f in fields if f)
def _render(self):
self._list.clear()

View File

@@ -162,6 +162,15 @@ class MainWindow(QMainWindow):
act_quit.triggered.connect(self.close)
file_menu.addAction(act_quit)
# ── Danse ─────────────────────────────────────────────────────────────
dance_menu = menubar.addMenu("Danse")
act_new_dance = QAction("Opret dans...", self)
act_new_dance.setShortcut("Ctrl+D")
act_new_dance.setToolTip("Opret en dans i databasen uden at knytte den til musik")
act_new_dance.triggered.connect(self._create_dance_dialog)
dance_menu.addAction(act_new_dance)
# ── Ingen Danseliste- eller Visning-menu ──────────────────────────────
# Ny/Gem/Hent ligger direkte i danseliste-panelet
# Tema-skift ligger i topbar-knappen
@@ -178,6 +187,16 @@ class MainWindow(QMainWindow):
self.setStatusBar(self._statusbar)
self._statusbar.showMessage("Klar")
# Versionsnummer permanent til højre
try:
from main import APP_VERSION
except Exception:
APP_VERSION = "0.8.1"
version_lbl = QLabel(f"v{APP_VERSION}")
version_lbl.setObjectName("result_count")
version_lbl.setContentsMargins(0, 0, 8, 0)
self._statusbar.addPermanentWidget(version_lbl)
def _set_status(self, text: str, timeout_ms: int = 0):
"""Vis besked i statuslinjen. timeout_ms=0 = permanent."""
self._statusbar.showMessage(text, timeout_ms)
@@ -481,9 +500,10 @@ class MainWindow(QMainWindow):
SELECT s.id, s.title, s.artist, s.album, s.bpm,
s.duration_sec, s.local_path, s.file_format,
s.file_missing,
GROUP_CONCAT(d.name, ',') AS dance_names,
GROUP_CONCAT(COALESCE(dl.name,''), ',') AS dance_levels,
GROUP_CONCAT(DISTINCT ad.name) AS alt_dance_names
GROUP_CONCAT(d.name, ',') AS dance_names,
GROUP_CONCAT(COALESCE(dl.name,''), ',') AS dance_levels,
GROUP_CONCAT(COALESCE(d.choreographer,''), ',') AS dance_choreographers,
GROUP_CONCAT(DISTINCT ad.name) AS alt_dance_names
FROM songs s
LEFT JOIN song_dances sd ON sd.song_id = s.id
LEFT JOIN dances d ON d.id = sd.dance_id
@@ -498,22 +518,24 @@ class MainWindow(QMainWindow):
songs = []
for row in rows:
dances = row["dance_names"].split(",") if row["dance_names"] else []
levels = row["dance_levels"].split(",") if row["dance_levels"] else []
alt_dances = row["alt_dance_names"].split(",") if row["alt_dance_names"] else []
dances = row["dance_names"].split(",") if row["dance_names"] else []
levels = row["dance_levels"].split(",") if row["dance_levels"] else []
choreos = row["dance_choreographers"].split(",") if row["dance_choreographers"] else []
alt_dances = row["alt_dance_names"].split(",") if row["alt_dance_names"] else []
songs.append({
"id": row["id"],
"title": row["title"],
"artist": row["artist"],
"album": row["album"],
"bpm": row["bpm"],
"duration_sec": row["duration_sec"],
"local_path": row["local_path"],
"file_format": row["file_format"],
"file_missing": bool(row["file_missing"]),
"dances": dances,
"dance_levels": levels,
"alt_dances": alt_dances,
"id": row["id"],
"title": row["title"],
"artist": row["artist"],
"album": row["album"],
"bpm": row["bpm"],
"duration_sec": row["duration_sec"],
"local_path": row["local_path"],
"file_format": row["file_format"],
"file_missing": bool(row["file_missing"]),
"dances": dances,
"dance_levels": levels,
"dance_choreographers": choreos,
"alt_dances": alt_dances,
})
self._library_loaded.emit(songs)
except Exception:
@@ -652,6 +674,66 @@ class MainWindow(QMainWindow):
except Exception as e:
self._set_status(f"Fejl ved tilføjelse: {e}")
def _create_dance_dialog(self):
"""Opret en dans i databasen — fritliggende, uden tilknytning til musik."""
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QFormLayout, QLineEdit,
QComboBox, QDialogButtonBox, QMessageBox
)
try:
from local.local_db import get_dance_levels, get_or_create_dance
except Exception as e:
QMessageBox.warning(self, "Fejl", str(e))
return
levels = [dict(r) for r in get_dance_levels()]
dlg = QDialog(self)
dlg.setWindowTitle("Opret dans")
dlg.setFixedWidth(380)
layout = QVBoxLayout(dlg)
layout.setSpacing(8)
layout.setContentsMargins(14, 14, 14, 14)
form = QFormLayout()
form.setSpacing(8)
name_edit = QLineEdit()
name_edit.setPlaceholderText("f.eks. Cowboy Cha Cha")
form.addRow("Dans-navn:", name_edit)
level_cb = QComboBox()
level_cb.addItem("— intet niveau —", None)
for lvl in levels:
level_cb.addItem(lvl["name"], lvl["id"])
form.addRow("Niveau:", level_cb)
choreo_edit = QLineEdit()
choreo_edit.setPlaceholderText("Koreografens navn (valgfri)")
form.addRow("Koreograf:", choreo_edit)
layout.addLayout(form)
btns = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok |
QDialogButtonBox.StandardButton.Cancel
)
btns.accepted.connect(dlg.accept)
btns.rejected.connect(dlg.reject)
layout.addWidget(btns)
name_edit.setFocus()
if dlg.exec():
name = name_edit.text().strip()
choreo = choreo_edit.text().strip()
level = level_cb.currentData()
if name:
try:
get_or_create_dance(name, level, choreographer=choreo)
self._set_status(f'Dans "{name}" oprettet', 3000)
except Exception as e:
QMessageBox.warning(self, "Fejl", f"Kunne ikke oprette dans:\n{e}")
def _open_settings(self):
dialog = SettingsDialog(parent=self)
if dialog.exec():

View File

@@ -732,15 +732,19 @@ class PlaylistPanel(QWidget):
if not current:
dances = song.get("dances", [])
current = dances[0] if dances else ""
current_choreo = song.get("active_choreo", "")
dlg = DancePickerDialog(
current_dance=current,
current_choreo=current_choreo,
song_title=song.get("title", ""),
parent=self.window()
)
if dlg.exec():
chosen = dlg.get_dance()
choreo = dlg.get_choreo()
if chosen:
song["active_dance"] = chosen
song["active_dance"] = chosen
song["active_choreo"] = choreo
self._refresh()
self._sync_dance_to_db(idx, song)

View File

@@ -47,8 +47,8 @@ class TagEditorDialog(QDialog):
self._alts = [] # fra DB: {dance_id, name, level_id, level_name, note}
self.setWindowTitle(f"Rediger tags — {song.get('title', '')}")
self.setMinimumSize(720, 500)
self.resize(820, 580)
self.setMinimumSize(860, 520)
self.resize(980, 600)
self._load_levels()
self._load_existing()
@@ -97,11 +97,11 @@ class TagEditorDialog(QDialog):
hint.setWordWrap(True)
layout.addWidget(hint)
# To kolonner
# To kolonner — hoveddanse får mere plads
cols = QHBoxLayout()
cols.setSpacing(12)
cols.addWidget(self._build_dances_panel())
cols.addWidget(self._build_alts_panel())
cols.addWidget(self._build_dances_panel(), stretch=3)
cols.addWidget(self._build_alts_panel(), stretch=2)
layout.addLayout(cols, stretch=1)
btn_row = QHBoxLayout()
@@ -132,11 +132,11 @@ class TagEditorDialog(QDialog):
layout.addWidget(scroll, stretch=1)
self._dance_rows = []
for d in self._dances:
self._add_dance_row(d["name"], d["level_id"])
self._add_dance_row(d["name"], d["level_id"], d.get("choreographer", ""))
# Søgefelt
self._new_dance = QLineEdit()
self._new_dance.setPlaceholderText(_("tags.new_dance"))
self._new_dance.setPlaceholderText("Søg dans eller koreograf...")
self._new_dance.textChanged.connect(self._on_dance_search)
self._new_dance.returnPressed.connect(self._on_add_dance)
layout.addWidget(self._new_dance)
@@ -162,7 +162,7 @@ class TagEditorDialog(QDialog):
self._load_dance_suggestions("", self._dance_suggestions)
return grp
def _add_dance_row(self, name="", level_id=None):
def _add_dance_row(self, name="", level_id=None, choreographer=""):
try:
from translations import _, translate_level
except Exception:
@@ -175,14 +175,13 @@ class TagEditorDialog(QDialog):
edit = DanceLineEdit("Dans...", self)
edit.setText(name)
row_layout.addWidget(edit, stretch=1)
row_layout.addWidget(edit, stretch=2)
# Niveau-dropdown
level_cb = QComboBox()
level_cb.addItem(_("tags.no_level"), None)
for lvl in self._levels:
level_cb.addItem(translate_level(lvl["name"]), lvl["id"])
# Sæt til det rigtige niveau
if level_id is not None:
for i in range(level_cb.count()):
if level_cb.itemData(i) == level_id:
@@ -191,24 +190,102 @@ class TagEditorDialog(QDialog):
level_cb.setFixedWidth(130)
row_layout.addWidget(level_cb)
# Når autoudfyld vælger — opdater dropdown
def on_dance_selected(dance_info, cb=level_cb):
# Koreograf-felt med autocomplete
choreo_edit = QLineEdit()
choreo_edit.setText(choreographer)
choreo_edit.setPlaceholderText("Koreograf...")
choreo_edit.setFixedWidth(140)
choreo_edit.textChanged.connect(
lambda txt, ce=choreo_edit: self._show_choreo_suggestions(txt, ce)
)
row_layout.addWidget(choreo_edit)
# Når autoudfyld vælger — opdater dropdown og koreograf
def on_dance_selected(dance_info, cb=level_cb, ce=choreo_edit):
if dance_info.get("level_id") is not None:
for i in range(cb.count()):
if cb.itemData(i) == dance_info["level_id"]:
cb.setCurrentIndex(i)
break
if dance_info.get("choreographer") and not ce.text().strip():
ce.setText(dance_info["choreographer"])
edit.dance_selected.connect(on_dance_selected)
btn_info = QPushButton("•••")
btn_info.setFixedSize(36, 24)
btn_info.setToolTip("Åbn dans-info (link, video, noter)")
btn_info.setObjectName("btn_info_row")
btn_info.style().unpolish(btn_info)
btn_info.style().polish(btn_info)
row_layout.addWidget(btn_info)
btn_rm = QPushButton("")
btn_rm.setFixedSize(24, 24)
btn_rm.setFixedSize(32, 24)
btn_rm.setToolTip("Fjern dans")
btn_rm.setObjectName("btn_rm_row")
btn_rm.style().unpolish(btn_rm)
btn_rm.style().polish(btn_rm)
row_layout.addWidget(btn_rm)
idx = self._dance_layout.count() - 1
self._dance_layout.insertWidget(idx, row_widget)
entry = {"widget": row_widget, "edit": edit, "level": level_cb}
entry = {"widget": row_widget, "edit": edit, "level": level_cb, "choreo": choreo_edit}
self._dance_rows.append(entry)
btn_rm.clicked.connect(lambda: self._remove_dance_row(entry))
btn_info.clicked.connect(lambda: self._open_dance_info(entry))
def _create_dance(self):
"""Opret en ny dans i databasen uden at knytte den til musik."""
from PyQt6.QtWidgets import QDialog, QFormLayout, QDialogButtonBox
from PyQt6.QtCore import Qt
dlg = QDialog(self)
dlg.setWindowTitle("Opret dans")
dlg.setFixedWidth(360)
layout = QVBoxLayout(dlg)
layout.setSpacing(8)
form = QFormLayout()
name_edit = QLineEdit()
name_edit.setPlaceholderText("f.eks. Cowboy Cha Cha")
form.addRow("Dans-navn:", name_edit)
from PyQt6.QtWidgets import QComboBox
level_cb = QComboBox()
level_cb.addItem("— intet niveau —", None)
for lvl in self._levels:
level_cb.addItem(lvl["name"], lvl["id"])
form.addRow("Niveau:", level_cb)
choreo_edit = QLineEdit()
choreo_edit.setPlaceholderText("Koreografens navn (valgfri)")
form.addRow("Koreograf:", choreo_edit)
layout.addLayout(form)
btns = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok |
QDialogButtonBox.StandardButton.Cancel
)
btns.accepted.connect(dlg.accept)
btns.rejected.connect(dlg.reject)
layout.addWidget(btns)
name_edit.setFocus()
if dlg.exec():
name = name_edit.text().strip()
choreo = choreo_edit.text().strip()
level = level_cb.currentData()
if name:
try:
from local.local_db import get_or_create_dance
get_or_create_dance(name, level, choreographer=choreo)
# Opdater forslagslisten
self._load_dance_suggestions("", self._dance_suggestions)
self._load_existing_dance_suggestions("", self._alt_suggestions)
self._new_dance.setPlaceholderText(f'"{name}" oprettet ✓')
except Exception as e:
QMessageBox.warning(self, "Fejl", f"Kunne ikke oprette dans:\n{e}")
def _remove_dance_row(self, entry):
self._dance_rows.remove(entry)
@@ -217,9 +294,52 @@ class TagEditorDialog(QDialog):
def _on_dance_search(self):
self._dance_search_timer.start()
def _load_existing_dance_suggestions(self, prefix: str, list_widget):
"""Kun eksisterende danse fra DB — ingen nye kan oprettes herfra."""
try:
from local.local_db import get_dance_suggestions
suggestions = get_dance_suggestions(prefix, limit=20)
list_widget.clear()
for s in suggestions:
label = f"{s['name']} / {s['level_name']}" if s.get("level_name") else s["name"]
item = QListWidgetItem(label)
item.setData(Qt.ItemDataRole.UserRole, s.get("level_id"))
item.setData(Qt.ItemDataRole.UserRole + 1, s["name"])
item.setData(Qt.ItemDataRole.UserRole + 2, s.get("choreographer", ""))
list_widget.addItem(item)
except Exception:
pass
def _on_alt_search(self):
self._alt_search_timer.start()
def _open_dance_info(self, entry: dict):
"""Åbn dans-info dialog for den dans der er i denne række."""
name = entry["edit"].text().strip()
if not name:
entry["edit"].setFocus()
entry["edit"].setPlaceholderText("Skriv dans-navn først...")
return
from ui.dance_info_dialog import DanceInfoDialog
dlg = DanceInfoDialog(song=self._song, parent=self)
dlg.exec()
# Opdater koreograf-feltet fra DB bagefter
try:
from local.local_db import get_db
with get_db() as conn:
row = conn.execute(
"SELECT choreographer FROM dances "
"WHERE name=? COLLATE NOCASE LIMIT 1", (name,)
).fetchone()
if row and row["choreographer"]:
entry["choreo"].setText(row["choreographer"])
except Exception:
pass
def _show_choreo_suggestions(self, prefix: str, source_edit: 'QLineEdit'):
"""Vis autocomplete popup for koreograf direkte under feltet."""
pass # Simpel løsning: autocomplete via QCompleter nedenfor
def _load_dance_suggestions(self, prefix: str, list_widget):
try:
from local.local_db import get_dance_suggestions
@@ -227,19 +347,23 @@ class TagEditorDialog(QDialog):
list_widget.clear()
for s in suggestions:
label = f"{s['name']} / {s['level_name']}" if s.get("level_name") else s["name"]
if s.get("choreographer"):
label += f" · {s['choreographer']}"
item = QListWidgetItem(label)
item.setData(Qt.ItemDataRole.UserRole, s.get("level_id"))
item.setData(Qt.ItemDataRole.UserRole, s.get("level_id"))
item.setData(Qt.ItemDataRole.UserRole + 1, s["name"])
item.setData(Qt.ItemDataRole.UserRole + 2, s.get("choreographer", ""))
list_widget.addItem(item)
except Exception:
pass
def _add_from_suggestion(self, item, panel: str):
"""Tilføj dans fra forslags-listen ved klik."""
name = item.data(Qt.ItemDataRole.UserRole + 1) or item.text().split(" / ")[0]
level_id = item.data(Qt.ItemDataRole.UserRole)
name = item.data(Qt.ItemDataRole.UserRole + 1) or item.text().split(" / ")[0]
level_id = item.data(Qt.ItemDataRole.UserRole)
choreographer = item.data(Qt.ItemDataRole.UserRole + 2) or ""
if panel == "dance":
self._add_dance_row(name, level_id)
self._add_dance_row(name, level_id, choreographer)
self._new_dance.clear()
self._new_dance.setFocus()
self._load_dance_suggestions("", self._dance_suggestions)
@@ -273,6 +397,8 @@ class TagEditorDialog(QDialog):
from translations import _
grp = QGroupBox(_("tags.alts"))
layout = QVBoxLayout(grp)
# Eksisterende alternativ-rækker
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.Shape.NoFrame)
@@ -286,8 +412,13 @@ class TagEditorDialog(QDialog):
for a in self._alts:
self._add_alt_row(a["name"], a["level_id"], a.get("note", ""))
# Søgefelt — kun eksisterende danse
hint = QLabel("Søg blandt eksisterende danse:")
hint.setObjectName("result_count")
layout.addWidget(hint)
self._new_alt = QLineEdit()
self._new_alt.setPlaceholderText(_("tags.new_alt"))
self._new_alt.setPlaceholderText("Søg dans...")
self._new_alt.textChanged.connect(self._on_alt_search)
self._new_alt.returnPressed.connect(self._on_add_alt)
layout.addWidget(self._new_alt)
@@ -304,11 +435,11 @@ class TagEditorDialog(QDialog):
self._alt_search_timer.setSingleShot(True)
self._alt_search_timer.setInterval(150)
self._alt_search_timer.timeout.connect(
lambda: self._load_dance_suggestions(
lambda: self._load_existing_dance_suggestions(
self._new_alt.text().strip(), self._alt_suggestions
)
)
self._load_dance_suggestions("", self._alt_suggestions)
self._load_existing_dance_suggestions("", self._alt_suggestions)
return grp
def _add_alt_row(self, name="", level_id=None, note=""):
@@ -326,44 +457,34 @@ class TagEditorDialog(QDialog):
lbl.setObjectName("track_meta")
row_layout.addWidget(lbl)
edit = DanceLineEdit("Dans...", self)
edit.setText(name)
row_layout.addWidget(edit, stretch=1)
# Vis dans-navn — ikke redigerbart, kun valgt fra listen
lbl_name = QLabel(name)
lbl_name.setObjectName("track_title")
row_layout.addWidget(lbl_name, stretch=1)
# Niveau-dropdown
level_cb = QComboBox()
level_cb.addItem(_("tags.no_level"), None)
# Niveau (read-only label)
level_name = ""
for lvl in self._levels:
level_cb.addItem(translate_level(lvl["name"]), lvl["id"])
if level_id is not None:
for i in range(level_cb.count()):
if level_cb.itemData(i) == level_id:
level_cb.setCurrentIndex(i)
break
level_cb.setFixedWidth(130)
row_layout.addWidget(level_cb)
def on_dance_selected(dance_info, cb=level_cb):
if dance_info.get("level_id") is not None:
for i in range(cb.count()):
if cb.itemData(i) == dance_info["level_id"]:
cb.setCurrentIndex(i)
break
edit.dance_selected.connect(on_dance_selected)
note_edit = QLineEdit()
note_edit.setPlaceholderText(_("tags.note"))
note_edit.setText(note)
note_edit.setFixedWidth(80)
row_layout.addWidget(note_edit)
if lvl["id"] == level_id:
level_name = lvl["name"]
break
if level_name:
lbl_level = QLabel(level_name)
lbl_level.setObjectName("result_count")
lbl_level.setFixedWidth(110)
row_layout.addWidget(lbl_level)
btn_rm = QPushButton("")
btn_rm.setFixedSize(24, 24)
btn_rm.setFixedSize(32, 24)
btn_rm.setToolTip("Fjern alternativ-dans")
btn_rm.setObjectName("btn_rm_row")
btn_rm.style().unpolish(btn_rm)
btn_rm.style().polish(btn_rm)
row_layout.addWidget(btn_rm)
idx = self._alt_layout.count() - 1
self._alt_layout.insertWidget(idx, row_widget)
entry = {"widget": row_widget, "edit": edit, "level": level_cb, "note": note_edit}
entry = {"widget": row_widget, "name": name, "level_id": level_id}
self._alt_rows.append(entry)
btn_rm.clicked.connect(lambda: self._remove_alt_row(entry))
@@ -372,12 +493,10 @@ class TagEditorDialog(QDialog):
entry["widget"].deleteLater()
def _on_add_alt(self):
text = self._new_alt.text().strip()
if text:
name, level_id = self._parse_name_level(text)
self._add_alt_row(name, level_id)
self._new_alt.clear()
self._load_dance_suggestions("", self._alt_suggestions)
"""Alternativ-danse kan kun tilføjes fra forslagslisten, ikke som fri tekst."""
if self._alt_suggestions.count() > 0:
self._alt_suggestions.setCurrentRow(0)
self._add_from_suggestion(self._alt_suggestions.currentItem(), "alt")
def _save(self):
song_id = self._song.get("id")
@@ -387,24 +506,25 @@ class TagEditorDialog(QDialog):
from local.local_db import new_conn, get_or_create_dance
from local.tag_reader import write_dances, can_write_dances
# Saml data fra UI — niveau kommer fra dropdown, ikke fra tekstfeltet
# Saml data fra UI
dances = []
for row in self._dance_rows:
name = row["edit"].text().strip()
if name:
dances.append({
"name": name,
"level_id": row["level"].currentData(),
"name": name,
"level_id": row["level"].currentData(),
"choreographer": row["choreo"].text().strip(),
})
alts = []
for row in self._alt_rows:
name = row["edit"].text().strip()
name = row.get("name", "")
if name:
alts.append({
"name": name,
"level_id": row["level"].currentData(),
"note": row["note"].text().strip(),
"level_id": row.get("level_id"),
"note": "",
})
conn = new_conn()
@@ -415,7 +535,8 @@ class TagEditorDialog(QDialog):
# Indsæt hoveddanse
for i, d in enumerate(dances, 1):
dance_id = get_or_create_dance(d["name"], d["level_id"], conn)
dance_id = get_or_create_dance(d["name"], d["level_id"], conn,
choreographer=d.get("choreographer", ""))
conn.execute(
"INSERT OR IGNORE INTO song_dances (song_id, dance_id, dance_order) "
"VALUES (?,?,?)",

View File

@@ -79,6 +79,24 @@ QPushButton#btn_demo:hover, QPushButton#btn_demo:checked {
color: #111214;
border-color: #3b8fd4;
}
QPushButton#btn_info_row {
color: #3b8fd4;
border-color: #3b8fd4;
padding: 2px 6px;
}
QPushButton#btn_info_row:hover {
background-color: rgba(59,143,212,0.15);
border-color: #3b8fd4;
}
QPushButton#btn_rm_row {
color: #e74c3c;
border-color: #e74c3c;
padding: 2px 6px;
}
QPushButton#btn_rm_row:hover {
background-color: rgba(231,76,60,0.15);
border-color: #e74c3c;
}
/* Slider */
QSlider::groove:horizontal {
@@ -328,6 +346,22 @@ QPushButton#btn_play_small {
QPushButton#btn_play_small:hover {
background-color: #a05808;
}
QPushButton#btn_info_row {
color: #1a6fb0;
border-color: #1a6fb0;
padding: 2px 6px;
}
QPushButton#btn_info_row:hover {
background-color: rgba(26,111,176,0.12);
}
QPushButton#btn_rm_row {
color: #c0392b;
border-color: #c0392b;
padding: 2px 6px;
}
QPushButton#btn_rm_row:hover {
background-color: rgba(192,57,43,0.12);
}
QListWidget {
background-color: #d8dae0;
color: #1a1c22;