Rettelsaer
This commit is contained in:
173
linedance-app/local/linked_playlist.py
Normal file
173
linedance-app/local/linked_playlist.py
Normal 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
|
||||
@@ -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(
|
||||
|
||||
245
linedance-app/local/sync_manager.py
Normal file
245
linedance-app/local/sync_manager.py
Normal 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()
|
||||
Reference in New Issue
Block a user