""" 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 from pathlib import Path logger = logging.getLogger(__name__) class SyncManager: 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() req = urllib.request.Request( 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() raise Exception(f"HTTP {e.code}: {detail}") def _get(self, path: str) -> dict: req = urllib.request.Request( f"{self._api_url}{path}", headers={"Authorization": f"Bearer {self._token}"}, ) 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): def _run(): try: payload = self._build_push_payload() 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", {}), ) logger.info(f"Push OK: {result.get('songs_synced','?')} sange synkroniseret") if on_done: on_done(result) except Exception as e: 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): def _run(): try: # 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_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 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) # 3. Fjern soft-slettede permanent efter succesfuld sync if deleted: 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("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") if on_done: on_done({"push": push_result, "pull": pull_result}) except Exception as e: logger.error(f"push_and_pull fejl: {e}", exc_info=True) 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, timeout=10) conn.row_factory = sqlite3.Row conn.execute("PRAGMA journal_mode=WAL") # Sange (dem der har filer — altså kendes lokalt) songs = [] 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": 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, "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(): 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 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": 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": row["song_id"], "dance_name": row["dance_name"], "level_name": row["level_name"] or "", "note": row["note"] or "", }) # 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(): pl_songs = [] for ps in conn.execute(""" 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": ps["song_id"], "song_title": ps["title"] or "", "song_artist": ps["artist"] or "", "position": int(ps["position"] or 1), "status": ps["status"] or "pending", "is_workshop": bool(ps["is_workshop"]), "dance_override": ps["dance_override"] or "", }) # 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"], "description": pl["description"] or "", "tags": pl["tags"] or "", "visibility": "private", "songs": pl_songs, }) # Slettede playlister deleted = [ 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 AND api_project_id != ''" ).fetchall() ] conn.close() return { "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.""" import uuid conn = sqlite3.connect(self._db_path, timeout=10) conn.row_factory = sqlite3.Row conn.execute("PRAGMA journal_mode=WAL") try: # Opdater dans-info 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")): 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( "UPDATE playlists SET name=? WHERE id=?", (name, 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,'edit')", (pl_id, name, pl.get("description",""), server_id) ) # Genindlæs sange conn.execute("DELETE FROM playlist_songs WHERE playlist_id=?", (pl_id,)) position = 1 songs_from_server = pl.get("songs", []) logger.info(f"Pull: liste '{name}' har {len(songs_from_server)} sange") 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), ) # 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