fediversion/backend/routers/canon.py
fullsizemalt 5b236608f8 feat: Add SongCanon API for cross-band song linking
- Add routers/canon.py with endpoints:
  - GET /canon - list all canonical songs with versions
  - GET /canon/{slug} - get canon with all band versions
  - GET /canon/song/{id}/related - get related versions
- Add link_canon_songs.py auto-linker script
  - Finds songs with same title across bands
  - Creates SongCanon entries automatically
  - Run with --apply to execute
2025-12-28 16:28:58 -08:00

142 lines
4 KiB
Python

from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from typing import List
from database import get_session
from models import SongCanon, Song, Vertical
from pydantic import BaseModel
router = APIRouter(prefix="/canon", tags=["canon"])
class SongVersionRead(BaseModel):
id: int
title: str
slug: str | None
vertical_id: int
vertical_name: str
vertical_slug: str
class SongCanonRead(BaseModel):
id: int
title: str
slug: str
original_artist: str | None
notes: str | None
versions: List[SongVersionRead]
class SongCanonCreate(BaseModel):
title: str
slug: str
original_artist: str | None = None
notes: str | None = None
@router.get("/", response_model=List[SongCanonRead])
def list_canon_songs(
limit: int = 50,
offset: int = 0,
session: Session = Depends(get_session)
):
"""List all canonical songs with their cross-band versions"""
canons = session.exec(
select(SongCanon).offset(offset).limit(limit)
).all()
result = []
for canon in canons:
versions = []
songs = session.exec(
select(Song).where(Song.canon_id == canon.id)
).all()
for song in songs:
vertical = session.get(Vertical, song.vertical_id)
versions.append({
"id": song.id,
"title": song.title,
"slug": song.slug,
"vertical_id": song.vertical_id,
"vertical_name": vertical.name if vertical else "Unknown",
"vertical_slug": vertical.slug if vertical else "unknown"
})
result.append({
"id": canon.id,
"title": canon.title,
"slug": canon.slug,
"original_artist": canon.original_artist,
"notes": canon.notes,
"versions": versions
})
return result
@router.get("/{slug}", response_model=SongCanonRead)
def get_canon_song(slug: str, session: Session = Depends(get_session)):
"""Get a canonical song with all its band-specific versions"""
canon = session.exec(
select(SongCanon).where(SongCanon.slug == slug)
).first()
if not canon:
raise HTTPException(status_code=404, detail="Canonical song not found")
versions = []
songs = session.exec(
select(Song).where(Song.canon_id == canon.id)
).all()
for song in songs:
vertical = session.get(Vertical, song.vertical_id)
versions.append({
"id": song.id,
"title": song.title,
"slug": song.slug,
"vertical_id": song.vertical_id,
"vertical_name": vertical.name if vertical else "Unknown",
"vertical_slug": vertical.slug if vertical else "unknown"
})
return {
"id": canon.id,
"title": canon.title,
"slug": canon.slug,
"original_artist": canon.original_artist,
"notes": canon.notes,
"versions": versions
}
@router.get("/song/{song_id}/related", response_model=List[SongVersionRead])
def get_related_versions(song_id: int, session: Session = Depends(get_session)):
"""Get all versions of the same song across bands"""
song = session.get(Song, song_id)
if not song:
raise HTTPException(status_code=404, detail="Song not found")
if not song.canon_id:
return []
# Get all songs with same canon_id (excluding this one)
related = session.exec(
select(Song)
.where(Song.canon_id == song.canon_id)
.where(Song.id != song_id)
).all()
result = []
for s in related:
vertical = session.get(Vertical, s.vertical_id)
result.append({
"id": s.id,
"title": s.title,
"slug": s.slug,
"vertical_id": s.vertical_id,
"vertical_name": vertical.name if vertical else "Unknown",
"vertical_slug": vertical.slug if vertical else "unknown"
})
return result