Start
This commit is contained in:
3
linedance-api/.env.example
Normal file
3
linedance-api/.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
DATABASE_URL=mysql+pymysql://bruger:kodeord@localhost:3306/linedance
|
||||
SECRET_KEY=skift-denne-til-en-lang-tilfaeldig-streng
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=10080
|
||||
87
linedance-api/README.md
Normal file
87
linedance-api/README.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Linedance API
|
||||
|
||||
FastAPI backend med MySQL, JWT auth og WebSocket live-opdateringer.
|
||||
|
||||
## Opsætning på VPS
|
||||
|
||||
### 1. Klon og installer
|
||||
```bash
|
||||
git clone <dit-repo>
|
||||
cd linedance-api
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 2. Konfigurer miljøvariabler
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
Udfyld disse værdier:
|
||||
```
|
||||
DATABASE_URL=mysql+pymysql://BRUGER:KODEORD@localhost:3306/linedance
|
||||
SECRET_KEY=<lang tilfældig streng — kør: openssl rand -hex 32>
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=10080
|
||||
```
|
||||
|
||||
### 3. Opret MySQL-database
|
||||
```sql
|
||||
CREATE DATABASE linedance CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
CREATE USER 'linedance'@'localhost' IDENTIFIED BY 'dit-kodeord';
|
||||
GRANT ALL PRIVILEGES ON linedance.* TO 'linedance'@'localhost';
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
### 4. Start API (udvikling)
|
||||
```bash
|
||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
### 5. Start API (produktion med systemd)
|
||||
Opret `/etc/systemd/system/linedance.service`:
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Linedance API
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=www-data
|
||||
WorkingDirectory=/var/www/linedance-api
|
||||
Environment="PATH=/var/www/linedance-api/venv/bin"
|
||||
ExecStart=/var/www/linedance-api/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 2
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
```bash
|
||||
systemctl enable linedance
|
||||
systemctl start linedance
|
||||
```
|
||||
|
||||
## API-oversigt
|
||||
|
||||
| Metode | Sti | Beskrivelse |
|
||||
|--------|-----|-------------|
|
||||
| POST | `/auth/register` | Opret bruger |
|
||||
| POST | `/auth/login` | Log ind, få token |
|
||||
| GET | `/projects/` | Mine projekter |
|
||||
| POST | `/projects/` | Opret projekt |
|
||||
| PATCH | `/projects/{id}` | Rediger projekt |
|
||||
| POST | `/projects/{id}/invite` | Inviter bruger |
|
||||
| GET | `/projects/invitations/pending` | Afventende invitationer |
|
||||
| POST | `/projects/invitations/{id}/accept` | Accepter invitation |
|
||||
| GET | `/projects/{id}/songs` | Danseliste |
|
||||
| POST | `/projects/{id}/songs` | Tilføj sang |
|
||||
| PATCH | `/projects/{id}/songs/{ps_id}/status` | Opdater status (playing/played/skipped) |
|
||||
| GET | `/songs/` | Mine sange |
|
||||
| POST | `/songs/` | Opret sang |
|
||||
| POST | `/songs/{id}/dances` | Tilføj dans til sang |
|
||||
| POST | `/songs/{id}/dances/{did}/alternatives` | Tilføj alternativ-dans |
|
||||
| WS | `/ws/{project_id}` | Live opdateringer |
|
||||
|
||||
## Interaktiv dokumentation
|
||||
Åbn `http://din-server:8000/docs` i browseren.
|
||||
0
linedance-api/app/__init__.py
Normal file
0
linedance-api/app/__init__.py
Normal file
11
linedance-api/app/core/config.py
Normal file
11
linedance-api/app/core/config.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
class Settings(BaseSettings):
|
||||
DATABASE_URL: str
|
||||
SECRET_KEY: str
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 10080 # 7 dage
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
settings = Settings()
|
||||
21
linedance-api/app/core/database.py
Normal file
21
linedance-api/app/core/database.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||
from app.core.config import settings
|
||||
|
||||
engine = create_engine(
|
||||
settings.DATABASE_URL,
|
||||
pool_pre_ping=True, # genforbinder hvis connection er død
|
||||
pool_recycle=3600, # genbruger ikke forbindelser ældre end 1 time
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
53
linedance-api/app/core/security.py
Normal file
53
linedance-api/app/core/security.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy.orm import Session
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
|
||||
|
||||
def create_access_token(data: dict) -> str:
|
||||
expire = datetime.now(timezone.utc) + timedelta(
|
||||
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
)
|
||||
return jwt.encode({**data, "exp": expire}, settings.SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
from app.models.user import User
|
||||
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Kunne ikke validere token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
||||
user_id: str = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise credentials_exception
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
return user
|
||||
52
linedance-api/app/main.py
Normal file
52
linedance-api/app/main.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.core.database import engine, Base
|
||||
from app.routers import auth, projects, songs, alternatives, dances
|
||||
from app.websocket.manager import router as ws_router
|
||||
|
||||
# Opret tabeller hvis de ikke findes (til udvikling — brug Alembic i produktion)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
app = FastAPI(
|
||||
title="Linedance API",
|
||||
version="0.1.0",
|
||||
description="Backend for linedance-afspiller og projektstyring",
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # Stram til i produktion
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(projects.router)
|
||||
app.include_router(songs.router)
|
||||
app.include_router(alternatives.router)
|
||||
app.include_router(dances.router)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def seed_dance_levels():
|
||||
"""Opret standard dans-niveauer hvis tabellen er tom."""
|
||||
from sqlalchemy.orm import Session
|
||||
from app.models import DanceLevel
|
||||
with Session(engine) as db:
|
||||
if db.query(DanceLevel).count() == 0:
|
||||
defaults = [
|
||||
DanceLevel(sort_order=1, name="Begynder", description="Passer til alle"),
|
||||
DanceLevel(sort_order=2, name="Let øvet", description="Lidt erfaring kræves"),
|
||||
DanceLevel(sort_order=3, name="Øvet", description="Kræver regelmæssig træning"),
|
||||
DanceLevel(sort_order=4, name="Erfaren", description="For dedikerede dansere"),
|
||||
DanceLevel(sort_order=5, name="Ekspert", description="Konkurrenceniveau"),
|
||||
]
|
||||
db.add_all(defaults)
|
||||
db.commit()
|
||||
app.include_router(ws_router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
return {"status": "ok", "service": "Linedance API"}
|
||||
180
linedance-api/app/models/__init__.py
Normal file
180
linedance-api/app/models/__init__.py
Normal file
@@ -0,0 +1,180 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, Float, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
def new_uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
def now_utc() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
# ── User ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
|
||||
|
||||
projects: Mapped[list["Project"]] = relationship("Project", back_populates="owner")
|
||||
memberships: Mapped[list["ProjectMember"]] = relationship("ProjectMember", back_populates="user")
|
||||
songs: Mapped[list["Song"]] = relationship("Song", back_populates="owner")
|
||||
alt_ratings: Mapped[list["DanceAltRating"]] = relationship("DanceAltRating", back_populates="user")
|
||||
|
||||
|
||||
# ── Song ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
class Song(Base):
|
||||
__tablename__ = "songs"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
owner_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
artist: Mapped[str] = mapped_column(String(255), default="")
|
||||
album: Mapped[str] = mapped_column(String(255), default="")
|
||||
bpm: Mapped[int] = mapped_column(Integer, default=0)
|
||||
duration_sec: Mapped[int] = mapped_column(Integer, default=0)
|
||||
file_format: Mapped[str] = mapped_column(String(8), default="")
|
||||
mbid: Mapped[str|None] = mapped_column(String(36), nullable=True) # MusicBrainz ID
|
||||
acoustid: Mapped[str|None] = mapped_column(String(64), nullable=True) # AcoustID fingerprint
|
||||
synced_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
|
||||
|
||||
owner: Mapped["User"] = relationship("User", back_populates="songs")
|
||||
project_songs: Mapped[list["ProjectSong"]] = relationship("ProjectSong", back_populates="song")
|
||||
|
||||
|
||||
# ── Dans-entitet ──────────────────────────────────────────────────────────────
|
||||
|
||||
class DanceLevel(Base):
|
||||
__tablename__ = "dance_levels"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
description: Mapped[str] = mapped_column(String(255), default="")
|
||||
|
||||
|
||||
class Dance(Base):
|
||||
"""Dans-entitet: navn + niveau er unik kombination."""
|
||||
__tablename__ = "dances"
|
||||
__table_args__ = (UniqueConstraint("name", "level_id", name="uq_dance_name_level"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
level_id: Mapped[int|None] = mapped_column(Integer, ForeignKey("dance_levels.id"), nullable=True)
|
||||
use_count: Mapped[int] = mapped_column(Integer, default=1)
|
||||
source: Mapped[str] = mapped_column(String(16), default="local") # local | community
|
||||
synced_at: Mapped[datetime|None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
level: Mapped["DanceLevel|None"] = relationship("DanceLevel")
|
||||
|
||||
|
||||
# ── Project / Playlist ────────────────────────────────────────────────────────
|
||||
|
||||
class Project(Base):
|
||||
__tablename__ = "projects"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
owner_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
is_public: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, onupdate=now_utc)
|
||||
|
||||
owner: Mapped["User"] = relationship("User", back_populates="projects")
|
||||
members: Mapped[list["ProjectMember"]] = relationship("ProjectMember", back_populates="project", cascade="all, delete-orphan")
|
||||
project_songs: Mapped[list["ProjectSong"]] = relationship("ProjectSong", back_populates="project", order_by="ProjectSong.position", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class ProjectMember(Base):
|
||||
__tablename__ = "project_members"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
project_id: Mapped[str] = mapped_column(String(36), ForeignKey("projects.id"), nullable=False)
|
||||
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False)
|
||||
role: Mapped[str] = mapped_column(String(16), default="viewer") # owner | editor | viewer
|
||||
status: Mapped[str] = mapped_column(String(16), default="pending") # pending | accepted
|
||||
invited_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
|
||||
|
||||
project: Mapped["Project"] = relationship("Project", back_populates="members")
|
||||
user: Mapped["User"] = relationship("User", back_populates="memberships")
|
||||
|
||||
|
||||
class ProjectSong(Base):
|
||||
__tablename__ = "project_songs"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
project_id: Mapped[str] = mapped_column(String(36), ForeignKey("projects.id"), nullable=False)
|
||||
song_id: Mapped[str] = mapped_column(String(36), ForeignKey("songs.id"), nullable=False)
|
||||
position: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(16), default="pending") # pending|played|skipped
|
||||
|
||||
project: Mapped["Project"] = relationship("Project", back_populates="project_songs")
|
||||
song: Mapped["Song"] = relationship("Song", back_populates="project_songs")
|
||||
|
||||
|
||||
# ── Community dans-tags ───────────────────────────────────────────────────────
|
||||
|
||||
class CommunityDance(Base):
|
||||
"""Fællesskabets dans-tags på sange — identificeret ved mbid eller titel+artist."""
|
||||
__tablename__ = "community_dances"
|
||||
__table_args__ = (UniqueConstraint("song_mbid", "dance_id", name="uq_comm_dance"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
song_mbid: Mapped[str|None] = mapped_column(String(36), nullable=True)
|
||||
song_title: Mapped[str] = mapped_column(String(255), default="")
|
||||
song_artist: Mapped[str] = mapped_column(String(255), default="")
|
||||
dance_id: Mapped[int] = mapped_column(Integer, ForeignKey("dances.id"), nullable=False)
|
||||
submitted_by: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
|
||||
|
||||
dance: Mapped["Dance"] = relationship("Dance")
|
||||
|
||||
|
||||
# ── Community alternativ-dans + rating ────────────────────────────────────────
|
||||
|
||||
class CommunityDanceAlt(Base):
|
||||
"""Fællesskabets alternativ-danse til en sang — uafhængigt af sangens hoveddanse."""
|
||||
__tablename__ = "community_dance_alts"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("song_mbid", "song_title", "song_artist",
|
||||
"alt_dance_id", name="uq_comm_alt"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
song_mbid: Mapped[str|None] = mapped_column(String(36), nullable=True)
|
||||
song_title: Mapped[str] = mapped_column(String(255), default="")
|
||||
song_artist: Mapped[str] = mapped_column(String(255), default="")
|
||||
alt_dance_id: Mapped[int] = mapped_column(Integer, ForeignKey("dances.id"), nullable=False)
|
||||
note: Mapped[str] = mapped_column(Text, default="")
|
||||
submitted_by: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False)
|
||||
avg_rating: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
rating_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
|
||||
|
||||
alt_dance: Mapped["Dance"] = relationship("Dance", foreign_keys=[alt_dance_id])
|
||||
ratings: Mapped[list["DanceAltRating"]] = relationship("DanceAltRating", back_populates="alternative", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class DanceAltRating(Base):
|
||||
"""En brugers 1-5 stjerne rating — knyttet til sang + dans + alternativ + bruger."""
|
||||
__tablename__ = "dance_alt_ratings"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("alternative_id", "user_id", name="uq_rating"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
alternative_id: Mapped[str] = mapped_column(String(36), ForeignKey("community_dance_alts.id"), nullable=False)
|
||||
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False)
|
||||
score: Mapped[int] = mapped_column(Integer, nullable=False) # 1-5
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
|
||||
|
||||
alternative: Mapped["CommunityDanceAlt"] = relationship("CommunityDanceAlt", back_populates="ratings")
|
||||
user: Mapped["User"] = relationship("User", back_populates="alt_ratings")
|
||||
235
linedance-api/app/routers/alternatives.py
Normal file
235
linedance-api/app/routers/alternatives.py
Normal file
@@ -0,0 +1,235 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
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"])
|
||||
|
||||
# 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()
|
||||
39
linedance-api/app/routers/auth.py
Normal file
39
linedance-api/app/routers/auth.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
from app.core.database import get_db
|
||||
from app.core.security import hash_password, verify_password, create_access_token
|
||||
from app.models import User
|
||||
from app.schemas import UserCreate, UserOut, Token
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserOut, status_code=201)
|
||||
def register(data: UserCreate, db: Session = Depends(get_db)):
|
||||
if db.query(User).filter(User.username == data.username).first():
|
||||
raise HTTPException(400, "Brugernavnet er allerede i brug")
|
||||
if db.query(User).filter(User.email == data.email).first():
|
||||
raise HTTPException(400, "E-mailen er allerede i brug")
|
||||
|
||||
user = User(
|
||||
username=data.username,
|
||||
email=data.email,
|
||||
password_hash=hash_password(data.password),
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
|
||||
user = db.query(User).filter(User.username == form.username).first()
|
||||
if not user or not verify_password(form.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Forkert brugernavn eller kodeord",
|
||||
)
|
||||
token = create_access_token({"sub": user.id})
|
||||
return {"access_token": token}
|
||||
108
linedance-api/app/routers/dances.py
Normal file
108
linedance-api/app/routers/dances.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
dances.py — Endpoints til dans-navne, niveauer og community alternativer.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
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
|
||||
|
||||
router = APIRouter(prefix="/dances", tags=["dances"])
|
||||
|
||||
|
||||
# ── Schemas ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class DanceLevelOut(BaseModel):
|
||||
id: int
|
||||
sort_order: int
|
||||
name: str
|
||||
description: str
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
class DanceNameOut(BaseModel):
|
||||
name: str
|
||||
use_count: int
|
||||
|
||||
class DanceNameSubmit(BaseModel):
|
||||
name: str
|
||||
|
||||
class CommunityDanceOut(BaseModel):
|
||||
id: str
|
||||
song_mbid: str | None
|
||||
dance_name: str
|
||||
level_id: int | None
|
||||
level_name: str | None
|
||||
submitted_by: str
|
||||
use_count: int
|
||||
|
||||
class CommunityAltOut(BaseModel):
|
||||
id: str
|
||||
song_mbid: str | None
|
||||
dance_name: str
|
||||
alt_dance_name: str
|
||||
level_id: int | None
|
||||
level_name: str | None
|
||||
note: str
|
||||
bayesian_score: float
|
||||
rating_count: int
|
||||
my_rating: int | None
|
||||
|
||||
|
||||
# ── Dans-niveauer ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/levels", response_model=list[DanceLevelOut])
|
||||
def get_levels(db: Session = Depends(get_db)):
|
||||
"""Hent alle dans-niveauer — bruges til synkronisering i appen."""
|
||||
from sqlalchemy import text
|
||||
rows = db.execute(text(
|
||||
"SELECT id, sort_order, name, description FROM dance_levels ORDER BY sort_order"
|
||||
)).fetchall()
|
||||
return [{"id": r[0], "sort_order": r[1], "name": r[2], "description": r[3]} for r in rows]
|
||||
|
||||
|
||||
# ── Dans-navne ────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/names", response_model=list[DanceNameOut])
|
||||
def get_dance_names(prefix: str = "", limit: int = 50, db: Session = Depends(get_db)):
|
||||
"""Hent kendte dans-navne — bruges til autoudfyld og synkronisering."""
|
||||
from sqlalchemy import text
|
||||
pattern = f"{prefix}%"
|
||||
rows = db.execute(text(
|
||||
"SELECT name, use_count FROM dance_names "
|
||||
"WHERE name LIKE :pattern "
|
||||
"ORDER BY use_count DESC, name "
|
||||
"LIMIT :limit"
|
||||
), {"pattern": pattern, "limit": limit}).fetchall()
|
||||
return [{"name": r[0], "use_count": r[1]} for r in rows]
|
||||
|
||||
|
||||
@router.post("/names", status_code=201)
|
||||
def submit_dance_name(
|
||||
data: DanceNameSubmit,
|
||||
db: Session = Depends(get_db),
|
||||
me: User = Depends(get_current_user),
|
||||
):
|
||||
"""Indsend et dans-navn — opretter eller tæller op."""
|
||||
from sqlalchemy import text
|
||||
name = data.name.strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "Navn må ikke være tomt")
|
||||
existing = db.execute(
|
||||
text("SELECT id FROM dance_names WHERE name = :name COLLATE NOCASE"),
|
||||
{"name": name}
|
||||
).fetchone()
|
||||
if existing:
|
||||
db.execute(
|
||||
text("UPDATE dance_names SET use_count = use_count + 1 WHERE id = :id"),
|
||||
{"id": existing[0]}
|
||||
)
|
||||
else:
|
||||
db.execute(
|
||||
text("INSERT INTO dance_names (name, use_count) VALUES (:name, 1)"),
|
||||
{"name": name}
|
||||
)
|
||||
db.commit()
|
||||
return {"detail": "ok"}
|
||||
190
linedance-api/app/routers/projects.py
Normal file
190
linedance-api/app/routers/projects.py
Normal file
@@ -0,0 +1,190 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models import User, Project, ProjectMember, ProjectSong, Song
|
||||
from app.schemas import (
|
||||
ProjectCreate, ProjectUpdate, ProjectOut,
|
||||
InviteMember, ProjectSongAdd, ProjectSongStatusUpdate, ProjectSongOut,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||
|
||||
|
||||
def _get_project_or_404(project_id: str, db: Session) -> Project:
|
||||
p = db.query(Project).filter(Project.id == project_id).first()
|
||||
if not p:
|
||||
raise HTTPException(404, "Projekt ikke fundet")
|
||||
return p
|
||||
|
||||
|
||||
def _assert_role(project: Project, user: User, db: Session, min_role: str = "viewer"):
|
||||
roles = ["viewer", "editor", "owner"]
|
||||
if project.owner_id == user.id:
|
||||
return # ejer har altid adgang
|
||||
member = db.query(ProjectMember).filter_by(project_id=project.id, user_id=user.id, status="accepted").first()
|
||||
if not member:
|
||||
if project.is_public and min_role == "viewer":
|
||||
return
|
||||
raise HTTPException(403, "Du har ikke adgang til dette projekt")
|
||||
if roles.index(member.role) < roles.index(min_role):
|
||||
raise HTTPException(403, "Din rolle giver ikke rettighed til dette")
|
||||
|
||||
|
||||
# ── CRUD ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/", response_model=list[ProjectOut])
|
||||
def list_projects(db: Session = Depends(get_db), me: User = Depends(get_current_user)):
|
||||
owned = db.query(Project).filter(Project.owner_id == me.id).all()
|
||||
member_ids = [m.project_id for m in db.query(ProjectMember).filter_by(user_id=me.id, status="accepted").all()]
|
||||
shared = db.query(Project).filter(Project.id.in_(member_ids)).all()
|
||||
return list({p.id: p for p in owned + shared}.values())
|
||||
|
||||
|
||||
@router.post("/", response_model=ProjectOut, status_code=201)
|
||||
def create_project(data: ProjectCreate, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
|
||||
project = Project(owner_id=me.id, **data.model_dump())
|
||||
db.add(project)
|
||||
db.commit()
|
||||
db.refresh(project)
|
||||
return project
|
||||
|
||||
|
||||
@router.get("/{project_id}", response_model=ProjectOut)
|
||||
def get_project(project_id: str, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
|
||||
p = _get_project_or_404(project_id, db)
|
||||
_assert_role(p, me, db, "viewer")
|
||||
return p
|
||||
|
||||
|
||||
@router.patch("/{project_id}", response_model=ProjectOut)
|
||||
def update_project(project_id: str, data: ProjectUpdate, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
|
||||
p = _get_project_or_404(project_id, db)
|
||||
_assert_role(p, me, db, "editor")
|
||||
for field, val in data.model_dump(exclude_none=True).items():
|
||||
setattr(p, field, val)
|
||||
db.commit()
|
||||
db.refresh(p)
|
||||
return p
|
||||
|
||||
|
||||
@router.delete("/{project_id}", status_code=204)
|
||||
def delete_project(project_id: str, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
|
||||
p = _get_project_or_404(project_id, db)
|
||||
if p.owner_id != me.id:
|
||||
raise HTTPException(403, "Kun ejeren kan slette projektet")
|
||||
db.delete(p)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ── Invitationer ──────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/{project_id}/invite", status_code=201)
|
||||
def invite_member(project_id: str, data: InviteMember, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
|
||||
p = _get_project_or_404(project_id, db)
|
||||
if p.owner_id != me.id:
|
||||
raise HTTPException(403, "Kun ejeren kan invitere")
|
||||
|
||||
target = db.query(User).filter(User.username == data.username).first()
|
||||
if not target:
|
||||
raise HTTPException(404, f"Brugeren '{data.username}' findes ikke")
|
||||
if target.id == me.id:
|
||||
raise HTTPException(400, "Du kan ikke invitere dig selv")
|
||||
|
||||
existing = db.query(ProjectMember).filter_by(project_id=project_id, user_id=target.id).first()
|
||||
if existing:
|
||||
raise HTTPException(400, "Brugeren er allerede inviteret eller medlem")
|
||||
|
||||
member = ProjectMember(project_id=project_id, user_id=target.id, role=data.role, status="pending")
|
||||
db.add(member)
|
||||
db.commit()
|
||||
return {"detail": f"{data.username} er inviteret som {data.role}"}
|
||||
|
||||
|
||||
@router.get("/invitations/pending")
|
||||
def get_pending_invitations(db: Session = Depends(get_db), me: User = Depends(get_current_user)):
|
||||
invitations = db.query(ProjectMember).filter_by(user_id=me.id, status="pending").all()
|
||||
return [
|
||||
{"invitation_id": inv.id, "project_id": inv.project_id, "role": inv.role, "invited_at": inv.invited_at}
|
||||
for inv in invitations
|
||||
]
|
||||
|
||||
|
||||
@router.post("/invitations/{invitation_id}/accept")
|
||||
def accept_invitation(invitation_id: str, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
|
||||
inv = db.query(ProjectMember).filter_by(id=invitation_id, user_id=me.id).first()
|
||||
if not inv:
|
||||
raise HTTPException(404, "Invitation ikke fundet")
|
||||
inv.status = "accepted"
|
||||
db.commit()
|
||||
return {"detail": "Invitation accepteret"}
|
||||
|
||||
|
||||
@router.delete("/invitations/{invitation_id}")
|
||||
def decline_invitation(invitation_id: str, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
|
||||
inv = db.query(ProjectMember).filter_by(id=invitation_id, user_id=me.id).first()
|
||||
if not inv:
|
||||
raise HTTPException(404, "Invitation ikke fundet")
|
||||
db.delete(inv)
|
||||
db.commit()
|
||||
return {"detail": "Invitation afvist"}
|
||||
|
||||
|
||||
# ── Danseliste (ProjectSongs) ─────────────────────────────────────────────────
|
||||
|
||||
@router.get("/{project_id}/songs", response_model=list[ProjectSongOut])
|
||||
def list_project_songs(project_id: str, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
|
||||
p = _get_project_or_404(project_id, db)
|
||||
_assert_role(p, me, db, "viewer")
|
||||
return p.project_songs
|
||||
|
||||
|
||||
@router.post("/{project_id}/songs", response_model=ProjectSongOut, status_code=201)
|
||||
def add_song_to_project(project_id: str, data: ProjectSongAdd, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
|
||||
p = _get_project_or_404(project_id, db)
|
||||
_assert_role(p, me, db, "editor")
|
||||
|
||||
song = db.query(Song).filter(Song.id == data.song_id).first()
|
||||
if not song:
|
||||
raise HTTPException(404, "Sang ikke fundet")
|
||||
|
||||
position = data.position
|
||||
if position is None:
|
||||
last = db.query(ProjectSong).filter_by(project_id=project_id).order_by(ProjectSong.position.desc()).first()
|
||||
position = (last.position + 1) if last else 1
|
||||
|
||||
ps = ProjectSong(project_id=project_id, song_id=data.song_id, position=position)
|
||||
db.add(ps)
|
||||
db.commit()
|
||||
db.refresh(ps)
|
||||
return ps
|
||||
|
||||
|
||||
@router.patch("/{project_id}/songs/{ps_id}/status", response_model=ProjectSongOut)
|
||||
def update_song_status(project_id: str, ps_id: str, data: ProjectSongStatusUpdate, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
|
||||
p = _get_project_or_404(project_id, db)
|
||||
_assert_role(p, me, db, "editor")
|
||||
|
||||
ps = db.query(ProjectSong).filter_by(id=ps_id, project_id=project_id).first()
|
||||
if not ps:
|
||||
raise HTTPException(404, "Sang ikke fundet i projektet")
|
||||
|
||||
valid = {"pending", "playing", "played", "skipped"}
|
||||
if data.status not in valid:
|
||||
raise HTTPException(400, f"Ugyldig status. Vælg én af: {valid}")
|
||||
|
||||
ps.status = data.status
|
||||
db.commit()
|
||||
db.refresh(ps)
|
||||
return ps
|
||||
|
||||
|
||||
@router.delete("/{project_id}/songs/{ps_id}", status_code=204)
|
||||
def remove_song_from_project(project_id: str, ps_id: str, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
|
||||
p = _get_project_or_404(project_id, db)
|
||||
_assert_role(p, me, db, "editor")
|
||||
ps = db.query(ProjectSong).filter_by(id=ps_id, project_id=project_id).first()
|
||||
if not ps:
|
||||
raise HTTPException(404, "Sang ikke fundet i projektet")
|
||||
db.delete(ps)
|
||||
db.commit()
|
||||
109
linedance-api/app/routers/songs.py
Normal file
109
linedance-api/app/routers/songs.py
Normal file
@@ -0,0 +1,109 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models import User, Song, SongDance, DanceAlternative
|
||||
from app.schemas import (
|
||||
SongCreate, SongOut,
|
||||
SongDanceCreate, SongDanceOut,
|
||||
DanceAlternativeCreate, DanceAlternativeOut,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/songs", tags=["songs"])
|
||||
|
||||
|
||||
# ── Sange ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/", response_model=list[SongOut])
|
||||
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()
|
||||
|
||||
|
||||
@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)
|
||||
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()
|
||||
if not song:
|
||||
raise HTTPException(404, "Sang ikke fundet")
|
||||
db.delete(song)
|
||||
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()
|
||||
115
linedance-api/app/schemas/__init__.py
Normal file
115
linedance-api/app/schemas/__init__.py
Normal file
@@ -0,0 +1,115 @@
|
||||
from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
|
||||
# ── Auth ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
username: str
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
class UserOut(BaseModel):
|
||||
id: str
|
||||
username: str
|
||||
email: str
|
||||
created_at: datetime
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
# ── Project ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class ProjectCreate(BaseModel):
|
||||
name: str
|
||||
description: str = ""
|
||||
is_public: bool = False
|
||||
|
||||
class ProjectUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
is_public: bool | None = None
|
||||
|
||||
class ProjectOut(BaseModel):
|
||||
id: str
|
||||
owner_id: str
|
||||
name: str
|
||||
description: str
|
||||
is_public: bool
|
||||
updated_at: datetime
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
class InviteMember(BaseModel):
|
||||
username: str
|
||||
role: str = "viewer" # editor | viewer
|
||||
|
||||
|
||||
# ── Song ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
class SongCreate(BaseModel):
|
||||
title: str
|
||||
artist: str = ""
|
||||
local_path: str = ""
|
||||
bpm: int = 0
|
||||
duration_sec: int = 0
|
||||
|
||||
class SongOut(BaseModel):
|
||||
id: str
|
||||
owner_id: str
|
||||
title: str
|
||||
artist: str
|
||||
local_path: str
|
||||
bpm: int
|
||||
duration_sec: int
|
||||
synced_at: datetime
|
||||
dances: list[SongDanceOut] = []
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ── Dance ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
class SongDanceCreate(BaseModel):
|
||||
dance_name: str
|
||||
dance_order: int = 1
|
||||
|
||||
class SongDanceOut(BaseModel):
|
||||
id: str
|
||||
dance_name: str
|
||||
dance_order: int
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
class DanceAlternativeCreate(BaseModel):
|
||||
alt_song_dance_id: str
|
||||
note: str = ""
|
||||
|
||||
class DanceAlternativeOut(BaseModel):
|
||||
id: str
|
||||
song_dance_id: str
|
||||
alt_song_dance_id: str
|
||||
note: str
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ── ProjectSong ───────────────────────────────────────────────────────────────
|
||||
|
||||
class ProjectSongAdd(BaseModel):
|
||||
song_id: str
|
||||
position: int | None = None # None = tilføj sidst
|
||||
|
||||
class ProjectSongStatusUpdate(BaseModel):
|
||||
status: str # pending | playing | played | skipped
|
||||
|
||||
class ProjectSongOut(BaseModel):
|
||||
id: str
|
||||
song_id: str
|
||||
position: int
|
||||
status: str
|
||||
song: SongOut
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
SongOut.model_rebuild()
|
||||
78
linedance-api/app/websocket/manager.py
Normal file
78
linedance-api/app/websocket/manager.py
Normal file
@@ -0,0 +1,78 @@
|
||||
import json
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from app.core.database import get_db
|
||||
from app.models import Project, ProjectSong
|
||||
|
||||
router = APIRouter(prefix="/ws", tags=["websocket"])
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
def __init__(self):
|
||||
# project_id -> liste af aktive forbindelser
|
||||
self.rooms: dict[str, list[WebSocket]] = {}
|
||||
|
||||
async def connect(self, project_id: str, ws: WebSocket):
|
||||
await ws.accept()
|
||||
self.rooms.setdefault(project_id, []).append(ws)
|
||||
|
||||
def disconnect(self, project_id: str, ws: WebSocket):
|
||||
if project_id in self.rooms:
|
||||
self.rooms[project_id].discard(ws) if hasattr(self.rooms[project_id], 'discard') else None
|
||||
try:
|
||||
self.rooms[project_id].remove(ws)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
async def broadcast(self, project_id: str, message: dict):
|
||||
dead = []
|
||||
for ws in self.rooms.get(project_id, []):
|
||||
try:
|
||||
await ws.send_text(json.dumps(message))
|
||||
except Exception:
|
||||
dead.append(ws)
|
||||
for ws in dead:
|
||||
self.disconnect(project_id, ws)
|
||||
|
||||
|
||||
manager = ConnectionManager()
|
||||
|
||||
|
||||
@router.websocket("/{project_id}")
|
||||
async def project_live(
|
||||
project_id: str,
|
||||
websocket: WebSocket,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
project = db.query(Project).filter(Project.id == project_id).first()
|
||||
if not project:
|
||||
await websocket.close(code=4004)
|
||||
return
|
||||
|
||||
await manager.connect(project_id, websocket)
|
||||
|
||||
# Send nuværende tilstand med det samme ved opkobling
|
||||
songs = db.query(ProjectSong).filter_by(project_id=project_id).order_by(ProjectSong.position).all()
|
||||
await websocket.send_text(json.dumps({
|
||||
"event": "state",
|
||||
"project_id": project_id,
|
||||
"songs": [
|
||||
{"id": ps.id, "position": ps.position, "status": ps.status, "song_id": ps.song_id}
|
||||
for ps in songs
|
||||
],
|
||||
}))
|
||||
|
||||
try:
|
||||
while True:
|
||||
await websocket.receive_text() # hold forbindelsen åben
|
||||
except WebSocketDisconnect:
|
||||
manager.disconnect(project_id, websocket)
|
||||
|
||||
|
||||
async def notify_status_change(project_id: str, project_song_id: str, new_status: str):
|
||||
"""Kaldes fra projects-router når en sangs status ændres."""
|
||||
await manager.broadcast(project_id, {
|
||||
"event": "status_update",
|
||||
"project_song_id": project_song_id,
|
||||
"status": new_status,
|
||||
})
|
||||
29
linedance-api/local/__init__.py
Normal file
29
linedance-api/local/__init__.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
local/ — Lokalt data-lag til Linedance-afspilleren.
|
||||
|
||||
Moduler:
|
||||
local_db.py — SQLite database (sange, afspilningslister, biblioteker)
|
||||
tag_reader.py — Læser/skriver metadata fra lydfiler
|
||||
file_watcher.py — Overvåger mapper og holder SQLite opdateret
|
||||
|
||||
Typisk brug ved app-start:
|
||||
|
||||
from local.local_db import init_db
|
||||
from local.file_watcher import get_watcher
|
||||
|
||||
# Initialiser database
|
||||
init_db()
|
||||
|
||||
# Start fil-overvågning (on_change kaldes ved ændringer — opdater GUI)
|
||||
def on_file_change(event_type, path, song_id):
|
||||
print(f"{event_type}: {path}")
|
||||
|
||||
watcher = get_watcher(on_change=on_file_change)
|
||||
watcher.start()
|
||||
|
||||
# Tilføj et bibliotek (scanner automatisk + starter overvågning)
|
||||
watcher.add_library("/home/carsten/Musik")
|
||||
|
||||
# Ved app-luk:
|
||||
watcher.stop()
|
||||
"""
|
||||
258
linedance-api/local/file_watcher.py
Normal file
258
linedance-api/local/file_watcher.py
Normal file
@@ -0,0 +1,258 @@
|
||||
"""
|
||||
file_watcher.py — Overvåger musikbiblioteker og holder SQLite opdateret.
|
||||
|
||||
Bruger watchdog til at reagere på fil-ændringer i realtid.
|
||||
Kører fuld scan ved opstart for at fange ændringer lavet mens appen var lukket.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
try:
|
||||
from watchdog.observers import Observer
|
||||
from watchdog.events import (
|
||||
FileSystemEventHandler,
|
||||
FileCreatedEvent,
|
||||
FileModifiedEvent,
|
||||
FileDeletedEvent,
|
||||
FileMovedEvent,
|
||||
)
|
||||
WATCHDOG_AVAILABLE = True
|
||||
except ImportError:
|
||||
WATCHDOG_AVAILABLE = False
|
||||
print("Advarsel: watchdog ikke installeret — fil-overvågning deaktiveret")
|
||||
|
||||
from local.tag_reader import is_supported, read_tags, get_file_modified_at
|
||||
from local.local_db import (
|
||||
get_libraries, add_library, remove_library,
|
||||
upsert_song, mark_song_missing,
|
||||
get_all_song_paths_for_library, update_library_scan_time,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MusicLibraryHandler(FileSystemEventHandler):
|
||||
"""
|
||||
Reagerer på ændringer i et musikbibliotek.
|
||||
Kører i watchdog's baggrundstråd — DB-operationer er thread-safe via WAL.
|
||||
"""
|
||||
|
||||
def __init__(self, library_id: int, on_change: Callable | None = None):
|
||||
self.library_id = library_id
|
||||
self.on_change = on_change # valgfrit callback til GUI-opdatering
|
||||
self._debounce: dict[str, float] = {}
|
||||
self._debounce_lock = threading.Lock()
|
||||
|
||||
def _debounced(self, path: str) -> bool:
|
||||
"""
|
||||
Forhindrer at samme fil behandles flere gange på kort tid.
|
||||
Nogle programmer gemmer filer i flere trin (temp-fil → rename).
|
||||
"""
|
||||
now = time.time()
|
||||
with self._debounce_lock:
|
||||
last = self._debounce.get(path, 0)
|
||||
if now - last < 1.5: # 1.5 sekunder cooldown
|
||||
return False
|
||||
self._debounce[path] = now
|
||||
return True
|
||||
|
||||
def on_created(self, event):
|
||||
if event.is_directory or not is_supported(event.src_path):
|
||||
return
|
||||
if self._debounced(event.src_path):
|
||||
self._process_file(event.src_path)
|
||||
|
||||
def on_modified(self, event):
|
||||
if event.is_directory or not is_supported(event.src_path):
|
||||
return
|
||||
if self._debounced(event.src_path):
|
||||
self._process_file(event.src_path)
|
||||
|
||||
def on_deleted(self, event):
|
||||
if event.is_directory or not is_supported(event.src_path):
|
||||
return
|
||||
logger.info(f"Fil slettet: {event.src_path}")
|
||||
mark_song_missing(event.src_path)
|
||||
if self.on_change:
|
||||
self.on_change("deleted", event.src_path, None)
|
||||
|
||||
def on_moved(self, event):
|
||||
if event.is_directory:
|
||||
return
|
||||
# Behandl som slet + opret
|
||||
if is_supported(event.src_path):
|
||||
mark_song_missing(event.src_path)
|
||||
if is_supported(event.dest_path):
|
||||
if self._debounced(event.dest_path):
|
||||
self._process_file(event.dest_path)
|
||||
|
||||
def _process_file(self, path: str):
|
||||
"""Læs tags og gem i SQLite."""
|
||||
try:
|
||||
logger.debug(f"Høster tags fra: {path}")
|
||||
tags = read_tags(path)
|
||||
tags["library_id"] = self.library_id
|
||||
song_id = upsert_song(tags)
|
||||
logger.info(f"Opdateret: {Path(path).name} ({len(tags.get('dances', []))} danse)")
|
||||
if self.on_change:
|
||||
self.on_change("upserted", path, song_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Fejl ved behandling af {path}: {e}")
|
||||
|
||||
|
||||
class LibraryWatcher:
|
||||
"""
|
||||
Styrer watchdog-observere for alle aktive musikbiblioteker.
|
||||
Én instans per applikation.
|
||||
"""
|
||||
|
||||
def __init__(self, on_change: Callable | None = None):
|
||||
self.on_change = on_change
|
||||
self._observer: Observer | None = None
|
||||
self._running = False
|
||||
|
||||
def start(self):
|
||||
"""Start overvågning af alle aktive biblioteker + kør fuld scan."""
|
||||
if not WATCHDOG_AVAILABLE:
|
||||
logger.warning("watchdog ikke tilgængelig — starter kun fuld scan")
|
||||
self._full_scan_all()
|
||||
return
|
||||
|
||||
self._observer = Observer()
|
||||
libraries = get_libraries(active_only=True)
|
||||
|
||||
for lib in libraries:
|
||||
path = Path(lib["path"])
|
||||
if not path.exists():
|
||||
logger.warning(f"Bibliotek findes ikke: {path}")
|
||||
continue
|
||||
|
||||
handler = MusicLibraryHandler(lib["id"], self.on_change)
|
||||
self._observer.schedule(handler, str(path), recursive=True)
|
||||
logger.info(f"Overvåger: {path}")
|
||||
|
||||
self._observer.start()
|
||||
self._running = True
|
||||
|
||||
# Fuld scan i baggrundstråd så GUI ikke blokeres
|
||||
threading.Thread(target=self._full_scan_all, daemon=True).start()
|
||||
|
||||
def stop(self):
|
||||
if self._observer and self._running:
|
||||
self._observer.stop()
|
||||
self._observer.join()
|
||||
self._running = False
|
||||
|
||||
def add_library(self, path: str) -> int:
|
||||
"""Tilføj et nyt bibliotek og start overvågning af det med det samme."""
|
||||
library_id = add_library(path)
|
||||
|
||||
if self._observer and self._running:
|
||||
handler = MusicLibraryHandler(library_id, self.on_change)
|
||||
self._observer.schedule(handler, path, recursive=True)
|
||||
logger.info(f"Tilføjet bibliotek: {path}")
|
||||
|
||||
# Scan det nye bibliotek i baggrunden
|
||||
threading.Thread(
|
||||
target=self._full_scan_library,
|
||||
args=(library_id, path),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
return library_id
|
||||
|
||||
def remove_library(self, library_id: int):
|
||||
"""Deaktiver bibliotek. Watchdog stopper automatisk ved næste restart."""
|
||||
remove_library(library_id)
|
||||
# Genstart observer for at fjerne watch (watchdog understøtter ikke unschedule by id)
|
||||
if self._observer and self._running:
|
||||
self._observer.unschedule_all()
|
||||
self._reschedule_all()
|
||||
|
||||
def _reschedule_all(self):
|
||||
"""Genplanlæg alle aktive biblioteker på observeren."""
|
||||
for lib in get_libraries(active_only=True):
|
||||
path = Path(lib["path"])
|
||||
if path.exists():
|
||||
handler = MusicLibraryHandler(lib["id"], self.on_change)
|
||||
self._observer.schedule(handler, str(path), recursive=True)
|
||||
|
||||
def _full_scan_all(self):
|
||||
"""Kør fuld scan på alle aktive biblioteker."""
|
||||
for lib in get_libraries(active_only=True):
|
||||
path = Path(lib["path"])
|
||||
if path.exists():
|
||||
self._full_scan_library(lib["id"], str(path))
|
||||
|
||||
def _full_scan_library(self, library_id: int, library_path: str):
|
||||
"""
|
||||
Sammenligner filer på disk med SQLite og synkroniserer forskelle.
|
||||
|
||||
Tre operationer:
|
||||
1. Nye filer → indsæt i SQLite
|
||||
2. Ændrede filer → opdater SQLite (baseret på fil-timestamp)
|
||||
3. Forsvundne → marker som missing i SQLite
|
||||
"""
|
||||
logger.info(f"Fuld scan starter: {library_path}")
|
||||
base = Path(library_path)
|
||||
|
||||
# Hvad SQLite kender til
|
||||
known = get_all_song_paths_for_library(library_id)
|
||||
|
||||
# Hvad der faktisk er på disk
|
||||
found_paths = set()
|
||||
processed = 0
|
||||
errors = 0
|
||||
|
||||
for file_path in base.rglob("*"):
|
||||
if not file_path.is_file() or not is_supported(file_path):
|
||||
continue
|
||||
|
||||
path_str = str(file_path)
|
||||
found_paths.add(path_str)
|
||||
disk_modified = get_file_modified_at(file_path)
|
||||
|
||||
# Ny fil eller ændret siden sidst
|
||||
if path_str not in known or known[path_str] != disk_modified:
|
||||
try:
|
||||
tags = read_tags(file_path)
|
||||
tags["library_id"] = library_id
|
||||
upsert_song(tags)
|
||||
processed += 1
|
||||
if self.on_change:
|
||||
self.on_change("upserted", path_str, None)
|
||||
except Exception as e:
|
||||
logger.error(f"Scan-fejl for {file_path}: {e}")
|
||||
errors += 1
|
||||
|
||||
# Marker forsvundne filer
|
||||
missing_count = 0
|
||||
for known_path in known:
|
||||
if known_path not in found_paths:
|
||||
mark_song_missing(known_path)
|
||||
missing_count += 1
|
||||
if self.on_change:
|
||||
self.on_change("deleted", known_path, None)
|
||||
|
||||
update_library_scan_time(library_id)
|
||||
logger.info(
|
||||
f"Scan færdig: {library_path} — "
|
||||
f"{processed} opdateret, {missing_count} mangler, {errors} fejl"
|
||||
)
|
||||
|
||||
|
||||
# ── Singleton til brug i appen ────────────────────────────────────────────────
|
||||
|
||||
_watcher: LibraryWatcher | None = None
|
||||
|
||||
|
||||
def get_watcher(on_change: Callable | None = None) -> LibraryWatcher:
|
||||
"""Returnerer den globale LibraryWatcher-instans."""
|
||||
global _watcher
|
||||
if _watcher is None:
|
||||
_watcher = LibraryWatcher(on_change=on_change)
|
||||
return _watcher
|
||||
330
linedance-api/local/local_db.py
Normal file
330
linedance-api/local/local_db.py
Normal file
@@ -0,0 +1,330 @@
|
||||
"""
|
||||
local_db.py — Lokal SQLite database til offline brug.
|
||||
|
||||
Håndterer:
|
||||
- Musikbiblioteker (stier der overvåges)
|
||||
- Sange høstet fra filsystemet
|
||||
- Lokale afspilningslister (offline-projekter)
|
||||
- Synkroniseringsstatus mod API
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
DB_PATH = Path.home() / ".linedance" / "local.db"
|
||||
|
||||
_local = threading.local()
|
||||
|
||||
|
||||
def _get_conn() -> sqlite3.Connection:
|
||||
"""Returnerer en thread-lokal forbindelse."""
|
||||
if not hasattr(_local, "conn") or _local.conn is None:
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH, check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL") # bedre concurrent adgang
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
_local.conn = conn
|
||||
return _local.conn
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_db():
|
||||
conn = _get_conn()
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
|
||||
def init_db():
|
||||
"""Opret alle tabeller hvis de ikke findes."""
|
||||
with get_db() as conn:
|
||||
conn.executescript("""
|
||||
-- Musikbiblioteker der overvåges
|
||||
CREATE TABLE IF NOT EXISTS libraries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
last_full_scan TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Sange høstet fra filsystemet
|
||||
CREATE TABLE IF NOT EXISTS songs (
|
||||
id TEXT PRIMARY KEY,
|
||||
library_id INTEGER REFERENCES libraries(id),
|
||||
local_path TEXT NOT NULL UNIQUE,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
artist TEXT NOT NULL DEFAULT '',
|
||||
album TEXT NOT NULL DEFAULT '',
|
||||
bpm INTEGER NOT NULL DEFAULT 0,
|
||||
duration_sec INTEGER NOT NULL DEFAULT 0,
|
||||
file_format TEXT NOT NULL DEFAULT '',
|
||||
file_modified_at TEXT NOT NULL,
|
||||
file_missing INTEGER NOT NULL DEFAULT 0,
|
||||
api_song_id TEXT, -- NULL hvis ikke synkroniseret
|
||||
last_synced_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Danse knyttet til en sang (kun MP3 kan skrive tags)
|
||||
CREATE TABLE IF NOT EXISTS song_dances (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
song_id TEXT NOT NULL REFERENCES songs(id) ON DELETE CASCADE,
|
||||
dance_name TEXT NOT NULL,
|
||||
dance_order INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
-- Lokale afspilningslister (offline-projekter)
|
||||
CREATE TABLE IF NOT EXISTS playlists (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
api_project_id TEXT, -- NULL hvis ikke synkroniseret
|
||||
last_synced_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Sange i en afspilningsliste
|
||||
CREATE TABLE IF NOT EXISTS playlist_songs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
|
||||
song_id TEXT NOT NULL REFERENCES songs(id),
|
||||
position INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- pending|playing|played|skipped
|
||||
UNIQUE(playlist_id, position)
|
||||
);
|
||||
|
||||
-- Synkroniseringskø — ændringer der venter på at komme online
|
||||
CREATE TABLE IF NOT EXISTS sync_queue (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entity_type TEXT NOT NULL, -- 'song'|'playlist'|'playlist_song'
|
||||
entity_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL, -- 'create'|'update'|'delete'
|
||||
payload TEXT NOT NULL, -- JSON
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Indekser til hurtig søgning
|
||||
CREATE INDEX IF NOT EXISTS idx_songs_title ON songs(title);
|
||||
CREATE INDEX IF NOT EXISTS idx_songs_artist ON songs(artist);
|
||||
CREATE INDEX IF NOT EXISTS idx_songs_missing ON songs(file_missing);
|
||||
CREATE INDEX IF NOT EXISTS idx_songs_library ON songs(library_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_song_dances ON song_dances(song_id);
|
||||
""")
|
||||
|
||||
|
||||
# ── Biblioteker ───────────────────────────────────────────────────────────────
|
||||
|
||||
def add_library(path: str) -> int:
|
||||
with get_db() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT OR IGNORE INTO libraries (path) VALUES (?)", (path,)
|
||||
)
|
||||
if cur.lastrowid:
|
||||
return cur.lastrowid
|
||||
row = conn.execute("SELECT id FROM libraries WHERE path=?", (path,)).fetchone()
|
||||
return row["id"]
|
||||
|
||||
|
||||
def get_libraries(active_only: bool = True) -> list[sqlite3.Row]:
|
||||
with get_db() as conn:
|
||||
if active_only:
|
||||
return conn.execute(
|
||||
"SELECT * FROM libraries WHERE is_active=1 ORDER BY path"
|
||||
).fetchall()
|
||||
return conn.execute("SELECT * FROM libraries ORDER BY path").fetchall()
|
||||
|
||||
|
||||
def remove_library(library_id: int):
|
||||
with get_db() as conn:
|
||||
conn.execute("UPDATE libraries SET is_active=0 WHERE id=?", (library_id,))
|
||||
|
||||
|
||||
def update_library_scan_time(library_id: int):
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"UPDATE libraries SET last_full_scan=? WHERE id=?", (now, library_id)
|
||||
)
|
||||
|
||||
|
||||
# ── Sange ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def upsert_song(song_data: dict) -> str:
|
||||
"""
|
||||
Indsæt eller opdater en sang baseret på local_path.
|
||||
Returnerer song_id.
|
||||
"""
|
||||
import uuid
|
||||
with get_db() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM songs WHERE local_path=?", (song_data["local_path"],)
|
||||
).fetchone()
|
||||
|
||||
if existing:
|
||||
song_id = existing["id"]
|
||||
conn.execute("""
|
||||
UPDATE songs SET
|
||||
title=?, artist=?, album=?, bpm=?, duration_sec=?,
|
||||
file_format=?, file_modified_at=?, file_missing=0
|
||||
WHERE id=?
|
||||
""", (
|
||||
song_data.get("title", ""),
|
||||
song_data.get("artist", ""),
|
||||
song_data.get("album", ""),
|
||||
song_data.get("bpm", 0),
|
||||
song_data.get("duration_sec", 0),
|
||||
song_data.get("file_format", ""),
|
||||
song_data.get("file_modified_at", ""),
|
||||
song_id,
|
||||
))
|
||||
else:
|
||||
song_id = str(uuid.uuid4())
|
||||
conn.execute("""
|
||||
INSERT INTO songs
|
||||
(id, library_id, local_path, title, artist, album,
|
||||
bpm, duration_sec, file_format, file_modified_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)
|
||||
""", (
|
||||
song_id,
|
||||
song_data.get("library_id"),
|
||||
song_data["local_path"],
|
||||
song_data.get("title", ""),
|
||||
song_data.get("artist", ""),
|
||||
song_data.get("album", ""),
|
||||
song_data.get("bpm", 0),
|
||||
song_data.get("duration_sec", 0),
|
||||
song_data.get("file_format", ""),
|
||||
song_data.get("file_modified_at", ""),
|
||||
))
|
||||
|
||||
# Opdater danse hvis de er med i data
|
||||
if "dances" in song_data:
|
||||
conn.execute("DELETE FROM song_dances WHERE song_id=?", (song_id,))
|
||||
for i, dance_name in enumerate(song_data["dances"], start=1):
|
||||
conn.execute(
|
||||
"INSERT INTO song_dances (song_id, dance_name, dance_order) VALUES (?,?,?)",
|
||||
(song_id, dance_name, i),
|
||||
)
|
||||
|
||||
return song_id
|
||||
|
||||
|
||||
def mark_song_missing(local_path: str):
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"UPDATE songs SET file_missing=1 WHERE local_path=?", (local_path,)
|
||||
)
|
||||
|
||||
|
||||
def get_song_by_path(local_path: str) -> sqlite3.Row | None:
|
||||
with get_db() as conn:
|
||||
return conn.execute(
|
||||
"SELECT * FROM songs WHERE local_path=?", (local_path,)
|
||||
).fetchone()
|
||||
|
||||
|
||||
def search_songs(query: str, limit: int = 50) -> list[sqlite3.Row]:
|
||||
"""Søg i titel, artist og dansenavne."""
|
||||
pattern = f"%{query}%"
|
||||
with get_db() as conn:
|
||||
return conn.execute("""
|
||||
SELECT DISTINCT s.* FROM songs s
|
||||
LEFT JOIN song_dances sd ON sd.song_id = s.id
|
||||
WHERE s.file_missing = 0
|
||||
AND (s.title LIKE ? OR s.artist LIKE ? OR s.album LIKE ? OR sd.dance_name LIKE ?)
|
||||
ORDER BY s.artist, s.title
|
||||
LIMIT ?
|
||||
""", (pattern, pattern, pattern, pattern, limit)).fetchall()
|
||||
|
||||
|
||||
def get_songs_for_library(library_id: int) -> list[sqlite3.Row]:
|
||||
with get_db() as conn:
|
||||
return conn.execute(
|
||||
"SELECT * FROM songs WHERE library_id=? ORDER BY artist, title",
|
||||
(library_id,)
|
||||
).fetchall()
|
||||
|
||||
|
||||
def get_all_song_paths_for_library(library_id: int) -> dict[str, str]:
|
||||
"""Returnerer {local_path: file_modified_at} — bruges til fuld scan."""
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT local_path, file_modified_at FROM songs WHERE library_id=?",
|
||||
(library_id,)
|
||||
).fetchall()
|
||||
return {row["local_path"]: row["file_modified_at"] for row in rows}
|
||||
|
||||
|
||||
# ── Afspilningslister ─────────────────────────────────────────────────────────
|
||||
|
||||
def create_playlist(name: str, description: str = "") -> int:
|
||||
with get_db() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO playlists (name, description) VALUES (?,?)",
|
||||
(name, description)
|
||||
)
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def get_playlists() -> list[sqlite3.Row]:
|
||||
with get_db() as conn:
|
||||
return conn.execute(
|
||||
"SELECT * FROM playlists ORDER BY created_at DESC"
|
||||
).fetchall()
|
||||
|
||||
|
||||
def add_song_to_playlist(playlist_id: int, song_id: str, position: int | None = None) -> int:
|
||||
with get_db() as conn:
|
||||
if position is None:
|
||||
row = conn.execute(
|
||||
"SELECT MAX(position) as max_pos FROM playlist_songs WHERE playlist_id=?",
|
||||
(playlist_id,)
|
||||
).fetchone()
|
||||
position = (row["max_pos"] or 0) + 1
|
||||
|
||||
cur = conn.execute(
|
||||
"INSERT INTO playlist_songs (playlist_id, song_id, position) VALUES (?,?,?)",
|
||||
(playlist_id, song_id, position)
|
||||
)
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def update_playlist_song_status(playlist_song_id: int, status: str):
|
||||
valid = {"pending", "playing", "played", "skipped"}
|
||||
if status not in valid:
|
||||
raise ValueError(f"Ugyldig status: {status}")
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"UPDATE playlist_songs SET status=? WHERE id=?",
|
||||
(status, playlist_song_id)
|
||||
)
|
||||
|
||||
|
||||
def get_playlist_with_songs(playlist_id: int) -> dict:
|
||||
with get_db() as conn:
|
||||
playlist = conn.execute(
|
||||
"SELECT * FROM playlists WHERE id=?", (playlist_id,)
|
||||
).fetchone()
|
||||
if not playlist:
|
||||
return {}
|
||||
|
||||
songs = conn.execute("""
|
||||
SELECT ps.id as ps_id, ps.position, ps.status,
|
||||
s.*, GROUP_CONCAT(sd.dance_name ORDER BY sd.dance_order) as dances
|
||||
FROM playlist_songs ps
|
||||
JOIN songs s ON s.id = ps.song_id
|
||||
LEFT JOIN song_dances sd ON sd.song_id = s.id
|
||||
WHERE ps.playlist_id = ?
|
||||
GROUP BY ps.id
|
||||
ORDER BY ps.position
|
||||
""", (playlist_id,)).fetchall()
|
||||
|
||||
return {"playlist": dict(playlist), "songs": [dict(s) for s in songs]}
|
||||
280
linedance-api/local/tag_reader.py
Normal file
280
linedance-api/local/tag_reader.py
Normal file
@@ -0,0 +1,280 @@
|
||||
"""
|
||||
tag_reader.py — Læser og skriver metadata fra lydfiler.
|
||||
|
||||
Understøttede formater og danse-tag support:
|
||||
MP3 — læs + skriv danse (ID3 TXXX-felter)
|
||||
FLAC — læs + skriv danse (Vorbis Comments)
|
||||
OGG — læs + skriv danse (Vorbis Comments)
|
||||
OPUS — læs + skriv danse (Vorbis Comments)
|
||||
M4A — læs + skriv danse (MP4 custom felt ----:LINEDANCE:DANCE)
|
||||
WAV — læs metadata, ingen danse-tag support
|
||||
WMA — læs metadata, ingen danse-tag support
|
||||
AIFF — læs metadata, ingen danse-tag support
|
||||
|
||||
Danse gemmes ALTID i SQLite uanset format.
|
||||
Fil-skrivning er kun muligt for de formater der understøtter custom tags.
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from mutagen import File as MutagenFile
|
||||
from mutagen.id3 import ID3, TXXX
|
||||
from mutagen.flac import FLAC
|
||||
from mutagen.mp4 import MP4, MP4FreeForm
|
||||
MUTAGEN_AVAILABLE = True
|
||||
except ImportError:
|
||||
MUTAGEN_AVAILABLE = False
|
||||
print("Advarsel: mutagen ikke installeret — tag-læsning deaktiveret")
|
||||
|
||||
|
||||
# Filtyper vi høster metadata fra
|
||||
SUPPORTED_EXTENSIONS = {
|
||||
".mp3", ".flac", ".wav", ".m4a", ".aac",
|
||||
".ogg", ".opus", ".wma", ".aiff", ".aif",
|
||||
}
|
||||
|
||||
# Formater der understøtter skrivning af danse-tags til fil
|
||||
WRITABLE_DANCE_FORMATS = {".mp3", ".flac", ".ogg", ".opus", ".m4a"}
|
||||
|
||||
# Tag-nøgler brugt på tværs af formater
|
||||
TXXX_DANCE_PREFIX = "LINEDANCE_DANCE_" # MP3: TXXX:LINEDANCE_DANCE_1
|
||||
VORBIS_DANCE_KEY = "linedance_dance" # FLAC/OGG: linedance_dance.1
|
||||
M4A_DANCE_FREEFORM = "----:LINEDANCE:DANCE" # M4A: ----:LINEDANCE:DANCE (liste)
|
||||
|
||||
|
||||
def is_supported(path: str | Path) -> bool:
|
||||
return Path(path).suffix.lower() in SUPPORTED_EXTENSIONS
|
||||
|
||||
|
||||
def can_write_dances(path: str | Path) -> bool:
|
||||
"""Returnerer True hvis formatet understøtter skrivning af danse-tags til fil."""
|
||||
return Path(path).suffix.lower() in WRITABLE_DANCE_FORMATS
|
||||
|
||||
|
||||
def get_file_modified_at(path: str | Path) -> str:
|
||||
ts = os.path.getmtime(str(path))
|
||||
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
|
||||
|
||||
|
||||
# ── Læsning ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def read_tags(path: str | Path) -> dict:
|
||||
"""
|
||||
Læser metadata og danse fra en lydfil.
|
||||
Returnerer dict med: title, artist, album, bpm, duration_sec,
|
||||
file_format, file_modified_at, dances, can_write_dances.
|
||||
"""
|
||||
path = Path(path)
|
||||
result = {
|
||||
"local_path": str(path),
|
||||
"title": path.stem,
|
||||
"artist": "",
|
||||
"album": "",
|
||||
"bpm": 0,
|
||||
"duration_sec": 0,
|
||||
"file_format": path.suffix.lower().lstrip("."),
|
||||
"file_modified_at": get_file_modified_at(path),
|
||||
"dances": [],
|
||||
"can_write_dances": can_write_dances(path),
|
||||
}
|
||||
|
||||
if not MUTAGEN_AVAILABLE:
|
||||
return result
|
||||
|
||||
try:
|
||||
audio = MutagenFile(str(path), easy=False)
|
||||
if audio is None:
|
||||
return result
|
||||
|
||||
if hasattr(audio, "info") and audio.info:
|
||||
result["duration_sec"] = int(getattr(audio.info, "length", 0))
|
||||
|
||||
ext = path.suffix.lower()
|
||||
|
||||
if ext == ".mp3":
|
||||
_read_mp3(audio, result)
|
||||
elif ext == ".flac":
|
||||
_read_vorbis(audio, result)
|
||||
elif ext in (".ogg", ".opus"):
|
||||
_read_vorbis(audio, result)
|
||||
elif ext in (".m4a", ".aac", ".mp4"):
|
||||
_read_m4a(audio, result)
|
||||
else:
|
||||
_read_generic(audio, result)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Fejl ved læsning af {path}: {e}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _read_mp3(audio, result: dict):
|
||||
tags = audio.tags
|
||||
if not tags:
|
||||
return
|
||||
if "TIT2" in tags:
|
||||
result["title"] = str(tags["TIT2"].text[0])
|
||||
if "TPE1" in tags:
|
||||
result["artist"] = str(tags["TPE1"].text[0])
|
||||
if "TALB" in tags:
|
||||
result["album"] = str(tags["TALB"].text[0])
|
||||
if "TBPM" in tags:
|
||||
try:
|
||||
result["bpm"] = int(float(str(tags["TBPM"].text[0])))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
dances = {}
|
||||
for key, frame in tags.items():
|
||||
if key.startswith("TXXX:") and TXXX_DANCE_PREFIX in key:
|
||||
try:
|
||||
num = int(key.replace(f"TXXX:{TXXX_DANCE_PREFIX}", ""))
|
||||
dances[num] = str(frame.text[0])
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
result["dances"] = [dances[k] for k in sorted(dances.keys())]
|
||||
|
||||
|
||||
def _read_vorbis(audio, result: dict):
|
||||
"""FLAC og OGG/Opus bruger begge Vorbis Comments."""
|
||||
tags = audio.tags
|
||||
if not tags:
|
||||
return
|
||||
result["title"] = tags.get("title", [result["title"]])[0]
|
||||
result["artist"] = tags.get("artist", [""])[0]
|
||||
result["album"] = tags.get("album", [""])[0]
|
||||
try:
|
||||
result["bpm"] = int(tags.get("bpm", [0])[0])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
# Danse gemmes som linedance_dance.1, linedance_dance.2 ...
|
||||
dances = {}
|
||||
for key, values in tags.items():
|
||||
if key.lower().startswith(f"{VORBIS_DANCE_KEY}."):
|
||||
try:
|
||||
num = int(key.split(".")[-1])
|
||||
dances[num] = values[0]
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
# Fallback: enkelt felt linedance_dance med komma-separeret liste
|
||||
if not dances and VORBIS_DANCE_KEY in tags:
|
||||
result["dances"] = [d.strip() for d in tags[VORBIS_DANCE_KEY][0].split(",") if d.strip()]
|
||||
return
|
||||
result["dances"] = [dances[k] for k in sorted(dances.keys())]
|
||||
|
||||
|
||||
def _read_m4a(audio, result: dict):
|
||||
tags = audio.tags
|
||||
if not tags:
|
||||
return
|
||||
if "\xa9nam" in tags:
|
||||
result["title"] = str(tags["\xa9nam"][0])
|
||||
if "\xa9ART" in tags:
|
||||
result["artist"] = str(tags["\xa9ART"][0])
|
||||
if "\xa9alb" in tags:
|
||||
result["album"] = str(tags["\xa9alb"][0])
|
||||
if "tmpo" in tags:
|
||||
try:
|
||||
result["bpm"] = int(tags["tmpo"][0])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
# Danse gemmes som ----:LINEDANCE:DANCE — én værdi per dans
|
||||
if M4A_DANCE_FREEFORM in tags:
|
||||
result["dances"] = [
|
||||
v.decode("utf-8") if isinstance(v, (bytes, MP4FreeForm)) else str(v)
|
||||
for v in tags[M4A_DANCE_FREEFORM]
|
||||
]
|
||||
|
||||
|
||||
def _read_generic(audio, result: dict):
|
||||
try:
|
||||
easy = MutagenFile(result["local_path"], easy=True)
|
||||
if easy and easy.tags:
|
||||
result["title"] = easy.tags.get("title", [result["title"]])[0]
|
||||
result["artist"] = easy.tags.get("artist", [""])[0]
|
||||
result["album"] = easy.tags.get("album", [""])[0]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── Skrivning ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def write_dances(path: str | Path, dances: list[str]) -> bool:
|
||||
"""
|
||||
Skriver danse til filen hvis formatet understøtter det.
|
||||
Returnerer True ved succes, False hvis formatet ikke understøtter det.
|
||||
Kaster Exception ved fejl under skrivning.
|
||||
"""
|
||||
if not MUTAGEN_AVAILABLE:
|
||||
return False
|
||||
|
||||
path = Path(path)
|
||||
ext = path.suffix.lower()
|
||||
|
||||
if ext not in WRITABLE_DANCE_FORMATS:
|
||||
return False
|
||||
|
||||
if ext == ".mp3":
|
||||
return _write_mp3_dances(path, dances)
|
||||
elif ext in (".flac", ".ogg", ".opus"):
|
||||
return _write_vorbis_dances(path, dances)
|
||||
elif ext in (".m4a", ".aac"):
|
||||
return _write_m4a_dances(path, dances)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _write_mp3_dances(path: Path, dances: list[str]) -> bool:
|
||||
try:
|
||||
tags = ID3(str(path))
|
||||
for key in [k for k in tags.keys() if TXXX_DANCE_PREFIX in k]:
|
||||
del tags[key]
|
||||
for i, name in enumerate(dances, start=1):
|
||||
tags.add(TXXX(encoding=3, desc=f"{TXXX_DANCE_PREFIX}{i}", text=name))
|
||||
tags.save(str(path))
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"MP3 skrivefejl {path}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _write_vorbis_dances(path: Path, dances: list[str]) -> bool:
|
||||
try:
|
||||
audio = MutagenFile(str(path), easy=False)
|
||||
if audio is None or audio.tags is None:
|
||||
return False
|
||||
# Slet eksisterende danse-felter
|
||||
keys_to_delete = [k for k in audio.tags.keys() if k.lower().startswith(f"{VORBIS_DANCE_KEY}.")]
|
||||
for key in keys_to_delete:
|
||||
del audio.tags[key]
|
||||
# Skriv nye — ét felt per dans
|
||||
for i, name in enumerate(dances, start=1):
|
||||
audio.tags[f"{VORBIS_DANCE_KEY}.{i}"] = name
|
||||
audio.save()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Vorbis skrivefejl {path}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _write_m4a_dances(path: Path, dances: list[str]) -> bool:
|
||||
try:
|
||||
audio = MP4(str(path))
|
||||
audio.tags[M4A_DANCE_FREEFORM] = [
|
||||
MP4FreeForm(name.encode("utf-8")) for name in dances
|
||||
]
|
||||
audio.save()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"M4A skrivefejl {path}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# ── Hurtig læsning af kun danse (uden fuld tag-scan) ─────────────────────────
|
||||
|
||||
def read_dances_from_file(path: str | Path) -> list[str]:
|
||||
"""Læser kun danse fra en fil — hurtigere end fuld read_tags()."""
|
||||
tags = read_tags(path)
|
||||
return tags.get("dances", [])
|
||||
15
linedance-api/requirements.txt
Normal file
15
linedance-api/requirements.txt
Normal file
@@ -0,0 +1,15 @@
|
||||
fastapi>=0.111.0
|
||||
uvicorn[standard]>=0.29.0
|
||||
sqlalchemy>=2.0.0
|
||||
pymysql>=1.1.0
|
||||
alembic>=1.13.0
|
||||
passlib[bcrypt]>=1.7.4
|
||||
python-jose[cryptography]>=3.3.0
|
||||
pydantic[email]>=2.0.0
|
||||
pydantic-settings>=2.0.0
|
||||
python-dotenv>=1.0.0
|
||||
python-multipart>=0.0.9
|
||||
|
||||
# Lokalt data-lag
|
||||
mutagen>=1.47.0
|
||||
watchdog>=4.0.0
|
||||
Reference in New Issue
Block a user