From 4aba2f02a25532570f44d7e2a95f7f08c9ccb057 Mon Sep 17 00:00:00 2001 From: Carsten Kvist Date: Tue, 14 Apr 2026 17:00:29 +0200 Subject: [PATCH] Web - 1 --- linedance-api/app/routers/sharing.py | 59 ++- linedance-api/docker-compose.yml | 10 + linedance-api/web/Dockerfile | 3 + linedance-api/web/nginx.conf | 13 + linedance-api/web/public/index.html | 551 +++++++++++++++++++++++++ linedance-app/local/acoustid_worker.py | 179 +++++--- linedance-app/local/local_db.py | 5 + linedance-app/local/tag_reader.py | 63 +++ linedance-app/ui/main_window.py | 15 +- linedance-app/ui/settings_dialog.py | 18 +- 10 files changed, 840 insertions(+), 76 deletions(-) create mode 100644 linedance-api/web/Dockerfile create mode 100644 linedance-api/web/nginx.conf create mode 100644 linedance-api/web/public/index.html diff --git a/linedance-api/app/routers/sharing.py b/linedance-api/app/routers/sharing.py index 9dd6b6fb..3bf74898 100644 --- a/linedance-api/app/routers/sharing.py +++ b/linedance-api/app/routers/sharing.py @@ -118,8 +118,63 @@ def set_visibility( # ── Hent playliste-indhold ──────────────────────────────────────────────────── -@router.get("/playlists/{project_id}") -def get_shared_playlist( +@router.get("/public") +def list_public_playlists(db: Session = Depends(get_db)): + """Hent alle public playlister — ingen login krævet.""" + projects = db.query(Project).filter_by(visibility="public").all() + result = [] + for p in projects: + owner = db.query(User).filter_by(id=p.owner_id).first() + result.append({ + "id": p.id, + "name": p.name, + "owner": owner.username if owner else "?", + "song_count": len(p.project_songs), + }) + return result + + +@router.post("/playlists/{project_id}/copy", status_code=201) +def copy_playlist( + project_id: str, + db: Session = Depends(get_db), + me: User = Depends(get_current_user), +): + """Kopiér en public playliste til brugerens egen konto.""" + p = db.query(Project).filter_by(id=project_id).first() + if not p: + raise HTTPException(404, "Playliste ikke fundet") + if p.visibility != "public": + raise HTTPException(403, "Kun public playlister kan kopieres") + if p.owner_id == me.id: + raise HTTPException(400, "Du ejer allerede denne playliste") + + from app.models import Song + owner = db.query(User).filter_by(id=p.owner_id).first() + new_name = f"{p.name} (kopi fra {owner.username if owner else '?'})" + + new_p = Project( + owner_id=me.id, + name=new_name, + description=p.description or "", + visibility="private", + ) + db.add(new_p) + db.flush() + + for ps in p.project_songs: + from app.models import ProjectSong + db.add(ProjectSong( + project_id=new_p.id, + song_id=ps.song_id, + position=ps.position, + status="pending", + is_workshop=ps.is_workshop, + dance_override=ps.dance_override or "", + )) + + db.commit() + return {"detail": "Kopieret", "id": new_p.id} project_id: str, db: Session = Depends(get_db), me: User = Depends(get_current_user), diff --git a/linedance-api/docker-compose.yml b/linedance-api/docker-compose.yml index 7f9a446b..f8aeb83d 100644 --- a/linedance-api/docker-compose.yml +++ b/linedance-api/docker-compose.yml @@ -10,6 +10,16 @@ services: networks: - linedance + web: + build: ./web + restart: always + ports: + - "80:80" + networks: + - linedance + depends_on: + - api + adminer: image: adminer restart: always diff --git a/linedance-api/web/Dockerfile b/linedance-api/web/Dockerfile new file mode 100644 index 00000000..181e9369 --- /dev/null +++ b/linedance-api/web/Dockerfile @@ -0,0 +1,3 @@ +FROM nginx:alpine +COPY public /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf diff --git a/linedance-api/web/nginx.conf b/linedance-api/web/nginx.conf new file mode 100644 index 00000000..067a4800 --- /dev/null +++ b/linedance-api/web/nginx.conf @@ -0,0 +1,13 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + gzip on; + gzip_types text/html text/css application/javascript; +} diff --git a/linedance-api/web/public/index.html b/linedance-api/web/public/index.html new file mode 100644 index 00000000..2aedffcc --- /dev/null +++ b/linedance-api/web/public/index.html @@ -0,0 +1,551 @@ + + + + + +LineDance Player — Public Playlister + + + + + +
+ + +
+ +
+

Public
playlister

+

Browse og kopiér playlister delt af LineDance Player-brugere.

+
+ +
+
Alle public playlister
+
+
Henter playlister...
+
+
+ + +
+
+
+
+

+

+
+ +
+
+ +
+
+ + +
+ +
+ + + + diff --git a/linedance-app/local/acoustid_worker.py b/linedance-app/local/acoustid_worker.py index d1b0a170..a913b8f8 100644 --- a/linedance-app/local/acoustid_worker.py +++ b/linedance-app/local/acoustid_worker.py @@ -20,11 +20,14 @@ logger = logging.getLogger(__name__) # AcoustID API nøgle — gratis til open-source apps # https://acoustid.org/api-key -ACOUSTID_API_KEY = "8XaBELgH" # Offentlig test-nøgle, skift til din egen ved produktion +ACOUSTID_API_KEY = "9JYq1saI1H" ACOUSTID_API_URL = "https://api.acoustid.org/v2/lookup" -# Pause mellem API-kald (AcoustID tillader 3/sek) -API_DELAY = 0.4 +# Pause mellem API-kald — rolig baggrundskørsel +API_DELAY = 5.0 + +# Maks sange per session — fordeles over mange opstart +MAX_PER_SESSION = 20 def find_fpcalc() -> str | None: @@ -72,16 +75,16 @@ def fingerprint_file(path: str, fpcalc: str) -> tuple[str, int] | None: return None -def lookup_acoustid(fingerprint: str, duration: int) -> dict | None: +def lookup_acoustid(fingerprint: str, duration: int, api_key: str = "") -> dict | None: """ Spørg AcoustID API om MBID for et fingerprint. Returnerer dict med 'mbid' og 'acoustid' eller None. """ try: - import urllib.request, urllib.parse + import urllib.request, urllib.parse, urllib.error params = urllib.parse.urlencode({ - "client": ACOUSTID_API_KEY, + "client": api_key or ACOUSTID_API_KEY, "fingerprint": fingerprint, "duration": duration, "meta": "recordings", @@ -90,10 +93,16 @@ def lookup_acoustid(fingerprint: str, duration: int) -> dict | None: req = urllib.request.Request(url) req.add_header("User-Agent", "LineDancePlayer/1.0") - with urllib.request.urlopen(req, timeout=10) as resp: - data = json.loads(resp.read()) + try: + with urllib.request.urlopen(req, timeout=10) as resp: + data = json.loads(resp.read()) + except urllib.error.HTTPError as e: + body = e.read().decode("utf-8", errors="replace") + logger.warning(f"AcoustID API fejl {e.code}: {body[:200]}") + return None if data.get("status") != "ok": + logger.warning(f"AcoustID status: {data.get('status')} — {data.get('error', {}).get('message','')}") return None results = data.get("results", []) @@ -116,10 +125,10 @@ def lookup_acoustid(fingerprint: str, duration: int) -> dict | None: return None -def run_acoustid_scan(db_path: str, on_progress=None, stop_event: threading.Event = None): +def run_acoustid_scan(db_path: str, api_key: str = "", on_progress=None, stop_event: threading.Event = None): """ Gennemgå alle sange uden MBID og forsøg AcoustID fingerprinting. - Kører som baggrundsjob. + Kører i batches på MAX_PER_SESSION med BATCH_DELAY pause imellem. """ import sqlite3 @@ -130,73 +139,108 @@ def run_acoustid_scan(db_path: str, on_progress=None, stop_event: threading.Even on_progress(0, 0, "fpcalc ikke installeret") return + key = api_key or ACOUSTID_API_KEY + if not key: + logger.warning("AcoustID: ingen API-nøgle — spring over") + return + logger.info(f"AcoustID: fpcalc fundet: {fpcalc}") - conn = sqlite3.connect(db_path) - conn.row_factory = sqlite3.Row + batch_num = 0 + total_found = 0 - # Find sange uden MBID der har en lokal fil - rows = conn.execute(""" - SELECT id, local_path, title, artist - FROM songs - WHERE (mbid IS NULL OR mbid = '') - AND file_missing = 0 - AND local_path IS NOT NULL AND local_path != '' - ORDER BY RANDOM() - LIMIT 500 - """).fetchall() - - total = len(rows) - done = 0 - found = 0 - - logger.info(f"AcoustID: {total} sange uden MBID") - - for row in rows: + while True: if stop_event and stop_event.is_set(): logger.info("AcoustID: stoppet af bruger") break - path = row["local_path"] - if not Path(path).exists(): + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + + rows = conn.execute(""" + SELECT id, local_path, title, artist + FROM songs + WHERE (mbid IS NULL OR mbid = '') + AND file_missing = 0 + AND local_path IS NOT NULL AND local_path != '' + ORDER BY RANDOM() + LIMIT ? + """, (MAX_PER_SESSION,)).fetchall() + + conn.close() + + if not rows: + logger.info(f"AcoustID: alle sange har nu MBID — stopper") + if on_progress: + on_progress(1, 1, f"Færdig — {total_found} sange fik MBID i alt") + break + + batch_num += 1 + total = len(rows) + done = 0 + found = 0 + logger.info(f"AcoustID: batch {batch_num} — {total} sange") + + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + + for row in rows: + if stop_event and stop_event.is_set(): + conn.close() + return + + path = row["local_path"] + if not Path(path).exists(): + done += 1 + continue + + if on_progress: + on_progress(done, total, row["title"] or path) + + fp_result = fingerprint_file(path, fpcalc) + if not fp_result: + done += 1 + time.sleep(0.1) + continue + + fingerprint, duration = fp_result + + result = lookup_acoustid(fingerprint, duration, key) + time.sleep(API_DELAY) + + if result: + mbid = result.get("mbid", "") + acoustid = result.get("acoustid", "") + conn.execute( + "UPDATE songs SET mbid=?, acoustid=? WHERE id=?", + (mbid or None, acoustid or None, row["id"]) + ) + conn.commit() + found += 1 + total_found += 1 + logger.info( + f"AcoustID: {row['title']} → MBID={mbid[:8] if mbid else '—'}" + ) + if mbid and path: + try: + from local.tag_reader import write_mbid_to_file + write_mbid_to_file(path, mbid) + except Exception as e: + logger.warning(f"AcoustID: kunne ikke skrive MBID til fil: {e}") + done += 1 - continue + + conn.close() + logger.info(f"AcoustID: batch {batch_num} færdig — {found}/{total} fik MBID") if on_progress: - on_progress(done, total, row["title"] or path) + on_progress(done, total, f"Batch {batch_num}: {found}/{total} fik MBID — venter 60 sek") - # Fingerprint filen - fp_result = fingerprint_file(path, fpcalc) - if not fp_result: - done += 1 - time.sleep(0.05) - continue - - fingerprint, duration = fp_result - - # Spørg AcoustID - result = lookup_acoustid(fingerprint, duration) - time.sleep(API_DELAY) # Respektér rate limit - - if result: - mbid = result.get("mbid", "") - acoustid = result.get("acoustid", "") - conn.execute( - "UPDATE songs SET mbid=?, acoustid=? WHERE id=?", - (mbid or None, acoustid or None, row["id"]) - ) - conn.commit() - found += 1 - logger.info( - f"AcoustID: {row['title']} → MBID={mbid[:8] if mbid else '—'}" - ) - - done += 1 - - conn.close() - logger.info(f"AcoustID: færdig — {found}/{total} sange fik MBID") - if on_progress: - on_progress(total, total, f"Færdig — {found} sange fik MBID") + # Vent et minut inden næste batch + for _ in range(60): + if stop_event and stop_event.is_set(): + return + time.sleep(1) class AcoustIDWorker: @@ -211,7 +255,7 @@ class AcoustIDWorker: self._stop_event = threading.Event() self._running = False - def start(self, on_progress=None): + def start(self, api_key: str = "", on_progress=None): """Start baggrunds-fingerprinting. Gør ingenting hvis allerede kører.""" if self._running: return @@ -222,6 +266,7 @@ class AcoustIDWorker: try: run_acoustid_scan( self._db_path, + api_key=api_key, on_progress=on_progress, stop_event=self._stop_event, ) diff --git a/linedance-app/local/local_db.py b/linedance-app/local/local_db.py index ede83fcb..b030f011 100644 --- a/linedance-app/local/local_db.py +++ b/linedance-app/local/local_db.py @@ -256,6 +256,11 @@ MIGRATIONS: dict[int, list[str]] = { """ALTER TABLE playlists ADD COLUMN is_linked INTEGER NOT NULL DEFAULT 0""", """ALTER TABLE playlists ADD COLUMN server_permission TEXT NOT NULL DEFAULT 'view'""", ], + 8: [ + # MusicBrainz og AcoustID matching + """ALTER TABLE songs ADD COLUMN mbid TEXT""", + """ALTER TABLE songs ADD COLUMN acoustid TEXT""", + ], } diff --git a/linedance-app/local/tag_reader.py b/linedance-app/local/tag_reader.py index 1284125b..dd2e3ce9 100644 --- a/linedance-app/local/tag_reader.py +++ b/linedance-app/local/tag_reader.py @@ -427,3 +427,66 @@ def analyze_and_save_bpm(path: str | Path, song_id: str) -> float | None: except Exception as e: print(f"BPM gem fejl: {e}") return bpm + + +# ── MBID skrivning ──────────────────────────────────────────────────────────── + +def write_mbid_to_file(path: str | Path, mbid: str) -> bool: + """Skriv MusicBrainz Recording ID til filens tags.""" + path = Path(path) + ext = path.suffix.lower() + try: + if ext == ".mp3": + return _write_mbid_mp3(path, mbid) + elif ext in (".flac", ".ogg", ".opus"): + return _write_mbid_vorbis(path, mbid) + elif ext in (".m4a", ".aac"): + return _write_mbid_m4a(path, mbid) + return False + except Exception as e: + logger.warning(f"MBID skrivefejl {path}: {e}") + return False + + +def _write_mbid_mp3(path: Path, mbid: str) -> bool: + try: + from mutagen.id3 import ID3, TXXX, UFID + tags = ID3(str(path)) + # Skriv som TXXX:MusicBrainz Recording Id + tags.delall("TXXX:MusicBrainz Recording Id") + tags.add(TXXX(encoding=3, desc="MusicBrainz Recording Id", text=mbid)) + # Skriv også som UFID + tags.delall("UFID:http://musicbrainz.org") + tags.add(UFID(owner="http://musicbrainz.org", data=mbid.encode("utf-8"))) + tags.save(str(path)) + return True + except Exception as e: + logger.warning(f"MBID MP3 skrivefejl {path}: {e}") + return False + + +def _write_mbid_vorbis(path: Path, mbid: str) -> bool: + try: + from mutagen import File as MutagenFile + audio = MutagenFile(str(path), easy=False) + if audio is None or audio.tags is None: + return False + audio.tags["musicbrainz_trackid"] = mbid + audio.save() + return True + except Exception as e: + logger.warning(f"MBID Vorbis skrivefejl {path}: {e}") + return False + + +def _write_mbid_m4a(path: Path, mbid: str) -> bool: + try: + from mutagen.mp4 import MP4, MP4FreeForm + audio = MP4(str(path)) + key = "----:com.apple.iTunes:MusicBrainz Recording Id" + audio[key] = [MP4FreeForm(mbid.encode("utf-8"))] + audio.save() + return True + except Exception as e: + logger.warning(f"MBID M4A skrivefejl {path}: {e}") + return False diff --git a/linedance-app/ui/main_window.py b/linedance-app/ui/main_window.py index b4cdee56..2c33525c 100644 --- a/linedance-app/ui/main_window.py +++ b/linedance-app/ui/main_window.py @@ -1,6 +1,8 @@ """ main_window.py — Linedance afspiller hovedvindue. """ +import logging +logger = logging.getLogger(__name__) from PyQt6.QtWidgets import ( QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, @@ -565,9 +567,11 @@ class MainWindow(QMainWindow): pass QTimer.singleShot(5000, self.start_background_scan) - # Start AcoustID fingerprinting efter 60 sekunder hvis aktiveret - if self._settings.get("acoustid_enabled", False): - QTimer.singleShot(60000, self._start_acoustid) + # Start AcoustID fingerprinting efter 10 sekunder hvis aktiveret + acoustid_on = self._settings.get("acoustid_enabled", False) + logger.info(f"AcoustID indstilling: {acoustid_on}") + if acoustid_on: + QTimer.singleShot(10000, self._start_acoustid) def _start_acoustid(self): """Start AcoustID fingerprinting i baggrunden.""" @@ -590,7 +594,10 @@ class MainWindow(QMainWindow): f"AcoustID: {done}/{total} — {title}", 3000 ) - self._acoustid_worker.start(on_progress=on_progress) + self._acoustid_worker.start( + api_key=self._settings.get("acoustid_api_key", ""), + on_progress=on_progress, + ) self._set_status("AcoustID fingerprinting startet i baggrunden", 4000) def start_background_scan(self): diff --git a/linedance-app/ui/settings_dialog.py b/linedance-app/ui/settings_dialog.py index 4b1e06b9..44a3ce92 100644 --- a/linedance-app/ui/settings_dialog.py +++ b/linedance-app/ui/settings_dialog.py @@ -29,6 +29,7 @@ SETTINGS_KEY_PREV_DEVICE = "playback/audio_device_preview" SETTINGS_KEY_AFTER_SONG = "playback/after_song_mode" SETTINGS_KEY_AFTER_DELAY = "playback/after_song_delay" SETTINGS_KEY_ACOUSTID = "playback/acoustid_enabled" +SETTINGS_KEY_ACOUSTID_KEY = "playback/acoustid_api_key" def load_settings() -> dict: @@ -51,7 +52,8 @@ def load_settings() -> dict: "audio_device_preview":s.value(SETTINGS_KEY_PREV_DEVICE, ""), "after_song_mode": s.value(SETTINGS_KEY_AFTER_SONG, "manual"), "after_song_delay": s.value(SETTINGS_KEY_AFTER_DELAY, 2, type=int), - "acoustid_enabled": s.value(SETTINGS_KEY_ACOUSTID, False, type=bool), + "acoustid_enabled": s.value(SETTINGS_KEY_ACOUSTID, False, type=bool), + "acoustid_api_key": s.value(SETTINGS_KEY_ACOUSTID_KEY, ""), } @@ -74,7 +76,8 @@ def save_settings(values: dict): s.setValue(SETTINGS_KEY_PREV_DEVICE, values.get("audio_device_preview", "")) s.setValue(SETTINGS_KEY_AFTER_SONG, values.get("after_song_mode", "manual")) s.setValue(SETTINGS_KEY_AFTER_DELAY, values.get("after_song_delay", 2)) - s.setValue(SETTINGS_KEY_ACOUSTID, values.get("acoustid_enabled", False)) + s.setValue(SETTINGS_KEY_ACOUSTID, values.get("acoustid_enabled", False)) + s.setValue(SETTINGS_KEY_ACOUSTID_KEY, values.get("acoustid_api_key", "")) class SettingsDialog(QDialog): @@ -272,10 +275,17 @@ class SettingsDialog(QDialog): self._chk_acoustid.setToolTip( "Analyserer sange uden MBID og slår dem op i AcoustID-databasen.\n" "Kræver fpcalc (Chromaprint) installeret.\n" - "Startes automatisk 60 sekunder efter opstart." + "Startes automatisk 10 sekunder efter opstart." ) grp4_layout.addWidget(self._chk_acoustid) + key_row = QHBoxLayout() + key_row.addWidget(QLabel("API-nøgle:")) + self._acoustid_key = QLineEdit() + self._acoustid_key.setPlaceholderText("Hent gratis på acoustid.org/api-key") + key_row.addWidget(self._acoustid_key) + grp4_layout.addLayout(key_row) + note4 = QLabel( "fpcalc skal installeres separat:\n" " Linux: sudo apt install libchromaprint-tools\n" @@ -476,6 +486,7 @@ class SettingsDialog(QDialog): self._radio_manual.setChecked(True) self._spin_after_delay.setValue(v.get("after_song_delay", 2)) self._chk_acoustid.setChecked(v.get("acoustid_enabled", False)) + self._acoustid_key.setText(v.get("acoustid_api_key", "")) # ── Gem ─────────────────────────────────────────────────────────────────── @@ -502,6 +513,7 @@ class SettingsDialog(QDialog): ), "after_song_delay": self._spin_after_delay.value(), "acoustid_enabled": self._chk_acoustid.isChecked(), + "acoustid_api_key": self._acoustid_key.text().strip(), } save_settings(values) self._values = values