409 lines
15 KiB
Python
409 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
|
|
"""
|
|
import uuid
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
from fastapi import APIRouter, Depends
|
|
from sqlalchemy.orm import Session
|
|
from pydantic import BaseModel
|
|
|
|
from app.core.database import get_db
|
|
from app.core.security import get_current_user
|
|
from app.models import (
|
|
User, Song, Dance, DanceLevel, Project, ProjectSong,
|
|
PlaylistShare, CommunityDance, SongDance, SongAltDance,
|
|
)
|
|
|
|
router = APIRouter(prefix="/sync", tags=["sync"])
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ── Schemas ───────────────────────────────────────────────────────────────────
|
|
|
|
class SongData(BaseModel):
|
|
local_id: str
|
|
title: str
|
|
artist: str = ""
|
|
album: str = ""
|
|
bpm: int = 0
|
|
duration_sec: int = 0
|
|
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 (Project.id)
|
|
|
|
|
|
# ── Hjælpefunktion: find eller opret sang globalt ─────────────────────────────
|
|
|
|
def _find_or_create_song(db: Session, title: str, artist: str = "",
|
|
mbid: str = "", acoustid: str = "",
|
|
album: str = "", bpm: int = 0,
|
|
duration_sec: int = 0) -> Song:
|
|
"""
|
|
Match-hierarki:
|
|
1. MBID — sikreste
|
|
2. AcoustID
|
|
3. Titel + artist
|
|
4. Opret ny
|
|
"""
|
|
if mbid:
|
|
song = db.query(Song).filter_by(mbid=mbid).first()
|
|
if song:
|
|
return song
|
|
|
|
if acoustid:
|
|
song = db.query(Song).filter_by(acoustid=acoustid).first()
|
|
if song:
|
|
# Tilføj mbid hvis den mangler
|
|
if mbid and not song.mbid:
|
|
song.mbid = mbid
|
|
return song
|
|
|
|
if title:
|
|
song = db.query(Song).filter(
|
|
Song.title == title,
|
|
Song.artist == artist,
|
|
).first()
|
|
if song:
|
|
# Opdater med bedre data hvis tilgængeligt
|
|
if mbid and not song.mbid:
|
|
song.mbid = mbid
|
|
if acoustid and not song.acoustid:
|
|
song.acoustid = acoustid
|
|
if bpm and not song.bpm:
|
|
song.bpm = bpm
|
|
return song
|
|
|
|
# Opret ny global sang
|
|
song = Song(
|
|
title=title, artist=artist, album=album,
|
|
bpm=bpm, duration_sec=duration_sec,
|
|
mbid=mbid or None,
|
|
acoustid=acoustid or None,
|
|
)
|
|
db.add(song)
|
|
db.flush()
|
|
return song
|
|
|
|
|
|
# ── 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."""
|
|
import sqlalchemy as _sa
|
|
|
|
song_id_map = {} # local_id → server Song.id
|
|
dance_id_map = {} # "name|level_id" → Dance.id
|
|
level_map = {} # level_name.lower() → DanceLevel.id
|
|
|
|
# ── Dans-niveauer ─────────────────────────────────────────────────────────
|
|
for lvl in db.query(DanceLevel).all():
|
|
level_map[lvl.name.lower()] = lvl.id
|
|
|
|
# ── Sange (globale) ───────────────────────────────────────────────────────
|
|
for s in payload.songs:
|
|
if not s.title:
|
|
continue
|
|
song = _find_or_create_song(
|
|
db, s.title, s.artist,
|
|
mbid=s.mbid, acoustid=s.acoustid,
|
|
album=s.album, bpm=s.bpm, duration_sec=s.duration_sec,
|
|
)
|
|
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:
|
|
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
|
|
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 — synkroniser fuldt per sang ──────────────────────────
|
|
# Slet eksisterende tags for sange der er med i push, genindsæt fra klient
|
|
synced_song_ids = set()
|
|
for sd in payload.song_dances:
|
|
song_id = song_id_map.get(sd.song_local_id)
|
|
if not song_id:
|
|
continue
|
|
if song_id not in synced_song_ids:
|
|
db.execute(_sa.text("DELETE FROM song_dances WHERE song_id=:sid"),
|
|
{"sid": song_id})
|
|
synced_song_ids.add(song_id)
|
|
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(uuid.uuid4()), "song_id": song_id,
|
|
"dance_id": dance_id, "dance_order": sd.dance_order})
|
|
|
|
# Sange pushet uden dans-tags — slet også på server
|
|
sent_local_ids = {sd.song_local_id for sd in payload.song_dances}
|
|
for local_id, song_id in song_id_map.items():
|
|
if local_id in sent_local_ids and song_id not in synced_song_ids:
|
|
db.execute(_sa.text("DELETE FROM song_dances WHERE song_id=:sid"),
|
|
{"sid": song_id})
|
|
|
|
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(uuid.uuid4()), "song_id": song_id,
|
|
"dance_id": dance_id, "note": sa.note or ""})
|
|
|
|
# ── Playlister ────────────────────────────────────────────────────────────
|
|
playlist_id_map = {}
|
|
for pl in payload.playlists:
|
|
# Find eksisterende via server-ID (local_id er api_project_id på klienten)
|
|
existing = None
|
|
if pl.local_id:
|
|
existing = db.query(Project).filter_by(
|
|
id=pl.local_id, owner_id=me.id
|
|
).first()
|
|
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
|
|
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:
|
|
# Find sang via song_id_map eller titel+artist
|
|
song_id = song_id_map.get(ps.song_local_id)
|
|
if not song_id and ps.song_title:
|
|
song = _find_or_create_song(db, ps.song_title, ps.song_artist)
|
|
song_id = song.id
|
|
if not song_id:
|
|
continue
|
|
db.add(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,
|
|
))
|
|
|
|
# ── Slet playlister ───────────────────────────────────────────────────────
|
|
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": {k: str(v) for k, v in song_id_map.items()},
|
|
"playlist_id_map": {k: str(v) for k, v in playlist_id_map.items()},
|
|
}
|
|
|
|
|
|
# ── 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
|
|
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()
|
|
]
|
|
|
|
# Delte playlister
|
|
shared_ids = {
|
|
s.project_id for s in db.query(PlaylistShare).filter(
|
|
(PlaylistShare.shared_with_id == me.id) |
|
|
(PlaylistShare.invited_email == me.email)
|
|
).all()
|
|
}
|
|
|
|
shared = []
|
|
for p in db.query(Project).filter(Project.id.in_(shared_ids)).all():
|
|
if p.owner_id == me.id:
|
|
continue
|
|
owner = db.query(User).filter_by(id=p.owner_id).first()
|
|
shared.append({
|
|
"server_id": p.id,
|
|
"name": p.name,
|
|
"owner": owner.username if owner else "?",
|
|
"songs": [
|
|
{
|
|
"song_id": str(ps.song_id),
|
|
"title": ps.song.title,
|
|
"artist": ps.song.artist,
|
|
"mbid": ps.song.mbid or "",
|
|
"acoustid": ps.song.acoustid or "",
|
|
"bpm": ps.song.bpm,
|
|
"duration_sec": ps.song.duration_sec,
|
|
"position": ps.position,
|
|
"status": ps.status,
|
|
"is_workshop": ps.is_workshop,
|
|
"dance_override": ps.dance_override or "",
|
|
}
|
|
for ps in sorted(p.project_songs, key=lambda x: x.position)
|
|
if ps.song
|
|
],
|
|
})
|
|
|
|
# Egne playlister
|
|
my_playlists = []
|
|
for p in db.query(Project).filter_by(owner_id=me.id).all():
|
|
my_playlists.append({
|
|
"server_id": p.id,
|
|
"name": p.name,
|
|
"description": p.description or "",
|
|
"songs": [
|
|
{
|
|
"song_id": str(ps.song_id),
|
|
"title": ps.song.title,
|
|
"artist": ps.song.artist,
|
|
"mbid": ps.song.mbid or "",
|
|
"acoustid": ps.song.acoustid or "",
|
|
"bpm": ps.song.bpm,
|
|
"duration_sec": ps.song.duration_sec,
|
|
"position": ps.position,
|
|
"status": ps.status,
|
|
"is_workshop": ps.is_workshop,
|
|
"dance_override": ps.dance_override or "",
|
|
}
|
|
for ps in sorted(p.project_songs, key=lambda x: x.position)
|
|
if ps.song
|
|
],
|
|
})
|
|
|
|
logger.info(f"Pull: {len(my_playlists)} playlister for {me.username}")
|
|
|
|
# Dans-tags (brugerens egne)
|
|
song_tags = []
|
|
for sd in db.query(SongDance).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_id": sd.song_id,
|
|
"dance_name": dance.name,
|
|
"choreographer": dance.choreographer or "",
|
|
"level_name": level.name if level else "",
|
|
"dance_order": sd.dance_order,
|
|
})
|
|
|
|
return {
|
|
"levels": levels,
|
|
"dances": dances,
|
|
"shared": shared,
|
|
"my_playlists": my_playlists,
|
|
"song_tags": song_tags,
|
|
} |