394 lines
15 KiB
Python
394 lines
15 KiB
Python
"""
|
|
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 ────────────────────────────────────────────────────────────
|
|
# VIGTIGT: Match altid på local_id (= api_project_id på klienten),
|
|
# aldrig på navn — navn er ikke unikt og giver duplikater.
|
|
playlist_id_map = {}
|
|
for pl in payload.playlists:
|
|
# Prøv først at finde via server-ID (local_id er klientens lokale db-id
|
|
# som tidligere er returneret som server-ID via playlist_id_map)
|
|
existing = None
|
|
if pl.local_id:
|
|
existing = db.query(Project).filter_by(
|
|
id=pl.local_id, owner_id=me.id
|
|
).first()
|
|
# Fallback: navn — kun hvis vi aldrig har set denne liste før
|
|
if not existing:
|
|
existing = db.query(Project).filter_by(
|
|
owner_id=me.id, name=pl.name
|
|
).first()
|
|
if existing:
|
|
existing.name = pl.name
|
|
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,
|
|
} |