major refactor
This commit is contained in:
35
README.md
35
README.md
@@ -71,12 +71,12 @@ docker compose logs -f
|
|||||||
docker compose logs -f celery
|
docker compose logs -f celery
|
||||||
|
|
||||||
# Create admin user
|
# Create admin user
|
||||||
docker compose exec django python manage.py createsuperuser
|
docker compose exec django python admin/manage.py createsuperuser
|
||||||
```
|
```
|
||||||
|
|
||||||
## Code Generation
|
## Code Generation
|
||||||
|
|
||||||
Models are defined as dataclasses in `schema/models/` and generated via `modelgen`:
|
Models are defined as dataclasses in `core/schema/models/` and generated via `modelgen`:
|
||||||
- **Django ORM** models (`--include dataclasses,enums`)
|
- **Django ORM** models (`--include dataclasses,enums`)
|
||||||
- **Pydantic** schemas (`--include dataclasses,enums`)
|
- **Pydantic** schemas (`--include dataclasses,enums`)
|
||||||
- **TypeScript** types (`--include dataclasses,enums,api`)
|
- **TypeScript** types (`--include dataclasses,enums,api`)
|
||||||
@@ -113,26 +113,29 @@ See [docs/media-storage.md](docs/media-storage.md) for full details.
|
|||||||
|
|
||||||
```
|
```
|
||||||
mpr/
|
mpr/
|
||||||
├── api/ # FastAPI application
|
├── admin/ # Django project
|
||||||
│ ├── routes/ # API endpoints
|
│ ├── manage.py # Django management script
|
||||||
│ └── schemas/ # Pydantic models (generated)
|
│ └── mpr/ # Django settings & app
|
||||||
├── core/ # Core utilities
|
│ └── media_assets/# Django app
|
||||||
│ └── ffmpeg/ # FFmpeg wrappers
|
├── core/ # Core application logic
|
||||||
|
│ ├── api/ # FastAPI + GraphQL API
|
||||||
|
│ │ └── schema/ # GraphQL types (generated)
|
||||||
|
│ ├── ffmpeg/ # FFmpeg wrappers
|
||||||
|
│ ├── rpc/ # gRPC server & client
|
||||||
|
│ │ └── protos/ # Protobuf definitions (generated)
|
||||||
|
│ ├── schema/ # Source of truth
|
||||||
|
│ │ └── models/ # Dataclass definitions
|
||||||
|
│ ├── storage/ # S3/GCP/local storage backends
|
||||||
|
│ └── task/ # Celery job execution
|
||||||
|
│ ├── executor.py # Executor abstraction
|
||||||
|
│ └── tasks.py # Celery tasks
|
||||||
├── ctrl/ # Docker & deployment
|
├── ctrl/ # Docker & deployment
|
||||||
│ ├── docker-compose.yml
|
│ ├── docker-compose.yml
|
||||||
│ └── nginx.conf
|
│ └── nginx.conf
|
||||||
├── media/
|
├── media/
|
||||||
│ ├── in/ # Source media files
|
│ ├── in/ # Source media files
|
||||||
│ └── out/ # Transcoded output
|
│ └── out/ # Transcoded output
|
||||||
├── rpc/ # gRPC server & client
|
├── modelgen/ # Code generation tool
|
||||||
│ └── protos/ # Protobuf definitions (generated)
|
|
||||||
├── mpr/ # Django project
|
|
||||||
│ └── media_assets/ # Django app
|
|
||||||
├── schema/ # Source of truth
|
|
||||||
│ └── models/ # Dataclass definitions
|
|
||||||
├── task/ # Celery job execution
|
|
||||||
│ ├── executor.py # Executor abstraction
|
|
||||||
│ └── tasks.py # Celery tasks
|
|
||||||
└── ui/ # Frontend
|
└── ui/ # Frontend
|
||||||
└── timeline/ # React app
|
└── timeline/ # React app
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import sys
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Run administrative tasks."""
|
"""Run administrative tasks."""
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mpr.settings')
|
# Ensure project root is on sys.path
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'admin.mpr.settings')
|
||||||
try:
|
try:
|
||||||
from django.core.management import execute_from_command_line
|
from django.core.management import execute_from_command_line
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
@@ -11,6 +11,6 @@ import os
|
|||||||
|
|
||||||
from django.core.asgi import get_asgi_application
|
from django.core.asgi import get_asgi_application
|
||||||
|
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mpr.settings')
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'admin.mpr.settings')
|
||||||
|
|
||||||
application = get_asgi_application()
|
application = get_asgi_application()
|
||||||
@@ -2,9 +2,9 @@ import os
|
|||||||
|
|
||||||
from celery import Celery
|
from celery import Celery
|
||||||
|
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mpr.settings")
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "admin.mpr.settings")
|
||||||
|
|
||||||
app = Celery("mpr")
|
app = Celery("mpr")
|
||||||
app.config_from_object("django.conf:settings", namespace="CELERY")
|
app.config_from_object("django.conf:settings", namespace="CELERY")
|
||||||
app.autodiscover_tasks()
|
app.autodiscover_tasks()
|
||||||
app.autodiscover_tasks(["task"])
|
app.autodiscover_tasks(["core.task"])
|
||||||
@@ -3,5 +3,6 @@ from django.apps import AppConfig
|
|||||||
|
|
||||||
class MediaAssetsConfig(AppConfig):
|
class MediaAssetsConfig(AppConfig):
|
||||||
default_auto_field = "django.db.models.BigAutoField"
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
name = "mpr.media_assets"
|
name = "admin.mpr.media_assets"
|
||||||
|
label = "media_assets"
|
||||||
verbose_name = "Media Assets"
|
verbose_name = "Media Assets"
|
||||||
@@ -4,10 +4,10 @@ from pathlib import Path
|
|||||||
|
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
from mpr.media_assets.models import TranscodePreset
|
from admin.mpr.media_assets.models import TranscodePreset
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent.parent))
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent.parent.parent))
|
||||||
from schema.models import BUILTIN_PRESETS
|
from core.schema.models import BUILTIN_PRESETS
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
# Generated by Django 6.0.1 on 2026-02-01 15:13
|
# Generated by Django 4.2.29 on 2026-03-13 04:04
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
import uuid
|
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
@@ -13,47 +12,21 @@ class Migration(migrations.Migration):
|
|||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
|
||||||
name='TranscodePreset',
|
|
||||||
fields=[
|
|
||||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
|
||||||
('name', models.CharField(max_length=100, unique=True)),
|
|
||||||
('description', models.TextField(blank=True, default='')),
|
|
||||||
('is_builtin', models.BooleanField(default=False)),
|
|
||||||
('container', models.CharField(default='mp4', max_length=20)),
|
|
||||||
('video_codec', models.CharField(default='libx264', max_length=50)),
|
|
||||||
('video_bitrate', models.CharField(blank=True, max_length=20, null=True)),
|
|
||||||
('video_crf', models.IntegerField(blank=True, null=True)),
|
|
||||||
('video_preset', models.CharField(blank=True, max_length=20, null=True)),
|
|
||||||
('resolution', models.CharField(blank=True, max_length=20, null=True)),
|
|
||||||
('framerate', models.FloatField(blank=True, null=True)),
|
|
||||||
('audio_codec', models.CharField(default='aac', max_length=50)),
|
|
||||||
('audio_bitrate', models.CharField(blank=True, max_length=20, null=True)),
|
|
||||||
('audio_channels', models.IntegerField(blank=True, null=True)),
|
|
||||||
('audio_samplerate', models.IntegerField(blank=True, null=True)),
|
|
||||||
('extra_args', models.JSONField(blank=True, default=list)),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'ordering': ['name'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='MediaAsset',
|
name='MediaAsset',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
('filename', models.CharField(max_length=500)),
|
('filename', models.CharField(max_length=500)),
|
||||||
('file_path', models.CharField(max_length=1000)),
|
('file_path', models.CharField(max_length=1000)),
|
||||||
('status', models.CharField(choices=[('pending', 'Pending Probe'), ('ready', 'Ready'), ('error', 'Error')], default='pending', max_length=20)),
|
('status', models.CharField(choices=[('pending', 'Pending'), ('ready', 'Ready'), ('error', 'Error')], default='pending', max_length=20)),
|
||||||
('error_message', models.TextField(blank=True, null=True)),
|
('error_message', models.TextField(blank=True, default='')),
|
||||||
('file_size', models.BigIntegerField(blank=True, null=True)),
|
('file_size', models.BigIntegerField(blank=True, null=True)),
|
||||||
('duration', models.FloatField(blank=True, null=True)),
|
('duration', models.FloatField(blank=True, default=None, null=True)),
|
||||||
('video_codec', models.CharField(blank=True, max_length=50, null=True)),
|
('video_codec', models.CharField(blank=True, max_length=255, null=True)),
|
||||||
('audio_codec', models.CharField(blank=True, max_length=50, null=True)),
|
('audio_codec', models.CharField(blank=True, max_length=255, null=True)),
|
||||||
('width', models.IntegerField(blank=True, null=True)),
|
('width', models.IntegerField(blank=True, default=None, null=True)),
|
||||||
('height', models.IntegerField(blank=True, null=True)),
|
('height', models.IntegerField(blank=True, default=None, null=True)),
|
||||||
('framerate', models.FloatField(blank=True, null=True)),
|
('framerate', models.FloatField(blank=True, default=None, null=True)),
|
||||||
('bitrate', models.BigIntegerField(blank=True, null=True)),
|
('bitrate', models.BigIntegerField(blank=True, null=True)),
|
||||||
('properties', models.JSONField(blank=True, default=dict)),
|
('properties', models.JSONField(blank=True, default=dict)),
|
||||||
('comments', models.TextField(blank=True, default='')),
|
('comments', models.TextField(blank=True, default='')),
|
||||||
@@ -63,36 +36,61 @@ class Migration(migrations.Migration):
|
|||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'ordering': ['-created_at'],
|
'ordering': ['-created_at'],
|
||||||
'indexes': [models.Index(fields=['status'], name='media_asset_status_9ea2f2_idx'), models.Index(fields=['created_at'], name='media_asset_created_368039_idx')],
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='TranscodeJob',
|
name='TranscodeJob',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('source_asset_id', models.UUIDField()),
|
||||||
|
('preset_id', models.UUIDField(blank=True, null=True)),
|
||||||
('preset_snapshot', models.JSONField(blank=True, default=dict)),
|
('preset_snapshot', models.JSONField(blank=True, default=dict)),
|
||||||
('trim_start', models.FloatField(blank=True, null=True)),
|
('trim_start', models.FloatField(blank=True, default=None, null=True)),
|
||||||
('trim_end', models.FloatField(blank=True, null=True)),
|
('trim_end', models.FloatField(blank=True, default=None, null=True)),
|
||||||
('output_filename', models.CharField(max_length=500)),
|
('output_filename', models.CharField(max_length=500)),
|
||||||
('output_path', models.CharField(blank=True, max_length=1000, null=True)),
|
('output_path', models.CharField(blank=True, max_length=1000, null=True)),
|
||||||
|
('output_asset_id', models.UUIDField(blank=True, null=True)),
|
||||||
('status', models.CharField(choices=[('pending', 'Pending'), ('processing', 'Processing'), ('completed', 'Completed'), ('failed', 'Failed'), ('cancelled', 'Cancelled')], default='pending', max_length=20)),
|
('status', models.CharField(choices=[('pending', 'Pending'), ('processing', 'Processing'), ('completed', 'Completed'), ('failed', 'Failed'), ('cancelled', 'Cancelled')], default='pending', max_length=20)),
|
||||||
('progress', models.FloatField(default=0.0)),
|
('progress', models.FloatField(default=0.0)),
|
||||||
('current_frame', models.IntegerField(blank=True, null=True)),
|
('current_frame', models.IntegerField(blank=True, default=None, null=True)),
|
||||||
('current_time', models.FloatField(blank=True, null=True)),
|
('current_time', models.FloatField(blank=True, default=None, null=True)),
|
||||||
('speed', models.CharField(blank=True, max_length=20, null=True)),
|
('speed', models.CharField(blank=True, max_length=255, null=True)),
|
||||||
('error_message', models.TextField(blank=True, null=True)),
|
('error_message', models.TextField(blank=True, default='')),
|
||||||
('celery_task_id', models.CharField(blank=True, max_length=100, null=True)),
|
('celery_task_id', models.CharField(blank=True, max_length=255, null=True)),
|
||||||
|
('execution_arn', models.CharField(blank=True, max_length=255, null=True)),
|
||||||
('priority', models.IntegerField(default=0)),
|
('priority', models.IntegerField(default=0)),
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
('started_at', models.DateTimeField(blank=True, null=True)),
|
('started_at', models.DateTimeField(blank=True, null=True)),
|
||||||
('completed_at', models.DateTimeField(blank=True, null=True)),
|
('completed_at', models.DateTimeField(blank=True, null=True)),
|
||||||
('output_asset', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_jobs', to='media_assets.mediaasset')),
|
|
||||||
('source_asset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transcode_jobs', to='media_assets.mediaasset')),
|
|
||||||
('preset', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='jobs', to='media_assets.transcodepreset')),
|
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'ordering': ['priority', 'created_at'],
|
'ordering': ['-created_at'],
|
||||||
'indexes': [models.Index(fields=['status', 'priority'], name='media_asset_status_e6ac18_idx'), models.Index(fields=['created_at'], name='media_asset_created_ba3a46_idx'), models.Index(fields=['celery_task_id'], name='media_asset_celery__81a88e_idx')],
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='TranscodePreset',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('name', models.CharField(max_length=255)),
|
||||||
|
('description', models.TextField(blank=True, default='')),
|
||||||
|
('is_builtin', models.BooleanField(default=False)),
|
||||||
|
('container', models.CharField(max_length=255)),
|
||||||
|
('video_codec', models.CharField(max_length=255)),
|
||||||
|
('video_bitrate', models.CharField(blank=True, max_length=255, null=True)),
|
||||||
|
('video_crf', models.IntegerField(blank=True, default=None, null=True)),
|
||||||
|
('video_preset', models.CharField(blank=True, max_length=255, null=True)),
|
||||||
|
('resolution', models.CharField(blank=True, max_length=255, null=True)),
|
||||||
|
('framerate', models.FloatField(blank=True, default=None, null=True)),
|
||||||
|
('audio_codec', models.CharField(max_length=255)),
|
||||||
|
('audio_bitrate', models.CharField(blank=True, max_length=255, null=True)),
|
||||||
|
('audio_channels', models.IntegerField(blank=True, default=None, null=True)),
|
||||||
|
('audio_samplerate', models.IntegerField(blank=True, default=None, null=True)),
|
||||||
|
('extra_args', models.JSONField(blank=True, default=list)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
0
admin/mpr/media_assets/migrations/__init__.py
Normal file
0
admin/mpr/media_assets/migrations/__init__.py
Normal file
@@ -7,7 +7,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import environ
|
import environ
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||||
|
|
||||||
env = environ.Env(
|
env = environ.Env(
|
||||||
DEBUG=(bool, False),
|
DEBUG=(bool, False),
|
||||||
@@ -27,7 +27,7 @@ INSTALLED_APPS = [
|
|||||||
"django.contrib.sessions",
|
"django.contrib.sessions",
|
||||||
"django.contrib.messages",
|
"django.contrib.messages",
|
||||||
"django.contrib.staticfiles",
|
"django.contrib.staticfiles",
|
||||||
"mpr.media_assets",
|
"admin.mpr.media_assets",
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
@@ -40,7 +40,7 @@ MIDDLEWARE = [
|
|||||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
]
|
]
|
||||||
|
|
||||||
ROOT_URLCONF = "mpr.urls"
|
ROOT_URLCONF = "admin.mpr.urls"
|
||||||
|
|
||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
{
|
{
|
||||||
@@ -57,7 +57,7 @@ TEMPLATES = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
WSGI_APPLICATION = "mpr.wsgi.application"
|
WSGI_APPLICATION = "admin.mpr.wsgi.application"
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
DATABASE_URL = env("DATABASE_URL", default="sqlite:///db.sqlite3")
|
DATABASE_URL = env("DATABASE_URL", default="sqlite:///db.sqlite3")
|
||||||
@@ -11,6 +11,6 @@ import os
|
|||||||
|
|
||||||
from django.core.wsgi import get_wsgi_application
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mpr.settings')
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'admin.mpr.settings')
|
||||||
|
|
||||||
application = get_wsgi_application()
|
application = get_wsgi_application()
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
GraphQL API using strawberry, served via FastAPI.
|
GraphQL API using strawberry, served via FastAPI.
|
||||||
|
|
||||||
Primary API for MPR — all client interactions go through GraphQL.
|
Primary API for MPR — all client interactions go through GraphQL.
|
||||||
Uses Django ORM directly for data access.
|
Uses core.db for data access.
|
||||||
Types are generated from schema/ via modelgen — see api/schema/graphql.py.
|
Types are generated from schema/ via modelgen — see api/schema/graphql.py.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -11,9 +11,10 @@ from typing import List, Optional
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import strawberry
|
import strawberry
|
||||||
|
from strawberry.schema.config import StrawberryConfig
|
||||||
from strawberry.types import Info
|
from strawberry.types import Info
|
||||||
|
|
||||||
from api.schema.graphql import (
|
from core.api.schema.graphql import (
|
||||||
CreateJobInput,
|
CreateJobInput,
|
||||||
DeleteResultType,
|
DeleteResultType,
|
||||||
MediaAssetType,
|
MediaAssetType,
|
||||||
@@ -44,22 +45,17 @@ class Query:
|
|||||||
status: Optional[str] = None,
|
status: Optional[str] = None,
|
||||||
search: Optional[str] = None,
|
search: Optional[str] = None,
|
||||||
) -> List[MediaAssetType]:
|
) -> List[MediaAssetType]:
|
||||||
from mpr.media_assets.models import MediaAsset
|
from core.db import list_assets
|
||||||
|
|
||||||
qs = MediaAsset.objects.all()
|
return list_assets(status=status, search=search)
|
||||||
if status:
|
|
||||||
qs = qs.filter(status=status)
|
|
||||||
if search:
|
|
||||||
qs = qs.filter(filename__icontains=search)
|
|
||||||
return list(qs)
|
|
||||||
|
|
||||||
@strawberry.field
|
@strawberry.field
|
||||||
def asset(self, info: Info, id: UUID) -> Optional[MediaAssetType]:
|
def asset(self, info: Info, id: UUID) -> Optional[MediaAssetType]:
|
||||||
from mpr.media_assets.models import MediaAsset
|
from core.db import get_asset
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return MediaAsset.objects.get(id=id)
|
return get_asset(id)
|
||||||
except MediaAsset.DoesNotExist:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@strawberry.field
|
@strawberry.field
|
||||||
@@ -69,29 +65,24 @@ class Query:
|
|||||||
status: Optional[str] = None,
|
status: Optional[str] = None,
|
||||||
source_asset_id: Optional[UUID] = None,
|
source_asset_id: Optional[UUID] = None,
|
||||||
) -> List[TranscodeJobType]:
|
) -> List[TranscodeJobType]:
|
||||||
from mpr.media_assets.models import TranscodeJob
|
from core.db import list_jobs
|
||||||
|
|
||||||
qs = TranscodeJob.objects.all()
|
return list_jobs(status=status, source_asset_id=source_asset_id)
|
||||||
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
|
@strawberry.field
|
||||||
def job(self, info: Info, id: UUID) -> Optional[TranscodeJobType]:
|
def job(self, info: Info, id: UUID) -> Optional[TranscodeJobType]:
|
||||||
from mpr.media_assets.models import TranscodeJob
|
from core.db import get_job
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return TranscodeJob.objects.get(id=id)
|
return get_job(id)
|
||||||
except TranscodeJob.DoesNotExist:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@strawberry.field
|
@strawberry.field
|
||||||
def presets(self, info: Info) -> List[TranscodePresetType]:
|
def presets(self, info: Info) -> List[TranscodePresetType]:
|
||||||
from mpr.media_assets.models import TranscodePreset
|
from core.db import list_presets
|
||||||
|
|
||||||
return list(TranscodePreset.objects.all())
|
return list_presets()
|
||||||
|
|
||||||
@strawberry.field
|
@strawberry.field
|
||||||
def system_status(self, info: Info) -> SystemStatusType:
|
def system_status(self, info: Info) -> SystemStatusType:
|
||||||
@@ -107,10 +98,10 @@ class Query:
|
|||||||
class Mutation:
|
class Mutation:
|
||||||
@strawberry.mutation
|
@strawberry.mutation
|
||||||
def scan_media_folder(self, info: Info) -> ScanResultType:
|
def scan_media_folder(self, info: Info) -> ScanResultType:
|
||||||
from mpr.media_assets.models import MediaAsset
|
from core.db import create_asset, get_asset_filenames
|
||||||
|
|
||||||
objects = list_objects(BUCKET_IN, extensions=MEDIA_EXTS)
|
objects = list_objects(BUCKET_IN, extensions=MEDIA_EXTS)
|
||||||
existing = set(MediaAsset.objects.values_list("filename", flat=True))
|
existing = get_asset_filenames()
|
||||||
|
|
||||||
registered = []
|
registered = []
|
||||||
skipped = []
|
skipped = []
|
||||||
@@ -120,7 +111,7 @@ class Mutation:
|
|||||||
skipped.append(obj["filename"])
|
skipped.append(obj["filename"])
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
MediaAsset.objects.create(
|
create_asset(
|
||||||
filename=obj["filename"],
|
filename=obj["filename"],
|
||||||
file_path=obj["key"],
|
file_path=obj["key"],
|
||||||
file_size=obj["size"],
|
file_size=obj["size"],
|
||||||
@@ -140,25 +131,25 @@ class Mutation:
|
|||||||
def create_job(self, info: Info, input: CreateJobInput) -> TranscodeJobType:
|
def create_job(self, info: Info, input: CreateJobInput) -> TranscodeJobType:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from mpr.media_assets.models import MediaAsset, TranscodeJob, TranscodePreset
|
from core.db import create_job, get_asset, get_preset
|
||||||
|
|
||||||
try:
|
try:
|
||||||
source = MediaAsset.objects.get(id=input.source_asset_id)
|
source = get_asset(input.source_asset_id)
|
||||||
except MediaAsset.DoesNotExist:
|
except Exception:
|
||||||
raise Exception("Source asset not found")
|
raise Exception("Source asset not found")
|
||||||
|
|
||||||
preset = None
|
preset = None
|
||||||
preset_snapshot = {}
|
preset_snapshot = {}
|
||||||
if input.preset_id:
|
if input.preset_id:
|
||||||
try:
|
try:
|
||||||
preset = TranscodePreset.objects.get(id=input.preset_id)
|
preset = get_preset(input.preset_id)
|
||||||
preset_snapshot = {
|
preset_snapshot = {
|
||||||
"name": preset.name,
|
"name": preset.name,
|
||||||
"container": preset.container,
|
"container": preset.container,
|
||||||
"video_codec": preset.video_codec,
|
"video_codec": preset.video_codec,
|
||||||
"audio_codec": preset.audio_codec,
|
"audio_codec": preset.audio_codec,
|
||||||
}
|
}
|
||||||
except TranscodePreset.DoesNotExist:
|
except Exception:
|
||||||
raise Exception("Preset not found")
|
raise Exception("Preset not found")
|
||||||
|
|
||||||
if not preset and not input.trim_start and not input.trim_end:
|
if not preset and not input.trim_start and not input.trim_end:
|
||||||
@@ -170,7 +161,7 @@ class Mutation:
|
|||||||
ext = preset_snapshot.get("container", "mp4") if preset else "mp4"
|
ext = preset_snapshot.get("container", "mp4") if preset else "mp4"
|
||||||
output_filename = f"{stem}_output.{ext}"
|
output_filename = f"{stem}_output.{ext}"
|
||||||
|
|
||||||
job = TranscodeJob.objects.create(
|
job = create_job(
|
||||||
source_asset_id=source.id,
|
source_asset_id=source.id,
|
||||||
preset_id=preset.id if preset else None,
|
preset_id=preset.id if preset else None,
|
||||||
preset_snapshot=preset_snapshot,
|
preset_snapshot=preset_snapshot,
|
||||||
@@ -183,7 +174,7 @@ class Mutation:
|
|||||||
|
|
||||||
executor_mode = os.environ.get("MPR_EXECUTOR", "local")
|
executor_mode = os.environ.get("MPR_EXECUTOR", "local")
|
||||||
if executor_mode in ("lambda", "gcp"):
|
if executor_mode in ("lambda", "gcp"):
|
||||||
from task.executor import get_executor
|
from core.task.executor import get_executor
|
||||||
|
|
||||||
get_executor().run(
|
get_executor().run(
|
||||||
job_id=str(job.id),
|
job_id=str(job.id),
|
||||||
@@ -195,7 +186,7 @@ class Mutation:
|
|||||||
duration=source.duration,
|
duration=source.duration,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
from task.tasks import run_transcode_job
|
from core.task.tasks import run_transcode_job
|
||||||
|
|
||||||
result = run_transcode_job.delay(
|
result = run_transcode_job.delay(
|
||||||
job_id=str(job.id),
|
job_id=str(job.id),
|
||||||
@@ -213,69 +204,61 @@ class Mutation:
|
|||||||
|
|
||||||
@strawberry.mutation
|
@strawberry.mutation
|
||||||
def cancel_job(self, info: Info, id: UUID) -> TranscodeJobType:
|
def cancel_job(self, info: Info, id: UUID) -> TranscodeJobType:
|
||||||
from mpr.media_assets.models import TranscodeJob
|
from core.db import get_job, update_job
|
||||||
|
|
||||||
try:
|
try:
|
||||||
job = TranscodeJob.objects.get(id=id)
|
job = get_job(id)
|
||||||
except TranscodeJob.DoesNotExist:
|
except Exception:
|
||||||
raise Exception("Job not found")
|
raise Exception("Job not found")
|
||||||
|
|
||||||
if job.status not in ("pending", "processing"):
|
if job.status not in ("pending", "processing"):
|
||||||
raise Exception(f"Cannot cancel job with status: {job.status}")
|
raise Exception(f"Cannot cancel job with status: {job.status}")
|
||||||
|
|
||||||
job.status = "cancelled"
|
return update_job(job, status="cancelled")
|
||||||
job.save(update_fields=["status"])
|
|
||||||
return job
|
|
||||||
|
|
||||||
@strawberry.mutation
|
@strawberry.mutation
|
||||||
def retry_job(self, info: Info, id: UUID) -> TranscodeJobType:
|
def retry_job(self, info: Info, id: UUID) -> TranscodeJobType:
|
||||||
from mpr.media_assets.models import TranscodeJob
|
from core.db import get_job, update_job
|
||||||
|
|
||||||
try:
|
try:
|
||||||
job = TranscodeJob.objects.get(id=id)
|
job = get_job(id)
|
||||||
except TranscodeJob.DoesNotExist:
|
except Exception:
|
||||||
raise Exception("Job not found")
|
raise Exception("Job not found")
|
||||||
|
|
||||||
if job.status != "failed":
|
if job.status != "failed":
|
||||||
raise Exception("Only failed jobs can be retried")
|
raise Exception("Only failed jobs can be retried")
|
||||||
|
|
||||||
job.status = "pending"
|
return update_job(job, status="pending", progress=0, error_message=None)
|
||||||
job.progress = 0
|
|
||||||
job.error_message = None
|
|
||||||
job.save(update_fields=["status", "progress", "error_message"])
|
|
||||||
return job
|
|
||||||
|
|
||||||
@strawberry.mutation
|
@strawberry.mutation
|
||||||
def update_asset(self, info: Info, id: UUID, input: UpdateAssetInput) -> MediaAssetType:
|
def update_asset(self, info: Info, id: UUID, input: UpdateAssetInput) -> MediaAssetType:
|
||||||
from mpr.media_assets.models import MediaAsset
|
from core.db import get_asset, update_asset
|
||||||
|
|
||||||
try:
|
try:
|
||||||
asset = MediaAsset.objects.get(id=id)
|
asset = get_asset(id)
|
||||||
except MediaAsset.DoesNotExist:
|
except Exception:
|
||||||
raise Exception("Asset not found")
|
raise Exception("Asset not found")
|
||||||
|
|
||||||
update_fields = []
|
fields = {}
|
||||||
if input.comments is not None:
|
if input.comments is not None:
|
||||||
asset.comments = input.comments
|
fields["comments"] = input.comments
|
||||||
update_fields.append("comments")
|
|
||||||
if input.tags is not None:
|
if input.tags is not None:
|
||||||
asset.tags = input.tags
|
fields["tags"] = input.tags
|
||||||
update_fields.append("tags")
|
|
||||||
|
|
||||||
if update_fields:
|
if fields:
|
||||||
asset.save(update_fields=update_fields)
|
asset = update_asset(asset, **fields)
|
||||||
|
|
||||||
return asset
|
return asset
|
||||||
|
|
||||||
@strawberry.mutation
|
@strawberry.mutation
|
||||||
def delete_asset(self, info: Info, id: UUID) -> DeleteResultType:
|
def delete_asset(self, info: Info, id: UUID) -> DeleteResultType:
|
||||||
from mpr.media_assets.models import MediaAsset
|
from core.db import delete_asset, get_asset
|
||||||
|
|
||||||
try:
|
try:
|
||||||
asset = MediaAsset.objects.get(id=id)
|
asset = get_asset(id)
|
||||||
asset.delete()
|
delete_asset(asset)
|
||||||
return DeleteResultType(ok=True)
|
return DeleteResultType(ok=True)
|
||||||
except MediaAsset.DoesNotExist:
|
except Exception:
|
||||||
raise Exception("Asset not found")
|
raise Exception("Asset not found")
|
||||||
|
|
||||||
|
|
||||||
@@ -283,4 +266,8 @@ class Mutation:
|
|||||||
# Schema
|
# Schema
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
schema = strawberry.Schema(query=Query, mutation=Mutation)
|
schema = strawberry.Schema(
|
||||||
|
query=Query,
|
||||||
|
mutation=Mutation,
|
||||||
|
config=StrawberryConfig(auto_camel_case=False),
|
||||||
|
)
|
||||||
@@ -10,10 +10,10 @@ from typing import Optional
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
# Add project root to path
|
# Add project root to path
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||||
|
|
||||||
# Initialize Django before importing models
|
# Initialize Django before importing models
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mpr.settings")
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "admin.mpr.settings")
|
||||||
|
|
||||||
import django
|
import django
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ from fastapi import FastAPI, Header, HTTPException
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from strawberry.fastapi import GraphQLRouter
|
from strawberry.fastapi import GraphQLRouter
|
||||||
|
|
||||||
from api.graphql import schema as graphql_schema
|
from core.api.graphql import schema as graphql_schema
|
||||||
|
|
||||||
CALLBACK_API_KEY = os.environ.get("CALLBACK_API_KEY", "")
|
CALLBACK_API_KEY = os.environ.get("CALLBACK_API_KEY", "")
|
||||||
|
|
||||||
@@ -74,26 +74,25 @@ def job_callback(
|
|||||||
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from mpr.media_assets.models import TranscodeJob
|
from core.db import get_job, update_job
|
||||||
|
|
||||||
try:
|
try:
|
||||||
job = TranscodeJob.objects.get(id=job_id)
|
job = get_job(job_id)
|
||||||
except TranscodeJob.DoesNotExist:
|
except Exception:
|
||||||
raise HTTPException(status_code=404, detail="Job not found")
|
raise HTTPException(status_code=404, detail="Job not found")
|
||||||
|
|
||||||
status = payload.get("status", "failed")
|
status = payload.get("status", "failed")
|
||||||
job.status = status
|
fields = {
|
||||||
job.progress = 100.0 if status == "completed" else job.progress
|
"status": status,
|
||||||
update_fields = ["status", "progress"]
|
"progress": 100.0 if status == "completed" else job.progress,
|
||||||
|
}
|
||||||
|
|
||||||
if payload.get("error"):
|
if payload.get("error"):
|
||||||
job.error_message = payload["error"]
|
fields["error_message"] = payload["error"]
|
||||||
update_fields.append("error_message")
|
|
||||||
|
|
||||||
if status in ("completed", "failed"):
|
if status in ("completed", "failed"):
|
||||||
job.completed_at = timezone.now()
|
fields["completed_at"] = timezone.now()
|
||||||
update_fields.append("completed_at")
|
|
||||||
|
|
||||||
job.save(update_fields=update_fields)
|
update_job(job, **fields)
|
||||||
|
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
19
core/db/__init__.py
Normal file
19
core/db/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from .assets import (
|
||||||
|
create_asset,
|
||||||
|
delete_asset,
|
||||||
|
get_asset,
|
||||||
|
get_asset_filenames,
|
||||||
|
list_assets,
|
||||||
|
update_asset,
|
||||||
|
)
|
||||||
|
from .jobs import (
|
||||||
|
create_job,
|
||||||
|
get_job,
|
||||||
|
list_jobs,
|
||||||
|
update_job,
|
||||||
|
update_job_fields,
|
||||||
|
)
|
||||||
|
from .presets import (
|
||||||
|
get_preset,
|
||||||
|
list_presets,
|
||||||
|
)
|
||||||
48
core/db/assets.py
Normal file
48
core/db/assets.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"""Database operations for MediaAsset."""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
|
||||||
|
def list_assets(status: Optional[str] = None, search: Optional[str] = None):
|
||||||
|
from admin.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)
|
||||||
|
|
||||||
|
|
||||||
|
def get_asset(id: UUID):
|
||||||
|
from admin.mpr.media_assets.models import MediaAsset
|
||||||
|
|
||||||
|
return MediaAsset.objects.get(id=id)
|
||||||
|
|
||||||
|
|
||||||
|
def get_asset_filenames() -> set[str]:
|
||||||
|
from admin.mpr.media_assets.models import MediaAsset
|
||||||
|
|
||||||
|
return set(MediaAsset.objects.values_list("filename", flat=True))
|
||||||
|
|
||||||
|
|
||||||
|
def create_asset(*, filename: str, file_path: str, file_size: int):
|
||||||
|
from admin.mpr.media_assets.models import MediaAsset
|
||||||
|
|
||||||
|
return MediaAsset.objects.create(
|
||||||
|
filename=filename,
|
||||||
|
file_path=file_path,
|
||||||
|
file_size=file_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def update_asset(asset, **fields):
|
||||||
|
for key, value in fields.items():
|
||||||
|
setattr(asset, key, value)
|
||||||
|
asset.save(update_fields=list(fields.keys()))
|
||||||
|
return asset
|
||||||
|
|
||||||
|
|
||||||
|
def delete_asset(asset):
|
||||||
|
asset.delete()
|
||||||
40
core/db/jobs.py
Normal file
40
core/db/jobs.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"""Database operations for TranscodeJob."""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
|
||||||
|
def list_jobs(status: Optional[str] = None, source_asset_id: Optional[UUID] = None):
|
||||||
|
from admin.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)
|
||||||
|
|
||||||
|
|
||||||
|
def get_job(id: UUID):
|
||||||
|
from admin.mpr.media_assets.models import TranscodeJob
|
||||||
|
|
||||||
|
return TranscodeJob.objects.get(id=id)
|
||||||
|
|
||||||
|
|
||||||
|
def create_job(**fields):
|
||||||
|
from admin.mpr.media_assets.models import TranscodeJob
|
||||||
|
|
||||||
|
return TranscodeJob.objects.create(**fields)
|
||||||
|
|
||||||
|
|
||||||
|
def update_job(job, **fields):
|
||||||
|
for key, value in fields.items():
|
||||||
|
setattr(job, key, value)
|
||||||
|
job.save(update_fields=list(fields.keys()))
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
def update_job_fields(job_id, **fields):
|
||||||
|
from admin.mpr.media_assets.models import TranscodeJob
|
||||||
|
|
||||||
|
TranscodeJob.objects.filter(id=job_id).update(**fields)
|
||||||
15
core/db/presets.py
Normal file
15
core/db/presets.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
"""Database operations for TranscodePreset."""
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
|
||||||
|
def list_presets():
|
||||||
|
from admin.mpr.media_assets.models import TranscodePreset
|
||||||
|
|
||||||
|
return list(TranscodePreset.objects.all())
|
||||||
|
|
||||||
|
|
||||||
|
def get_preset(id: UUID):
|
||||||
|
from admin.mpr.media_assets.models import TranscodePreset
|
||||||
|
|
||||||
|
return TranscodePreset.objects.get(id=id)
|
||||||
@@ -59,7 +59,7 @@ class WorkerServicer(worker_pb2_grpc.WorkerServiceServicer):
|
|||||||
|
|
||||||
# Dispatch to Celery if available
|
# Dispatch to Celery if available
|
||||||
if self.celery_app:
|
if self.celery_app:
|
||||||
from task.tasks import run_transcode_job
|
from core.task.tasks import run_transcode_job
|
||||||
|
|
||||||
task = run_transcode_job.delay(
|
task = run_transcode_job.delay(
|
||||||
job_id=job_id,
|
job_id=job_id,
|
||||||
@@ -219,9 +219,8 @@ def update_job_progress(
|
|||||||
try:
|
try:
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from mpr.media_assets.models import TranscodeJob
|
from core.db import update_job_fields
|
||||||
|
|
||||||
update_fields = ["progress", "current_frame", "current_time", "speed", "status"]
|
|
||||||
updates = {
|
updates = {
|
||||||
"progress": progress,
|
"progress": progress,
|
||||||
"current_frame": current_frame,
|
"current_frame": current_frame,
|
||||||
@@ -232,16 +231,13 @@ def update_job_progress(
|
|||||||
|
|
||||||
if error:
|
if error:
|
||||||
updates["error_message"] = error
|
updates["error_message"] = error
|
||||||
update_fields.append("error_message")
|
|
||||||
|
|
||||||
if status == "processing":
|
if status == "processing":
|
||||||
updates["started_at"] = timezone.now()
|
updates["started_at"] = timezone.now()
|
||||||
update_fields.append("started_at")
|
|
||||||
elif status in ("completed", "failed"):
|
elif status in ("completed", "failed"):
|
||||||
updates["completed_at"] = timezone.now()
|
updates["completed_at"] = timezone.now()
|
||||||
update_fields.append("completed_at")
|
|
||||||
|
|
||||||
TranscodeJob.objects.filter(id=job_id).update(**updates)
|
update_job_fields(job_id, **updates)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to update job {job_id} in DB: {e}")
|
logger.warning(f"Failed to update job {job_id} in DB: {e}")
|
||||||
|
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"schema": "schema/models",
|
"schema": "core/schema/models",
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"target": "django",
|
"target": "django",
|
||||||
"output": "mpr/media_assets/models.py",
|
"output": "admin/mpr/media_assets/models.py",
|
||||||
"include": ["dataclasses", "enums"]
|
"include": ["dataclasses", "enums"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"target": "graphene",
|
"target": "graphene",
|
||||||
"output": "api/schema/graphql.py",
|
"output": "core/api/schema/graphql.py",
|
||||||
"include": ["dataclasses", "enums", "api"]
|
"include": ["dataclasses", "enums", "api"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"target": "protobuf",
|
"target": "protobuf",
|
||||||
"output": "rpc/protos/worker.proto",
|
"output": "core/rpc/protos/worker.proto",
|
||||||
"include": ["grpc"]
|
"include": ["grpc"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
10
core/storage/__init__.py
Normal file
10
core/storage/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from .s3 import (
|
||||||
|
BUCKET_IN,
|
||||||
|
BUCKET_OUT,
|
||||||
|
download_file,
|
||||||
|
download_to_temp,
|
||||||
|
get_presigned_url,
|
||||||
|
get_s3_client,
|
||||||
|
list_objects,
|
||||||
|
upload_file,
|
||||||
|
)
|
||||||
1
core/storage/gcp.py
Normal file
1
core/storage/gcp.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""GCP Cloud Storage backend (placeholder)."""
|
||||||
1
core/storage/local.py
Normal file
1
core/storage/local.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Local filesystem storage backend (placeholder)."""
|
||||||
@@ -156,8 +156,8 @@ class LambdaExecutor(Executor):
|
|||||||
# Store execution ARN on the job
|
# Store execution ARN on the job
|
||||||
execution_arn = response["executionArn"]
|
execution_arn = response["executionArn"]
|
||||||
try:
|
try:
|
||||||
from mpr.media_assets.models import TranscodeJob
|
from core.db import update_job_fields
|
||||||
TranscodeJob.objects.filter(id=job_id).update(execution_arn=execution_arn)
|
update_job_fields(job_id, execution_arn=execution_arn)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -228,9 +228,9 @@ class GCPExecutor(Executor):
|
|||||||
execution_name = operation.metadata.name
|
execution_name = operation.metadata.name
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from mpr.media_assets.models import TranscodeJob
|
from core.db import update_job_fields
|
||||||
|
|
||||||
TranscodeJob.objects.filter(id=job_id).update(execution_arn=execution_name)
|
update_job_fields(job_id, execution_arn=execution_name)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -9,8 +9,8 @@ from typing import Any, Dict, Optional
|
|||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
|
|
||||||
from core.storage import BUCKET_IN, BUCKET_OUT, download_to_temp, upload_file
|
from core.storage import BUCKET_IN, BUCKET_OUT, download_to_temp, upload_file
|
||||||
from rpc.server import update_job_progress
|
from core.rpc.server import update_job_progress
|
||||||
from task.executor import get_executor
|
from core.task.executor import get_executor
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ REDIS_URL=redis://redis:6379/0
|
|||||||
|
|
||||||
# Django
|
# Django
|
||||||
DEBUG=1
|
DEBUG=1
|
||||||
DJANGO_SETTINGS_MODULE=mpr.settings
|
DJANGO_SETTINGS_MODULE=admin.mpr.settings
|
||||||
SECRET_KEY=change-this-in-production
|
SECRET_KEY=change-this-in-production
|
||||||
|
|
||||||
# Worker
|
# Worker
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y \
|
|
||||||
ffmpeg \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
@@ -11,4 +7,4 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
CMD ["python", "admin/manage.py", "runserver", "0.0.0.0:8000"]
|
||||||
|
|||||||
14
ctrl/Dockerfile.worker
Normal file
14
ctrl/Dockerfile.worker
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
ffmpeg \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt requirements-worker.txt ./
|
||||||
|
RUN pip install --no-cache-dir -r requirements-worker.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
CMD ["celery", "-A", "admin.mpr", "worker", "--loglevel=info"]
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
x-common-env: &common-env
|
x-common-env: &common-env
|
||||||
DATABASE_URL: postgresql://mpr_user:mpr_pass@postgres:5432/mpr
|
DATABASE_URL: postgresql://mpr_user:mpr_pass@postgres:5432/mpr
|
||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
DJANGO_SETTINGS_MODULE: mpr.settings
|
DJANGO_SETTINGS_MODULE: admin.mpr.settings
|
||||||
DEBUG: 1
|
DEBUG: 1
|
||||||
GRPC_HOST: grpc
|
GRPC_HOST: grpc
|
||||||
GRPC_PORT: 50051
|
GRPC_PORT: 50051
|
||||||
@@ -96,9 +96,9 @@ services:
|
|||||||
context: ..
|
context: ..
|
||||||
dockerfile: ctrl/Dockerfile
|
dockerfile: ctrl/Dockerfile
|
||||||
command: >
|
command: >
|
||||||
bash -c "python manage.py migrate &&
|
bash -c "python admin/manage.py migrate &&
|
||||||
python manage.py loadbuiltins || true &&
|
python admin/manage.py loadbuiltins || true &&
|
||||||
python manage.py runserver 0.0.0.0:8701"
|
python admin/manage.py runserver 0.0.0.0:8701"
|
||||||
ports:
|
ports:
|
||||||
- "8701:8701"
|
- "8701:8701"
|
||||||
environment:
|
environment:
|
||||||
@@ -115,11 +115,12 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
dockerfile: ctrl/Dockerfile
|
dockerfile: ctrl/Dockerfile
|
||||||
command: uvicorn api.main:app --host 0.0.0.0 --port 8702 --reload
|
command: uvicorn core.api.main:app --host 0.0.0.0 --port 8702 --reload
|
||||||
ports:
|
ports:
|
||||||
- "8702:8702"
|
- "8702:8702"
|
||||||
environment:
|
environment:
|
||||||
<<: *common-env
|
<<: *common-env
|
||||||
|
DJANGO_ALLOW_ASYNC_UNSAFE: "true"
|
||||||
volumes:
|
volumes:
|
||||||
- ..:/app
|
- ..:/app
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -132,7 +133,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
dockerfile: ctrl/Dockerfile
|
dockerfile: ctrl/Dockerfile
|
||||||
command: python -m rpc.server
|
command: python -m core.rpc.server
|
||||||
ports:
|
ports:
|
||||||
- "50052:50051"
|
- "50052:50051"
|
||||||
environment:
|
environment:
|
||||||
@@ -150,8 +151,8 @@ services:
|
|||||||
celery:
|
celery:
|
||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
dockerfile: ctrl/Dockerfile
|
dockerfile: ctrl/Dockerfile.worker
|
||||||
command: celery -A mpr worker -l info -Q transcode -c 2
|
command: celery -A admin.mpr worker -l info -Q transcode -c 2
|
||||||
environment:
|
environment:
|
||||||
<<: *common-env
|
<<: *common-env
|
||||||
MPR_EXECUTOR: local
|
MPR_EXECUTOR: local
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Model generation script for MPR
|
# Model generation script for MPR
|
||||||
# Generates all targets from schema/modelgen.json config
|
# Generates all targets from core/schema/modelgen.json config
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
echo "Generating models from schema/models..."
|
echo "Generating models from core/schema/models..."
|
||||||
python -m modelgen generate --config schema/modelgen.json
|
python -m modelgen generate --config core/schema/modelgen.json
|
||||||
|
|
||||||
# Generate gRPC stubs from proto
|
# Generate gRPC stubs from proto
|
||||||
echo "Generating gRPC stubs..."
|
echo "Generating gRPC stubs..."
|
||||||
python -m grpc_tools.protoc \
|
python -m grpc_tools.protoc \
|
||||||
-I rpc/protos \
|
-I core/rpc/protos \
|
||||||
--python_out=rpc \
|
--python_out=core/rpc \
|
||||||
--grpc_python_out=rpc \
|
--grpc_python_out=core/rpc \
|
||||||
rpc/protos/worker.proto
|
core/rpc/protos/worker.proto
|
||||||
|
|
||||||
# Fix relative import in generated grpc stub
|
# Fix relative import in generated grpc stub
|
||||||
sed -i 's/^import worker_pb2/from . import worker_pb2/' rpc/worker_pb2_grpc.py
|
sed -i 's/^import worker_pb2/from . import worker_pb2/' core/rpc/worker_pb2_grpc.py
|
||||||
|
|
||||||
echo "Done!"
|
echo "Done!"
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ COPY ctrl/lambda/requirements.txt .
|
|||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
# Copy application code
|
# Copy application code
|
||||||
COPY task/lambda_handler.py ${LAMBDA_TASK_ROOT}/task/lambda_handler.py
|
COPY core/task/lambda_handler.py ${LAMBDA_TASK_ROOT}/core/task/lambda_handler.py
|
||||||
COPY task/__init__.py ${LAMBDA_TASK_ROOT}/task/__init__.py
|
COPY core/task/__init__.py ${LAMBDA_TASK_ROOT}/core/task/__init__.py
|
||||||
COPY core/ ${LAMBDA_TASK_ROOT}/core/
|
COPY core/ ${LAMBDA_TASK_ROOT}/core/
|
||||||
|
|
||||||
CMD ["task.lambda_handler.handler"]
|
CMD ["core.task.lambda_handler.handler"]
|
||||||
|
|||||||
@@ -44,9 +44,9 @@ http {
|
|||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
}
|
}
|
||||||
|
|
||||||
# FastAPI
|
# FastAPI — trailing slash strips /api prefix before forwarding
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://fastapi;
|
proxy_pass http://fastapi/;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ aws s3 sync /local/media/ s3://mpr-media-in/
|
|||||||
|
|
||||||
## GCP Production (GCS via S3 compatibility)
|
## GCP Production (GCS via S3 compatibility)
|
||||||
|
|
||||||
GCS exposes an S3-compatible API. The same `core/storage.py` boto3 code works
|
GCS exposes an S3-compatible API. The same `core/storage/s3.py` boto3 code works
|
||||||
with no changes — only the endpoint and credentials differ.
|
with no changes — only the endpoint and credentials differ.
|
||||||
|
|
||||||
### GCS HMAC Keys
|
### GCS HMAC Keys
|
||||||
@@ -103,15 +103,15 @@ aws --endpoint-url https://storage.googleapis.com s3 cp video.mp4 s3://mpr-media
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Cloud Run Job Handler
|
### Cloud Run Job Handler
|
||||||
`task/gcp_handler.py` is the Cloud Run Job entrypoint. It reads the job payload
|
`core/task/gcp_handler.py` is the Cloud Run Job entrypoint. It reads the job payload
|
||||||
from `MPR_JOB_PAYLOAD` (injected by `GCPExecutor`), uses `core/storage` for all
|
from `MPR_JOB_PAYLOAD` (injected by `GCPExecutor`), uses `core/storage` for all
|
||||||
GCS access (S3 compat), and POSTs the completion callback to the API.
|
GCS access (S3 compat), and POSTs the completion callback to the API.
|
||||||
|
|
||||||
Set the Cloud Run Job command to: `python -m task.gcp_handler`
|
Set the Cloud Run Job command to: `python -m core.task.gcp_handler`
|
||||||
|
|
||||||
## Storage Module
|
## Storage Module
|
||||||
|
|
||||||
`core/storage.py` provides all S3 operations:
|
`core/storage/` package provides all S3 operations:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from core.storage import (
|
from core.storage import (
|
||||||
@@ -157,7 +157,7 @@ mutation { scanMediaFolder { found registered skipped files } }
|
|||||||
|
|
||||||
### Cloud Run Job Mode (GCP)
|
### Cloud Run Job Mode (GCP)
|
||||||
1. `GCPExecutor` triggers Cloud Run Job with payload in `MPR_JOB_PAYLOAD`
|
1. `GCPExecutor` triggers Cloud Run Job with payload in `MPR_JOB_PAYLOAD`
|
||||||
2. `task/gcp_handler.py` downloads source from `S3_BUCKET_IN` (GCS S3 compat)
|
2. `core/task/gcp_handler.py` downloads source from `S3_BUCKET_IN` (GCS S3 compat)
|
||||||
3. Runs FFmpeg in container
|
3. Runs FFmpeg in container
|
||||||
4. Uploads result to `S3_BUCKET_OUT` (GCS S3 compat)
|
4. Uploads result to `S3_BUCKET_OUT` (GCS S3 compat)
|
||||||
5. Calls back to API with result
|
5. Calls back to API with result
|
||||||
|
|||||||
2
requirements-worker.txt
Normal file
2
requirements-worker.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
ffmpeg-python>=0.2.0
|
||||||
@@ -12,9 +12,6 @@ pydantic>=2.5.0
|
|||||||
celery[redis]>=5.3.0
|
celery[redis]>=5.3.0
|
||||||
redis>=5.0.0
|
redis>=5.0.0
|
||||||
|
|
||||||
# FFmpeg
|
|
||||||
ffmpeg-python>=0.2.0
|
|
||||||
|
|
||||||
# gRPC
|
# gRPC
|
||||||
grpcio>=1.60.0
|
grpcio>=1.60.0
|
||||||
grpcio-tools>=1.60.0
|
grpcio-tools>=1.60.0
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* API client for FastAPI backend
|
* GraphQL API client
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -8,34 +8,51 @@ import type {
|
|||||||
TranscodeJob,
|
TranscodeJob,
|
||||||
CreateJobRequest,
|
CreateJobRequest,
|
||||||
SystemStatus,
|
SystemStatus,
|
||||||
WorkerStatus,
|
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
const API_BASE = "/api";
|
const GRAPHQL_URL = "/api/graphql";
|
||||||
|
|
||||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
async function gql<T>(query: string, variables?: Record<string, unknown>): Promise<T> {
|
||||||
const response = await fetch(`${API_BASE}${path}`, {
|
const response = await fetch(GRAPHQL_URL, {
|
||||||
headers: {
|
method: "POST",
|
||||||
"Content-Type": "application/json",
|
headers: { "Content-Type": "application/json" },
|
||||||
},
|
body: JSON.stringify({ query, variables }),
|
||||||
...options,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
const json = await response.json();
|
||||||
const error = await response.text();
|
|
||||||
throw new Error(`API error: ${response.status} - ${error}`);
|
if (json.errors?.length) {
|
||||||
|
throw new Error(json.errors[0].message);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
return json.data as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assets
|
// Assets
|
||||||
export async function getAssets(): Promise<MediaAsset[]> {
|
export async function getAssets(): Promise<MediaAsset[]> {
|
||||||
return request("/assets/");
|
const data = await gql<{ assets: MediaAsset[] }>(`
|
||||||
|
query {
|
||||||
|
assets {
|
||||||
|
id filename file_path status error_message file_size duration
|
||||||
|
video_codec audio_codec width height framerate bitrate
|
||||||
|
properties comments tags created_at updated_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
return data.assets;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAsset(id: string): Promise<MediaAsset> {
|
export async function getAsset(id: string): Promise<MediaAsset> {
|
||||||
return request(`/assets/${id}`);
|
const data = await gql<{ asset: MediaAsset }>(`
|
||||||
|
query($id: UUID!) {
|
||||||
|
asset(id: $id) {
|
||||||
|
id filename file_path status error_message file_size duration
|
||||||
|
video_codec audio_codec width height framerate bitrate
|
||||||
|
properties comments tags created_at updated_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`, { id });
|
||||||
|
return data.asset;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function scanMediaFolder(): Promise<{
|
export async function scanMediaFolder(): Promise<{
|
||||||
@@ -44,43 +61,95 @@ export async function scanMediaFolder(): Promise<{
|
|||||||
skipped: number;
|
skipped: number;
|
||||||
files: string[];
|
files: string[];
|
||||||
}> {
|
}> {
|
||||||
return request("/assets/scan", {
|
const data = await gql<{ scan_media_folder: { found: number; registered: number; skipped: number; files: string[] } }>(`
|
||||||
method: "POST",
|
mutation {
|
||||||
});
|
scan_media_folder { found registered skipped files }
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
return data.scan_media_folder;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Presets
|
// Presets
|
||||||
export async function getPresets(): Promise<TranscodePreset[]> {
|
export async function getPresets(): Promise<TranscodePreset[]> {
|
||||||
return request("/presets/");
|
const data = await gql<{ presets: TranscodePreset[] }>(`
|
||||||
|
query {
|
||||||
|
presets {
|
||||||
|
id name description is_builtin container
|
||||||
|
video_codec video_bitrate video_crf video_preset resolution framerate
|
||||||
|
audio_codec audio_bitrate audio_channels audio_samplerate
|
||||||
|
extra_args created_at updated_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
return data.presets;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Jobs
|
// Jobs
|
||||||
export async function getJobs(): Promise<TranscodeJob[]> {
|
export async function getJobs(): Promise<TranscodeJob[]> {
|
||||||
return request("/jobs/");
|
const data = await gql<{ jobs: TranscodeJob[] }>(`
|
||||||
|
query {
|
||||||
|
jobs {
|
||||||
|
id source_asset_id preset_id preset_snapshot trim_start trim_end
|
||||||
|
output_filename output_path output_asset_id status progress
|
||||||
|
current_frame current_time speed error_message celery_task_id
|
||||||
|
execution_arn priority created_at started_at completed_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
return data.jobs;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getJob(id: string): Promise<TranscodeJob> {
|
export async function getJob(id: string): Promise<TranscodeJob> {
|
||||||
return request(`/jobs/${id}`);
|
const data = await gql<{ job: TranscodeJob }>(`
|
||||||
|
query($id: UUID!) {
|
||||||
|
job(id: $id) {
|
||||||
|
id source_asset_id preset_id preset_snapshot trim_start trim_end
|
||||||
|
output_filename output_path output_asset_id status progress
|
||||||
|
current_frame current_time speed error_message celery_task_id
|
||||||
|
execution_arn priority created_at started_at completed_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`, { id });
|
||||||
|
return data.job;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createJob(data: CreateJobRequest): Promise<TranscodeJob> {
|
export async function createJob(req: CreateJobRequest): Promise<TranscodeJob> {
|
||||||
return request("/jobs/", {
|
const data = await gql<{ create_job: TranscodeJob }>(`
|
||||||
method: "POST",
|
mutation($input: CreateJobInput!) {
|
||||||
body: JSON.stringify(data),
|
create_job(input: $input) {
|
||||||
|
id source_asset_id status output_filename progress created_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`, {
|
||||||
|
input: {
|
||||||
|
source_asset_id: req.source_asset_id,
|
||||||
|
preset_id: req.preset_id,
|
||||||
|
trim_start: req.trim_start,
|
||||||
|
trim_end: req.trim_end,
|
||||||
|
output_filename: req.output_filename ?? null,
|
||||||
|
priority: req.priority ?? 0,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
return data.create_job;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cancelJob(id: string): Promise<TranscodeJob> {
|
export async function cancelJob(id: string): Promise<TranscodeJob> {
|
||||||
return request(`/jobs/${id}/cancel`, {
|
const data = await gql<{ cancel_job: TranscodeJob }>(`
|
||||||
method: "POST",
|
mutation($id: UUID!) {
|
||||||
});
|
cancel_job(id: $id) {
|
||||||
|
id status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`, { id });
|
||||||
|
return data.cancel_job;
|
||||||
}
|
}
|
||||||
|
|
||||||
// System
|
// System
|
||||||
export async function getSystemStatus(): Promise<SystemStatus> {
|
export async function getSystemStatus(): Promise<SystemStatus> {
|
||||||
return request("/system/status");
|
const data = await gql<{ system_status: SystemStatus }>(`
|
||||||
}
|
query {
|
||||||
|
system_status { status version }
|
||||||
export async function getWorkerStatus(): Promise<WorkerStatus> {
|
}
|
||||||
return request("/system/worker");
|
`);
|
||||||
|
return data.system_status;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user