Bedre mappehåndtering

This commit is contained in:
2026-04-12 13:42:05 +02:00
parent d6cc22dc9a
commit bdb1f5915a
6 changed files with 417 additions and 639 deletions

View File

@@ -1,147 +1,114 @@
"""
library_manager.py — Dialog til at administrere musikbiblioteker med BPM-scanning.
library_manager.py — Håndter musikmapper.
Tilføj/fjern mapper. Scan KUN ved eksplicit knap-tryk.
"""
import sqlite3
from pathlib import Path
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QWidget,
QPushButton, QListWidget, QListWidgetItem, QMessageBox,
QFrame, QSizePolicy,
QPushButton, QFrame, QMessageBox, QProgressBar,
)
from PyQt6.QtCore import Qt, pyqtSignal, QThread
from PyQt6.QtGui import QColor
class BpmScanWorker(QThread):
progress = pyqtSignal(int, int) # done, total
finished = pyqtSignal(int) # antal scannet
def __init__(self, library_id: int, scan_all: bool = False):
super().__init__()
self._library_id = library_id
self._scan_all = scan_all # False = kun manglende, True = alle
def run(self):
import sqlite3
from local.local_db import DB_PATH
from local.tag_reader import analyze_bpm
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
if self._scan_all:
songs = conn.execute(
"SELECT id, local_path FROM songs WHERE library_id=? AND file_missing=0",
(self._library_id,)
).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()
total = len(songs)
done = 0
for song in songs:
if self.isInterruptionRequested():
break
try:
bpm = analyze_bpm(song["local_path"])
if bpm:
conn.execute(
"UPDATE songs SET bpm=? WHERE id=?",
(int(round(bpm)), song["id"])
)
conn.commit()
except Exception:
pass
done += 1
self.progress.emit(done, total)
conn.close()
self.finished.emit(done)
from PyQt6.QtCore import Qt, pyqtSignal, QTimer
class LibraryManagerDialog(QDialog):
library_removed = pyqtSignal(int)
libraries_changed = pyqtSignal() # signal til main_window om at genindlæse
def __init__(self, parent=None):
def __init__(self, db_path: str, parent=None):
super().__init__(parent)
self.setWindowTitle("Administrer musikbiblioteker")
self.setMinimumWidth(580)
self.setMinimumHeight(360)
self._bpm_workers = {} # library_id → BpmScanWorker
self._db_path = db_path
self._workers = {} # library_id → ScanWorker
self._scanning = False
self.setWindowTitle("Musikmapper")
self.setMinimumWidth(600)
self.setMinimumHeight(300)
self._build_ui()
self._load()
# ── UI ────────────────────────────────────────────────────────────────────
def _build_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(16, 16, 16, 16)
layout.setSpacing(10)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(8)
lbl = QLabel("Aktive musikbiblioteker:")
lbl.setObjectName("track_meta")
lbl = QLabel("Tilføj eller fjern musikmapper. Scan starter kun ved klik på knappen.")
lbl.setObjectName("result_count")
lbl.setWordWrap(True)
layout.addWidget(lbl)
self._libs_layout = QVBoxLayout()
self._libs_layout.setSpacing(6)
layout.addLayout(self._libs_layout)
layout.addStretch()
note = QLabel(
"Når du fjerner et bibliotek, slettes det fra overvågningen.\n"
"Sangene forbliver i databasen men markeres som manglende (⚠)."
)
note.setObjectName("result_count")
note.setWordWrap(True)
layout.addWidget(note)
# Global status
self._lbl_status = QLabel("")
self._lbl_status.setObjectName("result_count")
self._lbl_status.hide()
layout.addWidget(self._lbl_status)
self._progress = QProgressBar()
self._progress.hide()
layout.addWidget(self._progress)
# Knap-række
btn_row = QHBoxLayout()
btn_add = QPushButton("+ Tilføj mappe")
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_close.clicked.connect(self._on_close)
btn_row.addWidget(btn_close)
layout.addLayout(btn_row)
def _load(self):
"""Indlæs biblioteker fra DB og vis dem — ingen scanning."""
while self._libs_layout.count():
item = self._libs_layout.takeAt(0)
if item.widget():
item.widget().deleteLater()
try:
import sqlite3
from local.local_db import DB_PATH, get_libraries
libs = get_libraries(active_only=True)
conn = sqlite3.connect(self._db_path)
conn.row_factory = sqlite3.Row
libs = conn.execute(
"SELECT id, path, last_full_scan FROM libraries WHERE is_active=1 ORDER BY path"
).fetchall()
total_songs = {}
for lib in libs:
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
count = conn.execute(
cnt = conn.execute(
"SELECT COUNT(*) FROM songs WHERE library_id=? AND file_missing=0",
(lib["id"],)
).fetchone()[0]
missing_bpm = conn.execute(
"SELECT COUNT(*) FROM songs WHERE library_id=? AND file_missing=0 "
"AND (bpm IS NULL OR bpm=0)",
(lib["id"],)
).fetchone()[0]
conn.close()
lib_dict = dict(lib)
lib_dict["_count"] = count
lib_dict["_missing_bpm"] = missing_bpm
self._libs_layout.addWidget(self._make_lib_row(lib_dict))
total_songs[lib["id"]] = cnt
conn.close()
for lib in libs:
self._libs_layout.addWidget(
self._make_lib_row(dict(lib), total_songs[lib["id"]])
)
if not libs:
lbl = QLabel("Ingen musikmapper tilføjet endnu.")
lbl.setObjectName("result_count")
self._libs_layout.addWidget(lbl)
except Exception as e:
lbl = QLabel(f"Fejl: {e}")
lbl = QLabel(f"Fejl ved indlæsning: {e}")
self._libs_layout.addWidget(lbl)
def _make_lib_row(self, lib: dict) -> QFrame:
from pathlib import Path
lib_id = lib["id"]
path = lib["path"]
exists = Path(path).exists()
def _make_lib_row(self, lib: dict, song_count: int) -> QFrame:
lib_id = lib["id"]
path = lib["path"]
exists = Path(path).exists()
last = lib.get("last_full_scan") or "aldrig"
if isinstance(last, str) and len(last) > 16:
last = last[:16]
scanning = lib_id in self._workers
frame = QFrame()
frame.setObjectName("track_display")
@@ -149,56 +116,43 @@ class LibraryManagerDialog(QDialog):
vbox.setContentsMargins(10, 8, 10, 8)
vbox.setSpacing(4)
# Sti + scan-info
last_scan = lib.get("last_full_scan") or "aldrig"
if isinstance(last_scan, str) and len(last_scan) > 10:
last_scan = last_scan[:10]
total = lib.get("_count", 0)
missing_bpm = lib.get("_missing_bpm", 0)
# Sti
lbl_path = QLabel(("" if not exists else "") + path)
lbl_path.setObjectName("track_title" if exists else "result_count")
vbox.addWidget(lbl_path)
lbl_info = QLabel(
f" {total} sange · senest scannet: {last_scan} · "
f"{missing_bpm} uden BPM"
)
# Info
lbl_info = QLabel(f" {song_count} sange · senest scannet: {last}")
lbl_info.setObjectName("result_count")
vbox.addWidget(lbl_info)
# Statuslinje til BPM-fremgang
lbl_status = QLabel("")
lbl_status.setObjectName("result_count")
lbl_status.hide()
vbox.addWidget(lbl_status)
# Scan-status label
lbl_scan = QLabel("")
lbl_scan.setObjectName("result_count")
lbl_scan.hide()
vbox.addWidget(lbl_scan)
# Knap-række
# Knapper
btn_row = QHBoxLayout()
btn_row.setSpacing(6)
btn_scan = QPushButton("Fil-scan")
btn_scan = QPushButton("Scan nye filer")
btn_scan.setFixedHeight(30)
btn_scan.setToolTip("Scan for nye og ændrede filer")
btn_scan.clicked.connect(lambda _, lid=lib_id, p=path: self._scan_files(lid, p))
btn_scan.setEnabled(exists and not scanning)
btn_scan.clicked.connect(
lambda _, lid=lib_id, p=path, b=btn_scan, s=lbl_scan:
self._start_scan(lid, p, False, b, s)
)
btn_row.addWidget(btn_scan)
btn_bpm = QPushButton(f" BPM manglende ({missing_bpm})")
btn_bpm.setFixedHeight(30)
btn_bpm.setToolTip("Analysér BPM på sange der mangler det")
btn_bpm.setEnabled(missing_bpm > 0)
btn_bpm.clicked.connect(
lambda _, lid=lib_id, b=btn_bpm, s=lbl_status: self._start_bpm(lid, False, b, s)
btn_scan_all = QPushButton(" Scan alle filer")
btn_scan_all.setFixedHeight(30)
btn_scan_all.setEnabled(exists and not scanning)
btn_scan_all.clicked.connect(
lambda _, lid=lib_id, p=path, b=btn_scan_all, s=lbl_scan:
self._start_scan(lid, p, True, b, s)
)
btn_row.addWidget(btn_bpm)
btn_bpm_all = QPushButton("♩ BPM alle")
btn_bpm_all.setFixedHeight(30)
btn_bpm_all.setToolTip("Genanalysér BPM på alle sange (overskriver eksisterende)")
btn_bpm_all.clicked.connect(
lambda _, lid=lib_id, b=btn_bpm_all, s=lbl_status: self._start_bpm(lid, True, b, s)
)
btn_row.addWidget(btn_bpm_all)
btn_row.addWidget(btn_scan_all)
btn_row.addStretch()
@@ -210,70 +164,111 @@ class LibraryManagerDialog(QDialog):
vbox.addLayout(btn_row)
return frame
def _scan_files(self, library_id: int, path: str):
mw = self.parent()
if hasattr(mw, "_watcher") and mw._watcher:
mw._watcher._full_scan_library(library_id, path)
from PyQt6.QtCore import QTimer
QTimer.singleShot(1000, self._load)
def _start_bpm(self, library_id: int, scan_all: bool,
btn: QPushButton, lbl_status: QLabel):
if library_id in self._bpm_workers:
return # allerede i gang
worker = BpmScanWorker(library_id, scan_all=scan_all)
def on_progress(done, total):
lbl_status.setText(f"{done}/{total} analyseret...")
lbl_status.show()
btn.setEnabled(False)
def on_finished(count):
lbl_status.setText(f"{count} sange analyseret")
btn.setEnabled(True)
self._bpm_workers.pop(library_id, None)
# Opdater UI og bibliotek
from PyQt6.QtCore import QTimer
QTimer.singleShot(500, self._load)
mw = self.parent()
if hasattr(mw, "_reload_library"):
QTimer.singleShot(600, mw._reload_library)
worker.progress.connect(on_progress)
worker.finished.connect(on_finished)
self._bpm_workers[library_id] = worker
worker.start()
worker.setPriority(QThread.Priority.LowestPriority)
# ── Tilføj / fjern ────────────────────────────────────────────────────────
def _add_folder(self):
from PyQt6.QtWidgets import QFileDialog
folder = QFileDialog.getExistingDirectory(self, "Vælg musikmappe")
if folder:
mw = self.parent()
if hasattr(mw, "add_library_path"):
mw.add_library_path(folder)
from PyQt6.QtCore import QTimer
QTimer.singleShot(800, self._load)
if not folder:
return
try:
conn = sqlite3.connect(self._db_path)
# Tjek om mappen allerede er tilføjet
existing = conn.execute(
"SELECT id FROM libraries WHERE path=?", (folder,)
).fetchone()
if existing:
QMessageBox.information(self, "Allerede tilføjet",
"Denne mappe er allerede i listen.")
conn.close()
return
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 bibliotek",
f"Fjern overvågningen af:\n{lib['path']}\n\n"
"Sange forbliver i databasen men markeres som manglende.",
self, "Fjern mappe",
f"Fjern:\n{lib['path']}\n\n"
"Alle sange fra denne mappe slettes også fra databasen.\n"
"Dans-tags og playlister bevares.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply == QMessageBox.StandardButton.Yes:
try:
mw = self.parent()
if hasattr(mw, "_watcher") and mw._watcher:
mw._watcher.remove_library(lib["id"])
else:
from local.local_db import remove_library
remove_library(lib["id"])
self.library_removed.emit(lib["id"])
if hasattr(mw, "_reload_library"):
mw._reload_library()
self._load()
except Exception as e:
QMessageBox.warning(self, "Fejl", f"Kunne ikke fjerne: {e}")
if reply != QMessageBox.StandardButton.Yes:
return
# Stop evt. 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)
# Slet sange der tilhører dette bibliotek
conn.execute("DELETE FROM songs WHERE library_id=?", (lib["id"],))
# Deaktiver biblioteket
conn.execute("UPDATE libraries SET is_active=0 WHERE id=?", (lib["id"],))
conn.commit()
conn.close()
self._load()
self.libraries_changed.emit()
except Exception as e:
QMessageBox.warning(self, "Fejl", f"Kunne ikke fjerne: {e}")
# ── Scanning ──────────────────────────────────────────────────────────────
def _start_scan(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.scan_worker import ScanWorker
worker = ScanWorker(library_id, path, str(DB_PATH),
overwrite_bpm=scan_all)
def on_progress(done, total, filename):
if total > 0:
lbl.setText(f"{done}/{total}{filename}")
lbl.show()
btn.setEnabled(False)
def on_finished(count, lib_path):
lbl.setText(f"{count} sange scannet")
btn.setEnabled(True)
self._workers.pop(library_id, None)
QTimer.singleShot(300, self._load)
self.libraries_changed.emit()
def on_error(msg):
lbl.setText(f"⚠ Fejl: {msg}")
lbl.show()
btn.setEnabled(True)
self._workers.pop(library_id, None)
worker.progress.connect(on_progress)
worker.finished.connect(on_finished)
worker.error.connect(on_error)
self._workers[library_id] = worker
worker.setPriority(QThread.Priority.LowestPriority)
worker.start()
# ── Luk ───────────────────────────────────────────────────────────────────
def _on_close(self):
# Stop alle aktive scannere
for worker in self._workers.values():
worker.cancel()
self.accept()
def closeEvent(self, event):
for worker in self._workers.values():
worker.cancel()
event.accept()

View File

@@ -378,10 +378,18 @@ class LibraryPanel(QWidget):
def _manage_libraries(self):
from ui.library_manager import LibraryManagerDialog
dialog = LibraryManagerDialog(parent=self.window())
dialog.library_removed.connect(lambda _: self.scan_requested.emit())
from local.local_db import DB_PATH
dialog = LibraryManagerDialog(db_path=str(DB_PATH), parent=self.window())
dialog.libraries_changed.connect(self._on_libraries_changed)
dialog.exec()
def _on_libraries_changed(self):
"""Kald reload på main_window når biblioteker ændres."""
mw = self.window()
if hasattr(mw, "_reload_library"):
from PyQt6.QtCore import QTimer
QTimer.singleShot(500, mw._reload_library)
def _add_folder(self):
from PyQt6.QtWidgets import QFileDialog
folder = QFileDialog.getExistingDirectory(self, "Vælg musikmappe")

View File

@@ -496,7 +496,7 @@ class MainWindow(QMainWindow):
)
def _post_init(self):
"""Kør efter DB er initialiseret — gendan state og start watcher."""
"""Kør efter DB er initialiseret — gendan state."""
try:
restored = self._playlist_panel.restore_active_playlist()
if restored:
@@ -512,8 +512,12 @@ class MainWindow(QMainWindow):
)
except Exception:
pass
QTimer.singleShot(5000, self._start_watcher)
QTimer.singleShot(60000, self.start_scan)
# Periodisk reload af bibliotek hvert 10. sekund — fanger ny-scannede sange
self._auto_reload_timer = QTimer(self)
self._auto_reload_timer.setInterval(10000)
self._auto_reload_timer.timeout.connect(self._reload_library)
self._auto_reload_timer.start()
def add_library_path(self, path: str):
try:

View File

@@ -1,64 +1,47 @@
"""
scan_worker.py — Kører fuld biblioteks-scanning i en baggrundstråd
så GUI ikke fryser.
scan_worker.py — QThread der scanner biblioteker i baggrunden.
Rapporterer fremgang via signals uden at blokere GUI.
"""
from PyQt6.QtCore import QThread, pyqtSignal
class ScanWorker(QThread):
"""
Kører _full_scan_all() i en baggrundstråd.
Sender status-opdateringer undervejs.
"""
status_update = pyqtSignal(str) # løbende statusbeskeder
scan_done = pyqtSignal(int) # antal behandlede filer
progress = pyqtSignal(int, int, str) # done, total, filename
finished = pyqtSignal(int, str) # antal, library_path
error = pyqtSignal(str)
def __init__(self, watcher, parent=None):
super().__init__(parent)
self._watcher = watcher
self._total = 0
def __init__(self, library_id: int, library_path: str,
db_path: str, overwrite_bpm: bool = False):
super().__init__()
self._library_id = library_id
self._library_path = library_path
self._db_path = db_path
self._overwrite_bpm = overwrite_bpm
self._cancelled = False
def cancel(self):
self._cancelled = True
self.requestInterruption()
def run(self):
try:
from local.local_db import get_libraries
from local.tag_reader import is_supported
import os
libraries = get_libraries(active_only=True)
from local.scanner import scan_library
if not libraries:
self.status_update.emit("Ingen biblioteker konfigureret")
self.scan_done.emit(0)
return
total_processed = 0
for lib in libraries:
from pathlib import Path
path = Path(lib["path"])
name = path.name
if not path.exists():
self.status_update.emit(f"⚠ Mappe ikke fundet: {path}")
continue
self.status_update.emit(f"Scanner: {name}...")
# Tæl filer med os.walk — håndterer permission-fejl sikkert
count = 0
for dirpath, _, filenames in os.walk(str(path), followlinks=False):
for f in filenames:
if is_supported(f):
count += 1
self.status_update.emit(f"Scanner: {name} ({count} filer)...")
# Kør scanning
self._watcher._full_scan_library(lib["id"], str(path))
total_processed += count
self.status_update.emit(f"Scan færdig — {total_processed} filer gennemgået")
self.scan_done.emit(total_processed)
def on_progress(done, total, filename):
if self.isInterruptionRequested():
raise InterruptedError()
self.progress.emit(done, total, filename)
count = scan_library(
self._library_id,
self._library_path,
self._db_path,
overwrite_bpm=self._overwrite_bpm,
progress_callback=on_progress,
)
if not self._cancelled:
self.finished.emit(count, self._library_path)
except InterruptedError:
pass
except Exception as e:
self.status_update.emit(f"Scan fejl: {e}")
self.scan_done.emit(0)
self.error.emit(str(e))