""" library_panel.py — Musikbibliotek med søgning og drag-and-drop til danseliste. """ from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QListWidget, QListWidgetItem, QLineEdit, QLabel, QHBoxLayout, QPushButton, QProgressBar, QAbstractItemView, ) from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QMimeData, QByteArray from PyQt6.QtGui import QColor, QDrag class DraggableLibraryList(QListWidget): """QListWidget der understøtter drag-start med sang-data som mime.""" def __init__(self, parent=None): super().__init__(parent) self.setDragEnabled(True) self.setDragDropMode(QAbstractItemView.DragDropMode.DragOnly) self.setDefaultDropAction(Qt.DropAction.CopyAction) def startDrag(self, supported_actions): item = self.currentItem() if not item: return song = item.data(Qt.ItemDataRole.UserRole) if not song: return import json data = json.dumps(song).encode("utf-8") mime = QMimeData() mime.setData("application/x-linedance-song", QByteArray(data)) mime.setText(song.get("title", "")) drag = QDrag(self) drag.setMimeData(mime) drag.exec(Qt.DropAction.CopyAction) class LibraryPanel(QWidget): song_selected = pyqtSignal(dict) add_to_playlist = pyqtSignal(dict) scan_requested = pyqtSignal() edit_tags_requested = pyqtSignal(dict) send_mail_requested = pyqtSignal(dict) def __init__(self, parent=None): super().__init__(parent) self._all_songs: list[dict] = [] self._filtered: list[dict] = [] self._bpm_scan_running = False self._search_timer = QTimer(self) self._search_timer.setSingleShot(True) self._search_timer.setInterval(150) self._search_timer.timeout.connect(self._do_search) self._build_ui() def _build_ui(self): layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) # Header header = QHBoxLayout() header.setContentsMargins(10, 6, 10, 6) lbl = QLabel("BIBLIOTEK") lbl.setObjectName("section_title") header.addWidget(lbl) header.addStretch() self._btn_bpm_scan = QPushButton("♩ BPM alle") self._btn_bpm_scan.setFixedHeight(24) self._btn_bpm_scan.setToolTip("Analysér BPM på alle sange uden BPM (kører i baggrunden)") self._btn_bpm_scan.clicked.connect(self._start_bulk_bpm_scan) header.addWidget(self._btn_bpm_scan) btn_manage = QPushButton("⚙ Mapper") btn_manage.setFixedHeight(24) btn_manage.setToolTip("Tilføj, fjern og scan musikbiblioteker") btn_manage.clicked.connect(self._manage_libraries) header.addWidget(btn_manage) layout.addLayout(header) # Scan status self._scan_bar = QProgressBar() self._scan_bar.setObjectName("scan_bar") self._scan_bar.setTextVisible(True) self._scan_bar.setFormat("Scanner...") self._scan_bar.setFixedHeight(16) self._scan_bar.setRange(0, 0) self._scan_bar.hide() layout.addWidget(self._scan_bar) self._scan_label = QLabel("") self._scan_label.setObjectName("result_count") self._scan_label.hide() layout.addWidget(self._scan_label) # Søgefelt self._search = QLineEdit() self._search.setPlaceholderText("Søg i titel, artist, album, dans...") self._search.textChanged.connect(self._on_search_changed) layout.addWidget(self._search) # Resultat-tæller + drag-hint hint_row = QHBoxLayout() hint_row.setContentsMargins(8, 2, 8, 2) self._count_label = QLabel("0 sange") self._count_label.setObjectName("result_count") hint_row.addWidget(self._count_label) hint_row.addStretch() drag_hint = QLabel("træk til danseliste →") drag_hint.setObjectName("result_count") hint_row.addWidget(drag_hint) layout.addLayout(hint_row) # Liste — draggable self._list = DraggableLibraryList() self._list.setObjectName("library_list") self._list.itemDoubleClicked.connect(self._on_double_click) self._list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self._list.customContextMenuRequested.connect(self._show_context_menu) layout.addWidget(self._list) # ── Scanning ────────────────────────────────────────────────────────────── def _on_scan_clicked(self): self.scan_requested.emit() def set_scanning(self, scanning: bool, status_text: str = ""): if scanning: self._scan_bar.show() self._scan_label.setText(status_text or "Starter...") self._scan_label.show() else: self._scan_bar.hide() self._scan_label.hide() def update_scan_status(self, text: str): self._scan_label.setText(text) # ── Sange ───────────────────────────────────────────────────────────────── def load_songs(self, songs: list[dict]): self._all_songs = songs self._do_search() # ── Søgning ─────────────────────────────────────────────────────────────── def _on_search_changed(self): self._search_timer.start() def _do_search(self): q = self._search.text().strip().lower() self._filtered = [s for s in self._all_songs if self._matches(s, q)] if q else list(self._all_songs) total = len(self._all_songs) found = len(self._filtered) q_text = self._search.text().strip() self._count_label.setText( f"{found} resultat{'er' if found != 1 else ''} for \"{q_text}\"" if q_text else f"{total} sang{'e' if total != 1 else ''}" ) self._render() def _matches(self, song: dict, q: str) -> bool: return any(q in f.lower() for f in [ song.get("title", ""), song.get("artist", ""), song.get("album", ""), song.get("file_format", ""), ] + song.get("dances", [])) def _render(self): self._list.clear() q = self._search.text().strip().lower() for song in self._filtered: dances = song.get("dances", []) dance_levels = song.get("dance_levels", []) missing = song.get("file_missing", False) dance_parts = [] for i, d in enumerate(dances): lvl = dance_levels[i] if i < len(dance_levels) else "" dance_parts.append(f"{d} / {lvl}" if lvl else d) dance_str = " · " + " | ".join(dance_parts) if dance_parts else "" line1 = ("⚠ " if missing else "") + song.get("title", "—") bpm = song.get("bpm", 0) bpm_str = f"{bpm} BPM" if bpm else "? BPM" line2 = f" {song.get('artist','—')} · {bpm_str} · {song.get('file_format','').upper()}{dance_str}" row_widget = QWidget() row_widget.setStyleSheet("background: transparent;") row_layout = QHBoxLayout(row_widget) row_layout.setContentsMargins(2, 2, 2, 2) row_layout.setSpacing(8) lbl = QLabel(f"{line1}\n{line2}") lbl.setWordWrap(False) row_layout.addWidget(lbl, stretch=1) btn_danse = QPushButton("Danse") btn_danse.setFixedHeight(30) btn_danse.setFixedWidth(70) btn_danse.setToolTip("Rediger dans-tags") btn_danse.setStyleSheet( "QPushButton { background: #e8a020; color: #111; border-radius: 4px; " "font-weight: bold; font-size: 12px; border: none; }" "QPushButton:hover { background: #f0b030; }" ) btn_danse.clicked.connect(lambda _, s=song: self.edit_tags_requested.emit(s)) row_layout.addWidget(btn_danse) item = QListWidgetItem() item.setData(Qt.ItemDataRole.UserRole, song) row_widget.adjustSize() hint = row_widget.sizeHint() hint.setHeight(max(hint.height(), 52)) item.setSizeHint(hint) self._list.addItem(item) self._list.setItemWidget(item, row_widget) def _start_bulk_bpm_scan(self): """Start BPM-analyse på alle sange uden BPM i baggrundstråd med lav prioritet.""" if self._bpm_scan_running: return songs_without_bpm = [s for s in self._all_songs if not s.get("bpm") and not s.get("file_missing")] if not songs_without_bpm: self._btn_bpm_scan.setText("♩ Alle har BPM") return self._bpm_scan_running = True self._btn_bpm_scan.setText(f"♩ Scanner 0/{len(songs_without_bpm)}...") self._btn_bpm_scan.setEnabled(False) from PyQt6.QtCore import QThread, pyqtSignal as _sig class BulkBpmWorker(QThread): progress = _sig(int, int, str) # done, total, title finished = _sig() def __init__(self, songs): super().__init__() self._songs = songs def run(self): from local.tag_reader import analyze_and_save_bpm total = len(self._songs) for i, song in enumerate(self._songs, start=1): if self.isInterruptionRequested(): break try: bpm = analyze_and_save_bpm(song["local_path"], song["id"]) if bpm: song["bpm"] = int(round(bpm)) except Exception: pass self.progress.emit(i, total, song.get("title", "")) self.finished.emit() self._bulk_bpm_worker = BulkBpmWorker(songs_without_bpm) def on_progress(done, total, title): self._btn_bpm_scan.setText(f"♩ {done}/{total}...") # Opdater sangen i listen for s in self._all_songs: if s.get("title") == title and s.get("bpm"): break self._do_search() def on_finished(): self._bpm_scan_running = False self._btn_bpm_scan.setEnabled(True) self._btn_bpm_scan.setText("♩ BPM alle") self._do_search() self._bulk_bpm_worker.progress.connect(on_progress) self._bulk_bpm_worker.finished.connect(on_finished) self._bulk_bpm_worker.start() self._bulk_bpm_worker.setPriority(QThread.Priority.LowestPriority) # ── Handlinger ──────────────────────────────────────────────────────────── def _on_double_click(self, item: QListWidgetItem): song = item.data(Qt.ItemDataRole.UserRole) if song: self.song_selected.emit(song) def _show_context_menu(self, pos): from PyQt6.QtWidgets import QMenu item = self._list.itemAt(pos) if not item: return song = item.data(Qt.ItemDataRole.UserRole) if not song: return menu = QMenu(self) act_add = menu.addAction("Tilføj til danseliste") act_play = menu.addAction("Afspil") menu.addSeparator() act_tags = menu.addAction("✎ Rediger dans-tags...") act_bpm = menu.addAction("♩ Analysér BPM") menu.addSeparator() send_menu = menu.addMenu("Send til") act_mail = send_menu.addAction("✉ Send som mail") action = menu.exec(self._list.mapToGlobal(pos)) if action == act_add: self.add_to_playlist.emit(song) elif action == act_play: self.song_selected.emit(song) elif action == act_tags: self.edit_tags_requested.emit(song) elif action == act_bpm: self._analyze_bpm(song) elif action == act_mail: self.send_mail_requested.emit(song) def _analyze_bpm(self, song: dict): """Analysér BPM i baggrundstråd og opdater biblioteket.""" path = song.get("local_path", "") song_id = song.get("id", "") if not path or not song_id: return from PyQt6.QtCore import QThread, pyqtSignal as _sig class BpmWorker(QThread): done = _sig(float) def __init__(self, p, sid): super().__init__() self._p, self._sid = p, sid def run(self): from local.tag_reader import analyze_and_save_bpm bpm = analyze_and_save_bpm(self._p, self._sid) if bpm: self.done.emit(bpm) self._bpm_worker = BpmWorker(path, song_id) def on_bpm_done(bpm): # Opdater sangen i _all_songs listen direkte for s in self._all_songs: if s.get("id") == song_id: s["bpm"] = int(round(bpm)) break self._do_search() self._bpm_worker.done.connect(on_bpm_done) self._bpm_worker.start() def _manage_libraries(self): from ui.library_manager import LibraryManagerDialog dialog = LibraryManagerDialog(parent=self.window()) dialog.library_removed.connect(lambda _: self.scan_requested.emit()) dialog.exec() def _add_folder(self): from PyQt6.QtWidgets import QFileDialog folder = QFileDialog.getExistingDirectory(self, "Vælg musikmappe") if folder: mw = self.window() if hasattr(mw, "add_library_path"): mw.add_library_path(folder)