This commit is contained in:
2026-04-14 17:00:29 +02:00
parent 460b41a8c5
commit 4aba2f02a2
10 changed files with 840 additions and 76 deletions

View File

@@ -118,8 +118,63 @@ def set_visibility(
# ── Hent playliste-indhold ──────────────────────────────────────────────────── # ── Hent playliste-indhold ────────────────────────────────────────────────────
@router.get("/playlists/{project_id}") @router.get("/public")
def get_shared_playlist( 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, project_id: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
me: User = Depends(get_current_user), me: User = Depends(get_current_user),

View File

@@ -10,6 +10,16 @@ services:
networks: networks:
- linedance - linedance
web:
build: ./web
restart: always
ports:
- "80:80"
networks:
- linedance
depends_on:
- api
adminer: adminer:
image: adminer image: adminer
restart: always restart: always

View File

@@ -0,0 +1,3 @@
FROM nginx:alpine
COPY public /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf

View File

@@ -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;
}

View File

@@ -0,0 +1,551 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LineDance Player — Public Playlister</title>
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0e0f11;
--surface: #16181c;
--border: #2a2d35;
--accent: #e8a020;
--accent2: #c47a10;
--text: #e8eaf0;
--muted: #6b7080;
--green: #2ecc71;
--mono: 'DM Mono', monospace;
--sans: 'DM Sans', sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--sans);
min-height: 100vh;
}
/* ── Header ── */
header {
border-bottom: 1px solid var(--border);
padding: 0 2rem;
display: flex;
align-items: center;
gap: 1.5rem;
height: 60px;
position: sticky;
top: 0;
background: var(--bg);
z-index: 100;
}
.logo {
font-family: var(--mono);
font-size: 1rem;
letter-spacing: 0.05em;
}
.logo span { color: var(--accent); }
nav { margin-left: auto; display: flex; gap: 1rem; align-items: center; }
.btn {
font-family: var(--sans);
font-size: 0.85rem;
font-weight: 500;
padding: 0.4rem 1rem;
border-radius: 6px;
border: 1px solid var(--border);
background: transparent;
color: var(--text);
cursor: pointer;
transition: all 0.15s;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.btn:hover { background: var(--surface); border-color: var(--accent); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #111; font-weight: 700; }
.btn.primary:hover { background: var(--accent2); border-color: var(--accent2); }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
/* ── Hero ── */
.hero {
padding: 4rem 2rem 3rem;
max-width: 900px;
margin: 0 auto;
}
.hero h1 {
font-size: clamp(2rem, 5vw, 3.5rem);
font-weight: 700;
line-height: 1.1;
margin-bottom: 1rem;
}
.hero h1 em { color: var(--accent); font-style: normal; }
.hero p { color: var(--muted); font-size: 1.1rem; max-width: 500px; }
/* ── Grid ── */
main { max-width: 900px; margin: 0 auto; padding: 0 2rem 4rem; }
.section-title {
font-family: var(--mono);
font-size: 0.75rem;
letter-spacing: 0.15em;
color: var(--muted);
text-transform: uppercase;
margin-bottom: 1.5rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--border);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 1rem;
}
/* ── Playlist card ── */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1.25rem;
cursor: pointer;
transition: all 0.2s;
position: relative;
overflow: hidden;
}
.card::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
background: var(--accent);
transform: scaleX(0);
transition: transform 0.2s;
transform-origin: left;
}
.card:hover { border-color: var(--accent); transform: translateY(-2px); }
.card:hover::before { transform: scaleX(1); }
.card-title {
font-weight: 600;
font-size: 1rem;
margin-bottom: 0.4rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-owner {
font-size: 0.8rem;
color: var(--muted);
font-family: var(--mono);
margin-bottom: 0.75rem;
}
.card-meta {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.8rem;
color: var(--muted);
}
.badge {
font-family: var(--mono);
font-size: 0.7rem;
padding: 0.2rem 0.5rem;
border-radius: 4px;
background: rgba(232, 160, 32, 0.15);
color: var(--accent);
border: 1px solid rgba(232, 160, 32, 0.3);
}
/* ── Detail panel ── */
#detail {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.7);
backdrop-filter: blur(4px);
z-index: 200;
align-items: center;
justify-content: center;
padding: 2rem;
}
#detail.open { display: flex; }
.detail-box {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
width: 100%;
max-width: 560px;
max-height: 80vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.detail-header {
padding: 1.5rem;
border-bottom: 1px solid var(--border);
display: flex;
align-items: flex-start;
gap: 1rem;
}
.detail-header-text { flex: 1; min-width: 0; }
.detail-header h2 {
font-size: 1.2rem;
font-weight: 600;
margin-bottom: 0.25rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.detail-header p { font-size: 0.85rem; color: var(--muted); }
.detail-songs {
flex: 1;
overflow-y: auto;
padding: 0.5rem 0;
}
.detail-songs::-webkit-scrollbar { width: 4px; }
.detail-songs::-webkit-scrollbar-track { background: transparent; }
.detail-songs::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
.song-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 1.5rem;
transition: background 0.1s;
}
.song-row:hover { background: rgba(255,255,255,0.03); }
.song-num {
font-family: var(--mono);
font-size: 0.75rem;
color: var(--muted);
width: 1.5rem;
text-align: right;
flex-shrink: 0;
}
.song-info { flex: 1; min-width: 0; }
.song-title {
font-size: 0.9rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.song-artist {
font-size: 0.78rem;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.song-dance {
font-family: var(--mono);
font-size: 0.72rem;
color: var(--accent);
flex-shrink: 0;
}
.detail-footer {
padding: 1rem 1.5rem;
border-top: 1px solid var(--border);
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
/* ── Login modal ── */
#login-modal {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.7);
backdrop-filter: blur(4px);
z-index: 300;
align-items: center;
justify-content: center;
}
#login-modal.open { display: flex; }
.login-box {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
padding: 2rem;
width: 100%;
max-width: 360px;
}
.login-box h3 { font-size: 1.1rem; margin-bottom: 1.5rem; }
.form-row { margin-bottom: 1rem; }
.form-row label { display: block; font-size: 0.8rem; color: var(--muted); margin-bottom: 0.4rem; }
.form-row input {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.5rem 0.75rem;
color: var(--text);
font-family: var(--sans);
font-size: 0.9rem;
outline: none;
transition: border-color 0.15s;
}
.form-row input:focus { border-color: var(--accent); }
.msg { font-size: 0.82rem; padding: 0.6rem 0.75rem; border-radius: 6px; margin-bottom: 1rem; }
.msg.error { background: rgba(231,76,60,0.15); color: #e74c3c; border: 1px solid rgba(231,76,60,0.3); }
.msg.success { background: rgba(46,204,113,0.15); color: var(--green); border: 1px solid rgba(46,204,113,0.3); }
/* ── Empty / loading ── */
.empty {
text-align: center;
padding: 4rem 2rem;
color: var(--muted);
font-size: 0.95rem;
}
.spinner {
width: 32px; height: 32px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin { to { transform: rotate(360deg); } }
.fade-in { animation: fadeIn 0.3s ease; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
</style>
</head>
<body>
<header>
<div class="logo">LINE<span>DANCE</span> PLAYER</div>
<nav>
<span id="user-label" style="font-size:0.85rem;color:var(--muted)"></span>
<button class="btn" id="btn-login">Log ind</button>
<button class="btn" id="btn-logout" style="display:none">Log ud</button>
</nav>
</header>
<div class="hero">
<h1>Public<br><em>playlister</em></h1>
<p>Browse og kopiér playlister delt af LineDance Player-brugere.</p>
</div>
<main>
<div class="section-title">Alle public playlister</div>
<div id="grid" class="grid">
<div class="empty"><div class="spinner"></div>Henter playlister...</div>
</div>
</main>
<!-- Detail panel -->
<div id="detail">
<div class="detail-box fade-in">
<div class="detail-header">
<div class="detail-header-text">
<h2 id="d-title"></h2>
<p id="d-meta"></p>
</div>
<button class="btn" id="btn-close-detail"></button>
</div>
<div class="detail-songs" id="d-songs"></div>
<div class="detail-footer">
<button class="btn" id="btn-close-detail2">Luk</button>
<button class="btn primary" id="btn-copy">Kopiér til min konto</button>
</div>
</div>
</div>
<!-- Login modal -->
<div id="login-modal">
<div class="login-box fade-in">
<h3>Log ind</h3>
<div id="login-msg"></div>
<div class="form-row">
<label>Brugernavn eller e-mail</label>
<input type="text" id="inp-user" placeholder="dit@email.dk">
</div>
<div class="form-row">
<label>Adgangskode</label>
<input type="password" id="inp-pass" placeholder="••••••••">
</div>
<div style="display:flex;gap:.75rem;justify-content:flex-end;margin-top:1.25rem">
<button class="btn" id="btn-cancel-login">Annuller</button>
<button class="btn primary" id="btn-do-login">Log ind</button>
</div>
</div>
</div>
<script>
const API = 'https://linedanceplayer.dk';
let token = localStorage.getItem('ld_token') || '';
let username = localStorage.getItem('ld_user') || '';
let currentPlaylist = null;
// ── Auth UI ──
function updateAuthUI() {
const lbl = document.getElementById('user-label');
const btnLogin = document.getElementById('btn-login');
const btnLogout = document.getElementById('btn-logout');
if (token) {
lbl.textContent = username;
btnLogin.style.display = 'none';
btnLogout.style.display = '';
} else {
lbl.textContent = '';
btnLogin.style.display = '';
btnLogout.style.display = 'none';
}
}
document.getElementById('btn-login').onclick = () =>
document.getElementById('login-modal').classList.add('open');
document.getElementById('btn-cancel-login').onclick = () =>
document.getElementById('login-modal').classList.remove('open');
document.getElementById('btn-logout').onclick = () => {
token = ''; username = '';
localStorage.removeItem('ld_token');
localStorage.removeItem('ld_user');
updateAuthUI();
};
document.getElementById('btn-do-login').onclick = async () => {
const user = document.getElementById('inp-user').value.trim();
const pass = document.getElementById('inp-pass').value;
const msg = document.getElementById('login-msg');
msg.innerHTML = '';
try {
const fd = new FormData();
fd.append('username', user);
fd.append('password', pass);
const r = await fetch(`${API}/auth/login`, { method: 'POST', body: fd });
const d = await r.json();
if (!r.ok) throw new Error(d.detail || 'Login fejlede');
token = d.access_token;
username = user;
localStorage.setItem('ld_token', token);
localStorage.setItem('ld_user', username);
document.getElementById('login-modal').classList.remove('open');
updateAuthUI();
msg.innerHTML = '';
} catch(e) {
msg.innerHTML = `<div class="msg error">${e.message}</div>`;
}
};
// ── Load playlister ──
async function loadPlaylists() {
const grid = document.getElementById('grid');
try {
const r = await fetch(`${API}/sharing/public`);
const lists = await r.json();
if (!lists.length) {
grid.innerHTML = '<div class="empty">Ingen public playlister endnu.</div>';
return;
}
grid.innerHTML = lists.map(p => `
<div class="card fade-in" data-id="${p.id}">
<div class="card-title">${esc(p.name)}</div>
<div class="card-owner">@ ${esc(p.owner)}</div>
<div class="card-meta">
<span class="badge">${p.song_count} sange</span>
</div>
</div>
`).join('');
grid.querySelectorAll('.card').forEach(c =>
c.onclick = () => openDetail(c.dataset.id)
);
} catch(e) {
grid.innerHTML = `<div class="empty">Kunne ikke hente playlister.<br>${e.message}</div>`;
}
}
// ── Detail ──
async function openDetail(id) {
currentPlaylist = id;
document.getElementById('detail').classList.add('open');
document.getElementById('d-songs').innerHTML =
'<div class="empty"><div class="spinner"></div></div>';
try {
const r = await fetch(`${API}/sharing/playlists/${id}`);
const p = await r.json();
document.getElementById('d-title').textContent = p.name;
document.getElementById('d-meta').textContent =
`${p.songs.length} sange · ${p.owner || ''}`;
document.getElementById('d-songs').innerHTML = p.songs.length
? p.songs.map((s, i) => `
<div class="song-row">
<span class="song-num">${i+1}</span>
<div class="song-info">
<div class="song-title">${esc(s.title)}</div>
<div class="song-artist">${esc(s.artist)}</div>
</div>
${s.dance_override ? `<span class="song-dance">${esc(s.dance_override)}</span>` : ''}
</div>`).join('')
: '<div class="empty">Ingen sange i listen.</div>';
} catch(e) {
document.getElementById('d-songs').innerHTML =
`<div class="empty">Kunne ikke hente detaljer.</div>`;
}
}
function closeDetail() {
document.getElementById('detail').classList.remove('open');
currentPlaylist = null;
}
document.getElementById('btn-close-detail').onclick = closeDetail;
document.getElementById('btn-close-detail2').onclick = closeDetail;
document.getElementById('detail').onclick = e => {
if (e.target === document.getElementById('detail')) closeDetail();
};
// ── Kopiér ──
document.getElementById('btn-copy').onclick = async () => {
if (!token) {
document.getElementById('login-modal').classList.add('open');
return;
}
const btn = document.getElementById('btn-copy');
btn.disabled = true;
btn.textContent = 'Kopierer...';
try {
const r = await fetch(`${API}/sharing/playlists/${currentPlaylist}/copy`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` }
});
const d = await r.json();
if (!r.ok) throw new Error(d.detail || 'Fejl');
btn.textContent = '✓ Kopieret!';
btn.style.background = 'var(--green)';
setTimeout(() => {
btn.textContent = 'Kopiér til min konto';
btn.style.background = '';
btn.disabled = false;
}, 2500);
} catch(e) {
btn.textContent = '⚠ ' + e.message;
btn.disabled = false;
}
};
function esc(s) {
return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
updateAuthUI();
loadPlaylists();
</script>
</body>
</html>

View File

@@ -20,11 +20,14 @@ logger = logging.getLogger(__name__)
# AcoustID API nøgle — gratis til open-source apps # AcoustID API nøgle — gratis til open-source apps
# https://acoustid.org/api-key # 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" ACOUSTID_API_URL = "https://api.acoustid.org/v2/lookup"
# Pause mellem API-kald (AcoustID tillader 3/sek) # Pause mellem API-kald — rolig baggrundskørsel
API_DELAY = 0.4 API_DELAY = 5.0
# Maks sange per session — fordeles over mange opstart
MAX_PER_SESSION = 20
def find_fpcalc() -> str | None: def find_fpcalc() -> str | None:
@@ -72,16 +75,16 @@ def fingerprint_file(path: str, fpcalc: str) -> tuple[str, int] | None:
return 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. Spørg AcoustID API om MBID for et fingerprint.
Returnerer dict med 'mbid' og 'acoustid' eller None. Returnerer dict med 'mbid' og 'acoustid' eller None.
""" """
try: try:
import urllib.request, urllib.parse import urllib.request, urllib.parse, urllib.error
params = urllib.parse.urlencode({ params = urllib.parse.urlencode({
"client": ACOUSTID_API_KEY, "client": api_key or ACOUSTID_API_KEY,
"fingerprint": fingerprint, "fingerprint": fingerprint,
"duration": duration, "duration": duration,
"meta": "recordings", "meta": "recordings",
@@ -90,10 +93,16 @@ def lookup_acoustid(fingerprint: str, duration: int) -> dict | None:
req = urllib.request.Request(url) req = urllib.request.Request(url)
req.add_header("User-Agent", "LineDancePlayer/1.0") req.add_header("User-Agent", "LineDancePlayer/1.0")
with urllib.request.urlopen(req, timeout=10) as resp: try:
data = json.loads(resp.read()) 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": if data.get("status") != "ok":
logger.warning(f"AcoustID status: {data.get('status')}{data.get('error', {}).get('message','')}")
return None return None
results = data.get("results", []) results = data.get("results", [])
@@ -116,10 +125,10 @@ def lookup_acoustid(fingerprint: str, duration: int) -> dict | None:
return 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. 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 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") on_progress(0, 0, "fpcalc ikke installeret")
return 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}") logger.info(f"AcoustID: fpcalc fundet: {fpcalc}")
conn = sqlite3.connect(db_path) batch_num = 0
conn.row_factory = sqlite3.Row total_found = 0
# Find sange uden MBID der har en lokal fil while True:
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:
if stop_event and stop_event.is_set(): if stop_event and stop_event.is_set():
logger.info("AcoustID: stoppet af bruger") logger.info("AcoustID: stoppet af bruger")
break break
path = row["local_path"] conn = sqlite3.connect(db_path)
if not Path(path).exists(): 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 done += 1
continue
conn.close()
logger.info(f"AcoustID: batch {batch_num} færdig — {found}/{total} fik MBID")
if on_progress: 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 # Vent et minut inden næste batch
fp_result = fingerprint_file(path, fpcalc) for _ in range(60):
if not fp_result: if stop_event and stop_event.is_set():
done += 1 return
time.sleep(0.05) time.sleep(1)
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")
class AcoustIDWorker: class AcoustIDWorker:
@@ -211,7 +255,7 @@ class AcoustIDWorker:
self._stop_event = threading.Event() self._stop_event = threading.Event()
self._running = False 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.""" """Start baggrunds-fingerprinting. Gør ingenting hvis allerede kører."""
if self._running: if self._running:
return return
@@ -222,6 +266,7 @@ class AcoustIDWorker:
try: try:
run_acoustid_scan( run_acoustid_scan(
self._db_path, self._db_path,
api_key=api_key,
on_progress=on_progress, on_progress=on_progress,
stop_event=self._stop_event, stop_event=self._stop_event,
) )

View File

@@ -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 is_linked INTEGER NOT NULL DEFAULT 0""",
"""ALTER TABLE playlists ADD COLUMN server_permission TEXT NOT NULL DEFAULT 'view'""", """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""",
],
} }

View File

@@ -427,3 +427,66 @@ def analyze_and_save_bpm(path: str | Path, song_id: str) -> float | None:
except Exception as e: except Exception as e:
print(f"BPM gem fejl: {e}") print(f"BPM gem fejl: {e}")
return bpm 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

View File

@@ -1,6 +1,8 @@
""" """
main_window.py — Linedance afspiller hovedvindue. main_window.py — Linedance afspiller hovedvindue.
""" """
import logging
logger = logging.getLogger(__name__)
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
@@ -565,9 +567,11 @@ class MainWindow(QMainWindow):
pass pass
QTimer.singleShot(5000, self.start_background_scan) QTimer.singleShot(5000, self.start_background_scan)
# Start AcoustID fingerprinting efter 60 sekunder hvis aktiveret # Start AcoustID fingerprinting efter 10 sekunder hvis aktiveret
if self._settings.get("acoustid_enabled", False): acoustid_on = self._settings.get("acoustid_enabled", False)
QTimer.singleShot(60000, self._start_acoustid) logger.info(f"AcoustID indstilling: {acoustid_on}")
if acoustid_on:
QTimer.singleShot(10000, self._start_acoustid)
def _start_acoustid(self): def _start_acoustid(self):
"""Start AcoustID fingerprinting i baggrunden.""" """Start AcoustID fingerprinting i baggrunden."""
@@ -590,7 +594,10 @@ class MainWindow(QMainWindow):
f"AcoustID: {done}/{total}{title}", 3000 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) self._set_status("AcoustID fingerprinting startet i baggrunden", 4000)
def start_background_scan(self): def start_background_scan(self):

View File

@@ -29,6 +29,7 @@ SETTINGS_KEY_PREV_DEVICE = "playback/audio_device_preview"
SETTINGS_KEY_AFTER_SONG = "playback/after_song_mode" SETTINGS_KEY_AFTER_SONG = "playback/after_song_mode"
SETTINGS_KEY_AFTER_DELAY = "playback/after_song_delay" SETTINGS_KEY_AFTER_DELAY = "playback/after_song_delay"
SETTINGS_KEY_ACOUSTID = "playback/acoustid_enabled" SETTINGS_KEY_ACOUSTID = "playback/acoustid_enabled"
SETTINGS_KEY_ACOUSTID_KEY = "playback/acoustid_api_key"
def load_settings() -> dict: def load_settings() -> dict:
@@ -51,7 +52,8 @@ def load_settings() -> dict:
"audio_device_preview":s.value(SETTINGS_KEY_PREV_DEVICE, ""), "audio_device_preview":s.value(SETTINGS_KEY_PREV_DEVICE, ""),
"after_song_mode": s.value(SETTINGS_KEY_AFTER_SONG, "manual"), "after_song_mode": s.value(SETTINGS_KEY_AFTER_SONG, "manual"),
"after_song_delay": s.value(SETTINGS_KEY_AFTER_DELAY, 2, type=int), "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_PREV_DEVICE, values.get("audio_device_preview", ""))
s.setValue(SETTINGS_KEY_AFTER_SONG, values.get("after_song_mode", "manual")) 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_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): class SettingsDialog(QDialog):
@@ -272,10 +275,17 @@ class SettingsDialog(QDialog):
self._chk_acoustid.setToolTip( self._chk_acoustid.setToolTip(
"Analyserer sange uden MBID og slår dem op i AcoustID-databasen.\n" "Analyserer sange uden MBID og slår dem op i AcoustID-databasen.\n"
"Kræver fpcalc (Chromaprint) installeret.\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) 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( note4 = QLabel(
"fpcalc skal installeres separat:\n" "fpcalc skal installeres separat:\n"
" Linux: sudo apt install libchromaprint-tools\n" " Linux: sudo apt install libchromaprint-tools\n"
@@ -476,6 +486,7 @@ class SettingsDialog(QDialog):
self._radio_manual.setChecked(True) self._radio_manual.setChecked(True)
self._spin_after_delay.setValue(v.get("after_song_delay", 2)) self._spin_after_delay.setValue(v.get("after_song_delay", 2))
self._chk_acoustid.setChecked(v.get("acoustid_enabled", False)) self._chk_acoustid.setChecked(v.get("acoustid_enabled", False))
self._acoustid_key.setText(v.get("acoustid_api_key", ""))
# ── Gem ─────────────────────────────────────────────────────────────────── # ── Gem ───────────────────────────────────────────────────────────────────
@@ -502,6 +513,7 @@ class SettingsDialog(QDialog):
), ),
"after_song_delay": self._spin_after_delay.value(), "after_song_delay": self._spin_after_delay.value(),
"acoustid_enabled": self._chk_acoustid.isChecked(), "acoustid_enabled": self._chk_acoustid.isChecked(),
"acoustid_api_key": self._acoustid_key.text().strip(),
} }
save_settings(values) save_settings(values)
self._values = values self._values = values