NY db struktur
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user