feat: add modular Video entity with many-to-many relationships
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
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:
parent
265200b6ad
commit
7d266208ae
3 changed files with 697 additions and 37 deletions
131
backend/alembic/versions/0b6d33dcfe94_add_video_tables.py
Normal file
131
backend/alembic/versions/0b6d33dcfe94_add_video_tables.py
Normal 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 ###
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue