feat: add modular Video entity with many-to-many relationships
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s

- Video model with VideoType/VideoPlatform enums
- Junction tables: VideoShow, VideoPerformance, VideoSong, VideoMusician
- Full API router with CRUD, entity-specific endpoints, link management
- Legacy compatibility endpoint for existing youtube_link fields
- Building for scale, no shortcuts
This commit is contained in:
fullsizemalt 2025-12-30 19:26:51 -08:00
parent 265200b6ad
commit 7d266208ae
3 changed files with 697 additions and 37 deletions

View file

@ -0,0 +1,131 @@
"""add_video_tables
Revision ID: 0b6d33dcfe94
Revises: ad5a56553d20
Create Date: 2025-12-30 19:23:49.165420
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0b6d33dcfe94'
down_revision: Union[str, Sequence[str], None] = 'ad5a56553d20'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('video',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('url', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('title', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('platform', sa.Enum('YOUTUBE', 'VIMEO', 'NUGS', 'BANDCAMP', 'ARCHIVE', 'OTHER', name='videoplatform'), nullable=False),
sa.Column('video_type', sa.Enum('FULL_SHOW', 'SINGLE_SONG', 'SEQUENCE', 'INTERVIEW', 'DOCUMENTARY', 'LIVE_STREAM', 'OTHER', name='videotype'), nullable=False),
sa.Column('duration_seconds', sa.Integer(), nullable=True),
sa.Column('thumbnail_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('external_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('recorded_date', sa.DateTime(), nullable=True),
sa.Column('published_date', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('vertical_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['vertical_id'], ['vertical.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('video', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_video_url'), ['url'], unique=False)
batch_op.create_index(batch_op.f('ix_video_vertical_id'), ['vertical_id'], unique=False)
op.create_table('videomusician',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('video_id', sa.Integer(), nullable=False),
sa.Column('musician_id', sa.Integer(), nullable=False),
sa.Column('role', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.ForeignKeyConstraint(['musician_id'], ['musician.id'], ),
sa.ForeignKeyConstraint(['video_id'], ['video.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('videomusician', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_videomusician_musician_id'), ['musician_id'], unique=False)
batch_op.create_index(batch_op.f('ix_videomusician_video_id'), ['video_id'], unique=False)
op.create_table('videoshow',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('video_id', sa.Integer(), nullable=False),
sa.Column('show_id', sa.Integer(), nullable=False),
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.ForeignKeyConstraint(['show_id'], ['show.id'], ),
sa.ForeignKeyConstraint(['video_id'], ['video.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('videoshow', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_videoshow_show_id'), ['show_id'], unique=False)
batch_op.create_index(batch_op.f('ix_videoshow_video_id'), ['video_id'], unique=False)
op.create_table('videosong',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('video_id', sa.Integer(), nullable=False),
sa.Column('song_id', sa.Integer(), nullable=False),
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.ForeignKeyConstraint(['song_id'], ['song.id'], ),
sa.ForeignKeyConstraint(['video_id'], ['video.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('videosong', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_videosong_song_id'), ['song_id'], unique=False)
batch_op.create_index(batch_op.f('ix_videosong_video_id'), ['video_id'], unique=False)
op.create_table('videoperformance',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('video_id', sa.Integer(), nullable=False),
sa.Column('performance_id', sa.Integer(), nullable=False),
sa.Column('timestamp_start', sa.Integer(), nullable=True),
sa.Column('timestamp_end', sa.Integer(), nullable=True),
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.ForeignKeyConstraint(['performance_id'], ['performance.id'], ),
sa.ForeignKeyConstraint(['video_id'], ['video.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('videoperformance', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_videoperformance_performance_id'), ['performance_id'], unique=False)
batch_op.create_index(batch_op.f('ix_videoperformance_video_id'), ['video_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('videoperformance', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_videoperformance_video_id'))
batch_op.drop_index(batch_op.f('ix_videoperformance_performance_id'))
op.drop_table('videoperformance')
with op.batch_alter_table('videosong', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_videosong_video_id'))
batch_op.drop_index(batch_op.f('ix_videosong_song_id'))
op.drop_table('videosong')
with op.batch_alter_table('videoshow', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_videoshow_video_id'))
batch_op.drop_index(batch_op.f('ix_videoshow_show_id'))
op.drop_table('videoshow')
with op.batch_alter_table('videomusician', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_videomusician_video_id'))
batch_op.drop_index(batch_op.f('ix_videomusician_musician_id'))
op.drop_table('videomusician')
with op.batch_alter_table('video', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_video_vertical_id'))
batch_op.drop_index(batch_op.f('ix_video_url'))
op.drop_table('video')
# ### end Alembic commands ###

View file

@ -624,3 +624,99 @@ class ChaseSong(SQLModel, table=True):
user: User = Relationship() user: User = Relationship()
song: "Song" = Relationship() song: "Song" = Relationship()
# --- Video System ---
class VideoType(str, Enum):
FULL_SHOW = "full_show" # Complete show recording
SINGLE_SONG = "single_song" # Individual song performance
SEQUENCE = "sequence" # Multi-song sequence
INTERVIEW = "interview" # Artist interview
DOCUMENTARY = "documentary" # Documentary/behind the scenes
LIVE_STREAM = "live_stream" # Live stream recording
OTHER = "other"
class VideoPlatform(str, Enum):
YOUTUBE = "youtube"
VIMEO = "vimeo"
NUGS = "nugs"
BANDCAMP = "bandcamp"
ARCHIVE = "archive" # archive.org
OTHER = "other"
class Video(SQLModel, table=True):
"""Modular video entity that can be linked to multiple entities"""
id: Optional[int] = Field(default=None, primary_key=True)
url: str = Field(index=True, description="Full video URL")
title: Optional[str] = Field(default=None, description="Video title")
description: Optional[str] = Field(default=None)
platform: VideoPlatform = Field(default=VideoPlatform.YOUTUBE)
video_type: VideoType = Field(default=VideoType.SINGLE_SONG)
# Metadata
duration_seconds: Optional[int] = Field(default=None)
thumbnail_url: Optional[str] = Field(default=None)
external_id: Optional[str] = Field(default=None, description="Platform-specific ID (e.g., YouTube video ID)")
# Timestamps
recorded_date: Optional[datetime] = Field(default=None, description="When the video was recorded")
published_date: Optional[datetime] = Field(default=None, description="When published to platform")
created_at: datetime = Field(default_factory=datetime.utcnow)
# Optional vertical scoping
vertical_id: Optional[int] = Field(default=None, foreign_key="vertical.id", index=True)
# Relationships
shows: List["VideoShow"] = Relationship(back_populates="video")
performances: List["VideoPerformance"] = Relationship(back_populates="video")
songs: List["VideoSong"] = Relationship(back_populates="video")
musicians: List["VideoMusician"] = Relationship(back_populates="video")
class VideoShow(SQLModel, table=True):
"""Junction table linking videos to shows"""
id: Optional[int] = Field(default=None, primary_key=True)
video_id: int = Field(foreign_key="video.id", index=True)
show_id: int = Field(foreign_key="show.id", index=True)
notes: Optional[str] = Field(default=None, description="Context for this link")
video: Video = Relationship(back_populates="shows")
show: "Show" = Relationship()
class VideoPerformance(SQLModel, table=True):
"""Junction table linking videos to specific performances"""
id: Optional[int] = Field(default=None, primary_key=True)
video_id: int = Field(foreign_key="video.id", index=True)
performance_id: int = Field(foreign_key="performance.id", index=True)
timestamp_start: Optional[int] = Field(default=None, description="Start time in seconds for this performance in the video")
timestamp_end: Optional[int] = Field(default=None, description="End time in seconds")
notes: Optional[str] = Field(default=None)
video: Video = Relationship(back_populates="performances")
performance: "Performance" = Relationship()
class VideoSong(SQLModel, table=True):
"""Junction table linking videos to songs (general, not performance-specific)"""
id: Optional[int] = Field(default=None, primary_key=True)
video_id: int = Field(foreign_key="video.id", index=True)
song_id: int = Field(foreign_key="song.id", index=True)
notes: Optional[str] = Field(default=None)
video: Video = Relationship(back_populates="songs")
song: "Song" = Relationship()
class VideoMusician(SQLModel, table=True):
"""Junction table linking videos to musicians (for interviews, documentaries, etc.)"""
id: Optional[int] = Field(default=None, primary_key=True)
video_id: int = Field(foreign_key="video.id", index=True)
musician_id: int = Field(foreign_key="musician.id", index=True)
role: Optional[str] = Field(default=None, description="Role in video: 'featured', 'interview', 'performance'")
notes: Optional[str] = Field(default=None)
video: Video = Relationship(back_populates="musicians")
musician: "Musician" = Relationship()

View file

@ -1,24 +1,484 @@
""" """
Videos endpoint - list all performances and shows with YouTube links Video API Router - Modular Video Entity System
Videos can be linked to multiple entities: shows, performances, songs, musicians
Building for scale - no shortcuts.
""" """
from typing import List, Optional from typing import List, Optional
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import Session, select from sqlmodel import Session, select
from database import get_session from database import get_session
from models import Show, Performance, Song, Venue from models import (
Video, VideoShow, VideoPerformance, VideoSong, VideoMusician,
Show, Performance, Song, Musician, Vertical,
VideoType, VideoPlatform
)
from pydantic import BaseModel
from datetime import datetime
import re
router = APIRouter(prefix="/videos", tags=["videos"]) router = APIRouter(prefix="/videos", tags=["videos"])
@router.get("/") # --- Schemas ---
def get_all_videos(
class VideoCreate(BaseModel):
url: str
title: Optional[str] = None
description: Optional[str] = None
platform: Optional[str] = "youtube"
video_type: Optional[str] = "single_song"
duration_seconds: Optional[int] = None
thumbnail_url: Optional[str] = None
vertical_id: Optional[int] = None
# Link IDs (optional on create)
show_ids: Optional[List[int]] = None
performance_ids: Optional[List[int]] = None
song_ids: Optional[List[int]] = None
musician_ids: Optional[List[int]] = None
class VideoRead(BaseModel):
id: int
url: str
title: Optional[str]
description: Optional[str]
platform: str
video_type: str
duration_seconds: Optional[int]
thumbnail_url: Optional[str]
external_id: Optional[str]
recorded_date: Optional[datetime]
published_date: Optional[datetime]
created_at: datetime
vertical_id: Optional[int]
class Config:
from_attributes = True
class VideoWithRelations(VideoRead):
"""Video with linked entities"""
shows: List[dict] = []
performances: List[dict] = []
songs: List[dict] = []
musicians: List[dict] = []
# --- Helpers ---
def extract_youtube_id(url: str) -> Optional[str]:
"""Extract YouTube video ID from URL"""
patterns = [
r'(?:youtube\.com/watch\?v=|youtu\.be/|youtube\.com/embed/)([a-zA-Z0-9_-]{11})',
]
for pattern in patterns:
match = re.search(pattern, url)
if match:
return match.group(1)
return None
def detect_platform(url: str) -> str:
"""Detect video platform from URL"""
url_lower = url.lower()
if 'youtube.com' in url_lower or 'youtu.be' in url_lower:
return "youtube"
elif 'vimeo.com' in url_lower:
return "vimeo"
elif 'nugs.net' in url_lower:
return "nugs"
elif 'bandcamp.com' in url_lower:
return "bandcamp"
elif 'archive.org' in url_lower:
return "archive"
return "other"
# --- Endpoints ---
@router.get("/", response_model=List[VideoRead])
def list_videos(
limit: int = Query(default=50, le=200),
offset: int = 0,
platform: Optional[str] = None,
video_type: Optional[str] = None,
vertical_id: Optional[int] = None,
session: Session = Depends(get_session)
):
"""List all videos with optional filters"""
query = select(Video).order_by(Video.created_at.desc())
if platform:
query = query.where(Video.platform == platform)
if video_type:
query = query.where(Video.video_type == video_type)
if vertical_id:
query = query.where(Video.vertical_id == vertical_id)
query = query.offset(offset).limit(limit)
videos = session.exec(query).all()
# Convert to response format
return [
VideoRead(
id=v.id,
url=v.url,
title=v.title,
description=v.description,
platform=v.platform.value if hasattr(v.platform, 'value') else str(v.platform),
video_type=v.video_type.value if hasattr(v.video_type, 'value') else str(v.video_type),
duration_seconds=v.duration_seconds,
thumbnail_url=v.thumbnail_url,
external_id=v.external_id,
recorded_date=v.recorded_date,
published_date=v.published_date,
created_at=v.created_at,
vertical_id=v.vertical_id,
)
for v in videos
]
@router.get("/stats")
def get_video_stats(session: Session = Depends(get_session)):
"""Get video statistics"""
from sqlmodel import func
total_videos = session.exec(select(func.count(Video.id))).one()
# Count by platform
youtube_count = session.exec(
select(func.count(Video.id)).where(Video.platform == VideoPlatform.YOUTUBE)
).one()
# Count by type
full_show_count = session.exec(
select(func.count(Video.id)).where(Video.video_type == VideoType.FULL_SHOW)
).one()
return {
"total_videos": total_videos,
"youtube_videos": youtube_count,
"full_show_videos": full_show_count,
}
@router.get("/{video_id}", response_model=VideoWithRelations)
def get_video(video_id: int, session: Session = Depends(get_session)):
"""Get a single video with all relationships"""
video = session.get(Video, video_id)
if not video:
raise HTTPException(status_code=404, detail="Video not found")
# Build response with relations
return VideoWithRelations(
id=video.id,
url=video.url,
title=video.title,
description=video.description,
platform=video.platform.value if hasattr(video.platform, 'value') else str(video.platform),
video_type=video.video_type.value if hasattr(video.video_type, 'value') else str(video.video_type),
duration_seconds=video.duration_seconds,
thumbnail_url=video.thumbnail_url,
external_id=video.external_id,
recorded_date=video.recorded_date,
published_date=video.published_date,
created_at=video.created_at,
vertical_id=video.vertical_id,
shows=[{"id": vs.show.id, "date": vs.show.date.isoformat(), "slug": vs.show.slug} for vs in video.shows if vs.show],
performances=[{"id": vp.performance.id, "slug": vp.performance.slug} for vp in video.performances if vp.performance],
songs=[{"id": vs.song.id, "title": vs.song.title, "slug": vs.song.slug} for vs in video.songs if vs.song],
musicians=[{"id": vm.musician.id, "name": vm.musician.name, "slug": vm.musician.slug} for vm in video.musicians if vm.musician],
)
@router.post("/", response_model=VideoRead)
def create_video(video_data: VideoCreate, session: Session = Depends(get_session)):
"""Create a new video with optional entity links"""
# Auto-detect platform
platform = detect_platform(video_data.url)
# Extract external ID for YouTube
external_id = None
if platform == "youtube":
external_id = extract_youtube_id(video_data.url)
# Map string to enum
try:
platform_enum = VideoPlatform(platform)
except ValueError:
platform_enum = VideoPlatform.OTHER
try:
video_type_enum = VideoType(video_data.video_type or "single_song")
except ValueError:
video_type_enum = VideoType.OTHER
# Create video
video = Video(
url=video_data.url,
title=video_data.title,
description=video_data.description,
platform=platform_enum,
video_type=video_type_enum,
duration_seconds=video_data.duration_seconds,
thumbnail_url=video_data.thumbnail_url,
external_id=external_id,
vertical_id=video_data.vertical_id,
)
session.add(video)
session.commit()
session.refresh(video)
# Create relationships
if video_data.show_ids:
for show_id in video_data.show_ids:
link = VideoShow(video_id=video.id, show_id=show_id)
session.add(link)
if video_data.performance_ids:
for perf_id in video_data.performance_ids:
link = VideoPerformance(video_id=video.id, performance_id=perf_id)
session.add(link)
if video_data.song_ids:
for song_id in video_data.song_ids:
link = VideoSong(video_id=video.id, song_id=song_id)
session.add(link)
if video_data.musician_ids:
for musician_id in video_data.musician_ids:
link = VideoMusician(video_id=video.id, musician_id=musician_id)
session.add(link)
session.commit()
return VideoRead(
id=video.id,
url=video.url,
title=video.title,
description=video.description,
platform=video.platform.value if hasattr(video.platform, 'value') else str(video.platform),
video_type=video.video_type.value if hasattr(video.video_type, 'value') else str(video.video_type),
duration_seconds=video.duration_seconds,
thumbnail_url=video.thumbnail_url,
external_id=video.external_id,
recorded_date=video.recorded_date,
published_date=video.published_date,
created_at=video.created_at,
vertical_id=video.vertical_id,
)
# --- Entity-specific video endpoints ---
@router.get("/by-show/{show_id}", response_model=List[VideoRead])
def get_videos_for_show(show_id: int, session: Session = Depends(get_session)):
"""Get all videos linked to a specific show"""
query = select(Video).join(VideoShow).where(VideoShow.show_id == show_id)
videos = session.exec(query).all()
return [
VideoRead(
id=v.id, url=v.url, title=v.title, description=v.description,
platform=v.platform.value if hasattr(v.platform, 'value') else str(v.platform),
video_type=v.video_type.value if hasattr(v.video_type, 'value') else str(v.video_type),
duration_seconds=v.duration_seconds, thumbnail_url=v.thumbnail_url,
external_id=v.external_id, recorded_date=v.recorded_date,
published_date=v.published_date, created_at=v.created_at, vertical_id=v.vertical_id
) for v in videos
]
@router.get("/by-performance/{performance_id}", response_model=List[VideoRead])
def get_videos_for_performance(performance_id: int, session: Session = Depends(get_session)):
"""Get all videos linked to a specific performance"""
query = select(Video).join(VideoPerformance).where(VideoPerformance.performance_id == performance_id)
videos = session.exec(query).all()
return [
VideoRead(
id=v.id, url=v.url, title=v.title, description=v.description,
platform=v.platform.value if hasattr(v.platform, 'value') else str(v.platform),
video_type=v.video_type.value if hasattr(v.video_type, 'value') else str(v.video_type),
duration_seconds=v.duration_seconds, thumbnail_url=v.thumbnail_url,
external_id=v.external_id, recorded_date=v.recorded_date,
published_date=v.published_date, created_at=v.created_at, vertical_id=v.vertical_id
) for v in videos
]
@router.get("/by-song/{song_id}", response_model=List[VideoRead])
def get_videos_for_song(song_id: int, session: Session = Depends(get_session)):
"""Get all videos linked to a specific song"""
query = select(Video).join(VideoSong).where(VideoSong.song_id == song_id)
videos = session.exec(query).all()
return [
VideoRead(
id=v.id, url=v.url, title=v.title, description=v.description,
platform=v.platform.value if hasattr(v.platform, 'value') else str(v.platform),
video_type=v.video_type.value if hasattr(v.video_type, 'value') else str(v.video_type),
duration_seconds=v.duration_seconds, thumbnail_url=v.thumbnail_url,
external_id=v.external_id, recorded_date=v.recorded_date,
published_date=v.published_date, created_at=v.created_at, vertical_id=v.vertical_id
) for v in videos
]
@router.get("/by-musician/{musician_id}", response_model=List[VideoRead])
def get_videos_for_musician(musician_id: int, session: Session = Depends(get_session)):
"""Get all videos linked to a specific musician"""
query = select(Video).join(VideoMusician).where(VideoMusician.musician_id == musician_id)
videos = session.exec(query).all()
return [
VideoRead(
id=v.id, url=v.url, title=v.title, description=v.description,
platform=v.platform.value if hasattr(v.platform, 'value') else str(v.platform),
video_type=v.video_type.value if hasattr(v.video_type, 'value') else str(v.video_type),
duration_seconds=v.duration_seconds, thumbnail_url=v.thumbnail_url,
external_id=v.external_id, recorded_date=v.recorded_date,
published_date=v.published_date, created_at=v.created_at, vertical_id=v.vertical_id
) for v in videos
]
@router.get("/by-band/{vertical_slug}", response_model=List[VideoRead])
def get_videos_for_band(
vertical_slug: str,
limit: int = Query(default=50, le=200),
session: Session = Depends(get_session)
):
"""Get all videos for a band/vertical"""
vertical = session.exec(select(Vertical).where(Vertical.slug == vertical_slug)).first()
if not vertical:
raise HTTPException(status_code=404, detail="Band not found")
query = select(Video).where(Video.vertical_id == vertical.id).order_by(Video.created_at.desc()).limit(limit)
videos = session.exec(query).all()
return [
VideoRead(
id=v.id, url=v.url, title=v.title, description=v.description,
platform=v.platform.value if hasattr(v.platform, 'value') else str(v.platform),
video_type=v.video_type.value if hasattr(v.video_type, 'value') else str(v.video_type),
duration_seconds=v.duration_seconds, thumbnail_url=v.thumbnail_url,
external_id=v.external_id, recorded_date=v.recorded_date,
published_date=v.published_date, created_at=v.created_at, vertical_id=v.vertical_id
) for v in videos
]
# --- Link management endpoints ---
@router.post("/{video_id}/link-show/{show_id}")
def link_video_to_show(video_id: int, show_id: int, session: Session = Depends(get_session)):
"""Link a video to a show"""
video = session.get(Video, video_id)
show = session.get(Show, show_id)
if not video or not show:
raise HTTPException(status_code=404, detail="Video or show not found")
existing = session.exec(
select(VideoShow).where(VideoShow.video_id == video_id, VideoShow.show_id == show_id)
).first()
if existing:
return {"message": "Already linked"}
link = VideoShow(video_id=video_id, show_id=show_id)
session.add(link)
session.commit()
return {"message": "Linked successfully"}
@router.post("/{video_id}/link-performance/{performance_id}")
def link_video_to_performance(
video_id: int,
performance_id: int,
timestamp_start: Optional[int] = Query(default=None),
timestamp_end: Optional[int] = Query(default=None),
session: Session = Depends(get_session)
):
"""Link a video to a performance with optional timestamps"""
video = session.get(Video, video_id)
performance = session.get(Performance, performance_id)
if not video or not performance:
raise HTTPException(status_code=404, detail="Video or performance not found")
existing = session.exec(
select(VideoPerformance).where(
VideoPerformance.video_id == video_id,
VideoPerformance.performance_id == performance_id
)
).first()
if existing:
return {"message": "Already linked"}
link = VideoPerformance(
video_id=video_id,
performance_id=performance_id,
timestamp_start=timestamp_start,
timestamp_end=timestamp_end
)
session.add(link)
session.commit()
return {"message": "Linked successfully"}
@router.post("/{video_id}/link-song/{song_id}")
def link_video_to_song(video_id: int, song_id: int, session: Session = Depends(get_session)):
"""Link a video to a song"""
video = session.get(Video, video_id)
song = session.get(Song, song_id)
if not video or not song:
raise HTTPException(status_code=404, detail="Video or song not found")
existing = session.exec(
select(VideoSong).where(VideoSong.video_id == video_id, VideoSong.song_id == song_id)
).first()
if existing:
return {"message": "Already linked"}
link = VideoSong(video_id=video_id, song_id=song_id)
session.add(link)
session.commit()
return {"message": "Linked successfully"}
@router.post("/{video_id}/link-musician/{musician_id}")
def link_video_to_musician(
video_id: int,
musician_id: int,
role: Optional[str] = Query(default=None),
session: Session = Depends(get_session)
):
"""Link a video to a musician"""
video = session.get(Video, video_id)
musician = session.get(Musician, musician_id)
if not video or not musician:
raise HTTPException(status_code=404, detail="Video or musician not found")
existing = session.exec(
select(VideoMusician).where(VideoMusician.video_id == video_id, VideoMusician.musician_id == musician_id)
).first()
if existing:
return {"message": "Already linked"}
link = VideoMusician(video_id=video_id, musician_id=musician_id, role=role)
session.add(link)
session.commit()
return {"message": "Linked successfully"}
# --- Legacy compatibility: Also return youtube_link from existing Show/Performance fields ---
@router.get("/legacy/all")
def get_legacy_videos(
limit: int = Query(default=100, le=500), limit: int = Query(default=100, le=500),
offset: int = Query(default=0), offset: int = Query(default=0),
session: Session = Depends(get_session) session: Session = Depends(get_session)
): ):
"""Get all performances and shows with YouTube links.""" """Legacy endpoint: Get shows and performances with youtube_link fields (for backwards compatibility)"""
from models import Venue
# Get performances with videos # Get performances with youtube_link
perf_query = ( perf_query = (
select( select(
Performance.id, Performance.id,
@ -32,12 +492,10 @@ def get_all_videos(
Venue.name.label("venue_name"), Venue.name.label("venue_name"),
Venue.city.label("venue_city"), Venue.city.label("venue_city"),
Venue.state.label("venue_state"), Venue.state.label("venue_state"),
Performance.slug.label("performance_slug"),
Venue.slug.label("venue_slug")
) )
.join(Song, Performance.song_id == Song.id) .join(Song, Performance.song_id == Song.id)
.join(Show, Performance.show_id == Show.id) .join(Show, Performance.show_id == Show.id)
.join(Venue, Show.venue_id == Venue.id) .outerjoin(Venue, Show.venue_id == Venue.id)
.where(Performance.youtube_link != None) .where(Performance.youtube_link != None)
.order_by(Show.date.desc()) .order_by(Show.date.desc())
.limit(limit) .limit(limit)
@ -54,19 +512,16 @@ def get_all_videos(
"show_id": r[2], "show_id": r[2],
"song_id": r[3], "song_id": r[3],
"song_title": r[4], "song_title": r[4],
"song_slug": r[5],
"date": r[6].isoformat() if r[6] else None, "date": r[6].isoformat() if r[6] else None,
"show_slug": r[7], "show_slug": r[7],
"venue_name": r[8], "venue_name": r[8],
"venue_city": r[9], "venue_city": r[9],
"venue_state": r[10], "venue_state": r[10],
"performance_slug": r[11],
"venue_slug": r[12]
} }
for r in perf_results for r in perf_results
] ]
# Get shows with videos # Get shows with youtube_link
show_query = ( show_query = (
select( select(
Show.id, Show.id,
@ -76,9 +531,8 @@ def get_all_videos(
Venue.name.label("venue_name"), Venue.name.label("venue_name"),
Venue.city.label("venue_city"), Venue.city.label("venue_city"),
Venue.state.label("venue_state"), Venue.state.label("venue_state"),
Venue.slug.label("venue_slug")
) )
.join(Venue, Show.venue_id == Venue.id) .outerjoin(Venue, Show.venue_id == Venue.id)
.where(Show.youtube_link != None) .where(Show.youtube_link != None)
.order_by(Show.date.desc()) .order_by(Show.date.desc())
.limit(limit) .limit(limit)
@ -97,7 +551,6 @@ def get_all_videos(
"venue_name": r[4], "venue_name": r[4],
"venue_city": r[5], "venue_city": r[5],
"venue_state": r[6], "venue_state": r[6],
"venue_slug": r[7]
} }
for r in show_results for r in show_results
] ]
@ -108,23 +561,3 @@ def get_all_videos(
"total_performances": len(performances), "total_performances": len(performances),
"total_shows": len(shows) "total_shows": len(shows)
} }
@router.get("/stats")
def get_video_stats(session: Session = Depends(get_session)):
"""Get counts of videos in the database."""
from sqlmodel import func
perf_count = session.exec(
select(func.count(Performance.id)).where(Performance.youtube_link != None)
).one()
show_count = session.exec(
select(func.count(Show.id)).where(Show.youtube_link != None)
).one()
return {
"performance_videos": perf_count,
"full_show_videos": show_count,
"total": perf_count + show_count
}