NY db struktur

This commit is contained in:
2026-04-19 23:45:59 +02:00
parent a9aa451d63
commit efc30cdbb2
6 changed files with 1056 additions and 1390 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,11 @@
"""
scanner.py — Scanning af musikbiblioteker i baggrunden.
scanner.py — Scanning af musikbiblioteker i baggrunden. v0.9
Kører som en separat subprocess der scanner ét bibliotek ad gangen
og rapporterer fremgang via stdout JSON-linjer.
Kan også importeres direkte og bruges via ScanWorker QThread.
Skriver til files-tabellen og finder/opretter sange i songs-tabellen.
"""
import os
import sys
import json
import sqlite3
import uuid
import logging
import time
from pathlib import Path
logger = logging.getLogger(__name__)
@@ -19,8 +13,8 @@ logger = logging.getLogger(__name__)
SUPPORTED = {'.mp3', '.flac', '.m4a', '.ogg', '.wav', '.aiff', '.wma'}
def is_supported(path: Path) -> bool:
return path.suffix.lower() in SUPPORTED
def is_supported(path) -> bool:
return Path(path).suffix.lower() in SUPPORTED
def get_file_mtime(path: Path) -> str:
@@ -32,30 +26,29 @@ def get_file_mtime(path: Path) -> str:
def scan_library(library_id: int, library_path: str, db_path: str,
overwrite_bpm: bool = False,
progress_callback=None):
progress_callback=None) -> int:
"""
Scan ét bibliotek og upsert sange til SQLite.
progress_callback(done, total, current_file) kaldes løbende.
Scan ét bibliotek og upsert til files + songs tabellerne.
Returnerer antal scannede filer.
"""
import sqlite3
from local.tag_reader import read_tags
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
from local.local_db import find_or_create_song, upsert_file
base = Path(library_path)
if not base.exists():
conn.close()
return 0
# Byg indeks over kendte filer
# Byg indeks over kendte filer (path → mtime)
conn = sqlite3.connect(db_path, timeout=10)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
known = {}
for row in conn.execute(
"SELECT local_path, file_modified_at, file_missing FROM songs WHERE library_id=?",
(library_id,)
"SELECT local_path, file_modified_at FROM files WHERE file_missing=0"
).fetchall():
# Sange markeret som manglende medtages ikke i known — de skal altid genscanes
if not row["file_missing"]:
known[row["local_path"]] = row["file_modified_at"]
known[row["local_path"]] = row["file_modified_at"]
# Find alle musikfiler
all_files = []
@@ -68,8 +61,6 @@ def scan_library(library_id: int, library_path: str, db_path: str,
total = len(all_files)
done = 0
import time
for fp in all_files:
path_str = str(fp)
mtime = get_file_mtime(fp)
@@ -77,108 +68,55 @@ def scan_library(library_id: int, library_path: str, db_path: str,
if progress_callback:
progress_callback(done, total, fp.name)
# Spring over hvis ikke ændret
# Spring over uændrede filer
if path_str in known and known[path_str] == mtime:
done += 1
# Yield hvert 100. fil så andre tråde kan køre
if done % 100 == 0:
time.sleep(0.005)
time.sleep(0.005)
continue
try:
tags = read_tags(fp)
extra = json.dumps(tags.get("extra_tags", {}), ensure_ascii=False)
tags = read_tags(str(fp))
title = tags.get("title", "") or fp.stem
artist = tags.get("artist", "")
album = tags.get("album", "")
bpm = tags.get("bpm", 0)
mbid = tags.get("mbid", "")
acoustid = tags.get("acoustid", "")
duration_sec = tags.get("duration_sec", 0)
file_format = tags.get("file_format", fp.suffix.lstrip(".").lower())
extra_tags = tags.get("extra_tags", "{}")
# Match 0: MBID-match — sikrest mulige match
existing = None
mbid_from_file = tags.get("mbid", "")
if mbid_from_file:
existing = conn.execute(
"SELECT id, bpm FROM songs WHERE mbid=? LIMIT 1",
(mbid_from_file,)
).fetchone()
if existing:
conn.execute(
"UPDATE songs SET local_path=? WHERE id=?",
(path_str, existing["id"])
)
# Find eller opret sang i global katalog
song_id = find_or_create_song(
title=title, artist=artist, album=album,
bpm=bpm, duration_sec=duration_sec,
mbid=mbid, acoustid=acoustid,
)
# Match 1: præcis sti-match
if not existing:
existing = conn.execute(
"SELECT id, bpm FROM songs WHERE local_path=?", (path_str,)
).fetchone()
# Opdater BPM på sangen hvis vi har bedre data
if bpm and bpm > 0:
conn.execute(
"UPDATE songs SET bpm=? WHERE id=? AND (bpm=0 OR bpm IS NULL)",
(bpm, song_id)
)
# Match 2: titel+artist match — fil er flyttet eller var missing
if not existing:
title = tags.get("title", "")
artist = tags.get("artist", "")
if title:
# Prioritér file_missing=1 sange, men tag også sange med ugyldig sti
existing = conn.execute("""
SELECT id, bpm FROM songs
WHERE title=? AND artist=? AND file_missing=1
LIMIT 1
""", (title, artist)).fetchone()
if not existing:
# Tjek om der er en sang med samme titel+artist men ugyldig sti
existing = conn.execute("""
SELECT id, bpm, local_path FROM songs
WHERE title=? AND artist=? AND file_missing=0
LIMIT 1
""", (title, artist)).fetchone()
if existing:
from pathlib import Path as _Path
old_path = existing["local_path"] or ""
if old_path and not _Path(old_path).exists():
pass # Sti er ugyldig — brug dette match
else:
existing = None # Sti er valid — det er en anden fil
# Opret eller opdater fil-post
upsert_file(
song_id=song_id,
local_path=path_str,
file_format=file_format,
file_modified_at=mtime,
extra_tags=extra_tags,
)
if existing:
# Opdater stien så den peger på den nye placering
conn.execute(
"UPDATE songs SET local_path=? WHERE id=?",
(path_str, existing["id"])
)
if existing:
bpm = tags.get("bpm", 0)
if not overwrite_bpm and existing["bpm"] and existing["bpm"] > 0:
bpm = existing["bpm"] # behold eksisterende BPM
mbid = tags.get("mbid", "")
conn.execute("""
UPDATE songs SET
library_id=?, title=?, artist=?, album=?,
bpm=?, duration_sec=?, file_format=?,
file_modified_at=?, file_missing=0, extra_tags=?,
mbid=CASE WHEN ? != '' THEN ? ELSE mbid END
WHERE id=?
""", (library_id, tags.get("title",""), tags.get("artist",""),
tags.get("album",""), bpm, tags.get("duration_sec",0),
tags.get("file_format",""), mtime, extra,
mbid, mbid, existing["id"]))
song_id = existing["id"]
else:
song_id = str(uuid.uuid4())
conn.execute("""
INSERT OR IGNORE INTO songs
(id, library_id, local_path, title, artist, album,
bpm, duration_sec, file_format, file_modified_at, extra_tags, mbid)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
""", (song_id, library_id, path_str,
tags.get("title",""), tags.get("artist",""),
tags.get("album",""), tags.get("bpm",0),
tags.get("duration_sec",0), tags.get("file_format",""),
mtime, extra, tags.get("mbid","")))
# Importer dans-tags fra filen hvis de ikke allerede er i DB
# Dans-tags fra fil
file_dances = tags.get("dances", [])
if file_dances:
existing_dances = conn.execute(
existing_count = conn.execute(
"SELECT COUNT(*) FROM song_dances WHERE song_id=?", (song_id,)
).fetchone()[0]
if existing_dances == 0:
if existing_count == 0:
import uuid
for order, dance_name in enumerate(file_dances, start=1):
dance_row = conn.execute(
"SELECT id FROM dances WHERE name=? COLLATE NOCASE LIMIT 1",
@@ -192,64 +130,25 @@ def scan_library(library_id: int, library_path: str, db_path: str,
else:
dance_id = dance_row["id"]
conn.execute(
"INSERT OR IGNORE INTO song_dances (song_id, dance_id, dance_order) VALUES (?,?,?)",
(song_id, dance_id, order)
"INSERT OR IGNORE INTO song_dances (id, song_id, dance_id, dance_order) VALUES (?,?,?,?)",
(str(uuid.uuid4()), song_id, dance_id, order)
)
conn.commit()
except Exception as e:
# UNIQUE constraint er forventet og ufarlig — sang findes allerede
if "UNIQUE constraint" in str(e):
logger.debug(f"Sang allerede i DB: {fp.name}")
else:
logger.warning(f"Scan fejl {fp.name}: {e}")
logger.warning(f"Scan fejl {fp.name}: {e}")
done += 1
# Lille pause efter hver scannet fil så GUI ikke hænger
time.sleep(0.02)
# Marker manglende filer
for path_str in known:
if not Path(path_str).exists():
conn.execute(
"UPDATE songs SET file_missing=1 WHERE local_path=?", (path_str,)
"UPDATE files SET file_missing=1 WHERE local_path=?", (path_str,)
)
conn.commit()
conn.execute(
"UPDATE libraries SET last_full_scan=datetime('now') WHERE id=?",
(library_id,)
)
conn.commit()
conn.close()
return done
# ── Subprocess entry point ─────────────────────────────────────────────────────
if __name__ == "__main__":
"""
Kørsel som subprocess:
python scanner.py <library_id> <library_path> <db_path>
Rapporterer JSON-linjer til stdout: {"done":N,"total":M,"file":"..."}
"""
if len(sys.argv) < 4:
sys.exit(1)
lib_id = int(sys.argv[1])
lib_path = sys.argv[2]
db_path = sys.argv[3]
# Tilføj app-mappen til path så local.tag_reader kan importeres
app_dir = str(Path(__file__).parent.parent)
if app_dir not in sys.path:
sys.path.insert(0, app_dir)
def report(done, total, filename):
print(json.dumps({"done": done, "total": total, "file": filename}),
flush=True)
count = scan_library(lib_id, lib_path, db_path,
progress_callback=report)
print(json.dumps({"done": count, "total": count, "finished": True}),
flush=True)
logger.info(f"Scan færdig: {done} filer i {library_path}")
return done

View File

@@ -1,150 +1,122 @@
"""
sync_manager.py — Synkronisering mellem lokal SQLite og server API.
Kører i baggrundstråd — blokerer aldrig GUI.
sync_manager.py — Synkronisering mellem lokal database og server. v0.9
"""
import json
import logging
import sqlite3
import threading
import urllib.request
import urllib.error
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
class SyncManager:
def __init__(self, db_path: str, server_url: str, token: str):
self._db_path = db_path
self._server_url = server_url.rstrip("/")
self._token = token
self._lock = threading.Lock()
def _headers(self):
return {
"Content-Type": "application/json",
"Authorization": f"Bearer {self._token}",
}
def __init__(self, api_url: str, db_path: str):
self._api_url = api_url.rstrip("/")
self._db_path = db_path
self._token: str | None = None
def set_token(self, token: str):
self._token = token
# ── HTTP ──────────────────────────────────────────────────────────────────
def _post(self, path: str, data: dict) -> dict:
body = json.dumps(data).encode("utf-8")
body = json.dumps(data).encode()
req = urllib.request.Request(
f"{self._server_url}{path}", data=body,
headers=self._headers(), method="POST"
f"{self._api_url}{path}",
data=body,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {self._token}",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
detail = e.read().decode("utf-8", errors="replace")
detail = e.read().decode()
raise Exception(f"HTTP {e.code}: {detail}")
def _get(self, path: str) -> dict:
req = urllib.request.Request(
f"{self._server_url}{path}",
headers=self._headers(), method="GET"
f"{self._api_url}{path}",
headers={"Authorization": f"Bearer {self._token}"},
)
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
detail = e.read().decode()
raise Exception(f"HTTP {e.code}: {detail}")
# ── Push ──────────────────────────────────────────────────────────────────
def push(self, on_done=None, on_error=None):
"""Push lokal data til server i baggrundstråd."""
def _run():
try:
payload = self._build_push_payload()
logger.info(f"Push OK: {len(payload['songs'])} sange")
result = self._post("/sync/push", payload)
self._save_playlist_ids(result.get("playlist_id_map", {}))
# Fjern soft-slettede playlister permanent efter succesfuld push
if payload.get("deleted_playlists"):
conn = sqlite3.connect(self._db_path)
conn.execute(
"DELETE FROM playlists WHERE is_deleted=1 AND api_project_id IS NOT NULL"
)
conn.commit()
conn.close()
logger.info(f"Push OK: {result.get('songs_synced', '?')} sange synkroniseret")
if on_done:
on_done(result)
except Exception as e:
logger.error(f"Sync push fejl: {e}", exc_info=True)
if on_error:
on_error(str(e))
threading.Thread(target=_run, daemon=True).start()
def _save_playlist_ids(self, id_map: dict):
"""Gem server-IDs (api_project_id) på lokale playlister."""
if not id_map:
return
conn = sqlite3.connect(self._db_path)
for local_id, server_id in id_map.items():
try:
conn.execute(
"UPDATE playlists SET api_project_id=? WHERE id=?",
(server_id, int(local_id))
logger.info(f"Push: {len(payload['songs'])} sange, "
f"{len(payload['playlists'])} playlister")
result = self._post("/sync/push", payload)
self._save_server_ids(
result.get("song_id_map", {}),
result.get("playlist_id_map", {}),
)
except Exception:
pass
conn.commit()
conn.close()
def pull(self, on_done=None, on_error=None):
"""Pull server-data ned i baggrundstråd."""
def _run():
try:
result = self._get("/sync/pull")
pl_count = len(result.get("my_playlists", []))
logger.info(f"Pull OK: {pl_count} playlister")
self._apply_pull(result)
logger.info(f"Push OK: {result.get('songs_synced','?')} sange synkroniseret")
if on_done:
on_done(result)
except Exception as e:
logger.error(f"Sync pull fejl: {e}", exc_info=True)
logger.error(f"Push fejl: {e}", exc_info=True)
if on_error:
on_error(str(e))
threading.Thread(target=_run, daemon=True).start()
# ── Push + Pull ───────────────────────────────────────────────────────────
def push_and_pull(self, on_done=None, on_error=None):
"""Push FØR pull — så sletninger når serveren inden pull henter data ned."""
def _run():
try:
# 1. Push lokal data op — inkl. sletninger
# 1. Push
payload = self._build_push_payload()
deleted = payload.get("deleted_playlists", [])
logger.info(f"Sync push — {len(payload['songs'])} sange, "
f"{len(payload['playlists'])} playlister, "
f"sletter {len(deleted)}: {deleted}")
push_result = self._post("/sync/push", payload)
self._save_playlist_ids(push_result.get("playlist_id_map", {}))
self._save_server_ids(
push_result.get("song_id_map", {}),
push_result.get("playlist_id_map", {}),
)
logger.info(f"Push svar: status={push_result.get('status')}, "
f"sange={push_result.get('songs_synced', 0)}, "
f"playlister={push_result.get('playlists_synced', 0)}")
# 2. Pull — sletninger er nu gennemført på serveren.
# _apply_pull filtrerer is_deleted=1 rækker fra automatisk.
# 2. Pull
pull_result = self._get("/sync/pull")
pl_names = [p.get("name") for p in pull_result.get("my_playlists", [])]
logger.info(f"Pull modtog {len(pl_names)} playlister: {pl_names}")
self._apply_pull(pull_result)
# Fjern soft-slettede playlister permanent nu serveren er opdateret
# 3. Fjern soft-slettede permanent efter succesfuld sync
if deleted:
conn = sqlite3.connect(self._db_path)
conn = sqlite3.connect(self._db_path, timeout=10)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute(
"DELETE FROM playlists WHERE is_deleted=1 AND api_project_id IS NOT NULL"
)
conn.commit()
conn.close()
logger.info(f"Soft-slettede playlister fjernet lokalt efter sync")
logger.info("Soft-slettede playlister fjernet lokalt efter sync")
pl_count = len(pull_result.get("my_playlists", []))
logger.info(
f"Sync OK — {len(payload['songs'])} sange, "
f"{len(payload['playlists'])} playlister, "
f"{pl_count} server-playlister"
)
logger.info(f"Sync OK — {len(payload['songs'])} sange, "
f"{len(payload['playlists'])} playlister, "
f"{pl_count} server-playlister")
if on_done:
on_done({"push": push_result, "pull": pull_result})
except Exception as e:
@@ -156,34 +128,36 @@ class SyncManager:
# ── Byg payload ───────────────────────────────────────────────────────────
def _build_push_payload(self) -> dict:
conn = sqlite3.connect(self._db_path)
conn = sqlite3.connect(self._db_path, timeout=10)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
# Sange
# Sange (dem der har filer — altså kendes lokalt)
songs = []
for row in conn.execute(
"SELECT id, title, artist, album, bpm, duration_sec, file_format, mbid, acoustid "
"FROM songs WHERE file_missing=0"
).fetchall():
for row in conn.execute("""
SELECT DISTINCT s.id, s.title, s.artist, s.album,
s.bpm, s.duration_sec, s.mbid, s.acoustid, s.server_synced
FROM songs s
JOIN files f ON f.song_id = s.id AND f.file_missing = 0
""").fetchall():
songs.append({
"local_id": str(row["id"]),
"local_id": row["id"],
"title": row["title"] or "",
"artist": row["artist"] or "",
"album": row["album"] or "",
"bpm": row["bpm"] or 0,
"duration_sec": row["duration_sec"] or 0,
"file_format": row["file_format"] or "",
"mbid": row["mbid"] or "",
"acoustid": row["acoustid"] or "",
})
# Danse
dances = []
for row in conn.execute(
"SELECT d.name, dl.name as level_name, d.choreographer, "
"d.video_url, d.stepsheet_url, d.notes "
"FROM dances d LEFT JOIN dance_levels dl ON dl.id = d.level_id"
).fetchall():
for row in conn.execute("""
SELECT d.name, dl.name as level_name, d.choreographer,
d.video_url, d.stepsheet_url, d.notes
FROM dances d LEFT JOIN dance_levels dl ON dl.id = d.level_id
""").fetchall():
dances.append({
"name": row["name"] or "",
"level_name": row["level_name"] or "",
@@ -193,16 +167,17 @@ class SyncManager:
"notes": row["notes"] or "",
})
# Dans-tags per sang
# Dans-tags
song_dances = []
for row in conn.execute("""
SELECT sd.song_id, d.name as dance_name, dl.name as level_name, sd.dance_order
SELECT sd.song_id, d.name as dance_name,
dl.name as level_name, sd.dance_order
FROM song_dances sd
JOIN dances d ON d.id = sd.dance_id
LEFT JOIN dance_levels dl ON dl.id = d.level_id
""").fetchall():
song_dances.append({
"song_local_id": str(row["song_id"]),
"song_local_id": row["song_id"],
"dance_name": row["dance_name"],
"level_name": row["level_name"] or "",
"dance_order": row["dance_order"],
@@ -211,36 +186,36 @@ class SyncManager:
# Alternativ-danse
song_alts = []
for row in conn.execute("""
SELECT sad.song_id, d.name as dance_name, dl.name as level_name, sad.note
SELECT sad.song_id, d.name as dance_name,
dl.name as level_name, sad.note
FROM song_alt_dances sad
JOIN dances d ON d.id = sad.dance_id
LEFT JOIN dance_levels dl ON dl.id = d.level_id
""").fetchall():
song_alts.append({
"song_local_id": str(row["song_id"]),
"song_local_id": row["song_id"],
"dance_name": row["dance_name"],
"level_name": row["level_name"] or "",
"note": row["note"] or "",
})
# Playlister — send alle (nye og eksisterende) til serveren.
# Brug api_project_id som local_id hvis den kendes — så serveren
# kan matche på ID og ikke oprette duplikater.
# Playlister — alle ikke-slettede
playlists = []
for pl in conn.execute(
"SELECT id, name, description, tags, api_project_id FROM playlists "
"WHERE name != '__aktiv__' AND is_deleted = 0"
).fetchall():
for pl in conn.execute("""
SELECT id, name, description, tags, api_project_id
FROM playlists
WHERE name != '__aktiv__' AND is_deleted = 0
""").fetchall():
pl_songs = []
for ps in conn.execute("""
SELECT s.id, s.title, s.artist,
SELECT s.id as song_id, s.title, s.artist,
ps.position, ps.status, ps.is_workshop, ps.dance_override
FROM playlist_songs ps
JOIN songs s ON s.id = ps.song_id
WHERE ps.playlist_id=? ORDER BY ps.position
""", (pl["id"],)).fetchall():
pl_songs.append({
"song_local_id": str(ps["id"]),
"song_local_id": ps["song_id"],
"song_title": ps["title"] or "",
"song_artist": ps["artist"] or "",
"position": int(ps["position"] or 1),
@@ -248,9 +223,8 @@ class SyncManager:
"is_workshop": bool(ps["is_workshop"]),
"dance_override": ps["dance_override"] or "",
})
# Brug api_project_id som local_id hvis den kendes —
# serveren bruger dette til at finde eksisterende liste
local_id = pl["api_project_id"] or str(pl["id"])
# Brug api_project_id som local_id hvis kendt
local_id = pl["api_project_id"] or pl["id"]
playlists.append({
"local_id": local_id,
"name": pl["name"],
@@ -260,9 +234,7 @@ class SyncManager:
"songs": pl_songs,
})
# Slettede playlister — skal fjernes fra serveren.
# Serveren forventer en liste af strings (api_project_id).
# Kun playlister der faktisk er nået serveren (har api_project_id).
# Slettede playlister
deleted = [
row["api_project_id"]
for row in conn.execute(
@@ -273,162 +245,280 @@ class SyncManager:
conn.close()
return {
"songs": songs,
"dances": dances,
"song_dances": song_dances,
"song_alts": song_alts,
"playlists": playlists,
"deleted_playlists": deleted,
"songs": songs,
"dances": dances,
"song_dances": song_dances,
"song_alts": song_alts,
"playlists": playlists,
"deleted_playlists": deleted,
}
# ── Gem server-IDs ────────────────────────────────────────────────────────
def _save_server_ids(self, song_id_map: dict, playlist_id_map: dict):
"""
Gem server-IDs lokalt.
song_id_map: lokal_song_id → server_song_id
playlist_id_map: lokal_pl_id → server_pl_id
"""
if not song_id_map and not playlist_id_map:
return
conn = sqlite3.connect(self._db_path, timeout=10)
conn.execute("PRAGMA journal_mode=WAL")
# Sange: hvis server gav et andet ID end det lokale, opdater
for local_id, server_id in song_id_map.items():
if local_id != server_id:
# Tjek om server-ID allerede eksisterer
existing = conn.execute(
"SELECT id FROM songs WHERE id=?", (server_id,)
).fetchone()
if not existing:
# Opdater lokal sang til server-ID
conn.execute(
"UPDATE songs SET id=?, server_synced=1 WHERE id=?",
(server_id, local_id)
)
# Opdater referencer
conn.execute(
"UPDATE files SET song_id=? WHERE song_id=?",
(server_id, local_id)
)
conn.execute(
"UPDATE playlist_songs SET song_id=? WHERE song_id=?",
(server_id, local_id)
)
conn.execute(
"UPDATE song_dances SET song_id=? WHERE song_id=?",
(server_id, local_id)
)
conn.execute(
"UPDATE song_alt_dances SET song_id=? WHERE song_id=?",
(server_id, local_id)
)
else:
conn.execute(
"UPDATE songs SET server_synced=1 WHERE id=?", (local_id,)
)
# Playlister
for local_id, server_id in playlist_id_map.items():
conn.execute(
"UPDATE playlists SET api_project_id=? WHERE id=? OR api_project_id=?",
(server_id, local_id, local_id)
)
conn.commit()
conn.close()
# ── Anvend pull ───────────────────────────────────────────────────────────
def _apply_pull(self, data: dict):
"""Gem server-data lokalt — opdaterer dans-info og importerer playlister."""
conn = sqlite3.connect(self._db_path)
"""Gem server-data lokalt."""
import uuid
conn = sqlite3.connect(self._db_path, timeout=10)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
# Opdater dans-info fra server
for d in data.get("dances", []):
if not d.get("name"):
continue
existing = conn.execute(
"SELECT id FROM dances WHERE name=? COLLATE NOCASE", (d["name"],)
).fetchone()
if existing and (d.get("choreographer") or d.get("video_url") or d.get("stepsheet_url")):
conn.execute("""
UPDATE dances SET
choreographer = CASE WHEN choreographer='' THEN ? ELSE choreographer END,
video_url = CASE WHEN video_url='' THEN ? ELSE video_url END,
stepsheet_url = CASE WHEN stepsheet_url='' THEN ? ELSE stepsheet_url END
WHERE id=?
""", (d.get("choreographer",""), d.get("video_url",""),
d.get("stepsheet_url",""), existing["id"]))
# Importer/opdater egne playlister fra server — server er sandhed
# Hent server-IDs på soft-slettede playlister så vi springer dem over
deleted_server_ids = {
row["api_project_id"]
for row in conn.execute(
"SELECT api_project_id FROM playlists "
"WHERE is_deleted=1 AND api_project_id IS NOT NULL"
).fetchall()
}
for pl in data.get("my_playlists", []):
server_id = pl.get("server_id")
name = pl.get("name", "")
if not server_id or not name:
continue
# Spring over hvis listen er soft-slettet lokalt
if server_id in deleted_server_ids:
continue
existing = conn.execute(
"SELECT id FROM playlists WHERE api_project_id=?", (server_id,)
).fetchone()
if existing:
pl_id = existing["id"]
# Opdater navn hvis det er ændret på serveren
conn.execute(
"UPDATE playlists SET name=? WHERE id=?", (name, pl_id)
)
else:
cur = conn.execute(
"INSERT INTO playlists (name, description, api_project_id, is_linked, server_permission) "
"VALUES (?,?,?,1,'edit')",
(name, pl.get("description",""), server_id)
)
pl_id = cur.lastrowid
# Genindlæs sange fra serveren — server er sandhed
conn.execute("DELETE FROM playlist_songs WHERE playlist_id=?", (pl_id,))
position = 1
for song_data in pl.get("songs", []):
title = song_data.get("title", "")
artist = song_data.get("artist", "")
if not title:
try:
# Opdater dans-info
for d in data.get("dances", []):
if not d.get("name"):
continue
local = conn.execute(
"SELECT id FROM songs WHERE title=? AND artist=? LIMIT 1",
(title, artist)
existing = conn.execute(
"SELECT id FROM dances WHERE name=? COLLATE NOCASE", (d["name"],)
).fetchone()
if not local:
import uuid
new_id = str(uuid.uuid4())
if existing and (d.get("choreographer") or d.get("video_url")):
conn.execute("""
UPDATE dances SET
choreographer = CASE WHEN choreographer='' THEN ? ELSE choreographer END,
video_url = CASE WHEN video_url='' THEN ? ELSE video_url END,
stepsheet_url = CASE WHEN stepsheet_url='' THEN ? ELSE stepsheet_url END
WHERE id=?
""", (d.get("choreographer",""), d.get("video_url",""),
d.get("stepsheet_url",""), existing["id"]))
# Hent soft-slettede server-IDs så vi springer dem over
deleted_server_ids = {
row["api_project_id"]
for row in conn.execute(
"SELECT api_project_id FROM playlists "
"WHERE is_deleted=1 AND api_project_id IS NOT NULL"
).fetchall()
}
# Importer egne playlister
for pl in data.get("my_playlists", []):
server_id = pl.get("server_id")
name = pl.get("name", "")
if not server_id or not name:
continue
if server_id in deleted_server_ids:
continue
existing = conn.execute(
"SELECT id FROM playlists WHERE api_project_id=?", (server_id,)
).fetchone()
if existing:
pl_id = existing["id"]
conn.execute(
"INSERT OR IGNORE INTO songs (id, title, artist, file_missing) VALUES (?,?,?,1)",
(new_id, title, artist)
"UPDATE playlists SET name=? WHERE id=?", (name, pl_id)
)
local_id = new_id
else:
local_id = local["id"]
pl_id = str(uuid.uuid4())
conn.execute(
"INSERT INTO playlists (id, name, description, api_project_id, is_linked, server_permission) "
"VALUES (?,?,?,?,1,'edit')",
(pl_id, name, pl.get("description",""), server_id)
)
conn.execute("""
INSERT OR IGNORE INTO playlist_songs
(playlist_id, song_id, position, status, is_workshop, dance_override)
VALUES (?,?,?,?,?,?)
""", (pl_id, local_id, position,
song_data.get("status","pending"),
1 if song_data.get("is_workshop") else 0,
song_data.get("dance_override","") or ""))
position += 1
# Importer delte playlister (read-only — is_linked=1, server_permission='view')
for pl in data.get("shared", []):
server_id = pl.get("server_id")
name = pl.get("name", "")
owner = pl.get("owner", "?")
if not server_id or not name:
continue
existing = conn.execute(
"SELECT id FROM playlists WHERE api_project_id=?", (server_id,)
).fetchone()
if existing:
# Opdater sange fra server (ejer kan have ændret listen)
pl_id = existing["id"]
# Genindlæs sange
conn.execute("DELETE FROM playlist_songs WHERE playlist_id=?", (pl_id,))
else:
cur = conn.execute(
"INSERT INTO playlists (name, description, api_project_id, is_linked, server_permission) "
"VALUES (?,?,?,1,'view')",
(f"{name} ({owner})", "", server_id)
)
pl_id = cur.lastrowid
position = 1
songs_from_server = pl.get("songs", [])
logger.info(f"Pull: liste '{name}' har {len(songs_from_server)} sange")
position = 1
for song_data in pl.get("songs", []):
title = song_data.get("title", "")
artist = song_data.get("artist", "")
if not title:
continue
local = conn.execute(
"SELECT id FROM songs WHERE title=? AND artist=? LIMIT 1",
(title, artist)
).fetchone()
if not local:
import uuid
new_id = str(uuid.uuid4())
conn.execute(
"INSERT OR IGNORE INTO songs (id, title, artist, file_missing) VALUES (?,?,?,1)",
(new_id, title, artist)
for song_data in songs_from_server:
server_song_id = song_data.get("song_id", "")
title = song_data.get("title", "")
artist = song_data.get("artist", "")
mbid = song_data.get("mbid", "")
acoustid = song_data.get("acoustid", "")
if not title and not server_song_id:
continue
# Find eller opret sang lokalt
local_song_id = self._find_or_create_song_local(
conn, server_song_id, title, artist,
mbid=mbid, acoustid=acoustid,
bpm=song_data.get("bpm", 0),
duration_sec=song_data.get("duration_sec", 0),
)
local_id = new_id
else:
local_id = local["id"]
conn.execute("""
INSERT OR IGNORE INTO playlist_songs
(playlist_id, song_id, position, status, is_workshop, dance_override)
VALUES (?,?,?,?,?,?)
""", (pl_id, local_id, position,
song_data.get("status","pending"),
1 if song_data.get("is_workshop") else 0,
song_data.get("dance_override","") or ""))
position += 1
conn.commit()
conn.close()
# Find tilgængelig fil til denne sang
file_row = conn.execute(
"SELECT id FROM files WHERE song_id=? AND file_missing=0 LIMIT 1",
(local_song_id,)
).fetchone()
file_id = file_row["id"] if file_row else None
conn.execute("""
INSERT INTO playlist_songs
(id, playlist_id, song_id, file_id, position, status, is_workshop, dance_override)
VALUES (?,?,?,?,?,?,?,?)
""", (str(uuid.uuid4()), pl_id, local_song_id, file_id, position,
song_data.get("status","pending"),
1 if song_data.get("is_workshop") else 0,
song_data.get("dance_override","") or ""))
position += 1
# Importer delte playlister
for pl in data.get("shared", []):
server_id = pl.get("server_id")
name = pl.get("name", "")
owner = pl.get("owner", "?")
if not server_id or not name:
continue
existing = conn.execute(
"SELECT id FROM playlists WHERE api_project_id=?", (server_id,)
).fetchone()
if existing:
pl_id = existing["id"]
conn.execute("DELETE FROM playlist_songs WHERE playlist_id=?", (pl_id,))
else:
pl_id = str(uuid.uuid4())
conn.execute(
"INSERT INTO playlists (id, name, description, api_project_id, is_linked, server_permission) "
"VALUES (?,?,?,?,1,'view')",
(pl_id, f"{name} ({owner})", "", server_id)
)
position = 1
for song_data in pl.get("songs", []):
server_song_id = song_data.get("song_id", "")
title = song_data.get("title", "")
artist = song_data.get("artist", "")
if not title and not server_song_id:
continue
local_song_id = self._find_or_create_song_local(
conn, server_song_id, title, artist,
mbid=song_data.get("mbid", ""),
acoustid=song_data.get("acoustid", ""),
)
file_row = conn.execute(
"SELECT id FROM files WHERE song_id=? AND file_missing=0 LIMIT 1",
(local_song_id,)
).fetchone()
file_id = file_row["id"] if file_row else None
conn.execute("""
INSERT INTO playlist_songs
(id, playlist_id, song_id, file_id, position, status, is_workshop, dance_override)
VALUES (?,?,?,?,?,?,?,?)
""", (str(uuid.uuid4()), pl_id, local_song_id, file_id, position,
song_data.get("status","pending"),
1 if song_data.get("is_workshop") else 0,
song_data.get("dance_override","") or ""))
position += 1
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def _find_or_create_song_local(self, conn, server_song_id: str, title: str,
artist: str = "", mbid: str = "",
acoustid: str = "", bpm: int = 0,
duration_sec: int = 0) -> str:
"""Find eller opret sang lokalt. Returnerer lokal song_id."""
import uuid
# Match på server-ID
if server_song_id:
row = conn.execute(
"SELECT id FROM songs WHERE id=?", (server_song_id,)
).fetchone()
if row:
return row["id"]
# Match på MBID
if mbid:
row = conn.execute(
"SELECT id FROM songs WHERE mbid=?", (mbid,)
).fetchone()
if row:
return row["id"]
# Match på AcoustID
if acoustid:
row = conn.execute(
"SELECT id FROM songs WHERE acoustid=?", (acoustid,)
).fetchone()
if row:
return row["id"]
# Match på titel + artist
if title:
row = conn.execute(
"SELECT id FROM songs WHERE title=? AND artist=?", (title, artist)
).fetchone()
if row:
return row["id"]
# Opret ny — brug server-ID hvis tilgængeligt
new_id = server_song_id or str(uuid.uuid4())
conn.execute(
"INSERT INTO songs (id, title, artist, bpm, duration_sec, mbid, acoustid, server_synced) "
"VALUES (?,?,?,?,?,?,?,1)",
(new_id, title, artist, bpm, duration_sec, mbid or None, acoustid or None)
)
logger.info(f"Pull: oprettet sang '{title}' ({new_id})")
return new_id

View File

@@ -406,9 +406,13 @@ class PlaylistPanel(QWidget):
return False
# Hent sange med status, workshop og dans-override
# JOIN songs — sangen er altid i songs tabellen (oprettet ved pull med file_missing=1)
# file_missing betyder bare at filen ikke er på denne maskine
songs_raw = conn.execute("""
SELECT s.*, ps.position, ps.status,
ps.is_workshop, ps.dance_override
SELECT s.id, s.title, s.artist, s.album,
s.bpm, s.duration_sec, s.file_format,
s.local_path, s.file_missing,
ps.position, ps.status, ps.is_workshop, ps.dance_override
FROM playlist_songs ps
JOIN songs s ON s.id = ps.song_id
WHERE ps.playlist_id=? ORDER BY ps.position
@@ -426,10 +430,10 @@ class PlaylistPanel(QWidget):
override = row["dance_override"] or ""
active_dance = override if override else (dance_names[0] if dance_names else "")
local_path = row["local_path"]
local_path = row["local_path"] or ""
file_missing = bool(row["file_missing"])
# Forsøg at finde sangen lokalt hvis den mangler
# Forsøg at finde filen lokalt hvis den mangler på denne maskine
if file_missing or not local_path:
match = conn.execute("""
SELECT local_path FROM songs
@@ -444,11 +448,11 @@ class PlaylistPanel(QWidget):
"id": row["id"],
"title": row["title"],
"artist": row["artist"],
"album": row["album"],
"bpm": row["bpm"],
"duration_sec": row["duration_sec"],
"album": row["album"] or "",
"bpm": row["bpm"] or 0,
"duration_sec": row["duration_sec"] or 0,
"local_path": local_path,
"file_format": row["file_format"],
"file_format": row["file_format"] or "",
"file_missing": file_missing,
"dances": dance_names,
"active_dance": active_dance,
@@ -605,9 +609,12 @@ class PlaylistPanel(QWidget):
else:
self._can_edit_server = False
with get_db() as conn:
# JOIN songs — sangen er altid i songs tabellen (oprettet ved pull med file_missing=1)
songs_raw = conn.execute("""
SELECT s.*, ps.position, ps.status,
ps.is_workshop, ps.dance_override
SELECT s.id, s.title, s.artist, s.album,
s.bpm, s.duration_sec, s.file_format,
s.local_path, s.file_missing,
ps.position, ps.status, ps.is_workshop, ps.dance_override
FROM playlist_songs ps
JOIN songs s ON s.id = ps.song_id
WHERE ps.playlist_id=? ORDER BY ps.position
@@ -618,17 +625,17 @@ class PlaylistPanel(QWidget):
for row in songs_raw:
dances = conn.execute("""
SELECT d.name FROM song_dances sd
JOIN dances d ON d.id = sd.dance_id
JOIN dances d ON d.id = sad.dance_id
WHERE sd.song_id=? ORDER BY sd.dance_order
""", (row["id"],)).fetchall()
dance_names = [d["name"] for d in dances]
override = row["dance_override"] or ""
active_dance = override if override else (dance_names[0] if dance_names else "")
local_path = row["local_path"]
local_path = row["local_path"] or ""
file_missing = bool(row["file_missing"])
# Forsøg at finde sangen lokalt hvis den mangler
# Forsøg at finde filen lokalt hvis den mangler på denne maskine
if file_missing or not local_path:
match = conn.execute("""
SELECT local_path FROM songs
@@ -644,11 +651,11 @@ class PlaylistPanel(QWidget):
"id": row["id"],
"title": row["title"],
"artist": row["artist"],
"album": row["album"],
"bpm": row["bpm"],
"duration_sec": row["duration_sec"],
"album": row["album"] or "",
"bpm": row["bpm"] or 0,
"duration_sec": row["duration_sec"] or 0,
"local_path": local_path,
"file_format": row["file_format"],
"file_format": row["file_format"] or "",
"file_missing": file_missing,
"dances": dance_names,
"active_dance": active_dance,
@@ -1251,4 +1258,4 @@ class PlaylistPanel(QWidget):
def _on_double_click(self, item: QListWidgetItem):
idx = item.data(Qt.ItemDataRole.UserRole)
if idx is not None:
self.song_selected.emit(idx)
self.song_selected.emit(idx)