Rettelsaer
This commit is contained in:
1
linedance-api/=4.0.0
Normal file
1
linedance-api/=4.0.0
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Requirement already satisfied: bcrypt in ./venv/lib/python3.12/site-packages (5.0.0)
|
||||||
@@ -61,11 +61,12 @@ LineDance Player
|
|||||||
port=settings.MAIL_PORT,
|
port=settings.MAIL_PORT,
|
||||||
username=settings.MAIL_USERNAME or None,
|
username=settings.MAIL_USERNAME or None,
|
||||||
password=settings.MAIL_PASSWORD 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:
|
except Exception as e:
|
||||||
# Log fejl men lad registrering gennemføre
|
|
||||||
print(f"Mail-fejl: {e}")
|
print(f"Mail-fejl: {e}")
|
||||||
|
raise # Vis fejlen i serverlogs
|
||||||
|
|
||||||
|
|
||||||
async def send_share_invitation(email: str, owner_name: str,
|
async def send_share_invitation(email: str, owner_name: str,
|
||||||
|
|||||||
@@ -1,21 +1,20 @@
|
|||||||
|
import bcrypt
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from fastapi import Depends, HTTPException
|
from fastapi import Depends, HTTPException
|
||||||
from fastapi.security import OAuth2PasswordBearer
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
from passlib.context import CryptContext
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
||||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
|
||||||
|
|
||||||
|
|
||||||
def hash_password(password: str) -> str:
|
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:
|
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:
|
def create_access_token(data: dict) -> str:
|
||||||
expire = datetime.now(timezone.utc) + timedelta(
|
expire = datetime.now(timezone.utc) + timedelta(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from app.core.database import engine, Base
|
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
|
from app.websocket.manager import router as ws_router
|
||||||
|
|
||||||
# Opret tabeller hvis de ikke findes (til udvikling — brug Alembic i produktion)
|
# 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(songs.router)
|
||||||
app.include_router(alternatives.router)
|
app.include_router(alternatives.router)
|
||||||
app.include_router(dances.router)
|
app.include_router(dances.router)
|
||||||
|
app.include_router(sync.router)
|
||||||
|
app.include_router(sharing.router)
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
|
|||||||
@@ -1,235 +1,3 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
"""alternatives.py — Placeholder (håndteres via /sync)."""
|
||||||
from sqlalchemy.orm import Session
|
from fastapi import APIRouter
|
||||||
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
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/alternatives", tags=["alternatives"])
|
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()
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ def _assert_role(project: Project, user: User, db: Session, min_role: str = "vie
|
|||||||
return # ejer har altid adgang
|
return # ejer har altid adgang
|
||||||
member = db.query(ProjectMember).filter_by(project_id=project.id, user_id=user.id, status="accepted").first()
|
member = db.query(ProjectMember).filter_by(project_id=project.id, user_id=user.id, status="accepted").first()
|
||||||
if not member:
|
if not member:
|
||||||
if project.is_public and min_role == "viewer":
|
if project.visibility == "public" and min_role == "viewer":
|
||||||
return
|
return
|
||||||
raise HTTPException(403, "Du har ikke adgang til dette projekt")
|
raise HTTPException(403, "Du har ikke adgang til dette projekt")
|
||||||
if roles.index(member.role) < roles.index(min_role):
|
if roles.index(member.role) < roles.index(min_role):
|
||||||
|
|||||||
394
linedance-api/app/routers/sharing.py
Normal file
394
linedance-api/app/routers/sharing.py
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
"""
|
||||||
|
sharing.py — Del playlister med andre brugere.
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.models import User, Project, PlaylistShare
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/sharing", tags=["sharing"])
|
||||||
|
|
||||||
|
|
||||||
|
class ShareRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
permission: str = "view" # view | copy | edit
|
||||||
|
|
||||||
|
|
||||||
|
class ShareOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
project_id: str
|
||||||
|
invited_email: str
|
||||||
|
permission: str
|
||||||
|
accepted_at: Optional[str] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# ── Del en playliste ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/playlists/{project_id}/share", status_code=201)
|
||||||
|
async def share_playlist(
|
||||||
|
project_id: str,
|
||||||
|
data: ShareRequest,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
me: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
project = db.query(Project).filter_by(id=project_id, owner_id=me.id).first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(404, "Playliste ikke fundet eller du er ikke ejer")
|
||||||
|
|
||||||
|
if data.permission not in ("view", "copy", "edit"):
|
||||||
|
raise HTTPException(400, "Ugyldig rettighed — brug view, copy eller edit")
|
||||||
|
|
||||||
|
# Find bruger via email
|
||||||
|
target = db.query(User).filter_by(email=data.email).first()
|
||||||
|
|
||||||
|
# Tjek om deling allerede eksisterer
|
||||||
|
existing = db.query(PlaylistShare).filter_by(
|
||||||
|
project_id=project_id,
|
||||||
|
invited_email=data.email,
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
existing.permission = data.permission
|
||||||
|
db.commit()
|
||||||
|
return {"detail": "Rettigheder opdateret", "share_id": existing.id}
|
||||||
|
|
||||||
|
share = PlaylistShare(
|
||||||
|
project_id=project_id,
|
||||||
|
shared_with_id=target.id if target else None,
|
||||||
|
invited_email=data.email,
|
||||||
|
permission=data.permission,
|
||||||
|
)
|
||||||
|
db.add(share)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(share)
|
||||||
|
|
||||||
|
# Send invitation-mail
|
||||||
|
try:
|
||||||
|
from app.core.mail import send_share_invitation
|
||||||
|
from app.core.config import settings
|
||||||
|
background_tasks.add_task(
|
||||||
|
send_share_invitation,
|
||||||
|
email=data.email,
|
||||||
|
owner_name=me.username,
|
||||||
|
playlist_name=project.name,
|
||||||
|
permission=data.permission,
|
||||||
|
accept_url=f"{settings.BASE_URL}/sharing/accept/{share.id}",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {"detail": "Invitation sendt", "share_id": share.id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/playlists/{project_id}/share/{share_id}")
|
||||||
|
def update_share(
|
||||||
|
project_id: str,
|
||||||
|
share_id: str,
|
||||||
|
data: ShareRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
me: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
project = db.query(Project).filter_by(id=project_id, owner_id=me.id).first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(404, "Playliste ikke fundet")
|
||||||
|
share = db.query(PlaylistShare).filter_by(id=share_id, project_id=project_id).first()
|
||||||
|
if not share:
|
||||||
|
raise HTTPException(404, "Deling ikke fundet")
|
||||||
|
share.permission = data.permission
|
||||||
|
db.commit()
|
||||||
|
return {"detail": "Rettigheder opdateret"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/playlists/{project_id}/share/{share_id}", status_code=204)
|
||||||
|
def remove_share(
|
||||||
|
project_id: str,
|
||||||
|
share_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
me: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
project = db.query(Project).filter_by(id=project_id, owner_id=me.id).first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(404, "Playliste ikke fundet")
|
||||||
|
share = db.query(PlaylistShare).filter_by(id=share_id, project_id=project_id).first()
|
||||||
|
if not share:
|
||||||
|
raise HTTPException(404, "Deling ikke fundet")
|
||||||
|
db.delete(share)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/playlists/{project_id}/shares")
|
||||||
|
def list_shares(
|
||||||
|
project_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
me: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
project = db.query(Project).filter_by(id=project_id, owner_id=me.id).first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(404, "Playliste ikke fundet")
|
||||||
|
shares = db.query(PlaylistShare).filter_by(project_id=project_id).all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": s.id,
|
||||||
|
"email": s.invited_email,
|
||||||
|
"permission": s.permission,
|
||||||
|
"accepted": s.accepted_at is not None,
|
||||||
|
}
|
||||||
|
for s in shares
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Visibility ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.patch("/playlists/{project_id}/visibility")
|
||||||
|
def set_visibility(
|
||||||
|
project_id: str,
|
||||||
|
visibility: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
me: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
if visibility not in ("private", "shared", "public"):
|
||||||
|
raise HTTPException(400, "Ugyldig synlighed — brug private, shared eller public")
|
||||||
|
project = db.query(Project).filter_by(id=project_id, owner_id=me.id).first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(404, "Playliste ikke fundet")
|
||||||
|
project.visibility = visibility
|
||||||
|
db.commit()
|
||||||
|
return {"detail": f"Synlighed sat til {visibility}"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Hent delte lister ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/playlists/shared-with-me")
|
||||||
|
def shared_with_me(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
me: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Hent alle playlister der er delt med mig."""
|
||||||
|
# Via direkte deling
|
||||||
|
shares = db.query(PlaylistShare).filter_by(
|
||||||
|
shared_with_id=me.id
|
||||||
|
).all()
|
||||||
|
project_ids = {s.project_id for s in shares}
|
||||||
|
|
||||||
|
# Via email-invitation
|
||||||
|
email_shares = db.query(PlaylistShare).filter_by(
|
||||||
|
invited_email=me.email
|
||||||
|
).all()
|
||||||
|
project_ids.update(s.project_id for s in email_shares)
|
||||||
|
|
||||||
|
# Public playlister
|
||||||
|
public = db.query(Project).filter_by(visibility="public").all()
|
||||||
|
project_ids.update(p.id for p in public)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for pid in project_ids:
|
||||||
|
p = db.query(Project).filter_by(id=pid).first()
|
||||||
|
if not p or p.owner_id == me.id:
|
||||||
|
continue
|
||||||
|
# Find min rettighed
|
||||||
|
share = db.query(PlaylistShare).filter(
|
||||||
|
PlaylistShare.project_id == pid,
|
||||||
|
(PlaylistShare.shared_with_id == me.id) |
|
||||||
|
(PlaylistShare.invited_email == me.email)
|
||||||
|
).first()
|
||||||
|
permission = share.permission if share else "view"
|
||||||
|
owner = db.query(User).filter_by(id=p.owner_id).first()
|
||||||
|
result.append({
|
||||||
|
"project_id": p.id,
|
||||||
|
"name": p.name,
|
||||||
|
"owner": owner.username if owner else "?",
|
||||||
|
"visibility": p.visibility,
|
||||||
|
"permission": permission,
|
||||||
|
"song_count": len(p.project_songs),
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ── Hent en delt playliste ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/playlists/{project_id}")
|
||||||
|
def get_shared_playlist(
|
||||||
|
project_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
me: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Hent indholdet af en delt playliste."""
|
||||||
|
p = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
if not p:
|
||||||
|
raise HTTPException(404, "Playliste ikke fundet")
|
||||||
|
|
||||||
|
# Tjek adgang
|
||||||
|
if p.owner_id != me.id:
|
||||||
|
if p.visibility != "public":
|
||||||
|
share = db.query(PlaylistShare).filter(
|
||||||
|
PlaylistShare.project_id == project_id,
|
||||||
|
(PlaylistShare.shared_with_id == me.id) |
|
||||||
|
(PlaylistShare.invited_email == me.email)
|
||||||
|
).first()
|
||||||
|
if not share:
|
||||||
|
raise HTTPException(403, "Du har ikke adgang til denne playliste")
|
||||||
|
|
||||||
|
from app.models import Song
|
||||||
|
songs = []
|
||||||
|
for ps in p.project_songs:
|
||||||
|
song = db.query(Song).filter_by(id=ps.song_id).first()
|
||||||
|
if not song:
|
||||||
|
continue
|
||||||
|
songs.append({
|
||||||
|
"title": song.title,
|
||||||
|
"artist": song.artist,
|
||||||
|
"album": song.album,
|
||||||
|
"bpm": song.bpm,
|
||||||
|
"duration_sec": song.duration_sec,
|
||||||
|
"position": ps.position,
|
||||||
|
"status": ps.status,
|
||||||
|
"is_workshop": ps.is_workshop,
|
||||||
|
"dance_override": ps.dance_override,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": p.id,
|
||||||
|
"name": p.name,
|
||||||
|
"description": p.description,
|
||||||
|
"visibility": p.visibility,
|
||||||
|
"songs": sorted(songs, key=lambda x: x["position"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Opdater sange i en linket liste ──────────────────────────────────────────
|
||||||
|
|
||||||
|
class LinkedSongData(BaseModel):
|
||||||
|
title: str
|
||||||
|
artist: str = ""
|
||||||
|
position: int = 1
|
||||||
|
status: str = "pending"
|
||||||
|
is_workshop: bool = False
|
||||||
|
dance_override: str = ""
|
||||||
|
|
||||||
|
class LinkedSongsUpdate(BaseModel):
|
||||||
|
songs: list[LinkedSongData]
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/playlists/{project_id}/songs")
|
||||||
|
def update_linked_songs(
|
||||||
|
project_id: str,
|
||||||
|
data: LinkedSongsUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
me: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Opdater sange i en linket playliste — kræver edit-rettighed."""
|
||||||
|
from app.models import Song, ProjectSong
|
||||||
|
|
||||||
|
p = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
if not p:
|
||||||
|
raise HTTPException(404, "Playliste ikke fundet")
|
||||||
|
|
||||||
|
# Tjek edit-rettighed
|
||||||
|
if p.owner_id != me.id:
|
||||||
|
share = db.query(PlaylistShare).filter(
|
||||||
|
PlaylistShare.project_id == project_id,
|
||||||
|
(PlaylistShare.shared_with_id == me.id) |
|
||||||
|
(PlaylistShare.invited_email == me.email)
|
||||||
|
).first()
|
||||||
|
if not share or share.permission != "edit":
|
||||||
|
raise HTTPException(403, "Du har ikke redigerings-rettighed")
|
||||||
|
|
||||||
|
# Slet eksisterende sange og geninsert
|
||||||
|
db.query(ProjectSong).filter_by(project_id=project_id).delete()
|
||||||
|
|
||||||
|
for song_data in data.songs:
|
||||||
|
song = db.query(Song).filter_by(
|
||||||
|
title=song_data.title, artist=song_data.artist
|
||||||
|
).first()
|
||||||
|
if not song:
|
||||||
|
continue
|
||||||
|
ps = ProjectSong(
|
||||||
|
project_id=project_id,
|
||||||
|
song_id=song.id,
|
||||||
|
position=song_data.position,
|
||||||
|
status=song_data.status,
|
||||||
|
is_workshop=song_data.is_workshop,
|
||||||
|
dance_override=song_data.dance_override,
|
||||||
|
)
|
||||||
|
db.add(ps)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"detail": "Liste opdateret", "songs": len(data.songs)}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Opdater sange på en delt playliste ───────────────────────────────────────
|
||||||
|
|
||||||
|
class LinkedSongData(BaseModel):
|
||||||
|
title: str
|
||||||
|
artist: str
|
||||||
|
position: int
|
||||||
|
status: str = "pending"
|
||||||
|
is_workshop: bool = False
|
||||||
|
dance_override: str = ""
|
||||||
|
|
||||||
|
class LinkedPlaylistUpdate(BaseModel):
|
||||||
|
songs: list[LinkedSongData]
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/playlists/{project_id}/songs")
|
||||||
|
def update_linked_playlist_songs(
|
||||||
|
project_id: str,
|
||||||
|
data: LinkedPlaylistUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
me: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Opdater sange på en delt playliste — kræver edit-rettighed."""
|
||||||
|
from app.models import Song
|
||||||
|
|
||||||
|
p = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
if not p:
|
||||||
|
raise HTTPException(404, "Playliste ikke fundet")
|
||||||
|
|
||||||
|
# Tjek rettighed
|
||||||
|
if p.owner_id != me.id:
|
||||||
|
from app.models import PlaylistShare
|
||||||
|
share = db.query(PlaylistShare).filter(
|
||||||
|
PlaylistShare.project_id == project_id,
|
||||||
|
(PlaylistShare.shared_with_id == me.id) |
|
||||||
|
(PlaylistShare.invited_email == me.email)
|
||||||
|
).first()
|
||||||
|
if not share or share.permission != "edit":
|
||||||
|
raise HTTPException(403, "Du har ikke rettighed til at redigere denne liste")
|
||||||
|
|
||||||
|
# Slet eksisterende sange og indsæt nye
|
||||||
|
from app.models import ProjectSong
|
||||||
|
db.query(ProjectSong).filter_by(project_id=project_id).delete()
|
||||||
|
|
||||||
|
for song_data in data.songs:
|
||||||
|
# Match sang globalt på titel+artist
|
||||||
|
song = db.query(Song).filter_by(
|
||||||
|
title=song_data.title, artist=song_data.artist
|
||||||
|
).first()
|
||||||
|
if not song:
|
||||||
|
song = Song(
|
||||||
|
owner_id=me.id,
|
||||||
|
title=song_data.title,
|
||||||
|
artist=song_data.artist,
|
||||||
|
)
|
||||||
|
db.add(song)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
ps = ProjectSong(
|
||||||
|
project_id=project_id,
|
||||||
|
song_id=song.id,
|
||||||
|
position=song_data.position,
|
||||||
|
status=song_data.status,
|
||||||
|
is_workshop=song_data.is_workshop,
|
||||||
|
dance_override=song_data.dance_override,
|
||||||
|
)
|
||||||
|
db.add(ps)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"detail": "Playliste opdateret", "songs": len(data.songs)}
|
||||||
@@ -1,41 +1,25 @@
|
|||||||
|
"""songs.py — Simpel sang-router (basis CRUD)."""
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
from pydantic import BaseModel
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.security import get_current_user
|
from app.core.security import get_current_user
|
||||||
from app.models import User, Song, SongDance, DanceAlternative
|
from app.models import User, Song
|
||||||
from app.schemas import (
|
|
||||||
SongCreate, SongOut,
|
|
||||||
SongDanceCreate, SongDanceOut,
|
|
||||||
DanceAlternativeCreate, DanceAlternativeOut,
|
|
||||||
)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/songs", tags=["songs"])
|
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])
|
@router.get("/", response_model=list[SongOut])
|
||||||
def list_songs(db: Session = Depends(get_db), me: User = Depends(get_current_user)):
|
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()
|
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)
|
@router.delete("/{song_id}", status_code=204)
|
||||||
def delete_song(song_id: str, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
|
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()
|
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")
|
raise HTTPException(404, "Sang ikke fundet")
|
||||||
db.delete(song)
|
db.delete(song)
|
||||||
db.commit()
|
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()
|
|
||||||
|
|||||||
274
linedance-api/app/routers/sync.py
Normal file
274
linedance-api/app/routers/sync.py
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
"""
|
||||||
|
sync.py — Push/pull synkronisering mellem lokal app og server.
|
||||||
|
|
||||||
|
POST /sync/push — send lokal data op til server
|
||||||
|
GET /sync/pull — hent server-data ned til app
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.models import (
|
||||||
|
User, Song, Dance, DanceLevel, Project, ProjectSong,
|
||||||
|
PlaylistShare, CommunityDance, CommunityDanceAlt,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/sync", tags=["sync"])
|
||||||
|
|
||||||
|
|
||||||
|
# ── Schemas ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class SongData(BaseModel):
|
||||||
|
local_id: str
|
||||||
|
title: str
|
||||||
|
artist: str = ""
|
||||||
|
album: str = ""
|
||||||
|
bpm: int = 0
|
||||||
|
duration_sec: int = 0
|
||||||
|
file_format: str = ""
|
||||||
|
|
||||||
|
class DanceData(BaseModel):
|
||||||
|
name: str
|
||||||
|
level_name: str = ""
|
||||||
|
choreographer: str = ""
|
||||||
|
video_url: str = ""
|
||||||
|
stepsheet_url: str = ""
|
||||||
|
notes: str = ""
|
||||||
|
|
||||||
|
class SongDanceData(BaseModel):
|
||||||
|
song_local_id: str
|
||||||
|
dance_name: str
|
||||||
|
level_name: str = ""
|
||||||
|
dance_order: int = 1
|
||||||
|
|
||||||
|
class SongAltDanceData(BaseModel):
|
||||||
|
song_local_id: str
|
||||||
|
dance_name: str
|
||||||
|
level_name: str = ""
|
||||||
|
note: str = ""
|
||||||
|
|
||||||
|
class PlaylistSongData(BaseModel):
|
||||||
|
song_local_id: str
|
||||||
|
position: int
|
||||||
|
status: str = "pending"
|
||||||
|
is_workshop: bool = False
|
||||||
|
dance_override: str = ""
|
||||||
|
|
||||||
|
class PlaylistData(BaseModel):
|
||||||
|
local_id: str
|
||||||
|
name: str
|
||||||
|
description: str = ""
|
||||||
|
tags: str = ""
|
||||||
|
visibility: str = "private"
|
||||||
|
songs: list[PlaylistSongData] = []
|
||||||
|
|
||||||
|
class PushPayload(BaseModel):
|
||||||
|
songs: list[SongData] = []
|
||||||
|
dances: list[DanceData] = []
|
||||||
|
song_dances: list[SongDanceData] = []
|
||||||
|
song_alts: list[SongAltDanceData] = []
|
||||||
|
playlists: list[PlaylistData] = []
|
||||||
|
|
||||||
|
|
||||||
|
# ── Push ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/push")
|
||||||
|
def push(
|
||||||
|
payload: PushPayload,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
me: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Upload lokal data til server. Returnerer server-IDs."""
|
||||||
|
song_id_map = {} # local_id → server Song.id
|
||||||
|
dance_id_map = {} # "name|level" → server Dance.id
|
||||||
|
level_map = {} # level_name → DanceLevel.id
|
||||||
|
|
||||||
|
# ── Dans-niveauer ─────────────────────────────────────────────────────────
|
||||||
|
for lvl in db.query(DanceLevel).all():
|
||||||
|
level_map[lvl.name.lower()] = lvl.id
|
||||||
|
|
||||||
|
# ── Sange ─────────────────────────────────────────────────────────────────
|
||||||
|
for s in payload.songs:
|
||||||
|
if not s.title:
|
||||||
|
continue
|
||||||
|
# Match globalt på titel+artist — samme sang deles på tværs af brugere
|
||||||
|
existing = db.query(Song).filter(
|
||||||
|
Song.title == s.title,
|
||||||
|
Song.artist == s.artist,
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
song_id_map[s.local_id] = existing.id
|
||||||
|
# Opdater BPM hvis det mangler
|
||||||
|
if s.bpm and not existing.bpm:
|
||||||
|
existing.bpm = s.bpm
|
||||||
|
else:
|
||||||
|
song = Song(
|
||||||
|
owner_id=me.id,
|
||||||
|
title=s.title, artist=s.artist, album=s.album,
|
||||||
|
bpm=s.bpm, duration_sec=s.duration_sec,
|
||||||
|
file_format=s.file_format,
|
||||||
|
)
|
||||||
|
db.add(song)
|
||||||
|
db.flush()
|
||||||
|
song_id_map[s.local_id] = song.id
|
||||||
|
|
||||||
|
# ── Danse ──────────────────────────────────────────────────────────────────
|
||||||
|
for d in payload.dances:
|
||||||
|
level_id = level_map.get(d.level_name.lower()) if d.level_name else None
|
||||||
|
key = f"{d.name.lower()}|{level_id}"
|
||||||
|
existing = db.query(Dance).filter_by(name=d.name, level_id=level_id).first()
|
||||||
|
if existing:
|
||||||
|
# Opdater info hvis den har ny data
|
||||||
|
if d.choreographer: existing.choreographer = d.choreographer
|
||||||
|
if d.video_url: existing.video_url = d.video_url
|
||||||
|
if d.stepsheet_url: existing.stepsheet_url = d.stepsheet_url
|
||||||
|
if d.notes: existing.notes = d.notes
|
||||||
|
dance_id_map[key] = existing.id
|
||||||
|
else:
|
||||||
|
dance = Dance(
|
||||||
|
name=d.name, level_id=level_id,
|
||||||
|
choreographer=d.choreographer, video_url=d.video_url,
|
||||||
|
stepsheet_url=d.stepsheet_url, notes=d.notes,
|
||||||
|
)
|
||||||
|
db.add(dance)
|
||||||
|
db.flush()
|
||||||
|
dance_id_map[key] = dance.id
|
||||||
|
|
||||||
|
# ── Community dans-tags ────────────────────────────────────────────────────
|
||||||
|
for sd in payload.song_dances:
|
||||||
|
song_id = song_id_map.get(sd.song_local_id)
|
||||||
|
if not song_id:
|
||||||
|
continue
|
||||||
|
song = db.query(Song).filter_by(id=song_id).first()
|
||||||
|
level_id = level_map.get(sd.level_name.lower()) if sd.level_name else None
|
||||||
|
key = f"{sd.dance_name.lower()}|{level_id}"
|
||||||
|
dance_id = dance_id_map.get(key)
|
||||||
|
if not dance_id:
|
||||||
|
continue
|
||||||
|
# Indsend som community dans-tag
|
||||||
|
existing = db.query(CommunityDance).filter_by(
|
||||||
|
song_title=song.title, song_artist=song.artist, dance_id=dance_id
|
||||||
|
).first()
|
||||||
|
if not existing:
|
||||||
|
cd = CommunityDance(
|
||||||
|
song_title=song.title, song_artist=song.artist,
|
||||||
|
dance_id=dance_id, submitted_by=me.id,
|
||||||
|
)
|
||||||
|
db.add(cd)
|
||||||
|
|
||||||
|
# ── Playlister ────────────────────────────────────────────────────────────
|
||||||
|
playlist_id_map = {}
|
||||||
|
for pl in payload.playlists:
|
||||||
|
existing = db.query(Project).filter_by(
|
||||||
|
owner_id=me.id, name=pl.name
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
existing.description = pl.description
|
||||||
|
existing.visibility = pl.visibility
|
||||||
|
# Slet og geninsert sange
|
||||||
|
db.query(ProjectSong).filter_by(project_id=existing.id).delete()
|
||||||
|
project = existing
|
||||||
|
else:
|
||||||
|
project = Project(
|
||||||
|
owner_id=me.id, name=pl.name,
|
||||||
|
description=pl.description, visibility=pl.visibility,
|
||||||
|
)
|
||||||
|
db.add(project)
|
||||||
|
db.flush()
|
||||||
|
playlist_id_map[pl.local_id] = project.id
|
||||||
|
|
||||||
|
for ps in pl.songs:
|
||||||
|
song_id = song_id_map.get(ps.song_local_id)
|
||||||
|
if not song_id:
|
||||||
|
continue
|
||||||
|
proj_song = ProjectSong(
|
||||||
|
project_id=project.id, song_id=song_id,
|
||||||
|
position=ps.position, status=ps.status,
|
||||||
|
is_workshop=ps.is_workshop,
|
||||||
|
dance_override=ps.dance_override,
|
||||||
|
)
|
||||||
|
db.add(proj_song)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"songs_synced": len(song_id_map),
|
||||||
|
"playlists_synced": len(playlist_id_map),
|
||||||
|
"song_id_map": song_id_map,
|
||||||
|
"playlist_id_map": playlist_id_map,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pull ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/pull")
|
||||||
|
def pull(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
me: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Hent server-data til lokal app."""
|
||||||
|
|
||||||
|
# Dans-niveauer
|
||||||
|
levels = [
|
||||||
|
{"id": l.id, "name": l.name, "sort_order": l.sort_order}
|
||||||
|
for l in db.query(DanceLevel).order_by(DanceLevel.sort_order).all()
|
||||||
|
]
|
||||||
|
|
||||||
|
# Danse med info
|
||||||
|
dances = [
|
||||||
|
{
|
||||||
|
"name": d.name,
|
||||||
|
"level_id": d.level_id,
|
||||||
|
"choreographer": d.choreographer,
|
||||||
|
"video_url": d.video_url,
|
||||||
|
"stepsheet_url": d.stepsheet_url,
|
||||||
|
"notes": d.notes,
|
||||||
|
"use_count": d.use_count,
|
||||||
|
}
|
||||||
|
for d in db.query(Dance).order_by(Dance.use_count.desc()).limit(500).all()
|
||||||
|
]
|
||||||
|
|
||||||
|
# Community dans-tags (populære)
|
||||||
|
community = []
|
||||||
|
for cd in db.query(CommunityDance).limit(1000).all():
|
||||||
|
community.append({
|
||||||
|
"song_title": cd.song_title,
|
||||||
|
"song_artist": cd.song_artist,
|
||||||
|
"dance_id": cd.dance_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Delte playlister
|
||||||
|
shared_ids = [
|
||||||
|
s.project_id for s in
|
||||||
|
db.query(PlaylistShare).filter_by(shared_with_id=me.id).all()
|
||||||
|
]
|
||||||
|
shared = []
|
||||||
|
for p in db.query(Project).filter(Project.id.in_(shared_ids)).all():
|
||||||
|
shared.append({
|
||||||
|
"id": p.id,
|
||||||
|
"name": p.name,
|
||||||
|
"owner_id": p.owner_id,
|
||||||
|
"visibility": p.visibility,
|
||||||
|
"songs": [
|
||||||
|
{
|
||||||
|
"song_id": ps.song_id,
|
||||||
|
"position": ps.position,
|
||||||
|
"status": ps.status,
|
||||||
|
"is_workshop": ps.is_workshop,
|
||||||
|
"dance_override": ps.dance_override,
|
||||||
|
}
|
||||||
|
for ps in p.project_songs
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"levels": levels,
|
||||||
|
"dances": dances,
|
||||||
|
"community": community,
|
||||||
|
"shared": shared,
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ uvicorn[standard]>=0.29.0
|
|||||||
sqlalchemy>=2.0.0
|
sqlalchemy>=2.0.0
|
||||||
pymysql>=1.1.0
|
pymysql>=1.1.0
|
||||||
alembic>=1.13.0
|
alembic>=1.13.0
|
||||||
passlib[bcrypt]>=1.7.4
|
bcrypt>=4.0.0
|
||||||
python-jose[cryptography]>=3.3.0
|
python-jose[cryptography]>=3.3.0
|
||||||
pydantic[email]>=2.0.0
|
pydantic[email]>=2.0.0
|
||||||
pydantic-settings>=2.0.0
|
pydantic-settings>=2.0.0
|
||||||
|
|||||||
14
linedance-api/start_local.bat
Normal file
14
linedance-api/start_local.bat
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
@echo off
|
||||||
|
echo Starter LineDance API lokalt...
|
||||||
|
cd /d %~dp0
|
||||||
|
if not exist venv (
|
||||||
|
python -m venv venv
|
||||||
|
venv\Scripts\pip install -r requirements.txt
|
||||||
|
)
|
||||||
|
if not exist .env (
|
||||||
|
copy .env.example .env
|
||||||
|
echo.
|
||||||
|
echo VIGTIGT: Rediger .env med dine database-indstillinger!
|
||||||
|
pause
|
||||||
|
)
|
||||||
|
venv\Scripts\uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
@@ -1,23 +1,22 @@
|
|||||||
# -*- mode: python ; coding: utf-8 -*-
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
from PyInstaller.utils.hooks import collect_all, collect_submodules
|
||||||
|
|
||||||
block_cipher = None
|
block_cipher = None
|
||||||
|
|
||||||
|
# Saml ALT fra PyQt6 inkl. plugins og DLL-filer
|
||||||
|
pyqt6_datas, pyqt6_binaries, pyqt6_hiddenimports = collect_all('PyQt6')
|
||||||
|
|
||||||
a = Analysis(
|
a = Analysis(
|
||||||
['main.py'],
|
['main.py'],
|
||||||
pathex=['.'],
|
pathex=['.'],
|
||||||
binaries=[],
|
binaries=pyqt6_binaries,
|
||||||
datas=[
|
datas=pyqt6_datas,
|
||||||
('translations', 'translations'),
|
hiddenimports=pyqt6_hiddenimports + [
|
||||||
('ui', 'ui'),
|
|
||||||
('local', 'local'),
|
|
||||||
('player', 'player'),
|
|
||||||
],
|
|
||||||
hiddenimports=[
|
|
||||||
'PyQt6.sip',
|
'PyQt6.sip',
|
||||||
'PyQt6.QtCore',
|
'PyQt6.QtCore',
|
||||||
'PyQt6.QtGui',
|
'PyQt6.QtGui',
|
||||||
'PyQt6.QtWidgets',
|
'PyQt6.QtWidgets',
|
||||||
'PyQt6.QtNetwork',
|
# UI moduler
|
||||||
'ui.main_window',
|
'ui.main_window',
|
||||||
'ui.playlist_panel',
|
'ui.playlist_panel',
|
||||||
'ui.library_panel',
|
'ui.library_panel',
|
||||||
@@ -25,76 +24,30 @@ a = Analysis(
|
|||||||
'ui.themes',
|
'ui.themes',
|
||||||
'ui.vu_meter',
|
'ui.vu_meter',
|
||||||
'ui.scan_worker',
|
'ui.scan_worker',
|
||||||
'ui.bpm_worker',
|
|
||||||
'ui.tag_editor',
|
'ui.tag_editor',
|
||||||
'ui.login_dialog',
|
'ui.login_dialog',
|
||||||
'ui.settings_dialog',
|
'ui.settings_dialog',
|
||||||
'ui.register_dialog',
|
'ui.playlist_manager',
|
||||||
'ui.playlist_browser',
|
|
||||||
'ui.playlist_info_dialog',
|
|
||||||
'ui.dance_info_dialog',
|
|
||||||
'ui.dance_picker_dialog',
|
|
||||||
'ui.next_up_bar',
|
'ui.next_up_bar',
|
||||||
|
# Player + local
|
||||||
'player.player',
|
'player.player',
|
||||||
'local.local_db',
|
'local.local_db',
|
||||||
'local.tag_reader',
|
'local.tag_reader',
|
||||||
'local.file_watcher',
|
'local.file_watcher',
|
||||||
'local.scanner',
|
# Biblioteker
|
||||||
'translations',
|
'mutagen', 'mutagen.mp3', 'mutagen.id3', 'mutagen.flac',
|
||||||
'translations.da',
|
|
||||||
'translations.en',
|
|
||||||
'mutagen',
|
|
||||||
'mutagen.mp3', 'mutagen.id3', 'mutagen.flac',
|
|
||||||
'mutagen.mp4', 'mutagen.oggvorbis', 'mutagen.ogg',
|
'mutagen.mp4', 'mutagen.oggvorbis', 'mutagen.ogg',
|
||||||
'mutagen.wave', 'mutagen.aiff', 'mutagen.asf',
|
'mutagen.wave', 'mutagen.aiff', 'mutagen.asf',
|
||||||
'watchdog',
|
'watchdog', 'watchdog.observers', 'watchdog.events',
|
||||||
'watchdog.observers',
|
'watchdog.observers.winapi',
|
||||||
'watchdog.observers.polling',
|
'vlc', 'sqlite3',
|
||||||
'watchdog.events',
|
|
||||||
'vlc',
|
|
||||||
'sqlite3',
|
|
||||||
],
|
],
|
||||||
hookspath=[],
|
hookspath=[],
|
||||||
|
hooksconfig={},
|
||||||
runtime_hooks=[],
|
runtime_hooks=[],
|
||||||
excludes=[
|
excludes=['tkinter', 'matplotlib', 'pandas', 'scipy', 'IPython'],
|
||||||
'tkinter', 'tk', 'tcl',
|
win_no_prefer_redirects=False,
|
||||||
'matplotlib', 'pandas', 'scipy', 'numpy',
|
win_private_assemblies=False,
|
||||||
'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',
|
|
||||||
],
|
|
||||||
cipher=block_cipher,
|
cipher=block_cipher,
|
||||||
noarchive=False,
|
noarchive=False,
|
||||||
)
|
)
|
||||||
@@ -104,15 +57,18 @@ pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
|||||||
exe = EXE(
|
exe = EXE(
|
||||||
pyz,
|
pyz,
|
||||||
a.scripts,
|
a.scripts,
|
||||||
[], # ← onedir: ingen binaries/datas her
|
[],
|
||||||
exclude_binaries=True, # ← onedir: binaries samles i COLLECT
|
exclude_binaries=True,
|
||||||
name='LineDancePlayer',
|
name='LineDancePlayer',
|
||||||
debug=False,
|
debug=False,
|
||||||
bootloader_ignore_signals=False,
|
bootloader_ignore_signals=False,
|
||||||
strip=False,
|
strip=False,
|
||||||
upx=True,
|
upx=False, # UPX kan give problemer med PyQt6 DLL-filer
|
||||||
upx_exclude=['Qt6*.dll', 'python3*.dll', 'vcruntime140.dll'],
|
console=False, # Ingen konsol-vindue
|
||||||
console=False,
|
disable_windowed_traceback=False,
|
||||||
|
target_arch=None,
|
||||||
|
codesign_identity=None,
|
||||||
|
entitlements_file=None,
|
||||||
icon=None,
|
icon=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -122,7 +78,7 @@ coll = COLLECT(
|
|||||||
a.zipfiles,
|
a.zipfiles,
|
||||||
a.datas,
|
a.datas,
|
||||||
strip=False,
|
strip=False,
|
||||||
upx=True,
|
upx=False,
|
||||||
upx_exclude=['Qt6*.dll', 'python3*.dll', 'vcruntime140.dll'],
|
upx_exclude=[],
|
||||||
name='LineDancePlayer',
|
name='LineDancePlayer',
|
||||||
)
|
)
|
||||||
|
|||||||
173
linedance-app/local/linked_playlist.py
Normal file
173
linedance-app/local/linked_playlist.py
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
"""
|
||||||
|
linked_playlist.py — Håndter linkede server-playlister.
|
||||||
|
Pull ved åbning, push ved gem.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LinkedPlaylistManager:
|
||||||
|
def __init__(self, db_path: str, server_url: str, token: str):
|
||||||
|
self._db_path = db_path
|
||||||
|
self._server_url = server_url.rstrip("/")
|
||||||
|
self._token = token
|
||||||
|
|
||||||
|
def _headers(self):
|
||||||
|
return {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {self._token}",
|
||||||
|
}
|
||||||
|
|
||||||
|
def pull(self, playlist_id: int) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Hent seneste version fra serveren og opdater lokal liste.
|
||||||
|
Returnerer sang-liste klar til playlist_panel.
|
||||||
|
"""
|
||||||
|
conn = sqlite3.connect(self._db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
pl = conn.execute(
|
||||||
|
"SELECT api_project_id, server_permission FROM playlists WHERE id=?",
|
||||||
|
(playlist_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not pl or not pl["api_project_id"]:
|
||||||
|
conn.close()
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Hent fra server
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{self._server_url}/sharing/playlists/{pl['api_project_id']}",
|
||||||
|
headers=self._headers()
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
data = json.loads(resp.read())
|
||||||
|
|
||||||
|
# Slet eksisterende sange og erstat med server-version
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM playlist_songs WHERE playlist_id=?", (playlist_id,)
|
||||||
|
)
|
||||||
|
|
||||||
|
songs = []
|
||||||
|
for song_data in sorted(data.get("songs", []), key=lambda x: x["position"]):
|
||||||
|
# Match lokalt på titel+artist
|
||||||
|
local = conn.execute(
|
||||||
|
"SELECT id, local_path, bpm, duration_sec, file_format, file_missing "
|
||||||
|
"FROM songs WHERE title=? AND artist=? AND file_missing=0 LIMIT 1",
|
||||||
|
(song_data["title"], song_data["artist"])
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if local:
|
||||||
|
conn.execute("""
|
||||||
|
INSERT OR IGNORE INTO playlist_songs
|
||||||
|
(playlist_id, song_id, position, status, is_workshop, dance_override)
|
||||||
|
VALUES (?,?,?,?,?,?)
|
||||||
|
""", (
|
||||||
|
playlist_id, local["id"],
|
||||||
|
song_data["position"], song_data["status"],
|
||||||
|
1 if song_data.get("is_workshop") else 0,
|
||||||
|
song_data.get("dance_override", ""),
|
||||||
|
))
|
||||||
|
|
||||||
|
# Hent danse
|
||||||
|
dances = conn.execute("""
|
||||||
|
SELECT d.name FROM song_dances sd
|
||||||
|
JOIN dances d ON d.id = sd.dance_id
|
||||||
|
WHERE sd.song_id=? ORDER BY sd.dance_order
|
||||||
|
""", (local["id"],)).fetchall()
|
||||||
|
|
||||||
|
songs.append({
|
||||||
|
"id": local["id"],
|
||||||
|
"title": song_data["title"],
|
||||||
|
"artist": song_data["artist"],
|
||||||
|
"album": song_data.get("album", ""),
|
||||||
|
"bpm": local["bpm"] or 0,
|
||||||
|
"duration_sec": local["duration_sec"] or 0,
|
||||||
|
"local_path": local["local_path"],
|
||||||
|
"file_format": local["file_format"] or "",
|
||||||
|
"file_missing": False,
|
||||||
|
"dances": [d["name"] for d in dances],
|
||||||
|
"active_dance": song_data.get("dance_override", ""),
|
||||||
|
"is_workshop": bool(song_data.get("is_workshop")),
|
||||||
|
"status": song_data.get("status", "pending"),
|
||||||
|
})
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return songs
|
||||||
|
|
||||||
|
def push(self, playlist_id: int):
|
||||||
|
"""Push lokal version til serveren."""
|
||||||
|
conn = sqlite3.connect(self._db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
pl = conn.execute(
|
||||||
|
"SELECT api_project_id, server_permission, name FROM playlists WHERE id=?",
|
||||||
|
(playlist_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not pl or not pl["api_project_id"]:
|
||||||
|
conn.close()
|
||||||
|
raise Exception("Playlisten er ikke linket til serveren")
|
||||||
|
|
||||||
|
if pl["server_permission"] not in ("edit",):
|
||||||
|
conn.close()
|
||||||
|
raise Exception(f"Du har ikke rettighed til at redigere denne liste (du har: {pl['server_permission']})")
|
||||||
|
|
||||||
|
# Byg payload til sync/push
|
||||||
|
songs_raw = conn.execute("""
|
||||||
|
SELECT s.id, s.title, s.artist, s.album, s.bpm, s.duration_sec,
|
||||||
|
s.file_format, ps.position, ps.status, ps.is_workshop, ps.dance_override
|
||||||
|
FROM playlist_songs ps
|
||||||
|
JOIN songs s ON s.id = ps.song_id
|
||||||
|
WHERE ps.playlist_id=? ORDER BY ps.position
|
||||||
|
""", (playlist_id,)).fetchall()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
from local.sync_manager import SyncManager
|
||||||
|
mgr = SyncManager(self._db_path, self._server_url, self._token)
|
||||||
|
|
||||||
|
# Byg mini-payload med kun denne playliste
|
||||||
|
song_ids = [row["id"] for row in songs_raw]
|
||||||
|
songs_payload = []
|
||||||
|
for row in songs_raw:
|
||||||
|
songs_payload.append({
|
||||||
|
"local_id": str(row["id"]),
|
||||||
|
"title": row["title"] or "",
|
||||||
|
"artist": row["artist"] or "",
|
||||||
|
"album": row["album"] or "",
|
||||||
|
"bpm": row["bpm"] or 0,
|
||||||
|
"duration_sec": row["duration_sec"] or 0,
|
||||||
|
"file_format": row["file_format"] or "",
|
||||||
|
})
|
||||||
|
|
||||||
|
pl_payload = [{
|
||||||
|
"local_id": str(playlist_id),
|
||||||
|
"name": pl["name"],
|
||||||
|
"description": "",
|
||||||
|
"tags": "",
|
||||||
|
"visibility": "shared",
|
||||||
|
"songs": [
|
||||||
|
{
|
||||||
|
"song_local_id": str(row["id"]),
|
||||||
|
"position": int(row["position"]),
|
||||||
|
"status": row["status"] or "pending",
|
||||||
|
"is_workshop": bool(row["is_workshop"]),
|
||||||
|
"dance_override": row["dance_override"] or "",
|
||||||
|
}
|
||||||
|
for row in songs_raw
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
|
||||||
|
result = mgr._post("/sync/push", {
|
||||||
|
"songs": songs_payload,
|
||||||
|
"dances": [],
|
||||||
|
"song_dances": [],
|
||||||
|
"song_alts": [],
|
||||||
|
"playlists": pl_payload,
|
||||||
|
})
|
||||||
|
return result
|
||||||
@@ -251,6 +251,11 @@ MIGRATIONS: dict[int, list[str]] = {
|
|||||||
"""ALTER TABLE playlist_songs ADD COLUMN is_workshop INTEGER NOT NULL DEFAULT 0""",
|
"""ALTER TABLE playlist_songs ADD COLUMN is_workshop INTEGER NOT NULL DEFAULT 0""",
|
||||||
"""ALTER TABLE playlist_songs ADD COLUMN dance_override TEXT NOT NULL DEFAULT ''""",
|
"""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
|
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):
|
def update_playlist_tags(playlist_id: int, tags: str):
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|||||||
245
linedance-app/local/sync_manager.py
Normal file
245
linedance-app/local/sync_manager.py
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
"""
|
||||||
|
sync_manager.py — Synkronisering mellem lokal SQLite og server API.
|
||||||
|
Kører i baggrundstråd — blokerer aldrig GUI.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SyncManager:
|
||||||
|
def __init__(self, db_path: str, server_url: str, token: str):
|
||||||
|
self._db_path = db_path
|
||||||
|
self._server_url = server_url.rstrip("/")
|
||||||
|
self._token = token
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def _headers(self):
|
||||||
|
return {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {self._token}",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _post(self, path: str, data: dict) -> dict:
|
||||||
|
body = json.dumps(data).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{self._server_url}{path}", data=body,
|
||||||
|
headers=self._headers(), method="POST"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
return json.loads(resp.read())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
detail = e.read().decode("utf-8", errors="replace")
|
||||||
|
raise Exception(f"HTTP {e.code}: {detail}")
|
||||||
|
|
||||||
|
def _get(self, path: str) -> dict:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{self._server_url}{path}",
|
||||||
|
headers=self._headers(), method="GET"
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
return json.loads(resp.read())
|
||||||
|
|
||||||
|
# ── Push ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def push(self, on_done=None, on_error=None):
|
||||||
|
"""Push lokal data til server i baggrundstråd."""
|
||||||
|
def _run():
|
||||||
|
try:
|
||||||
|
payload = self._build_push_payload()
|
||||||
|
result = self._post("/sync/push", payload)
|
||||||
|
# Gem server-IDs lokalt
|
||||||
|
self._save_playlist_ids(result.get("playlist_id_map", {}))
|
||||||
|
logger.info(f"Sync push: {result}")
|
||||||
|
if on_done:
|
||||||
|
on_done(result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Sync push fejl: {e}")
|
||||||
|
if on_error:
|
||||||
|
on_error(str(e))
|
||||||
|
threading.Thread(target=_run, daemon=True).start()
|
||||||
|
|
||||||
|
def _save_playlist_ids(self, id_map: dict):
|
||||||
|
"""Gem server-IDs (api_project_id) på lokale playlister."""
|
||||||
|
if not id_map:
|
||||||
|
return
|
||||||
|
conn = sqlite3.connect(self._db_path)
|
||||||
|
for local_id, server_id in id_map.items():
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE playlists SET api_project_id=? WHERE id=?",
|
||||||
|
(server_id, int(local_id))
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def pull(self, on_done=None, on_error=None):
|
||||||
|
"""Pull server-data ned i baggrundstråd."""
|
||||||
|
def _run():
|
||||||
|
try:
|
||||||
|
result = self._get("/sync/pull")
|
||||||
|
self._apply_pull(result)
|
||||||
|
logger.info(f"Sync pull: {len(result.get('dances', []))} danse")
|
||||||
|
if on_done:
|
||||||
|
on_done(result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Sync pull fejl: {e}")
|
||||||
|
if on_error:
|
||||||
|
on_error(str(e))
|
||||||
|
threading.Thread(target=_run, daemon=True).start()
|
||||||
|
|
||||||
|
def push_and_pull(self, on_done=None, on_error=None):
|
||||||
|
"""Push og derefter pull i samme tråd."""
|
||||||
|
def _run():
|
||||||
|
try:
|
||||||
|
payload = self._build_push_payload()
|
||||||
|
push_result = self._post("/sync/push", payload)
|
||||||
|
pull_result = self._get("/sync/pull")
|
||||||
|
self._apply_pull(pull_result)
|
||||||
|
if on_done:
|
||||||
|
on_done({"push": push_result, "pull": pull_result})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Sync fejl: {e}")
|
||||||
|
if on_error:
|
||||||
|
on_error(str(e))
|
||||||
|
threading.Thread(target=_run, daemon=True).start()
|
||||||
|
|
||||||
|
# ── Byg payload ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _build_push_payload(self) -> dict:
|
||||||
|
conn = sqlite3.connect(self._db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
# Sange
|
||||||
|
songs = []
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT id, title, artist, album, bpm, duration_sec, file_format "
|
||||||
|
"FROM songs WHERE file_missing=0"
|
||||||
|
).fetchall():
|
||||||
|
songs.append({
|
||||||
|
"local_id": str(row["id"]),
|
||||||
|
"title": row["title"] or "",
|
||||||
|
"artist": row["artist"] or "",
|
||||||
|
"album": row["album"] or "",
|
||||||
|
"bpm": row["bpm"] or 0,
|
||||||
|
"duration_sec": row["duration_sec"] or 0,
|
||||||
|
"file_format": row["file_format"] or "",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Danse
|
||||||
|
dances = []
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT d.name, dl.name as level_name, d.choreographer, "
|
||||||
|
"d.video_url, d.stepsheet_url, d.notes "
|
||||||
|
"FROM dances d LEFT JOIN dance_levels dl ON dl.id = d.level_id"
|
||||||
|
).fetchall():
|
||||||
|
dances.append({
|
||||||
|
"name": row["name"] or "",
|
||||||
|
"level_name": row["level_name"] or "",
|
||||||
|
"choreographer": row["choreographer"] or "",
|
||||||
|
"video_url": row["video_url"] or "",
|
||||||
|
"stepsheet_url": row["stepsheet_url"] or "",
|
||||||
|
"notes": row["notes"] or "",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Dans-tags per sang
|
||||||
|
song_dances = []
|
||||||
|
for row in conn.execute("""
|
||||||
|
SELECT sd.song_id, d.name as dance_name, dl.name as level_name, sd.dance_order
|
||||||
|
FROM song_dances sd
|
||||||
|
JOIN dances d ON d.id = sd.dance_id
|
||||||
|
LEFT JOIN dance_levels dl ON dl.id = d.level_id
|
||||||
|
""").fetchall():
|
||||||
|
song_dances.append({
|
||||||
|
"song_local_id": str(row["song_id"]),
|
||||||
|
"dance_name": row["dance_name"],
|
||||||
|
"level_name": row["level_name"] or "",
|
||||||
|
"dance_order": row["dance_order"],
|
||||||
|
})
|
||||||
|
|
||||||
|
# Alternativ-danse
|
||||||
|
song_alts = []
|
||||||
|
for row in conn.execute("""
|
||||||
|
SELECT sad.song_id, d.name as dance_name, dl.name as level_name, sad.note
|
||||||
|
FROM song_alt_dances sad
|
||||||
|
JOIN dances d ON d.id = sad.dance_id
|
||||||
|
LEFT JOIN dance_levels dl ON dl.id = d.level_id
|
||||||
|
""").fetchall():
|
||||||
|
song_alts.append({
|
||||||
|
"song_local_id": str(row["song_id"]),
|
||||||
|
"dance_name": row["dance_name"],
|
||||||
|
"level_name": row["level_name"] or "",
|
||||||
|
"note": row["note"] or "",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Playlister (kun navngivne — ikke __aktiv__)
|
||||||
|
playlists = []
|
||||||
|
for pl in conn.execute(
|
||||||
|
"SELECT id, name, description, tags FROM playlists "
|
||||||
|
"WHERE name != '__aktiv__'"
|
||||||
|
).fetchall():
|
||||||
|
pl_songs = []
|
||||||
|
for ps in conn.execute("""
|
||||||
|
SELECT song_id, position, status, is_workshop, dance_override
|
||||||
|
FROM playlist_songs WHERE playlist_id=? ORDER BY position
|
||||||
|
""", (pl["id"],)).fetchall():
|
||||||
|
pl_songs.append({
|
||||||
|
"song_local_id": ps["song_id"] or "",
|
||||||
|
"position": int(ps["position"] or 1),
|
||||||
|
"status": ps["status"] or "pending",
|
||||||
|
"is_workshop": bool(ps["is_workshop"]),
|
||||||
|
"dance_override": ps["dance_override"] or "",
|
||||||
|
})
|
||||||
|
playlists.append({
|
||||||
|
"local_id": str(pl["id"]),
|
||||||
|
"name": pl["name"],
|
||||||
|
"description": pl["description"] or "",
|
||||||
|
"tags": pl["tags"] or "",
|
||||||
|
"visibility": "private",
|
||||||
|
"songs": pl_songs,
|
||||||
|
})
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
return {
|
||||||
|
"songs": songs,
|
||||||
|
"dances": dances,
|
||||||
|
"song_dances": song_dances,
|
||||||
|
"song_alts": song_alts,
|
||||||
|
"playlists": playlists,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Anvend pull ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _apply_pull(self, data: dict):
|
||||||
|
"""Gem server-data lokalt — opdaterer dans-info og community forslag."""
|
||||||
|
conn = sqlite3.connect(self._db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
# Opdater dans-info fra server (koreograf, links, noter)
|
||||||
|
for d in data.get("dances", []):
|
||||||
|
if not d.get("name"):
|
||||||
|
continue
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT id FROM dances WHERE name=? COLLATE NOCASE", (d["name"],)
|
||||||
|
).fetchone()
|
||||||
|
if existing and (d.get("choreographer") or d.get("video_url") or d.get("stepsheet_url")):
|
||||||
|
conn.execute("""
|
||||||
|
UPDATE dances SET
|
||||||
|
choreographer = CASE WHEN choreographer='' THEN ? ELSE choreographer END,
|
||||||
|
video_url = CASE WHEN video_url='' THEN ? ELSE video_url END,
|
||||||
|
stepsheet_url = CASE WHEN stepsheet_url='' THEN ? ELSE stepsheet_url END
|
||||||
|
WHERE id=?
|
||||||
|
""", (d.get("choreographer",""), d.get("video_url",""),
|
||||||
|
d.get("stepsheet_url",""), existing["id"]))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
@@ -95,6 +95,9 @@ class MainWindow(QMainWindow):
|
|||||||
self._connect_player_signals()
|
self._connect_player_signals()
|
||||||
self._library_loaded.connect(self._apply_library)
|
self._library_loaded.connect(self._apply_library)
|
||||||
self._db_ready.connect(self._on_db_ready)
|
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_menu()
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
self._build_statusbar()
|
self._build_statusbar()
|
||||||
@@ -130,15 +133,15 @@ class MainWindow(QMainWindow):
|
|||||||
# ── Filer ─────────────────────────────────────────────────────────────
|
# ── Filer ─────────────────────────────────────────────────────────────
|
||||||
file_menu = menubar.addMenu("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.setShortcut("Ctrl+L")
|
||||||
self._act_go_online.triggered.connect(self._go_online)
|
self._act_go_online.triggered.connect(self._go_online)
|
||||||
file_menu.addAction(self._act_go_online)
|
file_menu.addAction(self._act_go_online)
|
||||||
|
|
||||||
self._act_go_offline = QAction("Gå offline", self)
|
self._act_sync = QAction("↕ Synkroniser nu", self)
|
||||||
self._act_go_offline.triggered.connect(self._go_offline)
|
self._act_sync.setShortcut("Ctrl+Shift+S")
|
||||||
self._act_go_offline.setEnabled(False)
|
self._act_sync.triggered.connect(self._manual_sync)
|
||||||
file_menu.addAction(self._act_go_offline)
|
file_menu.addAction(self._act_sync)
|
||||||
|
|
||||||
file_menu.addSeparator()
|
file_menu.addSeparator()
|
||||||
|
|
||||||
@@ -287,28 +290,26 @@ class MainWindow(QMainWindow):
|
|||||||
b.setCheckable(True)
|
b.setCheckable(True)
|
||||||
return b
|
return b
|
||||||
|
|
||||||
self._btn_prev = btn("|◀◀", size=52)
|
|
||||||
self._btn_play = btn("▶", "btn_play", size=72)
|
self._btn_play = btn("▶", "btn_play", size=72)
|
||||||
self._btn_stop = btn("■", "btn_stop", size=52)
|
self._btn_stop = btn("■", "btn_stop", size=72)
|
||||||
self._btn_next = btn("▶▶|", size=52)
|
|
||||||
self._btn_demo = btn(f"▶\n{self._demo_seconds} SEK", "btn_demo", size=64, checkable=True)
|
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_play.clicked.connect(self._toggle_play)
|
||||||
self._btn_stop.clicked.connect(self._stop)
|
self._btn_stop.clicked.connect(self._stop)
|
||||||
self._btn_next.clicked.connect(self._next_song)
|
|
||||||
self._btn_demo.clicked.connect(self._toggle_demo)
|
self._btn_demo.clicked.connect(self._toggle_demo)
|
||||||
|
|
||||||
layout.addWidget(self._btn_prev)
|
|
||||||
layout.addWidget(self._btn_play)
|
layout.addWidget(self._btn_play)
|
||||||
layout.addWidget(self._btn_stop)
|
layout.addWidget(self._btn_stop)
|
||||||
layout.addWidget(self._btn_next)
|
|
||||||
|
layout.addSpacing(24)
|
||||||
|
|
||||||
sep1 = QFrame()
|
sep1 = QFrame()
|
||||||
sep1.setFrameShape(QFrame.Shape.VLine)
|
sep1.setFrameShape(QFrame.Shape.VLine)
|
||||||
sep1.setFixedWidth(1)
|
sep1.setFixedWidth(1)
|
||||||
layout.addWidget(sep1)
|
layout.addWidget(sep1)
|
||||||
|
|
||||||
|
layout.addSpacing(24)
|
||||||
|
|
||||||
layout.addWidget(self._btn_demo)
|
layout.addWidget(self._btn_demo)
|
||||||
layout.addStretch()
|
layout.addStretch()
|
||||||
|
|
||||||
@@ -319,7 +320,9 @@ class MainWindow(QMainWindow):
|
|||||||
self._vol_slider = QSlider(Qt.Orientation.Horizontal)
|
self._vol_slider = QSlider(Qt.Orientation.Horizontal)
|
||||||
self._vol_slider.setRange(0, 100)
|
self._vol_slider.setRange(0, 100)
|
||||||
self._vol_slider.setValue(self._settings.get("volume", 78))
|
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)
|
self._vol_slider.valueChanged.connect(self._on_volume)
|
||||||
layout.addWidget(self._vol_slider)
|
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_selected.connect(self._load_song_by_idx)
|
||||||
self._playlist_panel.song_dropped.connect(self._on_song_dropped)
|
self._playlist_panel.song_dropped.connect(self._on_song_dropped)
|
||||||
self._playlist_panel.event_started.connect(self._on_event_started)
|
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 = LibraryPanel()
|
||||||
self._library_panel.song_selected.connect(self._on_library_song_selected)
|
self._library_panel.song_selected.connect(self._on_library_song_selected)
|
||||||
@@ -436,6 +446,9 @@ class MainWindow(QMainWindow):
|
|||||||
_library_loaded = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal(list)
|
_library_loaded = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal(list)
|
||||||
_db_ready = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal()
|
_db_ready = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal()
|
||||||
_file_changed_signal = __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):
|
def _reload_library(self):
|
||||||
"""Hent sange fra DB i baggrundstråd — thread-safe via signal."""
|
"""Hent sange fra DB i baggrundstråd — thread-safe via signal."""
|
||||||
@@ -508,20 +521,36 @@ class MainWindow(QMainWindow):
|
|||||||
try:
|
try:
|
||||||
restored = self._playlist_panel.restore_active_playlist()
|
restored = self._playlist_panel.restore_active_playlist()
|
||||||
if restored:
|
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():
|
if self._playlist_panel.restore_event_state():
|
||||||
|
# Event var i gang — genoptag
|
||||||
idx = self._playlist_panel._current_idx
|
idx = self._playlist_panel._current_idx
|
||||||
song = self._playlist_panel.get_song(idx)
|
song = self._playlist_panel.get_song(idx)
|
||||||
if song:
|
if song:
|
||||||
self._current_idx = idx
|
self._current_idx = idx
|
||||||
|
self._song_ended = False
|
||||||
self._load_song(song)
|
self._load_song(song)
|
||||||
|
self._playlist_panel.set_current(idx)
|
||||||
self._set_status(
|
self._set_status(
|
||||||
f"Event genoptaget ved: {song.get('title','')} — tryk ▶ for at fortsætte",
|
f"Event genoptaget ved: {song.get('title','')} — tryk ▶",
|
||||||
6000,
|
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Scan 30 sek efter opstart — fanger ændringer siden sidst
|
|
||||||
QTimer.singleShot(30000, self.start_background_scan)
|
QTimer.singleShot(30000, self.start_background_scan)
|
||||||
|
|
||||||
def start_background_scan(self):
|
def start_background_scan(self):
|
||||||
@@ -604,38 +633,102 @@ class MainWindow(QMainWindow):
|
|||||||
"""Forsøg automatisk login med gemte oplysninger."""
|
"""Forsøg automatisk login med gemte oplysninger."""
|
||||||
username = self._settings.get("username", "")
|
username = self._settings.get("username", "")
|
||||||
password = self._settings.get("password", "")
|
password = self._settings.get("password", "")
|
||||||
|
server_url = self._settings.get("server_url", "http://localhost:8000").rstrip("/")
|
||||||
if not username or not password:
|
if not username or not password:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
def _run():
|
||||||
try:
|
try:
|
||||||
import urllib.request, urllib.parse, json
|
import urllib.request, urllib.parse, json
|
||||||
data = urllib.parse.urlencode({"username": username, "password": password}).encode()
|
data = urllib.parse.urlencode({"username": username, "password": password}).encode()
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
f"{API_URL}/auth/login", data=data,
|
f"{server_url}/auth/login", data=data,
|
||||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
method="POST",
|
method="POST",
|
||||||
)
|
)
|
||||||
with urllib.request.urlopen(req, timeout=8) as resp:
|
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||||
body = json.loads(resp.read())
|
body = json.loads(resp.read())
|
||||||
self._api_token = body.get("access_token")
|
self._api_token = body.get("access_token")
|
||||||
self._api_url = API_URL
|
self._api_url = server_url
|
||||||
self._api_username = username
|
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_online_state(True)
|
||||||
self._set_status(f"Automatisk logget ind som {username}", 4000)
|
self._set_status(f"Logget ind som {username}", 4000)
|
||||||
# Synkroniser dans-niveauer og navne
|
|
||||||
QTimer.singleShot(500, self._sync_dance_data)
|
def _on_login_fail(self, error: str):
|
||||||
except Exception:
|
"""Kaldes i GUI-tråden når login fejler."""
|
||||||
self._set_status("Auto-login fejlede — kør Filer → Gå online manuelt", 5000)
|
self._set_status(f"Login fejlede: {error}", 5000)
|
||||||
|
|
||||||
def _go_online(self):
|
def _go_online(self):
|
||||||
dialog = LoginDialog(self)
|
"""Log ind/ud med gemte credentials."""
|
||||||
if dialog.exec():
|
if self._api_token:
|
||||||
url, username, token = dialog.get_credentials()
|
self._go_offline()
|
||||||
self._api_url = url
|
return
|
||||||
self._api_token = token
|
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._api_username = username
|
||||||
self._set_online_state(True)
|
self._login_success_signal.emit(username)
|
||||||
self._set_status(f"Online som {username}", 5000)
|
except Exception as e:
|
||||||
QTimer.singleShot(500, self._sync_dance_data)
|
self._login_fail_signal.emit(str(e))
|
||||||
|
|
||||||
|
import threading
|
||||||
|
threading.Thread(target=_run, daemon=True).start()
|
||||||
|
|
||||||
def _sync_dance_data(self):
|
def _sync_dance_data(self):
|
||||||
"""Synkroniser dans-niveauer og navne fra API."""
|
"""Synkroniser dans-niveauer og navne fra API."""
|
||||||
@@ -669,15 +762,56 @@ class MainWindow(QMainWindow):
|
|||||||
self._set_status("Offline — arbejder lokalt", 3000)
|
self._set_status("Offline — arbejder lokalt", 3000)
|
||||||
|
|
||||||
def _set_online_state(self, online: bool):
|
def _set_online_state(self, online: bool):
|
||||||
self._act_go_online.setEnabled(not online)
|
|
||||||
self._act_go_offline.setEnabled(online)
|
|
||||||
if online:
|
if online:
|
||||||
name = self._api_username or "?"
|
name = self._api_username or "?"
|
||||||
self._conn_label.setText(f"● ONLINE ({name})")
|
self._conn_label.setText(f"● ONLINE ({name})")
|
||||||
self._conn_label.setStyleSheet("color: #2ecc71;")
|
self._conn_label.setStyleSheet("color: #2ecc71;")
|
||||||
|
self._act_go_online.setText("● Gå offline")
|
||||||
|
self._init_sync()
|
||||||
else:
|
else:
|
||||||
self._conn_label.setText("● OFFLINE")
|
self._conn_label.setText("● OFFLINE")
|
||||||
self._conn_label.setStyleSheet("color: #5a6070;")
|
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):
|
def _new_playlist(self):
|
||||||
self._stop()
|
self._stop()
|
||||||
@@ -851,6 +985,12 @@ class MainWindow(QMainWindow):
|
|||||||
song = self._playlist_panel.get_song(idx)
|
song = self._playlist_panel.get_song(idx)
|
||||||
if not song:
|
if not song:
|
||||||
return
|
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._current_idx = idx
|
||||||
self._load_song(song)
|
self._load_song(song)
|
||||||
self._playlist_panel.set_current(idx)
|
self._playlist_panel.set_current(idx)
|
||||||
@@ -944,13 +1084,16 @@ class MainWindow(QMainWindow):
|
|||||||
self._btn_play.setText("▶")
|
self._btn_play.setText("▶")
|
||||||
self._vu.reset()
|
self._vu.reset()
|
||||||
|
|
||||||
|
# Synkroniser current_idx til playlist_panel
|
||||||
|
self._playlist_panel._current_idx = self._current_idx
|
||||||
|
|
||||||
# Markér den afspillede sang
|
# Markér den afspillede sang
|
||||||
self._playlist_panel.mark_played(self._current_idx)
|
self._playlist_panel.mark_played(self._current_idx)
|
||||||
|
|
||||||
# Synkroniser event-status til den gemte navngivne liste
|
# Synkroniser event-status til den gemte navngivne liste
|
||||||
self._sync_event_status_to_playlist()
|
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()
|
ni = self._playlist_panel.next_playable_idx()
|
||||||
next_song = self._playlist_panel.get_song(ni) if ni is not None else None
|
next_song = self._playlist_panel.get_song(ni) if ni is not None else None
|
||||||
if next_song:
|
if next_song:
|
||||||
@@ -959,7 +1102,6 @@ class MainWindow(QMainWindow):
|
|||||||
self._load_song(next_song)
|
self._load_song(next_song)
|
||||||
self._set_status(f"Klar: {next_song.get('title','')} — tryk ▶ for at starte")
|
self._set_status(f"Klar: {next_song.get('title','')} — tryk ▶ for at starte")
|
||||||
else:
|
else:
|
||||||
# Danseliste afsluttet — nulstil liste-markering og synkroniser
|
|
||||||
self._current_idx = -1
|
self._current_idx = -1
|
||||||
self._playlist_panel._current_idx = -1
|
self._playlist_panel._current_idx = -1
|
||||||
self._playlist_panel._song_ended = False
|
self._playlist_panel._song_ended = False
|
||||||
|
|||||||
@@ -130,6 +130,12 @@ class PlaylistBrowserDialog(QDialog):
|
|||||||
btn_tags = QPushButton("🏷 Rediger tags")
|
btn_tags = QPushButton("🏷 Rediger tags")
|
||||||
btn_tags.clicked.connect(self._edit_tags)
|
btn_tags.clicked.connect(self._edit_tags)
|
||||||
btn_row.addWidget(btn_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_row.addStretch()
|
||||||
btn_cancel = QPushButton("Annuller")
|
btn_cancel = QPushButton("Annuller")
|
||||||
@@ -344,3 +350,167 @@ class PlaylistBrowserDialog(QDialog):
|
|||||||
self._load_data()
|
self._load_data()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.warning(self, "Fejl", f"Kunne ikke slette: {e}")
|
QMessageBox.warning(self, "Fejl", f"Kunne ikke slette: {e}")
|
||||||
|
|
||||||
|
def _share_selected(self):
|
||||||
|
"""Åbn del-dialog for den valgte playliste."""
|
||||||
|
item = self._list.currentItem()
|
||||||
|
if not item:
|
||||||
|
QMessageBox.information(self, "Del", "Vælg en playliste først.")
|
||||||
|
return
|
||||||
|
pl = item.data(Qt.ItemDataRole.UserRole)
|
||||||
|
if not isinstance(pl, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Hent server-info fra settings
|
||||||
|
try:
|
||||||
|
from ui.settings_dialog import load_settings
|
||||||
|
s = load_settings()
|
||||||
|
server_url = s.get("server_url", "")
|
||||||
|
token = self._get_token()
|
||||||
|
if not token:
|
||||||
|
QMessageBox.warning(self, "Ikke logget ind",
|
||||||
|
"Du skal være logget ind for at dele.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Find server-ID for playlisten
|
||||||
|
server_id = pl.get("api_project_id")
|
||||||
|
if not server_id:
|
||||||
|
QMessageBox.warning(self, "Ikke synkroniseret",
|
||||||
|
"Synkroniser playlisten til serveren først\n"
|
||||||
|
"(Filer → Synkroniser nu).")
|
||||||
|
return
|
||||||
|
|
||||||
|
from ui.share_dialog import ShareDialog
|
||||||
|
dlg = ShareDialog(server_id, pl["name"], server_url, token,
|
||||||
|
parent=self)
|
||||||
|
dlg.exec()
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.warning(self, "Fejl", str(e))
|
||||||
|
|
||||||
|
def _get_token(self) -> str | None:
|
||||||
|
"""Hent JWT token fra main_window."""
|
||||||
|
mw = self.parent()
|
||||||
|
while mw and not hasattr(mw, "_api_token"):
|
||||||
|
mw = mw.parent()
|
||||||
|
return getattr(mw, "_api_token", None) if mw else None
|
||||||
|
|
||||||
|
def _fetch_shared(self):
|
||||||
|
"""Hent playlister der er delt med mig fra serveren."""
|
||||||
|
try:
|
||||||
|
from ui.settings_dialog import load_settings
|
||||||
|
s = load_settings()
|
||||||
|
server_url = s.get("server_url", "").rstrip("/")
|
||||||
|
token = self._get_token()
|
||||||
|
if not token:
|
||||||
|
QMessageBox.warning(self, "Ikke logget ind",
|
||||||
|
"Du skal være logget ind for at hente delte lister.")
|
||||||
|
return
|
||||||
|
|
||||||
|
import urllib.request, json
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{server_url}/sharing/playlists/shared-with-me",
|
||||||
|
headers={"Authorization": f"Bearer {token}"}
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
shared = json.loads(resp.read())
|
||||||
|
|
||||||
|
if not shared:
|
||||||
|
QMessageBox.information(self, "Ingen delte lister",
|
||||||
|
"Ingen playlister er delt med dig.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Vis valgdialog
|
||||||
|
from PyQt6.QtWidgets import QInputDialog
|
||||||
|
options = [
|
||||||
|
f"{p['name']} (af {p['owner']}, {p['song_count']} sange, {p['permission']})"
|
||||||
|
for p in shared
|
||||||
|
]
|
||||||
|
choice, ok = QInputDialog.getItem(
|
||||||
|
self, "Hent delt playliste",
|
||||||
|
"Vælg en playliste at hente:",
|
||||||
|
options, 0, False
|
||||||
|
)
|
||||||
|
if not ok:
|
||||||
|
return
|
||||||
|
|
||||||
|
idx = options.index(choice)
|
||||||
|
chosen = shared[idx]
|
||||||
|
|
||||||
|
# Hent indholdet
|
||||||
|
req2 = urllib.request.Request(
|
||||||
|
f"{server_url}/sharing/playlists/{chosen['project_id']}",
|
||||||
|
headers={"Authorization": f"Bearer {token}"}
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req2, timeout=10) as resp:
|
||||||
|
pl_data = json.loads(resp.read())
|
||||||
|
|
||||||
|
self._import_shared_playlist(pl_data, server_url, token,
|
||||||
|
permission=chosen.get("permission", "view"))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.warning(self, "Fejl", f"Kunne ikke hente: {e}")
|
||||||
|
|
||||||
|
def _import_shared_playlist(self, pl_data: dict, server_url: str, token: str,
|
||||||
|
permission: str = "view"):
|
||||||
|
"""Importer en delt playliste som en linket liste."""
|
||||||
|
import sqlite3
|
||||||
|
from local.local_db import DB_PATH, get_db, add_song_to_playlist
|
||||||
|
|
||||||
|
name = pl_data["name"]
|
||||||
|
server_id = pl_data["id"]
|
||||||
|
|
||||||
|
conn = sqlite3.connect(str(DB_PATH))
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
# Tjek om listen allerede er linket
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT id FROM playlists WHERE api_project_id=?", (server_id,)
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
# Opdater eksisterende
|
||||||
|
pl_id = existing["id"]
|
||||||
|
with get_db() as c:
|
||||||
|
c.execute("DELETE FROM playlist_songs WHERE playlist_id=?", (pl_id,))
|
||||||
|
else:
|
||||||
|
# Opret ny linket playliste
|
||||||
|
with get_db() as c:
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO playlists (name, api_project_id, is_linked, server_permission) "
|
||||||
|
"VALUES (?, ?, 1, ?)",
|
||||||
|
(name, server_id, permission)
|
||||||
|
)
|
||||||
|
pl_id = c.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||||||
|
|
||||||
|
# Indsæt sange med sang-matching
|
||||||
|
matched = 0
|
||||||
|
with get_db() as c:
|
||||||
|
for song_data in pl_data.get("songs", []):
|
||||||
|
local = c.execute(
|
||||||
|
"SELECT id FROM songs WHERE title=? AND artist=? AND file_missing=0",
|
||||||
|
(song_data["title"], song_data["artist"])
|
||||||
|
).fetchone()
|
||||||
|
if local:
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO playlist_songs "
|
||||||
|
"(playlist_id, song_id, position, status, is_workshop, dance_override) "
|
||||||
|
"VALUES (?,?,?,?,?,?)",
|
||||||
|
(pl_id, local["id"], song_data["position"],
|
||||||
|
song_data.get("status", "pending"),
|
||||||
|
1 if song_data.get("is_workshop") else 0,
|
||||||
|
song_data.get("dance_override") or "")
|
||||||
|
)
|
||||||
|
matched += 1
|
||||||
|
|
||||||
|
self._load_data()
|
||||||
|
self.playlist_selected.emit(pl_id, name)
|
||||||
|
perm_text = {"view": "se", "copy": "kopiere", "edit": "redigere"}.get(
|
||||||
|
permission, permission
|
||||||
|
)
|
||||||
|
QMessageBox.information(
|
||||||
|
self, "Linket",
|
||||||
|
f"'{name}' er nu linket til server-listen.\n"
|
||||||
|
f"Du har rettighed til at {perm_text} listen.\n\n"
|
||||||
|
f"{matched} af {len(pl_data.get('songs', []))} sange fundet lokalt."
|
||||||
|
)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ playlist_info_dialog.py — Flydende danseliste-info vindue med dynamisk opdater
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QSpinBox,
|
QWidget, QVBoxLayout, QHBoxLayout, QLabel,
|
||||||
QFrame, QGridLayout,
|
QFrame, QGridLayout,
|
||||||
)
|
)
|
||||||
from PyQt6.QtCore import Qt, pyqtSignal
|
from PyQt6.QtCore import Qt, pyqtSignal
|
||||||
@@ -22,7 +22,6 @@ def fmt_time(seconds: int) -> str:
|
|||||||
|
|
||||||
|
|
||||||
class PlaylistInfoWindow(QWidget):
|
class PlaylistInfoWindow(QWidget):
|
||||||
pause_changed = pyqtSignal(int)
|
|
||||||
|
|
||||||
def __init__(self, playlist_panel, parent=None):
|
def __init__(self, playlist_panel, parent=None):
|
||||||
super().__init__(parent,
|
super().__init__(parent,
|
||||||
@@ -83,33 +82,6 @@ class PlaylistInfoWindow(QWidget):
|
|||||||
|
|
||||||
layout.addWidget(stats)
|
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
|
# Fremgang og ETA
|
||||||
eta_frame = QFrame()
|
eta_frame = QFrame()
|
||||||
eta_frame.setObjectName("track_display")
|
eta_frame.setObjectName("track_display")
|
||||||
@@ -131,26 +103,14 @@ class PlaylistInfoWindow(QWidget):
|
|||||||
|
|
||||||
layout.addWidget(eta_frame)
|
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):
|
def _update(self):
|
||||||
songs = self._panel.get_songs()
|
songs = self._panel.get_songs()
|
||||||
statuses = self._panel.get_statuses()
|
statuses = self._panel.get_statuses()
|
||||||
total = len(songs)
|
total = len(songs)
|
||||||
played = statuses.count("played")
|
played = statuses.count("played")
|
||||||
skipped = statuses.count("skipped")
|
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_total = sum(1 for s in songs if s.get("is_workshop"))
|
||||||
ws_remain = sum(1 for s, st in zip(songs, statuses)
|
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_eta.setText("✓ Danselisten er afsluttet!")
|
||||||
self._lbl_finish.setText("")
|
self._lbl_finish.setText("")
|
||||||
elif total > 0:
|
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(
|
self._lbl_eta.setText(
|
||||||
f"{pct}% færdig · {fmt_time(remain_time)} tilbage"
|
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)
|
finish = datetime.now() + timedelta(seconds=remain_time)
|
||||||
self._lbl_finish.setText(f"Estimeret sluttid: {finish.strftime('%H:%M')}")
|
self._lbl_finish.setText(f"Estimeret sluttid: {finish.strftime('%H:%M')}")
|
||||||
|
|||||||
@@ -289,9 +289,11 @@ class PlaylistPanel(QWidget):
|
|||||||
return self._named_playlist_id
|
return self._named_playlist_id
|
||||||
|
|
||||||
def next_playable_idx(self) -> int | None:
|
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)):
|
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 i
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -303,25 +305,42 @@ class PlaylistPanel(QWidget):
|
|||||||
self._lbl_autosave.setText("● ikke gemt")
|
self._lbl_autosave.setText("● ikke gemt")
|
||||||
|
|
||||||
def _autosave(self):
|
def _autosave(self):
|
||||||
"""Gem til den faste 'Aktiv liste' i SQLite."""
|
"""Gem til '__aktiv__' OG til den navngivne liste hvis der er én."""
|
||||||
try:
|
try:
|
||||||
from local.local_db import get_db, create_playlist, add_song_to_playlist
|
from local.local_db import get_db, create_playlist, add_song_to_playlist
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
# Slet den gamle aktive liste
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"DELETE FROM playlists WHERE name=?", (ACTIVE_PLAYLIST_NAME,)
|
"DELETE FROM playlists WHERE name=?", (ACTIVE_PLAYLIST_NAME,)
|
||||||
)
|
)
|
||||||
# Opret ny
|
|
||||||
pl_id = create_playlist(ACTIVE_PLAYLIST_NAME)
|
pl_id = create_playlist(ACTIVE_PLAYLIST_NAME)
|
||||||
self._active_playlist_id = pl_id
|
self._active_playlist_id = pl_id
|
||||||
for i, song in enumerate(self._songs, start=1):
|
for i, song in enumerate(self._songs, start=1):
|
||||||
if song.get("id"):
|
if song.get("id"):
|
||||||
add_song_to_playlist(pl_id, song["id"], position=i)
|
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._lbl_autosave.setText("✓ gemt")
|
||||||
self.playlist_changed.emit()
|
self.playlist_changed.emit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._lbl_autosave.setText(f"⚠ gemfejl")
|
self._lbl_autosave.setText("⚠ gemfejl")
|
||||||
pass
|
|
||||||
|
|
||||||
def _save_named_playlist_id(self, pl_id: int | None):
|
def _save_named_playlist_id(self, pl_id: int | None):
|
||||||
"""Gem hvilken navngiven liste der er aktiv — til brug ved næste opstart."""
|
"""Gem hvilken navngiven liste der er aktiv — til brug ved næste opstart."""
|
||||||
@@ -374,6 +393,21 @@ class PlaylistPanel(QWidget):
|
|||||||
dance_names = [d["name"] for d in dances]
|
dance_names = [d["name"] for d in dances]
|
||||||
override = row["dance_override"] or ""
|
override = row["dance_override"] or ""
|
||||||
active_dance = override if override else (dance_names[0] if dance_names else "")
|
active_dance = override if override else (dance_names[0] if dance_names else "")
|
||||||
|
|
||||||
|
local_path = row["local_path"]
|
||||||
|
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({
|
songs.append({
|
||||||
"id": row["id"],
|
"id": row["id"],
|
||||||
"title": row["title"],
|
"title": row["title"],
|
||||||
@@ -381,9 +415,9 @@ class PlaylistPanel(QWidget):
|
|||||||
"album": row["album"],
|
"album": row["album"],
|
||||||
"bpm": row["bpm"],
|
"bpm": row["bpm"],
|
||||||
"duration_sec": row["duration_sec"],
|
"duration_sec": row["duration_sec"],
|
||||||
"local_path": row["local_path"],
|
"local_path": local_path,
|
||||||
"file_format": row["file_format"],
|
"file_format": row["file_format"],
|
||||||
"file_missing": bool(row["file_missing"]),
|
"file_missing": file_missing,
|
||||||
"dances": dance_names,
|
"dances": dance_names,
|
||||||
"active_dance": active_dance,
|
"active_dance": active_dance,
|
||||||
"is_workshop": bool(row["is_workshop"]),
|
"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._btn_save_current.setToolTip(f"Gem ændringer til '{pl['name']}'")
|
||||||
self._title_label.setText(f"DANSELISTE — {pl['name'].upper()}")
|
self._title_label.setText(f"DANSELISTE — {pl['name'].upper()}")
|
||||||
self._lbl_autosave.setText("✓ gendannet")
|
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()
|
ni = self.next_playable_idx()
|
||||||
if ni is not None:
|
if ni is not None:
|
||||||
self._current_idx = ni
|
self._current_idx = ni
|
||||||
self._refresh()
|
self._statuses[ni] = "playing"
|
||||||
self.next_song_ready.emit(self._songs[ni])
|
|
||||||
|
|
||||||
|
self._refresh()
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -479,10 +512,28 @@ class PlaylistPanel(QWidget):
|
|||||||
status = self._statuses[i-1] if i-1 < len(self._statuses) else "pending"
|
status = self._statuses[i-1] if i-1 < len(self._statuses) else "pending"
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO playlist_songs "
|
"INSERT INTO playlist_songs "
|
||||||
"(playlist_id, song_id, position, status) VALUES (?,?,?,?)",
|
"(playlist_id, song_id, position, status, is_workshop, dance_override) "
|
||||||
(self._named_playlist_id, song["id"], i, status)
|
"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._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:
|
except Exception as e:
|
||||||
QMessageBox.warning(self, "Fejl", f"Kunne ikke gemme: {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):
|
def _load_playlist_by_id(self, pl_id: int, pl_name: str):
|
||||||
try:
|
try:
|
||||||
from local.local_db import get_db
|
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:
|
with get_db() as conn:
|
||||||
songs_raw = conn.execute("""
|
songs_raw = conn.execute("""
|
||||||
SELECT s.*, ps.position, ps.status,
|
SELECT s.*, ps.position, ps.status,
|
||||||
@@ -505,6 +572,7 @@ class PlaylistPanel(QWidget):
|
|||||||
""", (pl_id,)).fetchall()
|
""", (pl_id,)).fetchall()
|
||||||
songs = []
|
songs = []
|
||||||
statuses = []
|
statuses = []
|
||||||
|
repaired = 0
|
||||||
for row in songs_raw:
|
for row in songs_raw:
|
||||||
dances = conn.execute("""
|
dances = conn.execute("""
|
||||||
SELECT d.name FROM song_dances sd
|
SELECT d.name FROM song_dances sd
|
||||||
@@ -512,29 +580,64 @@ class PlaylistPanel(QWidget):
|
|||||||
WHERE sd.song_id=? ORDER BY sd.dance_order
|
WHERE sd.song_id=? ORDER BY sd.dance_order
|
||||||
""", (row["id"],)).fetchall()
|
""", (row["id"],)).fetchall()
|
||||||
dance_names = [d["name"] for d in dances]
|
dance_names = [d["name"] for d in dances]
|
||||||
# dance_override bestemmer hvilken dans der vises
|
|
||||||
override = row["dance_override"] or ""
|
override = row["dance_override"] or ""
|
||||||
active_dance = override if override else (dance_names[0] if dance_names else "")
|
active_dance = override if override else (dance_names[0] if dance_names else "")
|
||||||
|
|
||||||
|
local_path = row["local_path"]
|
||||||
|
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({
|
songs.append({
|
||||||
"id": row["id"], "title": row["title"],
|
"id": row["id"],
|
||||||
"artist": row["artist"], "album": row["album"],
|
"title": row["title"],
|
||||||
"bpm": row["bpm"], "duration_sec": row["duration_sec"],
|
"artist": row["artist"],
|
||||||
"local_path": row["local_path"], "file_format": row["file_format"],
|
"album": row["album"],
|
||||||
"file_missing": bool(row["file_missing"]),
|
"bpm": row["bpm"],
|
||||||
|
"duration_sec": row["duration_sec"],
|
||||||
|
"local_path": local_path,
|
||||||
|
"file_format": row["file_format"],
|
||||||
|
"file_missing": file_missing,
|
||||||
"dances": dance_names,
|
"dances": dance_names,
|
||||||
"active_dance": active_dance,
|
"active_dance": active_dance,
|
||||||
"is_workshop": bool(row["is_workshop"]),
|
"is_workshop": bool(row["is_workshop"]),
|
||||||
})
|
})
|
||||||
statuses.append(row["status"] or "pending")
|
statuses.append(row["status"] or "pending")
|
||||||
|
|
||||||
self._songs = songs
|
self._songs = songs
|
||||||
self._statuses = statuses
|
self._statuses = statuses
|
||||||
self._current_idx = -1
|
self._current_idx = -1
|
||||||
self._song_ended = False
|
self._song_ended = False
|
||||||
self._named_playlist_id = pl_id
|
self._named_playlist_id = pl_id
|
||||||
self._title_label.setText(f"DANSELISTE — {pl_name.upper()}")
|
|
||||||
self._lbl_autosave.setText("✓ gendannet")
|
# Vis link-indikator i titlen
|
||||||
self._btn_save_current.setEnabled(True)
|
is_linked = pl_meta and pl_meta["is_linked"]
|
||||||
self._btn_save_current.setToolTip(f"Gem ændringer til '{pl_name}'")
|
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._save_named_playlist_id(pl_id)
|
||||||
self._refresh()
|
self._refresh()
|
||||||
self._trigger_autosave()
|
self._trigger_autosave()
|
||||||
@@ -628,6 +731,98 @@ class PlaylistPanel(QWidget):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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):
|
def _on_pause_changed(self, seconds: int):
|
||||||
self._pause_seconds = seconds
|
self._pause_seconds = seconds
|
||||||
|
|
||||||
@@ -642,7 +837,7 @@ class PlaylistPanel(QWidget):
|
|||||||
if reply == QMessageBox.StandardButton.Yes:
|
if reply == QMessageBox.StandardButton.Yes:
|
||||||
self._statuses = ["pending"] * len(self._songs)
|
self._statuses = ["pending"] * len(self._songs)
|
||||||
self._current_idx = -1
|
self._current_idx = -1
|
||||||
self._song_ended = True
|
self._song_ended = False
|
||||||
try:
|
try:
|
||||||
from local.local_db import clear_event_state
|
from local.local_db import clear_event_state
|
||||||
clear_event_state()
|
clear_event_state()
|
||||||
@@ -650,6 +845,12 @@ class PlaylistPanel(QWidget):
|
|||||||
pass
|
pass
|
||||||
self._refresh()
|
self._refresh()
|
||||||
self._scroll_to(0)
|
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()
|
self.event_started.emit()
|
||||||
|
|
||||||
# ── Højreklik ─────────────────────────────────────────────────────────────
|
# ── Højreklik ─────────────────────────────────────────────────────────────
|
||||||
@@ -718,9 +919,25 @@ class PlaylistPanel(QWidget):
|
|||||||
self._list.clear()
|
self._list.clear()
|
||||||
played = sum(1 for s in self._statuses if s == "played")
|
played = sum(1 for s in self._statuses if s == "played")
|
||||||
self._lbl_progress.setText(f"{played} / {len(self._songs)} afspillet")
|
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):
|
for i, song in enumerate(self._songs):
|
||||||
is_current = (i == self._current_idx and not self._song_ended)
|
is_current = (i == self._current_idx and not self._song_ended)
|
||||||
status = "playing" if is_current else self._statuses[i]
|
is_next = (i == next_idx and not is_current)
|
||||||
|
if is_current:
|
||||||
|
status = "playing"
|
||||||
|
elif is_next:
|
||||||
|
status = "next"
|
||||||
|
else:
|
||||||
|
status = self._statuses[i]
|
||||||
icon = self.STATUS_ICON.get(status, " ")
|
icon = self.STATUS_ICON.get(status, " ")
|
||||||
|
|
||||||
# Vis active_dance (override eller første dans) eller alle danse
|
# Vis active_dance (override eller første dans) eller alle danse
|
||||||
@@ -737,6 +954,9 @@ class PlaylistPanel(QWidget):
|
|||||||
if status == "playing":
|
if status == "playing":
|
||||||
item.setForeground(QColor(self.STATUS_COLOR["playing"]))
|
item.setForeground(QColor(self.STATUS_COLOR["playing"]))
|
||||||
f = item.font(); f.setBold(True); item.setFont(f)
|
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":
|
elif status == "played":
|
||||||
item.setForeground(QColor("#2ecc71"))
|
item.setForeground(QColor("#2ecc71"))
|
||||||
elif status == "skipped":
|
elif status == "skipped":
|
||||||
|
|||||||
@@ -78,11 +78,30 @@ class SettingsDialog(QDialog):
|
|||||||
layout.setSpacing(12)
|
layout.setSpacing(12)
|
||||||
|
|
||||||
tabs = QTabWidget()
|
tabs = QTabWidget()
|
||||||
tabs.addTab(self._build_appearance_tab(), "🎨 Udseende")
|
tabs.setStyleSheet("""
|
||||||
tabs.addTab(self._build_playback_tab(), "▶ Afspilning")
|
QTabBar::tab {
|
||||||
tabs.addTab(self._build_mail_tab(), "✉ Mail")
|
padding: 6px 14px;
|
||||||
tabs.addTab(self._build_online_tab(), "🌐 Online")
|
font-size: 13px;
|
||||||
tabs.addTab(self._build_language_tab(), "🌍 Sprog")
|
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)
|
layout.addWidget(tabs)
|
||||||
|
|
||||||
# Knapper
|
# Knapper
|
||||||
|
|||||||
192
linedance-app/ui/share_dialog.py
Normal file
192
linedance-app/ui/share_dialog.py
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
"""
|
||||||
|
share_dialog.py — Del en playliste med andre brugere eller sæt den public.
|
||||||
|
"""
|
||||||
|
from PyQt6.QtWidgets import (
|
||||||
|
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
|
||||||
|
QPushButton, QComboBox, QFrame, QListWidget, QListWidgetItem,
|
||||||
|
QMessageBox,
|
||||||
|
)
|
||||||
|
from PyQt6.QtCore import Qt
|
||||||
|
|
||||||
|
|
||||||
|
class ShareDialog(QDialog):
|
||||||
|
def __init__(self, playlist_id: str, playlist_name: str,
|
||||||
|
server_url: str, token: str, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._playlist_id = playlist_id
|
||||||
|
self._playlist_name = playlist_name
|
||||||
|
self._server_url = server_url.rstrip("/")
|
||||||
|
self._token = token
|
||||||
|
|
||||||
|
self.setWindowTitle(f"Del — {playlist_name}")
|
||||||
|
self.setMinimumWidth(480)
|
||||||
|
self._build_ui()
|
||||||
|
self._load_shares()
|
||||||
|
self._load_visibility()
|
||||||
|
|
||||||
|
def _headers(self):
|
||||||
|
return {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {self._token}",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _build_ui(self):
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(12, 12, 12, 12)
|
||||||
|
layout.setSpacing(10)
|
||||||
|
|
||||||
|
# Synlighed
|
||||||
|
vis_frame = QFrame()
|
||||||
|
vis_frame.setObjectName("track_display")
|
||||||
|
vis_layout = QHBoxLayout(vis_frame)
|
||||||
|
vis_layout.setContentsMargins(10, 8, 10, 8)
|
||||||
|
vis_layout.addWidget(QLabel("Synlighed:"))
|
||||||
|
self._vis_combo = QComboBox()
|
||||||
|
self._vis_combo.addItem("🔒 Privat (kun mig)", "private")
|
||||||
|
self._vis_combo.addItem("👥 Delt (inviterede)", "shared")
|
||||||
|
self._vis_combo.addItem("🌐 Public (alle kan se)", "public")
|
||||||
|
vis_layout.addWidget(self._vis_combo, stretch=1)
|
||||||
|
btn_vis = QPushButton("Gem")
|
||||||
|
btn_vis.setFixedHeight(28)
|
||||||
|
btn_vis.clicked.connect(self._set_visibility)
|
||||||
|
vis_layout.addWidget(btn_vis)
|
||||||
|
layout.addWidget(vis_frame)
|
||||||
|
|
||||||
|
# Invitér bruger
|
||||||
|
inv_frame = QFrame()
|
||||||
|
inv_frame.setObjectName("track_display")
|
||||||
|
inv_layout = QVBoxLayout(inv_frame)
|
||||||
|
inv_layout.setContentsMargins(10, 8, 10, 8)
|
||||||
|
inv_layout.setSpacing(6)
|
||||||
|
inv_layout.addWidget(QLabel("Invitér via e-mail:"))
|
||||||
|
|
||||||
|
row = QHBoxLayout()
|
||||||
|
self._email_input = QLineEdit()
|
||||||
|
self._email_input.setPlaceholderText("bruger@eksempel.dk")
|
||||||
|
row.addWidget(self._email_input)
|
||||||
|
self._perm_combo = QComboBox()
|
||||||
|
self._perm_combo.addItem("Se", "view")
|
||||||
|
self._perm_combo.addItem("Kopiere", "copy")
|
||||||
|
self._perm_combo.addItem("Redigere","edit")
|
||||||
|
self._perm_combo.setFixedWidth(90)
|
||||||
|
row.addWidget(self._perm_combo)
|
||||||
|
btn_inv = QPushButton("Invitér")
|
||||||
|
btn_inv.setFixedHeight(28)
|
||||||
|
btn_inv.clicked.connect(self._invite)
|
||||||
|
row.addWidget(btn_inv)
|
||||||
|
inv_layout.addLayout(row)
|
||||||
|
layout.addWidget(inv_frame)
|
||||||
|
|
||||||
|
# Liste over delinger
|
||||||
|
lbl = QLabel("Delt med:")
|
||||||
|
lbl.setObjectName("track_meta")
|
||||||
|
layout.addWidget(lbl)
|
||||||
|
|
||||||
|
self._shares_list = QListWidget()
|
||||||
|
self._shares_list.setMaximumHeight(150)
|
||||||
|
layout.addWidget(self._shares_list)
|
||||||
|
|
||||||
|
btn_remove = QPushButton("✕ Fjern valgt deling")
|
||||||
|
btn_remove.clicked.connect(self._remove_share)
|
||||||
|
layout.addWidget(btn_remove)
|
||||||
|
|
||||||
|
self._status = QLabel("")
|
||||||
|
self._status.setObjectName("result_count")
|
||||||
|
self._status.setWordWrap(True)
|
||||||
|
layout.addWidget(self._status)
|
||||||
|
|
||||||
|
btn_close = QPushButton("Luk")
|
||||||
|
btn_close.clicked.connect(self.accept)
|
||||||
|
layout.addWidget(btn_close)
|
||||||
|
|
||||||
|
def _load_visibility(self):
|
||||||
|
try:
|
||||||
|
import urllib.request, json
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{self._server_url}/sharing/playlists/{self._playlist_id}",
|
||||||
|
headers=self._headers()
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||||
|
data = json.loads(resp.read())
|
||||||
|
vis = data.get("visibility", "private")
|
||||||
|
for i in range(self._vis_combo.count()):
|
||||||
|
if self._vis_combo.itemData(i) == vis:
|
||||||
|
self._vis_combo.setCurrentIndex(i)
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _load_shares(self):
|
||||||
|
try:
|
||||||
|
import urllib.request, json
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{self._server_url}/sharing/playlists/{self._playlist_id}/shares",
|
||||||
|
headers=self._headers()
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||||
|
shares = json.loads(resp.read())
|
||||||
|
self._shares_list.clear()
|
||||||
|
for s in shares:
|
||||||
|
perm = {"view": "Se", "copy": "Kopiere", "edit": "Redigere"}.get(
|
||||||
|
s["permission"], s["permission"]
|
||||||
|
)
|
||||||
|
accepted = "✓" if s["accepted"] else "⏳"
|
||||||
|
item = QListWidgetItem(f"{accepted} {s['email']} — {perm}")
|
||||||
|
item.setData(Qt.ItemDataRole.UserRole, s["id"])
|
||||||
|
self._shares_list.addItem(item)
|
||||||
|
except Exception as e:
|
||||||
|
self._status.setText(f"Kunne ikke hente delinger: {e}")
|
||||||
|
|
||||||
|
def _set_visibility(self):
|
||||||
|
vis = self._vis_combo.currentData()
|
||||||
|
try:
|
||||||
|
import urllib.request, json
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{self._server_url}/sharing/playlists/{self._playlist_id}/visibility?visibility={vis}",
|
||||||
|
data=b"",
|
||||||
|
headers=self._headers(),
|
||||||
|
method="PATCH"
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||||
|
json.loads(resp.read())
|
||||||
|
self._status.setText(f"✓ Synlighed sat til {self._vis_combo.currentText()}")
|
||||||
|
except Exception as e:
|
||||||
|
self._status.setText(f"⚠ Fejl: {e}")
|
||||||
|
|
||||||
|
def _invite(self):
|
||||||
|
email = self._email_input.text().strip()
|
||||||
|
perm = self._perm_combo.currentData()
|
||||||
|
if not email or "@" not in email:
|
||||||
|
self._status.setText("⚠ Ugyldig e-mailadresse")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
import urllib.request, json
|
||||||
|
data = json.dumps({"email": email, "permission": perm}).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{self._server_url}/sharing/playlists/{self._playlist_id}/share",
|
||||||
|
data=data, headers=self._headers(), method="POST"
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||||
|
json.loads(resp.read())
|
||||||
|
self._email_input.clear()
|
||||||
|
self._status.setText(f"✓ Invitation sendt til {email}")
|
||||||
|
self._load_shares()
|
||||||
|
except Exception as e:
|
||||||
|
self._status.setText(f"⚠ Fejl: {e}")
|
||||||
|
|
||||||
|
def _remove_share(self):
|
||||||
|
item = self._shares_list.currentItem()
|
||||||
|
if not item:
|
||||||
|
return
|
||||||
|
share_id = item.data(Qt.ItemDataRole.UserRole)
|
||||||
|
try:
|
||||||
|
import urllib.request
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{self._server_url}/sharing/playlists/{self._playlist_id}/share/{share_id}",
|
||||||
|
headers=self._headers(), method="DELETE"
|
||||||
|
)
|
||||||
|
urllib.request.urlopen(req, timeout=8)
|
||||||
|
self._status.setText("✓ Deling fjernet")
|
||||||
|
self._load_shares()
|
||||||
|
except Exception as e:
|
||||||
|
self._status.setText(f"⚠ Fejl: {e}")
|
||||||
@@ -79,6 +79,25 @@ QSlider::handle:horizontal {
|
|||||||
border-radius: 6px;
|
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 */
|
/* Lister */
|
||||||
QListWidget {
|
QListWidget {
|
||||||
background-color: #1a1c1f;
|
background-color: #1a1c1f;
|
||||||
|
|||||||
Reference in New Issue
Block a user