""" GraphQL API using strawberry, served via FastAPI. Primary API for MPR — all client interactions go through GraphQL. Uses Django ORM directly for data access. Types are generated from schema/ via modelgen — see api/schema/graphql.py. """ import os from typing import List, Optional from uuid import UUID import strawberry from strawberry.types import Info from api.schema.graphql import ( CreateJobInput, DeleteResultType, MediaAssetType, ScanResultType, SystemStatusType, TranscodeJobType, TranscodePresetType, UpdateAssetInput, ) from core.storage import BUCKET_IN, list_objects VIDEO_EXTS = {".mp4", ".mkv", ".avi", ".mov", ".webm", ".flv", ".wmv", ".m4v"} AUDIO_EXTS = {".mp3", ".wav", ".flac", ".aac", ".ogg", ".m4a"} MEDIA_EXTS = VIDEO_EXTS | AUDIO_EXTS # --------------------------------------------------------------------------- # Queries # --------------------------------------------------------------------------- @strawberry.type class Query: @strawberry.field def assets( self, info: Info, status: Optional[str] = None, search: Optional[str] = None, ) -> List[MediaAssetType]: from mpr.media_assets.models import MediaAsset qs = MediaAsset.objects.all() if status: qs = qs.filter(status=status) if search: qs = qs.filter(filename__icontains=search) return list(qs) @strawberry.field def asset(self, info: Info, id: UUID) -> Optional[MediaAssetType]: from mpr.media_assets.models import MediaAsset try: return MediaAsset.objects.get(id=id) except MediaAsset.DoesNotExist: return None @strawberry.field def jobs( self, info: Info, status: Optional[str] = None, source_asset_id: Optional[UUID] = None, ) -> List[TranscodeJobType]: from mpr.media_assets.models import TranscodeJob qs = TranscodeJob.objects.all() if status: qs = qs.filter(status=status) if source_asset_id: qs = qs.filter(source_asset_id=source_asset_id) return list(qs) @strawberry.field def job(self, info: Info, id: UUID) -> Optional[TranscodeJobType]: from mpr.media_assets.models import TranscodeJob try: return TranscodeJob.objects.get(id=id) except TranscodeJob.DoesNotExist: return None @strawberry.field def presets(self, info: Info) -> List[TranscodePresetType]: from mpr.media_assets.models import TranscodePreset return list(TranscodePreset.objects.all()) @strawberry.field def system_status(self, info: Info) -> SystemStatusType: return SystemStatusType(status="ok", version="0.1.0") # --------------------------------------------------------------------------- # Mutations # --------------------------------------------------------------------------- @strawberry.type class Mutation: @strawberry.mutation def scan_media_folder(self, info: Info) -> ScanResultType: from mpr.media_assets.models import MediaAsset objects = list_objects(BUCKET_IN, extensions=MEDIA_EXTS) existing = set(MediaAsset.objects.values_list("filename", flat=True)) registered = [] skipped = [] for obj in objects: if obj["filename"] in existing: skipped.append(obj["filename"]) continue try: MediaAsset.objects.create( filename=obj["filename"], file_path=obj["key"], file_size=obj["size"], ) registered.append(obj["filename"]) except Exception: pass return ScanResultType( found=len(objects), registered=len(registered), skipped=len(skipped), files=registered, ) @strawberry.mutation def create_job(self, info: Info, input: CreateJobInput) -> TranscodeJobType: from pathlib import Path from mpr.media_assets.models import MediaAsset, TranscodeJob, TranscodePreset try: source = MediaAsset.objects.get(id=input.source_asset_id) except MediaAsset.DoesNotExist: raise Exception("Source asset not found") preset = None preset_snapshot = {} if input.preset_id: try: preset = TranscodePreset.objects.get(id=input.preset_id) preset_snapshot = { "name": preset.name, "container": preset.container, "video_codec": preset.video_codec, "audio_codec": preset.audio_codec, } except TranscodePreset.DoesNotExist: raise Exception("Preset not found") if not preset and not input.trim_start and not input.trim_end: raise Exception("Must specify preset_id or trim_start/trim_end") output_filename = input.output_filename if not output_filename: stem = Path(source.filename).stem ext = preset_snapshot.get("container", "mp4") if preset else "mp4" output_filename = f"{stem}_output.{ext}" job = TranscodeJob.objects.create( source_asset_id=source.id, preset_id=preset.id if preset else None, preset_snapshot=preset_snapshot, trim_start=input.trim_start, trim_end=input.trim_end, output_filename=output_filename, output_path=output_filename, priority=input.priority or 0, ) executor_mode = os.environ.get("MPR_EXECUTOR", "local") if executor_mode in ("lambda", "gcp"): from task.executor import get_executor get_executor().run( job_id=str(job.id), source_path=source.file_path, output_path=output_filename, preset=preset_snapshot or None, trim_start=input.trim_start, trim_end=input.trim_end, duration=source.duration, ) else: from task.tasks import run_transcode_job result = run_transcode_job.delay( job_id=str(job.id), source_key=source.file_path, output_key=output_filename, preset=preset_snapshot or None, trim_start=input.trim_start, trim_end=input.trim_end, duration=source.duration, ) job.celery_task_id = result.id job.save(update_fields=["celery_task_id"]) return job @strawberry.mutation def cancel_job(self, info: Info, id: UUID) -> TranscodeJobType: from mpr.media_assets.models import TranscodeJob try: job = TranscodeJob.objects.get(id=id) except TranscodeJob.DoesNotExist: raise Exception("Job not found") if job.status not in ("pending", "processing"): raise Exception(f"Cannot cancel job with status: {job.status}") job.status = "cancelled" job.save(update_fields=["status"]) return job @strawberry.mutation def retry_job(self, info: Info, id: UUID) -> TranscodeJobType: from mpr.media_assets.models import TranscodeJob try: job = TranscodeJob.objects.get(id=id) except TranscodeJob.DoesNotExist: raise Exception("Job not found") if job.status != "failed": raise Exception("Only failed jobs can be retried") job.status = "pending" job.progress = 0 job.error_message = None job.save(update_fields=["status", "progress", "error_message"]) return job @strawberry.mutation def update_asset(self, info: Info, id: UUID, input: UpdateAssetInput) -> MediaAssetType: from mpr.media_assets.models import MediaAsset try: asset = MediaAsset.objects.get(id=id) except MediaAsset.DoesNotExist: raise Exception("Asset not found") update_fields = [] if input.comments is not None: asset.comments = input.comments update_fields.append("comments") if input.tags is not None: asset.tags = input.tags update_fields.append("tags") if update_fields: asset.save(update_fields=update_fields) return asset @strawberry.mutation def delete_asset(self, info: Info, id: UUID) -> DeleteResultType: from mpr.media_assets.models import MediaAsset try: asset = MediaAsset.objects.get(id=id) asset.delete() return DeleteResultType(ok=True) except MediaAsset.DoesNotExist: raise Exception("Asset not found") # --------------------------------------------------------------------------- # Schema # --------------------------------------------------------------------------- schema = strawberry.Schema(query=Query, mutation=Mutation)