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
+
+
+
+
+
+
+ LINEDANCE PLAYER
+
+
+
+
+
Public
playlister
+
Browse og kopiér playlister delt af LineDance Player-brugere.
+
+
+
+ Alle public playlister
+
+
+
+
+
+
+
+
+
+
Log ind
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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