Rettelsaer
This commit is contained in:
@@ -1,23 +1,22 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
from PyInstaller.utils.hooks import collect_all, collect_submodules
|
||||
|
||||
block_cipher = None
|
||||
|
||||
# Saml ALT fra PyQt6 inkl. plugins og DLL-filer
|
||||
pyqt6_datas, pyqt6_binaries, pyqt6_hiddenimports = collect_all('PyQt6')
|
||||
|
||||
a = Analysis(
|
||||
['main.py'],
|
||||
pathex=['.'],
|
||||
binaries=[],
|
||||
datas=[
|
||||
('translations', 'translations'),
|
||||
('ui', 'ui'),
|
||||
('local', 'local'),
|
||||
('player', 'player'),
|
||||
],
|
||||
hiddenimports=[
|
||||
binaries=pyqt6_binaries,
|
||||
datas=pyqt6_datas,
|
||||
hiddenimports=pyqt6_hiddenimports + [
|
||||
'PyQt6.sip',
|
||||
'PyQt6.QtCore',
|
||||
'PyQt6.QtGui',
|
||||
'PyQt6.QtWidgets',
|
||||
'PyQt6.QtNetwork',
|
||||
# UI moduler
|
||||
'ui.main_window',
|
||||
'ui.playlist_panel',
|
||||
'ui.library_panel',
|
||||
@@ -25,76 +24,30 @@ a = Analysis(
|
||||
'ui.themes',
|
||||
'ui.vu_meter',
|
||||
'ui.scan_worker',
|
||||
'ui.bpm_worker',
|
||||
'ui.tag_editor',
|
||||
'ui.login_dialog',
|
||||
'ui.settings_dialog',
|
||||
'ui.register_dialog',
|
||||
'ui.playlist_browser',
|
||||
'ui.playlist_info_dialog',
|
||||
'ui.dance_info_dialog',
|
||||
'ui.dance_picker_dialog',
|
||||
'ui.playlist_manager',
|
||||
'ui.next_up_bar',
|
||||
# Player + local
|
||||
'player.player',
|
||||
'local.local_db',
|
||||
'local.tag_reader',
|
||||
'local.file_watcher',
|
||||
'local.scanner',
|
||||
'translations',
|
||||
'translations.da',
|
||||
'translations.en',
|
||||
'mutagen',
|
||||
'mutagen.mp3', 'mutagen.id3', 'mutagen.flac',
|
||||
# Biblioteker
|
||||
'mutagen', 'mutagen.mp3', 'mutagen.id3', 'mutagen.flac',
|
||||
'mutagen.mp4', 'mutagen.oggvorbis', 'mutagen.ogg',
|
||||
'mutagen.wave', 'mutagen.aiff', 'mutagen.asf',
|
||||
'watchdog',
|
||||
'watchdog.observers',
|
||||
'watchdog.observers.polling',
|
||||
'watchdog.events',
|
||||
'vlc',
|
||||
'sqlite3',
|
||||
'watchdog', 'watchdog.observers', 'watchdog.events',
|
||||
'watchdog.observers.winapi',
|
||||
'vlc', 'sqlite3',
|
||||
],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[
|
||||
'tkinter', 'tk', 'tcl',
|
||||
'matplotlib', 'pandas', 'scipy', 'numpy',
|
||||
'IPython', 'jupyter', 'notebook',
|
||||
'PIL', 'Pillow',
|
||||
'cv2', 'sklearn',
|
||||
'PyQt6.QtWebEngineWidgets',
|
||||
'PyQt6.QtWebEngineCore',
|
||||
'PyQt6.QtWebEngine',
|
||||
'PyQt6.QtMultimedia',
|
||||
'PyQt6.QtMultimediaWidgets',
|
||||
'PyQt6.QtBluetooth',
|
||||
'PyQt6.QtNfc',
|
||||
'PyQt6.QtPositioning',
|
||||
'PyQt6.QtLocation',
|
||||
'PyQt6.QtSensors',
|
||||
'PyQt6.QtSerialPort',
|
||||
'PyQt6.QtSql',
|
||||
'PyQt6.QtTest',
|
||||
'PyQt6.QtXml',
|
||||
'PyQt6.QtOpenGL',
|
||||
'PyQt6.QtOpenGLWidgets',
|
||||
'PyQt6.Qt3DCore',
|
||||
'PyQt6.Qt3DRender',
|
||||
'PyQt6.Qt3DInput',
|
||||
'PyQt6.Qt3DLogic',
|
||||
'PyQt6.Qt3DAnimation',
|
||||
'PyQt6.Qt3DExtras',
|
||||
'PyQt6.QtCharts',
|
||||
'PyQt6.QtDataVisualization',
|
||||
'PyQt6.QtQuick',
|
||||
'PyQt6.QtQuickWidgets',
|
||||
'PyQt6.QtQml',
|
||||
'PyQt6.QtRemoteObjects',
|
||||
'PyQt6.QtScxml',
|
||||
'PyQt6.QtStateMachine',
|
||||
'unittest', 'doctest', 'pdb',
|
||||
'pydoc',
|
||||
],
|
||||
excludes=['tkinter', 'matplotlib', 'pandas', 'scipy', 'IPython'],
|
||||
win_no_prefer_redirects=False,
|
||||
win_private_assemblies=False,
|
||||
cipher=block_cipher,
|
||||
noarchive=False,
|
||||
)
|
||||
@@ -104,15 +57,18 @@ pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
[], # ← onedir: ingen binaries/datas her
|
||||
exclude_binaries=True, # ← onedir: binaries samles i COLLECT
|
||||
[],
|
||||
exclude_binaries=True,
|
||||
name='LineDancePlayer',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=['Qt6*.dll', 'python3*.dll', 'vcruntime140.dll'],
|
||||
console=False,
|
||||
upx=False, # UPX kan give problemer med PyQt6 DLL-filer
|
||||
console=False, # Ingen konsol-vindue
|
||||
disable_windowed_traceback=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
icon=None,
|
||||
)
|
||||
|
||||
@@ -122,7 +78,7 @@ coll = COLLECT(
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=['Qt6*.dll', 'python3*.dll', 'vcruntime140.dll'],
|
||||
upx=False,
|
||||
upx_exclude=[],
|
||||
name='LineDancePlayer',
|
||||
)
|
||||
|
||||
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()
|
||||
@@ -95,6 +95,9 @@ class MainWindow(QMainWindow):
|
||||
self._connect_player_signals()
|
||||
self._library_loaded.connect(self._apply_library)
|
||||
self._db_ready.connect(self._on_db_ready)
|
||||
self._login_success_signal.connect(self._on_login_success)
|
||||
self._login_fail_signal.connect(self._on_login_fail)
|
||||
self._status_signal.connect(self._set_status)
|
||||
self._build_menu()
|
||||
self._build_ui()
|
||||
self._build_statusbar()
|
||||
@@ -130,15 +133,15 @@ class MainWindow(QMainWindow):
|
||||
# ── Filer ─────────────────────────────────────────────────────────────
|
||||
file_menu = menubar.addMenu("Filer")
|
||||
|
||||
self._act_go_online = QAction("Gå online...", self)
|
||||
self._act_go_online = QAction("● Gå online", self)
|
||||
self._act_go_online.setShortcut("Ctrl+L")
|
||||
self._act_go_online.triggered.connect(self._go_online)
|
||||
file_menu.addAction(self._act_go_online)
|
||||
|
||||
self._act_go_offline = QAction("Gå offline", self)
|
||||
self._act_go_offline.triggered.connect(self._go_offline)
|
||||
self._act_go_offline.setEnabled(False)
|
||||
file_menu.addAction(self._act_go_offline)
|
||||
self._act_sync = QAction("↕ Synkroniser nu", self)
|
||||
self._act_sync.setShortcut("Ctrl+Shift+S")
|
||||
self._act_sync.triggered.connect(self._manual_sync)
|
||||
file_menu.addAction(self._act_sync)
|
||||
|
||||
file_menu.addSeparator()
|
||||
|
||||
@@ -287,28 +290,26 @@ class MainWindow(QMainWindow):
|
||||
b.setCheckable(True)
|
||||
return b
|
||||
|
||||
self._btn_prev = btn("|◀◀", size=52)
|
||||
self._btn_play = btn("▶", "btn_play", size=72)
|
||||
self._btn_stop = btn("■", "btn_stop", size=52)
|
||||
self._btn_next = btn("▶▶|", size=52)
|
||||
self._btn_stop = btn("■", "btn_stop", size=72)
|
||||
self._btn_demo = btn(f"▶\n{self._demo_seconds} SEK", "btn_demo", size=64, checkable=True)
|
||||
|
||||
self._btn_prev.clicked.connect(self._prev_song)
|
||||
self._btn_play.clicked.connect(self._toggle_play)
|
||||
self._btn_stop.clicked.connect(self._stop)
|
||||
self._btn_next.clicked.connect(self._next_song)
|
||||
self._btn_demo.clicked.connect(self._toggle_demo)
|
||||
|
||||
layout.addWidget(self._btn_prev)
|
||||
layout.addWidget(self._btn_play)
|
||||
layout.addWidget(self._btn_stop)
|
||||
layout.addWidget(self._btn_next)
|
||||
|
||||
layout.addSpacing(24)
|
||||
|
||||
sep1 = QFrame()
|
||||
sep1.setFrameShape(QFrame.Shape.VLine)
|
||||
sep1.setFixedWidth(1)
|
||||
layout.addWidget(sep1)
|
||||
|
||||
layout.addSpacing(24)
|
||||
|
||||
layout.addWidget(self._btn_demo)
|
||||
layout.addStretch()
|
||||
|
||||
@@ -319,7 +320,9 @@ class MainWindow(QMainWindow):
|
||||
self._vol_slider = QSlider(Qt.Orientation.Horizontal)
|
||||
self._vol_slider.setRange(0, 100)
|
||||
self._vol_slider.setValue(self._settings.get("volume", 78))
|
||||
self._vol_slider.setFixedWidth(100)
|
||||
self._vol_slider.setFixedWidth(160)
|
||||
self._vol_slider.setFixedHeight(36)
|
||||
self._vol_slider.setObjectName("vol_slider")
|
||||
self._vol_slider.valueChanged.connect(self._on_volume)
|
||||
layout.addWidget(self._vol_slider)
|
||||
|
||||
@@ -336,7 +339,14 @@ class MainWindow(QMainWindow):
|
||||
self._playlist_panel.song_selected.connect(self._load_song_by_idx)
|
||||
self._playlist_panel.song_dropped.connect(self._on_song_dropped)
|
||||
self._playlist_panel.event_started.connect(self._on_event_started)
|
||||
self._playlist_panel.next_song_ready.connect(self._load_song)
|
||||
self._playlist_panel.next_song_ready.connect(self._on_next_song_ready)
|
||||
self._playlist_panel.playlist_changed.connect(self._on_playlist_changed)
|
||||
|
||||
# Debounce-timer til auto-sync — starter sync 5 sek efter sidst ændring
|
||||
self._sync_debounce = QTimer(self)
|
||||
self._sync_debounce.setSingleShot(True)
|
||||
self._sync_debounce.setInterval(5000)
|
||||
self._sync_debounce.timeout.connect(self._auto_sync)
|
||||
|
||||
self._library_panel = LibraryPanel()
|
||||
self._library_panel.song_selected.connect(self._on_library_song_selected)
|
||||
@@ -433,9 +443,12 @@ class MainWindow(QMainWindow):
|
||||
QTimer.singleShot(200, self._reload_library)
|
||||
|
||||
# Signal til at opdatere biblioteket fra baggrundstråd
|
||||
_library_loaded = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal(list)
|
||||
_db_ready = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal()
|
||||
_file_changed_signal = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal()
|
||||
_library_loaded = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal(list)
|
||||
_db_ready = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal()
|
||||
_file_changed_signal = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal()
|
||||
_login_success_signal = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal(str)
|
||||
_login_fail_signal = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal(str)
|
||||
_status_signal = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal(str, int)
|
||||
|
||||
def _reload_library(self):
|
||||
"""Hent sange fra DB i baggrundstråd — thread-safe via signal."""
|
||||
@@ -508,20 +521,36 @@ class MainWindow(QMainWindow):
|
||||
try:
|
||||
restored = self._playlist_panel.restore_active_playlist()
|
||||
if restored:
|
||||
# Hent den sang der er klar (current_idx sat af restore)
|
||||
idx = self._playlist_panel._current_idx
|
||||
song = self._playlist_panel.get_song(idx)
|
||||
|
||||
if self._playlist_panel.restore_event_state():
|
||||
idx = self._playlist_panel._current_idx
|
||||
# Event var i gang — genoptag
|
||||
idx = self._playlist_panel._current_idx
|
||||
song = self._playlist_panel.get_song(idx)
|
||||
if song:
|
||||
self._current_idx = idx
|
||||
self._song_ended = False
|
||||
self._load_song(song)
|
||||
self._playlist_panel.set_current(idx)
|
||||
self._set_status(
|
||||
f"Event genoptaget ved: {song.get('title','')} — tryk ▶ for at fortsætte",
|
||||
f"Event genoptaget ved: {song.get('title','')} — tryk ▶",
|
||||
6000,
|
||||
)
|
||||
elif song:
|
||||
# Normal opstart — load første sang klar
|
||||
self._current_idx = idx
|
||||
self._song_ended = False
|
||||
self._load_song(song)
|
||||
self._playlist_panel.set_current(idx)
|
||||
self._set_status(
|
||||
f"Klar: {song.get('title','')} — tryk ▶ for at starte",
|
||||
4000,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Scan 30 sek efter opstart — fanger ændringer siden sidst
|
||||
QTimer.singleShot(30000, self.start_background_scan)
|
||||
|
||||
def start_background_scan(self):
|
||||
@@ -602,40 +631,104 @@ class MainWindow(QMainWindow):
|
||||
|
||||
def _auto_login(self):
|
||||
"""Forsøg automatisk login med gemte oplysninger."""
|
||||
username = self._settings.get("username", "")
|
||||
password = self._settings.get("password", "")
|
||||
username = self._settings.get("username", "")
|
||||
password = self._settings.get("password", "")
|
||||
server_url = self._settings.get("server_url", "http://localhost:8000").rstrip("/")
|
||||
if not username or not password:
|
||||
return
|
||||
try:
|
||||
import urllib.request, urllib.parse, json
|
||||
data = urllib.parse.urlencode({"username": username, "password": password}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{API_URL}/auth/login", data=data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||
body = json.loads(resp.read())
|
||||
self._api_token = body.get("access_token")
|
||||
self._api_url = API_URL
|
||||
self._api_username = username
|
||||
self._set_online_state(True)
|
||||
self._set_status(f"Automatisk logget ind som {username}", 4000)
|
||||
# Synkroniser dans-niveauer og navne
|
||||
QTimer.singleShot(500, self._sync_dance_data)
|
||||
except Exception:
|
||||
self._set_status("Auto-login fejlede — kør Filer → Gå online manuelt", 5000)
|
||||
|
||||
def _run():
|
||||
try:
|
||||
import urllib.request, urllib.parse, json
|
||||
data = urllib.parse.urlencode({"username": username, "password": password}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{server_url}/auth/login", data=data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||
body = json.loads(resp.read())
|
||||
self._api_token = body.get("access_token")
|
||||
self._api_url = server_url
|
||||
self._api_username = username
|
||||
# Kald GUI-opdatering via signal — thread-safe
|
||||
self._login_success_signal.emit(username)
|
||||
except Exception as e:
|
||||
self._login_fail_signal.emit(str(e))
|
||||
|
||||
import threading
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
|
||||
def _on_playlist_changed(self):
|
||||
"""Danseliste ændret — start debounce-timer til auto-sync."""
|
||||
if hasattr(self, "_sync_debounce"):
|
||||
self._sync_debounce.start()
|
||||
|
||||
def _auto_sync(self):
|
||||
"""Kør sync hvis vi er online — kaldes af debounce-timer."""
|
||||
if not self._api_token:
|
||||
return
|
||||
if not hasattr(self, "_sync_manager") or not self._sync_manager:
|
||||
return
|
||||
self._sync_manager.push(
|
||||
on_done=lambda r: self._status_signal.emit(
|
||||
f"↑ Synkroniseret — {r.get('songs_synced', 0)} sange", 3000
|
||||
),
|
||||
on_error=lambda e: self._status_signal.emit(
|
||||
f"⚠ Sync fejl: {e}", 8000
|
||||
),
|
||||
)
|
||||
|
||||
def _on_next_song_ready(self, song: dict):
|
||||
"""Næste sang er klar — load den i afspilleren og markér orange."""
|
||||
idx = self._playlist_panel._current_idx
|
||||
self._current_idx = idx
|
||||
self._song_ended = False
|
||||
self._playlist_panel._song_ended = False
|
||||
self._load_song(song)
|
||||
self._playlist_panel.set_current(idx)
|
||||
|
||||
def _on_login_success(self, username: str):
|
||||
"""Kaldes i GUI-tråden når login lykkes."""
|
||||
self._set_online_state(True)
|
||||
self._set_status(f"Logget ind som {username}", 4000)
|
||||
|
||||
def _on_login_fail(self, error: str):
|
||||
"""Kaldes i GUI-tråden når login fejler."""
|
||||
self._set_status(f"Login fejlede: {error}", 5000)
|
||||
|
||||
def _go_online(self):
|
||||
dialog = LoginDialog(self)
|
||||
if dialog.exec():
|
||||
url, username, token = dialog.get_credentials()
|
||||
self._api_url = url
|
||||
self._api_token = token
|
||||
self._api_username = username
|
||||
self._set_online_state(True)
|
||||
self._set_status(f"Online som {username}", 5000)
|
||||
QTimer.singleShot(500, self._sync_dance_data)
|
||||
"""Log ind/ud med gemte credentials."""
|
||||
if self._api_token:
|
||||
self._go_offline()
|
||||
return
|
||||
username = self._settings.get("username", "")
|
||||
password = self._settings.get("password", "")
|
||||
server_url = self._settings.get("server_url", "http://localhost:8000").rstrip("/")
|
||||
if not username or not password:
|
||||
self._set_status("Udfyld brugernavn og kodeord i Indstillinger → Online", 5000)
|
||||
return
|
||||
|
||||
def _run():
|
||||
try:
|
||||
import urllib.request, urllib.parse, json
|
||||
data = urllib.parse.urlencode({"username": username, "password": password}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{server_url}/auth/login", data=data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||
body = json.loads(resp.read())
|
||||
self._api_token = body.get("access_token")
|
||||
self._api_url = server_url
|
||||
self._api_username = username
|
||||
self._login_success_signal.emit(username)
|
||||
except Exception as e:
|
||||
self._login_fail_signal.emit(str(e))
|
||||
|
||||
import threading
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
|
||||
def _sync_dance_data(self):
|
||||
"""Synkroniser dans-niveauer og navne fra API."""
|
||||
@@ -669,15 +762,56 @@ class MainWindow(QMainWindow):
|
||||
self._set_status("Offline — arbejder lokalt", 3000)
|
||||
|
||||
def _set_online_state(self, online: bool):
|
||||
self._act_go_online.setEnabled(not online)
|
||||
self._act_go_offline.setEnabled(online)
|
||||
if online:
|
||||
name = self._api_username or "?"
|
||||
self._conn_label.setText(f"● ONLINE ({name})")
|
||||
self._conn_label.setStyleSheet("color: #2ecc71;")
|
||||
self._act_go_online.setText("● Gå offline")
|
||||
self._init_sync()
|
||||
else:
|
||||
self._conn_label.setText("● OFFLINE")
|
||||
self._conn_label.setStyleSheet("color: #5a6070;")
|
||||
self._act_go_online.setText("● Gå online")
|
||||
self._sync_manager = None
|
||||
|
||||
def _init_sync(self):
|
||||
"""Opret SyncManager og kør initial push+pull."""
|
||||
try:
|
||||
from local.local_db import DB_PATH
|
||||
from local.sync_manager import SyncManager
|
||||
server_url = self._settings.get("server_url", "http://localhost:8000")
|
||||
self._sync_manager = SyncManager(
|
||||
db_path=str(DB_PATH),
|
||||
server_url=server_url,
|
||||
token=self._api_token,
|
||||
)
|
||||
self._sync_manager.push_and_pull(
|
||||
on_done=lambda r: self._status_signal.emit(
|
||||
f"✓ Synkroniseret — {r['push']['songs_synced']} sange", 5000
|
||||
),
|
||||
on_error=lambda e: self._status_signal.emit(
|
||||
f"⚠ Sync fejl: {e}", 5000
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
self._set_status(f"⚠ Sync fejl: {e}", 5000)
|
||||
|
||||
def _manual_sync(self):
|
||||
if not self._api_token:
|
||||
self._set_status("Log ind for at synkronisere", 3000)
|
||||
return
|
||||
if not hasattr(self, "_sync_manager") or not self._sync_manager:
|
||||
self._init_sync()
|
||||
return
|
||||
self._set_status("Synkroniserer...", 2000)
|
||||
self._sync_manager.push_and_pull(
|
||||
on_done=lambda r: self._status_signal.emit(
|
||||
f"✓ Synkroniseret — {r['push']['songs_synced']} sange", 4000
|
||||
),
|
||||
on_error=lambda e: self._status_signal.emit(
|
||||
f"⚠ Sync fejl: {e}", 5000
|
||||
),
|
||||
)
|
||||
|
||||
def _new_playlist(self):
|
||||
self._stop()
|
||||
@@ -851,6 +985,12 @@ class MainWindow(QMainWindow):
|
||||
song = self._playlist_panel.get_song(idx)
|
||||
if not song:
|
||||
return
|
||||
# Nulstil gammel markering
|
||||
old_idx = self._playlist_panel._current_idx
|
||||
if old_idx is not None and old_idx != idx:
|
||||
if 0 <= old_idx < len(self._playlist_panel._statuses):
|
||||
if self._playlist_panel._statuses[old_idx] == "playing":
|
||||
self._playlist_panel._statuses[old_idx] = "pending"
|
||||
self._current_idx = idx
|
||||
self._load_song(song)
|
||||
self._playlist_panel.set_current(idx)
|
||||
@@ -944,13 +1084,16 @@ class MainWindow(QMainWindow):
|
||||
self._btn_play.setText("▶")
|
||||
self._vu.reset()
|
||||
|
||||
# Synkroniser current_idx til playlist_panel
|
||||
self._playlist_panel._current_idx = self._current_idx
|
||||
|
||||
# Markér den afspillede sang
|
||||
self._playlist_panel.mark_played(self._current_idx)
|
||||
|
||||
# Synkroniser event-status til den gemte navngivne liste
|
||||
self._sync_event_status_to_playlist()
|
||||
|
||||
# Find første ikke-afspillede og ikke-skippede sang fra TOPPEN
|
||||
# Find næste uafspillede
|
||||
ni = self._playlist_panel.next_playable_idx()
|
||||
next_song = self._playlist_panel.get_song(ni) if ni is not None else None
|
||||
if next_song:
|
||||
@@ -959,7 +1102,6 @@ class MainWindow(QMainWindow):
|
||||
self._load_song(next_song)
|
||||
self._set_status(f"Klar: {next_song.get('title','')} — tryk ▶ for at starte")
|
||||
else:
|
||||
# Danseliste afsluttet — nulstil liste-markering og synkroniser
|
||||
self._current_idx = -1
|
||||
self._playlist_panel._current_idx = -1
|
||||
self._playlist_panel._song_ended = False
|
||||
|
||||
@@ -130,6 +130,12 @@ class PlaylistBrowserDialog(QDialog):
|
||||
btn_tags = QPushButton("🏷 Rediger tags")
|
||||
btn_tags.clicked.connect(self._edit_tags)
|
||||
btn_row.addWidget(btn_tags)
|
||||
btn_share = QPushButton("↗ Del...")
|
||||
btn_share.clicked.connect(self._share_selected)
|
||||
btn_row.addWidget(btn_share)
|
||||
btn_shared = QPushButton("🌐 Hent delte")
|
||||
btn_shared.clicked.connect(self._fetch_shared)
|
||||
btn_row.addWidget(btn_shared)
|
||||
|
||||
btn_row.addStretch()
|
||||
btn_cancel = QPushButton("Annuller")
|
||||
@@ -344,3 +350,167 @@ class PlaylistBrowserDialog(QDialog):
|
||||
self._load_data()
|
||||
except Exception as e:
|
||||
QMessageBox.warning(self, "Fejl", f"Kunne ikke slette: {e}")
|
||||
|
||||
def _share_selected(self):
|
||||
"""Åbn del-dialog for den valgte playliste."""
|
||||
item = self._list.currentItem()
|
||||
if not item:
|
||||
QMessageBox.information(self, "Del", "Vælg en playliste først.")
|
||||
return
|
||||
pl = item.data(Qt.ItemDataRole.UserRole)
|
||||
if not isinstance(pl, dict):
|
||||
return
|
||||
|
||||
# Hent server-info fra settings
|
||||
try:
|
||||
from ui.settings_dialog import load_settings
|
||||
s = load_settings()
|
||||
server_url = s.get("server_url", "")
|
||||
token = self._get_token()
|
||||
if not token:
|
||||
QMessageBox.warning(self, "Ikke logget ind",
|
||||
"Du skal være logget ind for at dele.")
|
||||
return
|
||||
|
||||
# Find server-ID for playlisten
|
||||
server_id = pl.get("api_project_id")
|
||||
if not server_id:
|
||||
QMessageBox.warning(self, "Ikke synkroniseret",
|
||||
"Synkroniser playlisten til serveren først\n"
|
||||
"(Filer → Synkroniser nu).")
|
||||
return
|
||||
|
||||
from ui.share_dialog import ShareDialog
|
||||
dlg = ShareDialog(server_id, pl["name"], server_url, token,
|
||||
parent=self)
|
||||
dlg.exec()
|
||||
except Exception as e:
|
||||
QMessageBox.warning(self, "Fejl", str(e))
|
||||
|
||||
def _get_token(self) -> str | None:
|
||||
"""Hent JWT token fra main_window."""
|
||||
mw = self.parent()
|
||||
while mw and not hasattr(mw, "_api_token"):
|
||||
mw = mw.parent()
|
||||
return getattr(mw, "_api_token", None) if mw else None
|
||||
|
||||
def _fetch_shared(self):
|
||||
"""Hent playlister der er delt med mig fra serveren."""
|
||||
try:
|
||||
from ui.settings_dialog import load_settings
|
||||
s = load_settings()
|
||||
server_url = s.get("server_url", "").rstrip("/")
|
||||
token = self._get_token()
|
||||
if not token:
|
||||
QMessageBox.warning(self, "Ikke logget ind",
|
||||
"Du skal være logget ind for at hente delte lister.")
|
||||
return
|
||||
|
||||
import urllib.request, json
|
||||
req = urllib.request.Request(
|
||||
f"{server_url}/sharing/playlists/shared-with-me",
|
||||
headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
shared = json.loads(resp.read())
|
||||
|
||||
if not shared:
|
||||
QMessageBox.information(self, "Ingen delte lister",
|
||||
"Ingen playlister er delt med dig.")
|
||||
return
|
||||
|
||||
# Vis valgdialog
|
||||
from PyQt6.QtWidgets import QInputDialog
|
||||
options = [
|
||||
f"{p['name']} (af {p['owner']}, {p['song_count']} sange, {p['permission']})"
|
||||
for p in shared
|
||||
]
|
||||
choice, ok = QInputDialog.getItem(
|
||||
self, "Hent delt playliste",
|
||||
"Vælg en playliste at hente:",
|
||||
options, 0, False
|
||||
)
|
||||
if not ok:
|
||||
return
|
||||
|
||||
idx = options.index(choice)
|
||||
chosen = shared[idx]
|
||||
|
||||
# Hent indholdet
|
||||
req2 = urllib.request.Request(
|
||||
f"{server_url}/sharing/playlists/{chosen['project_id']}",
|
||||
headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
with urllib.request.urlopen(req2, timeout=10) as resp:
|
||||
pl_data = json.loads(resp.read())
|
||||
|
||||
self._import_shared_playlist(pl_data, server_url, token,
|
||||
permission=chosen.get("permission", "view"))
|
||||
|
||||
except Exception as e:
|
||||
QMessageBox.warning(self, "Fejl", f"Kunne ikke hente: {e}")
|
||||
|
||||
def _import_shared_playlist(self, pl_data: dict, server_url: str, token: str,
|
||||
permission: str = "view"):
|
||||
"""Importer en delt playliste som en linket liste."""
|
||||
import sqlite3
|
||||
from local.local_db import DB_PATH, get_db, add_song_to_playlist
|
||||
|
||||
name = pl_data["name"]
|
||||
server_id = pl_data["id"]
|
||||
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
# Tjek om listen allerede er linket
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM playlists WHERE api_project_id=?", (server_id,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
|
||||
if existing:
|
||||
# Opdater eksisterende
|
||||
pl_id = existing["id"]
|
||||
with get_db() as c:
|
||||
c.execute("DELETE FROM playlist_songs WHERE playlist_id=?", (pl_id,))
|
||||
else:
|
||||
# Opret ny linket playliste
|
||||
with get_db() as c:
|
||||
c.execute(
|
||||
"INSERT INTO playlists (name, api_project_id, is_linked, server_permission) "
|
||||
"VALUES (?, ?, 1, ?)",
|
||||
(name, server_id, permission)
|
||||
)
|
||||
pl_id = c.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||||
|
||||
# Indsæt sange med sang-matching
|
||||
matched = 0
|
||||
with get_db() as c:
|
||||
for song_data in pl_data.get("songs", []):
|
||||
local = c.execute(
|
||||
"SELECT id FROM songs WHERE title=? AND artist=? AND file_missing=0",
|
||||
(song_data["title"], song_data["artist"])
|
||||
).fetchone()
|
||||
if local:
|
||||
c.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 "")
|
||||
)
|
||||
matched += 1
|
||||
|
||||
self._load_data()
|
||||
self.playlist_selected.emit(pl_id, name)
|
||||
perm_text = {"view": "se", "copy": "kopiere", "edit": "redigere"}.get(
|
||||
permission, permission
|
||||
)
|
||||
QMessageBox.information(
|
||||
self, "Linket",
|
||||
f"'{name}' er nu linket til server-listen.\n"
|
||||
f"Du har rettighed til at {perm_text} listen.\n\n"
|
||||
f"{matched} af {len(pl_data.get('songs', []))} sange fundet lokalt."
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ playlist_info_dialog.py — Flydende danseliste-info vindue med dynamisk opdater
|
||||
"""
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QSpinBox,
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QFrame, QGridLayout,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, pyqtSignal
|
||||
@@ -22,7 +22,6 @@ def fmt_time(seconds: int) -> str:
|
||||
|
||||
|
||||
class PlaylistInfoWindow(QWidget):
|
||||
pause_changed = pyqtSignal(int)
|
||||
|
||||
def __init__(self, playlist_panel, parent=None):
|
||||
super().__init__(parent,
|
||||
@@ -83,33 +82,6 @@ class PlaylistInfoWindow(QWidget):
|
||||
|
||||
layout.addWidget(stats)
|
||||
|
||||
# Indstillinger
|
||||
cfg = QFrame()
|
||||
cfg.setObjectName("track_display")
|
||||
cfg_layout = QGridLayout(cfg)
|
||||
cfg_layout.setContentsMargins(12, 8, 12, 8)
|
||||
cfg_layout.setSpacing(6)
|
||||
|
||||
cfg_layout.addWidget(QLabel("Tid mellem musikstykker:"), 0, 0)
|
||||
self._spin_pause = QSpinBox()
|
||||
self._spin_pause.setRange(0, 600)
|
||||
self._spin_pause.setValue(self._pause_seconds)
|
||||
self._spin_pause.setSuffix(" sek")
|
||||
self._spin_pause.setFixedWidth(90)
|
||||
self._spin_pause.valueChanged.connect(self._on_pause_changed)
|
||||
cfg_layout.addWidget(self._spin_pause, 0, 1)
|
||||
|
||||
cfg_layout.addWidget(QLabel("Tid per workshop:"), 1, 0)
|
||||
self._spin_ws = QSpinBox()
|
||||
self._spin_ws.setRange(0, 120)
|
||||
self._spin_ws.setValue(self._workshop_seconds // 60)
|
||||
self._spin_ws.setSuffix(" min")
|
||||
self._spin_ws.setFixedWidth(90)
|
||||
self._spin_ws.valueChanged.connect(self._on_ws_changed)
|
||||
cfg_layout.addWidget(self._spin_ws, 1, 1)
|
||||
|
||||
layout.addWidget(cfg)
|
||||
|
||||
# Fremgang og ETA
|
||||
eta_frame = QFrame()
|
||||
eta_frame.setObjectName("track_display")
|
||||
@@ -131,26 +103,14 @@ class PlaylistInfoWindow(QWidget):
|
||||
|
||||
layout.addWidget(eta_frame)
|
||||
|
||||
def _on_pause_changed(self, value: int):
|
||||
self._pause_seconds = value
|
||||
if hasattr(self._panel, "_pause_seconds"):
|
||||
self._panel._pause_seconds = value
|
||||
self.pause_changed.emit(value)
|
||||
self._update()
|
||||
|
||||
def _on_ws_changed(self, minutes: int):
|
||||
self._workshop_seconds = minutes * 60
|
||||
if hasattr(self._panel, "_workshop_seconds"):
|
||||
self._panel._workshop_seconds = self._workshop_seconds
|
||||
self._update()
|
||||
|
||||
def _update(self):
|
||||
songs = self._panel.get_songs()
|
||||
statuses = self._panel.get_statuses()
|
||||
total = len(songs)
|
||||
played = statuses.count("played")
|
||||
skipped = statuses.count("skipped")
|
||||
remaining = total - played - skipped
|
||||
done = played + skipped # samlet "overstået"
|
||||
remaining = total - done
|
||||
|
||||
ws_total = sum(1 for s in songs if s.get("is_workshop"))
|
||||
ws_remain = sum(1 for s, st in zip(songs, statuses)
|
||||
@@ -189,10 +149,10 @@ class PlaylistInfoWindow(QWidget):
|
||||
self._lbl_eta.setText("✓ Danselisten er afsluttet!")
|
||||
self._lbl_finish.setText("")
|
||||
elif total > 0:
|
||||
pct = int(played / total * 100) if total > 0 else 0
|
||||
pct = int(done / total * 100) if total > 0 else 0
|
||||
self._lbl_eta.setText(
|
||||
f"{pct}% færdig · {fmt_time(remain_time)} tilbage"
|
||||
if played > 0 else f"Samlet varighed: {fmt_time(total_time)}"
|
||||
if done > 0 else f"Samlet varighed: {fmt_time(total_time)}"
|
||||
)
|
||||
finish = datetime.now() + timedelta(seconds=remain_time)
|
||||
self._lbl_finish.setText(f"Estimeret sluttid: {finish.strftime('%H:%M')}")
|
||||
|
||||
@@ -289,9 +289,11 @@ class PlaylistPanel(QWidget):
|
||||
return self._named_playlist_id
|
||||
|
||||
def next_playable_idx(self) -> int | None:
|
||||
"""Find første sang fra toppen der ikke er 'skipped' eller 'played'."""
|
||||
"""Find første sang fra toppen der ikke er afspillet, sprunget over eller i gang."""
|
||||
for i in range(len(self._songs)):
|
||||
if self._statuses[i] not in ("skipped", "played"):
|
||||
if self._statuses[i] not in ("skipped", "played", "playing"):
|
||||
if i == self._current_idx and not self._song_ended:
|
||||
continue
|
||||
return i
|
||||
return None
|
||||
|
||||
@@ -303,25 +305,42 @@ class PlaylistPanel(QWidget):
|
||||
self._lbl_autosave.setText("● ikke gemt")
|
||||
|
||||
def _autosave(self):
|
||||
"""Gem til den faste 'Aktiv liste' i SQLite."""
|
||||
"""Gem til '__aktiv__' OG til den navngivne liste hvis der er én."""
|
||||
try:
|
||||
from local.local_db import get_db, create_playlist, add_song_to_playlist
|
||||
with get_db() as conn:
|
||||
# Slet den gamle aktive liste
|
||||
conn.execute(
|
||||
"DELETE FROM playlists WHERE name=?", (ACTIVE_PLAYLIST_NAME,)
|
||||
)
|
||||
# Opret ny
|
||||
pl_id = create_playlist(ACTIVE_PLAYLIST_NAME)
|
||||
self._active_playlist_id = pl_id
|
||||
for i, song in enumerate(self._songs, start=1):
|
||||
if song.get("id"):
|
||||
add_song_to_playlist(pl_id, song["id"], position=i)
|
||||
|
||||
# Gem også til den navngivne liste
|
||||
if self._named_playlist_id:
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM playlist_songs WHERE playlist_id=?",
|
||||
(self._named_playlist_id,)
|
||||
)
|
||||
for i, song in enumerate(self._songs, start=1):
|
||||
if song.get("id"):
|
||||
status = self._statuses[i-1] if i-1 < len(self._statuses) else "pending"
|
||||
conn.execute(
|
||||
"INSERT INTO playlist_songs "
|
||||
"(playlist_id, song_id, position, status, is_workshop, dance_override) "
|
||||
"VALUES (?,?,?,?,?,?)",
|
||||
(self._named_playlist_id, song["id"], i, status,
|
||||
1 if song.get("is_workshop") else 0,
|
||||
song.get("active_dance") or "")
|
||||
)
|
||||
|
||||
self._lbl_autosave.setText("✓ gemt")
|
||||
self.playlist_changed.emit()
|
||||
except Exception as e:
|
||||
self._lbl_autosave.setText(f"⚠ gemfejl")
|
||||
pass
|
||||
self._lbl_autosave.setText("⚠ gemfejl")
|
||||
|
||||
def _save_named_playlist_id(self, pl_id: int | None):
|
||||
"""Gem hvilken navngiven liste der er aktiv — til brug ved næste opstart."""
|
||||
@@ -374,6 +393,21 @@ class PlaylistPanel(QWidget):
|
||||
dance_names = [d["name"] for d in dances]
|
||||
override = row["dance_override"] or ""
|
||||
active_dance = override if override else (dance_names[0] if dance_names else "")
|
||||
|
||||
local_path = row["local_path"]
|
||||
file_missing = bool(row["file_missing"])
|
||||
|
||||
# Forsøg at finde sangen lokalt hvis den mangler
|
||||
if file_missing or not local_path:
|
||||
match = conn.execute("""
|
||||
SELECT local_path FROM songs
|
||||
WHERE title=? AND artist=? AND file_missing=0
|
||||
LIMIT 1
|
||||
""", (row["title"], row["artist"])).fetchone()
|
||||
if match:
|
||||
local_path = match["local_path"]
|
||||
file_missing = False
|
||||
|
||||
songs.append({
|
||||
"id": row["id"],
|
||||
"title": row["title"],
|
||||
@@ -381,9 +415,9 @@ class PlaylistPanel(QWidget):
|
||||
"album": row["album"],
|
||||
"bpm": row["bpm"],
|
||||
"duration_sec": row["duration_sec"],
|
||||
"local_path": row["local_path"],
|
||||
"local_path": local_path,
|
||||
"file_format": row["file_format"],
|
||||
"file_missing": bool(row["file_missing"]),
|
||||
"file_missing": file_missing,
|
||||
"dances": dance_names,
|
||||
"active_dance": active_dance,
|
||||
"is_workshop": bool(row["is_workshop"]),
|
||||
@@ -401,15 +435,14 @@ class PlaylistPanel(QWidget):
|
||||
self._btn_save_current.setToolTip(f"Gem ændringer til '{pl['name']}'")
|
||||
self._title_label.setText(f"DANSELISTE — {pl['name'].upper()}")
|
||||
self._lbl_autosave.setText("✓ gendannet")
|
||||
self._refresh()
|
||||
|
||||
# Find næste uafspillede og sæt den klar
|
||||
# Find næste uafspillede
|
||||
ni = self.next_playable_idx()
|
||||
if ni is not None:
|
||||
self._current_idx = ni
|
||||
self._refresh()
|
||||
self.next_song_ready.emit(self._songs[ni])
|
||||
self._statuses[ni] = "playing"
|
||||
|
||||
self._refresh()
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
@@ -479,10 +512,28 @@ class PlaylistPanel(QWidget):
|
||||
status = self._statuses[i-1] if i-1 < len(self._statuses) else "pending"
|
||||
conn.execute(
|
||||
"INSERT INTO playlist_songs "
|
||||
"(playlist_id, song_id, position, status) VALUES (?,?,?,?)",
|
||||
(self._named_playlist_id, song["id"], i, status)
|
||||
"(playlist_id, song_id, position, status, is_workshop, dance_override) "
|
||||
"VALUES (?,?,?,?,?,?)",
|
||||
(self._named_playlist_id, song["id"], i, status,
|
||||
1 if song.get("is_workshop") else 0,
|
||||
song.get("active_dance") or "")
|
||||
)
|
||||
self._lbl_autosave.setText("✓ gemt")
|
||||
|
||||
# Push til server hvis linket med edit-rettighed
|
||||
if getattr(self, "_can_edit_server", False):
|
||||
from local.local_db import get_db as _gdb
|
||||
with _gdb() as c:
|
||||
meta = c.execute(
|
||||
"SELECT api_project_id FROM playlists WHERE id=?",
|
||||
(self._named_playlist_id,)
|
||||
).fetchone()
|
||||
if meta and meta["api_project_id"]:
|
||||
self._push_linked_playlist(
|
||||
self._named_playlist_id, meta["api_project_id"]
|
||||
)
|
||||
self._lbl_autosave.setText("✓ gemt og synkroniseret")
|
||||
|
||||
except Exception as e:
|
||||
QMessageBox.warning(self, "Fejl", f"Kunne ikke gemme: {e}")
|
||||
|
||||
@@ -495,6 +546,22 @@ class PlaylistPanel(QWidget):
|
||||
def _load_playlist_by_id(self, pl_id: int, pl_name: str):
|
||||
try:
|
||||
from local.local_db import get_db
|
||||
|
||||
# Tjek om listen er linket til serveren — pull først
|
||||
with get_db() as conn:
|
||||
pl_meta = conn.execute(
|
||||
"SELECT api_project_id, is_linked, server_permission "
|
||||
"FROM playlists WHERE id=?", (pl_id,)
|
||||
).fetchone()
|
||||
|
||||
if pl_meta and pl_meta["is_linked"] and pl_meta["api_project_id"]:
|
||||
self._pull_linked_playlist(pl_id, pl_meta["api_project_id"])
|
||||
# Opdater gem-knap baseret på rettighed
|
||||
perm = pl_meta["server_permission"] or "view"
|
||||
self._named_playlist_id = pl_id
|
||||
self._can_edit_server = (perm == "edit")
|
||||
else:
|
||||
self._can_edit_server = False
|
||||
with get_db() as conn:
|
||||
songs_raw = conn.execute("""
|
||||
SELECT s.*, ps.position, ps.status,
|
||||
@@ -505,6 +572,7 @@ class PlaylistPanel(QWidget):
|
||||
""", (pl_id,)).fetchall()
|
||||
songs = []
|
||||
statuses = []
|
||||
repaired = 0
|
||||
for row in songs_raw:
|
||||
dances = conn.execute("""
|
||||
SELECT d.name FROM song_dances sd
|
||||
@@ -512,29 +580,64 @@ class PlaylistPanel(QWidget):
|
||||
WHERE sd.song_id=? ORDER BY sd.dance_order
|
||||
""", (row["id"],)).fetchall()
|
||||
dance_names = [d["name"] for d in dances]
|
||||
# dance_override bestemmer hvilken dans der vises
|
||||
override = row["dance_override"] or ""
|
||||
active_dance = override if override else (dance_names[0] if dance_names else "")
|
||||
|
||||
local_path = row["local_path"]
|
||||
file_missing = bool(row["file_missing"])
|
||||
|
||||
# Forsøg at finde sangen lokalt hvis den mangler
|
||||
if file_missing or not local_path:
|
||||
match = conn.execute("""
|
||||
SELECT local_path FROM songs
|
||||
WHERE title=? AND artist=? AND file_missing=0
|
||||
LIMIT 1
|
||||
""", (row["title"], row["artist"])).fetchone()
|
||||
if match:
|
||||
local_path = match["local_path"]
|
||||
file_missing = False
|
||||
repaired += 1
|
||||
|
||||
songs.append({
|
||||
"id": row["id"], "title": row["title"],
|
||||
"artist": row["artist"], "album": row["album"],
|
||||
"bpm": row["bpm"], "duration_sec": row["duration_sec"],
|
||||
"local_path": row["local_path"], "file_format": row["file_format"],
|
||||
"file_missing": bool(row["file_missing"]),
|
||||
"dances": dance_names,
|
||||
"id": row["id"],
|
||||
"title": row["title"],
|
||||
"artist": row["artist"],
|
||||
"album": row["album"],
|
||||
"bpm": row["bpm"],
|
||||
"duration_sec": row["duration_sec"],
|
||||
"local_path": local_path,
|
||||
"file_format": row["file_format"],
|
||||
"file_missing": file_missing,
|
||||
"dances": dance_names,
|
||||
"active_dance": active_dance,
|
||||
"is_workshop": bool(row["is_workshop"]),
|
||||
"is_workshop": bool(row["is_workshop"]),
|
||||
})
|
||||
statuses.append(row["status"] or "pending")
|
||||
|
||||
self._songs = songs
|
||||
self._statuses = statuses
|
||||
self._current_idx = -1
|
||||
self._song_ended = False
|
||||
self._named_playlist_id = pl_id
|
||||
self._title_label.setText(f"DANSELISTE — {pl_name.upper()}")
|
||||
self._lbl_autosave.setText("✓ gendannet")
|
||||
self._btn_save_current.setEnabled(True)
|
||||
self._btn_save_current.setToolTip(f"Gem ændringer til '{pl_name}'")
|
||||
|
||||
# Vis link-indikator i titlen
|
||||
is_linked = pl_meta and pl_meta["is_linked"]
|
||||
perm = pl_meta["server_permission"] if is_linked else "edit"
|
||||
link_icon = " 🔗" if is_linked else ""
|
||||
self._title_label.setText(f"DANSELISTE — {pl_name.upper()}{link_icon}")
|
||||
|
||||
status_txt = f"✓ indlæst — {repaired} sange fundet lokalt" if repaired else "✓ indlæst"
|
||||
if is_linked:
|
||||
status_txt += f" ({perm})"
|
||||
self._lbl_autosave.setText(status_txt)
|
||||
|
||||
# Gem-knap: deaktiver hvis view-only linket liste
|
||||
can_save = not is_linked or perm == "edit"
|
||||
self._btn_save_current.setEnabled(can_save)
|
||||
self._btn_save_current.setToolTip(
|
||||
f"Gem ændringer til '{pl_name}'" if can_save
|
||||
else "Du har kun læse-adgang til denne delte liste"
|
||||
)
|
||||
self._save_named_playlist_id(pl_id)
|
||||
self._refresh()
|
||||
self._trigger_autosave()
|
||||
@@ -628,6 +731,98 @@ class PlaylistPanel(QWidget):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _pull_linked_playlist(self, pl_id: int, server_id: str):
|
||||
"""Hent seneste version af en linket liste fra serveren."""
|
||||
try:
|
||||
from ui.settings_dialog import load_settings
|
||||
from local.local_db import get_db, DB_PATH
|
||||
s = load_settings()
|
||||
server_url = s.get("server_url", "").rstrip("/")
|
||||
# Hent token fra main_window
|
||||
mw = self.window()
|
||||
token = getattr(mw, "_api_token", None)
|
||||
if not token or not server_url:
|
||||
return
|
||||
|
||||
import urllib.request, json
|
||||
req = urllib.request.Request(
|
||||
f"{server_url}/sharing/playlists/{server_id}",
|
||||
headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||
pl_data = json.loads(resp.read())
|
||||
|
||||
# Opdater lokal liste med server-data
|
||||
import sqlite3
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("DELETE FROM playlist_songs WHERE playlist_id=?", (pl_id,))
|
||||
for song_data in pl_data.get("songs", []):
|
||||
local = conn.execute(
|
||||
"SELECT id FROM songs WHERE title=? AND artist=? AND file_missing=0",
|
||||
(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()
|
||||
except Exception as e:
|
||||
pass # Offline — brug lokalt cachet version
|
||||
|
||||
def _push_linked_playlist(self, pl_id: int, server_id: str):
|
||||
"""Push ændringer til server for en linket liste."""
|
||||
try:
|
||||
from ui.settings_dialog import load_settings
|
||||
from local.local_db import DB_PATH
|
||||
s = load_settings()
|
||||
server_url = s.get("server_url", "").rstrip("/")
|
||||
mw = self.window()
|
||||
token = getattr(mw, "_api_token", None)
|
||||
if not token or not server_url:
|
||||
return
|
||||
|
||||
import sqlite3, json, urllib.request
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
songs = []
|
||||
for ps in conn.execute(
|
||||
"SELECT 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():
|
||||
songs.append({
|
||||
"title": ps["title"],
|
||||
"artist": ps["artist"],
|
||||
"position": ps["position"],
|
||||
"status": ps["status"] or "pending",
|
||||
"is_workshop": bool(ps["is_workshop"]),
|
||||
"dance_override": ps["dance_override"] or "",
|
||||
})
|
||||
conn.close()
|
||||
|
||||
data = json.dumps({"songs": songs}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{server_url}/sharing/playlists/{server_id}/songs",
|
||||
data=data,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {token}",
|
||||
},
|
||||
method="PUT"
|
||||
)
|
||||
urllib.request.urlopen(req, timeout=8)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
def _on_pause_changed(self, seconds: int):
|
||||
self._pause_seconds = seconds
|
||||
|
||||
@@ -642,7 +837,7 @@ class PlaylistPanel(QWidget):
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
self._statuses = ["pending"] * len(self._songs)
|
||||
self._current_idx = -1
|
||||
self._song_ended = True
|
||||
self._song_ended = False
|
||||
try:
|
||||
from local.local_db import clear_event_state
|
||||
clear_event_state()
|
||||
@@ -650,6 +845,12 @@ class PlaylistPanel(QWidget):
|
||||
pass
|
||||
self._refresh()
|
||||
self._scroll_to(0)
|
||||
# Sæt første sang klar
|
||||
ni = self.next_playable_idx()
|
||||
if ni is not None:
|
||||
self._current_idx = ni
|
||||
self._refresh()
|
||||
self.next_song_ready.emit(self._songs[ni])
|
||||
self.event_started.emit()
|
||||
|
||||
# ── Højreklik ─────────────────────────────────────────────────────────────
|
||||
@@ -718,10 +919,26 @@ class PlaylistPanel(QWidget):
|
||||
self._list.clear()
|
||||
played = sum(1 for s in self._statuses if s == "played")
|
||||
self._lbl_progress.setText(f"{played} / {len(self._songs)} afspillet")
|
||||
|
||||
# Find næste uafspillede til blå markering — aldrig samme som current
|
||||
next_idx = None
|
||||
if self._current_idx >= 0 and not self._song_ended:
|
||||
# Sang spiller — vis næste som blå
|
||||
next_idx = self.next_playable_idx()
|
||||
elif self._current_idx == -1 or self._song_ended:
|
||||
# Ingen sang spiller — vis første som blå
|
||||
next_idx = self.next_playable_idx()
|
||||
|
||||
for i, song in enumerate(self._songs):
|
||||
is_current = (i == self._current_idx and not self._song_ended)
|
||||
status = "playing" if is_current else self._statuses[i]
|
||||
icon = self.STATUS_ICON.get(status, " ")
|
||||
is_next = (i == next_idx and not is_current)
|
||||
if is_current:
|
||||
status = "playing"
|
||||
elif is_next:
|
||||
status = "next"
|
||||
else:
|
||||
status = self._statuses[i]
|
||||
icon = self.STATUS_ICON.get(status, " ")
|
||||
|
||||
# Vis active_dance (override eller første dans) eller alle danse
|
||||
active = song.get("active_dance", "")
|
||||
@@ -737,6 +954,9 @@ class PlaylistPanel(QWidget):
|
||||
if status == "playing":
|
||||
item.setForeground(QColor(self.STATUS_COLOR["playing"]))
|
||||
f = item.font(); f.setBold(True); item.setFont(f)
|
||||
elif status == "next":
|
||||
item.setForeground(QColor(self.STATUS_COLOR["next"]))
|
||||
f = item.font(); f.setBold(True); item.setFont(f)
|
||||
elif status == "played":
|
||||
item.setForeground(QColor("#2ecc71"))
|
||||
elif status == "skipped":
|
||||
|
||||
@@ -78,11 +78,30 @@ class SettingsDialog(QDialog):
|
||||
layout.setSpacing(12)
|
||||
|
||||
tabs = QTabWidget()
|
||||
tabs.addTab(self._build_appearance_tab(), "🎨 Udseende")
|
||||
tabs.addTab(self._build_playback_tab(), "▶ Afspilning")
|
||||
tabs.addTab(self._build_mail_tab(), "✉ Mail")
|
||||
tabs.addTab(self._build_online_tab(), "🌐 Online")
|
||||
tabs.addTab(self._build_language_tab(), "🌍 Sprog")
|
||||
tabs.setStyleSheet("""
|
||||
QTabBar::tab {
|
||||
padding: 6px 14px;
|
||||
font-size: 13px;
|
||||
color: #9aa0b0;
|
||||
background: #1e2128;
|
||||
border: none;
|
||||
min-width: 80px;
|
||||
}
|
||||
QTabBar::tab:selected {
|
||||
color: #e0e4f0;
|
||||
background: #2a2d36;
|
||||
border-bottom: 2px solid #e8a020;
|
||||
}
|
||||
QTabBar::tab:hover {
|
||||
color: #e0e4f0;
|
||||
background: #252830;
|
||||
}
|
||||
""")
|
||||
tabs.addTab(self._build_appearance_tab(), "Udseende")
|
||||
tabs.addTab(self._build_playback_tab(), "Afspilning")
|
||||
tabs.addTab(self._build_mail_tab(), "Mail")
|
||||
tabs.addTab(self._build_online_tab(), "Online")
|
||||
tabs.addTab(self._build_language_tab(), "Sprog")
|
||||
layout.addWidget(tabs)
|
||||
|
||||
# Knapper
|
||||
|
||||
192
linedance-app/ui/share_dialog.py
Normal file
192
linedance-app/ui/share_dialog.py
Normal file
@@ -0,0 +1,192 @@
|
||||
"""
|
||||
share_dialog.py — Del en playliste med andre brugere eller sæt den public.
|
||||
"""
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
|
||||
QPushButton, QComboBox, QFrame, QListWidget, QListWidgetItem,
|
||||
QMessageBox,
|
||||
)
|
||||
from PyQt6.QtCore import Qt
|
||||
|
||||
|
||||
class ShareDialog(QDialog):
|
||||
def __init__(self, playlist_id: str, playlist_name: str,
|
||||
server_url: str, token: str, parent=None):
|
||||
super().__init__(parent)
|
||||
self._playlist_id = playlist_id
|
||||
self._playlist_name = playlist_name
|
||||
self._server_url = server_url.rstrip("/")
|
||||
self._token = token
|
||||
|
||||
self.setWindowTitle(f"Del — {playlist_name}")
|
||||
self.setMinimumWidth(480)
|
||||
self._build_ui()
|
||||
self._load_shares()
|
||||
self._load_visibility()
|
||||
|
||||
def _headers(self):
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self._token}",
|
||||
}
|
||||
|
||||
def _build_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(12, 12, 12, 12)
|
||||
layout.setSpacing(10)
|
||||
|
||||
# Synlighed
|
||||
vis_frame = QFrame()
|
||||
vis_frame.setObjectName("track_display")
|
||||
vis_layout = QHBoxLayout(vis_frame)
|
||||
vis_layout.setContentsMargins(10, 8, 10, 8)
|
||||
vis_layout.addWidget(QLabel("Synlighed:"))
|
||||
self._vis_combo = QComboBox()
|
||||
self._vis_combo.addItem("🔒 Privat (kun mig)", "private")
|
||||
self._vis_combo.addItem("👥 Delt (inviterede)", "shared")
|
||||
self._vis_combo.addItem("🌐 Public (alle kan se)", "public")
|
||||
vis_layout.addWidget(self._vis_combo, stretch=1)
|
||||
btn_vis = QPushButton("Gem")
|
||||
btn_vis.setFixedHeight(28)
|
||||
btn_vis.clicked.connect(self._set_visibility)
|
||||
vis_layout.addWidget(btn_vis)
|
||||
layout.addWidget(vis_frame)
|
||||
|
||||
# Invitér bruger
|
||||
inv_frame = QFrame()
|
||||
inv_frame.setObjectName("track_display")
|
||||
inv_layout = QVBoxLayout(inv_frame)
|
||||
inv_layout.setContentsMargins(10, 8, 10, 8)
|
||||
inv_layout.setSpacing(6)
|
||||
inv_layout.addWidget(QLabel("Invitér via e-mail:"))
|
||||
|
||||
row = QHBoxLayout()
|
||||
self._email_input = QLineEdit()
|
||||
self._email_input.setPlaceholderText("bruger@eksempel.dk")
|
||||
row.addWidget(self._email_input)
|
||||
self._perm_combo = QComboBox()
|
||||
self._perm_combo.addItem("Se", "view")
|
||||
self._perm_combo.addItem("Kopiere", "copy")
|
||||
self._perm_combo.addItem("Redigere","edit")
|
||||
self._perm_combo.setFixedWidth(90)
|
||||
row.addWidget(self._perm_combo)
|
||||
btn_inv = QPushButton("Invitér")
|
||||
btn_inv.setFixedHeight(28)
|
||||
btn_inv.clicked.connect(self._invite)
|
||||
row.addWidget(btn_inv)
|
||||
inv_layout.addLayout(row)
|
||||
layout.addWidget(inv_frame)
|
||||
|
||||
# Liste over delinger
|
||||
lbl = QLabel("Delt med:")
|
||||
lbl.setObjectName("track_meta")
|
||||
layout.addWidget(lbl)
|
||||
|
||||
self._shares_list = QListWidget()
|
||||
self._shares_list.setMaximumHeight(150)
|
||||
layout.addWidget(self._shares_list)
|
||||
|
||||
btn_remove = QPushButton("✕ Fjern valgt deling")
|
||||
btn_remove.clicked.connect(self._remove_share)
|
||||
layout.addWidget(btn_remove)
|
||||
|
||||
self._status = QLabel("")
|
||||
self._status.setObjectName("result_count")
|
||||
self._status.setWordWrap(True)
|
||||
layout.addWidget(self._status)
|
||||
|
||||
btn_close = QPushButton("Luk")
|
||||
btn_close.clicked.connect(self.accept)
|
||||
layout.addWidget(btn_close)
|
||||
|
||||
def _load_visibility(self):
|
||||
try:
|
||||
import urllib.request, json
|
||||
req = urllib.request.Request(
|
||||
f"{self._server_url}/sharing/playlists/{self._playlist_id}",
|
||||
headers=self._headers()
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||
data = json.loads(resp.read())
|
||||
vis = data.get("visibility", "private")
|
||||
for i in range(self._vis_combo.count()):
|
||||
if self._vis_combo.itemData(i) == vis:
|
||||
self._vis_combo.setCurrentIndex(i)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _load_shares(self):
|
||||
try:
|
||||
import urllib.request, json
|
||||
req = urllib.request.Request(
|
||||
f"{self._server_url}/sharing/playlists/{self._playlist_id}/shares",
|
||||
headers=self._headers()
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||
shares = json.loads(resp.read())
|
||||
self._shares_list.clear()
|
||||
for s in shares:
|
||||
perm = {"view": "Se", "copy": "Kopiere", "edit": "Redigere"}.get(
|
||||
s["permission"], s["permission"]
|
||||
)
|
||||
accepted = "✓" if s["accepted"] else "⏳"
|
||||
item = QListWidgetItem(f"{accepted} {s['email']} — {perm}")
|
||||
item.setData(Qt.ItemDataRole.UserRole, s["id"])
|
||||
self._shares_list.addItem(item)
|
||||
except Exception as e:
|
||||
self._status.setText(f"Kunne ikke hente delinger: {e}")
|
||||
|
||||
def _set_visibility(self):
|
||||
vis = self._vis_combo.currentData()
|
||||
try:
|
||||
import urllib.request, json
|
||||
req = urllib.request.Request(
|
||||
f"{self._server_url}/sharing/playlists/{self._playlist_id}/visibility?visibility={vis}",
|
||||
data=b"",
|
||||
headers=self._headers(),
|
||||
method="PATCH"
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||
json.loads(resp.read())
|
||||
self._status.setText(f"✓ Synlighed sat til {self._vis_combo.currentText()}")
|
||||
except Exception as e:
|
||||
self._status.setText(f"⚠ Fejl: {e}")
|
||||
|
||||
def _invite(self):
|
||||
email = self._email_input.text().strip()
|
||||
perm = self._perm_combo.currentData()
|
||||
if not email or "@" not in email:
|
||||
self._status.setText("⚠ Ugyldig e-mailadresse")
|
||||
return
|
||||
try:
|
||||
import urllib.request, json
|
||||
data = json.dumps({"email": email, "permission": perm}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{self._server_url}/sharing/playlists/{self._playlist_id}/share",
|
||||
data=data, headers=self._headers(), method="POST"
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||
json.loads(resp.read())
|
||||
self._email_input.clear()
|
||||
self._status.setText(f"✓ Invitation sendt til {email}")
|
||||
self._load_shares()
|
||||
except Exception as e:
|
||||
self._status.setText(f"⚠ Fejl: {e}")
|
||||
|
||||
def _remove_share(self):
|
||||
item = self._shares_list.currentItem()
|
||||
if not item:
|
||||
return
|
||||
share_id = item.data(Qt.ItemDataRole.UserRole)
|
||||
try:
|
||||
import urllib.request
|
||||
req = urllib.request.Request(
|
||||
f"{self._server_url}/sharing/playlists/{self._playlist_id}/share/{share_id}",
|
||||
headers=self._headers(), method="DELETE"
|
||||
)
|
||||
urllib.request.urlopen(req, timeout=8)
|
||||
self._status.setText("✓ Deling fjernet")
|
||||
self._load_shares()
|
||||
except Exception as e:
|
||||
self._status.setText(f"⚠ Fejl: {e}")
|
||||
@@ -79,6 +79,25 @@ QSlider::handle:horizontal {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* Volume slider — stor og tydelig */
|
||||
QSlider#vol_slider::groove:horizontal {
|
||||
height: 6px;
|
||||
background: #2c3038;
|
||||
border-radius: 3px;
|
||||
}
|
||||
QSlider#vol_slider::sub-page:horizontal {
|
||||
background: #e8a020;
|
||||
border-radius: 3px;
|
||||
}
|
||||
QSlider#vol_slider::handle:horizontal {
|
||||
background: #e8a020;
|
||||
border: 3px solid #f0c060;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin: -9px 0;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* Lister */
|
||||
QListWidget {
|
||||
background-color: #1a1c1f;
|
||||
|
||||
Reference in New Issue
Block a user