""" sync_manager.py — Synkronisering mellem lokal SQLite og server API. Kører i baggrundstråd — blokerer aldrig GUI. """ import json 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 _post(self, path: str, data: dict) -> dict: body = json.dumps(data).encode("utf-8") req = urllib.request.Request( f"{self._server_url}{path}", data=body, headers=self._headers(), 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") 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" ) with urllib.request.urlopen(req, timeout=30) as resp: return json.loads(resp.read()) # ── 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() result = self._post("/sync/push", payload) # Gem server-IDs lokalt self._save_playlist_ids(result.get("playlist_id_map", {})) logger.info(f"Sync push: {result}") if on_done: on_done(result) except Exception as e: logger.error(f"Sync push fejl: {e}") 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)) ) 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") self._apply_pull(result) logger.info(f"Sync pull: {len(result.get('dances', []))} danse") if on_done: on_done(result) except Exception as e: logger.error(f"Sync pull fejl: {e}") if on_error: on_error(str(e)) threading.Thread(target=_run, daemon=True).start() def push_and_pull(self, on_done=None, on_error=None): """Push og derefter pull i samme tråd.""" def _run(): try: payload = self._build_push_payload() push_result = self._post("/sync/push", payload) pull_result = self._get("/sync/pull") self._apply_pull(pull_result) if on_done: on_done({"push": push_result, "pull": pull_result}) except Exception as e: logger.error(f"Sync fejl: {e}") if on_error: on_error(str(e)) threading.Thread(target=_run, daemon=True).start() # ── Byg payload ─────────────────────────────────────────────────────────── def _build_push_payload(self) -> dict: conn = sqlite3.connect(self._db_path) conn.row_factory = sqlite3.Row # Sange songs = [] for row in conn.execute( "SELECT id, title, artist, album, bpm, duration_sec, file_format " "FROM songs WHERE file_missing=0" ).fetchall(): songs.append({ "local_id": str(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 "", }) # 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(): dances.append({ "name": row["name"] or "", "level_name": row["level_name"] or "", "choreographer": row["choreographer"] or "", "video_url": row["video_url"] or "", "stepsheet_url": row["stepsheet_url"] or "", "notes": row["notes"] or "", }) # Dans-tags per sang song_dances = [] for row in conn.execute(""" 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"]), "dance_name": row["dance_name"], "level_name": row["level_name"] or "", "dance_order": row["dance_order"], }) # 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 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"]), "dance_name": row["dance_name"], "level_name": row["level_name"] or "", "note": row["note"] or "", }) # Playlister (kun navngivne — ikke __aktiv__) playlists = [] for pl in conn.execute( "SELECT id, name, description, tags FROM playlists " "WHERE name != '__aktiv__'" ).fetchall(): pl_songs = [] for ps in conn.execute(""" SELECT song_id, position, status, is_workshop, dance_override FROM playlist_songs WHERE playlist_id=? ORDER BY position """, (pl["id"],)).fetchall(): pl_songs.append({ "song_local_id": ps["song_id"] or "", "position": int(ps["position"] or 1), "status": ps["status"] or "pending", "is_workshop": bool(ps["is_workshop"]), "dance_override": ps["dance_override"] or "", }) playlists.append({ "local_id": str(pl["id"]), "name": pl["name"], "description": pl["description"] or "", "tags": pl["tags"] or "", "visibility": "private", "songs": pl_songs, }) conn.close() return { "songs": songs, "dances": dances, "song_dances": song_dances, "song_alts": song_alts, "playlists": playlists, } # ── Anvend pull ─────────────────────────────────────────────────────────── def _apply_pull(self, data: dict): """Gem server-data lokalt — opdaterer dans-info og importerer playlister.""" conn = sqlite3.connect(self._db_path) conn.row_factory = sqlite3.Row # 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 egne playlister fra server hvis de ikke findes lokalt 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 # Tjek om listen allerede eksisterer lokalt existing = conn.execute( "SELECT id FROM playlists WHERE api_project_id=?", (server_id,) ).fetchone() if existing: continue # Allerede importeret — spring over # Opret liste 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 # Indsæt sange med lokal matching for song_data in pl.get("songs", []): local = conn.execute( "SELECT id FROM songs WHERE title=? AND artist=? AND file_missing=0 LIMIT 1", (song_data["title"], song_data["artist"]) ).fetchone() if local: conn.execute(""" INSERT INTO playlist_songs (playlist_id, song_id, position, status, is_workshop, dance_override) VALUES (?,?,?,?,?,?) """, (pl_id, local["id"], song_data["position"], song_data.get("status","pending"), 1 if song_data.get("is_workshop") else 0, song_data.get("dance_override","") or "")) conn.commit() conn.close()