Rettelsaer

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

1
linedance-api/=4.0.0 Normal file
View File

@@ -0,0 +1 @@
Requirement already satisfied: bcrypt in ./venv/lib/python3.12/site-packages (5.0.0)

View File

@@ -61,11 +61,12 @@ LineDance Player
port=settings.MAIL_PORT,
username=settings.MAIL_USERNAME or None,
password=settings.MAIL_PASSWORD or None,
use_tls=settings.MAIL_TLS,
start_tls=settings.MAIL_TLS, # STARTTLS på port 587
use_tls=False,
)
except Exception as e:
# Log fejl men lad registrering gennemføre
print(f"Mail-fejl: {e}")
raise # Vis fejlen i serverlogs
async def send_share_invitation(email: str, owner_name: str,

View File

@@ -1,21 +1,20 @@
import bcrypt
from datetime import datetime, timedelta, timezone
from fastapi import Depends, HTTPException
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.database import get_db
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
return bcrypt.hashpw(password[:72].encode(), bcrypt.gensalt()).decode()
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
return bcrypt.checkpw(plain[:72].encode(), hashed.encode())
def create_access_token(data: dict) -> str:
expire = datetime.now(timezone.utc) + timedelta(

View File

@@ -1,7 +1,7 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.database import engine, Base
from app.routers import auth, projects, songs, alternatives, dances
from app.routers import auth, projects, songs, alternatives, dances, sync, sharing
from app.websocket.manager import router as ws_router
# Opret tabeller hvis de ikke findes (til udvikling — brug Alembic i produktion)
@@ -26,6 +26,8 @@ app.include_router(projects.router)
app.include_router(songs.router)
app.include_router(alternatives.router)
app.include_router(dances.router)
app.include_router(sync.router)
app.include_router(sharing.router)
@app.on_event("startup")

View File

@@ -1,235 +1,3 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import func
from pydantic import BaseModel
from app.core.database import get_db
from app.core.security import get_current_user
from app.models import User, SongDance, DanceAlternative, DanceAlternativeRating
"""alternatives.py — Placeholder (håndteres via /sync)."""
from fastapi import APIRouter
router = APIRouter(prefix="/alternatives", tags=["alternatives"])
# Bayesiansk minimum — alternativer med færre ratings trækkes mod gennemsnittet
BAYESIAN_MIN_VOTES = 5
# ── Schemas ───────────────────────────────────────────────────────────────────
class AlternativeCreate(BaseModel):
song_dance_id: str # dans der foreslås alternativ TIL
alt_song_dance_id: str # den alternative dans
note: str = ""
class AlternativeOut(BaseModel):
id: str
song_dance_id: str
alt_song_dance_id: str
alt_dance_name: str
alt_song_title: str
created_by_username: str
note: str
my_score: int | None # den indloggede brugers egen rating
avg_score: float | None # simpelt gennemsnit (til visning)
bayesian_score: float # bruges til sortering
rating_count: int
model_config = {"from_attributes": True}
class RatingUpsert(BaseModel):
score: int # 1-5
# ── Hjælpefunktion: genberegn bayesian score ─────────────────────────────────
def _recalculate_bayesian(alternative: DanceAlternative, db: Session):
"""
Bayesiansk score: vægter gennemsnittet mod et globalt gennemsnit
når der er få ratings, så nye alternativer ikke dominerer listen.
Formel: (n × avg + m × global_avg) / (n + m)
n = antal ratings på dette alternativ
avg = gennemsnit for dette alternativ
m = BAYESIAN_MIN_VOTES (tillid-konstant)
global_avg = gennemsnit på tværs af ALLE ratings
"""
# Beregn stats for dette alternativ
result = db.query(
func.count(DanceAlternativeRating.id),
func.avg(DanceAlternativeRating.score),
).filter(DanceAlternativeRating.alternative_id == alternative.id).one()
n = result[0] or 0
avg = float(result[1]) if result[1] else 0.0
# Globalt gennemsnit på tværs af alle ratings
global_avg_result = db.query(func.avg(DanceAlternativeRating.score)).scalar()
global_avg = float(global_avg_result) if global_avg_result else 3.0 # 3.0 som neutral fallback
m = BAYESIAN_MIN_VOTES
bayesian = (n * avg + m * global_avg) / (n + m) if (n + m) > 0 else global_avg
alternative.bayesian_score = round(bayesian, 4)
db.flush()
# ── Endpoints ─────────────────────────────────────────────────────────────────
@router.post("/", status_code=201)
def create_alternative(
data: AlternativeCreate,
db: Session = Depends(get_db),
me: User = Depends(get_current_user),
):
"""Opret et nyt alternativ-dans forslag. Alle registrerede brugere kan bidrage."""
dance = db.query(SongDance).filter(SongDance.id == data.song_dance_id).first()
if not dance:
raise HTTPException(404, "Dans ikke fundet")
alt_dance = db.query(SongDance).filter(SongDance.id == data.alt_song_dance_id).first()
if not alt_dance:
raise HTTPException(404, "Alternativ-dans ikke fundet")
if data.song_dance_id == data.alt_song_dance_id:
raise HTTPException(400, "En dans kan ikke være sit eget alternativ")
# Undgå dubletter fra samme bruger
existing = db.query(DanceAlternative).filter_by(
song_dance_id=data.song_dance_id,
alt_song_dance_id=data.alt_song_dance_id,
created_by=me.id,
).first()
if existing:
raise HTTPException(400, "Du har allerede foreslået dette alternativ")
alt = DanceAlternative(
song_dance_id=data.song_dance_id,
alt_song_dance_id=data.alt_song_dance_id,
created_by=me.id,
note=data.note,
bayesian_score=3.0, # starter på globalt neutral
)
db.add(alt)
db.commit()
db.refresh(alt)
return {"id": alt.id, "detail": "Alternativ oprettet"}
@router.get("/for-dance/{song_dance_id}", response_model=list[AlternativeOut])
def list_alternatives_for_dance(
song_dance_id: str,
db: Session = Depends(get_db),
me: User = Depends(get_current_user),
):
"""
Hent alle alternativer til en given dans, sorteret efter bayesiansk score.
Viser din egen rating og gennemsnittet.
"""
alternatives = (
db.query(DanceAlternative)
.filter(DanceAlternative.song_dance_id == song_dance_id)
.order_by(DanceAlternative.bayesian_score.desc())
.all()
)
result = []
for alt in alternatives:
# Din egen rating
my_rating = db.query(DanceAlternativeRating).filter_by(
alternative_id=alt.id, user_id=me.id
).first()
# Aggregeret stats
stats = db.query(
func.count(DanceAlternativeRating.id),
func.avg(DanceAlternativeRating.score),
).filter(DanceAlternativeRating.alternative_id == alt.id).one()
result.append(AlternativeOut(
id=alt.id,
song_dance_id=alt.song_dance_id,
alt_song_dance_id=alt.alt_song_dance_id,
alt_dance_name=alt.alt_song_dance.dance_name,
alt_song_title=alt.alt_song_dance.song.title,
created_by_username=alt.creator.username,
note=alt.note,
my_score=my_rating.score if my_rating else None,
avg_score=round(float(stats[1]), 1) if stats[1] else None,
bayesian_score=alt.bayesian_score,
rating_count=stats[0] or 0,
))
return result
@router.put("/{alternative_id}/rate")
def rate_alternative(
alternative_id: str,
data: RatingUpsert,
db: Session = Depends(get_db),
me: User = Depends(get_current_user),
):
"""Sæt eller opdater din rating (1-5) på et alternativ."""
if not 1 <= data.score <= 5:
raise HTTPException(400, "Score skal være mellem 1 og 5")
alt = db.query(DanceAlternative).filter(DanceAlternative.id == alternative_id).first()
if not alt:
raise HTTPException(404, "Alternativ ikke fundet")
# Upsert — opdater eksisterende rating eller opret ny
existing = db.query(DanceAlternativeRating).filter_by(
alternative_id=alternative_id, user_id=me.id
).first()
if existing:
existing.score = data.score
else:
db.add(DanceAlternativeRating(
alternative_id=alternative_id,
user_id=me.id,
score=data.score,
))
db.flush()
_recalculate_bayesian(alt, db)
db.commit()
return {
"detail": "Rating gemt",
"my_score": data.score,
"bayesian_score": alt.bayesian_score,
}
@router.delete("/{alternative_id}/rate", status_code=204)
def remove_rating(
alternative_id: str,
db: Session = Depends(get_db),
me: User = Depends(get_current_user),
):
"""Fjern din rating fra et alternativ."""
rating = db.query(DanceAlternativeRating).filter_by(
alternative_id=alternative_id, user_id=me.id
).first()
if not rating:
raise HTTPException(404, "Du har ikke rated dette alternativ")
alt = db.query(DanceAlternative).filter(DanceAlternative.id == alternative_id).first()
db.delete(rating)
db.flush()
_recalculate_bayesian(alt, db)
db.commit()
@router.delete("/{alternative_id}", status_code=204)
def delete_alternative(
alternative_id: str,
db: Session = Depends(get_db),
me: User = Depends(get_current_user),
):
"""Slet et alternativ — kun den der oprettede det."""
alt = db.query(DanceAlternative).filter(DanceAlternative.id == alternative_id).first()
if not alt:
raise HTTPException(404, "Alternativ ikke fundet")
if alt.created_by != me.id:
raise HTTPException(403, "Du kan kun slette dine egne forslag")
db.delete(alt)
db.commit()

View File

@@ -24,7 +24,7 @@ def _assert_role(project: Project, user: User, db: Session, min_role: str = "vie
return # ejer har altid adgang
member = db.query(ProjectMember).filter_by(project_id=project.id, user_id=user.id, status="accepted").first()
if not member:
if project.is_public and min_role == "viewer":
if project.visibility == "public" and min_role == "viewer":
return
raise HTTPException(403, "Du har ikke adgang til dette projekt")
if roles.index(member.role) < roles.index(min_role):

View File

@@ -0,0 +1,394 @@
"""
sharing.py — Del playlister med andre brugere.
"""
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from sqlalchemy.orm import Session
from pydantic import BaseModel, EmailStr
from typing import Optional
from app.core.database import get_db
from app.core.security import get_current_user
from app.models import User, Project, PlaylistShare
router = APIRouter(prefix="/sharing", tags=["sharing"])
class ShareRequest(BaseModel):
email: EmailStr
permission: str = "view" # view | copy | edit
class ShareOut(BaseModel):
id: str
project_id: str
invited_email: str
permission: str
accepted_at: Optional[str] = None
class Config:
from_attributes = True
# ── Del en playliste ──────────────────────────────────────────────────────────
@router.post("/playlists/{project_id}/share", status_code=201)
async def share_playlist(
project_id: str,
data: ShareRequest,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
me: User = Depends(get_current_user),
):
project = db.query(Project).filter_by(id=project_id, owner_id=me.id).first()
if not project:
raise HTTPException(404, "Playliste ikke fundet eller du er ikke ejer")
if data.permission not in ("view", "copy", "edit"):
raise HTTPException(400, "Ugyldig rettighed — brug view, copy eller edit")
# Find bruger via email
target = db.query(User).filter_by(email=data.email).first()
# Tjek om deling allerede eksisterer
existing = db.query(PlaylistShare).filter_by(
project_id=project_id,
invited_email=data.email,
).first()
if existing:
existing.permission = data.permission
db.commit()
return {"detail": "Rettigheder opdateret", "share_id": existing.id}
share = PlaylistShare(
project_id=project_id,
shared_with_id=target.id if target else None,
invited_email=data.email,
permission=data.permission,
)
db.add(share)
db.commit()
db.refresh(share)
# Send invitation-mail
try:
from app.core.mail import send_share_invitation
from app.core.config import settings
background_tasks.add_task(
send_share_invitation,
email=data.email,
owner_name=me.username,
playlist_name=project.name,
permission=data.permission,
accept_url=f"{settings.BASE_URL}/sharing/accept/{share.id}",
)
except Exception:
pass
return {"detail": "Invitation sendt", "share_id": share.id}
@router.patch("/playlists/{project_id}/share/{share_id}")
def update_share(
project_id: str,
share_id: str,
data: ShareRequest,
db: Session = Depends(get_db),
me: User = Depends(get_current_user),
):
project = db.query(Project).filter_by(id=project_id, owner_id=me.id).first()
if not project:
raise HTTPException(404, "Playliste ikke fundet")
share = db.query(PlaylistShare).filter_by(id=share_id, project_id=project_id).first()
if not share:
raise HTTPException(404, "Deling ikke fundet")
share.permission = data.permission
db.commit()
return {"detail": "Rettigheder opdateret"}
@router.delete("/playlists/{project_id}/share/{share_id}", status_code=204)
def remove_share(
project_id: str,
share_id: str,
db: Session = Depends(get_db),
me: User = Depends(get_current_user),
):
project = db.query(Project).filter_by(id=project_id, owner_id=me.id).first()
if not project:
raise HTTPException(404, "Playliste ikke fundet")
share = db.query(PlaylistShare).filter_by(id=share_id, project_id=project_id).first()
if not share:
raise HTTPException(404, "Deling ikke fundet")
db.delete(share)
db.commit()
@router.get("/playlists/{project_id}/shares")
def list_shares(
project_id: str,
db: Session = Depends(get_db),
me: User = Depends(get_current_user),
):
project = db.query(Project).filter_by(id=project_id, owner_id=me.id).first()
if not project:
raise HTTPException(404, "Playliste ikke fundet")
shares = db.query(PlaylistShare).filter_by(project_id=project_id).all()
return [
{
"id": s.id,
"email": s.invited_email,
"permission": s.permission,
"accepted": s.accepted_at is not None,
}
for s in shares
]
# ── Visibility ────────────────────────────────────────────────────────────────
@router.patch("/playlists/{project_id}/visibility")
def set_visibility(
project_id: str,
visibility: str,
db: Session = Depends(get_db),
me: User = Depends(get_current_user),
):
if visibility not in ("private", "shared", "public"):
raise HTTPException(400, "Ugyldig synlighed — brug private, shared eller public")
project = db.query(Project).filter_by(id=project_id, owner_id=me.id).first()
if not project:
raise HTTPException(404, "Playliste ikke fundet")
project.visibility = visibility
db.commit()
return {"detail": f"Synlighed sat til {visibility}"}
# ── Hent delte lister ─────────────────────────────────────────────────────────
@router.get("/playlists/shared-with-me")
def shared_with_me(
db: Session = Depends(get_db),
me: User = Depends(get_current_user),
):
"""Hent alle playlister der er delt med mig."""
# Via direkte deling
shares = db.query(PlaylistShare).filter_by(
shared_with_id=me.id
).all()
project_ids = {s.project_id for s in shares}
# Via email-invitation
email_shares = db.query(PlaylistShare).filter_by(
invited_email=me.email
).all()
project_ids.update(s.project_id for s in email_shares)
# Public playlister
public = db.query(Project).filter_by(visibility="public").all()
project_ids.update(p.id for p in public)
result = []
for pid in project_ids:
p = db.query(Project).filter_by(id=pid).first()
if not p or p.owner_id == me.id:
continue
# Find min rettighed
share = db.query(PlaylistShare).filter(
PlaylistShare.project_id == pid,
(PlaylistShare.shared_with_id == me.id) |
(PlaylistShare.invited_email == me.email)
).first()
permission = share.permission if share else "view"
owner = db.query(User).filter_by(id=p.owner_id).first()
result.append({
"project_id": p.id,
"name": p.name,
"owner": owner.username if owner else "?",
"visibility": p.visibility,
"permission": permission,
"song_count": len(p.project_songs),
})
return result
# ── Hent en delt playliste ────────────────────────────────────────────────────
@router.get("/playlists/{project_id}")
def get_shared_playlist(
project_id: str,
db: Session = Depends(get_db),
me: User = Depends(get_current_user),
):
"""Hent indholdet af en delt playliste."""
p = db.query(Project).filter_by(id=project_id).first()
if not p:
raise HTTPException(404, "Playliste ikke fundet")
# Tjek adgang
if p.owner_id != me.id:
if p.visibility != "public":
share = db.query(PlaylistShare).filter(
PlaylistShare.project_id == project_id,
(PlaylistShare.shared_with_id == me.id) |
(PlaylistShare.invited_email == me.email)
).first()
if not share:
raise HTTPException(403, "Du har ikke adgang til denne playliste")
from app.models import Song
songs = []
for ps in p.project_songs:
song = db.query(Song).filter_by(id=ps.song_id).first()
if not song:
continue
songs.append({
"title": song.title,
"artist": song.artist,
"album": song.album,
"bpm": song.bpm,
"duration_sec": song.duration_sec,
"position": ps.position,
"status": ps.status,
"is_workshop": ps.is_workshop,
"dance_override": ps.dance_override,
})
return {
"id": p.id,
"name": p.name,
"description": p.description,
"visibility": p.visibility,
"songs": sorted(songs, key=lambda x: x["position"]),
}
# ── Opdater sange i en linket liste ──────────────────────────────────────────
class LinkedSongData(BaseModel):
title: str
artist: str = ""
position: int = 1
status: str = "pending"
is_workshop: bool = False
dance_override: str = ""
class LinkedSongsUpdate(BaseModel):
songs: list[LinkedSongData]
@router.put("/playlists/{project_id}/songs")
def update_linked_songs(
project_id: str,
data: LinkedSongsUpdate,
db: Session = Depends(get_db),
me: User = Depends(get_current_user),
):
"""Opdater sange i en linket playliste — kræver edit-rettighed."""
from app.models import Song, ProjectSong
p = db.query(Project).filter_by(id=project_id).first()
if not p:
raise HTTPException(404, "Playliste ikke fundet")
# Tjek edit-rettighed
if p.owner_id != me.id:
share = db.query(PlaylistShare).filter(
PlaylistShare.project_id == project_id,
(PlaylistShare.shared_with_id == me.id) |
(PlaylistShare.invited_email == me.email)
).first()
if not share or share.permission != "edit":
raise HTTPException(403, "Du har ikke redigerings-rettighed")
# Slet eksisterende sange og geninsert
db.query(ProjectSong).filter_by(project_id=project_id).delete()
for song_data in data.songs:
song = db.query(Song).filter_by(
title=song_data.title, artist=song_data.artist
).first()
if not song:
continue
ps = ProjectSong(
project_id=project_id,
song_id=song.id,
position=song_data.position,
status=song_data.status,
is_workshop=song_data.is_workshop,
dance_override=song_data.dance_override,
)
db.add(ps)
db.commit()
return {"detail": "Liste opdateret", "songs": len(data.songs)}
# ── Opdater sange på en delt playliste ───────────────────────────────────────
class LinkedSongData(BaseModel):
title: str
artist: str
position: int
status: str = "pending"
is_workshop: bool = False
dance_override: str = ""
class LinkedPlaylistUpdate(BaseModel):
songs: list[LinkedSongData]
@router.put("/playlists/{project_id}/songs")
def update_linked_playlist_songs(
project_id: str,
data: LinkedPlaylistUpdate,
db: Session = Depends(get_db),
me: User = Depends(get_current_user),
):
"""Opdater sange på en delt playliste — kræver edit-rettighed."""
from app.models import Song
p = db.query(Project).filter_by(id=project_id).first()
if not p:
raise HTTPException(404, "Playliste ikke fundet")
# Tjek rettighed
if p.owner_id != me.id:
from app.models import PlaylistShare
share = db.query(PlaylistShare).filter(
PlaylistShare.project_id == project_id,
(PlaylistShare.shared_with_id == me.id) |
(PlaylistShare.invited_email == me.email)
).first()
if not share or share.permission != "edit":
raise HTTPException(403, "Du har ikke rettighed til at redigere denne liste")
# Slet eksisterende sange og indsæt nye
from app.models import ProjectSong
db.query(ProjectSong).filter_by(project_id=project_id).delete()
for song_data in data.songs:
# Match sang globalt på titel+artist
song = db.query(Song).filter_by(
title=song_data.title, artist=song_data.artist
).first()
if not song:
song = Song(
owner_id=me.id,
title=song_data.title,
artist=song_data.artist,
)
db.add(song)
db.flush()
ps = ProjectSong(
project_id=project_id,
song_id=song.id,
position=song_data.position,
status=song_data.status,
is_workshop=song_data.is_workshop,
dance_override=song_data.dance_override,
)
db.add(ps)
db.commit()
return {"detail": "Playliste opdateret", "songs": len(data.songs)}

View File

@@ -1,41 +1,25 @@
"""songs.py — Simpel sang-router (basis CRUD)."""
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, SongDance, DanceAlternative
from app.schemas import (
SongCreate, SongOut,
SongDanceCreate, SongDanceOut,
DanceAlternativeCreate, DanceAlternativeOut,
)
from app.models import User, Song
router = APIRouter(prefix="/songs", tags=["songs"])
# ── Sange ─────────────────────────────────────────────────────────────────────
class SongOut(BaseModel):
id: str; title: str; artist: str; album: str
bpm: int; duration_sec: int; file_format: str
class Config: from_attributes = True
@router.get("/", response_model=list[SongOut])
def list_songs(db: Session = Depends(get_db), me: User = Depends(get_current_user)):
return db.query(Song).filter(Song.owner_id == me.id).all()
@router.post("/", response_model=SongOut, status_code=201)
def create_song(data: SongCreate, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
song = Song(owner_id=me.id, **data.model_dump())
db.add(song)
db.commit()
db.refresh(song)
return song
@router.get("/{song_id}", response_model=SongOut)
def get_song(song_id: str, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
song = db.query(Song).filter(Song.id == song_id, Song.owner_id == me.id).first()
if not song:
raise HTTPException(404, "Sang ikke fundet")
return song
@router.delete("/{song_id}", status_code=204)
def delete_song(song_id: str, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
song = db.query(Song).filter(Song.id == song_id, Song.owner_id == me.id).first()
@@ -43,67 +27,3 @@ def delete_song(song_id: str, db: Session = Depends(get_db), me: User = Depends(
raise HTTPException(404, "Sang ikke fundet")
db.delete(song)
db.commit()
# ── Danse på en sang ──────────────────────────────────────────────────────────
@router.post("/{song_id}/dances", response_model=SongDanceOut, status_code=201)
def add_dance(song_id: str, data: SongDanceCreate, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
song = db.query(Song).filter(Song.id == song_id, Song.owner_id == me.id).first()
if not song:
raise HTTPException(404, "Sang ikke fundet")
dance = SongDance(song_id=song_id, **data.model_dump())
db.add(dance)
db.commit()
db.refresh(dance)
return dance
@router.delete("/{song_id}/dances/{dance_id}", status_code=204)
def remove_dance(song_id: str, dance_id: str, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
song = db.query(Song).filter(Song.id == song_id, Song.owner_id == me.id).first()
if not song:
raise HTTPException(404, "Sang ikke fundet")
dance = db.query(SongDance).filter(SongDance.id == dance_id, SongDance.song_id == song_id).first()
if not dance:
raise HTTPException(404, "Dans ikke fundet")
db.delete(dance)
db.commit()
# ── Alternativ-danse ──────────────────────────────────────────────────────────
@router.post("/{song_id}/dances/{dance_id}/alternatives", response_model=DanceAlternativeOut, status_code=201)
def add_alternative(song_id: str, dance_id: str, data: DanceAlternativeCreate, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
song = db.query(Song).filter(Song.id == song_id, Song.owner_id == me.id).first()
if not song:
raise HTTPException(404, "Sang ikke fundet")
dance = db.query(SongDance).filter(SongDance.id == dance_id, SongDance.song_id == song_id).first()
if not dance:
raise HTTPException(404, "Dans ikke fundet")
alt_dance = db.query(SongDance).filter(SongDance.id == data.alt_song_dance_id).first()
if not alt_dance:
raise HTTPException(404, "Alternativ-dans ikke fundet")
alt = DanceAlternative(song_dance_id=dance_id, **data.model_dump())
db.add(alt)
db.commit()
db.refresh(alt)
return alt
@router.get("/{song_id}/dances/{dance_id}/alternatives", response_model=list[DanceAlternativeOut])
def list_alternatives(song_id: str, dance_id: str, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
dance = db.query(SongDance).filter(SongDance.id == dance_id, SongDance.song_id == song_id).first()
if not dance:
raise HTTPException(404, "Dans ikke fundet")
return dance.alternatives
@router.delete("/{song_id}/dances/{dance_id}/alternatives/{alt_id}", status_code=204)
def remove_alternative(song_id: str, dance_id: str, alt_id: str, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
alt = db.query(DanceAlternative).filter(DanceAlternative.id == alt_id, DanceAlternative.song_dance_id == dance_id).first()
if not alt:
raise HTTPException(404, "Alternativ ikke fundet")
db.delete(alt)
db.commit()

View File

@@ -0,0 +1,274 @@
"""
sync.py — Push/pull synkronisering mellem lokal app og server.
POST /sync/push — send lokal data op til server
GET /sync/pull — hent server-data ned til app
"""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from pydantic import BaseModel
from typing import Optional
from app.core.database import get_db
from app.core.security import get_current_user
from app.models import (
User, Song, Dance, DanceLevel, Project, ProjectSong,
PlaylistShare, CommunityDance, CommunityDanceAlt,
)
router = APIRouter(prefix="/sync", tags=["sync"])
# ── Schemas ───────────────────────────────────────────────────────────────────
class SongData(BaseModel):
local_id: str
title: str
artist: str = ""
album: str = ""
bpm: int = 0
duration_sec: int = 0
file_format: str = ""
class DanceData(BaseModel):
name: str
level_name: str = ""
choreographer: str = ""
video_url: str = ""
stepsheet_url: str = ""
notes: str = ""
class SongDanceData(BaseModel):
song_local_id: str
dance_name: str
level_name: str = ""
dance_order: int = 1
class SongAltDanceData(BaseModel):
song_local_id: str
dance_name: str
level_name: str = ""
note: str = ""
class PlaylistSongData(BaseModel):
song_local_id: str
position: int
status: str = "pending"
is_workshop: bool = False
dance_override: str = ""
class PlaylistData(BaseModel):
local_id: str
name: str
description: str = ""
tags: str = ""
visibility: str = "private"
songs: list[PlaylistSongData] = []
class PushPayload(BaseModel):
songs: list[SongData] = []
dances: list[DanceData] = []
song_dances: list[SongDanceData] = []
song_alts: list[SongAltDanceData] = []
playlists: list[PlaylistData] = []
# ── Push ──────────────────────────────────────────────────────────────────────
@router.post("/push")
def push(
payload: PushPayload,
db: Session = Depends(get_db),
me: User = Depends(get_current_user),
):
"""Upload lokal data til server. Returnerer server-IDs."""
song_id_map = {} # local_id → server Song.id
dance_id_map = {} # "name|level" → server Dance.id
level_map = {} # level_name → DanceLevel.id
# ── Dans-niveauer ─────────────────────────────────────────────────────────
for lvl in db.query(DanceLevel).all():
level_map[lvl.name.lower()] = lvl.id
# ── Sange ─────────────────────────────────────────────────────────────────
for s in payload.songs:
if not s.title:
continue
# Match globalt på titel+artist — samme sang deles på tværs af brugere
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 hvis det mangler
if s.bpm and not existing.bpm:
existing.bpm = s.bpm
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,
)
db.add(song)
db.flush()
song_id_map[s.local_id] = song.id
# ── Danse ──────────────────────────────────────────────────────────────────
for d in payload.dances:
level_id = level_map.get(d.level_name.lower()) if d.level_name else None
key = f"{d.name.lower()}|{level_id}"
existing = db.query(Dance).filter_by(name=d.name, level_id=level_id).first()
if existing:
# Opdater info hvis den har ny data
if d.choreographer: existing.choreographer = d.choreographer
if d.video_url: existing.video_url = d.video_url
if d.stepsheet_url: existing.stepsheet_url = d.stepsheet_url
if d.notes: existing.notes = d.notes
dance_id_map[key] = existing.id
else:
dance = Dance(
name=d.name, level_id=level_id,
choreographer=d.choreographer, video_url=d.video_url,
stepsheet_url=d.stepsheet_url, notes=d.notes,
)
db.add(dance)
db.flush()
dance_id_map[key] = dance.id
# ── Community dans-tags ────────────────────────────────────────────────────
for sd in payload.song_dances:
song_id = song_id_map.get(sd.song_local_id)
if not song_id:
continue
song = db.query(Song).filter_by(id=song_id).first()
level_id = level_map.get(sd.level_name.lower()) if sd.level_name else None
key = f"{sd.dance_name.lower()}|{level_id}"
dance_id = dance_id_map.get(key)
if not dance_id:
continue
# Indsend som community dans-tag
existing = db.query(CommunityDance).filter_by(
song_title=song.title, song_artist=song.artist, dance_id=dance_id
).first()
if not existing:
cd = CommunityDance(
song_title=song.title, song_artist=song.artist,
dance_id=dance_id, submitted_by=me.id,
)
db.add(cd)
# ── Playlister ────────────────────────────────────────────────────────────
playlist_id_map = {}
for pl in payload.playlists:
existing = db.query(Project).filter_by(
owner_id=me.id, name=pl.name
).first()
if existing:
existing.description = pl.description
existing.visibility = pl.visibility
# Slet og geninsert sange
db.query(ProjectSong).filter_by(project_id=existing.id).delete()
project = existing
else:
project = Project(
owner_id=me.id, name=pl.name,
description=pl.description, visibility=pl.visibility,
)
db.add(project)
db.flush()
playlist_id_map[pl.local_id] = project.id
for ps in pl.songs:
song_id = song_id_map.get(ps.song_local_id)
if not song_id:
continue
proj_song = ProjectSong(
project_id=project.id, song_id=song_id,
position=ps.position, status=ps.status,
is_workshop=ps.is_workshop,
dance_override=ps.dance_override,
)
db.add(proj_song)
db.commit()
return {
"status": "ok",
"songs_synced": len(song_id_map),
"playlists_synced": len(playlist_id_map),
"song_id_map": song_id_map,
"playlist_id_map": playlist_id_map,
}
# ── Pull ──────────────────────────────────────────────────────────────────────
@router.get("/pull")
def pull(
db: Session = Depends(get_db),
me: User = Depends(get_current_user),
):
"""Hent server-data til lokal app."""
# Dans-niveauer
levels = [
{"id": l.id, "name": l.name, "sort_order": l.sort_order}
for l in db.query(DanceLevel).order_by(DanceLevel.sort_order).all()
]
# Danse med info
dances = [
{
"name": d.name,
"level_id": d.level_id,
"choreographer": d.choreographer,
"video_url": d.video_url,
"stepsheet_url": d.stepsheet_url,
"notes": d.notes,
"use_count": d.use_count,
}
for d in db.query(Dance).order_by(Dance.use_count.desc()).limit(500).all()
]
# Community dans-tags (populære)
community = []
for cd in db.query(CommunityDance).limit(1000).all():
community.append({
"song_title": cd.song_title,
"song_artist": cd.song_artist,
"dance_id": cd.dance_id,
})
# Delte playlister
shared_ids = [
s.project_id for s in
db.query(PlaylistShare).filter_by(shared_with_id=me.id).all()
]
shared = []
for p in db.query(Project).filter(Project.id.in_(shared_ids)).all():
shared.append({
"id": p.id,
"name": p.name,
"owner_id": p.owner_id,
"visibility": p.visibility,
"songs": [
{
"song_id": ps.song_id,
"position": ps.position,
"status": ps.status,
"is_workshop": ps.is_workshop,
"dance_override": ps.dance_override,
}
for ps in p.project_songs
]
})
return {
"levels": levels,
"dances": dances,
"community": community,
"shared": shared,
}

View File

@@ -3,7 +3,7 @@ uvicorn[standard]>=0.29.0
sqlalchemy>=2.0.0
pymysql>=1.1.0
alembic>=1.13.0
passlib[bcrypt]>=1.7.4
bcrypt>=4.0.0
python-jose[cryptography]>=3.3.0
pydantic[email]>=2.0.0
pydantic-settings>=2.0.0

View File

@@ -0,0 +1,14 @@
@echo off
echo Starter LineDance API lokalt...
cd /d %~dp0
if not exist venv (
python -m venv venv
venv\Scripts\pip install -r requirements.txt
)
if not exist .env (
copy .env.example .env
echo.
echo VIGTIGT: Rediger .env med dine database-indstillinger!
pause
)
venv\Scripts\uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload

View File

@@ -1,23 +1,22 @@
# -*- mode: python ; coding: utf-8 -*-
from PyInstaller.utils.hooks import collect_all, collect_submodules
block_cipher = None
# Saml ALT fra PyQt6 inkl. plugins og DLL-filer
pyqt6_datas, pyqt6_binaries, pyqt6_hiddenimports = collect_all('PyQt6')
a = Analysis(
['main.py'],
pathex=['.'],
binaries=[],
datas=[
('translations', 'translations'),
('ui', 'ui'),
('local', 'local'),
('player', 'player'),
],
hiddenimports=[
binaries=pyqt6_binaries,
datas=pyqt6_datas,
hiddenimports=pyqt6_hiddenimports + [
'PyQt6.sip',
'PyQt6.QtCore',
'PyQt6.QtGui',
'PyQt6.QtWidgets',
'PyQt6.QtNetwork',
# UI moduler
'ui.main_window',
'ui.playlist_panel',
'ui.library_panel',
@@ -25,76 +24,30 @@ a = Analysis(
'ui.themes',
'ui.vu_meter',
'ui.scan_worker',
'ui.bpm_worker',
'ui.tag_editor',
'ui.login_dialog',
'ui.settings_dialog',
'ui.register_dialog',
'ui.playlist_browser',
'ui.playlist_info_dialog',
'ui.dance_info_dialog',
'ui.dance_picker_dialog',
'ui.playlist_manager',
'ui.next_up_bar',
# Player + local
'player.player',
'local.local_db',
'local.tag_reader',
'local.file_watcher',
'local.scanner',
'translations',
'translations.da',
'translations.en',
'mutagen',
'mutagen.mp3', 'mutagen.id3', 'mutagen.flac',
# Biblioteker
'mutagen', 'mutagen.mp3', 'mutagen.id3', 'mutagen.flac',
'mutagen.mp4', 'mutagen.oggvorbis', 'mutagen.ogg',
'mutagen.wave', 'mutagen.aiff', 'mutagen.asf',
'watchdog',
'watchdog.observers',
'watchdog.observers.polling',
'watchdog.events',
'vlc',
'sqlite3',
'watchdog', 'watchdog.observers', 'watchdog.events',
'watchdog.observers.winapi',
'vlc', 'sqlite3',
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[
'tkinter', 'tk', 'tcl',
'matplotlib', 'pandas', 'scipy', 'numpy',
'IPython', 'jupyter', 'notebook',
'PIL', 'Pillow',
'cv2', 'sklearn',
'PyQt6.QtWebEngineWidgets',
'PyQt6.QtWebEngineCore',
'PyQt6.QtWebEngine',
'PyQt6.QtMultimedia',
'PyQt6.QtMultimediaWidgets',
'PyQt6.QtBluetooth',
'PyQt6.QtNfc',
'PyQt6.QtPositioning',
'PyQt6.QtLocation',
'PyQt6.QtSensors',
'PyQt6.QtSerialPort',
'PyQt6.QtSql',
'PyQt6.QtTest',
'PyQt6.QtXml',
'PyQt6.QtOpenGL',
'PyQt6.QtOpenGLWidgets',
'PyQt6.Qt3DCore',
'PyQt6.Qt3DRender',
'PyQt6.Qt3DInput',
'PyQt6.Qt3DLogic',
'PyQt6.Qt3DAnimation',
'PyQt6.Qt3DExtras',
'PyQt6.QtCharts',
'PyQt6.QtDataVisualization',
'PyQt6.QtQuick',
'PyQt6.QtQuickWidgets',
'PyQt6.QtQml',
'PyQt6.QtRemoteObjects',
'PyQt6.QtScxml',
'PyQt6.QtStateMachine',
'unittest', 'doctest', 'pdb',
'pydoc',
],
excludes=['tkinter', 'matplotlib', 'pandas', 'scipy', 'IPython'],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
@@ -104,15 +57,18 @@ pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
[], # ← onedir: ingen binaries/datas her
exclude_binaries=True, # ← onedir: binaries samles i COLLECT
[],
exclude_binaries=True,
name='LineDancePlayer',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=['Qt6*.dll', 'python3*.dll', 'vcruntime140.dll'],
console=False,
upx=False, # UPX kan give problemer med PyQt6 DLL-filer
console=False, # Ingen konsol-vindue
disable_windowed_traceback=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=None,
)
@@ -122,7 +78,7 @@ coll = COLLECT(
a.zipfiles,
a.datas,
strip=False,
upx=True,
upx_exclude=['Qt6*.dll', 'python3*.dll', 'vcruntime140.dll'],
upx=False,
upx_exclude=[],
name='LineDancePlayer',
)

View File

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

View File

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

View File

@@ -0,0 +1,245 @@
"""
sync_manager.py — Synkronisering mellem lokal SQLite og server API.
Kører i baggrundstråd — blokerer aldrig GUI.
"""
import json
import sqlite3
import threading
import urllib.request
import urllib.error
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
class SyncManager:
def __init__(self, db_path: str, server_url: str, token: str):
self._db_path = db_path
self._server_url = server_url.rstrip("/")
self._token = token
self._lock = threading.Lock()
def _headers(self):
return {
"Content-Type": "application/json",
"Authorization": f"Bearer {self._token}",
}
def _post(self, path: str, data: dict) -> dict:
body = json.dumps(data).encode("utf-8")
req = urllib.request.Request(
f"{self._server_url}{path}", data=body,
headers=self._headers(), method="POST"
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
detail = e.read().decode("utf-8", errors="replace")
raise Exception(f"HTTP {e.code}: {detail}")
def _get(self, path: str) -> dict:
req = urllib.request.Request(
f"{self._server_url}{path}",
headers=self._headers(), method="GET"
)
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())
# ── Push ──────────────────────────────────────────────────────────────────
def push(self, on_done=None, on_error=None):
"""Push lokal data til server i baggrundstråd."""
def _run():
try:
payload = self._build_push_payload()
result = self._post("/sync/push", payload)
# Gem server-IDs lokalt
self._save_playlist_ids(result.get("playlist_id_map", {}))
logger.info(f"Sync push: {result}")
if on_done:
on_done(result)
except Exception as e:
logger.error(f"Sync push fejl: {e}")
if on_error:
on_error(str(e))
threading.Thread(target=_run, daemon=True).start()
def _save_playlist_ids(self, id_map: dict):
"""Gem server-IDs (api_project_id) på lokale playlister."""
if not id_map:
return
conn = sqlite3.connect(self._db_path)
for local_id, server_id in id_map.items():
try:
conn.execute(
"UPDATE playlists SET api_project_id=? WHERE id=?",
(server_id, int(local_id))
)
except Exception:
pass
conn.commit()
conn.close()
def pull(self, on_done=None, on_error=None):
"""Pull server-data ned i baggrundstråd."""
def _run():
try:
result = self._get("/sync/pull")
self._apply_pull(result)
logger.info(f"Sync pull: {len(result.get('dances', []))} danse")
if on_done:
on_done(result)
except Exception as e:
logger.error(f"Sync pull fejl: {e}")
if on_error:
on_error(str(e))
threading.Thread(target=_run, daemon=True).start()
def push_and_pull(self, on_done=None, on_error=None):
"""Push og derefter pull i samme tråd."""
def _run():
try:
payload = self._build_push_payload()
push_result = self._post("/sync/push", payload)
pull_result = self._get("/sync/pull")
self._apply_pull(pull_result)
if on_done:
on_done({"push": push_result, "pull": pull_result})
except Exception as e:
logger.error(f"Sync fejl: {e}")
if on_error:
on_error(str(e))
threading.Thread(target=_run, daemon=True).start()
# ── Byg payload ───────────────────────────────────────────────────────────
def _build_push_payload(self) -> dict:
conn = sqlite3.connect(self._db_path)
conn.row_factory = sqlite3.Row
# Sange
songs = []
for row in conn.execute(
"SELECT id, title, artist, album, bpm, duration_sec, file_format "
"FROM songs WHERE file_missing=0"
).fetchall():
songs.append({
"local_id": str(row["id"]),
"title": row["title"] or "",
"artist": row["artist"] or "",
"album": row["album"] or "",
"bpm": row["bpm"] or 0,
"duration_sec": row["duration_sec"] or 0,
"file_format": row["file_format"] or "",
})
# Danse
dances = []
for row in conn.execute(
"SELECT d.name, dl.name as level_name, d.choreographer, "
"d.video_url, d.stepsheet_url, d.notes "
"FROM dances d LEFT JOIN dance_levels dl ON dl.id = d.level_id"
).fetchall():
dances.append({
"name": row["name"] or "",
"level_name": row["level_name"] or "",
"choreographer": row["choreographer"] or "",
"video_url": row["video_url"] or "",
"stepsheet_url": row["stepsheet_url"] or "",
"notes": row["notes"] or "",
})
# Dans-tags per sang
song_dances = []
for row in conn.execute("""
SELECT sd.song_id, d.name as dance_name, dl.name as level_name, sd.dance_order
FROM song_dances sd
JOIN dances d ON d.id = sd.dance_id
LEFT JOIN dance_levels dl ON dl.id = d.level_id
""").fetchall():
song_dances.append({
"song_local_id": str(row["song_id"]),
"dance_name": row["dance_name"],
"level_name": row["level_name"] or "",
"dance_order": row["dance_order"],
})
# Alternativ-danse
song_alts = []
for row in conn.execute("""
SELECT sad.song_id, d.name as dance_name, dl.name as level_name, sad.note
FROM song_alt_dances sad
JOIN dances d ON d.id = sad.dance_id
LEFT JOIN dance_levels dl ON dl.id = d.level_id
""").fetchall():
song_alts.append({
"song_local_id": str(row["song_id"]),
"dance_name": row["dance_name"],
"level_name": row["level_name"] or "",
"note": row["note"] or "",
})
# Playlister (kun navngivne — ikke __aktiv__)
playlists = []
for pl in conn.execute(
"SELECT id, name, description, tags FROM playlists "
"WHERE name != '__aktiv__'"
).fetchall():
pl_songs = []
for ps in conn.execute("""
SELECT song_id, position, status, is_workshop, dance_override
FROM playlist_songs WHERE playlist_id=? ORDER BY position
""", (pl["id"],)).fetchall():
pl_songs.append({
"song_local_id": ps["song_id"] or "",
"position": int(ps["position"] or 1),
"status": ps["status"] or "pending",
"is_workshop": bool(ps["is_workshop"]),
"dance_override": ps["dance_override"] or "",
})
playlists.append({
"local_id": str(pl["id"]),
"name": pl["name"],
"description": pl["description"] or "",
"tags": pl["tags"] or "",
"visibility": "private",
"songs": pl_songs,
})
conn.close()
return {
"songs": songs,
"dances": dances,
"song_dances": song_dances,
"song_alts": song_alts,
"playlists": playlists,
}
# ── Anvend pull ───────────────────────────────────────────────────────────
def _apply_pull(self, data: dict):
"""Gem server-data lokalt — opdaterer dans-info og community forslag."""
conn = sqlite3.connect(self._db_path)
conn.row_factory = sqlite3.Row
# Opdater dans-info fra server (koreograf, links, noter)
for d in data.get("dances", []):
if not d.get("name"):
continue
existing = conn.execute(
"SELECT id FROM dances WHERE name=? COLLATE NOCASE", (d["name"],)
).fetchone()
if existing and (d.get("choreographer") or d.get("video_url") or d.get("stepsheet_url")):
conn.execute("""
UPDATE dances SET
choreographer = CASE WHEN choreographer='' THEN ? ELSE choreographer END,
video_url = CASE WHEN video_url='' THEN ? ELSE video_url END,
stepsheet_url = CASE WHEN stepsheet_url='' THEN ? ELSE stepsheet_url END
WHERE id=?
""", (d.get("choreographer",""), d.get("video_url",""),
d.get("stepsheet_url",""), existing["id"]))
conn.commit()
conn.close()

View File

@@ -95,6 +95,9 @@ class MainWindow(QMainWindow):
self._connect_player_signals()
self._library_loaded.connect(self._apply_library)
self._db_ready.connect(self._on_db_ready)
self._login_success_signal.connect(self._on_login_success)
self._login_fail_signal.connect(self._on_login_fail)
self._status_signal.connect(self._set_status)
self._build_menu()
self._build_ui()
self._build_statusbar()
@@ -130,15 +133,15 @@ class MainWindow(QMainWindow):
# ── Filer ─────────────────────────────────────────────────────────────
file_menu = menubar.addMenu("Filer")
self._act_go_online = QAction("Gå online...", self)
self._act_go_online = QAction("Gå online", self)
self._act_go_online.setShortcut("Ctrl+L")
self._act_go_online.triggered.connect(self._go_online)
file_menu.addAction(self._act_go_online)
self._act_go_offline = QAction("Gå offline", self)
self._act_go_offline.triggered.connect(self._go_offline)
self._act_go_offline.setEnabled(False)
file_menu.addAction(self._act_go_offline)
self._act_sync = QAction("↕ Synkroniser nu", self)
self._act_sync.setShortcut("Ctrl+Shift+S")
self._act_sync.triggered.connect(self._manual_sync)
file_menu.addAction(self._act_sync)
file_menu.addSeparator()
@@ -287,28 +290,26 @@ class MainWindow(QMainWindow):
b.setCheckable(True)
return b
self._btn_prev = btn("|◀◀", size=52)
self._btn_play = btn("", "btn_play", size=72)
self._btn_stop = btn("", "btn_stop", size=52)
self._btn_next = btn("▶▶|", size=52)
self._btn_stop = btn("", "btn_stop", size=72)
self._btn_demo = btn(f"\n{self._demo_seconds} SEK", "btn_demo", size=64, checkable=True)
self._btn_prev.clicked.connect(self._prev_song)
self._btn_play.clicked.connect(self._toggle_play)
self._btn_stop.clicked.connect(self._stop)
self._btn_next.clicked.connect(self._next_song)
self._btn_demo.clicked.connect(self._toggle_demo)
layout.addWidget(self._btn_prev)
layout.addWidget(self._btn_play)
layout.addWidget(self._btn_stop)
layout.addWidget(self._btn_next)
layout.addSpacing(24)
sep1 = QFrame()
sep1.setFrameShape(QFrame.Shape.VLine)
sep1.setFixedWidth(1)
layout.addWidget(sep1)
layout.addSpacing(24)
layout.addWidget(self._btn_demo)
layout.addStretch()
@@ -319,7 +320,9 @@ class MainWindow(QMainWindow):
self._vol_slider = QSlider(Qt.Orientation.Horizontal)
self._vol_slider.setRange(0, 100)
self._vol_slider.setValue(self._settings.get("volume", 78))
self._vol_slider.setFixedWidth(100)
self._vol_slider.setFixedWidth(160)
self._vol_slider.setFixedHeight(36)
self._vol_slider.setObjectName("vol_slider")
self._vol_slider.valueChanged.connect(self._on_volume)
layout.addWidget(self._vol_slider)
@@ -336,7 +339,14 @@ class MainWindow(QMainWindow):
self._playlist_panel.song_selected.connect(self._load_song_by_idx)
self._playlist_panel.song_dropped.connect(self._on_song_dropped)
self._playlist_panel.event_started.connect(self._on_event_started)
self._playlist_panel.next_song_ready.connect(self._load_song)
self._playlist_panel.next_song_ready.connect(self._on_next_song_ready)
self._playlist_panel.playlist_changed.connect(self._on_playlist_changed)
# Debounce-timer til auto-sync — starter sync 5 sek efter sidst ændring
self._sync_debounce = QTimer(self)
self._sync_debounce.setSingleShot(True)
self._sync_debounce.setInterval(5000)
self._sync_debounce.timeout.connect(self._auto_sync)
self._library_panel = LibraryPanel()
self._library_panel.song_selected.connect(self._on_library_song_selected)
@@ -436,6 +446,9 @@ class MainWindow(QMainWindow):
_library_loaded = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal(list)
_db_ready = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal()
_file_changed_signal = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal()
_login_success_signal = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal(str)
_login_fail_signal = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal(str)
_status_signal = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal(str, int)
def _reload_library(self):
"""Hent sange fra DB i baggrundstråd — thread-safe via signal."""
@@ -508,20 +521,36 @@ class MainWindow(QMainWindow):
try:
restored = self._playlist_panel.restore_active_playlist()
if restored:
# Hent den sang der er klar (current_idx sat af restore)
idx = self._playlist_panel._current_idx
song = self._playlist_panel.get_song(idx)
if self._playlist_panel.restore_event_state():
# Event var i gang — genoptag
idx = self._playlist_panel._current_idx
song = self._playlist_panel.get_song(idx)
if song:
self._current_idx = idx
self._song_ended = False
self._load_song(song)
self._playlist_panel.set_current(idx)
self._set_status(
f"Event genoptaget ved: {song.get('title','')} — tryk ▶ for at fortsætte",
f"Event genoptaget ved: {song.get('title','')} — tryk ▶",
6000,
)
elif song:
# Normal opstart — load første sang klar
self._current_idx = idx
self._song_ended = False
self._load_song(song)
self._playlist_panel.set_current(idx)
self._set_status(
f"Klar: {song.get('title','')} — tryk ▶ for at starte",
4000,
)
except Exception:
pass
# Scan 30 sek efter opstart — fanger ændringer siden sidst
QTimer.singleShot(30000, self.start_background_scan)
def start_background_scan(self):
@@ -604,38 +633,102 @@ class MainWindow(QMainWindow):
"""Forsøg automatisk login med gemte oplysninger."""
username = self._settings.get("username", "")
password = self._settings.get("password", "")
server_url = self._settings.get("server_url", "http://localhost:8000").rstrip("/")
if not username or not password:
return
def _run():
try:
import urllib.request, urllib.parse, json
data = urllib.parse.urlencode({"username": username, "password": password}).encode()
req = urllib.request.Request(
f"{API_URL}/auth/login", data=data,
f"{server_url}/auth/login", data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
method="POST",
)
with urllib.request.urlopen(req, timeout=8) as resp:
body = json.loads(resp.read())
self._api_token = body.get("access_token")
self._api_url = API_URL
self._api_url = server_url
self._api_username = username
# Kald GUI-opdatering via signal — thread-safe
self._login_success_signal.emit(username)
except Exception as e:
self._login_fail_signal.emit(str(e))
import threading
threading.Thread(target=_run, daemon=True).start()
def _on_playlist_changed(self):
"""Danseliste ændret — start debounce-timer til auto-sync."""
if hasattr(self, "_sync_debounce"):
self._sync_debounce.start()
def _auto_sync(self):
"""Kør sync hvis vi er online — kaldes af debounce-timer."""
if not self._api_token:
return
if not hasattr(self, "_sync_manager") or not self._sync_manager:
return
self._sync_manager.push(
on_done=lambda r: self._status_signal.emit(
f"↑ Synkroniseret — {r.get('songs_synced', 0)} sange", 3000
),
on_error=lambda e: self._status_signal.emit(
f"⚠ Sync fejl: {e}", 8000
),
)
def _on_next_song_ready(self, song: dict):
"""Næste sang er klar — load den i afspilleren og markér orange."""
idx = self._playlist_panel._current_idx
self._current_idx = idx
self._song_ended = False
self._playlist_panel._song_ended = False
self._load_song(song)
self._playlist_panel.set_current(idx)
def _on_login_success(self, username: str):
"""Kaldes i GUI-tråden når login lykkes."""
self._set_online_state(True)
self._set_status(f"Automatisk logget ind som {username}", 4000)
# Synkroniser dans-niveauer og navne
QTimer.singleShot(500, self._sync_dance_data)
except Exception:
self._set_status("Auto-login fejlede — kør Filer → Gå online manuelt", 5000)
self._set_status(f"Logget ind som {username}", 4000)
def _on_login_fail(self, error: str):
"""Kaldes i GUI-tråden når login fejler."""
self._set_status(f"Login fejlede: {error}", 5000)
def _go_online(self):
dialog = LoginDialog(self)
if dialog.exec():
url, username, token = dialog.get_credentials()
self._api_url = url
self._api_token = token
"""Log ind/ud med gemte credentials."""
if self._api_token:
self._go_offline()
return
username = self._settings.get("username", "")
password = self._settings.get("password", "")
server_url = self._settings.get("server_url", "http://localhost:8000").rstrip("/")
if not username or not password:
self._set_status("Udfyld brugernavn og kodeord i Indstillinger → Online", 5000)
return
def _run():
try:
import urllib.request, urllib.parse, json
data = urllib.parse.urlencode({"username": username, "password": password}).encode()
req = urllib.request.Request(
f"{server_url}/auth/login", data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
method="POST",
)
with urllib.request.urlopen(req, timeout=8) as resp:
body = json.loads(resp.read())
self._api_token = body.get("access_token")
self._api_url = server_url
self._api_username = username
self._set_online_state(True)
self._set_status(f"Online som {username}", 5000)
QTimer.singleShot(500, self._sync_dance_data)
self._login_success_signal.emit(username)
except Exception as e:
self._login_fail_signal.emit(str(e))
import threading
threading.Thread(target=_run, daemon=True).start()
def _sync_dance_data(self):
"""Synkroniser dans-niveauer og navne fra API."""
@@ -669,15 +762,56 @@ class MainWindow(QMainWindow):
self._set_status("Offline — arbejder lokalt", 3000)
def _set_online_state(self, online: bool):
self._act_go_online.setEnabled(not online)
self._act_go_offline.setEnabled(online)
if online:
name = self._api_username or "?"
self._conn_label.setText(f"● ONLINE ({name})")
self._conn_label.setStyleSheet("color: #2ecc71;")
self._act_go_online.setText("● Gå offline")
self._init_sync()
else:
self._conn_label.setText("● OFFLINE")
self._conn_label.setStyleSheet("color: #5a6070;")
self._act_go_online.setText("● Gå online")
self._sync_manager = None
def _init_sync(self):
"""Opret SyncManager og kør initial push+pull."""
try:
from local.local_db import DB_PATH
from local.sync_manager import SyncManager
server_url = self._settings.get("server_url", "http://localhost:8000")
self._sync_manager = SyncManager(
db_path=str(DB_PATH),
server_url=server_url,
token=self._api_token,
)
self._sync_manager.push_and_pull(
on_done=lambda r: self._status_signal.emit(
f"✓ Synkroniseret — {r['push']['songs_synced']} sange", 5000
),
on_error=lambda e: self._status_signal.emit(
f"⚠ Sync fejl: {e}", 5000
),
)
except Exception as e:
self._set_status(f"⚠ Sync fejl: {e}", 5000)
def _manual_sync(self):
if not self._api_token:
self._set_status("Log ind for at synkronisere", 3000)
return
if not hasattr(self, "_sync_manager") or not self._sync_manager:
self._init_sync()
return
self._set_status("Synkroniserer...", 2000)
self._sync_manager.push_and_pull(
on_done=lambda r: self._status_signal.emit(
f"✓ Synkroniseret — {r['push']['songs_synced']} sange", 4000
),
on_error=lambda e: self._status_signal.emit(
f"⚠ Sync fejl: {e}", 5000
),
)
def _new_playlist(self):
self._stop()
@@ -851,6 +985,12 @@ class MainWindow(QMainWindow):
song = self._playlist_panel.get_song(idx)
if not song:
return
# Nulstil gammel markering
old_idx = self._playlist_panel._current_idx
if old_idx is not None and old_idx != idx:
if 0 <= old_idx < len(self._playlist_panel._statuses):
if self._playlist_panel._statuses[old_idx] == "playing":
self._playlist_panel._statuses[old_idx] = "pending"
self._current_idx = idx
self._load_song(song)
self._playlist_panel.set_current(idx)
@@ -944,13 +1084,16 @@ class MainWindow(QMainWindow):
self._btn_play.setText("")
self._vu.reset()
# Synkroniser current_idx til playlist_panel
self._playlist_panel._current_idx = self._current_idx
# Markér den afspillede sang
self._playlist_panel.mark_played(self._current_idx)
# Synkroniser event-status til den gemte navngivne liste
self._sync_event_status_to_playlist()
# Find første ikke-afspillede og ikke-skippede sang fra TOPPEN
# Find ste uafspillede
ni = self._playlist_panel.next_playable_idx()
next_song = self._playlist_panel.get_song(ni) if ni is not None else None
if next_song:
@@ -959,7 +1102,6 @@ class MainWindow(QMainWindow):
self._load_song(next_song)
self._set_status(f"Klar: {next_song.get('title','')} — tryk ▶ for at starte")
else:
# Danseliste afsluttet — nulstil liste-markering og synkroniser
self._current_idx = -1
self._playlist_panel._current_idx = -1
self._playlist_panel._song_ended = False

View File

@@ -130,6 +130,12 @@ class PlaylistBrowserDialog(QDialog):
btn_tags = QPushButton("🏷 Rediger tags")
btn_tags.clicked.connect(self._edit_tags)
btn_row.addWidget(btn_tags)
btn_share = QPushButton("↗ Del...")
btn_share.clicked.connect(self._share_selected)
btn_row.addWidget(btn_share)
btn_shared = QPushButton("🌐 Hent delte")
btn_shared.clicked.connect(self._fetch_shared)
btn_row.addWidget(btn_shared)
btn_row.addStretch()
btn_cancel = QPushButton("Annuller")
@@ -344,3 +350,167 @@ class PlaylistBrowserDialog(QDialog):
self._load_data()
except Exception as e:
QMessageBox.warning(self, "Fejl", f"Kunne ikke slette: {e}")
def _share_selected(self):
"""Åbn del-dialog for den valgte playliste."""
item = self._list.currentItem()
if not item:
QMessageBox.information(self, "Del", "Vælg en playliste først.")
return
pl = item.data(Qt.ItemDataRole.UserRole)
if not isinstance(pl, dict):
return
# Hent server-info fra settings
try:
from ui.settings_dialog import load_settings
s = load_settings()
server_url = s.get("server_url", "")
token = self._get_token()
if not token:
QMessageBox.warning(self, "Ikke logget ind",
"Du skal være logget ind for at dele.")
return
# Find server-ID for playlisten
server_id = pl.get("api_project_id")
if not server_id:
QMessageBox.warning(self, "Ikke synkroniseret",
"Synkroniser playlisten til serveren først\n"
"(Filer → Synkroniser nu).")
return
from ui.share_dialog import ShareDialog
dlg = ShareDialog(server_id, pl["name"], server_url, token,
parent=self)
dlg.exec()
except Exception as e:
QMessageBox.warning(self, "Fejl", str(e))
def _get_token(self) -> str | None:
"""Hent JWT token fra main_window."""
mw = self.parent()
while mw and not hasattr(mw, "_api_token"):
mw = mw.parent()
return getattr(mw, "_api_token", None) if mw else None
def _fetch_shared(self):
"""Hent playlister der er delt med mig fra serveren."""
try:
from ui.settings_dialog import load_settings
s = load_settings()
server_url = s.get("server_url", "").rstrip("/")
token = self._get_token()
if not token:
QMessageBox.warning(self, "Ikke logget ind",
"Du skal være logget ind for at hente delte lister.")
return
import urllib.request, json
req = urllib.request.Request(
f"{server_url}/sharing/playlists/shared-with-me",
headers={"Authorization": f"Bearer {token}"}
)
with urllib.request.urlopen(req, timeout=10) as resp:
shared = json.loads(resp.read())
if not shared:
QMessageBox.information(self, "Ingen delte lister",
"Ingen playlister er delt med dig.")
return
# Vis valgdialog
from PyQt6.QtWidgets import QInputDialog
options = [
f"{p['name']} (af {p['owner']}, {p['song_count']} sange, {p['permission']})"
for p in shared
]
choice, ok = QInputDialog.getItem(
self, "Hent delt playliste",
"Vælg en playliste at hente:",
options, 0, False
)
if not ok:
return
idx = options.index(choice)
chosen = shared[idx]
# Hent indholdet
req2 = urllib.request.Request(
f"{server_url}/sharing/playlists/{chosen['project_id']}",
headers={"Authorization": f"Bearer {token}"}
)
with urllib.request.urlopen(req2, timeout=10) as resp:
pl_data = json.loads(resp.read())
self._import_shared_playlist(pl_data, server_url, token,
permission=chosen.get("permission", "view"))
except Exception as e:
QMessageBox.warning(self, "Fejl", f"Kunne ikke hente: {e}")
def _import_shared_playlist(self, pl_data: dict, server_url: str, token: str,
permission: str = "view"):
"""Importer en delt playliste som en linket liste."""
import sqlite3
from local.local_db import DB_PATH, get_db, add_song_to_playlist
name = pl_data["name"]
server_id = pl_data["id"]
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
# Tjek om listen allerede er linket
existing = conn.execute(
"SELECT id FROM playlists WHERE api_project_id=?", (server_id,)
).fetchone()
conn.close()
if existing:
# Opdater eksisterende
pl_id = existing["id"]
with get_db() as c:
c.execute("DELETE FROM playlist_songs WHERE playlist_id=?", (pl_id,))
else:
# Opret ny linket playliste
with get_db() as c:
c.execute(
"INSERT INTO playlists (name, api_project_id, is_linked, server_permission) "
"VALUES (?, ?, 1, ?)",
(name, server_id, permission)
)
pl_id = c.execute("SELECT last_insert_rowid()").fetchone()[0]
# Indsæt sange med sang-matching
matched = 0
with get_db() as c:
for song_data in pl_data.get("songs", []):
local = c.execute(
"SELECT id FROM songs WHERE title=? AND artist=? AND file_missing=0",
(song_data["title"], song_data["artist"])
).fetchone()
if local:
c.execute(
"INSERT INTO playlist_songs "
"(playlist_id, song_id, position, status, is_workshop, dance_override) "
"VALUES (?,?,?,?,?,?)",
(pl_id, local["id"], song_data["position"],
song_data.get("status", "pending"),
1 if song_data.get("is_workshop") else 0,
song_data.get("dance_override") or "")
)
matched += 1
self._load_data()
self.playlist_selected.emit(pl_id, name)
perm_text = {"view": "se", "copy": "kopiere", "edit": "redigere"}.get(
permission, permission
)
QMessageBox.information(
self, "Linket",
f"'{name}' er nu linket til server-listen.\n"
f"Du har rettighed til at {perm_text} listen.\n\n"
f"{matched} af {len(pl_data.get('songs', []))} sange fundet lokalt."
)

View File

@@ -3,7 +3,7 @@ playlist_info_dialog.py — Flydende danseliste-info vindue med dynamisk opdater
"""
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QSpinBox,
QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QFrame, QGridLayout,
)
from PyQt6.QtCore import Qt, pyqtSignal
@@ -22,7 +22,6 @@ def fmt_time(seconds: int) -> str:
class PlaylistInfoWindow(QWidget):
pause_changed = pyqtSignal(int)
def __init__(self, playlist_panel, parent=None):
super().__init__(parent,
@@ -83,33 +82,6 @@ class PlaylistInfoWindow(QWidget):
layout.addWidget(stats)
# Indstillinger
cfg = QFrame()
cfg.setObjectName("track_display")
cfg_layout = QGridLayout(cfg)
cfg_layout.setContentsMargins(12, 8, 12, 8)
cfg_layout.setSpacing(6)
cfg_layout.addWidget(QLabel("Tid mellem musikstykker:"), 0, 0)
self._spin_pause = QSpinBox()
self._spin_pause.setRange(0, 600)
self._spin_pause.setValue(self._pause_seconds)
self._spin_pause.setSuffix(" sek")
self._spin_pause.setFixedWidth(90)
self._spin_pause.valueChanged.connect(self._on_pause_changed)
cfg_layout.addWidget(self._spin_pause, 0, 1)
cfg_layout.addWidget(QLabel("Tid per workshop:"), 1, 0)
self._spin_ws = QSpinBox()
self._spin_ws.setRange(0, 120)
self._spin_ws.setValue(self._workshop_seconds // 60)
self._spin_ws.setSuffix(" min")
self._spin_ws.setFixedWidth(90)
self._spin_ws.valueChanged.connect(self._on_ws_changed)
cfg_layout.addWidget(self._spin_ws, 1, 1)
layout.addWidget(cfg)
# Fremgang og ETA
eta_frame = QFrame()
eta_frame.setObjectName("track_display")
@@ -131,26 +103,14 @@ class PlaylistInfoWindow(QWidget):
layout.addWidget(eta_frame)
def _on_pause_changed(self, value: int):
self._pause_seconds = value
if hasattr(self._panel, "_pause_seconds"):
self._panel._pause_seconds = value
self.pause_changed.emit(value)
self._update()
def _on_ws_changed(self, minutes: int):
self._workshop_seconds = minutes * 60
if hasattr(self._panel, "_workshop_seconds"):
self._panel._workshop_seconds = self._workshop_seconds
self._update()
def _update(self):
songs = self._panel.get_songs()
statuses = self._panel.get_statuses()
total = len(songs)
played = statuses.count("played")
skipped = statuses.count("skipped")
remaining = total - played - skipped
done = played + skipped # samlet "overstået"
remaining = total - done
ws_total = sum(1 for s in songs if s.get("is_workshop"))
ws_remain = sum(1 for s, st in zip(songs, statuses)
@@ -189,10 +149,10 @@ class PlaylistInfoWindow(QWidget):
self._lbl_eta.setText("✓ Danselisten er afsluttet!")
self._lbl_finish.setText("")
elif total > 0:
pct = int(played / total * 100) if total > 0 else 0
pct = int(done / total * 100) if total > 0 else 0
self._lbl_eta.setText(
f"{pct}% færdig · {fmt_time(remain_time)} tilbage"
if played > 0 else f"Samlet varighed: {fmt_time(total_time)}"
if done > 0 else f"Samlet varighed: {fmt_time(total_time)}"
)
finish = datetime.now() + timedelta(seconds=remain_time)
self._lbl_finish.setText(f"Estimeret sluttid: {finish.strftime('%H:%M')}")

View File

@@ -289,9 +289,11 @@ class PlaylistPanel(QWidget):
return self._named_playlist_id
def next_playable_idx(self) -> int | None:
"""Find første sang fra toppen der ikke er 'skipped' eller 'played'."""
"""Find første sang fra toppen der ikke er afspillet, sprunget over eller i gang."""
for i in range(len(self._songs)):
if self._statuses[i] not in ("skipped", "played"):
if self._statuses[i] not in ("skipped", "played", "playing"):
if i == self._current_idx and not self._song_ended:
continue
return i
return None
@@ -303,25 +305,42 @@ class PlaylistPanel(QWidget):
self._lbl_autosave.setText("● ikke gemt")
def _autosave(self):
"""Gem til den faste 'Aktiv liste' i SQLite."""
"""Gem til '__aktiv__' OG til den navngivne liste hvis der er én."""
try:
from local.local_db import get_db, create_playlist, add_song_to_playlist
with get_db() as conn:
# Slet den gamle aktive liste
conn.execute(
"DELETE FROM playlists WHERE name=?", (ACTIVE_PLAYLIST_NAME,)
)
# Opret ny
pl_id = create_playlist(ACTIVE_PLAYLIST_NAME)
self._active_playlist_id = pl_id
for i, song in enumerate(self._songs, start=1):
if song.get("id"):
add_song_to_playlist(pl_id, song["id"], position=i)
# Gem også til den navngivne liste
if self._named_playlist_id:
with get_db() as conn:
conn.execute(
"DELETE FROM playlist_songs WHERE playlist_id=?",
(self._named_playlist_id,)
)
for i, song in enumerate(self._songs, start=1):
if song.get("id"):
status = self._statuses[i-1] if i-1 < len(self._statuses) else "pending"
conn.execute(
"INSERT INTO playlist_songs "
"(playlist_id, song_id, position, status, is_workshop, dance_override) "
"VALUES (?,?,?,?,?,?)",
(self._named_playlist_id, song["id"], i, status,
1 if song.get("is_workshop") else 0,
song.get("active_dance") or "")
)
self._lbl_autosave.setText("✓ gemt")
self.playlist_changed.emit()
except Exception as e:
self._lbl_autosave.setText(f"⚠ gemfejl")
pass
self._lbl_autosave.setText("⚠ gemfejl")
def _save_named_playlist_id(self, pl_id: int | None):
"""Gem hvilken navngiven liste der er aktiv — til brug ved næste opstart."""
@@ -374,6 +393,21 @@ class PlaylistPanel(QWidget):
dance_names = [d["name"] for d in dances]
override = row["dance_override"] or ""
active_dance = override if override else (dance_names[0] if dance_names else "")
local_path = row["local_path"]
file_missing = bool(row["file_missing"])
# Forsøg at finde sangen lokalt hvis den mangler
if file_missing or not local_path:
match = conn.execute("""
SELECT local_path FROM songs
WHERE title=? AND artist=? AND file_missing=0
LIMIT 1
""", (row["title"], row["artist"])).fetchone()
if match:
local_path = match["local_path"]
file_missing = False
songs.append({
"id": row["id"],
"title": row["title"],
@@ -381,9 +415,9 @@ class PlaylistPanel(QWidget):
"album": row["album"],
"bpm": row["bpm"],
"duration_sec": row["duration_sec"],
"local_path": row["local_path"],
"local_path": local_path,
"file_format": row["file_format"],
"file_missing": bool(row["file_missing"]),
"file_missing": file_missing,
"dances": dance_names,
"active_dance": active_dance,
"is_workshop": bool(row["is_workshop"]),
@@ -401,15 +435,14 @@ class PlaylistPanel(QWidget):
self._btn_save_current.setToolTip(f"Gem ændringer til '{pl['name']}'")
self._title_label.setText(f"DANSELISTE — {pl['name'].upper()}")
self._lbl_autosave.setText("✓ gendannet")
self._refresh()
# Find næste uafspillede og sæt den klar
# Find næste uafspillede
ni = self.next_playable_idx()
if ni is not None:
self._current_idx = ni
self._refresh()
self.next_song_ready.emit(self._songs[ni])
self._statuses[ni] = "playing"
self._refresh()
return True
except Exception:
pass
@@ -479,10 +512,28 @@ class PlaylistPanel(QWidget):
status = self._statuses[i-1] if i-1 < len(self._statuses) else "pending"
conn.execute(
"INSERT INTO playlist_songs "
"(playlist_id, song_id, position, status) VALUES (?,?,?,?)",
(self._named_playlist_id, song["id"], i, status)
"(playlist_id, song_id, position, status, is_workshop, dance_override) "
"VALUES (?,?,?,?,?,?)",
(self._named_playlist_id, song["id"], i, status,
1 if song.get("is_workshop") else 0,
song.get("active_dance") or "")
)
self._lbl_autosave.setText("✓ gemt")
# Push til server hvis linket med edit-rettighed
if getattr(self, "_can_edit_server", False):
from local.local_db import get_db as _gdb
with _gdb() as c:
meta = c.execute(
"SELECT api_project_id FROM playlists WHERE id=?",
(self._named_playlist_id,)
).fetchone()
if meta and meta["api_project_id"]:
self._push_linked_playlist(
self._named_playlist_id, meta["api_project_id"]
)
self._lbl_autosave.setText("✓ gemt og synkroniseret")
except Exception as e:
QMessageBox.warning(self, "Fejl", f"Kunne ikke gemme: {e}")
@@ -495,6 +546,22 @@ class PlaylistPanel(QWidget):
def _load_playlist_by_id(self, pl_id: int, pl_name: str):
try:
from local.local_db import get_db
# Tjek om listen er linket til serveren — pull først
with get_db() as conn:
pl_meta = conn.execute(
"SELECT api_project_id, is_linked, server_permission "
"FROM playlists WHERE id=?", (pl_id,)
).fetchone()
if pl_meta and pl_meta["is_linked"] and pl_meta["api_project_id"]:
self._pull_linked_playlist(pl_id, pl_meta["api_project_id"])
# Opdater gem-knap baseret på rettighed
perm = pl_meta["server_permission"] or "view"
self._named_playlist_id = pl_id
self._can_edit_server = (perm == "edit")
else:
self._can_edit_server = False
with get_db() as conn:
songs_raw = conn.execute("""
SELECT s.*, ps.position, ps.status,
@@ -505,6 +572,7 @@ class PlaylistPanel(QWidget):
""", (pl_id,)).fetchall()
songs = []
statuses = []
repaired = 0
for row in songs_raw:
dances = conn.execute("""
SELECT d.name FROM song_dances sd
@@ -512,29 +580,64 @@ class PlaylistPanel(QWidget):
WHERE sd.song_id=? ORDER BY sd.dance_order
""", (row["id"],)).fetchall()
dance_names = [d["name"] for d in dances]
# dance_override bestemmer hvilken dans der vises
override = row["dance_override"] or ""
active_dance = override if override else (dance_names[0] if dance_names else "")
local_path = row["local_path"]
file_missing = bool(row["file_missing"])
# Forsøg at finde sangen lokalt hvis den mangler
if file_missing or not local_path:
match = conn.execute("""
SELECT local_path FROM songs
WHERE title=? AND artist=? AND file_missing=0
LIMIT 1
""", (row["title"], row["artist"])).fetchone()
if match:
local_path = match["local_path"]
file_missing = False
repaired += 1
songs.append({
"id": row["id"], "title": row["title"],
"artist": row["artist"], "album": row["album"],
"bpm": row["bpm"], "duration_sec": row["duration_sec"],
"local_path": row["local_path"], "file_format": row["file_format"],
"file_missing": bool(row["file_missing"]),
"id": row["id"],
"title": row["title"],
"artist": row["artist"],
"album": row["album"],
"bpm": row["bpm"],
"duration_sec": row["duration_sec"],
"local_path": local_path,
"file_format": row["file_format"],
"file_missing": file_missing,
"dances": dance_names,
"active_dance": active_dance,
"is_workshop": bool(row["is_workshop"]),
})
statuses.append(row["status"] or "pending")
self._songs = songs
self._statuses = statuses
self._current_idx = -1
self._song_ended = False
self._named_playlist_id = pl_id
self._title_label.setText(f"DANSELISTE — {pl_name.upper()}")
self._lbl_autosave.setText("✓ gendannet")
self._btn_save_current.setEnabled(True)
self._btn_save_current.setToolTip(f"Gem ændringer til '{pl_name}'")
# Vis link-indikator i titlen
is_linked = pl_meta and pl_meta["is_linked"]
perm = pl_meta["server_permission"] if is_linked else "edit"
link_icon = " 🔗" if is_linked else ""
self._title_label.setText(f"DANSELISTE — {pl_name.upper()}{link_icon}")
status_txt = f"✓ indlæst — {repaired} sange fundet lokalt" if repaired else "✓ indlæst"
if is_linked:
status_txt += f" ({perm})"
self._lbl_autosave.setText(status_txt)
# Gem-knap: deaktiver hvis view-only linket liste
can_save = not is_linked or perm == "edit"
self._btn_save_current.setEnabled(can_save)
self._btn_save_current.setToolTip(
f"Gem ændringer til '{pl_name}'" if can_save
else "Du har kun læse-adgang til denne delte liste"
)
self._save_named_playlist_id(pl_id)
self._refresh()
self._trigger_autosave()
@@ -628,6 +731,98 @@ class PlaylistPanel(QWidget):
except Exception:
pass
def _pull_linked_playlist(self, pl_id: int, server_id: str):
"""Hent seneste version af en linket liste fra serveren."""
try:
from ui.settings_dialog import load_settings
from local.local_db import get_db, DB_PATH
s = load_settings()
server_url = s.get("server_url", "").rstrip("/")
# Hent token fra main_window
mw = self.window()
token = getattr(mw, "_api_token", None)
if not token or not server_url:
return
import urllib.request, json
req = urllib.request.Request(
f"{server_url}/sharing/playlists/{server_id}",
headers={"Authorization": f"Bearer {token}"}
)
with urllib.request.urlopen(req, timeout=8) as resp:
pl_data = json.loads(resp.read())
# Opdater lokal liste med server-data
import sqlite3
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
conn.execute("DELETE FROM playlist_songs WHERE playlist_id=?", (pl_id,))
for song_data in pl_data.get("songs", []):
local = conn.execute(
"SELECT id FROM songs WHERE title=? AND artist=? AND file_missing=0",
(song_data["title"], song_data["artist"])
).fetchone()
if local:
conn.execute(
"INSERT INTO playlist_songs "
"(playlist_id, song_id, position, status, is_workshop, dance_override) "
"VALUES (?,?,?,?,?,?)",
(pl_id, local["id"], song_data["position"],
song_data.get("status", "pending"),
1 if song_data.get("is_workshop") else 0,
song_data.get("dance_override") or "")
)
conn.commit()
conn.close()
except Exception as e:
pass # Offline — brug lokalt cachet version
def _push_linked_playlist(self, pl_id: int, server_id: str):
"""Push ændringer til server for en linket liste."""
try:
from ui.settings_dialog import load_settings
from local.local_db import DB_PATH
s = load_settings()
server_url = s.get("server_url", "").rstrip("/")
mw = self.window()
token = getattr(mw, "_api_token", None)
if not token or not server_url:
return
import sqlite3, json, urllib.request
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
songs = []
for ps in conn.execute(
"SELECT s.title, s.artist, ps.position, ps.status, "
"ps.is_workshop, ps.dance_override "
"FROM playlist_songs ps JOIN songs s ON s.id=ps.song_id "
"WHERE ps.playlist_id=? ORDER BY ps.position", (pl_id,)
).fetchall():
songs.append({
"title": ps["title"],
"artist": ps["artist"],
"position": ps["position"],
"status": ps["status"] or "pending",
"is_workshop": bool(ps["is_workshop"]),
"dance_override": ps["dance_override"] or "",
})
conn.close()
data = json.dumps({"songs": songs}).encode()
req = urllib.request.Request(
f"{server_url}/sharing/playlists/{server_id}/songs",
data=data,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
},
method="PUT"
)
urllib.request.urlopen(req, timeout=8)
except Exception as e:
pass
def _on_pause_changed(self, seconds: int):
self._pause_seconds = seconds
@@ -642,7 +837,7 @@ class PlaylistPanel(QWidget):
if reply == QMessageBox.StandardButton.Yes:
self._statuses = ["pending"] * len(self._songs)
self._current_idx = -1
self._song_ended = True
self._song_ended = False
try:
from local.local_db import clear_event_state
clear_event_state()
@@ -650,6 +845,12 @@ class PlaylistPanel(QWidget):
pass
self._refresh()
self._scroll_to(0)
# Sæt første sang klar
ni = self.next_playable_idx()
if ni is not None:
self._current_idx = ni
self._refresh()
self.next_song_ready.emit(self._songs[ni])
self.event_started.emit()
# ── Højreklik ─────────────────────────────────────────────────────────────
@@ -718,9 +919,25 @@ class PlaylistPanel(QWidget):
self._list.clear()
played = sum(1 for s in self._statuses if s == "played")
self._lbl_progress.setText(f"{played} / {len(self._songs)} afspillet")
# Find næste uafspillede til blå markering — aldrig samme som current
next_idx = None
if self._current_idx >= 0 and not self._song_ended:
# Sang spiller — vis næste som blå
next_idx = self.next_playable_idx()
elif self._current_idx == -1 or self._song_ended:
# Ingen sang spiller — vis første som blå
next_idx = self.next_playable_idx()
for i, song in enumerate(self._songs):
is_current = (i == self._current_idx and not self._song_ended)
status = "playing" if is_current else self._statuses[i]
is_next = (i == next_idx and not is_current)
if is_current:
status = "playing"
elif is_next:
status = "next"
else:
status = self._statuses[i]
icon = self.STATUS_ICON.get(status, " ")
# Vis active_dance (override eller første dans) eller alle danse
@@ -737,6 +954,9 @@ class PlaylistPanel(QWidget):
if status == "playing":
item.setForeground(QColor(self.STATUS_COLOR["playing"]))
f = item.font(); f.setBold(True); item.setFont(f)
elif status == "next":
item.setForeground(QColor(self.STATUS_COLOR["next"]))
f = item.font(); f.setBold(True); item.setFont(f)
elif status == "played":
item.setForeground(QColor("#2ecc71"))
elif status == "skipped":

View File

@@ -78,11 +78,30 @@ class SettingsDialog(QDialog):
layout.setSpacing(12)
tabs = QTabWidget()
tabs.addTab(self._build_appearance_tab(), "🎨 Udseende")
tabs.addTab(self._build_playback_tab(), "▶ Afspilning")
tabs.addTab(self._build_mail_tab(), "✉ Mail")
tabs.addTab(self._build_online_tab(), "🌐 Online")
tabs.addTab(self._build_language_tab(), "🌍 Sprog")
tabs.setStyleSheet("""
QTabBar::tab {
padding: 6px 14px;
font-size: 13px;
color: #9aa0b0;
background: #1e2128;
border: none;
min-width: 80px;
}
QTabBar::tab:selected {
color: #e0e4f0;
background: #2a2d36;
border-bottom: 2px solid #e8a020;
}
QTabBar::tab:hover {
color: #e0e4f0;
background: #252830;
}
""")
tabs.addTab(self._build_appearance_tab(), "Udseende")
tabs.addTab(self._build_playback_tab(), "Afspilning")
tabs.addTab(self._build_mail_tab(), "Mail")
tabs.addTab(self._build_online_tab(), "Online")
tabs.addTab(self._build_language_tab(), "Sprog")
layout.addWidget(tabs)
# Knapper

View File

@@ -0,0 +1,192 @@
"""
share_dialog.py — Del en playliste med andre brugere eller sæt den public.
"""
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
QPushButton, QComboBox, QFrame, QListWidget, QListWidgetItem,
QMessageBox,
)
from PyQt6.QtCore import Qt
class ShareDialog(QDialog):
def __init__(self, playlist_id: str, playlist_name: str,
server_url: str, token: str, parent=None):
super().__init__(parent)
self._playlist_id = playlist_id
self._playlist_name = playlist_name
self._server_url = server_url.rstrip("/")
self._token = token
self.setWindowTitle(f"Del — {playlist_name}")
self.setMinimumWidth(480)
self._build_ui()
self._load_shares()
self._load_visibility()
def _headers(self):
return {
"Content-Type": "application/json",
"Authorization": f"Bearer {self._token}",
}
def _build_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(10)
# Synlighed
vis_frame = QFrame()
vis_frame.setObjectName("track_display")
vis_layout = QHBoxLayout(vis_frame)
vis_layout.setContentsMargins(10, 8, 10, 8)
vis_layout.addWidget(QLabel("Synlighed:"))
self._vis_combo = QComboBox()
self._vis_combo.addItem("🔒 Privat (kun mig)", "private")
self._vis_combo.addItem("👥 Delt (inviterede)", "shared")
self._vis_combo.addItem("🌐 Public (alle kan se)", "public")
vis_layout.addWidget(self._vis_combo, stretch=1)
btn_vis = QPushButton("Gem")
btn_vis.setFixedHeight(28)
btn_vis.clicked.connect(self._set_visibility)
vis_layout.addWidget(btn_vis)
layout.addWidget(vis_frame)
# Invitér bruger
inv_frame = QFrame()
inv_frame.setObjectName("track_display")
inv_layout = QVBoxLayout(inv_frame)
inv_layout.setContentsMargins(10, 8, 10, 8)
inv_layout.setSpacing(6)
inv_layout.addWidget(QLabel("Invitér via e-mail:"))
row = QHBoxLayout()
self._email_input = QLineEdit()
self._email_input.setPlaceholderText("bruger@eksempel.dk")
row.addWidget(self._email_input)
self._perm_combo = QComboBox()
self._perm_combo.addItem("Se", "view")
self._perm_combo.addItem("Kopiere", "copy")
self._perm_combo.addItem("Redigere","edit")
self._perm_combo.setFixedWidth(90)
row.addWidget(self._perm_combo)
btn_inv = QPushButton("Invitér")
btn_inv.setFixedHeight(28)
btn_inv.clicked.connect(self._invite)
row.addWidget(btn_inv)
inv_layout.addLayout(row)
layout.addWidget(inv_frame)
# Liste over delinger
lbl = QLabel("Delt med:")
lbl.setObjectName("track_meta")
layout.addWidget(lbl)
self._shares_list = QListWidget()
self._shares_list.setMaximumHeight(150)
layout.addWidget(self._shares_list)
btn_remove = QPushButton("✕ Fjern valgt deling")
btn_remove.clicked.connect(self._remove_share)
layout.addWidget(btn_remove)
self._status = QLabel("")
self._status.setObjectName("result_count")
self._status.setWordWrap(True)
layout.addWidget(self._status)
btn_close = QPushButton("Luk")
btn_close.clicked.connect(self.accept)
layout.addWidget(btn_close)
def _load_visibility(self):
try:
import urllib.request, json
req = urllib.request.Request(
f"{self._server_url}/sharing/playlists/{self._playlist_id}",
headers=self._headers()
)
with urllib.request.urlopen(req, timeout=8) as resp:
data = json.loads(resp.read())
vis = data.get("visibility", "private")
for i in range(self._vis_combo.count()):
if self._vis_combo.itemData(i) == vis:
self._vis_combo.setCurrentIndex(i)
break
except Exception:
pass
def _load_shares(self):
try:
import urllib.request, json
req = urllib.request.Request(
f"{self._server_url}/sharing/playlists/{self._playlist_id}/shares",
headers=self._headers()
)
with urllib.request.urlopen(req, timeout=8) as resp:
shares = json.loads(resp.read())
self._shares_list.clear()
for s in shares:
perm = {"view": "Se", "copy": "Kopiere", "edit": "Redigere"}.get(
s["permission"], s["permission"]
)
accepted = "" if s["accepted"] else ""
item = QListWidgetItem(f"{accepted} {s['email']}{perm}")
item.setData(Qt.ItemDataRole.UserRole, s["id"])
self._shares_list.addItem(item)
except Exception as e:
self._status.setText(f"Kunne ikke hente delinger: {e}")
def _set_visibility(self):
vis = self._vis_combo.currentData()
try:
import urllib.request, json
req = urllib.request.Request(
f"{self._server_url}/sharing/playlists/{self._playlist_id}/visibility?visibility={vis}",
data=b"",
headers=self._headers(),
method="PATCH"
)
with urllib.request.urlopen(req, timeout=8) as resp:
json.loads(resp.read())
self._status.setText(f"✓ Synlighed sat til {self._vis_combo.currentText()}")
except Exception as e:
self._status.setText(f"⚠ Fejl: {e}")
def _invite(self):
email = self._email_input.text().strip()
perm = self._perm_combo.currentData()
if not email or "@" not in email:
self._status.setText("⚠ Ugyldig e-mailadresse")
return
try:
import urllib.request, json
data = json.dumps({"email": email, "permission": perm}).encode()
req = urllib.request.Request(
f"{self._server_url}/sharing/playlists/{self._playlist_id}/share",
data=data, headers=self._headers(), method="POST"
)
with urllib.request.urlopen(req, timeout=8) as resp:
json.loads(resp.read())
self._email_input.clear()
self._status.setText(f"✓ Invitation sendt til {email}")
self._load_shares()
except Exception as e:
self._status.setText(f"⚠ Fejl: {e}")
def _remove_share(self):
item = self._shares_list.currentItem()
if not item:
return
share_id = item.data(Qt.ItemDataRole.UserRole)
try:
import urllib.request
req = urllib.request.Request(
f"{self._server_url}/sharing/playlists/{self._playlist_id}/share/{share_id}",
headers=self._headers(), method="DELETE"
)
urllib.request.urlopen(req, timeout=8)
self._status.setText("✓ Deling fjernet")
self._load_shares()
except Exception as e:
self._status.setText(f"⚠ Fejl: {e}")

View File

@@ -79,6 +79,25 @@ QSlider::handle:horizontal {
border-radius: 6px;
}
/* Volume slider — stor og tydelig */
QSlider#vol_slider::groove:horizontal {
height: 6px;
background: #2c3038;
border-radius: 3px;
}
QSlider#vol_slider::sub-page:horizontal {
background: #e8a020;
border-radius: 3px;
}
QSlider#vol_slider::handle:horizontal {
background: #e8a020;
border: 3px solid #f0c060;
width: 22px;
height: 22px;
margin: -9px 0;
border-radius: 12px;
}
/* Lister */
QListWidget {
background-color: #1a1c1f;