import uuid from datetime import datetime, timezone from pathlib import Path from typing import Any import aiofiles from fastapi import APIRouter, Body, File, HTTPException, Request, UploadFile from fastapi.responses import FileResponse, StreamingResponse from pydantic import BaseModel from sqlmodel import Session, col, func, select from app.api.deps import CurrentUser, SessionDep from app.core.config import settings from app.models import ( Clip, ClipPublic, ClipsPublic, ClipStatus, CutToMusicVideo, CutToMusicVideoPublic, ExtraVideo, ExtraVideoPublic, JobType, Message, ProcessingJob, ProcessingJobPublic, Project, ProjectCreate, ProjectPublic, ProjectSourceType, ProjectsPublic, ProjectStatus, ProjectUpdate, Segment, SegmentPublic, SegmentsPublic, SegmentUpdate, SocialVideo, TranscodingStatus, User, ) from app.tasks.video_processing import ( analyze_video_task, detect_chorus_task, generate_clips_task, generate_cut_to_music_task, transcode_video_task, ) router = APIRouter(prefix="/projects", tags=["projects"]) def _get_project_or_403( session: Session, project_id: uuid.UUID, current_user: User, *, raise_not_found: bool = True, ) -> "Project": project = session.get(Project, project_id) if not project: if raise_not_found: raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=403, detail="Not enough permissions") if project.owner_id != current_user.id and not current_user.is_superuser: raise HTTPException(status_code=403, detail="Not enough permissions") return project class AnalyzeRequest(BaseModel): """Request model for video analysis""" use_mock: bool = False @router.get("/", response_model=ProjectsPublic) def read_projects( session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100 ) -> Any: """ Retrieve projects. Superusers see all projects; regular users see only their own. """ count_statement = select(func.count()).select_from(Project) statement = ( select(Project) .order_by(col(Project.created_at).desc()) .offset(skip) .limit(limit) ) if not current_user.is_superuser: count_statement = count_statement.where(Project.owner_id == current_user.id) statement = statement.where(Project.owner_id == current_user.id) count = session.exec(count_statement).one() projects = session.exec(statement).all() data = [] for p in projects: pub = ProjectPublic.model_validate(p) pub.owner_full_name = p.owner.full_name if p.owner else None data.append(pub) return ProjectsPublic(data=data, count=count) @router.get("/{id}", response_model=ProjectPublic) def read_project(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any: """ Get project by ID. """ project = _get_project_or_403(session, id, current_user) pub = ProjectPublic.model_validate(project) pub.owner_full_name = project.owner.full_name if project.owner else None return pub @router.post("/", response_model=ProjectPublic) def create_project( *, session: SessionDep, current_user: CurrentUser, project_in: ProjectCreate ) -> Any: """ Create new project. """ # social_video_id is not a Project field — strip it before model_validate project_data = project_in.model_dump(exclude={"social_video_id"}) if project_in.source_type == ProjectSourceType.ARTIST_SOCIAL: if not project_in.social_video_id: raise HTTPException( status_code=400, detail="social_video_id is required for artist_social source type", ) sv = session.get(SocialVideo, project_in.social_video_id) if sv is None: raise HTTPException(status_code=404, detail="Social video not found") if not sv.is_qualified: raise HTTPException( status_code=400, detail="Only qualified social videos can be used as project sources" ) import os if settings.GEMINI_API_KEY: os.environ["GEMINI_API_KEY"] = settings.GEMINI_API_KEY from fansifter_clipper.ffmpeg_utils import probe_video video_path = Path(sv.video_path) try: metadata = probe_video(video_path) except Exception as e: raise HTTPException( status_code=400, detail=f"Failed to probe social video: {e}" ) project = Project.model_validate( project_data, update={ "owner_id": current_user.id, "video_filename": sv.filename, "video_path": str(video_path), "duration_seconds": metadata.duration, "width": metadata.width, "height": metadata.height, "fps": metadata.fps, "file_size_bytes": video_path.stat().st_size if video_path.exists() else None, "status": ProjectStatus.UPLOADING, "transcoding_status": TranscodingStatus.NOT_NEEDED, }, ) else: # Initialize with placeholder video path for upload / URL sources project = Project.model_validate( project_data, update={ "owner_id": current_user.id, "video_filename": "pending", "video_path": "pending", }, ) session.add(project) session.commit() session.refresh(project) return project @router.put("/{id}", response_model=ProjectPublic) def update_project( *, session: SessionDep, current_user: CurrentUser, id: uuid.UUID, project_in: ProjectUpdate, ) -> Any: """ Update a project. """ project = _get_project_or_403(session, id, current_user) update_dict = project_in.model_dump(exclude_unset=True) project.sqlmodel_update(update_dict) project.updated_at = datetime.now(timezone.utc) session.add(project) session.commit() session.refresh(project) return project @router.delete("/{id}") def delete_project( session: SessionDep, current_user: CurrentUser, id: uuid.UUID ) -> Message: """ Delete a project. """ project = _get_project_or_403(session, id, current_user) # Delete video files video_path = Path(project.video_path) if video_path.exists(): # Delete entire project directory project_dir = video_path.parent.parent if project_dir.exists(): import shutil shutil.rmtree(project_dir) session.delete(project) session.commit() return Message(message="Project deleted successfully") @router.post("/{id}/upload", response_model=Message) async def upload_video( session: SessionDep, current_user: CurrentUser, id: uuid.UUID, file: UploadFile = File(...), ) -> Message: """ Upload video file for project. """ project = _get_project_or_403(session, id, current_user) # Validate file if file.size and file.size > settings.MAX_UPLOAD_SIZE: raise HTTPException(status_code=413, detail="File too large") if not file.filename: raise HTTPException(status_code=400, detail="No filename provided") ext = Path(file.filename).suffix.lower() if ext not in settings.ALLOWED_VIDEO_EXTENSIONS: raise HTTPException( status_code=400, detail=f"Invalid file type. Allowed: {', '.join(settings.ALLOWED_VIDEO_EXTENSIONS)}", ) # Save file video_dir = ( settings.MEDIA_ROOT / "uploads" / str(current_user.id) / str(project.id) / "source" ) video_dir.mkdir(parents=True, exist_ok=True) video_path = video_dir / file.filename async with aiofiles.open(video_path, "wb") as f: while chunk := await file.read(1024 * 1024): # 1MB chunks await f.write(chunk) # Update project project.video_filename = file.filename project.video_path = str(video_path) project.file_size_bytes = video_path.stat().st_size # Probe metadata try: # Set GEMINI_API_KEY env var before importing clipper import os if settings.GEMINI_API_KEY: os.environ["GEMINI_API_KEY"] = settings.GEMINI_API_KEY # Import here to avoid validation errors at module load from fansifter_clipper.ffmpeg_utils import probe_video print(f"Probing video at path: {video_path}") metadata = probe_video(video_path) print(f"Video metadata: duration={metadata.duration}, size={metadata.width}x{metadata.height}") project.duration_seconds = metadata.duration project.width = metadata.width project.height = metadata.height project.fps = metadata.fps except Exception as e: print(f"Error probing video: {type(e).__name__}: {str(e)}") import traceback traceback.print_exc() raise HTTPException( status_code=400, detail=f"Failed to probe video metadata: {str(e)}" ) project.status = ProjectStatus.UPLOADING project.updated_at = datetime.now(timezone.utc) session.add(project) session.commit() # Trigger transcoding task for Safari compatibility transcode_video_task.apply_async( args=[str(project.id)], countdown=1, # Small delay to ensure commit completes ) project.transcoding_status = TranscodingStatus.PENDING session.add(project) session.commit() return Message(message="Upload successful") @router.post("/{id}/analyze", response_model=ProcessingJobPublic) def start_analysis( session: SessionDep, current_user: CurrentUser, id: uuid.UUID, request: AnalyzeRequest = Body(default=AnalyzeRequest()) ) -> Any: """ Start Gemini analysis of the video. """ project = _get_project_or_403(session, id, current_user) if project.status not in [ProjectStatus.UPLOADING, ProjectStatus.ANALYSIS_COMPLETE]: raise HTTPException( status_code=400, detail="Project must be uploaded before analysis" ) # Create job job = ProcessingJob( job_type=JobType.ANALYZE_VIDEO, project_id=project.id, ) session.add(job) session.commit() session.refresh(job) # Start Celery task with mock flag task = analyze_video_task.apply_async( args=[str(project.id), str(job.id), request.use_mock], task_id=str(job.id), ) job.celery_task_id = task.id session.add(job) session.commit() session.refresh(job) return job @router.get("/{id}/segments", response_model=SegmentsPublic) def read_segments( session: SessionDep, current_user: CurrentUser, id: uuid.UUID ) -> Any: """ Get segments for a project. """ _get_project_or_403(session, id, current_user) statement = ( select(Segment) .where(Segment.project_id == id) .order_by(col(Segment.order_index)) ) segments = session.exec(statement).all() return SegmentsPublic(data=segments, count=len(segments)) @router.put("/segments/{segment_id}", response_model=SegmentPublic) def update_segment( *, session: SessionDep, current_user: CurrentUser, segment_id: uuid.UUID, segment_in: SegmentUpdate, ) -> Any: """ Update a segment. """ segment = session.get(Segment, segment_id) if not segment: raise HTTPException(status_code=404, detail="Segment not found") _get_project_or_403(session, segment.project_id, current_user, raise_not_found=False) update_dict = segment_in.model_dump(exclude_unset=True) segment.sqlmodel_update(update_dict) segment.is_modified = True session.add(segment) session.commit() session.refresh(segment) return segment @router.put("/segments/{segment_id}/toggle", response_model=SegmentPublic) def toggle_segment( session: SessionDep, current_user: CurrentUser, segment_id: uuid.UUID ) -> Any: """ Toggle segment selection. """ segment = session.get(Segment, segment_id) if not segment: raise HTTPException(status_code=404, detail="Segment not found") _get_project_or_403(session, segment.project_id, current_user, raise_not_found=False) segment.is_selected = not segment.is_selected session.add(segment) session.commit() session.refresh(segment) return segment @router.post("/{id}/segments/reorder", response_model=Message) def reorder_segments( session: SessionDep, current_user: CurrentUser, id: uuid.UUID, segment_ids: list[uuid.UUID], ) -> Message: """ Reorder segments. """ _get_project_or_403(session, id, current_user) # Update order indices for idx, segment_id in enumerate(segment_ids): segment = session.get(Segment, segment_id) if segment and segment.project_id == id: segment.order_index = idx + 1 session.add(segment) session.commit() return Message(message="Segments reordered successfully") @router.post("/{id}/process", response_model=ProcessingJobPublic) def start_processing( session: SessionDep, current_user: CurrentUser, id: uuid.UUID ) -> Any: """ Start clip generation from edited segments. """ project = _get_project_or_403(session, id, current_user) if project.status != ProjectStatus.ANALYSIS_COMPLETE: raise HTTPException( status_code=400, detail="Project must complete analysis before processing" ) # Check if there are selected segments selected_count = sum(1 for seg in project.segments if seg.is_selected) if selected_count == 0: raise HTTPException(status_code=400, detail="No segments selected for processing") # Create job job = ProcessingJob( job_type=JobType.GENERATE_CLIPS, project_id=project.id, ) session.add(job) session.commit() session.refresh(job) # Start Celery task for individual clips task = generate_clips_task.apply_async( args=[str(project.id), str(job.id)], task_id=str(job.id), ) job.celery_task_id = task.id session.add(job) session.commit() session.refresh(job) # Dispatch 2 cut-to-music tasks in parallel (temperature=1 produces different outputs) for run_idx in (1, 2): ctm_video = CutToMusicVideo(project_id=project.id, run_index=run_idx) session.add(ctm_video) session.commit() session.refresh(ctm_video) generate_cut_to_music_task.apply_async( args=[str(project.id), str(ctm_video.id)] ) return job @router.get("/{id}/clips", response_model=ClipsPublic) def read_clips(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any: """ Get clips for a project. """ _get_project_or_403(session, id, current_user) statement = select(Clip).where(Clip.project_id == id).order_by(col(Clip.created_at)) clips = session.exec(statement).all() result = [] for clip in clips: d = clip.model_dump() if clip.segment: d["segment_score"] = clip.segment.score d["segment_description"] = clip.segment.description d["segment_start_time"] = clip.segment.start_time d["segment_end_time"] = clip.segment.end_time result.append(ClipPublic(**d)) return ClipsPublic(data=result, count=len(result)) @router.get("/{id}/video") def stream_video( request: Request, session: SessionDep, id: uuid.UUID, ): """ Stream the source video file with proper range request support for Safari. Note: This endpoint allows unauthenticated access for video playback compatibility. In production, implement proper token-based auth or signed URLs. """ project = session.get(Project, id) if not project: raise HTTPException(status_code=404, detail="Project not found") # Use transcoded version if available, fallback to original if ( project.transcoding_status == TranscodingStatus.COMPLETE and project.transcoded_path and Path(project.transcoded_path).exists() ): video_path = Path(project.transcoded_path) else: video_path = Path(project.video_path) if not video_path.exists(): raise HTTPException(status_code=404, detail="Video file not found") # Get file size file_size = video_path.stat().st_size # Parse range header range_header = request.headers.get("range") if range_header: # Parse range (e.g., "bytes=0-1023") range_str = range_header.replace("bytes=", "") start, end = range_str.split("-") start = int(start) if start else 0 end = int(end) if end 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="{project.video_filename}"', }, ) else: # No range header, return full file return FileResponse( path=video_path, media_type="video/mp4", headers={ "Content-Disposition": f'inline; filename="{project.video_filename}"', "Accept-Ranges": "bytes", }, ) @router.get("/clips/{clip_id}/video") def stream_clip( request: Request, session: SessionDep, clip_id: uuid.UUID ): """ Stream a generated clip file with proper range request support. Note: Allows unauthenticated access for video playback. """ clip = session.get(Clip, clip_id) if not clip: raise HTTPException(status_code=404, detail="Clip not found") clip_path = Path(clip.file_path) if not clip_path.exists(): raise HTTPException(status_code=404, detail="Clip file not found") # Get file size file_size = clip_path.stat().st_size # Parse range header range_header = request.headers.get("range") if range_header: # Parse range (e.g., "bytes=0-1023") range_str = range_header.replace("bytes=", "") start, end = range_str.split("-") start = int(start) if start else 0 end = int(end) if end else file_size - 1 end = min(end, file_size - 1) content_length = end - start + 1 def iterfile(): with open(clip_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="{clip.filename}"', }, ) else: # No range header, return full file return FileResponse( path=clip_path, media_type="video/mp4", headers={ "Content-Disposition": f'inline; filename="{clip.filename}"', "Accept-Ranges": "bytes", }, ) @router.get("/clips/{clip_id}/download") def download_clip( session: SessionDep, clip_id: uuid.UUID ) -> FileResponse: """ Download a generated clip file. Note: Allows unauthenticated access for easy downloading. """ clip = session.get(Clip, clip_id) if not clip: raise HTTPException(status_code=404, detail="Clip not found") clip_path = Path(clip.file_path) if not clip_path.exists(): raise HTTPException(status_code=404, detail="Clip file not found") return FileResponse( path=clip_path, media_type="video/mp4", filename=clip.filename, headers={"Content-Disposition": f'attachment; filename="{clip.filename}"'}, ) @router.post("/{id}/detect-chorus", response_model=Message) def detect_chorus( session: SessionDep, current_user: CurrentUser, id: uuid.UUID ) -> Any: """ Trigger chorus detection for an existing project without re-running full analysis. """ project = _get_project_or_403(session, id, current_user) detect_chorus_task.apply_async(args=[str(project.id)]) return Message(message="Chorus detection started") # ── Extra Videos ────────────────────────────────────────────────────────────── class AddExtraVideoUrlRequest(BaseModel): source_type: ProjectSourceType source_url: str | None = None social_video_id: uuid.UUID | None = None @router.get("/{id}/extra-videos", response_model=list[ExtraVideoPublic]) def list_extra_videos( session: SessionDep, current_user: CurrentUser, id: uuid.UUID ) -> Any: project = _get_project_or_403(session, id, current_user) return project.extra_videos @router.post("/{id}/extra-videos/upload", response_model=ExtraVideoPublic) async def upload_extra_video( session: SessionDep, current_user: CurrentUser, id: uuid.UUID, file: UploadFile = File(...), ) -> Any: _get_project_or_403(session, id, current_user) allowed = {".mp4", ".mov", ".avi", ".mkv", ".webm"} suffix = Path(file.filename or "video.mp4").suffix.lower() if suffix not in allowed: raise HTTPException(status_code=400, detail=f"Unsupported file type: {suffix}") save_dir = ( Path(str(settings.MEDIA_ROOT)) / "uploads" / str(current_user.id) / str(id) / "extras" ) save_dir.mkdir(parents=True, exist_ok=True) safe_name = file.filename or f"extra_{uuid.uuid4()}{suffix}" save_path = save_dir / safe_name async with aiofiles.open(save_path, "wb") as out: while chunk := await file.read(1024 * 1024): await out.write(chunk) ev = ExtraVideo( project_id=id, source_type=ProjectSourceType.LOCAL_UPLOAD, video_filename=safe_name, video_path=str(save_path), status="ready", ) session.add(ev) session.commit() session.refresh(ev) return ev @router.post("/{id}/extra-videos/url", response_model=ExtraVideoPublic) def add_extra_video_url( session: SessionDep, current_user: CurrentUser, id: uuid.UUID, body: AddExtraVideoUrlRequest, ) -> Any: _get_project_or_403(session, id, current_user) if body.source_type == ProjectSourceType.GOOGLE_DRIVE_URL: if not body.source_url: raise HTTPException(status_code=400, detail="source_url required for google_drive_url") ev = ExtraVideo( project_id=id, source_type=body.source_type, source_url=body.source_url, video_filename=body.source_url, status="pending", ) elif body.source_type == ProjectSourceType.ARTIST_SOCIAL: if not body.social_video_id: raise HTTPException(status_code=400, detail="social_video_id required for artist_social") social_video = session.get(SocialVideo, body.social_video_id) if not social_video or not social_video.is_qualified: raise HTTPException(status_code=404, detail="Qualified social video not found") ev = ExtraVideo( project_id=id, source_type=body.source_type, social_video_id=body.social_video_id, video_filename=social_video.filename, video_path=social_video.video_path, status="ready", ) else: raise HTTPException( status_code=400, detail="source_type must be google_drive_url or artist_social" ) session.add(ev) session.commit() session.refresh(ev) return ev @router.get("/{id}/extra-videos/{ev_id}/video") def stream_extra_video( request: Request, session: SessionDep, id: uuid.UUID, ev_id: uuid.UUID, ): """Stream an extra (UGC) video file with range request support.""" project = session.get(Project, id) if not project: raise HTTPException(status_code=404, detail="Project not found") ev = session.get(ExtraVideo, ev_id) if not ev or ev.project_id != id: raise HTTPException(status_code=404, detail="Extra video not found") if not ev.video_path: raise HTTPException(status_code=404, detail="Extra video file path not set") video_path = Path(ev.video_path) if not video_path.exists(): raise HTTPException(status_code=404, detail="Extra video file not found") 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="{ev.video_filename}"', }, ) else: return FileResponse( path=video_path, media_type="video/mp4", headers={ "Content-Disposition": f'inline; filename="{ev.video_filename}"', "Accept-Ranges": "bytes", }, ) @router.delete("/{id}/extra-videos/{ev_id}", response_model=Message) def delete_extra_video( session: SessionDep, current_user: CurrentUser, id: uuid.UUID, ev_id: uuid.UUID, ) -> Any: _get_project_or_403(session, id, current_user) ev = session.get(ExtraVideo, ev_id) if not ev or ev.project_id != id: raise HTTPException(status_code=404, detail="Extra video not found") if ev.video_path: p = Path(ev.video_path) if p.exists(): p.unlink(missing_ok=True) session.delete(ev) session.commit() return Message(message="Extra video deleted") # ── Cut-to-Music Videos ─────────────────────────────────────────────────────── @router.get("/{id}/cut-to-music-videos", response_model=list[CutToMusicVideoPublic]) def list_cut_to_music_videos( session: SessionDep, current_user: CurrentUser, id: uuid.UUID ) -> Any: project = _get_project_or_403(session, id, current_user) return sorted(project.cut_to_music_videos, key=lambda v: v.run_index) @router.get("/cut-to-music-videos/{ctm_id}/video") def stream_cut_to_music_video( request: Request, session: SessionDep, ctm_id: uuid.UUID, ): """Stream a cut-to-music video with range request support.""" ctm = session.get(CutToMusicVideo, ctm_id) if not ctm or not ctm.file_path: raise HTTPException(status_code=404, detail="Cut-to-music video not found") video_path = Path(ctm.file_path) if not video_path.exists(): raise HTTPException(status_code=404, detail="Video file not found") file_size = video_path.stat().st_size range_header = request.headers.get("range") if range_header: range_str = range_header.replace("bytes=", "") start, end = range_str.split("-") start = int(start) if start else 0 end = int(end) if end 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="{ctm.filename}"', }, ) else: return FileResponse( path=video_path, media_type="video/mp4", headers={ "Content-Disposition": f'inline; filename="{ctm.filename}"', "Accept-Ranges": "bytes", }, ) @router.get("/cut-to-music-videos/{ctm_id}/download") def download_cut_to_music_video( session: SessionDep, ctm_id: uuid.UUID, ) -> FileResponse: """Download a cut-to-music video.""" ctm = session.get(CutToMusicVideo, ctm_id) if not ctm or not ctm.file_path: raise HTTPException(status_code=404, detail="Cut-to-music video not found") video_path = Path(ctm.file_path) if not video_path.exists(): raise HTTPException(status_code=404, detail="Video file not found") return FileResponse( path=video_path, media_type="video/mp4", filename=ctm.filename, headers={"Content-Disposition": f'attachment; filename="{ctm.filename}"'}, ) @router.post("/cut-to-music-videos/{ctm_id}/regenerate", response_model=CutToMusicVideoPublic) def regenerate_cut_to_music_video( session: SessionDep, current_user: CurrentUser, ctm_id: uuid.UUID, ) -> Any: """Regenerate a specific cut-to-music video from scratch.""" ctm = session.get(CutToMusicVideo, ctm_id) if not ctm: raise HTTPException(status_code=404, detail="Cut-to-music video not found") project = _get_project_or_403(session, ctm.project_id, current_user, raise_not_found=False) if ctm.file_path: Path(ctm.file_path).unlink(missing_ok=True) ctm.status = ClipStatus.QUEUED ctm.progress_percent = 0.0 ctm.filename = None ctm.file_path = None ctm.error_message = None ctm.completed_at = None ctm.file_size_bytes = None ctm.duration_seconds = None session.add(ctm) session.commit() session.refresh(ctm) generate_cut_to_music_task.apply_async(args=[str(project.id), str(ctm.id)]) return ctm