""" 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 = "" mbid: str = "" acoustid: 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 song_title: str = "" song_artist: 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] = [] deleted_playlists: list[str] = [] # server-IDs (api_project_id) på slettede playlister # ── 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 1: MBID — sikrest existing = None if s.mbid: existing = db.query(Song).filter_by(mbid=s.mbid).first() # Match 2: titel+artist globalt if not existing: existing = db.query(Song).filter( Song.title == s.title, Song.artist == s.artist, ).first() if existing: song_id_map[s.local_id] = existing.id # Opdater BPM og MBID hvis de mangler if s.bpm and not existing.bpm: existing.bpm = s.bpm if s.mbid and not existing.mbid: existing.mbid = s.mbid if s.acoustid and not existing.acoustid: existing.acoustid = s.acoustid else: song = Song( owner_id=me.id, title=s.title, artist=s.artist, album=s.album, bpm=s.bpm, duration_sec=s.duration_sec, file_format=s.file_format, mbid=s.mbid or None, acoustid=s.acoustid or None, ) 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 # ── Sang-dans tags (brugerens egne) ─────────────────────────────────────── from app.models import SongDance, SongAltDance # ── Sang-dans tags ──────────────────────────────────────────────────────── from app.models import SongDance, SongAltDance import sqlalchemy as _sa for sd in payload.song_dances: song_id = song_id_map.get(sd.song_local_id) if not song_id: continue 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 db.execute(_sa.text( "INSERT IGNORE INTO song_dances (id, song_id, dance_id, dance_order) " "VALUES (:id, :song_id, :dance_id, :dance_order)" ), { "id": str(__import__("uuid").uuid4()), "song_id": song_id, "dance_id": dance_id, "dance_order": sd.dance_order, }) for sa in payload.song_alts: song_id = song_id_map.get(sa.song_local_id) if not song_id: continue level_id = level_map.get(sa.level_name.lower()) if sa.level_name else None key = f"{sa.dance_name.lower()}|{level_id}" dance_id = dance_id_map.get(key) if not dance_id: continue db.execute(_sa.text( "INSERT IGNORE INTO song_alt_dances (id, song_id, dance_id, note) " "VALUES (:id, :song_id, :dance_id, :note)" ), { "id": str(__import__("uuid").uuid4()), "song_id": song_id, "dance_id": dance_id, "note": sa.note or "", }) # ── 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 # Opdater kun sange hvis push faktisk har sange med if pl.songs: 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: # Prøv først via song_id_map (lokal ID) song_id = song_id_map.get(ps.song_local_id) # Fallback: match på titel+artist if not song_id and ps.song_title: existing_song = db.query(Song).filter_by( title=ps.song_title, artist=ps.song_artist ).first() if existing_song: song_id = existing_song.id 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) # ── Slet playlister der er fjernet lokalt ───────────────────────────────── # Klienten sender api_project_id (= server Project.id) som strings for project_id in payload.deleted_playlists: proj = db.query(Project).filter_by(id=project_id, owner_id=me.id).first() if proj: db.query(ProjectSong).filter_by(project_id=proj.id).delete() db.delete(proj) 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 (read-only — kun ejeren kan redigere) shared_ids = set() for s in db.query(PlaylistShare).filter( (PlaylistShare.shared_with_id == me.id) | (PlaylistShare.invited_email == me.email) ).all(): shared_ids.add(s.project_id) shared = [] for p in db.query(Project).filter(Project.id.in_(shared_ids)).all(): if p.owner_id == me.id: continue # Egne lister håndteres separat owner = db.query(User).filter_by(id=p.owner_id).first() songs_out = [] for ps in p.project_songs: song = db.query(Song).filter_by(id=ps.song_id).first() if not song: continue songs_out.append({ "title": song.title, "artist": song.artist, "position": ps.position, "status": ps.status, "is_workshop": ps.is_workshop, "dance_override": ps.dance_override or "", }) shared.append({ "server_id": p.id, "name": p.name, "owner": owner.username if owner else "?", "songs": sorted(songs_out, key=lambda x: x["position"]), }) # Egne playlister my_playlists = [] all_projects = db.query(Project).filter_by(owner_id=me.id).all() import logging logging.getLogger(__name__).info(f"Pull: fandt {len(all_projects)} projekter for {me.id}") for p in all_projects: songs_out = [] for ps in p.project_songs: song = db.query(Song).filter_by(id=ps.song_id).first() if not song: continue songs_out.append({ "title": song.title, "artist": song.artist, "position": ps.position, "status": ps.status, "is_workshop": ps.is_workshop, "dance_override": ps.dance_override or "", }) my_playlists.append({ "server_id": p.id, "name": p.name, "description": p.description or "", "songs": sorted(songs_out, key=lambda x: x["position"]), }) # Brugerens egne dans-tags from app.models import SongDance, SongAltDance song_tags = [] for sd in db.query(SongDance).join(Song).filter(Song.owner_id == me.id).all(): dance = db.query(Dance).filter_by(id=sd.dance_id).first() if not dance: continue level = db.query(DanceLevel).filter_by(id=dance.level_id).first() if dance.level_id else None song_tags.append({ "song_title": sd.song.title, "song_artist": sd.song.artist, "dance_name": dance.name, "level_name": level.name if level else "", "dance_order": sd.dance_order, }) return { "levels": levels, "dances": dances, "community": community, "shared": shared, "my_playlists": my_playlists, "song_tags": song_tags, }