Rettelsaer

This commit is contained in:
2026-04-13 07:23:37 +02:00
parent 45dcedaed4
commit bbd5690d72
22 changed files with 2026 additions and 538 deletions

View File

@@ -0,0 +1,173 @@
"""
linked_playlist.py — Håndter linkede server-playlister.
Pull ved åbning, push ved gem.
"""
import json
import sqlite3
import urllib.request
import urllib.error
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
class LinkedPlaylistManager:
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
def _headers(self):
return {
"Content-Type": "application/json",
"Authorization": f"Bearer {self._token}",
}
def pull(self, playlist_id: int) -> list[dict]:
"""
Hent seneste version fra serveren og opdater lokal liste.
Returnerer sang-liste klar til playlist_panel.
"""
conn = sqlite3.connect(self._db_path)
conn.row_factory = sqlite3.Row
pl = conn.execute(
"SELECT api_project_id, server_permission FROM playlists WHERE id=?",
(playlist_id,)
).fetchone()
if not pl or not pl["api_project_id"]:
conn.close()
return []
# Hent fra server
req = urllib.request.Request(
f"{self._server_url}/sharing/playlists/{pl['api_project_id']}",
headers=self._headers()
)
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read())
# Slet eksisterende sange og erstat med server-version
conn.execute(
"DELETE FROM playlist_songs WHERE playlist_id=?", (playlist_id,)
)
songs = []
for song_data in sorted(data.get("songs", []), key=lambda x: x["position"]):
# Match lokalt på titel+artist
local = conn.execute(
"SELECT id, local_path, bpm, duration_sec, file_format, file_missing "
"FROM songs WHERE title=? AND artist=? AND file_missing=0 LIMIT 1",
(song_data["title"], song_data["artist"])
).fetchone()
if local:
conn.execute("""
INSERT OR IGNORE INTO playlist_songs
(playlist_id, song_id, position, status, is_workshop, dance_override)
VALUES (?,?,?,?,?,?)
""", (
playlist_id, local["id"],
song_data["position"], song_data["status"],
1 if song_data.get("is_workshop") else 0,
song_data.get("dance_override", ""),
))
# Hent danse
dances = conn.execute("""
SELECT d.name FROM song_dances sd
JOIN dances d ON d.id = sd.dance_id
WHERE sd.song_id=? ORDER BY sd.dance_order
""", (local["id"],)).fetchall()
songs.append({
"id": local["id"],
"title": song_data["title"],
"artist": song_data["artist"],
"album": song_data.get("album", ""),
"bpm": local["bpm"] or 0,
"duration_sec": local["duration_sec"] or 0,
"local_path": local["local_path"],
"file_format": local["file_format"] or "",
"file_missing": False,
"dances": [d["name"] for d in dances],
"active_dance": song_data.get("dance_override", ""),
"is_workshop": bool(song_data.get("is_workshop")),
"status": song_data.get("status", "pending"),
})
conn.commit()
conn.close()
return songs
def push(self, playlist_id: int):
"""Push lokal version til serveren."""
conn = sqlite3.connect(self._db_path)
conn.row_factory = sqlite3.Row
pl = conn.execute(
"SELECT api_project_id, server_permission, name FROM playlists WHERE id=?",
(playlist_id,)
).fetchone()
if not pl or not pl["api_project_id"]:
conn.close()
raise Exception("Playlisten er ikke linket til serveren")
if pl["server_permission"] not in ("edit",):
conn.close()
raise Exception(f"Du har ikke rettighed til at redigere denne liste (du har: {pl['server_permission']})")
# Byg payload til sync/push
songs_raw = conn.execute("""
SELECT s.id, s.title, s.artist, s.album, s.bpm, s.duration_sec,
s.file_format, 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
""", (playlist_id,)).fetchall()
conn.close()
from local.sync_manager import SyncManager
mgr = SyncManager(self._db_path, self._server_url, self._token)
# Byg mini-payload med kun denne playliste
song_ids = [row["id"] for row in songs_raw]
songs_payload = []
for row in songs_raw:
songs_payload.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 "",
})
pl_payload = [{
"local_id": str(playlist_id),
"name": pl["name"],
"description": "",
"tags": "",
"visibility": "shared",
"songs": [
{
"song_local_id": str(row["id"]),
"position": int(row["position"]),
"status": row["status"] or "pending",
"is_workshop": bool(row["is_workshop"]),
"dance_override": row["dance_override"] or "",
}
for row in songs_raw
]
}]
result = mgr._post("/sync/push", {
"songs": songs_payload,
"dances": [],
"song_dances": [],
"song_alts": [],
"playlists": pl_payload,
})
return result

View File

@@ -251,6 +251,11 @@ MIGRATIONS: dict[int, list[str]] = {
"""ALTER TABLE playlist_songs ADD COLUMN is_workshop INTEGER NOT NULL DEFAULT 0""",
"""ALTER TABLE playlist_songs ADD COLUMN dance_override TEXT NOT NULL DEFAULT ''""",
],
7: [
# Linkede server-playlister
"""ALTER TABLE playlists ADD COLUMN is_linked INTEGER NOT NULL DEFAULT 0""",
"""ALTER TABLE playlists ADD COLUMN server_permission TEXT NOT NULL DEFAULT 'view'""",
],
}
@@ -481,6 +486,20 @@ def create_playlist(name: str, description: str = "", tags: str = "") -> int:
return cur.lastrowid
def create_linked_playlist(name: str, api_project_id: str,
permission: str = "view",
description: str = "", tags: str = "") -> int:
"""Opret en playliste der er linket til en server-playliste."""
with get_db() as conn:
cur = conn.execute(
"""INSERT INTO playlists
(name, description, tags, api_project_id, is_linked, server_permission)
VALUES (?,?,?,?,1,?)""",
(name, description, tags, api_project_id, permission)
)
return cur.lastrowid
def update_playlist_tags(playlist_id: int, tags: str):
with get_db() as conn:
conn.execute(

View File

@@ -0,0 +1,245 @@
"""
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 community forslag."""
conn = sqlite3.connect(self._db_path)
conn.row_factory = sqlite3.Row
# Opdater dans-info fra server (koreograf, links, noter)
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"]))
conn.commit()
conn.close()