Compare commits
18 Commits
4df87cf48e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d7ad55a0f | |||
| 324c94fde2 | |||
| 8d4c4a81c1 | |||
| 09412073cd | |||
| 25c2dd9e78 | |||
| 115cc92d6a | |||
| c3453d8d55 | |||
| d28aafb2c6 | |||
| b695a4858b | |||
| 37b49c1fed | |||
| 545cdc6866 | |||
| ec3989e6a4 | |||
| 6ed349277c | |||
| 2deb0260f0 | |||
| 8a4c879213 | |||
| f92af40dd7 | |||
| efc30cdbb2 | |||
| a9aa451d63 |
@@ -28,29 +28,25 @@ class User(Base):
|
|||||||
|
|
||||||
projects: Mapped[list["Project"]] = relationship("Project", back_populates="owner")
|
projects: Mapped[list["Project"]] = relationship("Project", back_populates="owner")
|
||||||
memberships: Mapped[list["ProjectMember"]] = relationship("ProjectMember", back_populates="user")
|
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")
|
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")
|
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):
|
class Song(Base):
|
||||||
__tablename__ = "songs"
|
__tablename__ = "songs"
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
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)
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
artist: Mapped[str] = mapped_column(String(255), default="")
|
artist: Mapped[str] = mapped_column(String(255), default="")
|
||||||
album: Mapped[str] = mapped_column(String(255), default="")
|
album: Mapped[str] = mapped_column(String(255), default="")
|
||||||
bpm: Mapped[int] = mapped_column(Integer, default=0)
|
bpm: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
duration_sec: 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, unique=True)
|
||||||
mbid: Mapped[str|None] = mapped_column(String(36), nullable=True)
|
|
||||||
acoustid: Mapped[str|None] = mapped_column(String(64), nullable=True)
|
acoustid: Mapped[str|None] = mapped_column(String(64), nullable=True)
|
||||||
synced_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
|
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")
|
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_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")
|
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):
|
class Dance(Base):
|
||||||
"""Dans-entitet: navn + niveau er unik kombination."""
|
|
||||||
__tablename__ = "dances"
|
__tablename__ = "dances"
|
||||||
__table_args__ = (UniqueConstraint("name", "level_id", name="uq_dance_name_level"),)
|
__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="")
|
stepsheet_url: Mapped[str] = mapped_column(String(512), default="")
|
||||||
notes: Mapped[str] = mapped_column(Text, default="")
|
notes: Mapped[str] = mapped_column(Text, default="")
|
||||||
use_count: Mapped[int] = mapped_column(Integer, default=1)
|
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)
|
synced_at: Mapped[datetime|None] = mapped_column(DateTime, nullable=True)
|
||||||
|
|
||||||
level: Mapped["DanceLevel|None"] = relationship("DanceLevel")
|
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)
|
owner_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False)
|
||||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
description: Mapped[str] = mapped_column(Text, default="")
|
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)
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, onupdate=now_utc)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=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)
|
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)
|
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)
|
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
|
role: Mapped[str] = mapped_column(String(16), default="viewer")
|
||||||
status: Mapped[str] = mapped_column(String(16), default="pending") # pending|accepted
|
status: Mapped[str] = mapped_column(String(16), default="pending")
|
||||||
invited_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
|
invited_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
|
||||||
|
|
||||||
project: Mapped["Project"] = relationship("Project", back_populates="members")
|
project: Mapped["Project"] = relationship("Project", back_populates="members")
|
||||||
@@ -135,15 +129,14 @@ class ProjectSong(Base):
|
|||||||
|
|
||||||
|
|
||||||
class PlaylistShare(Base):
|
class PlaylistShare(Base):
|
||||||
"""Deling af en playlist med specifikke brugere."""
|
|
||||||
__tablename__ = "playlist_shares"
|
__tablename__ = "playlist_shares"
|
||||||
__table_args__ = (UniqueConstraint("project_id", "shared_with_id", name="uq_share"),)
|
__table_args__ = (UniqueConstraint("project_id", "shared_with_id", name="uq_share"),)
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
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)
|
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)
|
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
|
invited_email: Mapped[str] = mapped_column(String(255), default="")
|
||||||
permission: Mapped[str] = mapped_column(String(16), default="view") # view|copy|edit
|
permission: Mapped[str] = mapped_column(String(16), default="view")
|
||||||
accepted_at: Mapped[datetime|None] = mapped_column(DateTime, nullable=True)
|
accepted_at: Mapped[datetime|None] = mapped_column(DateTime, nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
|
||||||
|
|
||||||
@@ -154,7 +147,6 @@ class PlaylistShare(Base):
|
|||||||
# ── Sang-dans tags ────────────────────────────────────────────────────────────
|
# ── Sang-dans tags ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class SongDance(Base):
|
class SongDance(Base):
|
||||||
"""Dans-tags på en sang (brugerens egne tags)."""
|
|
||||||
__tablename__ = "song_dances"
|
__tablename__ = "song_dances"
|
||||||
__table_args__ = (UniqueConstraint("song_id", "dance_id", name="uq_song_dance"),)
|
__table_args__ = (UniqueConstraint("song_id", "dance_id", name="uq_song_dance"),)
|
||||||
|
|
||||||
@@ -168,7 +160,6 @@ class SongDance(Base):
|
|||||||
|
|
||||||
|
|
||||||
class SongAltDance(Base):
|
class SongAltDance(Base):
|
||||||
"""Alternativ-dans tags på en sang."""
|
|
||||||
__tablename__ = "song_alt_dances"
|
__tablename__ = "song_alt_dances"
|
||||||
__table_args__ = (UniqueConstraint("song_id", "dance_id", name="uq_song_alt_dance"),)
|
__table_args__ = (UniqueConstraint("song_id", "dance_id", name="uq_song_alt_dance"),)
|
||||||
|
|
||||||
@@ -184,7 +175,6 @@ class SongAltDance(Base):
|
|||||||
# ── Community dans-tags ───────────────────────────────────────────────────────
|
# ── Community dans-tags ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
class CommunityDance(Base):
|
class CommunityDance(Base):
|
||||||
"""Fællesskabets dans-tags på sange."""
|
|
||||||
__tablename__ = "community_dances"
|
__tablename__ = "community_dances"
|
||||||
__table_args__ = (UniqueConstraint("song_mbid", "song_title", "song_artist", "dance_id", name="uq_comm_dance"),)
|
__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):
|
class CommunityDanceAlt(Base):
|
||||||
"""Fællesskabets alternativ-danse til en sang med ratings."""
|
|
||||||
__tablename__ = "community_dance_alts"
|
__tablename__ = "community_dance_alts"
|
||||||
__table_args__ = (UniqueConstraint("song_mbid", "song_title", "song_artist", "alt_dance_id", name="uq_comm_alt"),)
|
__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):
|
class DanceAltRating(Base):
|
||||||
"""1-5 stjerne rating af en alternativ-dans."""
|
|
||||||
__tablename__ = "dance_alt_ratings"
|
__tablename__ = "dance_alt_ratings"
|
||||||
__table_args__ = (UniqueConstraint("alternative_id", "user_id", name="uq_rating"),)
|
__table_args__ = (UniqueConstraint("alternative_id", "user_id", name="uq_rating"),)
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
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)
|
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)
|
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)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
|
||||||
|
|
||||||
alternative: Mapped["CommunityDanceAlt"] = relationship("CommunityDanceAlt", back_populates="ratings")
|
alternative: Mapped["CommunityDanceAlt"] = relationship("CommunityDanceAlt", back_populates="ratings")
|
||||||
|
|||||||
121
linedance-api/app/routers/alt_dance_ratings.py
Normal file
121
linedance-api/app/routers/alt_dance_ratings.py
Normal 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
|
||||||
@@ -4,8 +4,10 @@ sync.py — Push/pull synkronisering mellem lokal app og server.
|
|||||||
POST /sync/push — send lokal data op til server
|
POST /sync/push — send lokal data op til server
|
||||||
GET /sync/pull — hent server-data ned til app
|
GET /sync/pull — hent server-data ned til app
|
||||||
"""
|
"""
|
||||||
|
import uuid
|
||||||
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
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.core.security import get_current_user
|
||||||
from app.models import (
|
from app.models import (
|
||||||
User, Song, Dance, DanceLevel, Project, ProjectSong,
|
User, Song, Dance, DanceLevel, Project, ProjectSong,
|
||||||
PlaylistShare, CommunityDance, CommunityDanceAlt,
|
PlaylistShare, CommunityDance, SongDance, SongAltDance,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/sync", tags=["sync"])
|
router = APIRouter(prefix="/sync", tags=["sync"])
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# ── Schemas ───────────────────────────────────────────────────────────────────
|
# ── Schemas ───────────────────────────────────────────────────────────────────
|
||||||
@@ -29,7 +32,6 @@ class SongData(BaseModel):
|
|||||||
album: str = ""
|
album: str = ""
|
||||||
bpm: int = 0
|
bpm: int = 0
|
||||||
duration_sec: int = 0
|
duration_sec: int = 0
|
||||||
file_format: str = ""
|
|
||||||
mbid: str = ""
|
mbid: str = ""
|
||||||
acoustid: str = ""
|
acoustid: str = ""
|
||||||
|
|
||||||
@@ -52,6 +54,7 @@ class SongAltDanceData(BaseModel):
|
|||||||
dance_name: str
|
dance_name: str
|
||||||
level_name: str = ""
|
level_name: str = ""
|
||||||
note: str = ""
|
note: str = ""
|
||||||
|
user_rating: Optional[int] = None
|
||||||
|
|
||||||
class PlaylistSongData(BaseModel):
|
class PlaylistSongData(BaseModel):
|
||||||
song_local_id: str
|
song_local_id: str
|
||||||
@@ -76,7 +79,61 @@ class PushPayload(BaseModel):
|
|||||||
song_dances: list[SongDanceData] = []
|
song_dances: list[SongDanceData] = []
|
||||||
song_alts: list[SongAltDanceData] = []
|
song_alts: list[SongAltDanceData] = []
|
||||||
playlists: list[PlaylistData] = []
|
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 ──────────────────────────────────────────────────────────────────────
|
# ── Push ──────────────────────────────────────────────────────────────────────
|
||||||
@@ -88,82 +145,60 @@ def push(
|
|||||||
me: User = Depends(get_current_user),
|
me: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Upload lokal data til server. Returnerer server-IDs."""
|
"""Upload lokal data til server. Returnerer server-IDs."""
|
||||||
|
import sqlalchemy as _sa
|
||||||
|
|
||||||
song_id_map = {} # local_id → server Song.id
|
song_id_map = {} # local_id → server Song.id
|
||||||
dance_id_map = {} # "name|level" → server Dance.id
|
dance_id_map = {} # "name|level_id" → Dance.id
|
||||||
level_map = {} # level_name → DanceLevel.id
|
level_map = {} # level_name.lower() → DanceLevel.id
|
||||||
|
|
||||||
# ── Dans-niveauer ─────────────────────────────────────────────────────────
|
# ── Dans-niveauer ─────────────────────────────────────────────────────────
|
||||||
for lvl in db.query(DanceLevel).all():
|
for lvl in db.query(DanceLevel).all():
|
||||||
level_map[lvl.name.lower()] = lvl.id
|
level_map[lvl.name.lower()] = lvl.id
|
||||||
|
|
||||||
# ── Sange ─────────────────────────────────────────────────────────────────
|
# ── Sange (globale) ───────────────────────────────────────────────────────
|
||||||
for s in payload.songs:
|
for s in payload.songs:
|
||||||
if not s.title:
|
if not s.title:
|
||||||
continue
|
continue
|
||||||
# Match 1: MBID — sikrest
|
song = _find_or_create_song(
|
||||||
existing = None
|
db, s.title, s.artist,
|
||||||
if s.mbid:
|
mbid=s.mbid, acoustid=s.acoustid,
|
||||||
existing = db.query(Song).filter_by(mbid=s.mbid).first()
|
album=s.album, bpm=s.bpm, duration_sec=s.duration_sec,
|
||||||
# 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,
|
|
||||||
)
|
)
|
||||||
db.add(song)
|
|
||||||
db.flush()
|
|
||||||
song_id_map[s.local_id] = song.id
|
song_id_map[s.local_id] = song.id
|
||||||
|
|
||||||
# ── Danse ──────────────────────────────────────────────────────────────────
|
# ── Danse ─────────────────────────────────────────────────────────────────
|
||||||
for d in payload.dances:
|
for d in payload.dances:
|
||||||
level_id = level_map.get(d.level_name.lower()) if d.level_name else None
|
level_id = level_map.get(d.level_name.lower()) if d.level_name else None
|
||||||
key = f"{d.name.lower()}|{level_id}"
|
key = f"{d.name.lower()}|{level_id}"
|
||||||
existing = db.query(Dance).filter_by(name=d.name, level_id=level_id).first()
|
existing = db.query(Dance).filter_by(name=d.name, level_id=level_id).first()
|
||||||
if existing:
|
if existing:
|
||||||
# Opdater info hvis den har ny data
|
|
||||||
if d.choreographer: existing.choreographer = d.choreographer
|
if d.choreographer: existing.choreographer = d.choreographer
|
||||||
if d.video_url: existing.video_url = d.video_url
|
if d.video_url: existing.video_url = d.video_url
|
||||||
if d.stepsheet_url: existing.stepsheet_url = d.stepsheet_url
|
if d.stepsheet_url: existing.stepsheet_url = d.stepsheet_url
|
||||||
if d.notes: existing.notes = d.notes
|
|
||||||
dance_id_map[key] = existing.id
|
dance_id_map[key] = existing.id
|
||||||
else:
|
else:
|
||||||
dance = Dance(
|
dance = Dance(
|
||||||
name=d.name, level_id=level_id,
|
name=d.name, level_id=level_id,
|
||||||
choreographer=d.choreographer, video_url=d.video_url,
|
choreographer=d.choreographer,
|
||||||
stepsheet_url=d.stepsheet_url, notes=d.notes,
|
video_url=d.video_url,
|
||||||
|
stepsheet_url=d.stepsheet_url,
|
||||||
|
notes=d.notes,
|
||||||
)
|
)
|
||||||
db.add(dance)
|
db.add(dance)
|
||||||
db.flush()
|
db.flush()
|
||||||
dance_id_map[key] = dance.id
|
dance_id_map[key] = dance.id
|
||||||
|
|
||||||
# ── Sang-dans tags (brugerens egne) ───────────────────────────────────────
|
# ── Sang-dans tags — synkroniser fuldt per sang ──────────────────────────
|
||||||
from app.models import SongDance, SongAltDance
|
# Slet eksisterende tags for sange der er med i push, genindsæt fra klient
|
||||||
# ── Sang-dans tags ────────────────────────────────────────────────────────
|
synced_song_ids = set()
|
||||||
from app.models import SongDance, SongAltDance
|
|
||||||
import sqlalchemy as _sa
|
|
||||||
|
|
||||||
for sd in payload.song_dances:
|
for sd in payload.song_dances:
|
||||||
song_id = song_id_map.get(sd.song_local_id)
|
song_id = song_id_map.get(sd.song_local_id)
|
||||||
if not song_id:
|
if not song_id:
|
||||||
continue
|
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
|
level_id = level_map.get(sd.level_name.lower()) if sd.level_name else None
|
||||||
key = f"{sd.dance_name.lower()}|{level_id}"
|
key = f"{sd.dance_name.lower()}|{level_id}"
|
||||||
dance_id = dance_id_map.get(key)
|
dance_id = dance_id_map.get(key)
|
||||||
@@ -172,12 +207,15 @@ def push(
|
|||||||
db.execute(_sa.text(
|
db.execute(_sa.text(
|
||||||
"INSERT IGNORE INTO song_dances (id, song_id, dance_id, dance_order) "
|
"INSERT IGNORE INTO song_dances (id, song_id, dance_id, dance_order) "
|
||||||
"VALUES (:id, :song_id, :dance_id, :dance_order)"
|
"VALUES (:id, :song_id, :dance_id, :dance_order)"
|
||||||
), {
|
), {"id": str(uuid.uuid4()), "song_id": song_id,
|
||||||
"id": str(__import__("uuid").uuid4()),
|
"dance_id": dance_id, "dance_order": sd.dance_order})
|
||||||
"song_id": song_id,
|
|
||||||
"dance_id": dance_id,
|
# Sange der er fuldt synkroniseret men har ingen dans-tags — slet på server
|
||||||
"dance_order": sd.dance_order,
|
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:
|
for sa in payload.song_alts:
|
||||||
song_id = song_id_map.get(sa.song_local_id)
|
song_id = song_id_map.get(sa.song_local_id)
|
||||||
@@ -188,38 +226,71 @@ def push(
|
|||||||
dance_id = dance_id_map.get(key)
|
dance_id = dance_id_map.get(key)
|
||||||
if not dance_id:
|
if not dance_id:
|
||||||
continue
|
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(
|
db.execute(_sa.text(
|
||||||
"INSERT IGNORE INTO song_alt_dances (id, song_id, dance_id, note) "
|
"INSERT IGNORE INTO song_alt_dances (id, song_id, dance_id, note) "
|
||||||
"VALUES (:id, :song_id, :dance_id, :note)"
|
"VALUES (:id, :song_id, :dance_id, :note)"
|
||||||
), {
|
), {"id": str(uuid.uuid4()), "song_id": song_id,
|
||||||
"id": str(__import__("uuid").uuid4()),
|
"dance_id": dance_id, "note": sa.note or ""})
|
||||||
"song_id": song_id,
|
|
||||||
"dance_id": dance_id,
|
|
||||||
"note": sa.note or "",
|
|
||||||
})
|
|
||||||
|
|
||||||
# ── Playlister ────────────────────────────────────────────────────────────
|
# ── 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 = {}
|
playlist_id_map = {}
|
||||||
for pl in payload.playlists:
|
for pl in payload.playlists:
|
||||||
# Prøv først at finde via server-ID (local_id er klientens lokale db-id
|
# Find eksisterende via server-ID (local_id er api_project_id på klienten)
|
||||||
# som tidligere er returneret som server-ID via playlist_id_map)
|
|
||||||
existing = None
|
existing = None
|
||||||
if pl.local_id:
|
if pl.local_id:
|
||||||
existing = db.query(Project).filter_by(
|
existing = db.query(Project).filter_by(
|
||||||
id=pl.local_id, owner_id=me.id
|
id=pl.local_id, owner_id=me.id
|
||||||
).first()
|
).first()
|
||||||
# Fallback: navn — kun hvis vi aldrig har set denne liste før
|
|
||||||
if not existing:
|
if not existing:
|
||||||
existing = db.query(Project).filter_by(
|
existing = db.query(Project).filter_by(
|
||||||
owner_id=me.id, name=pl.name
|
owner_id=me.id, name=pl.name
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
existing.name = pl.name
|
existing.name = pl.name
|
||||||
existing.description = pl.description
|
existing.description = pl.description
|
||||||
existing.visibility = pl.visibility
|
existing.visibility = pl.visibility
|
||||||
# Opdater kun sange hvis push faktisk har sange med
|
|
||||||
if pl.songs:
|
if pl.songs:
|
||||||
db.query(ProjectSong).filter_by(project_id=existing.id).delete()
|
db.query(ProjectSong).filter_by(project_id=existing.id).delete()
|
||||||
project = existing
|
project = existing
|
||||||
@@ -233,27 +304,21 @@ def push(
|
|||||||
playlist_id_map[pl.local_id] = project.id
|
playlist_id_map[pl.local_id] = project.id
|
||||||
|
|
||||||
for ps in pl.songs:
|
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)
|
song_id = song_id_map.get(ps.song_local_id)
|
||||||
# Fallback: match på titel+artist
|
|
||||||
if not song_id and ps.song_title:
|
if not song_id and ps.song_title:
|
||||||
existing_song = db.query(Song).filter_by(
|
song = _find_or_create_song(db, ps.song_title, ps.song_artist)
|
||||||
title=ps.song_title, artist=ps.song_artist
|
song_id = song.id
|
||||||
).first()
|
|
||||||
if existing_song:
|
|
||||||
song_id = existing_song.id
|
|
||||||
if not song_id:
|
if not song_id:
|
||||||
continue
|
continue
|
||||||
proj_song = ProjectSong(
|
db.add(ProjectSong(
|
||||||
project_id=project.id, song_id=song_id,
|
project_id=project.id, song_id=song_id,
|
||||||
position=ps.position, status=ps.status,
|
position=ps.position, status=ps.status,
|
||||||
is_workshop=ps.is_workshop,
|
is_workshop=ps.is_workshop,
|
||||||
dance_override=ps.dance_override,
|
dance_override=ps.dance_override,
|
||||||
)
|
))
|
||||||
db.add(proj_song)
|
|
||||||
|
|
||||||
# ── Slet playlister der er fjernet lokalt ─────────────────────────────────
|
# ── Slet playlister ───────────────────────────────────────────────────────
|
||||||
# Klienten sender api_project_id (= server Project.id) som strings
|
|
||||||
for project_id in payload.deleted_playlists:
|
for project_id in payload.deleted_playlists:
|
||||||
proj = db.query(Project).filter_by(id=project_id, owner_id=me.id).first()
|
proj = db.query(Project).filter_by(id=project_id, owner_id=me.id).first()
|
||||||
if proj:
|
if proj:
|
||||||
@@ -265,9 +330,9 @@ def push(
|
|||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"songs_synced": len(song_id_map),
|
"songs_synced": len(song_id_map),
|
||||||
"playlists_synced": len(playlist_id_map)
|
"playlists_synced": len(playlist_id_map),
|
||||||
#"song_id_map": song_id_map,
|
"song_id_map": {k: str(v) for k, v in song_id_map.items()},
|
||||||
#"playlist_id_map": playlist_id_map,
|
"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()
|
for l in db.query(DanceLevel).order_by(DanceLevel.sort_order).all()
|
||||||
]
|
]
|
||||||
|
|
||||||
# Danse med info
|
# Danse
|
||||||
dances = [
|
dances = [
|
||||||
{
|
{
|
||||||
"name": d.name,
|
"name": d.name,
|
||||||
@@ -300,95 +365,109 @@ def pull(
|
|||||||
for d in db.query(Dance).order_by(Dance.use_count.desc()).limit(500).all()
|
for d in db.query(Dance).order_by(Dance.use_count.desc()).limit(500).all()
|
||||||
]
|
]
|
||||||
|
|
||||||
# Community dans-tags (populære)
|
# Delte playlister
|
||||||
community = []
|
shared_ids = {
|
||||||
for cd in db.query(CommunityDance).limit(1000).all():
|
s.project_id for s in db.query(PlaylistShare).filter(
|
||||||
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(
|
|
||||||
(PlaylistShare.shared_with_id == me.id) |
|
(PlaylistShare.shared_with_id == me.id) |
|
||||||
(PlaylistShare.invited_email == me.email)
|
(PlaylistShare.invited_email == me.email)
|
||||||
).all():
|
).all()
|
||||||
shared_ids.add(s.project_id)
|
}
|
||||||
|
|
||||||
shared = []
|
shared = []
|
||||||
for p in db.query(Project).filter(Project.id.in_(shared_ids)).all():
|
for p in db.query(Project).filter(Project.id.in_(shared_ids)).all():
|
||||||
if p.owner_id == me.id:
|
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
|
continue
|
||||||
songs_out.append({
|
owner = db.query(User).filter_by(id=p.owner_id).first()
|
||||||
"title": song.title,
|
|
||||||
"artist": song.artist,
|
|
||||||
"position": ps.position,
|
|
||||||
"status": ps.status,
|
|
||||||
"is_workshop": ps.is_workshop,
|
|
||||||
"dance_override": ps.dance_override or "",
|
|
||||||
})
|
|
||||||
shared.append({
|
shared.append({
|
||||||
"server_id": p.id,
|
"server_id": p.id,
|
||||||
"name": p.name,
|
"name": p.name,
|
||||||
"owner": owner.username if owner else "?",
|
"owner": owner.username if owner else "?",
|
||||||
"songs": sorted(songs_out, key=lambda x: x["position"]),
|
"songs": [
|
||||||
})
|
{
|
||||||
|
"song_id": str(ps.song_id),
|
||||||
# Egne playlister
|
"title": ps.song.title,
|
||||||
my_playlists = []
|
"artist": ps.song.artist,
|
||||||
all_projects = db.query(Project).filter_by(owner_id=me.id).all()
|
"mbid": ps.song.mbid or "",
|
||||||
import logging
|
"acoustid": ps.song.acoustid or "",
|
||||||
logging.getLogger(__name__).info(f"Pull: fandt {len(all_projects)} projekter for {me.id}")
|
"bpm": ps.song.bpm,
|
||||||
for p in all_projects:
|
"duration_sec": ps.song.duration_sec,
|
||||||
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,
|
"position": ps.position,
|
||||||
"status": ps.status,
|
"status": ps.status,
|
||||||
"is_workshop": ps.is_workshop,
|
"is_workshop": ps.is_workshop,
|
||||||
"dance_override": ps.dance_override or "",
|
"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({
|
my_playlists.append({
|
||||||
"server_id": p.id,
|
"server_id": p.id,
|
||||||
"name": p.name,
|
"name": p.name,
|
||||||
"description": p.description or "",
|
"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
|
logger.info(f"Pull: {len(my_playlists)} playlister for {me.username}")
|
||||||
from app.models import SongDance, SongAltDance
|
|
||||||
|
# Dans-tags (brugerens egne)
|
||||||
song_tags = []
|
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()
|
dance = db.query(Dance).filter_by(id=sd.dance_id).first()
|
||||||
if not dance:
|
if not dance:
|
||||||
continue
|
continue
|
||||||
level = db.query(DanceLevel).filter_by(id=dance.level_id).first() if dance.level_id else None
|
level = db.query(DanceLevel).filter_by(id=dance.level_id).first() if dance.level_id else None
|
||||||
song_tags.append({
|
song_tags.append({
|
||||||
"song_title": sd.song.title,
|
"song_id": sd.song_id,
|
||||||
"song_artist": sd.song.artist,
|
|
||||||
"dance_name": dance.name,
|
"dance_name": dance.name,
|
||||||
|
"choreographer": dance.choreographer or "",
|
||||||
"level_name": level.name if level else "",
|
"level_name": level.name if level else "",
|
||||||
"dance_order": sd.dance_order,
|
"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 {
|
return {
|
||||||
"levels": levels,
|
"levels": levels,
|
||||||
"dances": dances,
|
"dances": dances,
|
||||||
"community": community,
|
|
||||||
"shared": shared,
|
"shared": shared,
|
||||||
"my_playlists": my_playlists,
|
"my_playlists": my_playlists,
|
||||||
"song_tags": song_tags,
|
"song_tags": song_tags,
|
||||||
|
"community_alts": community_alts,
|
||||||
}
|
}
|
||||||
Binary file not shown.
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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">
|
<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>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
@@ -61,6 +61,25 @@
|
|||||||
.hero h1 em { color: var(--accent); font-style: normal; }
|
.hero h1 em { color: var(--accent); font-style: normal; }
|
||||||
.hero p { color: var(--muted); font-size: 1rem; }
|
.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 { max-width: 900px; margin: 0 auto; padding: 0 2rem 4rem; }
|
||||||
.section-title {
|
.section-title {
|
||||||
font-family: var(--mono); font-size: .72rem; letter-spacing: .15em;
|
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 { border-color: var(--accent); transform: translateY(-2px); }
|
||||||
.card.clickable:hover::before { transform: scaleX(1); }
|
.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-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-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-meta { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
|
||||||
|
.card-tags { display: flex; gap: .35rem; flex-wrap: wrap; margin-top: .5rem; }
|
||||||
.badge {
|
.badge {
|
||||||
font-family: var(--mono); font-size: .68rem; padding: .18rem .45rem;
|
font-family: var(--mono); font-size: .68rem; padding: .18rem .45rem;
|
||||||
border-radius: 4px; border: 1px solid;
|
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.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.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); }
|
.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 {
|
#detail {
|
||||||
display: none; position: fixed; inset: 0;
|
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.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); }
|
.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; }
|
.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; }
|
.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); } }
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
@@ -158,29 +213,47 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<div class="tab active" data-tab="public">Public 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 playlister</div>
|
<div class="tab" id="tab-mine" data-tab="mine" style="display:none">Mine danselister</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hero">
|
<div class="hero">
|
||||||
<h1 id="hero-title">Public<br><em>playlister</em></h1>
|
<h1 id="hero-title">Offentlige<br><em>danselister</em></h1>
|
||||||
<p id="hero-sub">Browse og kopiér playlister delt af LineDance Player-brugere.</p>
|
<p id="hero-sub">Browse og kopiér danselister delt af LineDance Player-brugere.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div id="pane-public">
|
<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 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>
|
</div>
|
||||||
<div id="pane-mine" style="display:none">
|
<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 id="grid-mine" class="grid">
|
||||||
<div class="empty"><div class="spinner"></div></div>
|
<div class="empty"><div class="spinner"></div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="detail">
|
<div id="detail">
|
||||||
<div class="detail-box">
|
<div class="detail-box">
|
||||||
@@ -213,6 +286,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="login-modal">
|
<div id="login-modal">
|
||||||
|
<div class="login-box">
|
||||||
<h3>Log ind</h3>
|
<h3>Log ind</h3>
|
||||||
<div id="login-msg"></div>
|
<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>
|
<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 username = localStorage.getItem('ld_user') || '';
|
||||||
let currentPlaylistId = null;
|
let currentPlaylistId = null;
|
||||||
let currentTab = 'public';
|
let currentTab = 'public';
|
||||||
|
let allPublicLists = [];
|
||||||
|
let activeTag = '';
|
||||||
|
let allMineLists = [];
|
||||||
|
let activeMineTag = '';
|
||||||
|
|
||||||
function updateAuthUI() {
|
function updateAuthUI() {
|
||||||
document.getElementById('btn-login').style.display = token ? 'none' : '';
|
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);
|
localStorage.setItem('ld_token', token); localStorage.setItem('ld_user', username);
|
||||||
document.getElementById('login-modal').classList.remove('open');
|
document.getElementById('login-modal').classList.remove('open');
|
||||||
updateAuthUI();
|
updateAuthUI();
|
||||||
|
// Skift til mine danselister ved login
|
||||||
|
switchTab('mine');
|
||||||
loadMyPlaylists();
|
loadMyPlaylists();
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
msg.innerHTML = `<div class="msg error">${e.message}</div>`;
|
msg.innerHTML = `<div class="msg error">${e.message}</div>`;
|
||||||
@@ -280,35 +360,172 @@ function switchTab(tab) {
|
|||||||
document.getElementById('pane-public').style.display = tab === 'public' ? '' : 'none';
|
document.getElementById('pane-public').style.display = tab === 'public' ? '' : 'none';
|
||||||
document.getElementById('pane-mine').style.display = tab === 'mine' ? '' : 'none';
|
document.getElementById('pane-mine').style.display = tab === 'mine' ? '' : 'none';
|
||||||
if (tab === 'public') {
|
if (tab === 'public') {
|
||||||
document.getElementById('hero-title').innerHTML = 'Public<br><em>playlister</em>';
|
document.getElementById('hero-title').innerHTML = 'Offentlige<br><em>danselister</em>';
|
||||||
document.getElementById('hero-sub').textContent = 'Browse og kopiér playlister delt af LineDance Player-brugere.';
|
document.getElementById('hero-sub').textContent = 'Browse og kopiér danselister delt af LineDance Player-brugere.';
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('hero-title').innerHTML = 'Mine<br><em>playlister</em>';
|
document.getElementById('hero-title').innerHTML = 'Mine<br><em>danselister</em>';
|
||||||
document.getElementById('hero-sub').textContent = 'Administrér synlighed på dine playlister.';
|
document.getElementById('hero-sub').textContent = 'Administrér dine danselister.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.querySelectorAll('.tab').forEach(t => t.onclick = () => switchTab(t.dataset.tab));
|
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');
|
const grid = document.getElementById('grid-public');
|
||||||
try {
|
if (!lists.length) {
|
||||||
const r = await fetch(`${API}/sharing/public`);
|
grid.innerHTML = '<div class="empty">Ingen danselister matcher søgningen.</div>';
|
||||||
const lists = await r.json();
|
return;
|
||||||
if (!lists.length) { grid.innerHTML = '<div class="empty">Ingen public playlister endnu.</div>'; return; }
|
}
|
||||||
grid.innerHTML = lists.map(p => `
|
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 clickable fade-in" data-id="${p.id}">
|
||||||
<div class="card-title">${esc(p.name)}</div>
|
<div class="card-title">${esc(p.name)}</div>
|
||||||
<div class="card-owner">@ ${esc(p.owner)}</div>
|
<div class="card-owner">@ ${esc(p.owner)}</div>
|
||||||
<div class="card-meta">
|
<div class="card-meta">
|
||||||
<span class="badge orange">${p.song_count} sange</span>
|
<span class="badge orange">${p.song_count} sange</span>
|
||||||
<span class="badge green">public</span>
|
<span class="badge green">offentlig</span>
|
||||||
</div>
|
</div>
|
||||||
</div>`).join('');
|
${tagHtml ? `<div class="card-tags">${tagHtml}</div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
grid.querySelectorAll('.card').forEach(c =>
|
grid.querySelectorAll('.card').forEach(c =>
|
||||||
c.onclick = () => openDetail(c.dataset.id, false));
|
c.onclick = () => openDetail(c.dataset.id, false));
|
||||||
} catch(e) {
|
|
||||||
grid.innerHTML = `<div class="empty">Kunne ikke hente playlister.<br>${e.message}</div>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 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() {
|
async function loadMyPlaylists() {
|
||||||
@@ -319,33 +536,15 @@ async function loadMyPlaylists() {
|
|||||||
headers: { 'Authorization': `Bearer ${token}` }
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
});
|
});
|
||||||
if (!r.ok) throw new Error('Ikke autoriseret');
|
if (!r.ok) throw new Error('Ikke autoriseret');
|
||||||
const lists = await r.json();
|
allMineLists = await r.json();
|
||||||
if (!lists.length) { grid.innerHTML = '<div class="empty">Ingen playlister endnu.</div>'; return; }
|
if (!allMineLists.length) {
|
||||||
grid.innerHTML = lists.map(p => {
|
document.getElementById('grid-mine').innerHTML = '<div class="empty">Ingen danselister endnu.</div>';
|
||||||
const vis = p.visibility || 'private';
|
return;
|
||||||
const bc = vis === 'public' ? 'green' : vis === 'shared' ? 'orange' : 'muted';
|
}
|
||||||
const bl = vis === 'public' ? 'public' : vis === 'shared' ? 'delt' : 'privat';
|
buildMineSidebar(allMineLists);
|
||||||
const sc = p.song_count || (p.songs || []).length || 0;
|
filterMine();
|
||||||
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('');
|
|
||||||
} catch(e) {
|
} 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}` }
|
method: 'PATCH', headers: { 'Authorization': `Bearer ${token}` }
|
||||||
});
|
});
|
||||||
if (!r.ok) throw new Error('Fejl');
|
if (!r.ok) throw new Error('Fejl');
|
||||||
const badge = document.getElementById(`vis-badge-${id}`);
|
loadMyPlaylists();
|
||||||
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');
|
|
||||||
}
|
|
||||||
loadPublicPlaylists();
|
loadPublicPlaylists();
|
||||||
} catch(e) { alert('Fejl: ' + e.message); }
|
} 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) {
|
async function openDetail(id, isOwn) {
|
||||||
currentPlaylistId = id;
|
currentPlaylistId = id;
|
||||||
document.getElementById('btn-copy').style.display = isOwn ? 'none' : '';
|
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; }
|
} catch(e) { btn.textContent = '⚠ ' + e.message; btn.disabled = false; }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── QR ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
let currentQRUrl = '';
|
let currentQRUrl = '';
|
||||||
|
|
||||||
function showQR(id, name) {
|
function showQR(id, name) {
|
||||||
@@ -433,13 +641,10 @@ function showQR(id, name) {
|
|||||||
document.getElementById('qr-url').textContent = url;
|
document.getElementById('qr-url').textContent = url;
|
||||||
document.getElementById('copy-msg').textContent = '';
|
document.getElementById('copy-msg').textContent = '';
|
||||||
document.getElementById('qr-modal').style.display = 'flex';
|
document.getElementById('qr-modal').style.display = 'flex';
|
||||||
|
|
||||||
// Tegn QR med et simpelt bibliotek
|
|
||||||
const canvas = document.getElementById('qr-canvas');
|
const canvas = document.getElementById('qr-canvas');
|
||||||
if (window.QRious) {
|
if (window.QRious) {
|
||||||
new QRious({ element: canvas, value: url, size: 220, backgroundAlpha: 0, foreground: '#eceef4' });
|
new QRious({ element: canvas, value: url, size: 220, backgroundAlpha: 0, foreground: '#eceef4' });
|
||||||
} else {
|
} else {
|
||||||
// Fallback: vis bare URL hvis bibliotek ikke er loadet
|
|
||||||
canvas.style.display = 'none';
|
canvas.style.display = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,25 @@ echo.
|
|||||||
echo OK: dist\LineDancePlayer\ er klar
|
echo OK: dist\LineDancePlayer\ er klar
|
||||||
echo.
|
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 ────────────────────────────────────────────────────────────
|
:: ── NSIS installer ────────────────────────────────────────────────────────────
|
||||||
echo [4/4] Bygger NSIS installer...
|
echo [4/4] Bygger NSIS installer...
|
||||||
echo.
|
echo.
|
||||||
|
|||||||
@@ -2,10 +2,32 @@
|
|||||||
|
|
||||||
block_cipher = None
|
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(
|
a = Analysis(
|
||||||
['main.py'],
|
['main.py'],
|
||||||
pathex=['.'],
|
pathex=['.'],
|
||||||
binaries=[],
|
binaries=VLC_BINARIES,
|
||||||
datas=[
|
datas=[
|
||||||
('translations', 'translations'),
|
('translations', 'translations'),
|
||||||
],
|
],
|
||||||
@@ -20,7 +42,7 @@ a = Analysis(
|
|||||||
'ui.scan_worker', 'ui.bpm_worker', 'ui.tag_editor',
|
'ui.scan_worker', 'ui.bpm_worker', 'ui.tag_editor',
|
||||||
'ui.settings_dialog', 'ui.playlist_browser',
|
'ui.settings_dialog', 'ui.playlist_browser',
|
||||||
'ui.playlist_info_dialog', 'ui.dance_info_dialog',
|
'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',
|
'ui.register_dialog',
|
||||||
'player.player',
|
'player.player',
|
||||||
'local.local_db', 'local.scanner', 'local.file_watcher',
|
'local.local_db', 'local.scanner', 'local.file_watcher',
|
||||||
|
|||||||
@@ -51,6 +51,12 @@ Section "LineDance Player" SecMain
|
|||||||
|
|
||||||
SetOutPath "$INSTDIR"
|
SetOutPath "$INSTDIR"
|
||||||
File "dist\LineDancePlayer\LineDancePlayer.exe"
|
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"
|
SetOutPath "$INSTDIR\_internal"
|
||||||
File /r "dist\LineDancePlayer\_internal\*"
|
File /r "dist\LineDancePlayer\_internal\*"
|
||||||
@@ -85,34 +91,7 @@ Section "LineDance Player" SecMain
|
|||||||
|
|
||||||
SectionEnd
|
SectionEnd
|
||||||
|
|
||||||
; ── VLC tjek ──────────────────────────────────────────────────────────────────
|
; VLC DLL-filer er bundlet med appen — intet VLC-tjek nødvendigt
|
||||||
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
|
|
||||||
|
|
||||||
; ── Afinstaller ───────────────────────────────────────────────────────────────
|
; ── Afinstaller ───────────────────────────────────────────────────────────────
|
||||||
Section "Uninstall"
|
Section "Uninstall"
|
||||||
|
|||||||
@@ -159,11 +159,11 @@ def run_acoustid_scan(db_path: str, api_key: str = "", on_progress=None, stop_ev
|
|||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
rows = conn.execute("""
|
rows = conn.execute("""
|
||||||
SELECT id, local_path, title, artist
|
SELECT s.id, s.title, s.artist, f.local_path
|
||||||
FROM songs
|
FROM songs s
|
||||||
WHERE (mbid IS NULL OR mbid = '')
|
JOIN files f ON f.song_id = s.id AND f.file_missing = 0
|
||||||
AND file_missing = 0
|
WHERE (s.mbid IS NULL OR s.mbid = '')
|
||||||
AND local_path IS NOT NULL AND local_path != ''
|
AND f.local_path IS NOT NULL AND f.local_path != ''
|
||||||
ORDER BY RANDOM()
|
ORDER BY RANDOM()
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
""", (MAX_PER_SESSION,)).fetchall()
|
""", (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:
|
if result:
|
||||||
mbid = result.get("mbid", "")
|
mbid = result.get("mbid", "")
|
||||||
acoustid = result.get("acoustid", "")
|
acoustid = result.get("acoustid", "")
|
||||||
|
# Opdater acoustid altid, men kun mbid hvis det ikke allerede bruges
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE songs SET mbid=?, acoustid=? WHERE id=?",
|
"UPDATE songs SET acoustid=? WHERE id=?",
|
||||||
(mbid or None, acoustid or None, row["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()
|
conn.commit()
|
||||||
found += 1
|
found += 1
|
||||||
total_found += 1
|
total_found += 1
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
Skriver til files-tabellen og finder/opretter sange i songs-tabellen.
|
||||||
og rapporterer fremgang via stdout JSON-linjer.
|
|
||||||
|
|
||||||
Kan også importeres direkte og bruges via ScanWorker QThread.
|
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
import json
|
|
||||||
import sqlite3
|
|
||||||
import uuid
|
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
SUPPORTED = {'.mp3', '.flac', '.m4a', '.ogg', '.wav', '.aiff', '.wma'}
|
SUPPORTED = {'.mp3', '.flac', '.m4a', '.ogg', '.wav', '.aiff', '.wma'}
|
||||||
|
import uuid as _uuid_module
|
||||||
|
|
||||||
|
|
||||||
def is_supported(path: Path) -> bool:
|
def _find_or_create_song_conn(conn, title, artist, album, bpm,
|
||||||
return path.suffix.lower() in SUPPORTED
|
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:
|
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,
|
def scan_library(library_id: int, library_path: str, db_path: str,
|
||||||
overwrite_bpm: bool = False,
|
overwrite_bpm: bool = False,
|
||||||
progress_callback=None):
|
progress_callback=None) -> int:
|
||||||
"""
|
"""
|
||||||
Scan ét bibliotek og upsert sange til SQLite.
|
Scan ét bibliotek og upsert til files + songs tabellerne.
|
||||||
progress_callback(done, total, current_file) kaldes løbende.
|
Returnerer antal scannede filer.
|
||||||
"""
|
"""
|
||||||
|
import sqlite3
|
||||||
from local.tag_reader import read_tags
|
from local.tag_reader import read_tags
|
||||||
|
|
||||||
conn = sqlite3.connect(db_path)
|
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
|
|
||||||
base = Path(library_path)
|
base = Path(library_path)
|
||||||
if not base.exists():
|
if not base.exists():
|
||||||
conn.close()
|
|
||||||
return 0
|
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 = {}
|
known = {}
|
||||||
for row in conn.execute(
|
for row in conn.execute(
|
||||||
"SELECT local_path, file_modified_at, file_missing FROM songs WHERE library_id=?",
|
"SELECT local_path, file_modified_at FROM files WHERE file_missing=0"
|
||||||
(library_id,)
|
|
||||||
).fetchall():
|
).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"]
|
known[row["local_path"]] = row["file_modified_at"]
|
||||||
|
|
||||||
# Find alle musikfiler
|
# Find alle musikfiler
|
||||||
@@ -68,8 +116,6 @@ def scan_library(library_id: int, library_path: str, db_path: str,
|
|||||||
total = len(all_files)
|
total = len(all_files)
|
||||||
done = 0
|
done = 0
|
||||||
|
|
||||||
import time
|
|
||||||
|
|
||||||
for fp in all_files:
|
for fp in all_files:
|
||||||
path_str = str(fp)
|
path_str = str(fp)
|
||||||
mtime = get_file_mtime(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:
|
if progress_callback:
|
||||||
progress_callback(done, total, fp.name)
|
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:
|
if path_str in known and known[path_str] == mtime:
|
||||||
done += 1
|
done += 1
|
||||||
# Yield hvert 100. fil så andre tråde kan køre
|
|
||||||
if done % 100 == 0:
|
|
||||||
time.sleep(0.005)
|
time.sleep(0.005)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
tags = read_tags(fp)
|
tags = read_tags(str(fp))
|
||||||
extra = json.dumps(tags.get("extra_tags", {}), ensure_ascii=False)
|
title = tags.get("title", "") or fp.stem
|
||||||
|
|
||||||
# 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", "")
|
|
||||||
artist = tags.get("artist", "")
|
artist = tags.get("artist", "")
|
||||||
if title:
|
album = tags.get("album", "")
|
||||||
# Prioritér file_missing=1 sange, men tag også sange med ugyldig sti
|
bpm = tags.get("bpm", 0)
|
||||||
existing = conn.execute("""
|
mbid = tags.get("mbid", "")
|
||||||
SELECT id, bpm FROM songs
|
acoustid = tags.get("acoustid", "")
|
||||||
WHERE title=? AND artist=? AND file_missing=1
|
duration_sec = tags.get("duration_sec", 0)
|
||||||
LIMIT 1
|
file_format = tags.get("file_format", fp.suffix.lstrip(".").lower())
|
||||||
""", (title, artist)).fetchone()
|
import json as _json
|
||||||
if not existing:
|
_extra = tags.get("extra_tags", {})
|
||||||
# Tjek om der er en sang med samme titel+artist men ugyldig sti
|
extra_tags = _json.dumps(_extra) if isinstance(_extra, dict) else (_extra or "{}")
|
||||||
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
|
|
||||||
|
|
||||||
if existing:
|
# Find eller opret sang — alt via samme conn
|
||||||
# Opdater stien så den peger på den nye placering
|
song_id = _find_or_create_song_conn(
|
||||||
conn.execute(
|
conn, title, artist, album, bpm, duration_sec, mbid, acoustid
|
||||||
"UPDATE songs SET local_path=? WHERE id=?",
|
|
||||||
(path_str, existing["id"])
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if existing:
|
# Opdater BPM
|
||||||
bpm = tags.get("bpm", 0)
|
if bpm and bpm > 0:
|
||||||
if not overwrite_bpm and existing["bpm"] and existing["bpm"] > 0:
|
conn.execute(
|
||||||
bpm = existing["bpm"] # behold eksisterende BPM
|
"UPDATE songs SET bpm=? WHERE id=? AND (bpm=0 OR bpm IS NULL)",
|
||||||
mbid = tags.get("mbid", "")
|
(bpm, song_id)
|
||||||
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","")))
|
|
||||||
|
|
||||||
# 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", [])
|
file_dances = tags.get("dances", [])
|
||||||
if file_dances:
|
if file_dances:
|
||||||
existing_dances = conn.execute(
|
import uuid
|
||||||
"SELECT COUNT(*) FROM song_dances WHERE song_id=?", (song_id,)
|
# Slet eksisterende og genindsæt fra filen
|
||||||
).fetchone()[0]
|
conn.execute("DELETE FROM song_dances WHERE song_id=?", (song_id,))
|
||||||
if existing_dances == 0:
|
|
||||||
for order, dance_name in enumerate(file_dances, start=1):
|
for order, dance_name in enumerate(file_dances, start=1):
|
||||||
dance_row = conn.execute(
|
dance_row = conn.execute(
|
||||||
"SELECT id FROM dances WHERE name=? COLLATE NOCASE LIMIT 1",
|
"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:
|
else:
|
||||||
dance_id = dance_row["id"]
|
dance_id = dance_row["id"]
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR IGNORE INTO song_dances (song_id, dance_id, dance_order) VALUES (?,?,?)",
|
"INSERT OR IGNORE INTO song_dances (id, song_id, dance_id, dance_order) VALUES (?,?,?,?)",
|
||||||
(song_id, dance_id, order)
|
(str(uuid.uuid4()), song_id, dance_id, order)
|
||||||
)
|
)
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
except Exception as e:
|
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}")
|
logger.warning(f"Scan fejl {fp.name}: {e}")
|
||||||
|
|
||||||
done += 1
|
done += 1
|
||||||
# Lille pause efter hver scannet fil så GUI ikke hænger
|
|
||||||
time.sleep(0.02)
|
time.sleep(0.02)
|
||||||
|
|
||||||
# Marker manglende filer
|
# Marker manglende filer
|
||||||
for path_str in known:
|
for path_str in known:
|
||||||
if not Path(path_str).exists():
|
if not Path(path_str).exists():
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE songs SET file_missing=1 WHERE local_path=?", (path_str,)
|
"UPDATE files 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,)
|
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
logger.info(f"Scan færdig: {done} filer i {library_path}")
|
||||||
return done
|
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)
|
|
||||||
@@ -1,150 +1,124 @@
|
|||||||
"""
|
"""
|
||||||
sync_manager.py — Synkronisering mellem lokal SQLite og server API.
|
sync_manager.py — Synkronisering mellem lokal database og server. v0.9
|
||||||
Kører i baggrundstråd — blokerer aldrig GUI.
|
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import threading
|
import threading
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class SyncManager:
|
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):
|
def __init__(self, api_url: str = "", db_path: str = "",
|
||||||
return {
|
server_url: str = "", token: str | None = None):
|
||||||
"Content-Type": "application/json",
|
# Støt både api_url og server_url som parameter-navn
|
||||||
"Authorization": f"Bearer {self._token}",
|
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:
|
def _post(self, path: str, data: dict) -> dict:
|
||||||
body = json.dumps(data).encode("utf-8")
|
body = json.dumps(data).encode()
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
f"{self._server_url}{path}", data=body,
|
f"{self._api_url}{path}",
|
||||||
headers=self._headers(), method="POST"
|
data=body,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {self._token}",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
return json.loads(resp.read())
|
return json.loads(resp.read())
|
||||||
except urllib.error.HTTPError as e:
|
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}")
|
raise Exception(f"HTTP {e.code}: {detail}")
|
||||||
|
|
||||||
def _get(self, path: str) -> dict:
|
def _get(self, path: str) -> dict:
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
f"{self._server_url}{path}",
|
f"{self._api_url}{path}",
|
||||||
headers=self._headers(), method="GET"
|
headers={"Authorization": f"Bearer {self._token}"},
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
return json.loads(resp.read())
|
return json.loads(resp.read())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
detail = e.read().decode()
|
||||||
|
raise Exception(f"HTTP {e.code}: {detail}")
|
||||||
|
|
||||||
# ── Push ──────────────────────────────────────────────────────────────────
|
# ── Push ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def push(self, on_done=None, on_error=None):
|
def push(self, on_done=None, on_error=None):
|
||||||
"""Push lokal data til server i baggrundstråd."""
|
|
||||||
def _run():
|
def _run():
|
||||||
try:
|
try:
|
||||||
payload = self._build_push_payload()
|
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)
|
result = self._post("/sync/push", payload)
|
||||||
self._save_playlist_ids(result.get("playlist_id_map", {}))
|
self._save_server_ids(
|
||||||
# Fjern soft-slettede playlister permanent efter succesfuld push
|
result.get("song_id_map", {}),
|
||||||
if payload.get("deleted_playlists"):
|
result.get("playlist_id_map", {}),
|
||||||
conn = sqlite3.connect(self._db_path)
|
|
||||||
conn.execute(
|
|
||||||
"DELETE FROM playlists WHERE is_deleted=1 AND api_project_id IS NOT NULL"
|
|
||||||
)
|
)
|
||||||
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:
|
if on_done:
|
||||||
on_done(result)
|
on_done(result)
|
||||||
except Exception as e:
|
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:
|
if on_error:
|
||||||
on_error(str(e))
|
on_error(str(e))
|
||||||
threading.Thread(target=_run, daemon=True).start()
|
threading.Thread(target=_run, daemon=True).start()
|
||||||
|
|
||||||
def _save_playlist_ids(self, id_map: dict):
|
# ── Push + Pull ───────────────────────────────────────────────────────────
|
||||||
"""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()
|
|
||||||
|
|
||||||
def push_and_pull(self, on_done=None, on_error=None):
|
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():
|
def _run():
|
||||||
try:
|
try:
|
||||||
# 1. Push lokal data op — inkl. sletninger
|
# 1. Push
|
||||||
payload = self._build_push_payload()
|
payload = self._build_push_payload()
|
||||||
deleted = payload.get("deleted_playlists", [])
|
deleted = payload.get("deleted_playlists", [])
|
||||||
logger.info(f"Sync push — {len(payload['songs'])} sange, "
|
logger.info(f"Sync push — {len(payload['songs'])} sange, "
|
||||||
f"{len(payload['playlists'])} playlister, "
|
f"{len(payload['playlists'])} playlister, "
|
||||||
f"sletter {len(deleted)}: {deleted}")
|
f"sletter {len(deleted)}: {deleted}")
|
||||||
push_result = self._post("/sync/push", payload)
|
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')}, "
|
logger.info(f"Push svar: status={push_result.get('status')}, "
|
||||||
f"sange={push_result.get('songs_synced', 0)}, "
|
f"sange={push_result.get('songs_synced', 0)}, "
|
||||||
f"playlister={push_result.get('playlists_synced', 0)}")
|
f"playlister={push_result.get('playlists_synced', 0)}")
|
||||||
|
|
||||||
# 2. Pull — sletninger er nu gennemført på serveren.
|
# 2. Pull
|
||||||
# _apply_pull filtrerer is_deleted=1 rækker fra automatisk.
|
|
||||||
pull_result = self._get("/sync/pull")
|
pull_result = self._get("/sync/pull")
|
||||||
pl_names = [p.get("name") for p in pull_result.get("my_playlists", [])]
|
pl_names = [p.get("name") for p in pull_result.get("my_playlists", [])]
|
||||||
logger.info(f"Pull modtog {len(pl_names)} playlister: {pl_names}")
|
logger.info(f"Pull modtog {len(pl_names)} playlister: {pl_names}")
|
||||||
self._apply_pull(pull_result)
|
self._apply_pull(pull_result)
|
||||||
|
|
||||||
# Fjern soft-slettede playlister permanent nu serveren er opdateret
|
# 3. Fjern soft-slettede permanent efter succesfuld sync
|
||||||
if deleted:
|
if deleted:
|
||||||
conn = sqlite3.connect(self._db_path)
|
conn = sqlite3.connect(self._db_path, timeout=10)
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"DELETE FROM playlists WHERE is_deleted=1 AND api_project_id IS NOT NULL"
|
"DELETE FROM playlists WHERE is_deleted=1 AND api_project_id IS NOT NULL"
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
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", []))
|
pl_count = len(pull_result.get("my_playlists", []))
|
||||||
logger.info(
|
logger.info(f"Sync OK — {len(payload['songs'])} sange, "
|
||||||
f"Sync OK — {len(payload['songs'])} sange, "
|
|
||||||
f"{len(payload['playlists'])} playlister, "
|
f"{len(payload['playlists'])} playlister, "
|
||||||
f"{pl_count} server-playlister"
|
f"{pl_count} server-playlister")
|
||||||
)
|
|
||||||
if on_done:
|
if on_done:
|
||||||
on_done({"push": push_result, "pull": pull_result})
|
on_done({"push": push_result, "pull": pull_result})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -156,34 +130,36 @@ class SyncManager:
|
|||||||
# ── Byg payload ───────────────────────────────────────────────────────────
|
# ── Byg payload ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _build_push_payload(self) -> dict:
|
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.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
|
||||||
# Sange
|
# Sange (dem der har filer — altså kendes lokalt)
|
||||||
songs = []
|
songs = []
|
||||||
for row in conn.execute(
|
for row in conn.execute("""
|
||||||
"SELECT id, title, artist, album, bpm, duration_sec, file_format, mbid, acoustid "
|
SELECT DISTINCT s.id, s.title, s.artist, s.album,
|
||||||
"FROM songs WHERE file_missing=0"
|
s.bpm, s.duration_sec, s.mbid, s.acoustid, s.server_synced
|
||||||
).fetchall():
|
FROM songs s
|
||||||
|
JOIN files f ON f.song_id = s.id AND f.file_missing = 0
|
||||||
|
""").fetchall():
|
||||||
songs.append({
|
songs.append({
|
||||||
"local_id": str(row["id"]),
|
"local_id": row["id"],
|
||||||
"title": row["title"] or "",
|
"title": row["title"] or "",
|
||||||
"artist": row["artist"] or "",
|
"artist": row["artist"] or "",
|
||||||
"album": row["album"] or "",
|
"album": row["album"] or "",
|
||||||
"bpm": row["bpm"] or 0,
|
"bpm": row["bpm"] or 0,
|
||||||
"duration_sec": row["duration_sec"] or 0,
|
"duration_sec": row["duration_sec"] or 0,
|
||||||
"file_format": row["file_format"] or "",
|
|
||||||
"mbid": row["mbid"] or "",
|
"mbid": row["mbid"] or "",
|
||||||
"acoustid": row["acoustid"] or "",
|
"acoustid": row["acoustid"] or "",
|
||||||
})
|
})
|
||||||
|
|
||||||
# Danse
|
# Danse
|
||||||
dances = []
|
dances = []
|
||||||
for row in conn.execute(
|
for row in conn.execute("""
|
||||||
"SELECT d.name, dl.name as level_name, d.choreographer, "
|
SELECT d.name, dl.name as level_name, d.choreographer,
|
||||||
"d.video_url, d.stepsheet_url, d.notes "
|
d.video_url, d.stepsheet_url, d.notes
|
||||||
"FROM dances d LEFT JOIN dance_levels dl ON dl.id = d.level_id"
|
FROM dances d LEFT JOIN dance_levels dl ON dl.id = d.level_id
|
||||||
).fetchall():
|
""").fetchall():
|
||||||
dances.append({
|
dances.append({
|
||||||
"name": row["name"] or "",
|
"name": row["name"] or "",
|
||||||
"level_name": row["level_name"] or "",
|
"level_name": row["level_name"] or "",
|
||||||
@@ -193,16 +169,17 @@ class SyncManager:
|
|||||||
"notes": row["notes"] or "",
|
"notes": row["notes"] or "",
|
||||||
})
|
})
|
||||||
|
|
||||||
# Dans-tags per sang
|
# Dans-tags
|
||||||
song_dances = []
|
song_dances = []
|
||||||
for row in conn.execute("""
|
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
|
FROM song_dances sd
|
||||||
JOIN dances d ON d.id = sd.dance_id
|
JOIN dances d ON d.id = sd.dance_id
|
||||||
LEFT JOIN dance_levels dl ON dl.id = d.level_id
|
LEFT JOIN dance_levels dl ON dl.id = d.level_id
|
||||||
""").fetchall():
|
""").fetchall():
|
||||||
song_dances.append({
|
song_dances.append({
|
||||||
"song_local_id": str(row["song_id"]),
|
"song_local_id": row["song_id"],
|
||||||
"dance_name": row["dance_name"],
|
"dance_name": row["dance_name"],
|
||||||
"level_name": row["level_name"] or "",
|
"level_name": row["level_name"] or "",
|
||||||
"dance_order": row["dance_order"],
|
"dance_order": row["dance_order"],
|
||||||
@@ -211,36 +188,37 @@ class SyncManager:
|
|||||||
# Alternativ-danse
|
# Alternativ-danse
|
||||||
song_alts = []
|
song_alts = []
|
||||||
for row in conn.execute("""
|
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
|
FROM song_alt_dances sad
|
||||||
JOIN dances d ON d.id = sad.dance_id
|
JOIN dances d ON d.id = sad.dance_id
|
||||||
LEFT JOIN dance_levels dl ON dl.id = d.level_id
|
LEFT JOIN dance_levels dl ON dl.id = d.level_id
|
||||||
""").fetchall():
|
""").fetchall():
|
||||||
song_alts.append({
|
song_alts.append({
|
||||||
"song_local_id": str(row["song_id"]),
|
"song_local_id": row["song_id"],
|
||||||
"dance_name": row["dance_name"],
|
"dance_name": row["dance_name"],
|
||||||
"level_name": row["level_name"] or "",
|
"level_name": row["level_name"] or "",
|
||||||
"note": row["note"] or "",
|
"note": row["note"] or "",
|
||||||
|
"user_rating": row["user_rating"],
|
||||||
})
|
})
|
||||||
|
|
||||||
# Playlister — send alle (nye og eksisterende) til serveren.
|
# Playlister — alle ikke-slettede
|
||||||
# Brug api_project_id som local_id hvis den kendes — så serveren
|
|
||||||
# kan matche på ID og ikke oprette duplikater.
|
|
||||||
playlists = []
|
playlists = []
|
||||||
for pl in conn.execute(
|
for pl in conn.execute("""
|
||||||
"SELECT id, name, description, tags, api_project_id FROM playlists "
|
SELECT id, name, description, tags, api_project_id
|
||||||
"WHERE name != '__aktiv__' AND is_deleted = 0"
|
FROM playlists
|
||||||
).fetchall():
|
WHERE name != '__aktiv__' AND is_deleted = 0
|
||||||
|
""").fetchall():
|
||||||
pl_songs = []
|
pl_songs = []
|
||||||
for ps in conn.execute("""
|
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
|
ps.position, ps.status, ps.is_workshop, ps.dance_override
|
||||||
FROM playlist_songs ps
|
FROM playlist_songs ps
|
||||||
JOIN songs s ON s.id = ps.song_id
|
JOIN songs s ON s.id = ps.song_id
|
||||||
WHERE ps.playlist_id=? ORDER BY ps.position
|
WHERE ps.playlist_id=? ORDER BY ps.position
|
||||||
""", (pl["id"],)).fetchall():
|
""", (pl["id"],)).fetchall():
|
||||||
pl_songs.append({
|
pl_songs.append({
|
||||||
"song_local_id": str(ps["id"]),
|
"song_local_id": ps["song_id"],
|
||||||
"song_title": ps["title"] or "",
|
"song_title": ps["title"] or "",
|
||||||
"song_artist": ps["artist"] or "",
|
"song_artist": ps["artist"] or "",
|
||||||
"position": int(ps["position"] or 1),
|
"position": int(ps["position"] or 1),
|
||||||
@@ -248,9 +226,8 @@ class SyncManager:
|
|||||||
"is_workshop": bool(ps["is_workshop"]),
|
"is_workshop": bool(ps["is_workshop"]),
|
||||||
"dance_override": ps["dance_override"] or "",
|
"dance_override": ps["dance_override"] or "",
|
||||||
})
|
})
|
||||||
# Brug api_project_id som local_id hvis den kendes —
|
# Brug api_project_id som local_id hvis kendt
|
||||||
# serveren bruger dette til at finde eksisterende liste
|
local_id = pl["api_project_id"] or pl["id"]
|
||||||
local_id = pl["api_project_id"] or str(pl["id"])
|
|
||||||
playlists.append({
|
playlists.append({
|
||||||
"local_id": local_id,
|
"local_id": local_id,
|
||||||
"name": pl["name"],
|
"name": pl["name"],
|
||||||
@@ -260,9 +237,7 @@ class SyncManager:
|
|||||||
"songs": pl_songs,
|
"songs": pl_songs,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Slettede playlister — skal fjernes fra serveren.
|
# Slettede playlister
|
||||||
# Serveren forventer en liste af strings (api_project_id).
|
|
||||||
# Kun playlister der faktisk er nået serveren (har api_project_id).
|
|
||||||
deleted = [
|
deleted = [
|
||||||
row["api_project_id"]
|
row["api_project_id"]
|
||||||
for row in conn.execute(
|
for row in conn.execute(
|
||||||
@@ -271,6 +246,9 @@ class SyncManager:
|
|||||||
).fetchall()
|
).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()
|
conn.close()
|
||||||
return {
|
return {
|
||||||
"songs": songs,
|
"songs": songs,
|
||||||
@@ -279,34 +257,103 @@ class SyncManager:
|
|||||||
"song_alts": song_alts,
|
"song_alts": song_alts,
|
||||||
"playlists": playlists,
|
"playlists": playlists,
|
||||||
"deleted_playlists": deleted,
|
"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 ───────────────────────────────────────────────────────────
|
# ── Anvend pull ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _apply_pull(self, data: dict):
|
def _apply_pull(self, data: dict):
|
||||||
"""Gem server-data lokalt — opdaterer dans-info og importerer playlister."""
|
"""Gem server-data lokalt."""
|
||||||
conn = sqlite3.connect(self._db_path)
|
import uuid
|
||||||
|
conn = sqlite3.connect(self._db_path, timeout=10)
|
||||||
conn.row_factory = sqlite3.Row
|
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", []):
|
for d in data.get("dances", []):
|
||||||
if not d.get("name"):
|
if not d.get("name"):
|
||||||
continue
|
continue
|
||||||
|
choreo = d.get("choreographer", "") or ""
|
||||||
existing = conn.execute(
|
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()
|
).fetchone()
|
||||||
if existing and (d.get("choreographer") or d.get("video_url") or d.get("stepsheet_url")):
|
if existing:
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
UPDATE dances SET
|
UPDATE dances SET
|
||||||
choreographer = CASE WHEN choreographer='' THEN ? ELSE choreographer END,
|
|
||||||
video_url = CASE WHEN video_url='' THEN ? ELSE video_url END,
|
video_url = CASE WHEN video_url='' THEN ? ELSE video_url END,
|
||||||
stepsheet_url = CASE WHEN stepsheet_url='' THEN ? ELSE stepsheet_url END
|
stepsheet_url = CASE WHEN stepsheet_url='' THEN ? ELSE stepsheet_url END
|
||||||
WHERE id=?
|
WHERE id=?
|
||||||
""", (d.get("choreographer",""), d.get("video_url",""),
|
""", (d.get("video_url",""), d.get("stepsheet_url",""), existing["id"]))
|
||||||
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 soft-slettede server-IDs så vi springer dem over
|
||||||
# Hent server-IDs på soft-slettede playlister så vi springer dem over
|
|
||||||
deleted_server_ids = {
|
deleted_server_ids = {
|
||||||
row["api_project_id"]
|
row["api_project_id"]
|
||||||
for row in conn.execute(
|
for row in conn.execute(
|
||||||
@@ -315,13 +362,12 @@ class SyncManager:
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Importer egne playlister
|
||||||
for pl in data.get("my_playlists", []):
|
for pl in data.get("my_playlists", []):
|
||||||
server_id = pl.get("server_id")
|
server_id = pl.get("server_id")
|
||||||
name = pl.get("name", "")
|
name = pl.get("name", "")
|
||||||
if not server_id or not name:
|
if not server_id or not name:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Spring over hvis listen er soft-slettet lokalt
|
|
||||||
if server_id in deleted_server_ids:
|
if server_id in deleted_server_ids:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -331,52 +377,59 @@ class SyncManager:
|
|||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
pl_id = existing["id"]
|
pl_id = existing["id"]
|
||||||
# Opdater navn hvis det er ændret på serveren
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE playlists SET name=? WHERE id=?", (name, pl_id)
|
"UPDATE playlists SET name=? WHERE id=?", (name, pl_id)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
cur = conn.execute(
|
pl_id = str(uuid.uuid4())
|
||||||
"INSERT INTO playlists (name, description, api_project_id, is_linked, server_permission) "
|
conn.execute(
|
||||||
"VALUES (?,?,?,1,'edit')",
|
"INSERT INTO playlists (id, name, description, api_project_id, is_linked, server_permission) "
|
||||||
(name, pl.get("description",""), server_id)
|
"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,))
|
conn.execute("DELETE FROM playlist_songs WHERE playlist_id=?", (pl_id,))
|
||||||
position = 1
|
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", "")
|
title = song_data.get("title", "")
|
||||||
artist = song_data.get("artist", "")
|
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
|
continue
|
||||||
local = conn.execute(
|
|
||||||
"SELECT id FROM songs WHERE title=? AND artist=? LIMIT 1",
|
# Find eller opret sang lokalt
|
||||||
(title, artist)
|
local_song_id = self._find_or_create_song_local(
|
||||||
).fetchone()
|
conn, server_song_id, title, artist,
|
||||||
if not local:
|
mbid=mbid, acoustid=acoustid,
|
||||||
import uuid
|
bpm=song_data.get("bpm", 0),
|
||||||
new_id = str(uuid.uuid4())
|
duration_sec=song_data.get("duration_sec", 0),
|
||||||
conn.execute(
|
|
||||||
"INSERT OR IGNORE INTO songs (id, title, artist, file_missing) VALUES (?,?,?,1)",
|
|
||||||
(new_id, title, artist)
|
|
||||||
)
|
)
|
||||||
local_id = new_id
|
|
||||||
else:
|
# Find tilgængelig fil til denne sang
|
||||||
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("""
|
conn.execute("""
|
||||||
INSERT OR IGNORE INTO playlist_songs
|
INSERT INTO playlist_songs
|
||||||
(playlist_id, song_id, position, status, is_workshop, dance_override)
|
(id, playlist_id, song_id, file_id, position, status, is_workshop, dance_override)
|
||||||
VALUES (?,?,?,?,?,?)
|
VALUES (?,?,?,?,?,?,?,?)
|
||||||
""", (pl_id, local_id, position,
|
""", (str(uuid.uuid4()), pl_id, local_song_id, file_id, position,
|
||||||
song_data.get("status","pending"),
|
song_data.get("status","pending"),
|
||||||
1 if song_data.get("is_workshop") else 0,
|
1 if song_data.get("is_workshop") else 0,
|
||||||
song_data.get("dance_override","") or ""))
|
song_data.get("dance_override","") or ""))
|
||||||
position += 1
|
position += 1
|
||||||
|
|
||||||
# Importer delte playlister (read-only — is_linked=1, server_permission='view')
|
# Importer delte playlister
|
||||||
for pl in data.get("shared", []):
|
for pl in data.get("shared", []):
|
||||||
server_id = pl.get("server_id")
|
server_id = pl.get("server_id")
|
||||||
name = pl.get("name", "")
|
name = pl.get("name", "")
|
||||||
@@ -389,46 +442,189 @@ class SyncManager:
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
# Opdater sange fra server (ejer kan have ændret listen)
|
|
||||||
pl_id = existing["id"]
|
pl_id = existing["id"]
|
||||||
conn.execute("DELETE FROM playlist_songs WHERE playlist_id=?", (pl_id,))
|
conn.execute("DELETE FROM playlist_songs WHERE playlist_id=?", (pl_id,))
|
||||||
else:
|
else:
|
||||||
cur = conn.execute(
|
pl_id = str(uuid.uuid4())
|
||||||
"INSERT INTO playlists (name, description, api_project_id, is_linked, server_permission) "
|
conn.execute(
|
||||||
"VALUES (?,?,?,1,'view')",
|
"INSERT INTO playlists (id, name, description, api_project_id, is_linked, server_permission) "
|
||||||
(f"{name} ({owner})", "", server_id)
|
"VALUES (?,?,?,?,1,'view')",
|
||||||
|
(pl_id, f"{name} ({owner})", "", server_id)
|
||||||
)
|
)
|
||||||
pl_id = cur.lastrowid
|
|
||||||
|
|
||||||
position = 1
|
position = 1
|
||||||
for song_data in pl.get("songs", []):
|
for song_data in pl.get("songs", []):
|
||||||
|
server_song_id = song_data.get("song_id", "")
|
||||||
title = song_data.get("title", "")
|
title = song_data.get("title", "")
|
||||||
artist = song_data.get("artist", "")
|
artist = song_data.get("artist", "")
|
||||||
if not title:
|
if not title and not server_song_id:
|
||||||
continue
|
continue
|
||||||
local = conn.execute(
|
|
||||||
"SELECT id FROM songs WHERE title=? AND artist=? LIMIT 1",
|
local_song_id = self._find_or_create_song_local(
|
||||||
(title, artist)
|
conn, server_song_id, title, artist,
|
||||||
).fetchone()
|
mbid=song_data.get("mbid", ""),
|
||||||
if not local:
|
acoustid=song_data.get("acoustid", ""),
|
||||||
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_id = new_id
|
file_row = conn.execute(
|
||||||
else:
|
"SELECT id FROM files WHERE song_id=? AND file_missing=0 LIMIT 1",
|
||||||
local_id = local["id"]
|
(local_song_id,)
|
||||||
|
).fetchone()
|
||||||
|
file_id = file_row["id"] if file_row else None
|
||||||
|
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
INSERT OR IGNORE INTO playlist_songs
|
INSERT INTO playlist_songs
|
||||||
(playlist_id, song_id, position, status, is_workshop, dance_override)
|
(id, playlist_id, song_id, file_id, position, status, is_workshop, dance_override)
|
||||||
VALUES (?,?,?,?,?,?)
|
VALUES (?,?,?,?,?,?,?,?)
|
||||||
""", (pl_id, local_id, position,
|
""", (str(uuid.uuid4()), pl_id, local_song_id, file_id, position,
|
||||||
song_data.get("status","pending"),
|
song_data.get("status","pending"),
|
||||||
1 if song_data.get("is_workshop") else 0,
|
1 if song_data.get("is_workshop") else 0,
|
||||||
song_data.get("dance_override","") or ""))
|
song_data.get("dance_override","") or ""))
|
||||||
position += 1
|
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()
|
conn.commit()
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
conn.close()
|
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
|
||||||
@@ -412,12 +412,19 @@ def read_dances_from_file(path: str | Path) -> list[str]:
|
|||||||
|
|
||||||
# ── BPM-analyse ───────────────────────────────────────────────────────────────
|
# ── 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:
|
def analyze_bpm(path: str | Path) -> float | None:
|
||||||
"""
|
"""
|
||||||
Analysér BPM fra lydfilen ved hjælp af librosa.
|
Analysér BPM fra lydfilen ved hjælp af librosa.
|
||||||
Returnerer BPM som float eller None ved fejl.
|
Returnerer BPM som float eller None ved fejl.
|
||||||
Tager 2-5 sekunder per sang — kør i baggrundstråd.
|
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:
|
try:
|
||||||
import librosa
|
import librosa
|
||||||
# Indlæs kun de første 60 sekunder for hastighed
|
# Indlæs kun de første 60 sekunder for hastighed
|
||||||
|
|||||||
@@ -8,7 +8,15 @@ Start:
|
|||||||
import sys
|
import sys
|
||||||
import os
|
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__))
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
|
||||||
|
|||||||
345
linedance-app/ui/alt_dance_picker_dialog.py
Normal file
345
linedance-app/ui/alt_dance_picker_dialog.py
Normal 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
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"""
|
"""
|
||||||
bpm_worker.py — QThread til BPM-analyse i baggrunden.
|
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
|
import sqlite3
|
||||||
from PyQt6.QtCore import QThread, pyqtSignal
|
from PyQt6.QtCore import QThread, pyqtSignal
|
||||||
@@ -15,10 +16,10 @@ class BpmScanWorker(QThread):
|
|||||||
self._library_id = library_id
|
self._library_id = library_id
|
||||||
self._db_path = db_path
|
self._db_path = db_path
|
||||||
self._scan_all = scan_all
|
self._scan_all = scan_all
|
||||||
|
self._cancelled = False
|
||||||
|
|
||||||
def cancel(self):
|
def cancel(self):
|
||||||
self.requestInterruption()
|
self.requestInterruption()
|
||||||
# Afbryd hurtigt ved at sætte et flag
|
|
||||||
self._cancelled = True
|
self._cancelled = True
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
@@ -28,20 +29,34 @@ class BpmScanWorker(QThread):
|
|||||||
from local.tag_reader import analyze_bpm
|
from local.tag_reader import analyze_bpm
|
||||||
conn = sqlite3.connect(self._db_path)
|
conn = sqlite3.connect(self._db_path)
|
||||||
conn.row_factory = sqlite3.Row
|
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:
|
if self._scan_all:
|
||||||
songs = conn.execute(
|
songs = conn.execute("""
|
||||||
"SELECT id, local_path FROM songs "
|
SELECT s.id, f.local_path
|
||||||
"WHERE library_id=? AND file_missing=0",
|
FROM songs s
|
||||||
(self._library_id,)
|
JOIN files f ON f.song_id = s.id AND f.file_missing = 0
|
||||||
).fetchall()
|
WHERE f.local_path LIKE ?
|
||||||
|
""", (lib_path + "%",)).fetchall()
|
||||||
else:
|
else:
|
||||||
songs = conn.execute(
|
songs = conn.execute("""
|
||||||
"SELECT id, local_path FROM songs "
|
SELECT s.id, f.local_path
|
||||||
"WHERE library_id=? AND file_missing=0 "
|
FROM songs s
|
||||||
"AND (bpm IS NULL OR bpm=0)",
|
JOIN files f ON f.song_id = s.id AND f.file_missing = 0
|
||||||
(self._library_id,)
|
WHERE f.local_path LIKE ?
|
||||||
).fetchall()
|
AND (s.bpm IS NULL OR s.bpm = 0)
|
||||||
|
""", (lib_path + "%",)).fetchall()
|
||||||
|
|
||||||
total = len(songs)
|
total = len(songs)
|
||||||
done = 0
|
done = 0
|
||||||
@@ -61,9 +76,9 @@ class BpmScanWorker(QThread):
|
|||||||
pass
|
pass
|
||||||
done += 1
|
done += 1
|
||||||
self.progress.emit(done, total)
|
self.progress.emit(done, total)
|
||||||
time.sleep(0.01) # Yield så GUI ikke hænger
|
time.sleep(0.01)
|
||||||
|
|
||||||
conn.close()
|
conn.close()
|
||||||
self.finished.emit(done)
|
self.finished.emit(done)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
self.finished.emit(0)
|
self.finished.emit(0)
|
||||||
@@ -31,11 +31,10 @@ class DanceInfoDialog(QDialog):
|
|||||||
|
|
||||||
def _load_dances(self):
|
def _load_dances(self):
|
||||||
try:
|
try:
|
||||||
from local.local_db import get_dances_for_song, get_alt_dances_for_song, new_conn
|
from local.local_db import get_db
|
||||||
conn = new_conn()
|
with get_db() as conn:
|
||||||
|
|
||||||
rows = conn.execute("""
|
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,
|
d.video_url, d.stepsheet_url, d.notes,
|
||||||
dl.name as level_name
|
dl.name as level_name
|
||||||
FROM song_dances sd
|
FROM song_dances sd
|
||||||
@@ -56,9 +55,8 @@ class DanceInfoDialog(QDialog):
|
|||||||
"is_alt": False,
|
"is_alt": False,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Alternativ-danse
|
|
||||||
alt_rows = conn.execute("""
|
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,
|
d.video_url, d.stepsheet_url, d.notes,
|
||||||
dl.name as level_name
|
dl.name as level_name
|
||||||
FROM song_alt_dances sad
|
FROM song_alt_dances sad
|
||||||
@@ -78,7 +76,6 @@ class DanceInfoDialog(QDialog):
|
|||||||
"notes": row["notes"] or "",
|
"notes": row["notes"] or "",
|
||||||
"is_alt": True,
|
"is_alt": True,
|
||||||
})
|
})
|
||||||
conn.close()
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"DanceInfoDialog load fejl: {e}")
|
print(f"DanceInfoDialog load fejl: {e}")
|
||||||
|
|
||||||
@@ -204,15 +201,14 @@ class DanceInfoDialog(QDialog):
|
|||||||
def _save(self):
|
def _save(self):
|
||||||
self._save_to_cache(self._current_idx)
|
self._save_to_cache(self._current_idx)
|
||||||
try:
|
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:
|
for d in self._dances:
|
||||||
update_dance_info(
|
conn.execute("""
|
||||||
d["dance_id"],
|
UPDATE dances SET choreographer=?, video_url=?,
|
||||||
choreographer = d["choreographer"],
|
stepsheet_url=?, notes=? WHERE id=?
|
||||||
video_url = d["video_url"],
|
""", (d["choreographer"], d["video_url"],
|
||||||
stepsheet_url = d["stepsheet_url"],
|
d["stepsheet_url"], d["notes"], d["dance_id"]))
|
||||||
notes = d["notes"],
|
|
||||||
)
|
|
||||||
self.accept()
|
self.accept()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.warning(self, "Fejl", f"Kunne ikke gemme: {e}")
|
QMessageBox.warning(self, "Fejl", f"Kunne ikke gemme: {e}")
|
||||||
|
|||||||
@@ -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 (
|
from PyQt6.QtWidgets import (
|
||||||
@@ -10,18 +12,18 @@ from PyQt6.QtCore import Qt, QTimer
|
|||||||
|
|
||||||
|
|
||||||
class DancePickerDialog(QDialog):
|
class DancePickerDialog(QDialog):
|
||||||
def __init__(self, current_dance: str = "", current_choreo: str = "",
|
def __init__(self, current_dance: str = "", song_title: str = "",
|
||||||
song_title: str = "", parent=None):
|
existing_dances: list[str] = None, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._chosen_dance = current_dance
|
self._chosen_dance = current_dance
|
||||||
self._chosen_choreo = current_choreo
|
self._existing_dances = existing_dances or []
|
||||||
self.setWindowTitle("Vælg dans")
|
self.setWindowTitle("Vælg dans")
|
||||||
self.setMinimumWidth(400)
|
self.setMinimumWidth(420)
|
||||||
self.setFixedWidth(440)
|
self.setFixedWidth(460)
|
||||||
self._build_ui(current_dance, current_choreo, song_title)
|
self._build_ui(current_dance, song_title)
|
||||||
self._load_dance_suggestions("")
|
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 = QVBoxLayout(self)
|
||||||
layout.setContentsMargins(12, 12, 12, 12)
|
layout.setContentsMargins(12, 12, 12, 12)
|
||||||
layout.setSpacing(8)
|
layout.setSpacing(8)
|
||||||
@@ -32,65 +34,38 @@ class DancePickerDialog(QDialog):
|
|||||||
lbl.setWordWrap(True)
|
lbl.setWordWrap(True)
|
||||||
layout.addWidget(lbl)
|
layout.addWidget(lbl)
|
||||||
|
|
||||||
# ── Dans ──────────────────────────────────────────────────────────────
|
layout.addWidget(QLabel("Dans:"))
|
||||||
lbl2 = QLabel("Dans:")
|
|
||||||
lbl2.setObjectName("track_meta")
|
|
||||||
layout.addWidget(lbl2)
|
|
||||||
|
|
||||||
self._edit_dance = QLineEdit()
|
self._edit = QLineEdit()
|
||||||
self._edit_dance.setText(current_dance)
|
self._edit.setText(current_dance)
|
||||||
self._edit_dance.setPlaceholderText("Skriv dans-navn...")
|
self._edit.setPlaceholderText("Skriv dans-navn...")
|
||||||
self._edit_dance.selectAll()
|
self._edit.selectAll()
|
||||||
self._edit_dance.textChanged.connect(self._on_dance_text_changed)
|
self._edit.textChanged.connect(self._on_text_changed)
|
||||||
self._edit_dance.returnPressed.connect(lambda: self._edit_choreo.setFocus())
|
self._edit.returnPressed.connect(self._on_accept)
|
||||||
layout.addWidget(self._edit_dance)
|
layout.addWidget(self._edit)
|
||||||
|
|
||||||
self._dance_list = QListWidget()
|
# Forslagsliste
|
||||||
self._dance_list.setMaximumHeight(160)
|
self._list = QListWidget()
|
||||||
self._dance_list.itemDoubleClicked.connect(self._on_dance_selected)
|
self._list.setMinimumHeight(200)
|
||||||
self._dance_list.itemClicked.connect(
|
self._list.itemDoubleClicked.connect(self._on_selected)
|
||||||
lambda item: self._edit_dance.setText(
|
self._list.itemClicked.connect(self._on_item_clicked)
|
||||||
item.data(Qt.ItemDataRole.UserRole) or item.text().split(" / ")[0]
|
layout.addWidget(self._list)
|
||||||
)
|
|
||||||
)
|
|
||||||
layout.addWidget(self._dance_list)
|
|
||||||
|
|
||||||
# ── Koreograf ─────────────────────────────────────────────────────────
|
# Info-label — viser niveau/koreograf for valgt dans
|
||||||
lbl3 = QLabel("Koreograf (valgfri):")
|
self._info_lbl = QLabel("")
|
||||||
lbl3.setObjectName("track_meta")
|
self._info_lbl.setObjectName("result_count")
|
||||||
layout.addWidget(lbl3)
|
self._info_lbl.setWordWrap(True)
|
||||||
|
layout.addWidget(self._info_lbl)
|
||||||
|
|
||||||
self._edit_choreo = QLineEdit()
|
# Debounce timer
|
||||||
self._edit_choreo.setText(current_choreo)
|
self._timer = QTimer(self)
|
||||||
self._edit_choreo.setPlaceholderText("Koreografens navn...")
|
self._timer.setSingleShot(True)
|
||||||
self._edit_choreo.textChanged.connect(self._on_choreo_text_changed)
|
self._timer.setInterval(150)
|
||||||
self._edit_choreo.returnPressed.connect(self._on_accept)
|
self._timer.timeout.connect(
|
||||||
layout.addWidget(self._edit_choreo)
|
lambda: self._load_suggestions(self._edit.text().strip())
|
||||||
|
|
||||||
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())
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self._choreo_timer = QTimer(self)
|
# Knapper
|
||||||
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 ───────────────────────────────────────────────────────────
|
|
||||||
btn_row = QHBoxLayout()
|
btn_row = QHBoxLayout()
|
||||||
btn_row.addStretch()
|
btn_row.addStretch()
|
||||||
btn_cancel = QPushButton("Annuller")
|
btn_cancel = QPushButton("Annuller")
|
||||||
@@ -102,62 +77,102 @@ class DancePickerDialog(QDialog):
|
|||||||
btn_row.addWidget(btn_ok)
|
btn_row.addWidget(btn_ok)
|
||||||
layout.addLayout(btn_row)
|
layout.addLayout(btn_row)
|
||||||
|
|
||||||
self._edit_dance.setFocus()
|
self._edit.setFocus()
|
||||||
|
|
||||||
def _on_dance_text_changed(self):
|
def _on_text_changed(self):
|
||||||
self._dance_timer.start()
|
self._timer.start()
|
||||||
|
|
||||||
def _on_choreo_text_changed(self):
|
def _load_suggestions(self, prefix: str):
|
||||||
self._choreo_timer.start()
|
|
||||||
|
|
||||||
def _load_dance_suggestions(self, prefix: str):
|
|
||||||
try:
|
try:
|
||||||
from local.local_db import get_dance_suggestions
|
from local.local_db import get_dance_suggestions
|
||||||
suggestions = get_dance_suggestions(prefix or "", limit=20)
|
from PyQt6.QtGui import QColor
|
||||||
self._dance_list.clear()
|
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:
|
for s in suggestions:
|
||||||
label = f"{s['name']} / {s['level_name']}" if s.get("level_name") else s["name"]
|
s = dict(s)
|
||||||
if s.get("choreographer"):
|
name = s["name"]
|
||||||
label += f" ({s['choreographer']})"
|
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 = QListWidgetItem(label)
|
||||||
item.setData(Qt.ItemDataRole.UserRole, s["name"])
|
item.setData(Qt.ItemDataRole.UserRole, {
|
||||||
item.setData(Qt.ItemDataRole.UserRole + 1, s.get("choreographer", ""))
|
"name": name,
|
||||||
self._dance_list.addItem(item)
|
"level": level,
|
||||||
except Exception:
|
"choreo": choreo,
|
||||||
pass
|
})
|
||||||
|
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):
|
def _on_item_clicked(self, item: QListWidgetItem):
|
||||||
try:
|
data = item.data(Qt.ItemDataRole.UserRole)
|
||||||
from local.local_db import get_choreographer_suggestions
|
if not data: # separator — ignorer
|
||||||
suggestions = get_choreographer_suggestions(prefix or "", limit=15)
|
return
|
||||||
self._choreo_list.clear()
|
name = data.get("name", "")
|
||||||
for name in suggestions:
|
level = data.get("level", "")
|
||||||
self._choreo_list.addItem(QListWidgetItem(name))
|
choreo = data.get("choreo", "")
|
||||||
except Exception:
|
self._edit.setText(name)
|
||||||
pass
|
# 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):
|
def _on_selected(self, item: QListWidgetItem):
|
||||||
name = item.data(Qt.ItemDataRole.UserRole) or item.text().split(" / ")[0]
|
data = item.data(Qt.ItemDataRole.UserRole)
|
||||||
choreo = item.data(Qt.ItemDataRole.UserRole + 1) or ""
|
if not data: # separator
|
||||||
self._edit_dance.setText(name)
|
return
|
||||||
if choreo and not self._edit_choreo.text().strip():
|
self._on_item_clicked(item)
|
||||||
self._edit_choreo.setText(choreo)
|
self._on_accept()
|
||||||
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_accept(self):
|
def _on_accept(self):
|
||||||
self._chosen_dance = self._edit_dance.text().strip()
|
self._chosen_dance = self._edit.text().strip()
|
||||||
self._chosen_choreo = self._edit_choreo.text().strip()
|
self.accept() # tillad tom streng = ingen dans
|
||||||
if self._chosen_dance:
|
|
||||||
self.accept()
|
|
||||||
|
|
||||||
def get_dance(self) -> str:
|
def get_dance(self) -> str:
|
||||||
return self._chosen_dance
|
return self._chosen_dance
|
||||||
|
|
||||||
|
# Behold get_choreo for bagudkompatibilitet — returnerer altid ""
|
||||||
def get_choreo(self) -> str:
|
def get_choreo(self) -> str:
|
||||||
return self._chosen_choreo
|
return ""
|
||||||
@@ -79,7 +79,7 @@ class LibraryManagerDialog(QDialog):
|
|||||||
conn = sqlite3.connect(self._db_path)
|
conn = sqlite3.connect(self._db_path)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
libs = conn.execute(
|
libs = conn.execute(
|
||||||
"SELECT id, path, last_full_scan FROM libraries "
|
"SELECT id, path FROM libraries "
|
||||||
"WHERE is_active=1 ORDER BY path"
|
"WHERE is_active=1 ORDER BY path"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
@@ -87,15 +87,16 @@ class LibraryManagerDialog(QDialog):
|
|||||||
bpm_missing = {}
|
bpm_missing = {}
|
||||||
for lib in libs:
|
for lib in libs:
|
||||||
counts[lib["id"]] = conn.execute(
|
counts[lib["id"]] = conn.execute(
|
||||||
"SELECT COUNT(*) FROM songs "
|
"SELECT COUNT(*) FROM files "
|
||||||
"WHERE library_id=? AND file_missing=0",
|
"WHERE file_missing=0 AND local_path LIKE ?",
|
||||||
(lib["id"],)
|
(lib["path"] + "%",)
|
||||||
).fetchone()[0]
|
).fetchone()[0]
|
||||||
bpm_missing[lib["id"]] = conn.execute(
|
bpm_missing[lib["id"]] = conn.execute(
|
||||||
"SELECT COUNT(*) FROM songs "
|
"SELECT COUNT(*) FROM files f "
|
||||||
"WHERE library_id=? AND file_missing=0 "
|
"JOIN songs s ON s.id = f.song_id "
|
||||||
"AND (bpm IS NULL OR bpm=0)",
|
"WHERE f.file_missing=0 AND f.local_path LIKE ? "
|
||||||
(lib["id"],)
|
"AND (s.bpm IS NULL OR s.bpm=0)",
|
||||||
|
(lib["path"] + "%",)
|
||||||
).fetchone()[0]
|
).fetchone()[0]
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -122,9 +123,7 @@ class LibraryManagerDialog(QDialog):
|
|||||||
lib_id = lib["id"]
|
lib_id = lib["id"]
|
||||||
path = lib["path"]
|
path = lib["path"]
|
||||||
exists = Path(path).exists()
|
exists = Path(path).exists()
|
||||||
last = lib.get("last_full_scan") or "aldrig"
|
last = "—"
|
||||||
if isinstance(last, str) and len(last) > 16:
|
|
||||||
last = last[:16]
|
|
||||||
|
|
||||||
frame = QFrame()
|
frame = QFrame()
|
||||||
frame.setObjectName("track_display")
|
frame.setObjectName("track_display")
|
||||||
@@ -246,11 +245,12 @@ class LibraryManagerDialog(QDialog):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
conn = sqlite3.connect(self._db_path)
|
conn = sqlite3.connect(self._db_path)
|
||||||
# Slet sange fra biblioteket
|
# Marker filer fra denne mappe som missing
|
||||||
conn.execute(
|
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(
|
conn.execute(
|
||||||
"DELETE FROM libraries WHERE id=?", (lib["id"],)
|
"DELETE FROM libraries WHERE id=?", (lib["id"],)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -549,9 +549,11 @@ class LibraryPanel(QWidget):
|
|||||||
self._bpm_worker.start()
|
self._bpm_worker.start()
|
||||||
|
|
||||||
def _refresh_library(self):
|
def _refresh_library(self):
|
||||||
"""Genindlæs bibliotek fra database."""
|
"""Opdater fil-tilgængelighed og genindlæs bibliotek."""
|
||||||
mw = self.window()
|
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()
|
mw._reload_library()
|
||||||
|
|
||||||
def _manage_libraries(self):
|
def _manage_libraries(self):
|
||||||
|
|||||||
@@ -379,6 +379,12 @@ class MainWindow(QMainWindow):
|
|||||||
self._sync_periodic.timeout.connect(self._manual_sync)
|
self._sync_periodic.timeout.connect(self._manual_sync)
|
||||||
self._sync_periodic.start()
|
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 = LibraryPanel()
|
||||||
self._library_panel.set_preview_player(self._preview_player)
|
self._library_panel.set_preview_player(self._preview_player)
|
||||||
|
|
||||||
@@ -438,9 +444,30 @@ class MainWindow(QMainWindow):
|
|||||||
from local.local_db import init_db
|
from local.local_db import init_db
|
||||||
init_db()
|
init_db()
|
||||||
self._db_ready.emit()
|
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:
|
except Exception as e:
|
||||||
pass
|
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):
|
def _start_watcher(self):
|
||||||
"""Start fil-watcher i baggrundstråd — blokerer aldrig GUI."""
|
"""Start fil-watcher i baggrundstråd — blokerer aldrig GUI."""
|
||||||
import threading
|
import threading
|
||||||
@@ -505,19 +532,19 @@ class MainWindow(QMainWindow):
|
|||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
rows = conn.execute("""
|
rows = conn.execute("""
|
||||||
SELECT s.id, s.title, s.artist, s.album, s.bpm,
|
SELECT s.id, s.title, s.artist, s.album, s.bpm,
|
||||||
s.duration_sec, s.local_path, s.file_format,
|
s.duration_sec,
|
||||||
s.file_missing,
|
f.id as file_id, f.local_path, f.file_format, f.file_missing,
|
||||||
GROUP_CONCAT(d.name, ',') AS dance_names,
|
GROUP_CONCAT(d.name, ',') AS dance_names,
|
||||||
GROUP_CONCAT(COALESCE(dl.name,''), ',') AS dance_levels,
|
GROUP_CONCAT(COALESCE(dl.name,''), ',') AS dance_levels,
|
||||||
GROUP_CONCAT(COALESCE(d.choreographer,''), ',') AS dance_choreographers,
|
GROUP_CONCAT(COALESCE(d.choreographer,''), ',') AS dance_choreographers,
|
||||||
GROUP_CONCAT(DISTINCT ad.name) AS alt_dance_names
|
GROUP_CONCAT(DISTINCT ad.name) AS alt_dance_names
|
||||||
FROM songs s
|
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 song_dances sd ON sd.song_id = s.id
|
||||||
LEFT JOIN dances d ON d.id = sd.dance_id
|
LEFT JOIN dances d ON d.id = sd.dance_id
|
||||||
LEFT JOIN dance_levels dl ON dl.id = d.level_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 song_alt_dances sad ON sad.song_id = s.id
|
||||||
LEFT JOIN dances ad ON ad.id = sad.dance_id
|
LEFT JOIN dances ad ON ad.id = sad.dance_id
|
||||||
WHERE s.file_missing = 0
|
|
||||||
GROUP BY s.id
|
GROUP BY s.id
|
||||||
ORDER BY s.artist, s.title
|
ORDER BY s.artist, s.title
|
||||||
""").fetchall()
|
""").fetchall()
|
||||||
@@ -536,6 +563,7 @@ class MainWindow(QMainWindow):
|
|||||||
"album": row["album"],
|
"album": row["album"],
|
||||||
"bpm": row["bpm"],
|
"bpm": row["bpm"],
|
||||||
"duration_sec": row["duration_sec"],
|
"duration_sec": row["duration_sec"],
|
||||||
|
"file_id": row["file_id"],
|
||||||
"local_path": row["local_path"],
|
"local_path": row["local_path"],
|
||||||
"file_format": row["file_format"],
|
"file_format": row["file_format"],
|
||||||
"file_missing": bool(row["file_missing"]),
|
"file_missing": bool(row["file_missing"]),
|
||||||
@@ -654,6 +682,8 @@ class MainWindow(QMainWindow):
|
|||||||
def on_one_finished(count, p):
|
def on_one_finished(count, p):
|
||||||
finished_count[0] += 1
|
finished_count[0] += 1
|
||||||
self._set_status(f"Scanning færdig — {count} filer", 4000)
|
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
|
# Ryd færdige workers ud
|
||||||
self._scan_workers = [w for w in self._scan_workers
|
self._scan_workers = [w for w in self._scan_workers
|
||||||
if w.isRunning()]
|
if w.isRunning()]
|
||||||
@@ -662,6 +692,9 @@ class MainWindow(QMainWindow):
|
|||||||
worker = ScanWorker(lib["id"], lib["path"], str(DB_PATH),
|
worker = ScanWorker(lib["id"], lib["path"], str(DB_PATH),
|
||||||
overwrite_bpm=False)
|
overwrite_bpm=False)
|
||||||
worker.finished.connect(on_one_finished)
|
worker.finished.connect(on_one_finished)
|
||||||
|
worker.batch_ready.connect(
|
||||||
|
lambda n: QTimer.singleShot(0, self._reload_library)
|
||||||
|
)
|
||||||
worker.start()
|
worker.start()
|
||||||
worker.setPriority(QThread.Priority.LowestPriority)
|
worker.setPriority(QThread.Priority.LowestPriority)
|
||||||
self._scan_workers.append(worker)
|
self._scan_workers.append(worker)
|
||||||
@@ -991,6 +1024,8 @@ class MainWindow(QMainWindow):
|
|||||||
if dialog.exec():
|
if dialog.exec():
|
||||||
# Genindlæs biblioteket så ændringer vises
|
# Genindlæs biblioteket så ændringer vises
|
||||||
QTimer.singleShot(200, self._reload_library)
|
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):
|
def _send_mail(self, song: dict):
|
||||||
import subprocess, sys, shutil, urllib.parse
|
import subprocess, sys, shutil, urllib.parse
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from PyQt6.QtGui import QColor
|
|||||||
class PlaylistBrowserDialog(QDialog):
|
class PlaylistBrowserDialog(QDialog):
|
||||||
"""Kombineret gem/hent dialog til danselister."""
|
"""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
|
sync_requested = pyqtSignal() # bed main_window om at køre sync
|
||||||
|
|
||||||
def __init__(self, mode: str = "load", current_songs: list = None,
|
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):
|
for i, song in enumerate(self._current_songs, start=1):
|
||||||
if song.get("id"):
|
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.playlist_selected.emit(pl_id, name)
|
||||||
self.accept()
|
self.accept()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -327,7 +329,9 @@ class PlaylistBrowserDialog(QDialog):
|
|||||||
pl_id = create_playlist(name, tags=tags)
|
pl_id = create_playlist(name, tags=tags)
|
||||||
for i, song in enumerate(self._current_songs, start=1):
|
for i, song in enumerate(self._current_songs, start=1):
|
||||||
if song.get("id"):
|
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.playlist_selected.emit(pl_id, name)
|
||||||
self.accept()
|
self.accept()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -2,6 +2,45 @@
|
|||||||
playlist_panel.py — Danseliste med Ny/Gem/Hent knapper, autogem og event-overblik.
|
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 (
|
from PyQt6.QtWidgets import (
|
||||||
QWidget, QVBoxLayout, QListWidget, QListWidgetItem,
|
QWidget, QVBoxLayout, QListWidget, QListWidgetItem,
|
||||||
QLabel, QHBoxLayout, QPushButton, QMenu, QAbstractItemView,
|
QLabel, QHBoxLayout, QPushButton, QMenu, QAbstractItemView,
|
||||||
@@ -50,8 +89,8 @@ class PlaylistPanel(QWidget):
|
|||||||
self._statuses: list[str] = []
|
self._statuses: list[str] = []
|
||||||
self._current_idx = -1
|
self._current_idx = -1
|
||||||
self._song_ended = False
|
self._song_ended = False
|
||||||
self._active_playlist_id: int | None = None
|
self._active_playlist_id: str | None = None
|
||||||
self._named_playlist_id: int | None = None # den indlæste/gemte navngivne liste
|
self._named_playlist_id: str | None = None # den indlæste/gemte navngivne liste
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
self.setAcceptDrops(True)
|
self.setAcceptDrops(True)
|
||||||
# Autogem-timer — venter 800ms efter sidst ændring
|
# Autogem-timer — venter 800ms efter sidst ændring
|
||||||
@@ -317,7 +356,7 @@ class PlaylistPanel(QWidget):
|
|||||||
pass
|
pass
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_named_playlist_id(self) -> int | None:
|
def get_named_playlist_id(self) -> str | None:
|
||||||
return self._named_playlist_id
|
return self._named_playlist_id
|
||||||
|
|
||||||
def next_playable_idx(self) -> int | None:
|
def next_playable_idx(self) -> int | None:
|
||||||
@@ -360,11 +399,14 @@ class PlaylistPanel(QWidget):
|
|||||||
for i, song in enumerate(self._songs, start=1):
|
for i, song in enumerate(self._songs, start=1):
|
||||||
if song.get("id"):
|
if song.get("id"):
|
||||||
status = self._statuses[i-1] if i-1 < len(self._statuses) else "pending"
|
status = self._statuses[i-1] if i-1 < len(self._statuses) else "pending"
|
||||||
|
import uuid as _uuid
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO playlist_songs "
|
"INSERT INTO playlist_songs "
|
||||||
"(playlist_id, song_id, position, status, is_workshop, dance_override) "
|
"(id, playlist_id, song_id, file_id, position, status, is_workshop, dance_override) "
|
||||||
"VALUES (?,?,?,?,?,?)",
|
"VALUES (?,?,?,?,?,?,?,?)",
|
||||||
(self._named_playlist_id, song["id"], i, status,
|
(str(_uuid.uuid4()), self._named_playlist_id,
|
||||||
|
song["id"], song.get("file_id"),
|
||||||
|
i, status,
|
||||||
1 if song.get("is_workshop") else 0,
|
1 if song.get("is_workshop") else 0,
|
||||||
song.get("active_dance") or "")
|
song.get("active_dance") or "")
|
||||||
)
|
)
|
||||||
@@ -374,7 +416,7 @@ class PlaylistPanel(QWidget):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._lbl_autosave.setText("⚠ gemfejl")
|
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."""
|
"""Gem hvilken navngiven liste der er aktiv — til brug ved næste opstart."""
|
||||||
from PyQt6.QtCore import QSettings
|
from PyQt6.QtCore import QSettings
|
||||||
s = QSettings("LineDance", "Player")
|
s = QSettings("LineDance", "Player")
|
||||||
@@ -388,7 +430,7 @@ class PlaylistPanel(QWidget):
|
|||||||
try:
|
try:
|
||||||
from PyQt6.QtCore import QSettings
|
from PyQt6.QtCore import QSettings
|
||||||
s = QSettings("LineDance", "Player")
|
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:
|
if not pl_id:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -406,11 +448,18 @@ class PlaylistPanel(QWidget):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
# Hent sange med status, workshop og dans-override
|
# 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("""
|
songs_raw = conn.execute("""
|
||||||
SELECT s.*, ps.position, ps.status,
|
SELECT s.id, s.title, s.artist, s.album,
|
||||||
ps.is_workshop, ps.dance_override
|
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
|
FROM playlist_songs ps
|
||||||
JOIN songs s ON s.id = ps.song_id
|
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
|
WHERE ps.playlist_id=? ORDER BY ps.position
|
||||||
""", (pl_id,)).fetchall()
|
""", (pl_id,)).fetchall()
|
||||||
|
|
||||||
@@ -426,16 +475,16 @@ class PlaylistPanel(QWidget):
|
|||||||
override = row["dance_override"] or ""
|
override = row["dance_override"] or ""
|
||||||
active_dance = override if override else (dance_names[0] if dance_names else "")
|
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"])
|
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:
|
if file_missing or not local_path:
|
||||||
match = conn.execute("""
|
match = conn.execute(
|
||||||
SELECT local_path FROM songs
|
"SELECT f.local_path FROM files f "
|
||||||
WHERE title=? AND artist=? AND file_missing=0
|
"WHERE f.song_id=? AND f.file_missing=0 LIMIT 1",
|
||||||
LIMIT 1
|
(row["id"],)
|
||||||
""", (row["title"], row["artist"])).fetchone()
|
).fetchone()
|
||||||
if match:
|
if match:
|
||||||
local_path = match["local_path"]
|
local_path = match["local_path"]
|
||||||
file_missing = False
|
file_missing = False
|
||||||
@@ -444,14 +493,16 @@ class PlaylistPanel(QWidget):
|
|||||||
"id": row["id"],
|
"id": row["id"],
|
||||||
"title": row["title"],
|
"title": row["title"],
|
||||||
"artist": row["artist"],
|
"artist": row["artist"],
|
||||||
"album": row["album"],
|
"album": row["album"] or "",
|
||||||
"bpm": row["bpm"],
|
"bpm": row["bpm"] or 0,
|
||||||
"duration_sec": row["duration_sec"],
|
"duration_sec": row["duration_sec"] or 0,
|
||||||
|
"file_id": row["file_id"] if "file_id" in row.keys() else None,
|
||||||
"local_path": local_path,
|
"local_path": local_path,
|
||||||
"file_format": row["file_format"],
|
"file_format": row["file_format"] or "",
|
||||||
"file_missing": file_missing,
|
"file_missing": file_missing,
|
||||||
"dances": dance_names,
|
"dances": dance_names,
|
||||||
"active_dance": active_dance,
|
"active_dance": active_dance,
|
||||||
|
"alt_dance": row["alt_dance_override"] if "alt_dance_override" in row.keys() else "",
|
||||||
"is_workshop": bool(row["is_workshop"]),
|
"is_workshop": bool(row["is_workshop"]),
|
||||||
})
|
})
|
||||||
statuses.append(row["status"] or "pending")
|
statuses.append(row["status"] or "pending")
|
||||||
@@ -547,11 +598,14 @@ class PlaylistPanel(QWidget):
|
|||||||
for i, song in enumerate(self._songs, start=1):
|
for i, song in enumerate(self._songs, start=1):
|
||||||
if song.get("id"):
|
if song.get("id"):
|
||||||
status = self._statuses[i-1] if i-1 < len(self._statuses) else "pending"
|
status = self._statuses[i-1] if i-1 < len(self._statuses) else "pending"
|
||||||
|
import uuid as _uuid
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO playlist_songs "
|
"INSERT INTO playlist_songs "
|
||||||
"(playlist_id, song_id, position, status, is_workshop, dance_override) "
|
"(id, playlist_id, song_id, file_id, position, status, is_workshop, dance_override) "
|
||||||
"VALUES (?,?,?,?,?,?)",
|
"VALUES (?,?,?,?,?,?,?,?)",
|
||||||
(self._named_playlist_id, song["id"], i, status,
|
(str(_uuid.uuid4()), self._named_playlist_id,
|
||||||
|
song["id"], song.get("file_id"),
|
||||||
|
i, status,
|
||||||
1 if song.get("is_workshop") else 0,
|
1 if song.get("is_workshop") else 0,
|
||||||
song.get("active_dance") or "")
|
song.get("active_dance") or "")
|
||||||
)
|
)
|
||||||
@@ -585,7 +639,7 @@ class PlaylistPanel(QWidget):
|
|||||||
dialog.sync_requested.connect(self._request_sync)
|
dialog.sync_requested.connect(self._request_sync)
|
||||||
dialog.exec()
|
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:
|
try:
|
||||||
from local.local_db import get_db
|
from local.local_db import get_db
|
||||||
|
|
||||||
@@ -605,11 +659,17 @@ class PlaylistPanel(QWidget):
|
|||||||
else:
|
else:
|
||||||
self._can_edit_server = False
|
self._can_edit_server = False
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
|
# JOIN songs — sangen er altid i songs tabellen (oprettet ved pull med file_missing=1)
|
||||||
songs_raw = conn.execute("""
|
songs_raw = conn.execute("""
|
||||||
SELECT s.*, ps.position, ps.status,
|
SELECT s.id, s.title, s.artist, s.album,
|
||||||
ps.is_workshop, ps.dance_override
|
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
|
FROM playlist_songs ps
|
||||||
JOIN songs s ON s.id = ps.song_id
|
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
|
WHERE ps.playlist_id=? ORDER BY ps.position
|
||||||
""", (pl_id,)).fetchall()
|
""", (pl_id,)).fetchall()
|
||||||
songs = []
|
songs = []
|
||||||
@@ -625,16 +685,16 @@ class PlaylistPanel(QWidget):
|
|||||||
override = row["dance_override"] or ""
|
override = row["dance_override"] or ""
|
||||||
active_dance = override if override else (dance_names[0] if dance_names else "")
|
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"])
|
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:
|
if file_missing or not local_path:
|
||||||
match = conn.execute("""
|
match = conn.execute(
|
||||||
SELECT local_path FROM songs
|
"SELECT f.local_path FROM files f "
|
||||||
WHERE title=? AND artist=? AND file_missing=0
|
"WHERE f.song_id=? AND f.file_missing=0 LIMIT 1",
|
||||||
LIMIT 1
|
(row["id"],)
|
||||||
""", (row["title"], row["artist"])).fetchone()
|
).fetchone()
|
||||||
if match:
|
if match:
|
||||||
local_path = match["local_path"]
|
local_path = match["local_path"]
|
||||||
file_missing = False
|
file_missing = False
|
||||||
@@ -644,11 +704,12 @@ class PlaylistPanel(QWidget):
|
|||||||
"id": row["id"],
|
"id": row["id"],
|
||||||
"title": row["title"],
|
"title": row["title"],
|
||||||
"artist": row["artist"],
|
"artist": row["artist"],
|
||||||
"album": row["album"],
|
"album": row["album"] or "",
|
||||||
"bpm": row["bpm"],
|
"bpm": row["bpm"] or 0,
|
||||||
"duration_sec": row["duration_sec"],
|
"duration_sec": row["duration_sec"] or 0,
|
||||||
|
"file_id": row["file_id"] if "file_id" in row.keys() else None,
|
||||||
"local_path": local_path,
|
"local_path": local_path,
|
||||||
"file_format": row["file_format"],
|
"file_format": row["file_format"] or "",
|
||||||
"file_missing": file_missing,
|
"file_missing": file_missing,
|
||||||
"dances": dance_names,
|
"dances": dance_names,
|
||||||
"active_dance": active_dance,
|
"active_dance": active_dance,
|
||||||
@@ -735,41 +796,194 @@ class PlaylistPanel(QWidget):
|
|||||||
def _change_dance(self, idx: int, song: dict):
|
def _change_dance(self, idx: int, song: dict):
|
||||||
"""Lad brugeren vælge/skrive hvilken dans der vises for dette nummer."""
|
"""Lad brugeren vælge/skrive hvilken dans der vises for dette nummer."""
|
||||||
from ui.dance_picker_dialog import DancePickerDialog
|
from ui.dance_picker_dialog import DancePickerDialog
|
||||||
|
dances = song.get("dances", [])
|
||||||
current = song.get("active_dance", "")
|
current = song.get("active_dance", "")
|
||||||
if not current:
|
if not current:
|
||||||
dances = song.get("dances", [])
|
|
||||||
current = dances[0] if dances else ""
|
current = dances[0] if dances else ""
|
||||||
current_choreo = song.get("active_choreo", "")
|
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(
|
dlg = DancePickerDialog(
|
||||||
current_dance=current,
|
current_dance=current,
|
||||||
current_choreo=current_choreo,
|
|
||||||
song_title=song.get("title", ""),
|
song_title=song.get("title", ""),
|
||||||
|
existing_dances=dances,
|
||||||
parent=self.window()
|
parent=self.window()
|
||||||
)
|
)
|
||||||
if dlg.exec():
|
if dlg.exec():
|
||||||
chosen = dlg.get_dance()
|
chosen = dlg.get_dance()
|
||||||
choreo = dlg.get_choreo()
|
# Dans-valg i playlisten er altid midlertidigt — kun dance_override
|
||||||
if chosen:
|
song["active_dance"] = chosen # tom streng = ingen dans
|
||||||
song["active_dance"] = chosen
|
|
||||||
song["active_choreo"] = choreo
|
|
||||||
self._refresh()
|
self._refresh()
|
||||||
self._sync_dance_to_db(idx, song)
|
self._sync_dance_to_db(idx, song)
|
||||||
|
|
||||||
def _sync_dance_to_db(self, idx: int, song: dict):
|
def _change_alt_dance(self, idx: int, song: dict):
|
||||||
"""Gem dance_override til playlist_songs."""
|
"""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:
|
if not self._named_playlist_id:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
from local.local_db import get_db
|
from local.local_db import get_db
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
conn.execute(
|
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=? "
|
"UPDATE playlist_songs SET dance_override=? "
|
||||||
"WHERE playlist_id=? AND position=?",
|
"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:
|
except Exception:
|
||||||
pass
|
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):
|
def _sync_ws_to_db(self, idx: int, song: dict):
|
||||||
"""Gem is_workshop til playlist_songs — både navngiven og aktiv liste."""
|
"""Gem is_workshop til playlist_songs — både navngiven og aktiv liste."""
|
||||||
pl_ids = []
|
pl_ids = []
|
||||||
@@ -791,20 +1005,24 @@ class PlaylistPanel(QWidget):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _pull_linked_playlist(self, pl_id: int, server_id: str):
|
def _pull_linked_playlist(self, pl_id: str, server_id: str):
|
||||||
"""Hent seneste version af en linket liste fra serveren."""
|
"""Hent seneste version af en linket liste fra serveren — i baggrundstråd."""
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
def _do_pull():
|
||||||
try:
|
try:
|
||||||
from ui.settings_dialog import load_settings
|
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()
|
s = load_settings()
|
||||||
server_url = s.get("server_url", "").rstrip("/")
|
server_url = s.get("server_url", "").rstrip("/")
|
||||||
# Hent token fra main_window
|
|
||||||
mw = self.window()
|
mw = self.window()
|
||||||
token = getattr(mw, "_api_token", None)
|
token = getattr(mw, "_api_token", None)
|
||||||
if not token or not server_url:
|
if not token or not server_url:
|
||||||
return
|
return
|
||||||
|
|
||||||
import urllib.request, json
|
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
f"{server_url}/sharing/playlists/{server_id}",
|
f"{server_url}/sharing/playlists/{server_id}",
|
||||||
headers={"Authorization": f"Bearer {token}"}
|
headers={"Authorization": f"Bearer {token}"}
|
||||||
@@ -812,32 +1030,66 @@ class PlaylistPanel(QWidget):
|
|||||||
with urllib.request.urlopen(req, timeout=8) as resp:
|
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||||
pl_data = json.loads(resp.read())
|
pl_data = json.loads(resp.read())
|
||||||
|
|
||||||
# Opdater lokal liste med server-data
|
conn = sqlite3.connect(str(DB_PATH), timeout=10)
|
||||||
import sqlite3
|
|
||||||
conn = sqlite3.connect(str(DB_PATH))
|
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
conn.execute("DELETE FROM playlist_songs WHERE playlist_id=?", (pl_id,))
|
conn.execute("DELETE FROM playlist_songs WHERE playlist_id=?", (pl_id,))
|
||||||
|
|
||||||
|
position = 1
|
||||||
for song_data in pl_data.get("songs", []):
|
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(
|
local = conn.execute(
|
||||||
"SELECT id FROM songs WHERE title=? AND artist=? AND file_missing=0",
|
"SELECT s.id FROM songs s "
|
||||||
(song_data["title"], song_data["artist"])
|
"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()
|
).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(
|
conn.execute(
|
||||||
"INSERT INTO playlist_songs "
|
"INSERT INTO playlist_songs "
|
||||||
"(playlist_id, song_id, position, status, is_workshop, dance_override) "
|
"(id, playlist_id, song_id, file_id, position, status, is_workshop, dance_override) "
|
||||||
"VALUES (?,?,?,?,?,?)",
|
"VALUES (?,?,?,?,?,?,?,?)",
|
||||||
(pl_id, local["id"], song_data["position"],
|
(str(uuid.uuid4()), pl_id, song_id, file_id,
|
||||||
song_data.get("status", "pending"),
|
position, song_data.get("status", "pending"),
|
||||||
1 if song_data.get("is_workshop") else 0,
|
1 if song_data.get("is_workshop") else 0,
|
||||||
song_data.get("dance_override") or "")
|
song_data.get("dance_override") or "")
|
||||||
)
|
)
|
||||||
|
position += 1
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
except Exception as e:
|
except Exception:
|
||||||
pass # Offline — brug lokalt cachet version
|
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."""
|
"""Push ændringer til server for en linket liste."""
|
||||||
try:
|
try:
|
||||||
from ui.settings_dialog import load_settings
|
from ui.settings_dialog import load_settings
|
||||||
@@ -925,7 +1177,8 @@ class PlaylistPanel(QWidget):
|
|||||||
for song in songs:
|
for song in songs:
|
||||||
path = song.get("local_path", "")
|
path = song.get("local_path", "")
|
||||||
if path and Path(path).exists():
|
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
|
continue
|
||||||
|
|
||||||
# Forsøg auto-match via titel+artist
|
# Forsøg auto-match via titel+artist
|
||||||
@@ -962,42 +1215,41 @@ class PlaylistPanel(QWidget):
|
|||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
for song in self._songs:
|
for song in self._songs:
|
||||||
path = song.get("local_path", "")
|
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():
|
if path and Path(path).exists():
|
||||||
song["availability"] = "green"
|
song["availability"] = "green" if _is_local_path(path) else "yellow"
|
||||||
song["file_missing"] = False
|
song["file_missing"] = False
|
||||||
# Opdater songs tabellen
|
# Opdater files tabellen
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE songs SET file_missing=0, local_path=? WHERE id=?",
|
"UPDATE files SET file_missing=0 WHERE local_path=?",
|
||||||
(path, song["id"])
|
(path,)
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Forsøg auto-match via titel+artist
|
# Forsøg auto-match via titel+artist i files tabellen
|
||||||
title = song.get("title", "")
|
title = song.get("title", "")
|
||||||
artist = song.get("artist", "")
|
artist = song.get("artist", "")
|
||||||
match = conn.execute("""
|
match = conn.execute("""
|
||||||
SELECT id, local_path FROM songs
|
SELECT f.id as file_id, f.local_path, s.id as song_id
|
||||||
WHERE title=? AND artist=? AND file_missing=0
|
FROM files f
|
||||||
AND local_path IS NOT NULL AND local_path != ''
|
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
|
LIMIT 1
|
||||||
""", (title, artist)).fetchone()
|
""", (title, artist)).fetchone()
|
||||||
|
|
||||||
if match and Path(match["local_path"]).exists():
|
if match and Path(match["local_path"]).exists():
|
||||||
song["local_path"] = match["local_path"]
|
song["local_path"] = match["local_path"]
|
||||||
song["id"] = match["id"]
|
song["file_id"] = match["file_id"]
|
||||||
song["availability"] = "green"
|
song["availability"] = "green" if _is_local_path(match["local_path"]) else "yellow"
|
||||||
song["file_missing"] = False
|
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:
|
if self._named_playlist_id:
|
||||||
conn.execute("""
|
conn.execute(
|
||||||
UPDATE playlist_songs SET song_id=?
|
"UPDATE playlist_songs SET file_id=? "
|
||||||
WHERE playlist_id=? AND song_id=(
|
"WHERE playlist_id=? AND song_id=?",
|
||||||
SELECT id FROM songs
|
(match["file_id"], self._named_playlist_id, song["id"])
|
||||||
WHERE title=? AND artist=?
|
|
||||||
LIMIT 1
|
|
||||||
)
|
)
|
||||||
""", (match["id"], self._named_playlist_id, title, artist))
|
|
||||||
else:
|
else:
|
||||||
song["availability"] = "red"
|
song["availability"] = "red"
|
||||||
|
|
||||||
@@ -1026,6 +1278,7 @@ class PlaylistPanel(QWidget):
|
|||||||
act_played = menu.addAction("✓ Sæt til afspillet")
|
act_played = menu.addAction("✓ Sæt til afspillet")
|
||||||
menu.addSeparator()
|
menu.addSeparator()
|
||||||
act_dance = menu.addAction("💃 Vælg dans...")
|
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
|
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")
|
act_ws = menu.addAction("🎓 Fjern workshop" if is_ws else "🎓 Markér som workshop")
|
||||||
menu.addSeparator()
|
menu.addSeparator()
|
||||||
@@ -1056,6 +1309,8 @@ class PlaylistPanel(QWidget):
|
|||||||
self._refresh(); self._trigger_autosave(); self._trigger_event_state_save()
|
self._refresh(); self._trigger_autosave(); self._trigger_event_state_save()
|
||||||
elif action == act_dance and song:
|
elif action == act_dance and song:
|
||||||
self._change_dance(idx, 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:
|
elif action == act_ws and song:
|
||||||
song["is_workshop"] = not song.get("is_workshop", False)
|
song["is_workshop"] = not song.get("is_workshop", False)
|
||||||
self._sync_ws_to_db(idx, song)
|
self._sync_ws_to_db(idx, song)
|
||||||
@@ -1209,11 +1464,11 @@ class PlaylistPanel(QWidget):
|
|||||||
status = self._statuses[i]
|
status = self._statuses[i]
|
||||||
icon = self.STATUS_ICON.get(status, " ")
|
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", "")
|
active = song.get("active_dance", "")
|
||||||
if not active:
|
if not active:
|
||||||
dances = song.get("dances", [])
|
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 ""
|
ws_tag = " 🎓" if song.get("is_workshop") else ""
|
||||||
|
|
||||||
# Tilgængeligheds-dot til højre — kun hvis tjekket (ikke yellow)
|
# 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_color = {"green": "#27ae60", "red": "#e74c3c"}.get(avail, None)
|
||||||
avail_tip = {"green": "Tilgængelig lokalt", "red": "Ikke fundet lokalt"}.get(avail, "")
|
avail_tip = {"green": "Tilgængelig lokalt", "red": "Ikke fundet lokalt"}.get(avail, "")
|
||||||
|
|
||||||
text = (f"{i+1:>2}. {song.get('title','—')}{ws_tag}\n"
|
text = (f"{i+1:>2}. {active}{ws_tag}\n"
|
||||||
f" {song.get('artist','')} · {active}")
|
f" {song.get('title','—')} · {song.get('artist','')}")
|
||||||
item = QListWidgetItem(f"{icon} {text}")
|
item = QListWidgetItem(f"{icon} {text}")
|
||||||
item.setData(Qt.ItemDataRole.UserRole, i)
|
item.setData(Qt.ItemDataRole.UserRole, i)
|
||||||
item.setData(Qt.ItemDataRole.UserRole + 1, avail_color)
|
item.setData(Qt.ItemDataRole.UserRole + 1, avail_color)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ class ScanWorker(QThread):
|
|||||||
progress = pyqtSignal(int, int, str) # done, total, filename
|
progress = pyqtSignal(int, int, str) # done, total, filename
|
||||||
finished = pyqtSignal(int, str) # antal, library_path
|
finished = pyqtSignal(int, str) # antal, library_path
|
||||||
error = pyqtSignal(str)
|
error = pyqtSignal(str)
|
||||||
|
batch_ready = pyqtSignal(int) # antal sange scannet så langt
|
||||||
|
|
||||||
def __init__(self, library_id: int, library_path: str,
|
def __init__(self, library_id: int, library_path: str,
|
||||||
db_path: str, overwrite_bpm: bool = False):
|
db_path: str, overwrite_bpm: bool = False):
|
||||||
@@ -26,11 +27,15 @@ class ScanWorker(QThread):
|
|||||||
def run(self):
|
def run(self):
|
||||||
try:
|
try:
|
||||||
from local.scanner import scan_library
|
from local.scanner import scan_library
|
||||||
|
self._batch_count = 0
|
||||||
|
|
||||||
def on_progress(done, total, filename):
|
def on_progress(done, total, filename):
|
||||||
if self.isInterruptionRequested():
|
if self.isInterruptionRequested():
|
||||||
raise InterruptedError()
|
raise InterruptedError()
|
||||||
self.progress.emit(done, total, filename)
|
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(
|
count = scan_library(
|
||||||
self._library_id,
|
self._library_id,
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ class TagEditorDialog(QDialog):
|
|||||||
|
|
||||||
# Forslags-liste
|
# Forslags-liste
|
||||||
self._dance_suggestions = QListWidget()
|
self._dance_suggestions = QListWidget()
|
||||||
self._dance_suggestions.setMaximumHeight(120)
|
self._dance_suggestions.setFixedHeight(150)
|
||||||
self._dance_suggestions.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
self._dance_suggestions.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||||
self._dance_suggestions.itemClicked.connect(
|
self._dance_suggestions.itemClicked.connect(
|
||||||
lambda item: self._add_from_suggestion(item, "dance")
|
lambda item: self._add_from_suggestion(item, "dance")
|
||||||
@@ -328,7 +328,13 @@ class TagEditorDialog(QDialog):
|
|||||||
suggestions = get_dance_suggestions(prefix, limit=20)
|
suggestions = get_dance_suggestions(prefix, limit=20)
|
||||||
list_widget.clear()
|
list_widget.clear()
|
||||||
for s in suggestions:
|
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 = QListWidgetItem(label)
|
||||||
item.setData(Qt.ItemDataRole.UserRole, s.get("level_id"))
|
item.setData(Qt.ItemDataRole.UserRole, s.get("level_id"))
|
||||||
item.setData(Qt.ItemDataRole.UserRole + 1, s["name"])
|
item.setData(Qt.ItemDataRole.UserRole + 1, s["name"])
|
||||||
@@ -373,16 +379,21 @@ class TagEditorDialog(QDialog):
|
|||||||
suggestions = get_dance_suggestions(prefix, limit=15)
|
suggestions = get_dance_suggestions(prefix, limit=15)
|
||||||
list_widget.clear()
|
list_widget.clear()
|
||||||
for s in suggestions:
|
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"):
|
if s.get("choreographer"):
|
||||||
label += f" · {s['choreographer']}"
|
parts.append(s["choreographer"])
|
||||||
|
label = " / ".join(parts)
|
||||||
item = QListWidgetItem(label)
|
item = QListWidgetItem(label)
|
||||||
item.setData(Qt.ItemDataRole.UserRole, s.get("level_id"))
|
item.setData(Qt.ItemDataRole.UserRole, s.get("level_id"))
|
||||||
item.setData(Qt.ItemDataRole.UserRole + 1, s["name"])
|
item.setData(Qt.ItemDataRole.UserRole + 1, s["name"])
|
||||||
item.setData(Qt.ItemDataRole.UserRole + 2, s.get("choreographer", ""))
|
item.setData(Qt.ItemDataRole.UserRole + 2, s.get("choreographer", ""))
|
||||||
list_widget.addItem(item)
|
list_widget.addItem(item)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
import logging
|
||||||
|
logging.getLogger(__name__).warning(f"Dans-forslag fejl: {e}", exc_info=True)
|
||||||
|
|
||||||
def _add_from_suggestion(self, item, panel: str):
|
def _add_from_suggestion(self, item, panel: str):
|
||||||
"""Tilføj dans fra forslags-listen ved klik."""
|
"""Tilføj dans fra forslags-listen ved klik."""
|
||||||
@@ -451,7 +462,7 @@ class TagEditorDialog(QDialog):
|
|||||||
layout.addWidget(self._new_alt)
|
layout.addWidget(self._new_alt)
|
||||||
|
|
||||||
self._alt_suggestions = QListWidget()
|
self._alt_suggestions = QListWidget()
|
||||||
self._alt_suggestions.setMaximumHeight(120)
|
self._alt_suggestions.setFixedHeight(150)
|
||||||
self._alt_suggestions.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
self._alt_suggestions.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||||
self._alt_suggestions.itemClicked.connect(
|
self._alt_suggestions.itemClicked.connect(
|
||||||
lambda item: self._add_from_suggestion(item, "alt")
|
lambda item: self._add_from_suggestion(item, "alt")
|
||||||
@@ -530,7 +541,8 @@ class TagEditorDialog(QDialog):
|
|||||||
local_path = self._song.get("local_path", "")
|
local_path = self._song.get("local_path", "")
|
||||||
|
|
||||||
try:
|
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
|
from local.tag_reader import write_dances, can_write_dances
|
||||||
|
|
||||||
# Saml data fra UI
|
# Saml data fra UI
|
||||||
@@ -554,8 +566,7 @@ class TagEditorDialog(QDialog):
|
|||||||
"note": "",
|
"note": "",
|
||||||
})
|
})
|
||||||
|
|
||||||
conn = new_conn()
|
with get_db() as conn:
|
||||||
|
|
||||||
# Slet eksisterende
|
# Slet eksisterende
|
||||||
conn.execute("DELETE FROM song_dances WHERE song_id=?", (song_id,))
|
conn.execute("DELETE FROM song_dances WHERE song_id=?", (song_id,))
|
||||||
conn.execute("DELETE FROM song_alt_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,
|
dance_id = get_or_create_dance(d["name"], d["level_id"], conn,
|
||||||
choreographer=d.get("choreographer", ""))
|
choreographer=d.get("choreographer", ""))
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR IGNORE INTO song_dances (song_id, dance_id, dance_order) "
|
"INSERT OR IGNORE INTO song_dances (id, song_id, dance_id, dance_order) "
|
||||||
"VALUES (?,?,?)",
|
"VALUES (?,?,?,?)",
|
||||||
(song_id, dance_id, i)
|
(str(uuid.uuid4()), song_id, dance_id, i)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Indsæt alternativ-danse
|
# Indsæt alternativ-danse
|
||||||
for a in alts:
|
for a in alts:
|
||||||
dance_id = get_or_create_dance(a["name"], a["level_id"], conn)
|
dance_id = get_or_create_dance(a["name"], a["level_id"], conn)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR IGNORE INTO song_alt_dances (song_id, dance_id, note) "
|
"INSERT OR IGNORE INTO song_alt_dances (id, song_id, dance_id, note) "
|
||||||
"VALUES (?,?,?)",
|
"VALUES (?,?,?,?)",
|
||||||
(song_id, dance_id, a.get("note", ""))
|
(str(uuid.uuid4()), song_id, dance_id, a.get("note", ""))
|
||||||
)
|
)
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
# Skriv danse-navne til filen
|
# 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]
|
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:
|
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",
|
QMessageBox.warning(self, "Advarsel",
|
||||||
"Gemt i database, men kunne ikke skrive til mp3-filen.\n"
|
"Gemt i database, men kunne ikke skrive til mp3-filen.\n"
|
||||||
"(Filen understøtter ikke dans-tags)")
|
"(Filen understøtter ikke dans-tags)")
|
||||||
except Exception as write_err:
|
except Exception as write_err:
|
||||||
|
_log.warning(f"write_dances fejl: {write_err}")
|
||||||
QMessageBox.warning(self, "Advarsel",
|
QMessageBox.warning(self, "Advarsel",
|
||||||
f"Gemt i database, men fejl ved skrivning til fil:\n{write_err}")
|
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()
|
self.accept()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user