Compare commits

..

18 Commits

Author SHA1 Message Date
2d7ad55a0f Ny installer 2026-04-28 08:37:27 +02:00
324c94fde2 Diverse rettelser 2026-04-25 21:28:31 +02:00
8d4c4a81c1 Opdateret downloadfil 2026-04-24 10:29:41 +02:00
09412073cd dll filer 2026-04-24 09:31:03 +02:00
25c2dd9e78 Søging på hjemmeside 2026-04-23 08:51:37 +02:00
115cc92d6a Ny installer igen 2026-04-22 16:53:51 +02:00
c3453d8d55 Ny installer 2026-04-22 12:59:13 +02:00
d28aafb2c6 Ny installer 2026-04-22 10:13:20 +02:00
b695a4858b Sync alternativer 2026-04-22 10:00:12 +02:00
37b49c1fed Rettet 2026-04-21 19:27:11 +02:00
545cdc6866 Bedre tag sync 2026-04-21 19:18:19 +02:00
ec3989e6a4 Merge branch 'main' of ssh://git.ckvist.lan:2222/carsten/LinedanceAfspiller 2026-04-21 16:47:40 +02:00
6ed349277c Bedre sync 2026-04-21 16:47:33 +02:00
2deb0260f0 ny install-fil 2026-04-20 10:53:32 +02:00
8a4c879213 Synk virker 2026-04-20 01:41:24 +02:00
f92af40dd7 db struktur 2026-04-20 00:01:41 +02:00
efc30cdbb2 NY db struktur 2026-04-19 23:45:59 +02:00
a9aa451d63 Sync OK 2026-04-19 22:04:47 +02:00
25 changed files with 3008 additions and 1864 deletions

View File

@@ -28,29 +28,25 @@ class User(Base):
projects: Mapped[list["Project"]] = relationship("Project", back_populates="owner")
memberships: Mapped[list["ProjectMember"]] = relationship("ProjectMember", back_populates="user")
songs: Mapped[list["Song"]] = relationship("Song", back_populates="owner")
alt_ratings: Mapped[list["DanceAltRating"]] = relationship("DanceAltRating", back_populates="user")
playlist_shares: Mapped[list["PlaylistShare"]] = relationship("PlaylistShare", foreign_keys="PlaylistShare.shared_with_id", back_populates="shared_with")
# ── Song ──────────────────────────────────────────────────────────────────────
# ── Song (global — ikke knyttet til en bruger) ────────────────────────────────
class Song(Base):
__tablename__ = "songs"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
owner_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False)
title: Mapped[str] = mapped_column(String(255), nullable=False)
artist: Mapped[str] = mapped_column(String(255), default="")
album: Mapped[str] = mapped_column(String(255), default="")
bpm: Mapped[int] = mapped_column(Integer, default=0)
duration_sec: Mapped[int] = mapped_column(Integer, default=0)
file_format: Mapped[str] = mapped_column(String(8), default="")
mbid: Mapped[str|None] = mapped_column(String(36), nullable=True)
mbid: Mapped[str|None] = mapped_column(String(36), nullable=True, unique=True)
acoustid: Mapped[str|None] = mapped_column(String(64), nullable=True)
synced_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
owner: Mapped["User"] = relationship("User", back_populates="songs")
project_songs: Mapped[list["ProjectSong"]] = relationship("ProjectSong", back_populates="song")
song_dances: Mapped[list["SongDance"]] = relationship("SongDance", back_populates="song", cascade="all, delete-orphan")
song_alt_dances: Mapped[list["SongAltDance"]] = relationship("SongAltDance", back_populates="song", cascade="all, delete-orphan")
@@ -68,7 +64,6 @@ class DanceLevel(Base):
class Dance(Base):
"""Dans-entitet: navn + niveau er unik kombination."""
__tablename__ = "dances"
__table_args__ = (UniqueConstraint("name", "level_id", name="uq_dance_name_level"),)
@@ -80,7 +75,6 @@ class Dance(Base):
stepsheet_url: Mapped[str] = mapped_column(String(512), default="")
notes: Mapped[str] = mapped_column(Text, default="")
use_count: Mapped[int] = mapped_column(Integer, default=1)
source: Mapped[str] = mapped_column(String(16), default="local")
synced_at: Mapped[datetime|None] = mapped_column(DateTime, nullable=True)
level: Mapped["DanceLevel|None"] = relationship("DanceLevel")
@@ -95,7 +89,7 @@ class Project(Base):
owner_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False)
name: Mapped[str] = mapped_column(String(128), nullable=False)
description: Mapped[str] = mapped_column(Text, default="")
visibility: Mapped[str] = mapped_column(String(16), default="private") # private|shared|public
visibility: Mapped[str] = mapped_column(String(16), default="private")
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, onupdate=now_utc)
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
@@ -111,8 +105,8 @@ class ProjectMember(Base):
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
project_id: Mapped[str] = mapped_column(String(36), ForeignKey("projects.id"), nullable=False)
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False)
role: Mapped[str] = mapped_column(String(16), default="viewer") # owner|editor|viewer
status: Mapped[str] = mapped_column(String(16), default="pending") # pending|accepted
role: Mapped[str] = mapped_column(String(16), default="viewer")
status: Mapped[str] = mapped_column(String(16), default="pending")
invited_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
project: Mapped["Project"] = relationship("Project", back_populates="members")
@@ -135,15 +129,14 @@ class ProjectSong(Base):
class PlaylistShare(Base):
"""Deling af en playlist med specifikke brugere."""
__tablename__ = "playlist_shares"
__table_args__ = (UniqueConstraint("project_id", "shared_with_id", name="uq_share"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
project_id: Mapped[str] = mapped_column(String(36), ForeignKey("projects.id"), nullable=False)
shared_with_id: Mapped[str|None] = mapped_column(String(36), ForeignKey("users.id"), nullable=True)
invited_email: Mapped[str] = mapped_column(String(255), default="") # til ikke-registrerede
permission: Mapped[str] = mapped_column(String(16), default="view") # view|copy|edit
invited_email: Mapped[str] = mapped_column(String(255), default="")
permission: Mapped[str] = mapped_column(String(16), default="view")
accepted_at: Mapped[datetime|None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
@@ -154,7 +147,6 @@ class PlaylistShare(Base):
# ── Sang-dans tags ────────────────────────────────────────────────────────────
class SongDance(Base):
"""Dans-tags på en sang (brugerens egne tags)."""
__tablename__ = "song_dances"
__table_args__ = (UniqueConstraint("song_id", "dance_id", name="uq_song_dance"),)
@@ -168,7 +160,6 @@ class SongDance(Base):
class SongAltDance(Base):
"""Alternativ-dans tags på en sang."""
__tablename__ = "song_alt_dances"
__table_args__ = (UniqueConstraint("song_id", "dance_id", name="uq_song_alt_dance"),)
@@ -184,7 +175,6 @@ class SongAltDance(Base):
# ── Community dans-tags ───────────────────────────────────────────────────────
class CommunityDance(Base):
"""Fællesskabets dans-tags på sange."""
__tablename__ = "community_dances"
__table_args__ = (UniqueConstraint("song_mbid", "song_title", "song_artist", "dance_id", name="uq_comm_dance"),)
@@ -200,7 +190,6 @@ class CommunityDance(Base):
class CommunityDanceAlt(Base):
"""Fællesskabets alternativ-danse til en sang med ratings."""
__tablename__ = "community_dance_alts"
__table_args__ = (UniqueConstraint("song_mbid", "song_title", "song_artist", "alt_dance_id", name="uq_comm_alt"),)
@@ -220,14 +209,13 @@ class CommunityDanceAlt(Base):
class DanceAltRating(Base):
"""1-5 stjerne rating af en alternativ-dans."""
__tablename__ = "dance_alt_ratings"
__table_args__ = (UniqueConstraint("alternative_id", "user_id", name="uq_rating"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
alternative_id: Mapped[str] = mapped_column(String(36), ForeignKey("community_dance_alts.id"), nullable=False)
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False)
score: Mapped[int] = mapped_column(Integer, nullable=False) # 1-5
score: Mapped[int] = mapped_column(Integer, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
alternative: Mapped["CommunityDanceAlt"] = relationship("CommunityDanceAlt", back_populates="ratings")

View File

@@ -0,0 +1,121 @@
"""
alt_dance_ratings.py — Community alternativ-dans ratings endpoint.
"""
import uuid as _uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from pydantic import BaseModel
from app.core.database import get_db
from app.core.security import get_current_user
from app.models import User, Song, Dance, CommunityDanceAlt, DanceAltRating
router = APIRouter(prefix="/alt-ratings", tags=["alt-ratings"])
class SubmitAltRequest(BaseModel):
song_id: str # server song UUID
dance_name: str
rating: int # 1-5
@router.post("/submit")
def submit_alt_rating(
req: SubmitAltRequest,
db: Session = Depends(get_db),
me: User = Depends(get_current_user),
):
"""Indsend eller opdater rating for en alternativ-dans på en sang."""
if not 1 <= req.rating <= 5:
raise HTTPException(400, "Rating skal være 1-5")
song = db.query(Song).filter_by(id=req.song_id).first()
if not song:
raise HTTPException(404, "Sang ikke fundet")
dance = db.query(Dance).filter(
Dance.name.ilike(req.dance_name)
).first()
if not dance:
raise HTTPException(404, "Dans ikke fundet")
# Find eller opret community alt-dans
alt = db.query(CommunityDanceAlt).filter_by(
song_mbid=song.mbid or None,
song_title=song.title,
song_artist=song.artist,
alt_dance_id=dance.id,
).first()
if not alt:
alt = CommunityDanceAlt(
id=str(_uuid.uuid4()),
song_mbid=song.mbid or None,
song_title=song.title,
song_artist=song.artist,
alt_dance_id=dance.id,
submitted_by=me.id,
avg_rating=float(req.rating),
rating_count=1,
)
db.add(alt)
db.flush()
# Opdater eller indsæt brugerens rating
existing_rating = db.query(DanceAltRating).filter_by(
alternative_id=alt.id,
user_id=me.id,
).first()
if existing_rating:
old_score = existing_rating.score
existing_rating.score = req.rating
# Opdater gennemsnit
total = alt.avg_rating * alt.rating_count - old_score + req.rating
alt.avg_rating = total / alt.rating_count
else:
db.add(DanceAltRating(
id=str(_uuid.uuid4()),
alternative_id=alt.id,
user_id=me.id,
score=req.rating,
))
# Opdater gennemsnit
total = alt.avg_rating * alt.rating_count + req.rating
alt.rating_count += 1
alt.avg_rating = total / alt.rating_count
db.commit()
return {"status": "ok", "avg_rating": alt.avg_rating, "rating_count": alt.rating_count}
@router.get("/for-song/{song_id}")
def get_alt_ratings_for_song(
song_id: str,
db: Session = Depends(get_db),
me: User = Depends(get_current_user),
):
"""Hent community alternativ-danse med ratings for en sang."""
song = db.query(Song).filter_by(id=song_id).first()
if not song:
raise HTTPException(404, "Sang ikke fundet")
alts = db.query(CommunityDanceAlt).filter(
(CommunityDanceAlt.song_mbid == song.mbid) if song.mbid else
((CommunityDanceAlt.song_title == song.title) &
(CommunityDanceAlt.song_artist == song.artist))
).all()
result = []
for alt in alts:
my_rating = db.query(DanceAltRating).filter_by(
alternative_id=alt.id,
user_id=me.id,
).first()
result.append({
"dance_name": alt.alt_dance.name,
"avg_rating": round(alt.avg_rating, 1),
"rating_count": alt.rating_count,
"my_rating": my_rating.score if my_rating else None,
})
return result

View File

@@ -4,8 +4,10 @@ sync.py — Push/pull synkronisering mellem lokal app og server.
POST /sync/push — send lokal data op til server
GET /sync/pull — hent server-data ned til app
"""
import uuid
import logging
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from pydantic import BaseModel
from typing import Optional
@@ -14,10 +16,11 @@ from app.core.database import get_db
from app.core.security import get_current_user
from app.models import (
User, Song, Dance, DanceLevel, Project, ProjectSong,
PlaylistShare, CommunityDance, CommunityDanceAlt,
PlaylistShare, CommunityDance, SongDance, SongAltDance,
)
router = APIRouter(prefix="/sync", tags=["sync"])
logger = logging.getLogger(__name__)
# ── Schemas ───────────────────────────────────────────────────────────────────
@@ -29,7 +32,6 @@ class SongData(BaseModel):
album: str = ""
bpm: int = 0
duration_sec: int = 0
file_format: str = ""
mbid: str = ""
acoustid: str = ""
@@ -52,6 +54,7 @@ class SongAltDanceData(BaseModel):
dance_name: str
level_name: str = ""
note: str = ""
user_rating: Optional[int] = None
class PlaylistSongData(BaseModel):
song_local_id: str
@@ -76,7 +79,61 @@ class PushPayload(BaseModel):
song_dances: list[SongDanceData] = []
song_alts: list[SongAltDanceData] = []
playlists: list[PlaylistData] = []
deleted_playlists: list[str] = [] # server-IDs (api_project_id) på slettede playlister
deleted_playlists: list[str] = [] # server-IDs (Project.id)
songs_with_dances_synced: list[str] = [] # sang-IDs der er fuldt synkroniseret
# ── Hjælpefunktion: find eller opret sang globalt ─────────────────────────────
def _find_or_create_song(db: Session, title: str, artist: str = "",
mbid: str = "", acoustid: str = "",
album: str = "", bpm: int = 0,
duration_sec: int = 0) -> Song:
"""
Match-hierarki:
1. MBID — sikreste
2. AcoustID
3. Titel + artist
4. Opret ny
"""
if mbid:
song = db.query(Song).filter_by(mbid=mbid).first()
if song:
return song
if acoustid:
song = db.query(Song).filter_by(acoustid=acoustid).first()
if song:
# Tilføj mbid hvis den mangler
if mbid and not song.mbid:
song.mbid = mbid
return song
if title:
song = db.query(Song).filter(
Song.title == title,
Song.artist == artist,
).first()
if song:
# Opdater med bedre data hvis tilgængeligt
if mbid and not song.mbid:
song.mbid = mbid
if acoustid and not song.acoustid:
song.acoustid = acoustid
if bpm and not song.bpm:
song.bpm = bpm
return song
# Opret ny global sang
song = Song(
title=title, artist=artist, album=album,
bpm=bpm, duration_sec=duration_sec,
mbid=mbid or None,
acoustid=acoustid or None,
)
db.add(song)
db.flush()
return song
# ── Push ──────────────────────────────────────────────────────────────────────
@@ -88,82 +145,60 @@ def push(
me: User = Depends(get_current_user),
):
"""Upload lokal data til server. Returnerer server-IDs."""
import sqlalchemy as _sa
song_id_map = {} # local_id → server Song.id
dance_id_map = {} # "name|level" → server Dance.id
level_map = {} # level_name → DanceLevel.id
dance_id_map = {} # "name|level_id" → Dance.id
level_map = {} # level_name.lower() → DanceLevel.id
# ── Dans-niveauer ─────────────────────────────────────────────────────────
for lvl in db.query(DanceLevel).all():
level_map[lvl.name.lower()] = lvl.id
# ── Sange ─────────────────────────────────────────────────────────────────
# ── Sange (globale) ───────────────────────────────────────────────────────
for s in payload.songs:
if not s.title:
continue
# Match 1: MBID — sikrest
existing = None
if s.mbid:
existing = db.query(Song).filter_by(mbid=s.mbid).first()
# Match 2: titel+artist globalt
if not existing:
existing = db.query(Song).filter(
Song.title == s.title,
Song.artist == s.artist,
).first()
if existing:
song_id_map[s.local_id] = existing.id
# Opdater BPM og MBID hvis de mangler
if s.bpm and not existing.bpm:
existing.bpm = s.bpm
if s.mbid and not existing.mbid:
existing.mbid = s.mbid
if s.acoustid and not existing.acoustid:
existing.acoustid = s.acoustid
else:
song = Song(
owner_id=me.id,
title=s.title, artist=s.artist, album=s.album,
bpm=s.bpm, duration_sec=s.duration_sec,
file_format=s.file_format,
mbid=s.mbid or None,
acoustid=s.acoustid or None,
song = _find_or_create_song(
db, s.title, s.artist,
mbid=s.mbid, acoustid=s.acoustid,
album=s.album, bpm=s.bpm, duration_sec=s.duration_sec,
)
db.add(song)
db.flush()
song_id_map[s.local_id] = song.id
# ── Danse ─────────────────────────────────────────────────────────────────
# ── Danse ─────────────────────────────────────────────────────────────────
for d in payload.dances:
level_id = level_map.get(d.level_name.lower()) if d.level_name else None
key = f"{d.name.lower()}|{level_id}"
existing = db.query(Dance).filter_by(name=d.name, level_id=level_id).first()
if existing:
# Opdater info hvis den har ny data
if d.choreographer: existing.choreographer = d.choreographer
if d.video_url: existing.video_url = d.video_url
if d.stepsheet_url: existing.stepsheet_url = d.stepsheet_url
if d.notes: existing.notes = d.notes
dance_id_map[key] = existing.id
else:
dance = Dance(
name=d.name, level_id=level_id,
choreographer=d.choreographer, video_url=d.video_url,
stepsheet_url=d.stepsheet_url, notes=d.notes,
choreographer=d.choreographer,
video_url=d.video_url,
stepsheet_url=d.stepsheet_url,
notes=d.notes,
)
db.add(dance)
db.flush()
dance_id_map[key] = dance.id
# ── Sang-dans tags (brugerens egne) ───────────────────────────────────────
from app.models import SongDance, SongAltDance
# ── Sang-dans tags ────────────────────────────────────────────────────────
from app.models import SongDance, SongAltDance
import sqlalchemy as _sa
# ── Sang-dans tags — synkroniser fuldt per sang ──────────────────────────
# Slet eksisterende tags for sange der er med i push, genindsæt fra klient
synced_song_ids = set()
for sd in payload.song_dances:
song_id = song_id_map.get(sd.song_local_id)
if not song_id:
continue
if song_id not in synced_song_ids:
db.execute(_sa.text("DELETE FROM song_dances WHERE song_id=:sid"),
{"sid": song_id})
synced_song_ids.add(song_id)
level_id = level_map.get(sd.level_name.lower()) if sd.level_name else None
key = f"{sd.dance_name.lower()}|{level_id}"
dance_id = dance_id_map.get(key)
@@ -172,12 +207,15 @@ def push(
db.execute(_sa.text(
"INSERT IGNORE INTO song_dances (id, song_id, dance_id, dance_order) "
"VALUES (:id, :song_id, :dance_id, :dance_order)"
), {
"id": str(__import__("uuid").uuid4()),
"song_id": song_id,
"dance_id": dance_id,
"dance_order": sd.dance_order,
})
), {"id": str(uuid.uuid4()), "song_id": song_id,
"dance_id": dance_id, "dance_order": sd.dance_order})
# Sange der er fuldt synkroniseret men har ingen dans-tags — slet på server
for local_id in payload.songs_with_dances_synced:
song_id = song_id_map.get(local_id)
if song_id and song_id not in synced_song_ids:
db.execute(_sa.text("DELETE FROM song_dances WHERE song_id=:sid"),
{"sid": song_id})
for sa in payload.song_alts:
song_id = song_id_map.get(sa.song_local_id)
@@ -188,38 +226,71 @@ def push(
dance_id = dance_id_map.get(key)
if not dance_id:
continue
# Opdater community rating hvis bruger har givet en vurdering
if sa.user_rating and 1 <= sa.user_rating <= 5:
from app.models import CommunityDanceAlt, DanceAltRating
song_obj = db.query(Song).filter_by(id=song_id).first()
if song_obj:
alt = db.query(CommunityDanceAlt).filter_by(
song_title=song_obj.title,
song_artist=song_obj.artist,
alt_dance_id=dance_id,
).first()
if not alt:
alt = CommunityDanceAlt(
id=str(uuid.uuid4()),
song_mbid=song_obj.mbid or None,
song_title=song_obj.title,
song_artist=song_obj.artist,
alt_dance_id=dance_id,
submitted_by=me.id,
avg_rating=float(sa.user_rating),
rating_count=1,
)
db.add(alt)
db.flush()
existing_r = db.query(DanceAltRating).filter_by(
alternative_id=alt.id, user_id=me.id
).first()
if existing_r:
old_score = existing_r.score
existing_r.score = sa.user_rating
total = alt.avg_rating * alt.rating_count - old_score + sa.user_rating
alt.avg_rating = total / alt.rating_count
else:
db.add(DanceAltRating(
id=str(uuid.uuid4()),
alternative_id=alt.id,
user_id=me.id,
score=sa.user_rating,
))
total = alt.avg_rating * alt.rating_count + sa.user_rating
alt.rating_count += 1
alt.avg_rating = total / alt.rating_count
db.execute(_sa.text(
"INSERT IGNORE INTO song_alt_dances (id, song_id, dance_id, note) "
"VALUES (:id, :song_id, :dance_id, :note)"
), {
"id": str(__import__("uuid").uuid4()),
"song_id": song_id,
"dance_id": dance_id,
"note": sa.note or "",
})
), {"id": str(uuid.uuid4()), "song_id": song_id,
"dance_id": dance_id, "note": sa.note or ""})
# ── Playlister ────────────────────────────────────────────────────────────
# VIGTIGT: Match altid på local_id (= api_project_id på klienten),
# aldrig på navn — navn er ikke unikt og giver duplikater.
playlist_id_map = {}
for pl in payload.playlists:
# Prøv først at finde via server-ID (local_id er klientens lokale db-id
# som tidligere er returneret som server-ID via playlist_id_map)
# Find eksisterende via server-ID (local_id er api_project_id på klienten)
existing = None
if pl.local_id:
existing = db.query(Project).filter_by(
id=pl.local_id, owner_id=me.id
).first()
# Fallback: navn — kun hvis vi aldrig har set denne liste før
if not existing:
existing = db.query(Project).filter_by(
owner_id=me.id, name=pl.name
).first()
if existing:
existing.name = pl.name
existing.description = pl.description
existing.visibility = pl.visibility
# Opdater kun sange hvis push faktisk har sange med
if pl.songs:
db.query(ProjectSong).filter_by(project_id=existing.id).delete()
project = existing
@@ -233,27 +304,21 @@ def push(
playlist_id_map[pl.local_id] = project.id
for ps in pl.songs:
# Prøv først via song_id_map (lokal ID)
# Find sang via song_id_map eller titel+artist
song_id = song_id_map.get(ps.song_local_id)
# Fallback: match på titel+artist
if not song_id and ps.song_title:
existing_song = db.query(Song).filter_by(
title=ps.song_title, artist=ps.song_artist
).first()
if existing_song:
song_id = existing_song.id
song = _find_or_create_song(db, ps.song_title, ps.song_artist)
song_id = song.id
if not song_id:
continue
proj_song = ProjectSong(
db.add(ProjectSong(
project_id=project.id, song_id=song_id,
position=ps.position, status=ps.status,
is_workshop=ps.is_workshop,
dance_override=ps.dance_override,
)
db.add(proj_song)
))
# ── Slet playlister der er fjernet lokalt ─────────────────────────────────
# Klienten sender api_project_id (= server Project.id) som strings
# ── Slet playlister ───────────────────────────────────────────────────────
for project_id in payload.deleted_playlists:
proj = db.query(Project).filter_by(id=project_id, owner_id=me.id).first()
if proj:
@@ -265,9 +330,9 @@ def push(
return {
"status": "ok",
"songs_synced": len(song_id_map),
"playlists_synced": len(playlist_id_map)
#"song_id_map": song_id_map,
#"playlist_id_map": playlist_id_map,
"playlists_synced": len(playlist_id_map),
"song_id_map": {k: str(v) for k, v in song_id_map.items()},
"playlist_id_map": {k: str(v) for k, v in playlist_id_map.items()},
}
@@ -286,7 +351,7 @@ def pull(
for l in db.query(DanceLevel).order_by(DanceLevel.sort_order).all()
]
# Danse med info
# Danse
dances = [
{
"name": d.name,
@@ -300,95 +365,109 @@ def pull(
for d in db.query(Dance).order_by(Dance.use_count.desc()).limit(500).all()
]
# Community dans-tags (populære)
community = []
for cd in db.query(CommunityDance).limit(1000).all():
community.append({
"song_title": cd.song_title,
"song_artist": cd.song_artist,
"dance_id": cd.dance_id,
})
# Delte playlister (read-only — kun ejeren kan redigere)
shared_ids = set()
for s in db.query(PlaylistShare).filter(
# Delte playlister
shared_ids = {
s.project_id for s in db.query(PlaylistShare).filter(
(PlaylistShare.shared_with_id == me.id) |
(PlaylistShare.invited_email == me.email)
).all():
shared_ids.add(s.project_id)
).all()
}
shared = []
for p in db.query(Project).filter(Project.id.in_(shared_ids)).all():
if p.owner_id == me.id:
continue # Egne lister håndteres separat
owner = db.query(User).filter_by(id=p.owner_id).first()
songs_out = []
for ps in p.project_songs:
song = db.query(Song).filter_by(id=ps.song_id).first()
if not song:
continue
songs_out.append({
"title": song.title,
"artist": song.artist,
"position": ps.position,
"status": ps.status,
"is_workshop": ps.is_workshop,
"dance_override": ps.dance_override or "",
})
owner = db.query(User).filter_by(id=p.owner_id).first()
shared.append({
"server_id": p.id,
"name": p.name,
"owner": owner.username if owner else "?",
"songs": sorted(songs_out, key=lambda x: x["position"]),
})
# Egne playlister
my_playlists = []
all_projects = db.query(Project).filter_by(owner_id=me.id).all()
import logging
logging.getLogger(__name__).info(f"Pull: fandt {len(all_projects)} projekter for {me.id}")
for p in all_projects:
songs_out = []
for ps in p.project_songs:
song = db.query(Song).filter_by(id=ps.song_id).first()
if not song:
continue
songs_out.append({
"title": song.title,
"artist": song.artist,
"songs": [
{
"song_id": str(ps.song_id),
"title": ps.song.title,
"artist": ps.song.artist,
"mbid": ps.song.mbid or "",
"acoustid": ps.song.acoustid or "",
"bpm": ps.song.bpm,
"duration_sec": ps.song.duration_sec,
"position": ps.position,
"status": ps.status,
"is_workshop": ps.is_workshop,
"dance_override": ps.dance_override or "",
}
for ps in sorted(p.project_songs, key=lambda x: x.position)
if ps.song
],
})
# Egne playlister
my_playlists = []
for p in db.query(Project).filter_by(owner_id=me.id).all():
my_playlists.append({
"server_id": p.id,
"name": p.name,
"description": p.description or "",
"songs": sorted(songs_out, key=lambda x: x["position"]),
"songs": [
{
"song_id": str(ps.song_id),
"title": ps.song.title,
"artist": ps.song.artist,
"mbid": ps.song.mbid or "",
"acoustid": ps.song.acoustid or "",
"bpm": ps.song.bpm,
"duration_sec": ps.song.duration_sec,
"position": ps.position,
"status": ps.status,
"is_workshop": ps.is_workshop,
"dance_override": ps.dance_override or "",
}
for ps in sorted(p.project_songs, key=lambda x: x.position)
if ps.song
],
})
# Brugerens egne dans-tags
from app.models import SongDance, SongAltDance
logger.info(f"Pull: {len(my_playlists)} playlister for {me.username}")
# Dans-tags (brugerens egne)
song_tags = []
for sd in db.query(SongDance).join(Song).filter(Song.owner_id == me.id).all():
for sd in db.query(SongDance).all():
dance = db.query(Dance).filter_by(id=sd.dance_id).first()
if not dance:
continue
level = db.query(DanceLevel).filter_by(id=dance.level_id).first() if dance.level_id else None
song_tags.append({
"song_title": sd.song.title,
"song_artist": sd.song.artist,
"song_id": sd.song_id,
"dance_name": dance.name,
"choreographer": dance.choreographer or "",
"level_name": level.name if level else "",
"dance_order": sd.dance_order,
})
# Community alternativ-danse (top 500 mest ratede)
from app.models import CommunityDanceAlt, DanceAltRating
community_alts = []
for alt in db.query(CommunityDanceAlt).order_by(
CommunityDanceAlt.avg_rating.desc()
).limit(500).all():
my_rating = db.query(DanceAltRating).filter_by(
alternative_id=alt.id, user_id=me.id
).first()
community_alts.append({
"song_mbid": alt.song_mbid or "",
"song_title": alt.song_title,
"song_artist": alt.song_artist,
"dance_name": alt.alt_dance.name if alt.alt_dance else "",
"avg_rating": round(alt.avg_rating, 1),
"rating_count": alt.rating_count,
"my_rating": my_rating.score if my_rating else None,
})
return {
"levels": levels,
"dances": dances,
"community": community,
"shared": shared,
"my_playlists": my_playlists,
"song_tags": song_tags,
"community_alts": community_alts,
}

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LineDance Player</title>
<title>LineDance Player — Danselister</title>
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>
:root {
@@ -61,6 +61,25 @@
.hero h1 em { color: var(--accent); font-style: normal; }
.hero p { color: var(--muted); font-size: 1rem; }
/* Tag-søgning */
.search-row {
display: flex; gap: .6rem; flex-wrap: wrap;
margin-bottom: 1.25rem; align-items: center;
}
.search-input {
background: var(--surface); border: 1px solid var(--border);
border-radius: 6px; padding: .4rem .8rem; color: var(--text);
font-family: var(--sans); font-size: .85rem; outline: none;
transition: border-color .15s; flex: 1; min-width: 180px; max-width: 320px;
}
.search-input:focus { border-color: var(--accent); }
.tag-btn {
font-family: var(--mono); font-size: .72rem; padding: .2rem .6rem;
border-radius: 4px; border: 1px solid var(--border);
background: transparent; color: var(--muted); cursor: pointer; transition: all .15s;
}
.tag-btn:hover, .tag-btn.active { background: rgba(232,160,32,.12); color: var(--accent); border-color: rgba(232,160,32,.3); }
.section { max-width: 900px; margin: 0 auto; padding: 0 2rem 4rem; }
.section-title {
font-family: var(--mono); font-size: .72rem; letter-spacing: .15em;
@@ -80,9 +99,11 @@
}
.card.clickable:hover { border-color: var(--accent); transform: translateY(-2px); }
.card.clickable:hover::before { transform: scaleX(1); }
.card.locked { border-color: rgba(107,112,128,.4); opacity: .75; }
.card-title { font-weight: 600; font-size: .95rem; margin-bottom: .3rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.card-owner { font-size: .78rem; color: var(--muted); font-family: var(--mono); margin-bottom: .75rem; }
.card-meta { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
.card-tags { display: flex; gap: .35rem; flex-wrap: wrap; margin-top: .5rem; }
.badge {
font-family: var(--mono); font-size: .68rem; padding: .18rem .45rem;
border-radius: 4px; border: 1px solid;
@@ -90,7 +111,8 @@
.badge.orange { background: rgba(232,160,32,.12); color: var(--accent); border-color: rgba(232,160,32,.3); }
.badge.green { background: rgba(46,204,113,.12); color: var(--green); border-color: rgba(46,204,113,.3); }
.badge.muted { background: rgba(107,112,128,.12); color: var(--muted); border-color: rgba(107,112,128,.3); }
.card-actions { display: flex; gap: .5rem; margin-top: .75rem; padding-top: .75rem; border-top: 1px solid var(--border); }
.badge.red { background: rgba(231,76,60,.12); color: var(--red); border-color: rgba(231,76,60,.3); }
.card-actions { display: flex; gap: .5rem; margin-top: .75rem; padding-top: .75rem; border-top: 1px solid var(--border); flex-wrap: wrap; }
#detail {
display: none; position: fixed; inset: 0;
@@ -139,6 +161,39 @@
.msg.error { background: rgba(231,76,60,.12); color: var(--red); border: 1px solid rgba(231,76,60,.3); }
.msg.success { background: rgba(46,204,113,.12); color: var(--green); border: 1px solid rgba(46,204,113,.3); }
/* Mine danselister — sidebar layout */
.mine-layout { display: flex; gap: 1.5rem; align-items: flex-start; }
.mine-sidebar {
width: 180px; flex-shrink: 0;
background: var(--surface); border: 1px solid var(--border);
border-radius: 10px; padding: .75rem; position: sticky; top: 80px;
}
.mine-sidebar-title {
font-family: var(--mono); font-size: .65rem; letter-spacing: .15em;
text-transform: uppercase; color: var(--muted);
padding-bottom: .5rem; margin-bottom: .5rem;
border-bottom: 1px solid var(--border);
}
.mine-tag-btn {
display: block; width: 100%; text-align: left;
font-size: .8rem; padding: .3rem .5rem; border-radius: 5px;
border: none; background: none; color: var(--muted);
cursor: pointer; transition: all .15s;
}
.mine-tag-btn:hover { color: var(--text); background: rgba(255,255,255,.04); }
.mine-tag-btn.active { color: var(--accent); background: rgba(232,160,32,.1); font-weight: 500; }
.mine-tag-btn .mine-tag-count {
float: right; font-family: var(--mono); font-size: .68rem; color: var(--muted);
}
.mine-grid-wrap { flex: 1; min-width: 0; }
.mine-search { margin-bottom: .75rem; }
.mine-search input {
width: 100%; background: var(--surface); border: 1px solid var(--border);
border-radius: 6px; padding: .4rem .8rem; color: var(--text);
font-family: var(--sans); font-size: .85rem; outline: none; transition: border-color .15s;
}
.mine-search input:focus { border-color: var(--accent); }
.empty { text-align: center; padding: 3rem 1rem; color: var(--muted); font-size: .9rem; grid-column: 1/-1; }
.spinner { width: 28px; height: 28px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; margin: 0 auto .75rem; }
@keyframes spin { to { transform: rotate(360deg); } }
@@ -158,28 +213,46 @@
</header>
<div class="tabs">
<div class="tab active" data-tab="public">Public playlister</div>
<div class="tab" id="tab-mine" data-tab="mine" style="display:none">Mine playlister</div>
<div class="tab active" data-tab="public">Offentlige danselister</div>
<div class="tab" id="tab-mine" data-tab="mine" style="display:none">Mine danselister</div>
</div>
<div class="hero">
<h1 id="hero-title">Public<br><em>playlister</em></h1>
<p id="hero-sub">Browse og kopiér playlister delt af LineDance Player-brugere.</p>
<h1 id="hero-title">Offentlige<br><em>danselister</em></h1>
<p id="hero-sub">Browse og kopiér danselister delt af LineDance Player-brugere.</p>
</div>
<div class="section">
<div id="pane-public">
<div class="section-title">Alle public playlister</div>
<div class="search-row">
<input class="search-input" id="search-public" placeholder="Søg på navn eller tag..." oninput="filterPublic()">
<div id="tag-btns" style="display:flex;gap:.4rem;flex-wrap:wrap;"></div>
</div>
<div class="section-title" id="public-title">Alle offentlige danselister</div>
<div id="grid-public" class="grid">
<div class="empty"><div class="spinner"></div>Henter playlister...</div>
<div class="empty"><div class="spinner"></div>Henter danselister...</div>
</div>
</div>
<div id="pane-mine" style="display:none">
<div class="section-title">Mine playlister</div>
<div class="section-title" id="mine-title">Mine danselister</div>
<div class="mine-layout">
<div class="mine-sidebar">
<div class="mine-sidebar-title">Tags</div>
<button class="mine-tag-btn active" data-tag="" onclick="setMineTag('')">
Alle <span class="mine-tag-count" id="mine-all-count"></span>
</button>
<div id="mine-tag-list"></div>
</div>
<div class="mine-grid-wrap">
<div class="mine-search">
<input id="search-mine" placeholder="Søg danseliste..." oninput="filterMine()">
</div>
<div id="grid-mine" class="grid">
<div class="empty"><div class="spinner"></div></div>
</div>
</div>
</div>
</div>
</div>
<div id="detail">
@@ -213,6 +286,7 @@
</div>
<div id="login-modal">
<div class="login-box">
<h3>Log ind</h3>
<div id="login-msg"></div>
<div class="form-row"><label>Brugernavn eller e-mail</label><input type="text" id="inp-user" placeholder="dit@email.dk"></div>
@@ -231,6 +305,10 @@ let token = localStorage.getItem('ld_token') || '';
let username = localStorage.getItem('ld_user') || '';
let currentPlaylistId = null;
let currentTab = 'public';
let allPublicLists = [];
let activeTag = '';
let allMineLists = [];
let activeMineTag = '';
function updateAuthUI() {
document.getElementById('btn-login').style.display = token ? 'none' : '';
@@ -267,6 +345,8 @@ document.getElementById('btn-do-login').onclick = async () => {
localStorage.setItem('ld_token', token); localStorage.setItem('ld_user', username);
document.getElementById('login-modal').classList.remove('open');
updateAuthUI();
// Skift til mine danselister ved login
switchTab('mine');
loadMyPlaylists();
} catch(e) {
msg.innerHTML = `<div class="msg error">${e.message}</div>`;
@@ -280,37 +360,174 @@ function switchTab(tab) {
document.getElementById('pane-public').style.display = tab === 'public' ? '' : 'none';
document.getElementById('pane-mine').style.display = tab === 'mine' ? '' : 'none';
if (tab === 'public') {
document.getElementById('hero-title').innerHTML = 'Public<br><em>playlister</em>';
document.getElementById('hero-sub').textContent = 'Browse og kopiér playlister delt af LineDance Player-brugere.';
document.getElementById('hero-title').innerHTML = 'Offentlige<br><em>danselister</em>';
document.getElementById('hero-sub').textContent = 'Browse og kopiér danselister delt af LineDance Player-brugere.';
} else {
document.getElementById('hero-title').innerHTML = 'Mine<br><em>playlister</em>';
document.getElementById('hero-sub').textContent = 'Administrér synlighed på dine playlister.';
document.getElementById('hero-title').innerHTML = 'Mine<br><em>danselister</em>';
document.getElementById('hero-sub').textContent = 'Administrér dine danselister.';
}
}
document.querySelectorAll('.tab').forEach(t => t.onclick = () => switchTab(t.dataset.tab));
async function loadPublicPlaylists() {
// ── Tag-søgning ───────────────────────────────────────────────────────────────
function filterPublic() {
const q = document.getElementById('search-public').value.trim().toLowerCase();
const filtered = allPublicLists.filter(p => {
const matchText = !q ||
p.name.toLowerCase().includes(q) ||
(p.tags || '').toLowerCase().includes(q) ||
(p.owner || '').toLowerCase().includes(q);
const matchTag = !activeTag ||
(p.tags || '').toLowerCase().split(',').map(t => t.trim()).includes(activeTag);
return matchText && matchTag;
});
renderPublicGrid(filtered);
const n = filtered.length;
document.getElementById('public-title').textContent =
(q || activeTag) ? `${n} danseliste${n !== 1 ? 'r' : ''} fundet` : 'Alle offentlige danselister';
}
function setTag(tag) {
activeTag = (activeTag === tag) ? '' : tag;
document.querySelectorAll('.tag-btn').forEach(b =>
b.classList.toggle('active', b.dataset.tag === activeTag));
filterPublic();
}
function renderPublicGrid(lists) {
const grid = document.getElementById('grid-public');
try {
const r = await fetch(`${API}/sharing/public`);
const lists = await r.json();
if (!lists.length) { grid.innerHTML = '<div class="empty">Ingen public playlister endnu.</div>'; return; }
grid.innerHTML = lists.map(p => `
if (!lists.length) {
grid.innerHTML = '<div class="empty">Ingen danselister matcher søgningen.</div>';
return;
}
grid.innerHTML = lists.map(p => {
const tags = (p.tags || '').split(',').map(t => t.trim()).filter(Boolean);
const tagHtml = tags.map(t =>
`<span class="badge muted" style="cursor:pointer" onclick="setTag('${esc(t)}')">${esc(t)}</span>`
).join('');
return `
<div class="card clickable fade-in" data-id="${p.id}">
<div class="card-title">${esc(p.name)}</div>
<div class="card-owner">@ ${esc(p.owner)}</div>
<div class="card-meta">
<span class="badge orange">${p.song_count} sange</span>
<span class="badge green">public</span>
<span class="badge green">offentlig</span>
</div>
</div>`).join('');
${tagHtml ? `<div class="card-tags">${tagHtml}</div>` : ''}
</div>`;
}).join('');
grid.querySelectorAll('.card').forEach(c =>
c.onclick = () => openDetail(c.dataset.id, false));
}
function buildTagButtons(lists) {
const tagSet = new Set();
lists.forEach(p => (p.tags || '').split(',').forEach(t => {
const tt = t.trim().toLowerCase();
if (tt) tagSet.add(tt);
}));
const tags = [...tagSet].sort();
const container = document.getElementById('tag-btns');
container.innerHTML = tags.map(t =>
`<button class="tag-btn" data-tag="${esc(t)}" onclick="setTag('${esc(t)}')">${esc(t)}</button>`
).join('');
}
async function loadPublicPlaylists() {
const grid = document.getElementById('grid-public');
try {
const r = await fetch(`${API}/sharing/public`);
allPublicLists = await r.json();
buildTagButtons(allPublicLists);
renderPublicGrid(allPublicLists);
} catch(e) {
grid.innerHTML = `<div class="empty">Kunne ikke hente playlister.<br>${e.message}</div>`;
grid.innerHTML = `<div class="empty">Kunne ikke hente danselister.<br>${e.message}</div>`;
}
}
// ── Mine danselister ──────────────────────────────────────────────────────────
function buildMineSidebar(lists) {
const tagCounts = {};
lists.forEach(p => (p.tags || '').split(',').forEach(t => {
const tt = t.trim().toLowerCase();
if (tt) tagCounts[tt] = (tagCounts[tt] || 0) + 1;
}));
document.getElementById('mine-all-count').textContent = lists.length;
const container = document.getElementById('mine-tag-list');
container.innerHTML = Object.entries(tagCounts)
.sort((a,b) => a[0].localeCompare(b[0]))
.map(([tag, count]) => `
<button class="mine-tag-btn${activeMineTag === tag ? ' active' : ''}"
data-tag="${esc(tag)}" onclick="setMineTag('${esc(tag)}')">
${esc(tag)} <span class="mine-tag-count">${count}</span>
</button>`).join('');
}
function setMineTag(tag) {
activeMineTag = tag;
document.querySelectorAll('.mine-tag-btn').forEach(b =>
b.classList.toggle('active', b.dataset.tag === tag));
filterMine();
}
function filterMine() {
const q = (document.getElementById('search-mine')?.value || '').trim().toLowerCase();
const filtered = allMineLists.filter(p => {
const matchText = !q ||
p.name.toLowerCase().includes(q) ||
(p.tags || '').toLowerCase().includes(q);
const matchTag = !activeMineTag ||
(p.tags || '').toLowerCase().split(',').map(t => t.trim()).includes(activeMineTag);
return matchText && matchTag;
});
renderMineGrid(filtered);
const n = filtered.length;
document.getElementById('mine-title').textContent =
(q || activeMineTag) ? `${n} danseliste${n !== 1 ? 'r' : ''} fundet` : 'Mine danselister';
}
function renderMineGrid(lists) {
const grid = document.getElementById('grid-mine');
if (!lists.length) {
grid.innerHTML = '<div class="empty">Ingen danselister matcher søgningen.</div>';
return;
}
grid.innerHTML = lists.map(p => {
const vis = p.visibility || 'private';
const locked = p.locked || false;
const bc = locked ? 'muted' : vis === 'public' ? 'green' : vis === 'shared' ? 'orange' : 'muted';
const bl = locked ? '🔒 låst' : vis === 'public' ? 'offentlig' : vis === 'shared' ? 'delt' : 'privat';
const sc = p.song_count || (p.songs || []).length || 0;
const tags = (p.tags || '').split(',').map(t => t.trim()).filter(Boolean);
const tagHtml = tags.map(t =>
`<span class="badge muted" style="cursor:pointer" onclick="setMineTag('${esc(t)}')">${esc(t)}</span>`
).join('');
return `
<div class="card fade-in${locked ? ' locked' : ''}">
<div class="card-title">${locked ? '🔒 ' : ''}${esc(p.name)}</div>
<div class="card-meta">
<span class="badge orange">${sc} sange</span>
<span class="badge ${bc}" id="vis-badge-${p.id}">${bl}</span>
</div>
${tagHtml ? `<div class="card-tags">${tagHtml}</div>` : ''}
<div class="card-actions">
<button class="btn sm" onclick="openDetail('${p.id}',true)">Se sange</button>
${!locked ? `
<button class="btn sm${vis==='public'?' danger':''}" id="vis-btn-${p.id}"
onclick="toggleVis('${p.id}','${vis}')">
${vis === 'public' ? 'Gør privat' : 'Gør offentlig'}
</button>
<button class="btn sm danger" onclick="confirmLock('${p.id}','${esc(p.name)}')" title="Lås permanent">🔒</button>
` : ''}
<a class="btn sm" href="/live.html?id=${p.id}" target="_blank" title="Storskærm">📺</a>
<button class="btn sm" onclick="showQR('${p.id}','${esc(p.name)}')" title="QR-kode">QR</button>
</div>
</div>`;
}).join('');
}
async function loadMyPlaylists() {
const grid = document.getElementById('grid-mine');
grid.innerHTML = '<div class="empty"><div class="spinner"></div></div>';
@@ -319,33 +536,15 @@ async function loadMyPlaylists() {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!r.ok) throw new Error('Ikke autoriseret');
const lists = await r.json();
if (!lists.length) { grid.innerHTML = '<div class="empty">Ingen playlister endnu.</div>'; return; }
grid.innerHTML = lists.map(p => {
const vis = p.visibility || 'private';
const bc = vis === 'public' ? 'green' : vis === 'shared' ? 'orange' : 'muted';
const bl = vis === 'public' ? 'public' : vis === 'shared' ? 'delt' : 'privat';
const sc = p.song_count || (p.songs || []).length || 0;
return `
<div class="card fade-in">
<div class="card-title">${esc(p.name)}</div>
<div class="card-meta">
<span class="badge orange">${sc} sange</span>
<span class="badge ${bc}" id="vis-badge-${p.id}">${bl}</span>
</div>
<div class="card-actions">
<button class="btn sm" onclick="openDetail('${p.id}',true)">Se sange</button>
<button class="btn sm${vis==='public'?' danger':''}" id="vis-btn-${p.id}"
onclick="toggleVis('${p.id}','${vis}')">
${vis === 'public' ? 'Gør privat' : 'Gør public'}
</button>
<a class="btn sm" href="/live.html?id=${p.id}" target="_blank" title="Åbn storskærm">📺</a>
<button class="btn sm" onclick="showQR('${p.id}','${esc(p.name)}')" title="QR-kode">QR</button>
</div>
</div>`;
}).join('');
allMineLists = await r.json();
if (!allMineLists.length) {
document.getElementById('grid-mine').innerHTML = '<div class="empty">Ingen danselister endnu.</div>';
return;
}
buildMineSidebar(allMineLists);
filterMine();
} catch(e) {
grid.innerHTML = `<div class="empty">Kunne ikke hente playlister.<br>${e.message}</div>`;
grid.innerHTML = `<div class="empty">Kunne ikke hente danselister.<br>${e.message}</div>`;
}
}
@@ -356,21 +555,28 @@ async function toggleVis(id, current) {
method: 'PATCH', headers: { 'Authorization': `Bearer ${token}` }
});
if (!r.ok) throw new Error('Fejl');
const badge = document.getElementById(`vis-badge-${id}`);
const btn = document.getElementById(`vis-btn-${id}`);
if (newVis === 'public') {
badge.className = 'badge green'; badge.textContent = 'public';
btn.className = 'btn sm danger'; btn.textContent = 'Gør privat';
btn.onclick = () => toggleVis(id, 'public');
} else {
badge.className = 'badge muted'; badge.textContent = 'privat';
btn.className = 'btn sm'; btn.textContent = 'Gør public';
btn.onclick = () => toggleVis(id, 'private');
}
loadMyPlaylists();
loadPublicPlaylists();
} catch(e) { alert('Fejl: ' + e.message); }
}
function confirmLock(id, name) {
if (!confirm(`Lås "${name}" permanent?\n\nEn låst danseliste kan ikke længere redigeres eller opdateres fra appen. Dette kan ikke fortrydes.`)) return;
lockPlaylist(id);
}
async function lockPlaylist(id) {
try {
const r = await fetch(`${API}/projects/${id}/lock`, {
method: 'POST', headers: { 'Authorization': `Bearer ${token}` }
});
if (!r.ok) throw new Error((await r.json()).detail || 'Fejl');
loadMyPlaylists();
} catch(e) { alert('Fejl: ' + e.message); }
}
// ── Detail-visning ────────────────────────────────────────────────────────────
async function openDetail(id, isOwn) {
currentPlaylistId = id;
document.getElementById('btn-copy').style.display = isOwn ? 'none' : '';
@@ -424,6 +630,8 @@ document.getElementById('btn-copy').onclick = async () => {
} catch(e) { btn.textContent = '⚠ ' + e.message; btn.disabled = false; }
};
// ── QR ───────────────────────────────────────────────────────────────────────
let currentQRUrl = '';
function showQR(id, name) {
@@ -433,13 +641,10 @@ function showQR(id, name) {
document.getElementById('qr-url').textContent = url;
document.getElementById('copy-msg').textContent = '';
document.getElementById('qr-modal').style.display = 'flex';
// Tegn QR med et simpelt bibliotek
const canvas = document.getElementById('qr-canvas');
if (window.QRious) {
new QRious({ element: canvas, value: url, size: 220, backgroundAlpha: 0, foreground: '#eceef4' });
} else {
// Fallback: vis bare URL hvis bibliotek ikke er loadet
canvas.style.display = 'none';
}
}

View File

@@ -44,6 +44,25 @@ echo.
echo OK: dist\LineDancePlayer\ er klar
echo.
:: ── Kopiér VLC DLL-filer ind i app-mappen ─────────────────────────────────────
echo Kopierer VLC DLL-filer...
set "VLC_PATH="
if exist "C:\Program Files\VideoLAN\VLC\libvlc.dll" set "VLC_PATH=C:\Program Files\VideoLAN\VLC"
if exist "C:\Program Files (x86)\VideoLAN\VLC\libvlc.dll" set "VLC_PATH=C:\Program Files (x86)\VideoLAN\VLC"
if defined VLC_PATH (
copy /Y "!VLC_PATH!\libvlc.dll" "dist\LineDancePlayer\libvlc.dll" >nul
copy /Y "!VLC_PATH!\libvlccore.dll" "dist\LineDancePlayer\libvlccore.dll" >nul
:: Kopiér også plugins-mappen som VLC kræver
if exist "!VLC_PATH!\plugins" (
xcopy /E /I /Y /Q "!VLC_PATH!\plugins" "dist\LineDancePlayer\plugins" >nul
)
echo OK: VLC DLL-filer kopieret fra !VLC_PATH!
) else (
echo ADVARSEL: VLC ikke fundet - brugere skal have VLC installeret selv
)
echo.
:: ── NSIS installer ────────────────────────────────────────────────────────────
echo [4/4] Bygger NSIS installer...
echo.

View File

@@ -2,10 +2,32 @@
block_cipher = None
import os, glob
# Find VLC installation
VLC_PATH = None
for _p in [
r'C:\Program Files\VideoLAN\VLC',
r'C:\Program Files (x86)\VideoLAN\VLC',
]:
if os.path.exists(os.path.join(_p, 'libvlc.dll')):
VLC_PATH = _p
break
VLC_BINARIES = []
if VLC_PATH:
VLC_BINARIES = [
(os.path.join(VLC_PATH, 'libvlc.dll'), '.'),
(os.path.join(VLC_PATH, 'libvlccore.dll'), '.'),
]
for _dll in glob.glob(os.path.join(VLC_PATH, 'plugins', '**', '*.dll'), recursive=True):
_rel = os.path.relpath(os.path.dirname(_dll), VLC_PATH)
VLC_BINARIES.append((_dll, _rel))
a = Analysis(
['main.py'],
pathex=['.'],
binaries=[],
binaries=VLC_BINARIES,
datas=[
('translations', 'translations'),
],
@@ -20,7 +42,7 @@ a = Analysis(
'ui.scan_worker', 'ui.bpm_worker', 'ui.tag_editor',
'ui.settings_dialog', 'ui.playlist_browser',
'ui.playlist_info_dialog', 'ui.dance_info_dialog',
'ui.dance_picker_dialog', 'ui.share_dialog',
'ui.dance_picker_dialog', 'ui.alt_dance_picker_dialog', 'ui.share_dialog',
'ui.register_dialog',
'player.player',
'local.local_db', 'local.scanner', 'local.file_watcher',

View File

@@ -51,6 +51,12 @@ Section "LineDance Player" SecMain
SetOutPath "$INSTDIR"
File "dist\LineDancePlayer\LineDancePlayer.exe"
; VLC DLL-filer og plugins er nu pakket direkte af PyInstaller
File /nonfatal "dist\LineDancePlayer\libvlc.dll"
File /nonfatal "dist\LineDancePlayer\libvlccore.dll"
SetOutPath "$INSTDIR\plugins"
File /nonfatal /r "dist\LineDancePlayer\plugins\*"
SetOutPath "$INSTDIR\_internal"
File /r "dist\LineDancePlayer\_internal\*"
@@ -85,34 +91,7 @@ Section "LineDance Player" SecMain
SectionEnd
; ── VLC tjek ──────────────────────────────────────────────────────────────────
Section -VLCCheck
; Tjek alle kendte VLC registry-stier
ReadRegStr $0 HKLM "SOFTWARE\VideoLAN\VLC" ""
ReadRegStr $1 HKCU "SOFTWARE\VideoLAN\VLC" ""
ReadRegStr $2 HKLM "SOFTWARE\WOW6432Node\VideoLAN\VLC" ""
; Tjek også om vlc.exe eksisterer
IfFileExists "$PROGRAMFILES\VideoLAN\VLC\vlc.exe" VLCFound 0
IfFileExists "$PROGRAMFILES64\VideoLAN\VLC\vlc.exe" VLCFound 0
${If} $0 != ""
Goto VLCFound
${EndIf}
${If} $1 != ""
Goto VLCFound
${EndIf}
${If} $2 != ""
Goto VLCFound
${EndIf}
; VLC ikke fundet
MessageBox MB_YESNO|MB_ICONINFORMATION \
"LineDance Player bruger VLC til afspilning.$\n$\nVLC ser ikke ud til at vaere installeret.$\n$\nVil du aabne download-siden nu?$\n$\n(Du kan installere VLC senere)" \
IDNO VLCSkip
ExecShell "open" "https://www.videolan.org/vlc/"
VLCSkip:
Goto VLCDone
VLCFound:
VLCDone:
SectionEnd
; VLC DLL-filer er bundlet med appen — intet VLC-tjek nødvendigt
; ── Afinstaller ───────────────────────────────────────────────────────────────
Section "Uninstall"

View File

@@ -159,11 +159,11 @@ def run_acoustid_scan(db_path: str, api_key: str = "", on_progress=None, stop_ev
conn.row_factory = sqlite3.Row
rows = conn.execute("""
SELECT id, local_path, title, artist
FROM songs
WHERE (mbid IS NULL OR mbid = '')
AND file_missing = 0
AND local_path IS NOT NULL AND local_path != ''
SELECT s.id, s.title, s.artist, f.local_path
FROM songs s
JOIN files f ON f.song_id = s.id AND f.file_missing = 0
WHERE (s.mbid IS NULL OR s.mbid = '')
AND f.local_path IS NOT NULL AND f.local_path != ''
ORDER BY RANDOM()
LIMIT ?
""", (MAX_PER_SESSION,)).fetchall()
@@ -213,10 +213,22 @@ def run_acoustid_scan(db_path: str, api_key: str = "", on_progress=None, stop_ev
if result:
mbid = result.get("mbid", "")
acoustid = result.get("acoustid", "")
# Opdater acoustid altid, men kun mbid hvis det ikke allerede bruges
conn.execute(
"UPDATE songs SET mbid=?, acoustid=? WHERE id=?",
(mbid or None, acoustid or None, row["id"])
"UPDATE songs SET acoustid=? WHERE id=?",
(acoustid or None, row["id"])
)
if mbid:
try:
conn.execute(
"UPDATE songs SET mbid=? WHERE id=? AND (mbid IS NULL OR mbid='')",
(mbid, row["id"])
)
conn.commit()
except Exception:
conn.rollback()
logger.debug(f"MBID {mbid[:8]} allerede i brug — springer over")
else:
conn.commit()
found += 1
total_found += 1

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +1,76 @@
"""
scanner.py — Scanning af musikbiblioteker i baggrunden.
scanner.py — Scanning af musikbiblioteker i baggrunden. v0.9
Kører som en separat subprocess der scanner ét bibliotek ad gangen
og rapporterer fremgang via stdout JSON-linjer.
Kan også importeres direkte og bruges via ScanWorker QThread.
Skriver til files-tabellen og finder/opretter sange i songs-tabellen.
"""
import os
import sys
import json
import sqlite3
import uuid
import logging
import time
from pathlib import Path
logger = logging.getLogger(__name__)
SUPPORTED = {'.mp3', '.flac', '.m4a', '.ogg', '.wav', '.aiff', '.wma'}
import uuid as _uuid_module
def is_supported(path: Path) -> bool:
return path.suffix.lower() in SUPPORTED
def _find_or_create_song_conn(conn, title, artist, album, bpm,
duration_sec, mbid, acoustid) -> str:
"""Find eller opret sang via eksisterende forbindelse."""
if mbid:
row = conn.execute("SELECT id FROM songs WHERE mbid=?", (mbid,)).fetchone()
if row:
return row["id"]
if acoustid:
row = conn.execute("SELECT id FROM songs WHERE acoustid=?", (acoustid,)).fetchone()
if row:
if mbid:
conn.execute("UPDATE songs SET mbid=? WHERE id=? AND mbid IS NULL", (mbid, row["id"]))
return row["id"]
if title:
row = conn.execute(
"SELECT id FROM songs WHERE title=? AND artist=?", (title, artist)
).fetchone()
if row:
if mbid:
conn.execute("UPDATE songs SET mbid=? WHERE id=? AND mbid IS NULL", (mbid, row["id"]))
return row["id"]
new_id = str(_uuid_module.uuid4())
conn.execute(
"INSERT INTO songs (id, title, artist, album, bpm, duration_sec, mbid, acoustid) "
"VALUES (?,?,?,?,?,?,?,?)",
(new_id, title, artist, album, bpm, duration_sec, mbid or None, acoustid or None)
)
return new_id
def _upsert_file_conn(conn, song_id, local_path, file_format,
file_modified_at, extra_tags) -> str:
"""Opret eller opdater fil-post via eksisterende forbindelse."""
existing = conn.execute(
"SELECT id FROM files WHERE local_path=?", (local_path,)
).fetchone()
if existing:
conn.execute("""
UPDATE files SET song_id=?, file_missing=0,
file_format=?, file_modified_at=?, extra_tags=?
WHERE id=?
""", (song_id, file_format, file_modified_at, extra_tags, existing["id"]))
return existing["id"]
else:
file_id = str(_uuid_module.uuid4())
conn.execute(
"INSERT INTO files (id, song_id, local_path, file_format, file_modified_at, extra_tags) "
"VALUES (?,?,?,?,?,?)",
(file_id, song_id, local_path, file_format, file_modified_at, extra_tags)
)
return file_id
def is_supported(path) -> bool:
return Path(path).suffix.lower() in SUPPORTED
def get_file_mtime(path: Path) -> str:
@@ -32,29 +82,27 @@ def get_file_mtime(path: Path) -> str:
def scan_library(library_id: int, library_path: str, db_path: str,
overwrite_bpm: bool = False,
progress_callback=None):
progress_callback=None) -> int:
"""
Scan ét bibliotek og upsert sange til SQLite.
progress_callback(done, total, current_file) kaldes løbende.
Scan ét bibliotek og upsert til files + songs tabellerne.
Returnerer antal scannede filer.
"""
import sqlite3
from local.tag_reader import read_tags
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
base = Path(library_path)
if not base.exists():
conn.close()
return 0
# Byg indeks over kendte filer
# Byg indeks over kendte filer (path → mtime)
conn = sqlite3.connect(db_path, timeout=10)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
known = {}
for row in conn.execute(
"SELECT local_path, file_modified_at, file_missing FROM songs WHERE library_id=?",
(library_id,)
"SELECT local_path, file_modified_at FROM files WHERE file_missing=0"
).fetchall():
# Sange markeret som manglende medtages ikke i known — de skal altid genscanes
if not row["file_missing"]:
known[row["local_path"]] = row["file_modified_at"]
# Find alle musikfiler
@@ -68,8 +116,6 @@ def scan_library(library_id: int, library_path: str, db_path: str,
total = len(all_files)
done = 0
import time
for fp in all_files:
path_str = str(fp)
mtime = get_file_mtime(fp)
@@ -77,108 +123,47 @@ def scan_library(library_id: int, library_path: str, db_path: str,
if progress_callback:
progress_callback(done, total, fp.name)
# Spring over hvis ikke ændret
# Spring over uændrede filer
if path_str in known and known[path_str] == mtime:
done += 1
# Yield hvert 100. fil så andre tråde kan køre
if done % 100 == 0:
time.sleep(0.005)
continue
try:
tags = read_tags(fp)
extra = json.dumps(tags.get("extra_tags", {}), ensure_ascii=False)
# Match 0: MBID-match — sikrest mulige match
existing = None
mbid_from_file = tags.get("mbid", "")
if mbid_from_file:
existing = conn.execute(
"SELECT id, bpm FROM songs WHERE mbid=? LIMIT 1",
(mbid_from_file,)
).fetchone()
if existing:
conn.execute(
"UPDATE songs SET local_path=? WHERE id=?",
(path_str, existing["id"])
)
# Match 1: præcis sti-match
if not existing:
existing = conn.execute(
"SELECT id, bpm FROM songs WHERE local_path=?", (path_str,)
).fetchone()
# Match 2: titel+artist match — fil er flyttet eller var missing
if not existing:
title = tags.get("title", "")
tags = read_tags(str(fp))
title = tags.get("title", "") or fp.stem
artist = tags.get("artist", "")
if title:
# Prioritér file_missing=1 sange, men tag også sange med ugyldig sti
existing = conn.execute("""
SELECT id, bpm FROM songs
WHERE title=? AND artist=? AND file_missing=1
LIMIT 1
""", (title, artist)).fetchone()
if not existing:
# Tjek om der er en sang med samme titel+artist men ugyldig sti
existing = conn.execute("""
SELECT id, bpm, local_path FROM songs
WHERE title=? AND artist=? AND file_missing=0
LIMIT 1
""", (title, artist)).fetchone()
if existing:
from pathlib import Path as _Path
old_path = existing["local_path"] or ""
if old_path and not _Path(old_path).exists():
pass # Sti er ugyldig — brug dette match
else:
existing = None # Sti er valid — det er en anden fil
album = tags.get("album", "")
bpm = tags.get("bpm", 0)
mbid = tags.get("mbid", "")
acoustid = tags.get("acoustid", "")
duration_sec = tags.get("duration_sec", 0)
file_format = tags.get("file_format", fp.suffix.lstrip(".").lower())
import json as _json
_extra = tags.get("extra_tags", {})
extra_tags = _json.dumps(_extra) if isinstance(_extra, dict) else (_extra or "{}")
if existing:
# Opdater stien så den peger på den nye placering
conn.execute(
"UPDATE songs SET local_path=? WHERE id=?",
(path_str, existing["id"])
# Find eller opret sang — alt via samme conn
song_id = _find_or_create_song_conn(
conn, title, artist, album, bpm, duration_sec, mbid, acoustid
)
if existing:
bpm = tags.get("bpm", 0)
if not overwrite_bpm and existing["bpm"] and existing["bpm"] > 0:
bpm = existing["bpm"] # behold eksisterende BPM
mbid = tags.get("mbid", "")
conn.execute("""
UPDATE songs SET
library_id=?, title=?, artist=?, album=?,
bpm=?, duration_sec=?, file_format=?,
file_modified_at=?, file_missing=0, extra_tags=?,
mbid=CASE WHEN ? != '' THEN ? ELSE mbid END
WHERE id=?
""", (library_id, tags.get("title",""), tags.get("artist",""),
tags.get("album",""), bpm, tags.get("duration_sec",0),
tags.get("file_format",""), mtime, extra,
mbid, mbid, existing["id"]))
song_id = existing["id"]
else:
song_id = str(uuid.uuid4())
conn.execute("""
INSERT OR IGNORE INTO songs
(id, library_id, local_path, title, artist, album,
bpm, duration_sec, file_format, file_modified_at, extra_tags, mbid)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
""", (song_id, library_id, path_str,
tags.get("title",""), tags.get("artist",""),
tags.get("album",""), tags.get("bpm",0),
tags.get("duration_sec",0), tags.get("file_format",""),
mtime, extra, tags.get("mbid","")))
# Opdater BPM
if bpm and bpm > 0:
conn.execute(
"UPDATE songs SET bpm=? WHERE id=? AND (bpm=0 OR bpm IS NULL)",
(bpm, song_id)
)
# Importer dans-tags fra filen hvis de ikke allerede er i DB
# Opret eller opdater fil-post
_upsert_file_conn(conn, song_id, path_str, file_format, mtime, extra_tags)
# Dans-tags fra fil — synkroniser altid fra filen
file_dances = tags.get("dances", [])
if file_dances:
existing_dances = conn.execute(
"SELECT COUNT(*) FROM song_dances WHERE song_id=?", (song_id,)
).fetchone()[0]
if existing_dances == 0:
import uuid
# Slet eksisterende og genindsæt fra filen
conn.execute("DELETE FROM song_dances WHERE song_id=?", (song_id,))
for order, dance_name in enumerate(file_dances, start=1):
dance_row = conn.execute(
"SELECT id FROM dances WHERE name=? COLLATE NOCASE LIMIT 1",
@@ -192,64 +177,25 @@ def scan_library(library_id: int, library_path: str, db_path: str,
else:
dance_id = dance_row["id"]
conn.execute(
"INSERT OR IGNORE INTO song_dances (song_id, dance_id, dance_order) VALUES (?,?,?)",
(song_id, dance_id, order)
"INSERT OR IGNORE INTO song_dances (id, song_id, dance_id, dance_order) VALUES (?,?,?,?)",
(str(uuid.uuid4()), song_id, dance_id, order)
)
conn.commit()
except Exception as e:
# UNIQUE constraint er forventet og ufarlig — sang findes allerede
if "UNIQUE constraint" in str(e):
logger.debug(f"Sang allerede i DB: {fp.name}")
else:
logger.warning(f"Scan fejl {fp.name}: {e}")
done += 1
# Lille pause efter hver scannet fil så GUI ikke hænger
time.sleep(0.02)
# Marker manglende filer
for path_str in known:
if not Path(path_str).exists():
conn.execute(
"UPDATE songs SET file_missing=1 WHERE local_path=?", (path_str,)
)
conn.commit()
conn.execute(
"UPDATE libraries SET last_full_scan=datetime('now') WHERE id=?",
(library_id,)
"UPDATE files SET file_missing=1 WHERE local_path=?", (path_str,)
)
conn.commit()
conn.close()
logger.info(f"Scan færdig: {done} filer i {library_path}")
return done
# ── Subprocess entry point ─────────────────────────────────────────────────────
if __name__ == "__main__":
"""
Kørsel som subprocess:
python scanner.py <library_id> <library_path> <db_path>
Rapporterer JSON-linjer til stdout: {"done":N,"total":M,"file":"..."}
"""
if len(sys.argv) < 4:
sys.exit(1)
lib_id = int(sys.argv[1])
lib_path = sys.argv[2]
db_path = sys.argv[3]
# Tilføj app-mappen til path så local.tag_reader kan importeres
app_dir = str(Path(__file__).parent.parent)
if app_dir not in sys.path:
sys.path.insert(0, app_dir)
def report(done, total, filename):
print(json.dumps({"done": done, "total": total, "file": filename}),
flush=True)
count = scan_library(lib_id, lib_path, db_path,
progress_callback=report)
print(json.dumps({"done": count, "total": count, "finished": True}),
flush=True)

View File

@@ -1,150 +1,124 @@
"""
sync_manager.py — Synkronisering mellem lokal SQLite og server API.
Kører i baggrundstråd — blokerer aldrig GUI.
sync_manager.py — Synkronisering mellem lokal database og server. v0.9
"""
import json
import logging
import sqlite3
import threading
import urllib.request
import urllib.error
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 __init__(self, api_url: str = "", db_path: str = "",
server_url: str = "", token: str | None = None):
# Støt både api_url og server_url som parameter-navn
self._api_url = (api_url or server_url).rstrip("/")
self._db_path = db_path
self._token: str | None = token
def set_token(self, token: str):
self._token = token
# ── HTTP ──────────────────────────────────────────────────────────────────
def _post(self, path: str, data: dict) -> dict:
body = json.dumps(data).encode("utf-8")
body = json.dumps(data).encode()
req = urllib.request.Request(
f"{self._server_url}{path}", data=body,
headers=self._headers(), method="POST"
f"{self._api_url}{path}",
data=body,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {self._token}",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
detail = e.read().decode("utf-8", errors="replace")
detail = e.read().decode()
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"
f"{self._api_url}{path}",
headers={"Authorization": f"Bearer {self._token}"},
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
detail = e.read().decode()
raise Exception(f"HTTP {e.code}: {detail}")
# ── Push ──────────────────────────────────────────────────────────────────
def push(self, on_done=None, on_error=None):
"""Push lokal data til server i baggrundstråd."""
def _run():
try:
payload = self._build_push_payload()
logger.info(f"Push OK: {len(payload['songs'])} sange")
logger.info(f"Push: {len(payload['songs'])} sange, "
f"{len(payload['playlists'])} playlister")
result = self._post("/sync/push", payload)
self._save_playlist_ids(result.get("playlist_id_map", {}))
# Fjern soft-slettede playlister permanent efter succesfuld push
if payload.get("deleted_playlists"):
conn = sqlite3.connect(self._db_path)
conn.execute(
"DELETE FROM playlists WHERE is_deleted=1 AND api_project_id IS NOT NULL"
self._save_server_ids(
result.get("song_id_map", {}),
result.get("playlist_id_map", {}),
)
conn.commit()
conn.close()
logger.info(f"Push OK: {result.get('songs_synced', '?')} sange synkroniseret")
logger.info(f"Push OK: {result.get('songs_synced','?')} sange synkroniseret")
if on_done:
on_done(result)
except Exception as e:
logger.error(f"Sync push fejl: {e}", exc_info=True)
logger.error(f"Push fejl: {e}", exc_info=True)
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")
pl_count = len(result.get("my_playlists", []))
logger.info(f"Pull OK: {pl_count} playlister")
self._apply_pull(result)
if on_done:
on_done(result)
except Exception as e:
logger.error(f"Sync pull fejl: {e}", exc_info=True)
if on_error:
on_error(str(e))
threading.Thread(target=_run, daemon=True).start()
# ── Push + Pull ───────────────────────────────────────────────────────────
def push_and_pull(self, on_done=None, on_error=None):
"""Push FØR pull — så sletninger når serveren inden pull henter data ned."""
def _run():
try:
# 1. Push lokal data op — inkl. sletninger
# 1. Push
payload = self._build_push_payload()
deleted = payload.get("deleted_playlists", [])
logger.info(f"Sync push — {len(payload['songs'])} sange, "
f"{len(payload['playlists'])} playlister, "
f"sletter {len(deleted)}: {deleted}")
push_result = self._post("/sync/push", payload)
self._save_playlist_ids(push_result.get("playlist_id_map", {}))
self._save_server_ids(
push_result.get("song_id_map", {}),
push_result.get("playlist_id_map", {}),
)
logger.info(f"Push svar: status={push_result.get('status')}, "
f"sange={push_result.get('songs_synced', 0)}, "
f"playlister={push_result.get('playlists_synced', 0)}")
# 2. Pull — sletninger er nu gennemført på serveren.
# _apply_pull filtrerer is_deleted=1 rækker fra automatisk.
# 2. Pull
pull_result = self._get("/sync/pull")
pl_names = [p.get("name") for p in pull_result.get("my_playlists", [])]
logger.info(f"Pull modtog {len(pl_names)} playlister: {pl_names}")
self._apply_pull(pull_result)
# Fjern soft-slettede playlister permanent nu serveren er opdateret
# 3. Fjern soft-slettede permanent efter succesfuld sync
if deleted:
conn = sqlite3.connect(self._db_path)
conn = sqlite3.connect(self._db_path, timeout=10)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute(
"DELETE FROM playlists WHERE is_deleted=1 AND api_project_id IS NOT NULL"
)
conn.commit()
conn.close()
logger.info(f"Soft-slettede playlister fjernet lokalt efter sync")
logger.info("Soft-slettede playlister fjernet lokalt efter sync")
pl_count = len(pull_result.get("my_playlists", []))
logger.info(
f"Sync OK — {len(payload['songs'])} sange, "
logger.info(f"Sync OK — {len(payload['songs'])} sange, "
f"{len(payload['playlists'])} playlister, "
f"{pl_count} server-playlister"
)
f"{pl_count} server-playlister")
if on_done:
on_done({"push": push_result, "pull": pull_result})
except Exception as e:
@@ -156,34 +130,36 @@ class SyncManager:
# ── Byg payload ───────────────────────────────────────────────────────────
def _build_push_payload(self) -> dict:
conn = sqlite3.connect(self._db_path)
conn = sqlite3.connect(self._db_path, timeout=10)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
# Sange
# Sange (dem der har filer — altså kendes lokalt)
songs = []
for row in conn.execute(
"SELECT id, title, artist, album, bpm, duration_sec, file_format, mbid, acoustid "
"FROM songs WHERE file_missing=0"
).fetchall():
for row in conn.execute("""
SELECT DISTINCT s.id, s.title, s.artist, s.album,
s.bpm, s.duration_sec, s.mbid, s.acoustid, s.server_synced
FROM songs s
JOIN files f ON f.song_id = s.id AND f.file_missing = 0
""").fetchall():
songs.append({
"local_id": str(row["id"]),
"local_id": row["id"],
"title": row["title"] or "",
"artist": row["artist"] or "",
"album": row["album"] or "",
"bpm": row["bpm"] or 0,
"duration_sec": row["duration_sec"] or 0,
"file_format": row["file_format"] or "",
"mbid": row["mbid"] or "",
"acoustid": row["acoustid"] or "",
})
# Danse
dances = []
for row in conn.execute(
"SELECT d.name, dl.name as level_name, d.choreographer, "
"d.video_url, d.stepsheet_url, d.notes "
"FROM dances d LEFT JOIN dance_levels dl ON dl.id = d.level_id"
).fetchall():
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 "",
@@ -193,16 +169,17 @@ class SyncManager:
"notes": row["notes"] or "",
})
# Dans-tags per sang
# Dans-tags
song_dances = []
for row in conn.execute("""
SELECT sd.song_id, d.name as dance_name, dl.name as level_name, sd.dance_order
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"]),
"song_local_id": row["song_id"],
"dance_name": row["dance_name"],
"level_name": row["level_name"] or "",
"dance_order": row["dance_order"],
@@ -211,36 +188,37 @@ class SyncManager:
# 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
SELECT sad.song_id, d.name as dance_name,
dl.name as level_name, sad.note, sad.user_rating
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"]),
"song_local_id": row["song_id"],
"dance_name": row["dance_name"],
"level_name": row["level_name"] or "",
"note": row["note"] or "",
"user_rating": row["user_rating"],
})
# Playlister — send alle (nye og eksisterende) til serveren.
# Brug api_project_id som local_id hvis den kendes — så serveren
# kan matche på ID og ikke oprette duplikater.
# Playlister — alle ikke-slettede
playlists = []
for pl in conn.execute(
"SELECT id, name, description, tags, api_project_id FROM playlists "
"WHERE name != '__aktiv__' AND is_deleted = 0"
).fetchall():
for pl in conn.execute("""
SELECT id, name, description, tags, api_project_id
FROM playlists
WHERE name != '__aktiv__' AND is_deleted = 0
""").fetchall():
pl_songs = []
for ps in conn.execute("""
SELECT s.id, s.title, s.artist,
SELECT s.id as song_id, s.title, s.artist,
ps.position, ps.status, ps.is_workshop, ps.dance_override
FROM playlist_songs ps
JOIN songs s ON s.id = ps.song_id
WHERE ps.playlist_id=? ORDER BY ps.position
""", (pl["id"],)).fetchall():
pl_songs.append({
"song_local_id": str(ps["id"]),
"song_local_id": ps["song_id"],
"song_title": ps["title"] or "",
"song_artist": ps["artist"] or "",
"position": int(ps["position"] or 1),
@@ -248,9 +226,8 @@ class SyncManager:
"is_workshop": bool(ps["is_workshop"]),
"dance_override": ps["dance_override"] or "",
})
# Brug api_project_id som local_id hvis den kendes —
# serveren bruger dette til at finde eksisterende liste
local_id = pl["api_project_id"] or str(pl["id"])
# Brug api_project_id som local_id hvis kendt
local_id = pl["api_project_id"] or pl["id"]
playlists.append({
"local_id": local_id,
"name": pl["name"],
@@ -260,9 +237,7 @@ class SyncManager:
"songs": pl_songs,
})
# Slettede playlister — skal fjernes fra serveren.
# Serveren forventer en liste af strings (api_project_id).
# Kun playlister der faktisk er nået serveren (har api_project_id).
# Slettede playlister
deleted = [
row["api_project_id"]
for row in conn.execute(
@@ -271,6 +246,9 @@ class SyncManager:
).fetchall()
]
# Alle sang-IDs der pusher dans-tags fuldt (inkl. dem med 0 tags)
all_song_ids = [s["local_id"] for s in songs]
conn.close()
return {
"songs": songs,
@@ -279,34 +257,103 @@ class SyncManager:
"song_alts": song_alts,
"playlists": playlists,
"deleted_playlists": deleted,
"songs_with_dances_synced": all_song_ids,
}
# ── Gem server-IDs ────────────────────────────────────────────────────────
def _save_server_ids(self, song_id_map: dict, playlist_id_map: dict):
"""
Gem server-IDs lokalt.
song_id_map: lokal_song_id → server_song_id
playlist_id_map: lokal_pl_id → server_pl_id
"""
if not song_id_map and not playlist_id_map:
return
conn = sqlite3.connect(self._db_path, timeout=10)
conn.execute("PRAGMA journal_mode=WAL")
# Sange: hvis server gav et andet ID end det lokale, opdater
for local_id, server_id in song_id_map.items():
if local_id != server_id:
# Tjek om server-ID allerede eksisterer
existing = conn.execute(
"SELECT id FROM songs WHERE id=?", (server_id,)
).fetchone()
if not existing:
# Opdater lokal sang til server-ID
conn.execute(
"UPDATE songs SET id=?, server_synced=1 WHERE id=?",
(server_id, local_id)
)
# Opdater referencer
conn.execute(
"UPDATE files SET song_id=? WHERE song_id=?",
(server_id, local_id)
)
conn.execute(
"UPDATE playlist_songs SET song_id=? WHERE song_id=?",
(server_id, local_id)
)
conn.execute(
"UPDATE song_dances SET song_id=? WHERE song_id=?",
(server_id, local_id)
)
conn.execute(
"UPDATE song_alt_dances SET song_id=? WHERE song_id=?",
(server_id, local_id)
)
else:
conn.execute(
"UPDATE songs SET server_synced=1 WHERE id=?", (local_id,)
)
# Playlister
for local_id, server_id in playlist_id_map.items():
conn.execute(
"UPDATE playlists SET api_project_id=? WHERE id=? OR api_project_id=?",
(server_id, local_id, local_id)
)
conn.commit()
conn.close()
# ── Anvend pull ───────────────────────────────────────────────────────────
def _apply_pull(self, data: dict):
"""Gem server-data lokalt — opdaterer dans-info og importerer playlister."""
conn = sqlite3.connect(self._db_path)
"""Gem server-data lokalt."""
import uuid
conn = sqlite3.connect(self._db_path, timeout=10)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
# Opdater dans-info fra server
try:
# Synkroniser danse fra server — opret nye, opdater eksisterende
for d in data.get("dances", []):
if not d.get("name"):
continue
choreo = d.get("choreographer", "") or ""
existing = conn.execute(
"SELECT id FROM dances WHERE name=? COLLATE NOCASE", (d["name"],)
"SELECT id FROM dances WHERE name=? COLLATE NOCASE "
"AND choreographer=? LIMIT 1",
(d["name"], choreo)
).fetchone()
if existing and (d.get("choreographer") or d.get("video_url") or d.get("stepsheet_url")):
if existing:
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"]))
""", (d.get("video_url",""), d.get("stepsheet_url",""), existing["id"]))
else:
conn.execute(
"INSERT OR IGNORE INTO dances (name, choreographer, video_url, stepsheet_url, notes) "
"VALUES (?,?,?,?,?)",
(d["name"], choreo,
d.get("video_url",""), d.get("stepsheet_url",""), d.get("notes",""))
)
# Importer/opdater egne playlister fra server — server er sandhed
# Hent server-IDs på soft-slettede playlister så vi springer dem over
# Hent soft-slettede server-IDs så vi springer dem over
deleted_server_ids = {
row["api_project_id"]
for row in conn.execute(
@@ -315,13 +362,12 @@ class SyncManager:
).fetchall()
}
# Importer egne playlister
for pl in data.get("my_playlists", []):
server_id = pl.get("server_id")
name = pl.get("name", "")
if not server_id or not name:
continue
# Spring over hvis listen er soft-slettet lokalt
if server_id in deleted_server_ids:
continue
@@ -331,52 +377,59 @@ class SyncManager:
if existing:
pl_id = existing["id"]
# Opdater navn hvis det er ændret på serveren
conn.execute(
"UPDATE playlists SET name=? WHERE id=?", (name, pl_id)
)
else:
cur = conn.execute(
"INSERT INTO playlists (name, description, api_project_id, is_linked, server_permission) "
"VALUES (?,?,?,1,'edit')",
(name, pl.get("description",""), server_id)
pl_id = str(uuid.uuid4())
conn.execute(
"INSERT INTO playlists (id, name, description, api_project_id, is_linked, server_permission) "
"VALUES (?,?,?,?,1,'edit')",
(pl_id, name, pl.get("description",""), server_id)
)
pl_id = cur.lastrowid
# Genindlæs sange fra serveren — server er sandhed
# Genindlæs sange
conn.execute("DELETE FROM playlist_songs WHERE playlist_id=?", (pl_id,))
position = 1
for song_data in pl.get("songs", []):
songs_from_server = pl.get("songs", [])
logger.info(f"Pull: liste '{name}' har {len(songs_from_server)} sange")
for song_data in songs_from_server:
server_song_id = song_data.get("song_id", "")
title = song_data.get("title", "")
artist = song_data.get("artist", "")
if not title:
mbid = song_data.get("mbid", "")
acoustid = song_data.get("acoustid", "")
if not title and not server_song_id:
continue
local = conn.execute(
"SELECT id FROM songs WHERE title=? AND artist=? LIMIT 1",
(title, artist)
).fetchone()
if not local:
import uuid
new_id = str(uuid.uuid4())
conn.execute(
"INSERT OR IGNORE INTO songs (id, title, artist, file_missing) VALUES (?,?,?,1)",
(new_id, title, artist)
# Find eller opret sang lokalt
local_song_id = self._find_or_create_song_local(
conn, server_song_id, title, artist,
mbid=mbid, acoustid=acoustid,
bpm=song_data.get("bpm", 0),
duration_sec=song_data.get("duration_sec", 0),
)
local_id = new_id
else:
local_id = local["id"]
# Find tilgængelig fil til denne sang
file_row = conn.execute(
"SELECT id FROM files WHERE song_id=? AND file_missing=0 LIMIT 1",
(local_song_id,)
).fetchone()
file_id = file_row["id"] if file_row else None
conn.execute("""
INSERT OR IGNORE INTO playlist_songs
(playlist_id, song_id, position, status, is_workshop, dance_override)
VALUES (?,?,?,?,?,?)
""", (pl_id, local_id, position,
INSERT INTO playlist_songs
(id, playlist_id, song_id, file_id, position, status, is_workshop, dance_override)
VALUES (?,?,?,?,?,?,?,?)
""", (str(uuid.uuid4()), pl_id, local_song_id, file_id, position,
song_data.get("status","pending"),
1 if song_data.get("is_workshop") else 0,
song_data.get("dance_override","") or ""))
position += 1
# Importer delte playlister (read-only — is_linked=1, server_permission='view')
# Importer delte playlister
for pl in data.get("shared", []):
server_id = pl.get("server_id")
name = pl.get("name", "")
@@ -389,46 +442,189 @@ class SyncManager:
).fetchone()
if existing:
# Opdater sange fra server (ejer kan have ændret listen)
pl_id = existing["id"]
conn.execute("DELETE FROM playlist_songs WHERE playlist_id=?", (pl_id,))
else:
cur = conn.execute(
"INSERT INTO playlists (name, description, api_project_id, is_linked, server_permission) "
"VALUES (?,?,?,1,'view')",
(f"{name} ({owner})", "", server_id)
pl_id = str(uuid.uuid4())
conn.execute(
"INSERT INTO playlists (id, name, description, api_project_id, is_linked, server_permission) "
"VALUES (?,?,?,?,1,'view')",
(pl_id, f"{name} ({owner})", "", server_id)
)
pl_id = cur.lastrowid
position = 1
for song_data in pl.get("songs", []):
server_song_id = song_data.get("song_id", "")
title = song_data.get("title", "")
artist = song_data.get("artist", "")
if not title:
if not title and not server_song_id:
continue
local = conn.execute(
"SELECT id FROM songs WHERE title=? AND artist=? LIMIT 1",
(title, artist)
).fetchone()
if not local:
import uuid
new_id = str(uuid.uuid4())
conn.execute(
"INSERT OR IGNORE INTO songs (id, title, artist, file_missing) VALUES (?,?,?,1)",
(new_id, title, artist)
local_song_id = self._find_or_create_song_local(
conn, server_song_id, title, artist,
mbid=song_data.get("mbid", ""),
acoustid=song_data.get("acoustid", ""),
)
local_id = new_id
else:
local_id = local["id"]
file_row = conn.execute(
"SELECT id FROM files WHERE song_id=? AND file_missing=0 LIMIT 1",
(local_song_id,)
).fetchone()
file_id = file_row["id"] if file_row else None
conn.execute("""
INSERT OR IGNORE INTO playlist_songs
(playlist_id, song_id, position, status, is_workshop, dance_override)
VALUES (?,?,?,?,?,?)
""", (pl_id, local_id, position,
INSERT INTO playlist_songs
(id, playlist_id, song_id, file_id, position, status, is_workshop, dance_override)
VALUES (?,?,?,?,?,?,?,?)
""", (str(uuid.uuid4()), pl_id, local_song_id, file_id, position,
song_data.get("status","pending"),
1 if song_data.get("is_workshop") else 0,
song_data.get("dance_override","") or ""))
position += 1
# Gem community alternativ-danse lokalt
conn.execute(
"CREATE TABLE IF NOT EXISTS community_alt_dances ("
"id TEXT PRIMARY KEY, song_id TEXT NOT NULL, dance_id INTEGER NOT NULL, "
"avg_rating REAL NOT NULL DEFAULT 0, rating_count INTEGER NOT NULL DEFAULT 0, "
"my_rating INTEGER, UNIQUE(song_id, dance_id))"
)
for ca in data.get("community_alts", []):
if not ca.get("dance_name"):
continue
song_row = None
if ca.get("song_mbid"):
song_row = conn.execute(
"SELECT id FROM songs WHERE mbid=?", (ca["song_mbid"],)
).fetchone()
if not song_row and ca.get("song_title"):
song_row = conn.execute(
"SELECT id FROM songs WHERE title=? AND artist=?",
(ca["song_title"], ca.get("song_artist", ""))
).fetchone()
if not song_row:
continue
song_id = song_row["id"]
dance_row = conn.execute(
"SELECT id FROM dances WHERE name=? COLLATE NOCASE LIMIT 1",
(ca["dance_name"],)
).fetchone()
if not dance_row:
cur = conn.execute(
"INSERT OR IGNORE INTO dances (name) VALUES (?)", (ca["dance_name"],)
)
dance_id = cur.lastrowid
else:
dance_id = dance_row["id"]
if not dance_id:
continue
conn.execute(
"INSERT INTO community_alt_dances "
"(id, song_id, dance_id, avg_rating, rating_count, my_rating) "
"VALUES (?,?,?,?,?,?) "
"ON CONFLICT(song_id, dance_id) DO UPDATE SET "
"avg_rating=excluded.avg_rating, rating_count=excluded.rating_count, "
"my_rating=COALESCE(excluded.my_rating, my_rating)",
(str(uuid.uuid4()), song_id, dance_id,
ca.get("avg_rating", 0), ca.get("rating_count", 0),
ca.get("my_rating"))
)
# Importer sang-dans tags fra server
for st in data.get("song_tags", []):
server_song_id = st.get("song_id", "")
dance_name = st.get("dance_name", "")
dance_order = st.get("dance_order", 1)
choreo = st.get("choreographer", "") or ""
if not server_song_id or not dance_name:
continue
# Find lokal sang
local_song = conn.execute(
"SELECT id FROM songs WHERE id=?", (server_song_id,)
).fetchone()
if not local_song:
continue
# Find dans
dance_row = conn.execute(
"SELECT id FROM dances WHERE name=? COLLATE NOCASE "
"AND choreographer=? LIMIT 1",
(dance_name, choreo)
).fetchone()
if not dance_row:
cur = conn.execute(
"INSERT OR IGNORE INTO dances (name, choreographer) VALUES (?,?)",
(dance_name, choreo)
)
dance_id = cur.lastrowid
else:
dance_id = dance_row["id"]
# Tilføj sang-dans tag hvis ikke allerede der
existing_sd = conn.execute(
"SELECT id FROM song_dances WHERE song_id=? AND dance_id=?",
(server_song_id, dance_id)
).fetchone()
if not existing_sd:
conn.execute(
"INSERT OR IGNORE INTO song_dances (id, song_id, dance_id, dance_order) "
"VALUES (?,?,?,?)",
(str(uuid.uuid4()), server_song_id, dance_id, dance_order)
)
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def _find_or_create_song_local(self, conn, server_song_id: str, title: str,
artist: str = "", mbid: str = "",
acoustid: str = "", bpm: int = 0,
duration_sec: int = 0) -> str:
"""Find eller opret sang lokalt. Returnerer lokal song_id."""
import uuid
# Match på server-ID
if server_song_id:
row = conn.execute(
"SELECT id FROM songs WHERE id=?", (server_song_id,)
).fetchone()
if row:
return row["id"]
# Match på MBID
if mbid:
row = conn.execute(
"SELECT id FROM songs WHERE mbid=?", (mbid,)
).fetchone()
if row:
return row["id"]
# Match på AcoustID
if acoustid:
row = conn.execute(
"SELECT id FROM songs WHERE acoustid=?", (acoustid,)
).fetchone()
if row:
return row["id"]
# Match på titel + artist
if title:
row = conn.execute(
"SELECT id FROM songs WHERE title=? AND artist=?", (title, artist)
).fetchone()
if row:
return row["id"]
# Opret ny — brug server-ID hvis tilgængeligt
new_id = server_song_id or str(uuid.uuid4())
conn.execute(
"INSERT INTO songs (id, title, artist, bpm, duration_sec, mbid, acoustid, server_synced) "
"VALUES (?,?,?,?,?,?,?,1)",
(new_id, title, artist, bpm, duration_sec, mbid or None, acoustid or None)
)
logger.info(f"Pull: oprettet sang '{title}' ({new_id})")
return new_id

View File

@@ -412,12 +412,19 @@ def read_dances_from_file(path: str | Path) -> list[str]:
# ── BPM-analyse ───────────────────────────────────────────────────────────────
# Formater der ikke understøttes af librosa uden ffmpeg
_BPM_UNSUPPORTED = {".wma", ".ac3", ".dts", ".ra", ".rm", ".rmvb"}
def analyze_bpm(path: str | Path) -> float | None:
"""
Analysér BPM fra lydfilen ved hjælp af librosa.
Returnerer BPM som float eller None ved fejl.
Tager 2-5 sekunder per sang — kør i baggrundstråd.
"""
suffix = Path(path).suffix.lower()
if suffix in _BPM_UNSUPPORTED:
logger.debug(f"BPM-analyse ikke understøttet for {suffix}: {path}")
return None
try:
import librosa
# Indlæs kun de første 60 sekunder for hastighed

View File

@@ -8,7 +8,15 @@ Start:
import sys
import os
APP_VERSION = "0.8.3"
APP_VERSION = "0.9.5"
# VLC setup — skal ske FØR vlc importeres
if getattr(sys, 'frozen', False):
_app_dir = os.path.dirname(sys.executable)
_libvlc = os.path.join(_app_dir, 'libvlc.dll')
if os.path.exists(_libvlc):
os.environ['PYTHON_VLC_LIB_PATH'] = _libvlc
os.environ['VLC_PLUGIN_PATH'] = os.path.join(_app_dir, 'plugins')
sys.path.insert(0, os.path.dirname(__file__))

View File

@@ -0,0 +1,345 @@
"""
alt_dance_picker_dialog.py — Vælg alternativ dans til en sang i playlisten.
Tre sektioner:
🟢 Mine egne alternativ-danse med min rating
🟡 Community alternativ-danse med community + min rating
Alle andre danse
"""
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
QPushButton, QListWidget, QListWidgetItem, QWidget,
)
from PyQt6.QtCore import Qt, QTimer, pyqtSignal
from PyQt6.QtGui import QColor
STAR_FULL = ""
STAR_EMPTY = ""
GREEN = "#27ae60"
YELLOW = "#e8a020"
MUTED = "#5a6070"
class StarRatingWidget(QWidget):
"""Klikbar stjerne-rating widget til brug i lister."""
rating_changed = pyqtSignal(int) # 1-5
def __init__(self, rating=None, max_stars=5, color=YELLOW, parent=None):
# YELLOW er ikke defineret endnu ved import — bruges som string nedenfor
super().__init__(parent)
self._rating = rating
self._max = max_stars
self._color = color
self._btns = []
layout = QHBoxLayout(self)
layout.setContentsMargins(2, 0, 2, 0)
layout.setSpacing(1)
for i in range(1, max_stars + 1):
btn = QPushButton("" if rating and i <= rating else "")
btn.setFixedSize(18, 18)
btn.setStyleSheet(f"""
QPushButton {{
font-size: 13px; border: none; background: none; padding: 0;
color: {color if rating and i <= rating else '#5a6070'};
}}
QPushButton:hover {{ color: {color}; }}
""")
btn.clicked.connect(lambda checked, r=i: self._on_click(r))
layout.addWidget(btn)
self._btns.append(btn)
def _on_click(self, r):
self._rating = r
for i, btn in enumerate(self._btns):
filled = i < r
btn.setText("" if filled else "")
btn.setStyleSheet(f"""
QPushButton {{
font-size: 13px; border: none; background: none; padding: 0;
color: {self._color if filled else '#5a6070'};
}}
QPushButton:hover {{ color: {self._color}; }}
""")
self.rating_changed.emit(r)
def get_rating(self):
return self._rating
def make_stars(rating, max_stars=5):
if not rating:
return STAR_EMPTY * max_stars
full = min(max_stars, round(float(rating)))
return STAR_FULL * full + STAR_EMPTY * (max_stars - full)
class AltDancePickerDialog(QDialog):
def __init__(self, song: dict, parent=None):
super().__init__(parent)
self._song = song
self._chosen_dance = ""
self._chosen_rating = None
self._cleared = False
self.setWindowTitle("Vælg alternativ dans")
self.setMinimumWidth(600)
self.setMinimumHeight(520)
self._build_ui()
self._load_suggestions("")
def _build_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(8)
# Sang-info
title = self._song.get("title", "?")
artist = self._song.get("artist", "")
lbl = QLabel(f"{title} · {artist}" if artist else title)
lbl.setObjectName("track_title")
lbl.setWordWrap(True)
layout.addWidget(lbl)
# Søgefelt
self._edit = QLineEdit()
self._edit.setPlaceholderText("Søg dans-navn...")
self._edit.textChanged.connect(self._on_text_changed)
self._edit.returnPressed.connect(self._on_accept)
layout.addWidget(self._edit)
# Forslagsliste
self._list = QListWidget()
self._list.setMinimumHeight(320)
self._list.itemClicked.connect(self._on_item_clicked)
self._list.itemDoubleClicked.connect(self._on_selected)
layout.addWidget(self._list)
# Info-label
self._info_lbl = QLabel("")
self._info_lbl.setObjectName("result_count")
self._info_lbl.setWordWrap(True)
layout.addWidget(self._info_lbl)
# Debounce timer
self._timer = QTimer(self)
self._timer.setSingleShot(True)
self._timer.setInterval(150)
self._timer.timeout.connect(
lambda: self._load_suggestions(self._edit.text().strip())
)
# Knapper
btn_row = QHBoxLayout()
btn_none = QPushButton("✕ Ingen alternativ")
btn_none.clicked.connect(self._on_clear)
btn_row.addWidget(btn_none)
btn_row.addStretch()
btn_cancel = QPushButton("Annuller")
btn_cancel.clicked.connect(self.reject)
btn_row.addWidget(btn_cancel)
btn_ok = QPushButton("✓ Vælg")
btn_ok.setObjectName("btn_play")
btn_ok.clicked.connect(self._on_accept)
btn_row.addWidget(btn_ok)
layout.addLayout(btn_row)
self._edit.setFocus()
def _on_text_changed(self):
self._timer.start()
def _make_sep(self, text):
sep = QListWidgetItem(text)
sep.setForeground(QColor(MUTED))
sep.setFlags(Qt.ItemFlag.ItemIsEnabled)
sep.setData(Qt.ItemDataRole.UserRole, None)
return sep
def _load_suggestions(self, prefix):
try:
from local.local_db import (
get_alt_dances_for_song_with_ratings,
get_community_alts_for_song,
get_dance_suggestions,
)
self._list.clear()
song_id = self._song.get("id", "")
# ── Mine egne alternativ-danse ──
own_alts = get_alt_dances_for_song_with_ratings(song_id)
own_names = {a["name"].lower() for a in own_alts}
matching_own = [a for a in own_alts
if not prefix or prefix.lower() in a["name"].lower()]
if matching_own:
self._list.addItem(self._make_sep(
f"── 🟢 Mine alternativ-danse ──"
))
for a in matching_own:
my_r = a.get("user_rating")
my_s = make_stars(my_r)
name = a["name"]
level = a.get("level_name", "")
disp = f"{name} / {level}" if level else name
# Venstre: navn, højre: mine stjerner
label = f"🟢 {disp:<40} {my_s}"
item = QListWidgetItem()
item.setSizeHint(__import__('PyQt6.QtCore', fromlist=['QSize']).QSize(0, 34))
item.setData(Qt.ItemDataRole.UserRole, {
"name": name, "level": level,
"choreo": a.get("choreographer", ""),
"my_rating": my_r, "comm_rating": None,
"dance_id": a["id"], "is_own": True,
})
self._list.addItem(item)
# Widget med navn + klikbare stjerner
w = QWidget()
wl = QHBoxLayout(w)
wl.setContentsMargins(4, 0, 4, 0)
wl.setSpacing(6)
lbl_name = QLabel(f"🟢 {disp}")
lbl_name.setStyleSheet(f"color: {GREEN};")
wl.addWidget(lbl_name, stretch=1)
stars_w = StarRatingWidget(my_r, color=GREEN)
stars_w.rating_changed.connect(
lambda r, song_id=self._song.get("id",""), d_id=a["id"]:
self._save_rating(song_id, d_id, r)
)
wl.addWidget(stars_w)
self._list.setItemWidget(item, w)
# ── Community alternativ-danse ──
comm_alts = get_community_alts_for_song(song_id)
matching_comm = [c for c in comm_alts
if (not prefix or prefix.lower() in c["name"].lower())
and c["name"].lower() not in own_names]
if matching_comm:
self._list.addItem(self._make_sep("── 🟡 Community ──"))
for c in matching_comm:
comm_r = c.get("avg_rating")
my_r = c.get("my_rating")
from PyQt6.QtCore import QSize
name = c["name"]
level = c.get("level_name", "")
disp = f"{name} / {level}" if level else name
item = QListWidgetItem()
item.setSizeHint(QSize(0, 34))
item.setData(Qt.ItemDataRole.UserRole, {
"name": name, "level": level,
"choreo": c.get("choreographer", ""),
"my_rating": my_r, "comm_rating": comm_r,
"dance_id": c["id"], "is_community": True,
})
self._list.addItem(item)
# Widget: navn + community stjerner (ikke klikbare) + mine (klikbare)
w = QWidget()
wl = QHBoxLayout(w)
wl.setContentsMargins(4, 0, 4, 0)
wl.setSpacing(6)
lbl_name = QLabel(f"🟡 {disp}")
lbl_name.setStyleSheet(f"color: {YELLOW};")
wl.addWidget(lbl_name, stretch=1)
# Community rating — read-only label
comm_lbl = QLabel(make_stars(comm_r) if comm_r else "☆☆☆☆☆")
comm_lbl.setStyleSheet(f"color: {YELLOW}; font-size: 13px;")
comm_lbl.setToolTip(f"Community: {comm_r:.1f}/5" if comm_r else "Ingen community rating")
wl.addWidget(comm_lbl)
# Min rating — klikbar
my_stars_w = StarRatingWidget(my_r, color=GREEN)
my_stars_w.rating_changed.connect(
lambda r, song_id=self._song.get("id",""), d_id=c["id"]:
self._save_rating(song_id, d_id, r)
)
wl.addWidget(my_stars_w)
self._list.setItemWidget(item, w)
# ── Alle danse ──
suggestions = get_dance_suggestions(prefix or "", limit=20)
if suggestions:
self._list.addItem(self._make_sep("── Alle danse ──"))
for s in suggestions:
s = dict(s)
name = s["name"]
is_own = name.lower() in own_names
is_comm = any(c["name"].lower() == name.lower() for c in comm_alts)
icon = "🟢 " if is_own else "🟡 " if is_comm else " "
color = GREEN if is_own else YELLOW if is_comm else "#eceef4"
disp = name
if s.get("level_name"):
disp += f" / {s['level_name']}"
if s.get("choreographer"):
disp += f" · {s['choreographer']}"
item = QListWidgetItem(f"{icon}{disp}")
item.setForeground(QColor(color))
item.setData(Qt.ItemDataRole.UserRole, {
"name": name,
"level": s.get("level_name", ""),
"choreo": s.get("choreographer", ""),
"my_rating": None, "comm_rating": None,
"dance_id": s.get("id"),
})
self._list.addItem(item)
except Exception as e:
import logging
logging.getLogger(__name__).warning(
f"AltDancePicker fejl: {e}", exc_info=True
)
def _on_item_clicked(self, item):
data = item.data(Qt.ItemDataRole.UserRole)
if not data:
return
self._chosen_dance = data.get("name", "")
self._edit.setText(self._chosen_dance)
parts = []
if data.get("level"):
parts.append(data["level"])
if data.get("choreo"):
parts.append(data["choreo"])
info = " · ".join(parts)
comm_r = data.get("comm_rating")
my_r = data.get("my_rating")
if comm_r:
info += f" 🟡 Community: {make_stars(comm_r)} ({comm_r:.1f})"
if my_r:
info += f" 🟢 Min: {make_stars(my_r)}"
self._info_lbl.setText(info)
def _on_selected(self, item):
data = item.data(Qt.ItemDataRole.UserRole)
if not data:
return
self._on_item_clicked(item)
self._on_accept()
def _on_accept(self):
self._chosen_dance = self._edit.text().strip()
self.accept()
def _save_rating(self, song_id: str, dance_id: int, rating: int):
"""Gem rating direkte fra stjerne-widget i listen."""
try:
from local.local_db import get_db
with get_db() as conn:
conn.execute(
"UPDATE song_alt_dances SET user_rating=? WHERE song_id=? AND dance_id=?",
(rating, song_id, dance_id)
)
except Exception as e:
import logging
logging.getLogger(__name__).warning(f"save_rating fejl: {e}")
def _on_clear(self):
self._chosen_dance = ""
self._chosen_rating = None
self._cleared = True
self.accept()
def get_dance(self) -> str:
return self._chosen_dance
def get_rating(self):
return self._chosen_rating
def was_cleared(self) -> bool:
return self._cleared

View File

@@ -1,5 +1,6 @@
"""
bpm_worker.py — QThread til BPM-analyse i baggrunden.
Ny v0.9 arkitektur: sange er i songs, filer i files, libraries i libraries.
"""
import sqlite3
from PyQt6.QtCore import QThread, pyqtSignal
@@ -15,10 +16,10 @@ class BpmScanWorker(QThread):
self._library_id = library_id
self._db_path = db_path
self._scan_all = scan_all
self._cancelled = False
def cancel(self):
self.requestInterruption()
# Afbryd hurtigt ved at sætte et flag
self._cancelled = True
def run(self):
@@ -28,20 +29,34 @@ class BpmScanWorker(QThread):
from local.tag_reader import analyze_bpm
conn = sqlite3.connect(self._db_path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
# Ny arkitektur: JOIN songs + files + libraries
lib_row = conn.execute(
"SELECT path FROM libraries WHERE id=?", (self._library_id,)
).fetchone()
if not lib_row:
self.finished.emit(0)
conn.close()
return
lib_path = lib_row["path"]
if self._scan_all:
songs = conn.execute(
"SELECT id, local_path FROM songs "
"WHERE library_id=? AND file_missing=0",
(self._library_id,)
).fetchall()
songs = conn.execute("""
SELECT s.id, f.local_path
FROM songs s
JOIN files f ON f.song_id = s.id AND f.file_missing = 0
WHERE f.local_path LIKE ?
""", (lib_path + "%",)).fetchall()
else:
songs = conn.execute(
"SELECT id, local_path FROM songs "
"WHERE library_id=? AND file_missing=0 "
"AND (bpm IS NULL OR bpm=0)",
(self._library_id,)
).fetchall()
songs = conn.execute("""
SELECT s.id, f.local_path
FROM songs s
JOIN files f ON f.song_id = s.id AND f.file_missing = 0
WHERE f.local_path LIKE ?
AND (s.bpm IS NULL OR s.bpm = 0)
""", (lib_path + "%",)).fetchall()
total = len(songs)
done = 0
@@ -61,9 +76,9 @@ class BpmScanWorker(QThread):
pass
done += 1
self.progress.emit(done, total)
time.sleep(0.01) # Yield så GUI ikke hænger
time.sleep(0.01)
conn.close()
self.finished.emit(done)
except Exception as e:
except Exception:
self.finished.emit(0)

View File

@@ -31,11 +31,10 @@ class DanceInfoDialog(QDialog):
def _load_dances(self):
try:
from local.local_db import get_dances_for_song, get_alt_dances_for_song, new_conn
conn = new_conn()
from local.local_db import get_db
with get_db() as conn:
rows = conn.execute("""
SELECT d.id, d.name, d.level_id, d.choreographer,
SELECT d.id, d.name, d.choreographer,
d.video_url, d.stepsheet_url, d.notes,
dl.name as level_name
FROM song_dances sd
@@ -56,9 +55,8 @@ class DanceInfoDialog(QDialog):
"is_alt": False,
})
# Alternativ-danse
alt_rows = conn.execute("""
SELECT d.id, d.name, d.level_id, d.choreographer,
SELECT d.id, d.name, d.choreographer,
d.video_url, d.stepsheet_url, d.notes,
dl.name as level_name
FROM song_alt_dances sad
@@ -78,7 +76,6 @@ class DanceInfoDialog(QDialog):
"notes": row["notes"] or "",
"is_alt": True,
})
conn.close()
except Exception as e:
print(f"DanceInfoDialog load fejl: {e}")
@@ -204,15 +201,14 @@ class DanceInfoDialog(QDialog):
def _save(self):
self._save_to_cache(self._current_idx)
try:
from local.local_db import update_dance_info
from local.local_db import get_db
with get_db() as conn:
for d in self._dances:
update_dance_info(
d["dance_id"],
choreographer = d["choreographer"],
video_url = d["video_url"],
stepsheet_url = d["stepsheet_url"],
notes = d["notes"],
)
conn.execute("""
UPDATE dances SET choreographer=?, video_url=?,
stepsheet_url=?, notes=? WHERE id=?
""", (d["choreographer"], d["video_url"],
d["stepsheet_url"], d["notes"], d["dance_id"]))
self.accept()
except Exception as e:
QMessageBox.warning(self, "Fejl", f"Kunne ikke gemme: {e}")

View File

@@ -1,5 +1,7 @@
"""
dance_picker_dialog.py — Dialog til at vælge dans og koreograf med autoudfyld.
dance_picker_dialog.py — Simpel dans-vælger til danselisten.
Viser dansenavn primært. Niveau og koreograf vises som info hvis tilgængeligt.
Ingen redigering af metadata — det hører til i tag-editoren i biblioteket.
"""
from PyQt6.QtWidgets import (
@@ -10,18 +12,18 @@ from PyQt6.QtCore import Qt, QTimer
class DancePickerDialog(QDialog):
def __init__(self, current_dance: str = "", current_choreo: str = "",
song_title: str = "", parent=None):
def __init__(self, current_dance: str = "", song_title: str = "",
existing_dances: list[str] = None, parent=None):
super().__init__(parent)
self._chosen_dance = current_dance
self._chosen_choreo = current_choreo
self._existing_dances = existing_dances or []
self.setWindowTitle("Vælg dans")
self.setMinimumWidth(400)
self.setFixedWidth(440)
self._build_ui(current_dance, current_choreo, song_title)
self._load_dance_suggestions("")
self.setMinimumWidth(420)
self.setFixedWidth(460)
self._build_ui(current_dance, song_title)
self._load_suggestions("")
def _build_ui(self, current_dance: str, current_choreo: str, song_title: str):
def _build_ui(self, current_dance: str, song_title: str):
layout = QVBoxLayout(self)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(8)
@@ -32,65 +34,38 @@ class DancePickerDialog(QDialog):
lbl.setWordWrap(True)
layout.addWidget(lbl)
# ── Dans ──────────────────────────────────────────────────────────────
lbl2 = QLabel("Dans:")
lbl2.setObjectName("track_meta")
layout.addWidget(lbl2)
layout.addWidget(QLabel("Dans:"))
self._edit_dance = QLineEdit()
self._edit_dance.setText(current_dance)
self._edit_dance.setPlaceholderText("Skriv dans-navn...")
self._edit_dance.selectAll()
self._edit_dance.textChanged.connect(self._on_dance_text_changed)
self._edit_dance.returnPressed.connect(lambda: self._edit_choreo.setFocus())
layout.addWidget(self._edit_dance)
self._edit = QLineEdit()
self._edit.setText(current_dance)
self._edit.setPlaceholderText("Skriv dans-navn...")
self._edit.selectAll()
self._edit.textChanged.connect(self._on_text_changed)
self._edit.returnPressed.connect(self._on_accept)
layout.addWidget(self._edit)
self._dance_list = QListWidget()
self._dance_list.setMaximumHeight(160)
self._dance_list.itemDoubleClicked.connect(self._on_dance_selected)
self._dance_list.itemClicked.connect(
lambda item: self._edit_dance.setText(
item.data(Qt.ItemDataRole.UserRole) or item.text().split(" / ")[0]
)
)
layout.addWidget(self._dance_list)
# Forslagsliste
self._list = QListWidget()
self._list.setMinimumHeight(200)
self._list.itemDoubleClicked.connect(self._on_selected)
self._list.itemClicked.connect(self._on_item_clicked)
layout.addWidget(self._list)
# ── Koreograf ─────────────────────────────────────────────────────────
lbl3 = QLabel("Koreograf (valgfri):")
lbl3.setObjectName("track_meta")
layout.addWidget(lbl3)
# Info-label — viser niveau/koreograf for valgt dans
self._info_lbl = QLabel("")
self._info_lbl.setObjectName("result_count")
self._info_lbl.setWordWrap(True)
layout.addWidget(self._info_lbl)
self._edit_choreo = QLineEdit()
self._edit_choreo.setText(current_choreo)
self._edit_choreo.setPlaceholderText("Koreografens navn...")
self._edit_choreo.textChanged.connect(self._on_choreo_text_changed)
self._edit_choreo.returnPressed.connect(self._on_accept)
layout.addWidget(self._edit_choreo)
self._choreo_list = QListWidget()
self._choreo_list.setMaximumHeight(100)
self._choreo_list.itemDoubleClicked.connect(self._on_choreo_selected)
self._choreo_list.itemClicked.connect(
lambda item: self._edit_choreo.setText(item.text())
)
layout.addWidget(self._choreo_list)
# ── Debounce timere ───────────────────────────────────────────────────
self._dance_timer = QTimer(self)
self._dance_timer.setSingleShot(True)
self._dance_timer.setInterval(200)
self._dance_timer.timeout.connect(
lambda: self._load_dance_suggestions(self._edit_dance.text().strip())
# Debounce timer
self._timer = QTimer(self)
self._timer.setSingleShot(True)
self._timer.setInterval(150)
self._timer.timeout.connect(
lambda: self._load_suggestions(self._edit.text().strip())
)
self._choreo_timer = QTimer(self)
self._choreo_timer.setSingleShot(True)
self._choreo_timer.setInterval(200)
self._choreo_timer.timeout.connect(
lambda: self._load_choreo_suggestions(self._edit_choreo.text().strip())
)
# ── Knapper ───────────────────────────────────────────────────────────
# Knapper
btn_row = QHBoxLayout()
btn_row.addStretch()
btn_cancel = QPushButton("Annuller")
@@ -102,62 +77,102 @@ class DancePickerDialog(QDialog):
btn_row.addWidget(btn_ok)
layout.addLayout(btn_row)
self._edit_dance.setFocus()
self._edit.setFocus()
def _on_dance_text_changed(self):
self._dance_timer.start()
def _on_text_changed(self):
self._timer.start()
def _on_choreo_text_changed(self):
self._choreo_timer.start()
def _load_dance_suggestions(self, prefix: str):
def _load_suggestions(self, prefix: str):
try:
from local.local_db import get_dance_suggestions
suggestions = get_dance_suggestions(prefix or "", limit=20)
self._dance_list.clear()
from PyQt6.QtGui import QColor
suggestions = get_dance_suggestions(prefix or "", limit=25)
self._list.clear()
# Allerøverst: mulighed for at fjerne dans
no_dance = QListWidgetItem("✕ Ingen dans")
no_dance.setForeground(QColor("#5a6070"))
no_dance.setData(Qt.ItemDataRole.UserRole, {"name": ""})
self._list.addItem(no_dance)
# Øverst: danse registreret på denne sang
if self._existing_dances:
# Filtrer på prefix hvis der skrives
matching = [d for d in self._existing_dances
if not prefix or prefix.lower() in d.lower()]
if matching:
# Separator-header
sep = QListWidgetItem("── Registreret på denne sang ──")
sep.setForeground(QColor("#5a6070"))
sep.setFlags(Qt.ItemFlag.ItemIsEnabled) # synlig men ikke valgbar
sep.setData(Qt.ItemDataRole.UserRole, None)
self._list.addItem(sep)
for name in matching:
item = QListWidgetItem(f"{name}")
item.setData(Qt.ItemDataRole.UserRole, {"name": name})
item.setForeground(QColor("#e8a020"))
self._list.addItem(item)
# Separator for alle danse
if suggestions:
sep2 = QListWidgetItem("── Alle danse ──")
sep2.setForeground(QColor("#5a6070"))
sep2.setFlags(Qt.ItemFlag.ItemIsEnabled) # synlig men ikke valgbar
sep2.setData(Qt.ItemDataRole.UserRole, None)
self._list.addItem(sep2)
for s in suggestions:
label = f"{s['name']} / {s['level_name']}" if s.get("level_name") else s["name"]
if s.get("choreographer"):
label += f" ({s['choreographer']})"
s = dict(s)
name = s["name"]
level = s.get("level_name") or ""
choreo = s.get("choreographer") or ""
parts = [name]
if level:
parts.append(level)
if choreo:
parts.append(choreo)
label = " / ".join(parts)
item = QListWidgetItem(label)
item.setData(Qt.ItemDataRole.UserRole, s["name"])
item.setData(Qt.ItemDataRole.UserRole + 1, s.get("choreographer", ""))
self._dance_list.addItem(item)
except Exception:
pass
item.setData(Qt.ItemDataRole.UserRole, {
"name": name,
"level": level,
"choreo": choreo,
})
self._list.addItem(item)
except Exception as e:
import logging
logging.getLogger(__name__).warning(f'Dans-forslag fejl: {e}', exc_info=True)
def _load_choreo_suggestions(self, prefix: str):
try:
from local.local_db import get_choreographer_suggestions
suggestions = get_choreographer_suggestions(prefix or "", limit=15)
self._choreo_list.clear()
for name in suggestions:
self._choreo_list.addItem(QListWidgetItem(name))
except Exception:
pass
def _on_item_clicked(self, item: QListWidgetItem):
data = item.data(Qt.ItemDataRole.UserRole)
if not data: # separator — ignorer
return
name = data.get("name", "")
level = data.get("level", "")
choreo = data.get("choreo", "")
self._edit.setText(name)
# Vis info
parts = []
if level:
parts.append(level)
if choreo:
parts.append(choreo)
self._info_lbl.setText(" · ".join(parts) if parts else "")
def _on_dance_selected(self, item: QListWidgetItem):
name = item.data(Qt.ItemDataRole.UserRole) or item.text().split(" / ")[0]
choreo = item.data(Qt.ItemDataRole.UserRole + 1) or ""
self._edit_dance.setText(name)
if choreo and not self._edit_choreo.text().strip():
self._edit_choreo.setText(choreo)
self._chosen_dance = name
self._chosen_choreo = self._edit_choreo.text().strip()
self.accept()
def _on_choreo_selected(self, item: QListWidgetItem):
self._edit_choreo.setText(item.text())
self._choreo_list.clear()
def _on_selected(self, item: QListWidgetItem):
data = item.data(Qt.ItemDataRole.UserRole)
if not data: # separator
return
self._on_item_clicked(item)
self._on_accept()
def _on_accept(self):
self._chosen_dance = self._edit_dance.text().strip()
self._chosen_choreo = self._edit_choreo.text().strip()
if self._chosen_dance:
self.accept()
self._chosen_dance = self._edit.text().strip()
self.accept() # tillad tom streng = ingen dans
def get_dance(self) -> str:
return self._chosen_dance
# Behold get_choreo for bagudkompatibilitet — returnerer altid ""
def get_choreo(self) -> str:
return self._chosen_choreo
return ""

View File

@@ -79,7 +79,7 @@ class LibraryManagerDialog(QDialog):
conn = sqlite3.connect(self._db_path)
conn.row_factory = sqlite3.Row
libs = conn.execute(
"SELECT id, path, last_full_scan FROM libraries "
"SELECT id, path FROM libraries "
"WHERE is_active=1 ORDER BY path"
).fetchall()
@@ -87,15 +87,16 @@ class LibraryManagerDialog(QDialog):
bpm_missing = {}
for lib in libs:
counts[lib["id"]] = conn.execute(
"SELECT COUNT(*) FROM songs "
"WHERE library_id=? AND file_missing=0",
(lib["id"],)
"SELECT COUNT(*) FROM files "
"WHERE file_missing=0 AND local_path LIKE ?",
(lib["path"] + "%",)
).fetchone()[0]
bpm_missing[lib["id"]] = conn.execute(
"SELECT COUNT(*) FROM songs "
"WHERE library_id=? AND file_missing=0 "
"AND (bpm IS NULL OR bpm=0)",
(lib["id"],)
"SELECT COUNT(*) FROM files f "
"JOIN songs s ON s.id = f.song_id "
"WHERE f.file_missing=0 AND f.local_path LIKE ? "
"AND (s.bpm IS NULL OR s.bpm=0)",
(lib["path"] + "%",)
).fetchone()[0]
conn.close()
@@ -122,9 +123,7 @@ class LibraryManagerDialog(QDialog):
lib_id = lib["id"]
path = lib["path"]
exists = Path(path).exists()
last = lib.get("last_full_scan") or "aldrig"
if isinstance(last, str) and len(last) > 16:
last = last[:16]
last = ""
frame = QFrame()
frame.setObjectName("track_display")
@@ -246,11 +245,12 @@ class LibraryManagerDialog(QDialog):
try:
conn = sqlite3.connect(self._db_path)
# Slet sange fra biblioteket
# Marker filer fra denne mappe som missing
conn.execute(
"DELETE FROM songs WHERE library_id=?", (lib["id"],)
"UPDATE files SET file_missing=1 WHERE local_path LIKE ?",
(lib["path"] + "%",)
)
# Slet selve biblioteks-rækken helt
# Slet selve biblioteks-rækken
conn.execute(
"DELETE FROM libraries WHERE id=?", (lib["id"],)
)

View File

@@ -549,9 +549,11 @@ class LibraryPanel(QWidget):
self._bpm_worker.start()
def _refresh_library(self):
"""Genindlæs bibliotek fra database."""
"""Opdater fil-tilgængelighed og genindlæs bibliotek."""
mw = self.window()
if hasattr(mw, "_reload_library"):
if hasattr(mw, "_run_availability_check"):
mw._run_availability_check()
elif hasattr(mw, "_reload_library"):
mw._reload_library()
def _manage_libraries(self):

View File

@@ -379,6 +379,12 @@ class MainWindow(QMainWindow):
self._sync_periodic.timeout.connect(self._manual_sync)
self._sync_periodic.start()
# Periodisk fil-tilgængeligheds-check — opdager USB tilslutning/fjernelse
self._availability_timer = QTimer(self)
self._availability_timer.setInterval(30 * 1000) # hvert 30. sekund
self._availability_timer.timeout.connect(self._run_availability_check)
self._availability_timer.start()
self._library_panel = LibraryPanel()
self._library_panel.set_preview_player(self._preview_player)
@@ -438,9 +444,30 @@ class MainWindow(QMainWindow):
from local.local_db import init_db
init_db()
self._db_ready.emit()
# Tjek fil-tilgængelighed i separat tråd
import threading
threading.Thread(
target=self._refresh_availability, daemon=True
).start()
except Exception as e:
pass
def _refresh_availability(self):
"""Opdater file_missing for alle kendte filer og genindlæs biblioteket."""
try:
from local.local_db import refresh_file_availability
refresh_file_availability()
QTimer.singleShot(0, self._reload_library)
except Exception:
pass
def _run_availability_check(self):
"""Kør periodisk fil-check i baggrundstråd — opdager USB til/fra."""
import threading
threading.Thread(
target=self._refresh_availability, daemon=True
).start()
def _start_watcher(self):
"""Start fil-watcher i baggrundstråd — blokerer aldrig GUI."""
import threading
@@ -505,19 +532,19 @@ class MainWindow(QMainWindow):
conn.row_factory = sqlite3.Row
rows = conn.execute("""
SELECT s.id, s.title, s.artist, s.album, s.bpm,
s.duration_sec, s.local_path, s.file_format,
s.file_missing,
s.duration_sec,
f.id as file_id, f.local_path, f.file_format, f.file_missing,
GROUP_CONCAT(d.name, ',') AS dance_names,
GROUP_CONCAT(COALESCE(dl.name,''), ',') AS dance_levels,
GROUP_CONCAT(COALESCE(d.choreographer,''), ',') AS dance_choreographers,
GROUP_CONCAT(DISTINCT ad.name) AS alt_dance_names
FROM songs s
JOIN files f ON f.song_id = s.id AND f.file_missing = 0
LEFT JOIN song_dances sd ON sd.song_id = s.id
LEFT JOIN dances d ON d.id = sd.dance_id
LEFT JOIN dance_levels dl ON dl.id = d.level_id
LEFT JOIN song_alt_dances sad ON sad.song_id = s.id
LEFT JOIN dances ad ON ad.id = sad.dance_id
WHERE s.file_missing = 0
GROUP BY s.id
ORDER BY s.artist, s.title
""").fetchall()
@@ -536,6 +563,7 @@ class MainWindow(QMainWindow):
"album": row["album"],
"bpm": row["bpm"],
"duration_sec": row["duration_sec"],
"file_id": row["file_id"],
"local_path": row["local_path"],
"file_format": row["file_format"],
"file_missing": bool(row["file_missing"]),
@@ -654,6 +682,8 @@ class MainWindow(QMainWindow):
def on_one_finished(count, p):
finished_count[0] += 1
self._set_status(f"Scanning færdig — {count} filer", 4000)
# Genindlæs biblioteket når scanning er færdig
QTimer.singleShot(200, self._reload_library)
# Ryd færdige workers ud
self._scan_workers = [w for w in self._scan_workers
if w.isRunning()]
@@ -662,6 +692,9 @@ class MainWindow(QMainWindow):
worker = ScanWorker(lib["id"], lib["path"], str(DB_PATH),
overwrite_bpm=False)
worker.finished.connect(on_one_finished)
worker.batch_ready.connect(
lambda n: QTimer.singleShot(0, self._reload_library)
)
worker.start()
worker.setPriority(QThread.Priority.LowestPriority)
self._scan_workers.append(worker)
@@ -991,6 +1024,8 @@ class MainWindow(QMainWindow):
if dialog.exec():
# Genindlæs biblioteket så ændringer vises
QTimer.singleShot(200, self._reload_library)
# Push ændringer til server med det samme
QTimer.singleShot(500, self._manual_sync)
def _send_mail(self, song: dict):
import subprocess, sys, shutil, urllib.parse

View File

@@ -19,7 +19,7 @@ from PyQt6.QtGui import QColor
class PlaylistBrowserDialog(QDialog):
"""Kombineret gem/hent dialog til danselister."""
playlist_selected = pyqtSignal(int, str) # playlist_id, name
playlist_selected = pyqtSignal(str, str) # playlist_id, name
sync_requested = pyqtSignal() # bed main_window om at køre sync
def __init__(self, mode: str = "load", current_songs: list = None,
@@ -315,7 +315,9 @@ class PlaylistBrowserDialog(QDialog):
)
for i, song in enumerate(self._current_songs, start=1):
if song.get("id"):
add_song_to_playlist(pl_id, song["id"], position=i)
add_song_to_playlist(pl_id, song["id"],
file_id=song.get("file_id"),
position=i)
self.playlist_selected.emit(pl_id, name)
self.accept()
except Exception as e:
@@ -327,7 +329,9 @@ class PlaylistBrowserDialog(QDialog):
pl_id = create_playlist(name, tags=tags)
for i, song in enumerate(self._current_songs, start=1):
if song.get("id"):
add_song_to_playlist(pl_id, song["id"], position=i)
add_song_to_playlist(pl_id, song["id"],
file_id=song.get("file_id"),
position=i)
self.playlist_selected.emit(pl_id, name)
self.accept()
except Exception as e:

View File

@@ -2,6 +2,45 @@
playlist_panel.py — Danseliste med Ny/Gem/Hent knapper, autogem og event-overblik.
"""
import sys as _sys
from pathlib import Path as _Path
def _is_local_path(path: str) -> bool:
"""Returnerer True hvis stien er på et lokalt/USB-drev, False hvis netværk."""
try:
if _sys.platform == "win32":
import ctypes
drive = path[:3]
# GetDriveType: 2=Removable, 3=Fixed, 4=Remote(netværk), 5=CDROM, 6=RAMdisk
dtype = ctypes.windll.kernel32.GetDriveTypeW(drive)
return dtype not in (4,) # 4 = netværksdrev
else:
# Linux/Mac — tjek /proc/mounts
NETWORK_FS = {
"nfs", "nfs4", "cifs", "smb", "smb2", "smb3",
"fuse.sshfs", "fuse.gvfsd-fuse", "fuse.s3fs",
"davfs", "ncpfs", "afs", "glusterfs", "fuse.glusterfs",
}
try:
with open("/proc/mounts") as f:
mounts = []
for line in f:
parts = line.split()
if len(parts) >= 3:
mounts.append((parts[1], parts[2]))
# Find længste matchende mount-punkt
mounts.sort(key=lambda x: len(x[0]), reverse=True)
for mount_point, fs_type in mounts:
if path.startswith(mount_point):
return fs_type not in NETWORK_FS
except Exception:
pass
return True # Antag lokal
except Exception:
return True # Antag lokal ved fejl
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QListWidget, QListWidgetItem,
QLabel, QHBoxLayout, QPushButton, QMenu, QAbstractItemView,
@@ -50,8 +89,8 @@ class PlaylistPanel(QWidget):
self._statuses: list[str] = []
self._current_idx = -1
self._song_ended = False
self._active_playlist_id: int | None = None
self._named_playlist_id: int | None = None # den indlæste/gemte navngivne liste
self._active_playlist_id: str | None = None
self._named_playlist_id: str | None = None # den indlæste/gemte navngivne liste
self._build_ui()
self.setAcceptDrops(True)
# Autogem-timer — venter 800ms efter sidst ændring
@@ -317,7 +356,7 @@ class PlaylistPanel(QWidget):
pass
return False
def get_named_playlist_id(self) -> int | None:
def get_named_playlist_id(self) -> str | None:
return self._named_playlist_id
def next_playable_idx(self) -> int | None:
@@ -360,11 +399,14 @@ class PlaylistPanel(QWidget):
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"
import uuid as _uuid
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,
"(id, playlist_id, song_id, file_id, position, status, is_workshop, dance_override) "
"VALUES (?,?,?,?,?,?,?,?)",
(str(_uuid.uuid4()), self._named_playlist_id,
song["id"], song.get("file_id"),
i, status,
1 if song.get("is_workshop") else 0,
song.get("active_dance") or "")
)
@@ -374,7 +416,7 @@ class PlaylistPanel(QWidget):
except Exception as e:
self._lbl_autosave.setText("⚠ gemfejl")
def _save_named_playlist_id(self, pl_id: int | None):
def _save_named_playlist_id(self, pl_id: str | None):
"""Gem hvilken navngiven liste der er aktiv — til brug ved næste opstart."""
from PyQt6.QtCore import QSettings
s = QSettings("LineDance", "Player")
@@ -388,7 +430,7 @@ class PlaylistPanel(QWidget):
try:
from PyQt6.QtCore import QSettings
s = QSettings("LineDance", "Player")
pl_id = s.value("session/named_playlist_id", None, type=int)
pl_id = s.value("session/named_playlist_id", None, type=str)
if not pl_id:
return False
@@ -406,11 +448,18 @@ class PlaylistPanel(QWidget):
return False
# Hent sange med status, workshop og dans-override
# JOIN songs — sangen er altid i songs tabellen (oprettet ved pull med file_missing=1)
# file_missing betyder bare at filen ikke er på denne maskine
songs_raw = conn.execute("""
SELECT s.*, ps.position, ps.status,
ps.is_workshop, ps.dance_override
SELECT s.id, s.title, s.artist, s.album,
s.bpm, s.duration_sec,
ps.file_id,
f.local_path, f.file_format,
COALESCE(f.file_missing, 1) as file_missing,
ps.position, ps.status, ps.is_workshop, ps.dance_override
FROM playlist_songs ps
JOIN songs s ON s.id = ps.song_id
LEFT JOIN files f ON f.id = ps.file_id
WHERE ps.playlist_id=? ORDER BY ps.position
""", (pl_id,)).fetchall()
@@ -426,16 +475,16 @@ class PlaylistPanel(QWidget):
override = row["dance_override"] or ""
active_dance = override if override else (dance_names[0] if dance_names else "")
local_path = row["local_path"]
local_path = row["local_path"] or ""
file_missing = bool(row["file_missing"])
# Forsøg at finde sangen lokalt hvis den mangler
# Forsøg at finde en anden fil lokalt hvis den specifikke 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()
match = conn.execute(
"SELECT f.local_path FROM files f "
"WHERE f.song_id=? AND f.file_missing=0 LIMIT 1",
(row["id"],)
).fetchone()
if match:
local_path = match["local_path"]
file_missing = False
@@ -444,14 +493,16 @@ class PlaylistPanel(QWidget):
"id": row["id"],
"title": row["title"],
"artist": row["artist"],
"album": row["album"],
"bpm": row["bpm"],
"duration_sec": row["duration_sec"],
"album": row["album"] or "",
"bpm": row["bpm"] or 0,
"duration_sec": row["duration_sec"] or 0,
"file_id": row["file_id"] if "file_id" in row.keys() else None,
"local_path": local_path,
"file_format": row["file_format"],
"file_format": row["file_format"] or "",
"file_missing": file_missing,
"dances": dance_names,
"active_dance": active_dance,
"alt_dance": row["alt_dance_override"] if "alt_dance_override" in row.keys() else "",
"is_workshop": bool(row["is_workshop"]),
})
statuses.append(row["status"] or "pending")
@@ -547,11 +598,14 @@ class PlaylistPanel(QWidget):
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"
import uuid as _uuid
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,
"(id, playlist_id, song_id, file_id, position, status, is_workshop, dance_override) "
"VALUES (?,?,?,?,?,?,?,?)",
(str(_uuid.uuid4()), self._named_playlist_id,
song["id"], song.get("file_id"),
i, status,
1 if song.get("is_workshop") else 0,
song.get("active_dance") or "")
)
@@ -585,7 +639,7 @@ class PlaylistPanel(QWidget):
dialog.sync_requested.connect(self._request_sync)
dialog.exec()
def _load_playlist_by_id(self, pl_id: int, pl_name: str):
def _load_playlist_by_id(self, pl_id: str, pl_name: str):
try:
from local.local_db import get_db
@@ -605,11 +659,17 @@ class PlaylistPanel(QWidget):
else:
self._can_edit_server = False
with get_db() as conn:
# JOIN songs — sangen er altid i songs tabellen (oprettet ved pull med file_missing=1)
songs_raw = conn.execute("""
SELECT s.*, ps.position, ps.status,
ps.is_workshop, ps.dance_override
SELECT s.id, s.title, s.artist, s.album,
s.bpm, s.duration_sec,
ps.file_id,
f.local_path, f.file_format,
COALESCE(f.file_missing, 1) as file_missing,
ps.position, ps.status, ps.is_workshop, ps.dance_override
FROM playlist_songs ps
JOIN songs s ON s.id = ps.song_id
LEFT JOIN files f ON f.id = ps.file_id
WHERE ps.playlist_id=? ORDER BY ps.position
""", (pl_id,)).fetchall()
songs = []
@@ -625,16 +685,16 @@ class PlaylistPanel(QWidget):
override = row["dance_override"] or ""
active_dance = override if override else (dance_names[0] if dance_names else "")
local_path = row["local_path"]
local_path = row["local_path"] or ""
file_missing = bool(row["file_missing"])
# Forsøg at finde sangen lokalt hvis den mangler
# Forsøg at finde en anden fil lokalt hvis den specifikke 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()
match = conn.execute(
"SELECT f.local_path FROM files f "
"WHERE f.song_id=? AND f.file_missing=0 LIMIT 1",
(row["id"],)
).fetchone()
if match:
local_path = match["local_path"]
file_missing = False
@@ -644,11 +704,12 @@ class PlaylistPanel(QWidget):
"id": row["id"],
"title": row["title"],
"artist": row["artist"],
"album": row["album"],
"bpm": row["bpm"],
"duration_sec": row["duration_sec"],
"album": row["album"] or "",
"bpm": row["bpm"] or 0,
"duration_sec": row["duration_sec"] or 0,
"file_id": row["file_id"] if "file_id" in row.keys() else None,
"local_path": local_path,
"file_format": row["file_format"],
"file_format": row["file_format"] or "",
"file_missing": file_missing,
"dances": dance_names,
"active_dance": active_dance,
@@ -735,41 +796,194 @@ class PlaylistPanel(QWidget):
def _change_dance(self, idx: int, song: dict):
"""Lad brugeren vælge/skrive hvilken dans der vises for dette nummer."""
from ui.dance_picker_dialog import DancePickerDialog
dances = song.get("dances", [])
current = song.get("active_dance", "")
if not current:
dances = song.get("dances", [])
current = dances[0] if dances else ""
current_choreo = song.get("active_choreo", "")
# Afgør om valget er permanent eller midlertidigt
# Permanent: ingen dans tagget, eller valgt dans er ikke i de taggede
# Midlertidig: sangen har flere danse og brugeren vælger en af dem
dlg = DancePickerDialog(
current_dance=current,
current_choreo=current_choreo,
song_title=song.get("title", ""),
existing_dances=dances,
parent=self.window()
)
if dlg.exec():
chosen = dlg.get_dance()
choreo = dlg.get_choreo()
if chosen:
song["active_dance"] = chosen
song["active_choreo"] = choreo
# Dans-valg i playlisten er altid midlertidigt — kun dance_override
song["active_dance"] = chosen # tom streng = ingen dans
self._refresh()
self._sync_dance_to_db(idx, song)
def _sync_dance_to_db(self, idx: int, song: dict):
"""Gem dance_override til playlist_songs."""
def _change_alt_dance(self, idx: int, song: dict):
"""Lad brugeren vælge alternativ dans til denne sang i playlisten."""
from ui.alt_dance_picker_dialog import AltDancePickerDialog
dlg = AltDancePickerDialog(song, parent=self.window())
if dlg.exec():
if dlg.was_cleared():
chosen = ""
else:
chosen = dlg.get_dance()
rating = dlg.get_rating()
song["alt_dance"] = chosen
self._refresh()
# Gem alt_dance_override på playlist_songs
self._sync_alt_dance_to_db(idx, song, chosen)
# Gem rating hvis givet
if chosen and rating is not None:
self._save_alt_dance_rating(song, chosen, rating)
def _sync_alt_dance_to_db(self, idx: int, song: dict, alt_dance: str):
"""Gem alt_dance_override til playlist_songs."""
if not self._named_playlist_id:
return
try:
from local.local_db import get_db
with get_db() as conn:
conn.execute(
"UPDATE playlist_songs SET alt_dance_override=? "
"WHERE playlist_id=? AND position=?",
(alt_dance, self._named_playlist_id, idx + 1)
)
except Exception as e:
import logging
logging.getLogger(__name__).warning(f"alt_dance_to_db fejl: {e}")
def _save_alt_dance_rating(self, song: dict, dance_name: str, rating: int):
"""Gem brugerens rating på en alternativ-dans."""
import uuid
song_id = song.get("id", "")
try:
from local.local_db import get_db
with get_db() as conn:
# Find dance_id
dance_row = conn.execute(
"SELECT id FROM dances WHERE name=? COLLATE NOCASE LIMIT 1",
(dance_name,)
).fetchone()
if not dance_row:
return
dance_id = dance_row["id"]
# Opdater eller indsæt rating
existing = conn.execute(
"SELECT id FROM song_alt_dances WHERE song_id=? AND dance_id=?",
(song_id, dance_id)
).fetchone()
if existing:
conn.execute(
"UPDATE song_alt_dances SET user_rating=? WHERE song_id=? AND dance_id=?",
(rating, song_id, dance_id)
)
else:
conn.execute(
"INSERT INTO song_alt_dances (id, song_id, dance_id, user_rating) VALUES (?,?,?,?)",
(str(uuid.uuid4()), song_id, dance_id, rating)
)
except Exception as e:
import logging
logging.getLogger(__name__).warning(f"save_alt_dance_rating fejl: {e}")
def _sync_dance_to_db(self, idx: int, song: dict):
"""Gem dance_override til playlist_songs (midlertidigt valg)."""
import logging
_log = logging.getLogger(__name__)
if not self._named_playlist_id:
_log.warning("_sync_dance_to_db: ingen named_playlist_id")
return
try:
from local.local_db import get_db
dance_val = song.get("active_dance") or ""
with get_db() as conn:
rows_affected = conn.execute(
"UPDATE playlist_songs SET dance_override=? "
"WHERE playlist_id=? AND position=?",
(song.get("active_dance", ""), self._named_playlist_id, idx + 1)
(dance_val, self._named_playlist_id, idx + 1)
).rowcount
_log.info(f"dance_override='{dance_val}' gemt på position {idx+1}, {rows_affected} rækker")
except Exception as e:
import logging
logging.getLogger(__name__).warning(f"_sync_dance_to_db fejl: {e}")
def _save_dance_permanently(self, idx: int, song: dict, dance_name: str, choreo: str = ""):
"""
Gem dans permanent på sangen:
1. song_dances tabellen
2. ID3-tag i filen (hvis tilgængelig)
3. Opdater sang-dict så listen vises korrekt
"""
import uuid
song_id = song.get("id", "")
local_path = song.get("local_path", "")
try:
from local.local_db import get_db
with get_db() as conn:
# Find eller opret dans
dance_row = conn.execute(
"SELECT id FROM dances WHERE name=? COLLATE NOCASE LIMIT 1",
(dance_name,)
).fetchone()
if dance_row:
dance_id = dance_row["id"]
if choreo:
conn.execute(
"UPDATE dances SET choreographer=? WHERE id=? AND choreographer=''",
(choreo, dance_id)
)
else:
cur = conn.execute(
"INSERT INTO dances (name, choreographer) VALUES (?,?)",
(dance_name, choreo or "")
)
dance_id = cur.lastrowid
# Tilføj til song_dances
existing = conn.execute(
"SELECT id FROM song_dances WHERE song_id=? AND dance_id=?",
(song_id, dance_id)
).fetchone()
if not existing:
# Find næste dance_order
max_order = conn.execute(
"SELECT MAX(dance_order) FROM song_dances WHERE song_id=?",
(song_id,)
).fetchone()[0] or 0
conn.execute(
"INSERT INTO song_dances (id, song_id, dance_id, dance_order) VALUES (?,?,?,?)",
(str(uuid.uuid4()), song_id, dance_id, max_order + 1)
)
# Opdater sang-dict
dances = song.get("dances", [])
if dance_name not in dances:
dances.append(dance_name)
song["dances"] = dances
song["active_dance"] = dance_name
# Gem i ID3-tag hvis filen er tilgængelig
if local_path:
try:
from local.tag_reader import write_dance_to_file
write_dance_to_file(local_path, dances)
except Exception:
pass
# Opdater også dance_override på listen
self._sync_dance_to_db(idx, song)
import logging
logging.getLogger(__name__).info(
f"Dans gemt permanent: '{dance_name}''{song.get('title','?')}'"
)
except Exception as e:
import logging
logging.getLogger(__name__).warning(f"Kunne ikke gemme dans permanent: {e}")
def _sync_ws_to_db(self, idx: int, song: dict):
"""Gem is_workshop til playlist_songs — både navngiven og aktiv liste."""
pl_ids = []
@@ -791,20 +1005,24 @@ 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."""
def _pull_linked_playlist(self, pl_id: str, server_id: str):
"""Hent seneste version af en linket liste fra serveren — i baggrundstråd."""
import threading
import uuid
def _do_pull():
try:
from ui.settings_dialog import load_settings
from local.local_db import get_db, DB_PATH
from local.local_db import DB_PATH
import sqlite3, urllib.request, json
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}"}
@@ -812,32 +1030,66 @@ class PlaylistPanel(QWidget):
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 = sqlite3.connect(str(DB_PATH), timeout=10)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("DELETE FROM playlist_songs WHERE playlist_id=?", (pl_id,))
position = 1
for song_data in pl_data.get("songs", []):
title = song_data.get("title", "")
artist = song_data.get("artist", "")
if not title:
continue
# Find sang via titel+artist
local = conn.execute(
"SELECT id FROM songs WHERE title=? AND artist=? AND file_missing=0",
(song_data["title"], song_data["artist"])
"SELECT s.id FROM songs s "
"JOIN files f ON f.song_id = s.id AND f.file_missing=0 "
"WHERE s.title=? AND s.artist=? LIMIT 1",
(title, artist)
).fetchone()
if local:
if not local:
# Sang mangler lokalt — opret som missing
local = conn.execute(
"SELECT id FROM songs WHERE title=? AND artist=? LIMIT 1",
(title, artist)
).fetchone()
if not local:
new_id = str(uuid.uuid4())
conn.execute(
"INSERT INTO songs (id, title, artist) VALUES (?,?,?)",
(new_id, title, artist)
)
song_id = new_id
else:
song_id = local["id"]
# Find fil
file_row = conn.execute(
"SELECT id FROM files WHERE song_id=? AND file_missing=0 LIMIT 1",
(song_id,)
).fetchone()
file_id = file_row["id"] if file_row else None
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"),
"(id, playlist_id, song_id, file_id, position, status, is_workshop, dance_override) "
"VALUES (?,?,?,?,?,?,?,?)",
(str(uuid.uuid4()), pl_id, song_id, file_id,
position, song_data.get("status", "pending"),
1 if song_data.get("is_workshop") else 0,
song_data.get("dance_override") or "")
)
position += 1
conn.commit()
conn.close()
except Exception as e:
except Exception:
pass # Offline — brug lokalt cachet version
def _push_linked_playlist(self, pl_id: int, server_id: str):
threading.Thread(target=_do_pull, daemon=True).start()
def _push_linked_playlist(self, pl_id: str, server_id: str):
"""Push ændringer til server for en linket liste."""
try:
from ui.settings_dialog import load_settings
@@ -925,7 +1177,8 @@ class PlaylistPanel(QWidget):
for song in songs:
path = song.get("local_path", "")
if path and Path(path).exists():
song["availability"] = "green"
# Grøn = lokal, Gul = netværk men tilgængeligt
song["availability"] = "green" if _is_local_path(path) else "yellow"
continue
# Forsøg auto-match via titel+artist
@@ -962,42 +1215,41 @@ class PlaylistPanel(QWidget):
with get_db() as conn:
for song in self._songs:
path = song.get("local_path", "")
# Grøn — eksisterer og tilgængeligt
# Grøn = lokal, Gul = netværk men tilgængeligt
if path and Path(path).exists():
song["availability"] = "green"
song["availability"] = "green" if _is_local_path(path) else "yellow"
song["file_missing"] = False
# Opdater songs tabellen
# Opdater files tabellen
conn.execute(
"UPDATE songs SET file_missing=0, local_path=? WHERE id=?",
(path, song["id"])
"UPDATE files SET file_missing=0 WHERE local_path=?",
(path,)
)
continue
# Forsøg auto-match via titel+artist
# Forsøg auto-match via titel+artist i files tabellen
title = song.get("title", "")
artist = song.get("artist", "")
match = conn.execute("""
SELECT id, local_path FROM songs
WHERE title=? AND artist=? AND file_missing=0
AND local_path IS NOT NULL AND local_path != ''
SELECT f.id as file_id, f.local_path, s.id as song_id
FROM files f
JOIN songs s ON s.id = f.song_id
WHERE s.title=? AND s.artist=? AND f.file_missing=0
AND f.local_path IS NOT NULL AND f.local_path != ''
LIMIT 1
""", (title, artist)).fetchone()
if match and Path(match["local_path"]).exists():
song["local_path"] = match["local_path"]
song["id"] = match["id"]
song["availability"] = "green"
song["file_id"] = match["file_id"]
song["availability"] = "green" if _is_local_path(match["local_path"]) else "yellow"
song["file_missing"] = False
# Opdater playlist_songs til at pege på den fundne sang
# Opdater playlist_songs til at pege på den fundne fil
if self._named_playlist_id:
conn.execute("""
UPDATE playlist_songs SET song_id=?
WHERE playlist_id=? AND song_id=(
SELECT id FROM songs
WHERE title=? AND artist=?
LIMIT 1
conn.execute(
"UPDATE playlist_songs SET file_id=? "
"WHERE playlist_id=? AND song_id=?",
(match["file_id"], self._named_playlist_id, song["id"])
)
""", (match["id"], self._named_playlist_id, title, artist))
else:
song["availability"] = "red"
@@ -1026,6 +1278,7 @@ class PlaylistPanel(QWidget):
act_played = menu.addAction("✓ Sæt til afspillet")
menu.addSeparator()
act_dance = menu.addAction("💃 Vælg dans...")
act_alt_dance = menu.addAction("💃 Vælg alternativ dans...")
is_ws = song.get("is_workshop", False) if song else False
act_ws = menu.addAction("🎓 Fjern workshop" if is_ws else "🎓 Markér som workshop")
menu.addSeparator()
@@ -1056,6 +1309,8 @@ class PlaylistPanel(QWidget):
self._refresh(); self._trigger_autosave(); self._trigger_event_state_save()
elif action == act_dance and song:
self._change_dance(idx, song)
elif action == act_alt_dance and song:
self._change_alt_dance(idx, song)
elif action == act_ws and song:
song["is_workshop"] = not song.get("is_workshop", False)
self._sync_ws_to_db(idx, song)
@@ -1209,11 +1464,11 @@ class PlaylistPanel(QWidget):
status = self._statuses[i]
icon = self.STATUS_ICON.get(status, " ")
# Vis active_dance (override eller første dans) eller alle danse
# Dans er primær tekst, sang er sekundær
active = song.get("active_dance", "")
if not active:
dances = song.get("dances", [])
active = dances[0] if dances else "ingen dans tagget"
active = dances[0] if dances else "ingen dans "
ws_tag = " 🎓" if song.get("is_workshop") else ""
# Tilgængeligheds-dot til højre — kun hvis tjekket (ikke yellow)
@@ -1221,8 +1476,8 @@ class PlaylistPanel(QWidget):
avail_color = {"green": "#27ae60", "red": "#e74c3c"}.get(avail, None)
avail_tip = {"green": "Tilgængelig lokalt", "red": "Ikke fundet lokalt"}.get(avail, "")
text = (f"{i+1:>2}. {song.get('title','')}{ws_tag}\n"
f" {song.get('artist','')} · {active}")
text = (f"{i+1:>2}. {active}{ws_tag}\n"
f" {song.get('title','')} · {song.get('artist','')}")
item = QListWidgetItem(f"{icon} {text}")
item.setData(Qt.ItemDataRole.UserRole, i)
item.setData(Qt.ItemDataRole.UserRole + 1, avail_color)

View File

@@ -9,6 +9,7 @@ class ScanWorker(QThread):
progress = pyqtSignal(int, int, str) # done, total, filename
finished = pyqtSignal(int, str) # antal, library_path
error = pyqtSignal(str)
batch_ready = pyqtSignal(int) # antal sange scannet så langt
def __init__(self, library_id: int, library_path: str,
db_path: str, overwrite_bpm: bool = False):
@@ -26,11 +27,15 @@ class ScanWorker(QThread):
def run(self):
try:
from local.scanner import scan_library
self._batch_count = 0
def on_progress(done, total, filename):
if self.isInterruptionRequested():
raise InterruptedError()
self.progress.emit(done, total, filename)
self._batch_count += 1
if self._batch_count % 50 == 0:
self.batch_ready.emit(self._batch_count)
count = scan_library(
self._library_id,

View File

@@ -164,7 +164,7 @@ class TagEditorDialog(QDialog):
# Forslags-liste
self._dance_suggestions = QListWidget()
self._dance_suggestions.setMaximumHeight(120)
self._dance_suggestions.setFixedHeight(150)
self._dance_suggestions.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self._dance_suggestions.itemClicked.connect(
lambda item: self._add_from_suggestion(item, "dance")
@@ -328,7 +328,13 @@ class TagEditorDialog(QDialog):
suggestions = get_dance_suggestions(prefix, limit=20)
list_widget.clear()
for s in suggestions:
label = f"{s['name']} / {s['level_name']}" if s.get("level_name") else s["name"]
s = dict(s)
parts = [s["name"]]
if s.get("level_name"):
parts.append(s["level_name"])
if s.get("choreographer"):
parts.append(s["choreographer"])
label = " / ".join(parts)
item = QListWidgetItem(label)
item.setData(Qt.ItemDataRole.UserRole, s.get("level_id"))
item.setData(Qt.ItemDataRole.UserRole + 1, s["name"])
@@ -373,16 +379,21 @@ class TagEditorDialog(QDialog):
suggestions = get_dance_suggestions(prefix, limit=15)
list_widget.clear()
for s in suggestions:
label = f"{s['name']} / {s['level_name']}" if s.get("level_name") else s["name"]
s = dict(s)
parts = [s["name"]]
if s.get("level_name"):
parts.append(s["level_name"])
if s.get("choreographer"):
label += f" · {s['choreographer']}"
parts.append(s["choreographer"])
label = " / ".join(parts)
item = QListWidgetItem(label)
item.setData(Qt.ItemDataRole.UserRole, s.get("level_id"))
item.setData(Qt.ItemDataRole.UserRole + 1, s["name"])
item.setData(Qt.ItemDataRole.UserRole + 2, s.get("choreographer", ""))
list_widget.addItem(item)
except Exception:
pass
except Exception as e:
import logging
logging.getLogger(__name__).warning(f"Dans-forslag fejl: {e}", exc_info=True)
def _add_from_suggestion(self, item, panel: str):
"""Tilføj dans fra forslags-listen ved klik."""
@@ -451,7 +462,7 @@ class TagEditorDialog(QDialog):
layout.addWidget(self._new_alt)
self._alt_suggestions = QListWidget()
self._alt_suggestions.setMaximumHeight(120)
self._alt_suggestions.setFixedHeight(150)
self._alt_suggestions.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self._alt_suggestions.itemClicked.connect(
lambda item: self._add_from_suggestion(item, "alt")
@@ -530,7 +541,8 @@ class TagEditorDialog(QDialog):
local_path = self._song.get("local_path", "")
try:
from local.local_db import new_conn, get_or_create_dance
import uuid
from local.local_db import get_db, get_or_create_dance
from local.tag_reader import write_dances, can_write_dances
# Saml data fra UI
@@ -554,8 +566,7 @@ class TagEditorDialog(QDialog):
"note": "",
})
conn = new_conn()
with get_db() as conn:
# Slet eksisterende
conn.execute("DELETE FROM song_dances WHERE song_id=?", (song_id,))
conn.execute("DELETE FROM song_alt_dances WHERE song_id=?", (song_id,))
@@ -565,34 +576,39 @@ class TagEditorDialog(QDialog):
dance_id = get_or_create_dance(d["name"], d["level_id"], conn,
choreographer=d.get("choreographer", ""))
conn.execute(
"INSERT OR IGNORE INTO song_dances (song_id, dance_id, dance_order) "
"VALUES (?,?,?)",
(song_id, dance_id, i)
"INSERT OR IGNORE INTO song_dances (id, song_id, dance_id, dance_order) "
"VALUES (?,?,?,?)",
(str(uuid.uuid4()), song_id, dance_id, i)
)
# Indsæt alternativ-danse
for a in alts:
dance_id = get_or_create_dance(a["name"], a["level_id"], conn)
conn.execute(
"INSERT OR IGNORE INTO song_alt_dances (song_id, dance_id, note) "
"VALUES (?,?,?)",
(song_id, dance_id, a.get("note", ""))
"INSERT OR IGNORE INTO song_alt_dances (id, song_id, dance_id, note) "
"VALUES (?,?,?,?)",
(str(uuid.uuid4()), song_id, dance_id, a.get("note", ""))
)
conn.commit()
conn.close()
# Skriv danse-navne til filen
if local_path and can_write_dances(local_path):
import logging as _logging
_log = _logging.getLogger(__name__)
dance_names = [d["name"] for d in dances]
_log.info(f"Gemmer {len(dances)} danse: {dance_names}, local_path={local_path!r}")
if local_path and can_write_dances(local_path):
try:
if not write_dances(local_path, dance_names):
result = write_dances(local_path, dance_names)
_log.info(f"write_dances resultat: {result}")
if not result:
QMessageBox.warning(self, "Advarsel",
"Gemt i database, men kunne ikke skrive til mp3-filen.\n"
"(Filen understøtter ikke dans-tags)")
except Exception as write_err:
_log.warning(f"write_dances fejl: {write_err}")
QMessageBox.warning(self, "Advarsel",
f"Gemt i database, men fejl ved skrivning til fil:\n{write_err}")
else:
_log.info(f"Springer fil-skrivning over: local_path={local_path!r}")
self.accept()