300 lines
11 KiB
Python
300 lines
11 KiB
Python
"""
|
||
library_manager.py — Håndter musikmapper.
|
||
Tilføj/fjern mapper. BPM-scanning per bibliotek.
|
||
Fil-scanning starter automatisk når vinduet lukkes.
|
||
"""
|
||
import sqlite3
|
||
from pathlib import Path
|
||
|
||
from PyQt6.QtWidgets import (
|
||
QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||
QPushButton, QFrame, QMessageBox, QScrollArea, QWidget,
|
||
)
|
||
from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QThread
|
||
|
||
|
||
class LibraryManagerDialog(QDialog):
|
||
libraries_changed = pyqtSignal()
|
||
|
||
def __init__(self, db_path: str, parent=None):
|
||
super().__init__(parent)
|
||
self._db_path = db_path
|
||
self._workers = {} # library_id → ScanWorker
|
||
|
||
self.setWindowTitle("Musikmapper")
|
||
self.setMinimumWidth(600)
|
||
self.setMinimumHeight(300)
|
||
self._build_ui()
|
||
self._load()
|
||
|
||
# ── UI ────────────────────────────────────────────────────────────────────
|
||
|
||
def _build_ui(self):
|
||
layout = QVBoxLayout(self)
|
||
layout.setContentsMargins(12, 12, 12, 12)
|
||
layout.setSpacing(8)
|
||
|
||
lbl = QLabel(
|
||
"Tilføj eller fjern musikmapper. "
|
||
"Fil-scanning starter automatisk når vinduet lukkes."
|
||
)
|
||
lbl.setObjectName("result_count")
|
||
lbl.setWordWrap(True)
|
||
layout.addWidget(lbl)
|
||
|
||
# Scrollbart område til biblioteksliste
|
||
scroll = QScrollArea()
|
||
scroll.setWidgetResizable(True)
|
||
scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||
self._scroll_content = QWidget()
|
||
self._libs_layout = QVBoxLayout(self._scroll_content)
|
||
self._libs_layout.setSpacing(6)
|
||
self._libs_layout.addStretch()
|
||
scroll.setWidget(self._scroll_content)
|
||
layout.addWidget(scroll, stretch=1)
|
||
|
||
# Knap-række
|
||
btn_row = QHBoxLayout()
|
||
btn_add = QPushButton("+ Tilføj mappe")
|
||
btn_add.clicked.connect(self._add_folder)
|
||
btn_row.addWidget(btn_add)
|
||
btn_row.addStretch()
|
||
btn_close = QPushButton("Luk")
|
||
btn_close.clicked.connect(self.accept)
|
||
btn_row.addWidget(btn_close)
|
||
layout.addLayout(btn_row)
|
||
|
||
def _load(self):
|
||
"""Indlæs biblioteker fra DB og vis dem."""
|
||
from PyQt6.QtWidgets import QApplication
|
||
|
||
# Ryd eksisterende rækker (ikke stretch)
|
||
while self._libs_layout.count() > 1:
|
||
item = self._libs_layout.takeAt(0)
|
||
if item.widget():
|
||
item.widget().deleteLater()
|
||
QApplication.processEvents() # Lad Qt rydde op før vi bygger nyt
|
||
|
||
try:
|
||
conn = sqlite3.connect(self._db_path)
|
||
conn.row_factory = sqlite3.Row
|
||
libs = conn.execute(
|
||
"SELECT id, path FROM libraries "
|
||
"WHERE is_active=1 ORDER BY path"
|
||
).fetchall()
|
||
|
||
counts = {}
|
||
bpm_missing = {}
|
||
for lib in libs:
|
||
counts[lib["id"]] = conn.execute(
|
||
"SELECT COUNT(*) FROM files "
|
||
"WHERE file_missing=0 AND local_path LIKE ?",
|
||
(lib["path"] + "%",)
|
||
).fetchone()[0]
|
||
bpm_missing[lib["id"]] = conn.execute(
|
||
"SELECT COUNT(*) FROM files f "
|
||
"JOIN songs s ON s.id = f.song_id "
|
||
"WHERE f.file_missing=0 AND f.local_path LIKE ? "
|
||
"AND (s.bpm IS NULL OR s.bpm=0)",
|
||
(lib["path"] + "%",)
|
||
).fetchone()[0]
|
||
conn.close()
|
||
|
||
if not libs:
|
||
lbl = QLabel("Ingen musikmapper tilføjet endnu.")
|
||
lbl.setObjectName("result_count")
|
||
self._libs_layout.insertWidget(0, lbl)
|
||
return
|
||
|
||
for i, lib in enumerate(libs):
|
||
row = self._make_lib_row(
|
||
dict(lib),
|
||
counts[lib["id"]],
|
||
bpm_missing[lib["id"]]
|
||
)
|
||
self._libs_layout.insertWidget(i, row)
|
||
|
||
except Exception as e:
|
||
lbl = QLabel(f"Fejl: {e}")
|
||
self._libs_layout.insertWidget(0, lbl)
|
||
|
||
def _make_lib_row(self, lib: dict, song_count: int,
|
||
bpm_missing: int) -> QFrame:
|
||
lib_id = lib["id"]
|
||
path = lib["path"]
|
||
exists = Path(path).exists()
|
||
last = "—"
|
||
|
||
frame = QFrame()
|
||
frame.setObjectName("track_display")
|
||
vbox = QVBoxLayout(frame)
|
||
vbox.setContentsMargins(10, 8, 10, 8)
|
||
vbox.setSpacing(4)
|
||
|
||
# Sti
|
||
lbl_path = QLabel(("⚠ " if not exists else "") + path)
|
||
lbl_path.setObjectName("track_title" if exists else "result_count")
|
||
vbox.addWidget(lbl_path)
|
||
|
||
# Info
|
||
lbl_info = QLabel(
|
||
f" {song_count} sange · "
|
||
f"senest scannet: {last} · "
|
||
f"{bpm_missing} uden BPM"
|
||
+ (" · ⚠ mappe ikke fundet" if not exists else "")
|
||
)
|
||
lbl_info.setObjectName("result_count")
|
||
vbox.addWidget(lbl_info)
|
||
|
||
# Status-label til scanning
|
||
lbl_status = QLabel("")
|
||
lbl_status.setObjectName("result_count")
|
||
lbl_status.hide()
|
||
vbox.addWidget(lbl_status)
|
||
|
||
# Knapper
|
||
btn_row = QHBoxLayout()
|
||
btn_row.setSpacing(6)
|
||
|
||
btn_bpm = QPushButton(f"♩ BPM manglende ({bpm_missing})")
|
||
btn_bpm.setFixedHeight(30)
|
||
btn_bpm.setEnabled(exists and bpm_missing > 0
|
||
and lib_id not in self._workers)
|
||
btn_bpm.clicked.connect(
|
||
lambda _, lid=lib_id, p=path, b=btn_bpm, s=lbl_status:
|
||
self._start_bpm(lid, p, False, b, s)
|
||
)
|
||
btn_row.addWidget(btn_bpm)
|
||
|
||
btn_bpm_all = QPushButton("♩ BPM alle")
|
||
btn_bpm_all.setFixedHeight(30)
|
||
btn_bpm_all.setEnabled(exists and lib_id not in self._workers)
|
||
btn_bpm_all.clicked.connect(
|
||
lambda _, lid=lib_id, p=path, b=btn_bpm_all, s=lbl_status:
|
||
self._start_bpm(lid, p, True, b, s)
|
||
)
|
||
btn_row.addWidget(btn_bpm_all)
|
||
|
||
btn_row.addStretch()
|
||
|
||
btn_remove = QPushButton("✕ Fjern")
|
||
btn_remove.setFixedHeight(30)
|
||
btn_remove.clicked.connect(lambda _, l=lib: self._remove_library(l))
|
||
btn_row.addWidget(btn_remove)
|
||
|
||
vbox.addLayout(btn_row)
|
||
return frame
|
||
|
||
# ── Tilføj / fjern ────────────────────────────────────────────────────────
|
||
|
||
def _add_folder(self):
|
||
from PyQt6.QtWidgets import QFileDialog
|
||
folder = QFileDialog.getExistingDirectory(self, "Vælg musikmappe")
|
||
if not folder:
|
||
return
|
||
try:
|
||
conn = sqlite3.connect(self._db_path)
|
||
conn.row_factory = sqlite3.Row
|
||
|
||
# Tjek om mappen allerede er aktiv
|
||
existing = conn.execute(
|
||
"SELECT id, is_active FROM libraries WHERE path=?", (folder,)
|
||
).fetchone()
|
||
|
||
if existing:
|
||
if existing["is_active"]:
|
||
QMessageBox.information(
|
||
self, "Allerede tilføjet",
|
||
"Denne mappe er allerede i listen."
|
||
)
|
||
conn.close()
|
||
return
|
||
else:
|
||
# Reaktiver en tidligere fjernet mappe
|
||
conn.execute(
|
||
"UPDATE libraries SET is_active=1 WHERE path=?",
|
||
(folder,)
|
||
)
|
||
else:
|
||
conn.execute(
|
||
"INSERT INTO libraries (path, is_active) VALUES (?, 1)",
|
||
(folder,)
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
self._load()
|
||
self.libraries_changed.emit()
|
||
except Exception as e:
|
||
QMessageBox.warning(self, "Fejl", f"Kunne ikke tilføje: {e}")
|
||
|
||
def _remove_library(self, lib: dict):
|
||
reply = QMessageBox.question(
|
||
self, "Fjern mappe",
|
||
f"Fjern:\n{lib['path']}\n\n"
|
||
"Alle sange fra denne mappe slettes fra databasen.\n"
|
||
"Dans-tags og playlister bevares.",
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||
)
|
||
if reply != QMessageBox.StandardButton.Yes:
|
||
return
|
||
|
||
# Stop evt. BPM-scanning på dette bibliotek
|
||
if lib["id"] in self._workers:
|
||
self._workers[lib["id"]].cancel()
|
||
self._workers.pop(lib["id"], None)
|
||
|
||
try:
|
||
conn = sqlite3.connect(self._db_path)
|
||
# Marker filer fra denne mappe som missing
|
||
conn.execute(
|
||
"UPDATE files SET file_missing=1 WHERE local_path LIKE ?",
|
||
(lib["path"] + "%",)
|
||
)
|
||
# Slet selve biblioteks-rækken
|
||
conn.execute(
|
||
"DELETE FROM libraries WHERE id=?", (lib["id"],)
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
self._load()
|
||
QTimer.singleShot(300, self.libraries_changed.emit)
|
||
except Exception as e:
|
||
QMessageBox.warning(self, "Fejl", f"Kunne ikke fjerne: {e}")
|
||
|
||
# ── BPM-scanning ──────────────────────────────────────────────────────────
|
||
|
||
def _start_bpm(self, library_id: int, path: str,
|
||
scan_all: bool, btn: QPushButton, lbl: QLabel):
|
||
if library_id in self._workers:
|
||
return
|
||
|
||
from local.local_db import DB_PATH
|
||
from ui.bpm_worker import BpmScanWorker
|
||
|
||
worker = BpmScanWorker(library_id, str(DB_PATH),
|
||
scan_all=scan_all)
|
||
|
||
def on_progress(done, total):
|
||
lbl.setText(f"♩ {done}/{total} analyseret...")
|
||
lbl.show()
|
||
btn.setEnabled(False)
|
||
|
||
def on_finished(count):
|
||
lbl.setText(f"✓ {count} analyseret")
|
||
btn.setEnabled(True)
|
||
self._workers.pop(library_id, None)
|
||
QTimer.singleShot(300, self._load)
|
||
|
||
worker.progress.connect(on_progress)
|
||
worker.finished.connect(on_finished)
|
||
self._workers[library_id] = worker
|
||
worker.start()
|
||
worker.setPriority(QThread.Priority.LowestPriority)
|
||
|
||
# ── Luk ───────────────────────────────────────────────────────────────────
|
||
|
||
def closeEvent(self, event):
|
||
for w in list(self._workers.values()):
|
||
w.cancel()
|
||
w.wait(2000) # Vent max 2 sek på at tråden stopper
|
||
event.accept() |