NY db struktur
This commit is contained in:
@@ -4,20 +4,22 @@ 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, HTTPException
|
||||
from fastapi import APIRouter, Depends
|
||||
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,
|
||||
PlaylistShare, CommunityDance, SongDance, SongAltDance,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/sync", tags=["sync"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Schemas ───────────────────────────────────────────────────────────────────
|
||||
@@ -29,7 +31,6 @@ class SongData(BaseModel):
|
||||
album: str = ""
|
||||
bpm: int = 0
|
||||
duration_sec: int = 0
|
||||
file_format: str = ""
|
||||
mbid: str = ""
|
||||
acoustid: str = ""
|
||||
|
||||
@@ -42,16 +43,16 @@ class DanceData(BaseModel):
|
||||
notes: str = ""
|
||||
|
||||
class SongDanceData(BaseModel):
|
||||
song_local_id: str
|
||||
dance_name: str
|
||||
level_name: str = ""
|
||||
dance_order: int = 1
|
||||
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 = ""
|
||||
song_local_id: str
|
||||
dance_name: str
|
||||
level_name: str = ""
|
||||
note: str = ""
|
||||
|
||||
class PlaylistSongData(BaseModel):
|
||||
song_local_id: str
|
||||
@@ -71,12 +72,65 @@ class PlaylistData(BaseModel):
|
||||
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
|
||||
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 ──────────────────────────────────────────────────────────────────────
|
||||
@@ -88,78 +142,50 @@ def push(
|
||||
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
|
||||
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 ─────────────────────────────────────────────────────────────────
|
||||
# ── Sange (globale) ───────────────────────────────────────────────────────
|
||||
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
|
||||
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 ──────────────────────────────────────────────────────────────────
|
||||
# ── 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
|
||||
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,
|
||||
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:
|
||||
@@ -172,12 +198,8 @@ def push(
|
||||
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,
|
||||
})
|
||||
), {"id": str(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)
|
||||
@@ -191,35 +213,27 @@ def push(
|
||||
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 "",
|
||||
})
|
||||
), {"id": str(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)
|
||||
# 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()
|
||||
# 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
|
||||
@@ -233,27 +247,21 @@ def push(
|
||||
playlist_id_map[pl.local_id] = project.id
|
||||
|
||||
for ps in pl.songs:
|
||||
# Prøv først via song_id_map (lokal ID)
|
||||
# Find sang via song_id_map eller titel+artist
|
||||
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
|
||||
song = _find_or_create_song(db, ps.song_title, ps.song_artist)
|
||||
song_id = song.id
|
||||
if not song_id:
|
||||
continue
|
||||
proj_song = ProjectSong(
|
||||
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,
|
||||
)
|
||||
db.add(proj_song)
|
||||
))
|
||||
|
||||
# ── Slet playlister der er fjernet lokalt ─────────────────────────────────
|
||||
# Klienten sender api_project_id (= server Project.id) som strings
|
||||
# ── 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:
|
||||
@@ -266,6 +274,7 @@ def push(
|
||||
"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()},
|
||||
}
|
||||
|
||||
@@ -285,99 +294,93 @@ def pull(
|
||||
for l in db.query(DanceLevel).order_by(DanceLevel.sort_order).all()
|
||||
]
|
||||
|
||||
# Danse med info
|
||||
# Danse
|
||||
dances = [
|
||||
{
|
||||
"name": d.name,
|
||||
"level_id": d.level_id,
|
||||
"name": d.name,
|
||||
"level_id": d.level_id,
|
||||
"choreographer": d.choreographer,
|
||||
"video_url": d.video_url,
|
||||
"video_url": d.video_url,
|
||||
"stepsheet_url": d.stepsheet_url,
|
||||
"notes": d.notes,
|
||||
"use_count": d.use_count,
|
||||
"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)
|
||||
# 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 # Egne lister håndteres separat
|
||||
continue
|
||||
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"]),
|
||||
"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 = []
|
||||
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 "",
|
||||
})
|
||||
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": sorted(songs_out, key=lambda x: x["position"]),
|
||||
"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
|
||||
],
|
||||
})
|
||||
|
||||
# Brugerens egne dans-tags
|
||||
from app.models import SongDance, SongAltDance
|
||||
logger.info(f"Pull: {len(my_playlists)} playlister for {me.username}")
|
||||
|
||||
# Dans-tags (brugerens egne)
|
||||
song_tags = []
|
||||
for sd in db.query(SongDance).join(Song).filter(Song.owner_id == me.id).all():
|
||||
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_title": sd.song.title,
|
||||
"song_artist": sd.song.artist,
|
||||
"song_id": sd.song_id,
|
||||
"dance_name": dance.name,
|
||||
"level_name": level.name if level else "",
|
||||
"dance_order": sd.dance_order,
|
||||
@@ -386,7 +389,6 @@ def pull(
|
||||
return {
|
||||
"levels": levels,
|
||||
"dances": dances,
|
||||
"community": community,
|
||||
"shared": shared,
|
||||
"my_playlists": my_playlists,
|
||||
"song_tags": song_tags,
|
||||
|
||||
Reference in New Issue
Block a user