From 7d266208ae870967263e43f2fc33b71e792b238f Mon Sep 17 00:00:00 2001 From: fullsizemalt <106900403+fullsizemalt@users.noreply.github.com> Date: Tue, 30 Dec 2025 19:26:51 -0800 Subject: [PATCH] feat: add modular Video entity with many-to-many relationships - 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 --- .../versions/0b6d33dcfe94_add_video_tables.py | 131 +++++ backend/models.py | 96 ++++ backend/routers/videos.py | 507 ++++++++++++++++-- 3 files changed, 697 insertions(+), 37 deletions(-) create mode 100644 backend/alembic/versions/0b6d33dcfe94_add_video_tables.py diff --git a/backend/alembic/versions/0b6d33dcfe94_add_video_tables.py b/backend/alembic/versions/0b6d33dcfe94_add_video_tables.py new file mode 100644 index 0000000..70116d2 --- /dev/null +++ b/backend/alembic/versions/0b6d33dcfe94_add_video_tables.py @@ -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 ### diff --git a/backend/models.py b/backend/models.py index cbd42dc..afc987f 100644 --- a/backend/models.py +++ b/backend/models.py @@ -624,3 +624,99 @@ class ChaseSong(SQLModel, table=True): user: User = 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() diff --git a/backend/routers/videos.py b/backend/routers/videos.py index 65e4f6c..662ee1e 100644 --- a/backend/routers/videos.py +++ b/backend/routers/videos.py @@ -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 fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, HTTPException, Query from sqlmodel import Session, select 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.get("/") -def get_all_videos( +# --- Schemas --- + +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), offset: int = Query(default=0), 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 = ( select( Performance.id, @@ -32,12 +492,10 @@ def get_all_videos( Venue.name.label("venue_name"), Venue.city.label("venue_city"), Venue.state.label("venue_state"), - Performance.slug.label("performance_slug"), - Venue.slug.label("venue_slug") ) .join(Song, Performance.song_id == Song.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) .order_by(Show.date.desc()) .limit(limit) @@ -54,19 +512,16 @@ def get_all_videos( "show_id": r[2], "song_id": r[3], "song_title": r[4], - "song_slug": r[5], "date": r[6].isoformat() if r[6] else None, "show_slug": r[7], "venue_name": r[8], "venue_city": r[9], "venue_state": r[10], - "performance_slug": r[11], - "venue_slug": r[12] } for r in perf_results ] - # Get shows with videos + # Get shows with youtube_link show_query = ( select( Show.id, @@ -76,9 +531,8 @@ def get_all_videos( Venue.name.label("venue_name"), Venue.city.label("venue_city"), 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) .order_by(Show.date.desc()) .limit(limit) @@ -97,7 +551,6 @@ def get_all_videos( "venue_name": r[4], "venue_city": r[5], "venue_state": r[6], - "venue_slug": r[7] } for r in show_results ] @@ -108,23 +561,3 @@ def get_all_videos( "total_performances": len(performances), "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 - }