"""Artist Socials API routes — TikTok video discovery and qualification.""" import json import uuid from datetime import datetime from pathlib import Path from typing import Any from fastapi import APIRouter, HTTPException, Request from fastapi.responses import FileResponse, StreamingResponse from sqlmodel import select from app.api.deps import SessionDep from app.models import ( ArtistPublic, QualificationStatus, SocialVideo, SocialVideoPublic, SocialVideosPublic, ) from app.tasks.social_analysis import analyze_social_video_task router = APIRouter(prefix="/social", tags=["social"]) def _get_tiktok_videos_dir() -> Path: from app.core.config import settings if settings.TIKTOK_VIDEOS_DIR: return settings.TIKTOK_VIDEOS_DIR # Default: /demo-app/tiktok-videos # This file lives at demo-app/backend/app/api/routes/social.py return Path(__file__).parent.parent.parent.parent.parent / "tiktok-videos" def _load_json_metadata(json_path: Path) -> dict: try: with open(json_path, encoding="utf-8") as f: return json.load(f) except Exception: return {} @router.get("/artists/", response_model=list[ArtistPublic]) def list_artists() -> Any: """List all artists with TikTok video folders.""" tiktok_dir = _get_tiktok_videos_dir() if not tiktok_dir.exists(): return [] artists = [] for folder in sorted(tiktok_dir.iterdir()): if not folder.is_dir(): continue mp4_files = list(folder.glob("*.mp4")) json_files = list(folder.glob("*.json")) author_name = folder.name if json_files: meta = _load_json_metadata(json_files[0]) author_name = meta.get("author_name") or folder.name artists.append( ArtistPublic( slug=folder.name, name=author_name, video_count=len(mp4_files), ) ) return artists @router.get("/artists/{slug}/videos/", response_model=SocialVideosPublic) def list_artist_videos(slug: str, session: SessionDep) -> Any: """ List all TikTok videos for an artist. Creates DB records and fires qualification tasks for any unprocessed videos. Returns immediately — qualification status updates as tasks complete. """ tiktok_dir = _get_tiktok_videos_dir() folder = tiktok_dir / slug if not folder.exists() or not folder.is_dir(): raise HTTPException(status_code=404, detail="Artist not found") mp4_files = sorted(folder.glob("*.mp4")) # Bulk-load existing records existing = { sv.filename: sv for sv in session.exec( select(SocialVideo).where(SocialVideo.artist_slug == slug) ).all() } videos = [] for mp4_path in mp4_files: sv = existing.get(mp4_path.name) if sv is None: # New video — create record and fire analysis task json_path = mp4_path.with_suffix(".json") meta = _load_json_metadata(json_path) ts_raw = meta.get("video_timestamp") video_timestamp = None if ts_raw: try: video_timestamp = datetime.fromisoformat(ts_raw) except ValueError: pass sv = SocialVideo( artist_slug=slug, filename=mp4_path.name, video_path=str(mp4_path), video_id=meta.get("video_id"), video_duration=int(meta["video_duration"]) if meta.get("video_duration") else None, video_timestamp=video_timestamp, author_name=meta.get("author_name"), author_username=meta.get("author_username"), video_description=meta.get("video_description"), video_playcount=int(meta["video_playcount"]) if meta.get("video_playcount") else None, qualification_status=QualificationStatus.PROCESSING, ) session.add(sv) session.commit() session.refresh(sv) analyze_social_video_task.delay(str(sv.id)) elif sv.qualification_status == QualificationStatus.PENDING: # Stuck in PENDING — re-fire sv.qualification_status = QualificationStatus.PROCESSING session.add(sv) session.commit() analyze_social_video_task.delay(str(sv.id)) videos.append(SocialVideoPublic.model_validate(sv)) videos.sort(key=lambda v: v.video_timestamp or datetime.min, reverse=True) return SocialVideosPublic(data=videos, count=len(videos)) @router.get("/videos/qualified/", response_model=SocialVideosPublic) def list_qualified_videos(session: SessionDep) -> Any: """List all qualified TikTok videos across all artists (for project creation).""" videos = session.exec( select(SocialVideo) .where(SocialVideo.is_qualified == True) # noqa: E712 .order_by(SocialVideo.artist_slug, SocialVideo.filename) ).all() return SocialVideosPublic( data=[SocialVideoPublic.model_validate(v) for v in videos], count=len(videos), ) @router.get("/videos/{video_id}/stream") def stream_social_video(video_id: uuid.UUID, request: Request, session: SessionDep) -> Any: """Stream a TikTok video file with range request support.""" sv = session.get(SocialVideo, video_id) if sv is None: raise HTTPException(status_code=404, detail="Video not found") video_path = Path(sv.video_path) if not video_path.exists(): raise HTTPException(status_code=404, detail="Video file not found on disk") file_size = video_path.stat().st_size range_header = request.headers.get("range") if range_header: range_str = range_header.replace("bytes=", "") start_str, end_str = range_str.split("-") start = int(start_str) if start_str else 0 end = int(end_str) if end_str else file_size - 1 end = min(end, file_size - 1) content_length = end - start + 1 def iterfile(): with open(video_path, "rb") as f: f.seek(start) remaining = content_length while remaining > 0: chunk_size = min(8192, remaining) chunk = f.read(chunk_size) if not chunk: break remaining -= len(chunk) yield chunk return StreamingResponse( iterfile(), status_code=206, media_type="video/mp4", headers={ "Content-Range": f"bytes {start}-{end}/{file_size}", "Accept-Ranges": "bytes", "Content-Length": str(content_length), "Content-Disposition": f'inline; filename="{sv.filename}"', }, ) return FileResponse( path=video_path, media_type="video/mp4", headers={ "Content-Disposition": f'inline; filename="{sv.filename}"', "Accept-Ranges": "bytes", }, )