From bbd5690d72bdcf03cc3eaab0f9d702edb68e3fe0 Mon Sep 17 00:00:00 2001 From: Carsten Kvist Date: Mon, 13 Apr 2026 07:23:37 +0200 Subject: [PATCH] Rettelsaer --- linedance-api/=4.0.0 | 1 + linedance-api/app/core/mail.py | 5 +- linedance-api/app/core/security.py | 7 +- linedance-api/app/main.py | 4 +- linedance-api/app/routers/alternatives.py | 236 +------------ linedance-api/app/routers/projects.py | 2 +- linedance-api/app/routers/sharing.py | 394 ++++++++++++++++++++++ linedance-api/app/routers/songs.py | 96 +----- linedance-api/app/routers/sync.py | 274 +++++++++++++++ linedance-api/requirements.txt | 2 +- linedance-api/start_local.bat | 14 + linedance-app/build_windows.spec | 102 ++---- linedance-app/local/linked_playlist.py | 173 ++++++++++ linedance-app/local/local_db.py | 19 ++ linedance-app/local/sync_manager.py | 245 ++++++++++++++ linedance-app/ui/main_window.py | 250 +++++++++++--- linedance-app/ui/playlist_browser.py | 170 ++++++++++ linedance-app/ui/playlist_info_dialog.py | 50 +-- linedance-app/ui/playlist_panel.py | 280 +++++++++++++-- linedance-app/ui/settings_dialog.py | 29 +- linedance-app/ui/share_dialog.py | 192 +++++++++++ linedance-app/ui/themes.py | 19 ++ 22 files changed, 2026 insertions(+), 538 deletions(-) create mode 100644 linedance-api/=4.0.0 create mode 100644 linedance-api/app/routers/sharing.py create mode 100644 linedance-api/app/routers/sync.py create mode 100644 linedance-api/start_local.bat create mode 100644 linedance-app/local/linked_playlist.py create mode 100644 linedance-app/local/sync_manager.py create mode 100644 linedance-app/ui/share_dialog.py diff --git a/linedance-api/=4.0.0 b/linedance-api/=4.0.0 new file mode 100644 index 00000000..4b02573c --- /dev/null +++ b/linedance-api/=4.0.0 @@ -0,0 +1 @@ +Requirement already satisfied: bcrypt in ./venv/lib/python3.12/site-packages (5.0.0) diff --git a/linedance-api/app/core/mail.py b/linedance-api/app/core/mail.py index 1e16fbf8..fa9d4fd5 100644 --- a/linedance-api/app/core/mail.py +++ b/linedance-api/app/core/mail.py @@ -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, diff --git a/linedance-api/app/core/security.py b/linedance-api/app/core/security.py index 291b2ef5..ccacb9ea 100644 --- a/linedance-api/app/core/security.py +++ b/linedance-api/app/core/security.py @@ -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( diff --git a/linedance-api/app/main.py b/linedance-api/app/main.py index c40654fb..0dc8242c 100644 --- a/linedance-api/app/main.py +++ b/linedance-api/app/main.py @@ -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") diff --git a/linedance-api/app/routers/alternatives.py b/linedance-api/app/routers/alternatives.py index 12fedf9a..0b3b131b 100644 --- a/linedance-api/app/routers/alternatives.py +++ b/linedance-api/app/routers/alternatives.py @@ -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() diff --git a/linedance-api/app/routers/projects.py b/linedance-api/app/routers/projects.py index 1d341b0e..49305c89 100644 --- a/linedance-api/app/routers/projects.py +++ b/linedance-api/app/routers/projects.py @@ -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): diff --git a/linedance-api/app/routers/sharing.py b/linedance-api/app/routers/sharing.py new file mode 100644 index 00000000..1f982a3f --- /dev/null +++ b/linedance-api/app/routers/sharing.py @@ -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)} diff --git a/linedance-api/app/routers/songs.py b/linedance-api/app/routers/songs.py index 3e10b145..4ba234c2 100644 --- a/linedance-api/app/routers/songs.py +++ b/linedance-api/app/routers/songs.py @@ -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() diff --git a/linedance-api/app/routers/sync.py b/linedance-api/app/routers/sync.py new file mode 100644 index 00000000..c6bc80a0 --- /dev/null +++ b/linedance-api/app/routers/sync.py @@ -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, + } diff --git a/linedance-api/requirements.txt b/linedance-api/requirements.txt index 70122fc4..7099bbaf 100644 --- a/linedance-api/requirements.txt +++ b/linedance-api/requirements.txt @@ -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 diff --git a/linedance-api/start_local.bat b/linedance-api/start_local.bat new file mode 100644 index 00000000..5427a8b8 --- /dev/null +++ b/linedance-api/start_local.bat @@ -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 diff --git a/linedance-app/build_windows.spec b/linedance-app/build_windows.spec index d28a87c5..e56deb62 100644 --- a/linedance-app/build_windows.spec +++ b/linedance-app/build_windows.spec @@ -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', ) diff --git a/linedance-app/local/linked_playlist.py b/linedance-app/local/linked_playlist.py new file mode 100644 index 00000000..0cf51140 --- /dev/null +++ b/linedance-app/local/linked_playlist.py @@ -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 diff --git a/linedance-app/local/local_db.py b/linedance-app/local/local_db.py index a26a6fca..ede83fcb 100644 --- a/linedance-app/local/local_db.py +++ b/linedance-app/local/local_db.py @@ -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( diff --git a/linedance-app/local/sync_manager.py b/linedance-app/local/sync_manager.py new file mode 100644 index 00000000..4d9a02fc --- /dev/null +++ b/linedance-app/local/sync_manager.py @@ -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() diff --git a/linedance-app/ui/main_window.py b/linedance-app/ui/main_window.py index 76b4274f..e7a8f74f 100644 --- a/linedance-app/ui/main_window.py +++ b/linedance-app/ui/main_window.py @@ -95,6 +95,9 @@ class MainWindow(QMainWindow): self._connect_player_signals() self._library_loaded.connect(self._apply_library) self._db_ready.connect(self._on_db_ready) + self._login_success_signal.connect(self._on_login_success) + self._login_fail_signal.connect(self._on_login_fail) + self._status_signal.connect(self._set_status) self._build_menu() self._build_ui() self._build_statusbar() @@ -130,15 +133,15 @@ class MainWindow(QMainWindow): # ── Filer ───────────────────────────────────────────────────────────── file_menu = menubar.addMenu("Filer") - self._act_go_online = QAction("Gå online...", self) + self._act_go_online = QAction("● Gå online", self) self._act_go_online.setShortcut("Ctrl+L") self._act_go_online.triggered.connect(self._go_online) file_menu.addAction(self._act_go_online) - self._act_go_offline = QAction("Gå offline", self) - self._act_go_offline.triggered.connect(self._go_offline) - self._act_go_offline.setEnabled(False) - file_menu.addAction(self._act_go_offline) + self._act_sync = QAction("↕ Synkroniser nu", self) + self._act_sync.setShortcut("Ctrl+Shift+S") + self._act_sync.triggered.connect(self._manual_sync) + file_menu.addAction(self._act_sync) file_menu.addSeparator() @@ -287,28 +290,26 @@ class MainWindow(QMainWindow): b.setCheckable(True) return b - self._btn_prev = btn("|◀◀", size=52) self._btn_play = btn("▶", "btn_play", size=72) - self._btn_stop = btn("■", "btn_stop", size=52) - self._btn_next = btn("▶▶|", size=52) + self._btn_stop = btn("■", "btn_stop", size=72) self._btn_demo = btn(f"▶\n{self._demo_seconds} SEK", "btn_demo", size=64, checkable=True) - self._btn_prev.clicked.connect(self._prev_song) self._btn_play.clicked.connect(self._toggle_play) self._btn_stop.clicked.connect(self._stop) - self._btn_next.clicked.connect(self._next_song) self._btn_demo.clicked.connect(self._toggle_demo) - layout.addWidget(self._btn_prev) layout.addWidget(self._btn_play) layout.addWidget(self._btn_stop) - layout.addWidget(self._btn_next) + + layout.addSpacing(24) sep1 = QFrame() sep1.setFrameShape(QFrame.Shape.VLine) sep1.setFixedWidth(1) layout.addWidget(sep1) + layout.addSpacing(24) + layout.addWidget(self._btn_demo) layout.addStretch() @@ -319,7 +320,9 @@ class MainWindow(QMainWindow): self._vol_slider = QSlider(Qt.Orientation.Horizontal) self._vol_slider.setRange(0, 100) self._vol_slider.setValue(self._settings.get("volume", 78)) - self._vol_slider.setFixedWidth(100) + self._vol_slider.setFixedWidth(160) + self._vol_slider.setFixedHeight(36) + self._vol_slider.setObjectName("vol_slider") self._vol_slider.valueChanged.connect(self._on_volume) layout.addWidget(self._vol_slider) @@ -336,7 +339,14 @@ class MainWindow(QMainWindow): self._playlist_panel.song_selected.connect(self._load_song_by_idx) self._playlist_panel.song_dropped.connect(self._on_song_dropped) self._playlist_panel.event_started.connect(self._on_event_started) - self._playlist_panel.next_song_ready.connect(self._load_song) + self._playlist_panel.next_song_ready.connect(self._on_next_song_ready) + self._playlist_panel.playlist_changed.connect(self._on_playlist_changed) + + # Debounce-timer til auto-sync — starter sync 5 sek efter sidst ændring + self._sync_debounce = QTimer(self) + self._sync_debounce.setSingleShot(True) + self._sync_debounce.setInterval(5000) + self._sync_debounce.timeout.connect(self._auto_sync) self._library_panel = LibraryPanel() self._library_panel.song_selected.connect(self._on_library_song_selected) @@ -433,9 +443,12 @@ class MainWindow(QMainWindow): QTimer.singleShot(200, self._reload_library) # Signal til at opdatere biblioteket fra baggrundstråd - _library_loaded = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal(list) - _db_ready = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal() - _file_changed_signal = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal() + _library_loaded = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal(list) + _db_ready = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal() + _file_changed_signal = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal() + _login_success_signal = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal(str) + _login_fail_signal = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal(str) + _status_signal = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal(str, int) def _reload_library(self): """Hent sange fra DB i baggrundstråd — thread-safe via signal.""" @@ -508,20 +521,36 @@ class MainWindow(QMainWindow): try: restored = self._playlist_panel.restore_active_playlist() if restored: + # Hent den sang der er klar (current_idx sat af restore) + idx = self._playlist_panel._current_idx + song = self._playlist_panel.get_song(idx) + if self._playlist_panel.restore_event_state(): - idx = self._playlist_panel._current_idx + # Event var i gang — genoptag + idx = self._playlist_panel._current_idx song = self._playlist_panel.get_song(idx) if song: self._current_idx = idx + self._song_ended = False self._load_song(song) + self._playlist_panel.set_current(idx) self._set_status( - f"Event genoptaget ved: {song.get('title','')} — tryk ▶ for at fortsætte", + f"Event genoptaget ved: {song.get('title','')} — tryk ▶", 6000, ) + elif song: + # Normal opstart — load første sang klar + self._current_idx = idx + self._song_ended = False + self._load_song(song) + self._playlist_panel.set_current(idx) + self._set_status( + f"Klar: {song.get('title','')} — tryk ▶ for at starte", + 4000, + ) except Exception: pass - # Scan 30 sek efter opstart — fanger ændringer siden sidst QTimer.singleShot(30000, self.start_background_scan) def start_background_scan(self): @@ -602,40 +631,104 @@ class MainWindow(QMainWindow): def _auto_login(self): """Forsøg automatisk login med gemte oplysninger.""" - username = self._settings.get("username", "") - password = self._settings.get("password", "") + username = self._settings.get("username", "") + password = self._settings.get("password", "") + server_url = self._settings.get("server_url", "http://localhost:8000").rstrip("/") if not username or not password: return - try: - import urllib.request, urllib.parse, json - data = urllib.parse.urlencode({"username": username, "password": password}).encode() - req = urllib.request.Request( - f"{API_URL}/auth/login", data=data, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - method="POST", - ) - with urllib.request.urlopen(req, timeout=8) as resp: - body = json.loads(resp.read()) - self._api_token = body.get("access_token") - self._api_url = API_URL - self._api_username = username - self._set_online_state(True) - self._set_status(f"Automatisk logget ind som {username}", 4000) - # Synkroniser dans-niveauer og navne - QTimer.singleShot(500, self._sync_dance_data) - except Exception: - self._set_status("Auto-login fejlede — kør Filer → Gå online manuelt", 5000) + + def _run(): + try: + import urllib.request, urllib.parse, json + data = urllib.parse.urlencode({"username": username, "password": password}).encode() + req = urllib.request.Request( + f"{server_url}/auth/login", data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=8) as resp: + body = json.loads(resp.read()) + self._api_token = body.get("access_token") + self._api_url = server_url + self._api_username = username + # Kald GUI-opdatering via signal — thread-safe + self._login_success_signal.emit(username) + except Exception as e: + self._login_fail_signal.emit(str(e)) + + import threading + threading.Thread(target=_run, daemon=True).start() + + def _on_playlist_changed(self): + """Danseliste ændret — start debounce-timer til auto-sync.""" + if hasattr(self, "_sync_debounce"): + self._sync_debounce.start() + + def _auto_sync(self): + """Kør sync hvis vi er online — kaldes af debounce-timer.""" + if not self._api_token: + return + if not hasattr(self, "_sync_manager") or not self._sync_manager: + return + self._sync_manager.push( + on_done=lambda r: self._status_signal.emit( + f"↑ Synkroniseret — {r.get('songs_synced', 0)} sange", 3000 + ), + on_error=lambda e: self._status_signal.emit( + f"⚠ Sync fejl: {e}", 8000 + ), + ) + + def _on_next_song_ready(self, song: dict): + """Næste sang er klar — load den i afspilleren og markér orange.""" + idx = self._playlist_panel._current_idx + self._current_idx = idx + self._song_ended = False + self._playlist_panel._song_ended = False + self._load_song(song) + self._playlist_panel.set_current(idx) + + def _on_login_success(self, username: str): + """Kaldes i GUI-tråden når login lykkes.""" + self._set_online_state(True) + self._set_status(f"Logget ind som {username}", 4000) + + def _on_login_fail(self, error: str): + """Kaldes i GUI-tråden når login fejler.""" + self._set_status(f"Login fejlede: {error}", 5000) def _go_online(self): - dialog = LoginDialog(self) - if dialog.exec(): - url, username, token = dialog.get_credentials() - self._api_url = url - self._api_token = token - self._api_username = username - self._set_online_state(True) - self._set_status(f"Online som {username}", 5000) - QTimer.singleShot(500, self._sync_dance_data) + """Log ind/ud med gemte credentials.""" + if self._api_token: + self._go_offline() + return + username = self._settings.get("username", "") + password = self._settings.get("password", "") + server_url = self._settings.get("server_url", "http://localhost:8000").rstrip("/") + if not username or not password: + self._set_status("Udfyld brugernavn og kodeord i Indstillinger → Online", 5000) + return + + def _run(): + try: + import urllib.request, urllib.parse, json + data = urllib.parse.urlencode({"username": username, "password": password}).encode() + req = urllib.request.Request( + f"{server_url}/auth/login", data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=8) as resp: + body = json.loads(resp.read()) + self._api_token = body.get("access_token") + self._api_url = server_url + self._api_username = username + self._login_success_signal.emit(username) + except Exception as e: + self._login_fail_signal.emit(str(e)) + + import threading + threading.Thread(target=_run, daemon=True).start() def _sync_dance_data(self): """Synkroniser dans-niveauer og navne fra API.""" @@ -669,15 +762,56 @@ class MainWindow(QMainWindow): self._set_status("Offline — arbejder lokalt", 3000) def _set_online_state(self, online: bool): - self._act_go_online.setEnabled(not online) - self._act_go_offline.setEnabled(online) if online: name = self._api_username or "?" self._conn_label.setText(f"● ONLINE ({name})") self._conn_label.setStyleSheet("color: #2ecc71;") + self._act_go_online.setText("● Gå offline") + self._init_sync() else: self._conn_label.setText("● OFFLINE") self._conn_label.setStyleSheet("color: #5a6070;") + self._act_go_online.setText("● Gå online") + self._sync_manager = None + + def _init_sync(self): + """Opret SyncManager og kør initial push+pull.""" + try: + from local.local_db import DB_PATH + from local.sync_manager import SyncManager + server_url = self._settings.get("server_url", "http://localhost:8000") + self._sync_manager = SyncManager( + db_path=str(DB_PATH), + server_url=server_url, + token=self._api_token, + ) + self._sync_manager.push_and_pull( + on_done=lambda r: self._status_signal.emit( + f"✓ Synkroniseret — {r['push']['songs_synced']} sange", 5000 + ), + on_error=lambda e: self._status_signal.emit( + f"⚠ Sync fejl: {e}", 5000 + ), + ) + except Exception as e: + self._set_status(f"⚠ Sync fejl: {e}", 5000) + + def _manual_sync(self): + if not self._api_token: + self._set_status("Log ind for at synkronisere", 3000) + return + if not hasattr(self, "_sync_manager") or not self._sync_manager: + self._init_sync() + return + self._set_status("Synkroniserer...", 2000) + self._sync_manager.push_and_pull( + on_done=lambda r: self._status_signal.emit( + f"✓ Synkroniseret — {r['push']['songs_synced']} sange", 4000 + ), + on_error=lambda e: self._status_signal.emit( + f"⚠ Sync fejl: {e}", 5000 + ), + ) def _new_playlist(self): self._stop() @@ -851,6 +985,12 @@ class MainWindow(QMainWindow): song = self._playlist_panel.get_song(idx) if not song: return + # Nulstil gammel markering + old_idx = self._playlist_panel._current_idx + if old_idx is not None and old_idx != idx: + if 0 <= old_idx < len(self._playlist_panel._statuses): + if self._playlist_panel._statuses[old_idx] == "playing": + self._playlist_panel._statuses[old_idx] = "pending" self._current_idx = idx self._load_song(song) self._playlist_panel.set_current(idx) @@ -944,13 +1084,16 @@ class MainWindow(QMainWindow): self._btn_play.setText("▶") self._vu.reset() + # Synkroniser current_idx til playlist_panel + self._playlist_panel._current_idx = self._current_idx + # Markér den afspillede sang self._playlist_panel.mark_played(self._current_idx) # Synkroniser event-status til den gemte navngivne liste self._sync_event_status_to_playlist() - # Find første ikke-afspillede og ikke-skippede sang fra TOPPEN + # Find næste uafspillede ni = self._playlist_panel.next_playable_idx() next_song = self._playlist_panel.get_song(ni) if ni is not None else None if next_song: @@ -959,7 +1102,6 @@ class MainWindow(QMainWindow): self._load_song(next_song) self._set_status(f"Klar: {next_song.get('title','')} — tryk ▶ for at starte") else: - # Danseliste afsluttet — nulstil liste-markering og synkroniser self._current_idx = -1 self._playlist_panel._current_idx = -1 self._playlist_panel._song_ended = False diff --git a/linedance-app/ui/playlist_browser.py b/linedance-app/ui/playlist_browser.py index 1a2eb201..e5c26c96 100644 --- a/linedance-app/ui/playlist_browser.py +++ b/linedance-app/ui/playlist_browser.py @@ -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." + ) diff --git a/linedance-app/ui/playlist_info_dialog.py b/linedance-app/ui/playlist_info_dialog.py index f4924416..51626782 100644 --- a/linedance-app/ui/playlist_info_dialog.py +++ b/linedance-app/ui/playlist_info_dialog.py @@ -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')}") diff --git a/linedance-app/ui/playlist_panel.py b/linedance-app/ui/playlist_panel.py index c66ab668..30e197bb 100644 --- a/linedance-app/ui/playlist_panel.py +++ b/linedance-app/ui/playlist_panel.py @@ -289,9 +289,11 @@ class PlaylistPanel(QWidget): return self._named_playlist_id def next_playable_idx(self) -> int | None: - """Find første sang fra toppen der ikke er 'skipped' eller 'played'.""" + """Find første sang fra toppen der ikke er afspillet, sprunget over eller i gang.""" for i in range(len(self._songs)): - if self._statuses[i] not in ("skipped", "played"): + if self._statuses[i] not in ("skipped", "played", "playing"): + if i == self._current_idx and not self._song_ended: + continue return i return None @@ -303,25 +305,42 @@ class PlaylistPanel(QWidget): self._lbl_autosave.setText("● ikke gemt") def _autosave(self): - """Gem til den faste 'Aktiv liste' i SQLite.""" + """Gem til '__aktiv__' OG til den navngivne liste hvis der er én.""" try: from local.local_db import get_db, create_playlist, add_song_to_playlist with get_db() as conn: - # Slet den gamle aktive liste conn.execute( "DELETE FROM playlists WHERE name=?", (ACTIVE_PLAYLIST_NAME,) ) - # Opret ny pl_id = create_playlist(ACTIVE_PLAYLIST_NAME) self._active_playlist_id = pl_id for i, song in enumerate(self._songs, start=1): if song.get("id"): add_song_to_playlist(pl_id, song["id"], position=i) + + # Gem også til den navngivne liste + if self._named_playlist_id: + with get_db() as conn: + conn.execute( + "DELETE FROM playlist_songs WHERE playlist_id=?", + (self._named_playlist_id,) + ) + for i, song in enumerate(self._songs, start=1): + if song.get("id"): + status = self._statuses[i-1] if i-1 < len(self._statuses) else "pending" + conn.execute( + "INSERT INTO playlist_songs " + "(playlist_id, song_id, position, status, is_workshop, dance_override) " + "VALUES (?,?,?,?,?,?)", + (self._named_playlist_id, song["id"], i, status, + 1 if song.get("is_workshop") else 0, + song.get("active_dance") or "") + ) + self._lbl_autosave.setText("✓ gemt") self.playlist_changed.emit() except Exception as e: - self._lbl_autosave.setText(f"⚠ gemfejl") - pass + self._lbl_autosave.setText("⚠ gemfejl") def _save_named_playlist_id(self, pl_id: int | None): """Gem hvilken navngiven liste der er aktiv — til brug ved næste opstart.""" @@ -374,6 +393,21 @@ class PlaylistPanel(QWidget): dance_names = [d["name"] for d in dances] override = row["dance_override"] or "" active_dance = override if override else (dance_names[0] if dance_names else "") + + local_path = row["local_path"] + file_missing = bool(row["file_missing"]) + + # Forsøg at finde sangen lokalt hvis den mangler + if file_missing or not local_path: + match = conn.execute(""" + SELECT local_path FROM songs + WHERE title=? AND artist=? AND file_missing=0 + LIMIT 1 + """, (row["title"], row["artist"])).fetchone() + if match: + local_path = match["local_path"] + file_missing = False + songs.append({ "id": row["id"], "title": row["title"], @@ -381,9 +415,9 @@ class PlaylistPanel(QWidget): "album": row["album"], "bpm": row["bpm"], "duration_sec": row["duration_sec"], - "local_path": row["local_path"], + "local_path": local_path, "file_format": row["file_format"], - "file_missing": bool(row["file_missing"]), + "file_missing": file_missing, "dances": dance_names, "active_dance": active_dance, "is_workshop": bool(row["is_workshop"]), @@ -401,15 +435,14 @@ class PlaylistPanel(QWidget): self._btn_save_current.setToolTip(f"Gem ændringer til '{pl['name']}'") self._title_label.setText(f"DANSELISTE — {pl['name'].upper()}") self._lbl_autosave.setText("✓ gendannet") - self._refresh() - # Find næste uafspillede og sæt den klar + # Find næste uafspillede ni = self.next_playable_idx() if ni is not None: self._current_idx = ni - self._refresh() - self.next_song_ready.emit(self._songs[ni]) + self._statuses[ni] = "playing" + self._refresh() return True except Exception: pass @@ -479,10 +512,28 @@ class PlaylistPanel(QWidget): status = self._statuses[i-1] if i-1 < len(self._statuses) else "pending" conn.execute( "INSERT INTO playlist_songs " - "(playlist_id, song_id, position, status) VALUES (?,?,?,?)", - (self._named_playlist_id, song["id"], i, status) + "(playlist_id, song_id, position, status, is_workshop, dance_override) " + "VALUES (?,?,?,?,?,?)", + (self._named_playlist_id, song["id"], i, status, + 1 if song.get("is_workshop") else 0, + song.get("active_dance") or "") ) self._lbl_autosave.setText("✓ gemt") + + # Push til server hvis linket med edit-rettighed + if getattr(self, "_can_edit_server", False): + from local.local_db import get_db as _gdb + with _gdb() as c: + meta = c.execute( + "SELECT api_project_id FROM playlists WHERE id=?", + (self._named_playlist_id,) + ).fetchone() + if meta and meta["api_project_id"]: + self._push_linked_playlist( + self._named_playlist_id, meta["api_project_id"] + ) + self._lbl_autosave.setText("✓ gemt og synkroniseret") + except Exception as e: QMessageBox.warning(self, "Fejl", f"Kunne ikke gemme: {e}") @@ -495,6 +546,22 @@ class PlaylistPanel(QWidget): def _load_playlist_by_id(self, pl_id: int, pl_name: str): try: from local.local_db import get_db + + # Tjek om listen er linket til serveren — pull først + with get_db() as conn: + pl_meta = conn.execute( + "SELECT api_project_id, is_linked, server_permission " + "FROM playlists WHERE id=?", (pl_id,) + ).fetchone() + + if pl_meta and pl_meta["is_linked"] and pl_meta["api_project_id"]: + self._pull_linked_playlist(pl_id, pl_meta["api_project_id"]) + # Opdater gem-knap baseret på rettighed + perm = pl_meta["server_permission"] or "view" + self._named_playlist_id = pl_id + self._can_edit_server = (perm == "edit") + else: + self._can_edit_server = False with get_db() as conn: songs_raw = conn.execute(""" SELECT s.*, ps.position, ps.status, @@ -505,6 +572,7 @@ class PlaylistPanel(QWidget): """, (pl_id,)).fetchall() songs = [] statuses = [] + repaired = 0 for row in songs_raw: dances = conn.execute(""" SELECT d.name FROM song_dances sd @@ -512,29 +580,64 @@ class PlaylistPanel(QWidget): WHERE sd.song_id=? ORDER BY sd.dance_order """, (row["id"],)).fetchall() dance_names = [d["name"] for d in dances] - # dance_override bestemmer hvilken dans der vises override = row["dance_override"] or "" active_dance = override if override else (dance_names[0] if dance_names else "") + + local_path = row["local_path"] + file_missing = bool(row["file_missing"]) + + # Forsøg at finde sangen lokalt hvis den mangler + if file_missing or not local_path: + match = conn.execute(""" + SELECT local_path FROM songs + WHERE title=? AND artist=? AND file_missing=0 + LIMIT 1 + """, (row["title"], row["artist"])).fetchone() + if match: + local_path = match["local_path"] + file_missing = False + repaired += 1 + songs.append({ - "id": row["id"], "title": row["title"], - "artist": row["artist"], "album": row["album"], - "bpm": row["bpm"], "duration_sec": row["duration_sec"], - "local_path": row["local_path"], "file_format": row["file_format"], - "file_missing": bool(row["file_missing"]), - "dances": dance_names, + "id": row["id"], + "title": row["title"], + "artist": row["artist"], + "album": row["album"], + "bpm": row["bpm"], + "duration_sec": row["duration_sec"], + "local_path": local_path, + "file_format": row["file_format"], + "file_missing": file_missing, + "dances": dance_names, "active_dance": active_dance, - "is_workshop": bool(row["is_workshop"]), + "is_workshop": bool(row["is_workshop"]), }) statuses.append(row["status"] or "pending") + self._songs = songs self._statuses = statuses self._current_idx = -1 self._song_ended = False self._named_playlist_id = pl_id - self._title_label.setText(f"DANSELISTE — {pl_name.upper()}") - self._lbl_autosave.setText("✓ gendannet") - self._btn_save_current.setEnabled(True) - self._btn_save_current.setToolTip(f"Gem ændringer til '{pl_name}'") + + # Vis link-indikator i titlen + is_linked = pl_meta and pl_meta["is_linked"] + perm = pl_meta["server_permission"] if is_linked else "edit" + link_icon = " 🔗" if is_linked else "" + self._title_label.setText(f"DANSELISTE — {pl_name.upper()}{link_icon}") + + status_txt = f"✓ indlæst — {repaired} sange fundet lokalt" if repaired else "✓ indlæst" + if is_linked: + status_txt += f" ({perm})" + self._lbl_autosave.setText(status_txt) + + # Gem-knap: deaktiver hvis view-only linket liste + can_save = not is_linked or perm == "edit" + self._btn_save_current.setEnabled(can_save) + self._btn_save_current.setToolTip( + f"Gem ændringer til '{pl_name}'" if can_save + else "Du har kun læse-adgang til denne delte liste" + ) self._save_named_playlist_id(pl_id) self._refresh() self._trigger_autosave() @@ -628,6 +731,98 @@ class PlaylistPanel(QWidget): except Exception: pass + def _pull_linked_playlist(self, pl_id: int, server_id: str): + """Hent seneste version af en linket liste fra serveren.""" + try: + from ui.settings_dialog import load_settings + from local.local_db import get_db, DB_PATH + s = load_settings() + server_url = s.get("server_url", "").rstrip("/") + # Hent token fra main_window + mw = self.window() + token = getattr(mw, "_api_token", None) + if not token or not server_url: + return + + import urllib.request, json + req = urllib.request.Request( + f"{server_url}/sharing/playlists/{server_id}", + headers={"Authorization": f"Bearer {token}"} + ) + with urllib.request.urlopen(req, timeout=8) as resp: + pl_data = json.loads(resp.read()) + + # Opdater lokal liste med server-data + import sqlite3 + conn = sqlite3.connect(str(DB_PATH)) + conn.row_factory = sqlite3.Row + conn.execute("DELETE FROM playlist_songs WHERE playlist_id=?", (pl_id,)) + for song_data in pl_data.get("songs", []): + local = conn.execute( + "SELECT id FROM songs WHERE title=? AND artist=? AND file_missing=0", + (song_data["title"], song_data["artist"]) + ).fetchone() + if local: + conn.execute( + "INSERT INTO playlist_songs " + "(playlist_id, song_id, position, status, is_workshop, dance_override) " + "VALUES (?,?,?,?,?,?)", + (pl_id, local["id"], song_data["position"], + song_data.get("status", "pending"), + 1 if song_data.get("is_workshop") else 0, + song_data.get("dance_override") or "") + ) + conn.commit() + conn.close() + except Exception as e: + pass # Offline — brug lokalt cachet version + + def _push_linked_playlist(self, pl_id: int, server_id: str): + """Push ændringer til server for en linket liste.""" + try: + from ui.settings_dialog import load_settings + from local.local_db import DB_PATH + s = load_settings() + server_url = s.get("server_url", "").rstrip("/") + mw = self.window() + token = getattr(mw, "_api_token", None) + if not token or not server_url: + return + + import sqlite3, json, urllib.request + conn = sqlite3.connect(str(DB_PATH)) + conn.row_factory = sqlite3.Row + songs = [] + for ps in conn.execute( + "SELECT s.title, s.artist, ps.position, ps.status, " + "ps.is_workshop, ps.dance_override " + "FROM playlist_songs ps JOIN songs s ON s.id=ps.song_id " + "WHERE ps.playlist_id=? ORDER BY ps.position", (pl_id,) + ).fetchall(): + songs.append({ + "title": ps["title"], + "artist": ps["artist"], + "position": ps["position"], + "status": ps["status"] or "pending", + "is_workshop": bool(ps["is_workshop"]), + "dance_override": ps["dance_override"] or "", + }) + conn.close() + + data = json.dumps({"songs": songs}).encode() + req = urllib.request.Request( + f"{server_url}/sharing/playlists/{server_id}/songs", + data=data, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {token}", + }, + method="PUT" + ) + urllib.request.urlopen(req, timeout=8) + except Exception as e: + pass + def _on_pause_changed(self, seconds: int): self._pause_seconds = seconds @@ -642,7 +837,7 @@ class PlaylistPanel(QWidget): if reply == QMessageBox.StandardButton.Yes: self._statuses = ["pending"] * len(self._songs) self._current_idx = -1 - self._song_ended = True + self._song_ended = False try: from local.local_db import clear_event_state clear_event_state() @@ -650,6 +845,12 @@ class PlaylistPanel(QWidget): pass self._refresh() self._scroll_to(0) + # Sæt første sang klar + ni = self.next_playable_idx() + if ni is not None: + self._current_idx = ni + self._refresh() + self.next_song_ready.emit(self._songs[ni]) self.event_started.emit() # ── Højreklik ───────────────────────────────────────────────────────────── @@ -718,10 +919,26 @@ class PlaylistPanel(QWidget): self._list.clear() played = sum(1 for s in self._statuses if s == "played") self._lbl_progress.setText(f"{played} / {len(self._songs)} afspillet") + + # Find næste uafspillede til blå markering — aldrig samme som current + next_idx = None + if self._current_idx >= 0 and not self._song_ended: + # Sang spiller — vis næste som blå + next_idx = self.next_playable_idx() + elif self._current_idx == -1 or self._song_ended: + # Ingen sang spiller — vis første som blå + next_idx = self.next_playable_idx() + for i, song in enumerate(self._songs): is_current = (i == self._current_idx and not self._song_ended) - status = "playing" if is_current else self._statuses[i] - icon = self.STATUS_ICON.get(status, " ") + is_next = (i == next_idx and not is_current) + if is_current: + status = "playing" + elif is_next: + status = "next" + else: + status = self._statuses[i] + icon = self.STATUS_ICON.get(status, " ") # Vis active_dance (override eller første dans) eller alle danse active = song.get("active_dance", "") @@ -737,6 +954,9 @@ class PlaylistPanel(QWidget): if status == "playing": item.setForeground(QColor(self.STATUS_COLOR["playing"])) f = item.font(); f.setBold(True); item.setFont(f) + elif status == "next": + item.setForeground(QColor(self.STATUS_COLOR["next"])) + f = item.font(); f.setBold(True); item.setFont(f) elif status == "played": item.setForeground(QColor("#2ecc71")) elif status == "skipped": diff --git a/linedance-app/ui/settings_dialog.py b/linedance-app/ui/settings_dialog.py index 20360a1f..20148dc8 100644 --- a/linedance-app/ui/settings_dialog.py +++ b/linedance-app/ui/settings_dialog.py @@ -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 diff --git a/linedance-app/ui/share_dialog.py b/linedance-app/ui/share_dialog.py new file mode 100644 index 00000000..68e934fa --- /dev/null +++ b/linedance-app/ui/share_dialog.py @@ -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}") diff --git a/linedance-app/ui/themes.py b/linedance-app/ui/themes.py index f5dff76a..16a5d94b 100644 --- a/linedance-app/ui/themes.py +++ b/linedance-app/ui/themes.py @@ -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;