Skip to content

Commit b0a1028

Browse files
committed
more ruggedization and testing
1 parent 8417278 commit b0a1028

20 files changed

+452
-165
lines changed

app/archive.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import shutil
2+
from logging import getLogger
3+
from pathlib import Path
4+
5+
from app.util.file_name_utils import original_file_location
6+
7+
8+
class Archive:
9+
def __init__(self, archive_base_path: Path = Path("/tmp")):
10+
self.archive_base_path = archive_base_path
11+
self.logger = getLogger(__name__)
12+
13+
def validate_destination(self, video_id: str, original_file_name: Path) -> Path:
14+
original_file_destination = self.archive_base_path / original_file_location(
15+
video_id, Path(original_file_name.name)
16+
)
17+
assert not original_file_destination.exists(), f"File {original_file_destination} already exists"
18+
19+
self.logger.info("Destination: %s", original_file_destination)
20+
return original_file_destination
21+
22+
def make_dir_for_original(self, original_file_name: Path):
23+
original_file_name.parent.mkdir(exist_ok=True, parents=True)
24+
self.logger.info("created destination folder %s", original_file_name.parent)
25+
return original_file_name
26+
27+
def move_original_to_archive(self, video_id: str, original_file_name: Path):
28+
original_file_destination = self.validate_destination(video_id, original_file_name)
29+
self.make_dir_for_original(original_file_destination)
30+
self.logger.info("moving original from %s to %s", original_file_name, original_file_destination)
31+
32+
shutil.move(original_file_name, original_file_destination)
33+
return original_file_destination

app/converter.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import logging
2+
from pathlib import Path
3+
4+
from frikanalen_django_api_client.models import FormatEnum, VideoFileRequest
5+
6+
from app.django_api.service import DjangoApiService
7+
from app.ffprobe_schema import FfprobeOutput
8+
from ffmpeg.command_factory import FfmpegCommandFactory
9+
from runner import Runner
10+
11+
12+
class Converter:
13+
ffmpeg_command_factory = FfmpegCommandFactory()
14+
django_api: DjangoApiService
15+
runner: Runner
16+
17+
def __init__(self, django_api: DjangoApiService, runner: Runner):
18+
self.runner = runner
19+
self.django_api = django_api
20+
21+
async def process_format(
22+
self,
23+
input_file_path: Path,
24+
output_format_name: FormatEnum,
25+
metadata: FfprobeOutput,
26+
video_id: int,
27+
):
28+
logging.info("Producing: %s", output_format_name)
29+
ffmpeg_command_factory = FfmpegCommandFactory()
30+
cmd_line, target_file_name = ffmpeg_command_factory.build_ffmpeg_command(
31+
input_file_path, output_format_name, metadata
32+
)
33+
await self.runner.run(cmd_line)
34+
await self.runner.wait_for_completion()
35+
36+
# Register it with API
37+
req = VideoFileRequest(filename=str(target_file_name), format_=output_format_name, video_id=video_id)
38+
await self.django_api.create_video_file(video_file=req)

app/django_api/build_client.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from frikanalen_django_api_client import AuthenticatedClient
2+
from starlette.requests import Request
3+
4+
from app.config import config
5+
6+
7+
def get_client_from_app_state(request: Request) -> AuthenticatedClient:
8+
return request.app.state.app_state.api_client # type: ignore[attr-defined]
9+
10+
11+
def build_client():
12+
return AuthenticatedClient(
13+
base_url=config.django.base_url, token=config.django.token, raise_on_unexpected_status=True
14+
)

app/django_api/service.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,24 @@
11
from datetime import datetime
22

3-
from fastapi import Depends
43
from frikanalen_django_api_client import AuthenticatedClient
54
from frikanalen_django_api_client.api.videofiles import videofiles_create, videofiles_list, videofiles_partial_update
65
from frikanalen_django_api_client.api.videos import videos_list, videos_partial_update
7-
from frikanalen_django_api_client.models import PatchedVideoRequest, VideoFile, VideoFileRequest
8-
6+
from frikanalen_django_api_client.models import (
7+
PatchedVideoRequest,
8+
VideoFile,
9+
VideoFileRequest,
10+
VideofilesListFormatFsname,
11+
)
12+
13+
from app.django_api.build_client import build_client
914
from app.loudness.loudness_measurement import LoudnessMeasurement
10-
from app.tus_hook.hook_server import build_client, get_client_from_app_state
1115
from app.util.pprint_object_list import pprint_object_list
1216

1317

1418
class DjangoApiService:
1519
client: AuthenticatedClient
1620

17-
def __init__(self, client: AuthenticatedClient = None):
18-
if client is None:
19-
client = Depends(get_client_from_app_state)
21+
def __init__(self, client: AuthenticatedClient):
2022
self.client = client
2123

2224
async def set_video_duration(self, video_id: str, duration: str):
@@ -36,7 +38,7 @@ async def get_original_files_without_loudness(self, limit=5) -> list[VideoFile]:
3638
return (
3739
await videofiles_list.asyncio(
3840
client=self.client,
39-
format_fsname="original",
41+
format_fsname=VideofilesListFormatFsname.ORIGINAL,
4042
integrated_lufs_isnull=True,
4143
limit=limit,
4244
ordering="-video",
@@ -61,8 +63,6 @@ async def get_videos(self, limit=10):
6163
if __name__ == "__main__":
6264
import asyncio
6365

64-
from app.tus_hook.hook_server import get_client_from_app_state
65-
6666
async def main():
6767
service = DjangoApiService(build_client())
6868
videos = await service.get_videos()

app/ingest.py

Lines changed: 34 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,49 @@
1-
import logging
2-
import shutil
31
from datetime import datetime
2+
from logging import Logger, getLogger
43
from pathlib import Path
54

5+
from frikanalen_django_api_client.models import FormatEnum
6+
67
from app.django_api.service import DjangoApiService
78
from app.ffprobe import do_probe
8-
from app.tus_hook.hook_server import build_client
99

10-
from .generate_assets import make_secondaries
10+
from .archive import Archive
11+
from .converter import Converter
1112
from .logging.video_id_filter import VideoIdFilter
12-
from .util.file_name_utils import original_file_location
1313

14-
django_api = DjangoApiService(build_client())
15-
archive_base_path = Path("/tmp/archive")
14+
DESIRED_FORMATS = (FormatEnum("large_thumb"),)
15+
1616

17+
class Ingester:
18+
video_id: str
19+
django_api: DjangoApiService
20+
converter_service: Converter
21+
archive: Archive
22+
logger: Logger
1723

18-
async def ingest(video_id: str, original_file: Path):
19-
logger = logging.getLogger(__name__)
20-
logger.addFilter(VideoIdFilter(video_id))
21-
logger.info("Ingesting file with video_id: %s, original_file: %s", video_id, original_file)
24+
def __init__(self, video_id: str, django_api: DjangoApiService, converter_service: Converter, archive: Archive):
25+
self.logger = getLogger(__name__)
26+
self.logger.addFilter(VideoIdFilter(video_id))
27+
self.video_id = video_id
28+
self.django_api = django_api
29+
self.converter_service = converter_service
30+
self.archive = archive
2231

23-
original_file_destination = archive_base_path / original_file_location(video_id, original_file)
24-
assert not original_file_destination.exists(), f"File {original_file_destination} already exists"
25-
logger.info("Destination: %s", original_file_destination)
32+
async def ingest(self, original_file: Path):
33+
self.logger.info("Ingesting file with video_id: %s, original_file: %s", self.video_id, original_file)
2634

27-
logger.info("Creating destination folder: %s", original_file_destination.parent)
28-
original_file_destination.parent.mkdir(exist_ok=True)
35+
await self.django_api.set_video_uploaded_time(self.video_id, datetime.now())
36+
metadata = await do_probe(original_file)
37+
self.logger.info("Got metadata, %d streams", len(metadata.streams))
2938

30-
await django_api.set_video_uploaded_time(video_id, datetime.now())
31-
metadata = await do_probe(original_file)
32-
logger.info("Got metadata: %s", metadata)
39+
original_file_destination = self.archive.move_original_to_archive(self.video_id, original_file)
40+
await self.django_api.set_video_duration(self.video_id, metadata.format.duration)
3341

34-
logger.info("Moving from %s to %s", original_file, original_file_destination)
35-
shutil.move(original_file, original_file_destination)
42+
for format_name in DESIRED_FORMATS:
43+
self.logger.info("Processing: %s", original_file_destination)
44+
await self.converter_service.process_format(
45+
original_file_destination, format_name, metadata, int(self.video_id)
46+
)
47+
self.logger.info("Finished processing: %s", self.video_id)
3648

37-
await django_api.set_video_duration(video_id, metadata["format"]["duration"])
38-
await make_secondaries(video_id, original_file_destination)
39-
await django_api.set_video_proper_import(video_id, True)
49+
await self.django_api.set_video_proper_import(self.video_id, True)

app/main.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import logging
2+
from contextlib import asynccontextmanager
3+
from dataclasses import dataclass
4+
from pathlib import Path
5+
from unittest.mock import AsyncMock
6+
7+
from fastapi import FastAPI, HTTPException
8+
from frikanalen_django_api_client import AuthenticatedClient
9+
from pydantic import BaseModel, Field, ValidationError
10+
from werkzeug.utils import secure_filename
11+
12+
from app.archive import Archive
13+
from app.converter import Converter
14+
from app.django_api.build_client import build_client
15+
from app.django_api.service import DjangoApiService
16+
from app.ingest import Ingester
17+
from app.tus_hook.hook_schema import HookRequest
18+
from runner import Runner
19+
20+
21+
@asynccontextmanager
22+
async def lifespan(app: FastAPI):
23+
"""
24+
Lifespan handler for the FastAPI app. This is where we initialize the HTTP client used by
25+
the DjangoAPI services. Constructs an authenticated HTTP client based on configuration
26+
values config.django.base_url and config.django.token.
27+
"""
28+
client = build_client()
29+
await client.__aenter__()
30+
31+
# ✅ Store it on app state
32+
app.state.app_state = IngestAppState(api_client=client) # type: ignore[attr-defined]
33+
34+
yield # App runs here
35+
36+
# ✅ Cleanup
37+
await client.__aexit__(None, None, None)
38+
39+
40+
app = FastAPI(lifespan=lifespan)
41+
42+
43+
@dataclass
44+
class IngestAppState:
45+
api_client: AuthenticatedClient
46+
47+
48+
@app.post("/")
49+
async def receive_hook(hook: HookRequest):
50+
if hook.type == "pre-create":
51+
try:
52+
UploadMetaData(**hook.event.upload.meta_data.model_dump())
53+
except ValidationError as e:
54+
raise HTTPException(status_code=422, detail=e.errors()) from e
55+
except AttributeError as e:
56+
raise HTTPException(status_code=422, detail="Missing required fields") from e
57+
58+
if hook.type == "post-finish":
59+
try:
60+
upload_meta = UploadMetaData(**hook.event.upload.meta_data.model_dump())
61+
assert hook.event.upload.storage["Type"] == "filestore", "Only filestore storage is supported"
62+
print(f"outpath: {hook.event.upload.storage['Path']}")
63+
64+
except ValidationError as e:
65+
logging.error(e.errors)
66+
raise HTTPException(status_code=422, detail=e.errors()) from e
67+
except AttributeError as e:
68+
logging.error(e)
69+
raise HTTPException(status_code=422, detail="Missing required fields") from e
70+
71+
django_api = AsyncMock(spec=DjangoApiService)
72+
ingest = Ingester(
73+
video_id=upload_meta.video_id,
74+
django_api=django_api,
75+
converter_service=Converter(django_api, Runner()),
76+
archive=Archive(),
77+
)
78+
out_path = Path(hook.event.upload.storage["Path"])
79+
if not out_path.exists():
80+
raise HTTPException(status_code=500, detail=f"File not found: {out_path}")
81+
82+
# rename out_path to original file name
83+
out_path.rename(out_path.parent / secure_filename(upload_meta.orig_file_name))
84+
await ingest.ingest(out_path)
85+
return {}
86+
87+
88+
class UploadMetaData(BaseModel):
89+
video_id: str = Field(..., alias="VideoID")
90+
orig_file_name: str = Field(..., alias="OrigFileName")
91+
upload_token: str = Field(..., alias="UploadToken")
92+
93+
94+
@app.get("/isAlive")
95+
async def read_health():
96+
return {"status": "ok"}

app/make_secondaries.py

Lines changed: 0 additions & 46 deletions
This file was deleted.

0 commit comments

Comments
 (0)