File size: 22,932 Bytes
fba6023
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
03c8ca2
 
fba6023
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e5a2fe9
fba6023
 
 
 
 
 
 
 
 
03c8ca2
fba6023
 
 
 
 
 
03c8ca2
fba6023
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8155940
 
 
 
 
 
 
fba6023
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
03c8ca2
 
fba6023
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
03c8ca2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f764c05
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
03c8ca2
 
 
 
 
 
 
 
 
fba6023
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8155940
 
 
19fccd4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
from __future__ import annotations

import asyncio
import base64
import json
import mimetypes
from pathlib import Path
from typing import Any
from uuid import uuid4

import aiofiles
from fastapi import Request
from starlette.datastructures import FormData, UploadFile

from app.core.config import Settings
from app.core.exceptions import InputError
from app.models.media import InputMedia, MediaSource, OperationResult, ResolvedRequest
from app.services.cleanup import CleanupService, RequestWorkspace
from app.services.downloader import Downloader
from app.services.validator import MediaValidator
from app.services.ytdlp_service import YTDLPService
from app.security.assets import CanonicalAssetNotFoundError, CanonicalAssetService
from app.security.context import auth_context


class InputResolver:
    """Normalizes multipart, JSON, Base64, n8n, URL, and raw-byte inputs."""

    INPUT_KEYS = {
        "url",
        "urls",
        "base64",
        "binary",
        "input",
        "inputs",
        "files",
        "file",
        "path",
        "temp_path",
        "asset_id",
    }

    def __init__(
        self,
        settings: Settings,
        cleanup: CleanupService,
        downloader: Downloader,
        ytdlp: YTDLPService,
        validator: MediaValidator,
        assets: CanonicalAssetService | None = None,
    ) -> None:
        self.settings = settings
        self.cleanup = cleanup
        self.downloader = downloader
        self.ytdlp = ytdlp
        self.validator = validator
        self.assets = assets

    async def resolve(self, request: Request, *, require_input: bool = True) -> ResolvedRequest:
        request_id = request.state.request_id
        workspace = await self.cleanup.create_workspace(request_id)
        content_type = request.headers.get("content-type", "").lower()
        is_ytdlp_request = request.url.path.startswith("/v1/ytdlp/")
        try:
            if content_type.startswith("multipart/form-data"):
                inputs, params = await self._multipart(request, workspace, is_ytdlp_request)
            elif "json" in content_type:
                inputs, params = await self._json(request, workspace, is_ytdlp_request)
            else:
                inputs, params = await self._raw(request, workspace, content_type)
        except Exception:
            await self.cleanup.complete(request_id)
            raise
        if require_input and not inputs:
            await self.cleanup.complete(request_id)
            raise InputError(
                "No media input found. Send multipart file(s), raw bytes, url, base64, or n8n binary data."
            )
        params = {**dict(request.query_params), **params}
        return ResolvedRequest(request_id=request_id, inputs=inputs, params=params)

    async def resolve_payload(
        self,
        payload: dict[str, Any],
        request_id: str,
        *,
        require_input: bool = True,
        ytdlp_options: dict[str, Any] | None = None,
    ) -> ResolvedRequest:
        """Resolve an in-process JSON payload through the same REST input pipeline.

        MCP and other non-HTTP transports use this method so URL, Base64, n8n
        binary, managed temporary paths, validation, and workspace handling stay
        centralized in one resolver.
        """
        workspace = await self.cleanup.create_workspace(request_id)
        try:
            descriptors = self._collect_descriptors(payload)
            params = self._params_from_payload(payload)
            inputs = [
                await self._resolve_descriptor(
                    descriptor,
                    workspace,
                    ytdlp_options=ytdlp_options,
                )
                for descriptor in descriptors
            ]
        except Exception:
            await self.cleanup.complete(request_id)
            raise
        if require_input and not inputs:
            await self.cleanup.complete(request_id)
            raise InputError(
                "No media input found. Provide url, base64, binary, temp_path, or inputs."
            )
        return ResolvedRequest(request_id=request_id, inputs=inputs, params=params)

    async def _multipart(
        self,
        request: Request,
        workspace: RequestWorkspace,
        is_ytdlp_request: bool,
    ) -> tuple[list[InputMedia], dict[str, Any]]:
        try:
            form: FormData = await request.form(
                max_files=100,
                max_fields=500,
                max_part_size=self.settings.max_upload_size,
            )
        except TypeError:
            form = await request.form()
        except Exception as exc:
            raise InputError("Invalid multipart form data") from exc
        inputs: list[InputMedia] = []
        fields: dict[str, Any] = {}
        for key, value in form.multi_items():
            if isinstance(value, UploadFile):
                inputs.append(await self._save_upload(value, workspace))
                continue
            if key in fields:
                current = fields[key]
                fields[key] = current + [value] if isinstance(current, list) else [current, value]
            else:
                fields[key] = value
        descriptors: list[dict[str, Any]] = []
        for key, value in fields.items():
            if key == "url":
                descriptors.extend({"url": item} for item in self._as_list(value))
            elif key == "urls":
                parsed = self._parse_field(value)
                descriptors.extend({"url": item} for item in self._as_list(parsed))
            elif key == "base64" or key.startswith("binary."):
                for item in self._as_list(value):
                    parsed = self._parse_field(item)
                    descriptors.append(parsed if isinstance(parsed, dict) else {"base64": parsed})
            elif key in {"input", "inputs", "files", "binary"}:
                parsed = self._parse_field(value)
                descriptors.extend(self._collect_descriptors(parsed, binary=key == "binary"))
        params = self._params_from_payload(fields)
        ytdlp_options = params if is_ytdlp_request else None
        for descriptor in descriptors:
            inputs.append(
                await self._resolve_descriptor(descriptor, workspace, ytdlp_options=ytdlp_options)
            )
        return inputs, params

    async def _json(
        self,
        request: Request,
        workspace: RequestWorkspace,
        is_ytdlp_request: bool,
    ) -> tuple[list[InputMedia], dict[str, Any]]:
        content_length = self._int(request.headers.get("content-length"))
        maximum_json = int(self.settings.max_upload_size * 1.5) + 1_048_576
        if content_length and content_length > maximum_json:
            raise InputError("JSON request body is too large")
        body = await request.body()
        if len(body) > maximum_json:
            raise InputError("JSON request body is too large")
        try:
            payload = await asyncio.to_thread(json.loads, body)
        except (json.JSONDecodeError, UnicodeDecodeError) as exc:
            raise InputError("Request body is not valid JSON") from exc
        if isinstance(payload, str):
            stripped = payload.strip()
            payload = {"url": stripped} if stripped.startswith(("http://", "https://")) else {"base64": stripped}
        elif isinstance(payload, list):
            payload = {"inputs": payload}
        elif not isinstance(payload, dict):
            raise InputError("JSON request body must be an object, array, URL string, or Base64 string")
        descriptors = self._collect_descriptors(payload)
        params = self._params_from_payload(payload)
        ytdlp_options = params if is_ytdlp_request else None
        inputs = [
            await self._resolve_descriptor(descriptor, workspace, ytdlp_options=ytdlp_options)
            for descriptor in descriptors
        ]
        return inputs, params

    async def _raw(
        self, request: Request, workspace: RequestWorkspace, content_type: str
    ) -> tuple[list[InputMedia], dict[str, Any]]:
        declared = self._int(request.headers.get("content-length"))
        if declared and declared > self.settings.max_upload_size:
            raise InputError("The request body exceeds MAX_UPLOAD_SIZE")
        filename = self.validator.safe_filename(
            request.headers.get("x-filename") or request.query_params.get("filename"),
            "input.bin",
        )
        path = workspace.uploads / f"{uuid4().hex}_{filename}"
        size = 0
        async with aiofiles.open(path, "wb") as output:
            async for chunk in request.stream():
                size += len(chunk)
                if size > self.settings.max_upload_size:
                    await output.close()
                    path.unlink(missing_ok=True)
                    raise InputError("The request body exceeds MAX_UPLOAD_SIZE")
                await output.write(chunk)
        if size == 0:
            path.unlink(missing_ok=True)
            return [], dict(request.query_params)
        mime_type = content_type.split(";", 1)[0] or self.validator.infer_mime(path)
        self.validator.validate_declared(filename, mime_type, size)
        return [
            InputMedia(
                source=MediaSource.OCTET_STREAM,
                filename=filename,
                mime_type=mime_type,
                temp_path=path,
                size=size,
            )
        ], dict(request.query_params)

    async def _save_upload(self, upload: UploadFile, workspace: RequestWorkspace) -> InputMedia:
        filename = self.validator.safe_filename(upload.filename, "upload.bin")
        path = workspace.uploads / f"{uuid4().hex}_{filename}"
        size = 0
        async with aiofiles.open(path, "wb") as output:
            while chunk := await upload.read(1024 * 1024):
                size += len(chunk)
                if size > self.settings.max_upload_size:
                    await output.close()
                    path.unlink(missing_ok=True)
                    raise InputError("The uploaded media exceeds MAX_UPLOAD_SIZE")
                await output.write(chunk)
        await upload.close()
        mime_type = upload.content_type or self.validator.infer_mime(path)
        self.validator.validate_declared(filename, mime_type, size)
        return InputMedia(
            source=MediaSource.MULTIPART,
            filename=filename,
            mime_type=mime_type,
            temp_path=path,
            size=size,
        )

    async def _resolve_descriptor(
        self,
        descriptor: dict[str, Any],
        workspace: RequestWorkspace,
        *,
        ytdlp_options: dict[str, Any] | None = None,
    ) -> InputMedia:
        if "asset_id" in descriptor:
            return await self._resolve_asset(descriptor, workspace)
        if "temp_path" in descriptor or "path" in descriptor:
            return await self._resolve_local_path(descriptor, workspace)
        if "url" in descriptor:
            url = descriptor["url"]
            if not isinstance(url, str) or not url.strip():
                raise InputError("Media URL must be a non-empty string")
            await self.downloader.validate_url(url)
            if ytdlp_options is not None or await asyncio.to_thread(self.ytdlp.supports_url, url):
                options = ytdlp_options or {}
                mode = str(options.get("mode", "video"))
                if options.get("playlist") or options.get("include_formats"):
                    max_entries = self._int(options.get("max_entries", 100))
                    if max_entries is None:
                        raise InputError("max_entries must be an integer")
                    metadata = await self.ytdlp.extract_metadata(
                        url,
                        playlist=bool(options.get("playlist")),
                        include_formats=bool(options.get("include_formats")),
                        max_entries=max_entries,
                    )
                    result = OperationResult(metadata=metadata)
                    mode = "metadata"
                else:
                    result = await self.ytdlp.download(
                        url,
                        workspace.uploads,
                        mode=mode,
                        format_selector=options.get("format"),
                        audio_format=str(options.get("audio_format", "mp3")),
                    )
                if result.path is None:
                    if mode != "metadata":
                        raise InputError("yt-dlp did not return a media file")
                    result.path = workspace.uploads / "metadata.json"
                    encoded = json.dumps(
                        result.metadata, ensure_ascii=False, separators=(",", ":")
                    ).encode("utf-8")
                    async with aiofiles.open(result.path, "wb") as output:
                        await output.write(encoded)
                    result.filename = result.path.name
                    result.mime_type = "application/json"
                return InputMedia(
                    source=MediaSource.YTDLP,
                    filename=result.filename or result.path.name,
                    mime_type=result.mime_type or self.validator.infer_mime(result.path),
                    temp_path=result.path,
                    size=result.path.stat().st_size,
                    duration=result.metadata.get("duration"),
                    metadata=result.metadata,
                )
            path, mime_type = await self.downloader.download(
                url, workspace.uploads, descriptor.get("filename")
            )
            return InputMedia(
                source=MediaSource.JSON_URL,
                filename=self._original_name(path),
                mime_type=mime_type,
                temp_path=path,
                size=path.stat().st_size,
                metadata={"url": url},
            )
        data = descriptor.get("base64", descriptor.get("data"))
        if not isinstance(data, str) or not data:
            raise InputError("Input descriptor must contain a url or Base64 data")
        return await self._decode_base64(data, descriptor, workspace)

    async def _resolve_asset(
        self, descriptor: dict[str, Any], workspace: RequestWorkspace
    ) -> InputMedia:
        asset_id = descriptor.get("asset_id")
        if not isinstance(asset_id, str) or not asset_id.strip():
            raise InputError("asset_id must be a non-empty string")
        context = auth_context.get()
        if self.assets is None or context is None or not context.workspace_id or not context.user_id:
            raise InputError("asset_id requires an authenticated workspace")
        try:
            record = await self.assets.get_owned_by_id(
                workspace_id=context.workspace_id,
                user_id=context.user_id,
                asset_id=asset_id,
            )
            source = self.cleanup.resolve_download(record.request_id, record.filename)
            await self.assets.verify_file(record, source)
        except (CanonicalAssetNotFoundError, FileNotFoundError, ValueError) as exc:
            raise InputError("Media asset was not found or is no longer available") from exc
        destination = workspace.uploads / f"{uuid4().hex}_{record.filename}"
        # Stream canonical media in bounded chunks. Asset resolution must never
        # load an entire video/audio file into the worker process memory.
        copied = 0
        try:
            async with (
                aiofiles.open(source, "rb") as input_stream,
                aiofiles.open(destination, "wb") as output_stream,
            ):
                while chunk := await input_stream.read(1024 * 1024):
                    copied += len(chunk)
                    if copied > self.settings.max_upload_size:
                        raise InputError("Media asset exceeds MAX_UPLOAD_SIZE")
                    await output_stream.write(chunk)
        except Exception:
            destination.unlink(missing_ok=True)
            raise
        if copied != record.file_size:
            destination.unlink(missing_ok=True)
            raise InputError("Media asset size changed while being resolved")
        return InputMedia(
            source=MediaSource.ASSET,
            filename=record.filename,
            mime_type=record.mime_type,
            temp_path=destination,
            size=record.file_size,
            metadata={"asset_id": record.id, "sha256": record.sha256},
        )

    async def _resolve_local_path(
        self, descriptor: dict[str, Any], workspace: RequestWorkspace
    ) -> InputMedia:
        raw_path = descriptor.get("temp_path", descriptor.get("path"))
        if not isinstance(raw_path, str) or not raw_path.strip():
            raise InputError("temp_path must be a non-empty string")
        candidate = Path(raw_path).expanduser().resolve()
        allowed_roots = (self.settings.temp_dir.resolve(), self.settings.output_dir.resolve())
        if not any(candidate == root or root in candidate.parents for root in allowed_roots):
            raise InputError("temp_path must be inside TEMP_DIR or OUTPUT_DIR")
        if not candidate.is_file():
            raise InputError("The managed temporary file does not exist")
        filename = self.validator.safe_filename(descriptor.get("filename"), candidate.name)
        mime_type = str(
            descriptor.get("mime_type")
            or descriptor.get("mimeType")
            or self.validator.infer_mime(candidate)
        )
        size = candidate.stat().st_size
        self.validator.validate_declared(filename, mime_type, size)
        destination = workspace.uploads / f"{uuid4().hex}_{filename}"
        async with (
            aiofiles.open(candidate, "rb") as source,
            aiofiles.open(destination, "wb") as output,
        ):
            while chunk := await source.read(1024 * 1024):
                await output.write(chunk)
        return InputMedia(
            source=MediaSource.LOCAL_PATH,
            filename=filename,
            mime_type=mime_type,
            temp_path=destination,
            size=size,
            metadata={"managed_source": str(candidate)},
        )

    async def _decode_base64(
        self, value: str, descriptor: dict[str, Any], workspace: RequestWorkspace
    ) -> InputMedia:
        mime_type = str(
            descriptor.get("mime_type") or descriptor.get("mimeType") or "application/octet-stream"
        )
        if value.startswith("data:"):
            try:
                header, value = value.split(",", 1)
            except ValueError as exc:
                raise InputError("Invalid Base64 data URI") from exc
            if ";base64" not in header:
                raise InputError("Only Base64 data URIs are supported")
            mime_type = header[5:].split(";", 1)[0] or mime_type
        compact = "".join(value.split())
        estimated_size = len(compact) * 3 // 4
        if estimated_size > self.settings.max_upload_size:
            raise InputError("Decoded Base64 media exceeds MAX_UPLOAD_SIZE")
        try:
            decoded = await asyncio.to_thread(base64.b64decode, compact, validate=True)
        except ValueError as exc:
            raise InputError("Media contains invalid Base64 data") from exc
        extension = mimetypes.guess_extension(mime_type) or ".bin"
        supplied_name = descriptor.get("filename") or descriptor.get("fileName")
        filename = self.validator.safe_filename(supplied_name, f"decoded{extension}")
        self.validator.validate_declared(filename, mime_type, len(decoded))
        path = workspace.uploads / f"{uuid4().hex}_{filename}"
        async with aiofiles.open(path, "wb") as output:
            await output.write(decoded)
        source = MediaSource.N8N_BINARY if "data" in descriptor else MediaSource.JSON_BASE64
        return InputMedia(
            source=source,
            filename=filename,
            mime_type=mime_type,
            temp_path=path,
            size=len(decoded),
        )

    def _collect_descriptors(self, value: Any, *, binary: bool = False) -> list[dict[str, Any]]:
        if isinstance(value, list):
            result: list[dict[str, Any]] = []
            for item in value:
                result.extend(self._collect_descriptors(item, binary=binary))
            return result
        if not isinstance(value, dict):
            if binary and isinstance(value, str):
                return [{"data": value}]
            if isinstance(value, str):
                stripped = value.strip()
                if stripped.startswith(("http://", "https://")):
                    return [{"url": stripped}]
            return []
        if any(key in value for key in ("url", "base64", "temp_path", "path", "asset_id")):
            return [value]
        if "data" in value and (
            binary or any(key in value for key in ("fileName", "filename", "mimeType", "mime_type"))
        ):
            return [value]
        result: list[dict[str, Any]] = []
        for key, item in value.items():
            if key in self.INPUT_KEYS:
                result.extend(self._collect_descriptors(item, binary=(key == "binary")))
            elif binary and isinstance(item, (dict, list)):
                result.extend(self._collect_descriptors(item, binary=True))
        return result

    def _params_from_payload(self, payload: dict[str, Any]) -> dict[str, Any]:
        """Keep operation parameters while excluding fields consumed as media inputs."""
        if not isinstance(payload, dict):
            return {}
        return {key: value for key, value in payload.items() if key not in self.INPUT_KEYS}

    @staticmethod
    def _as_list(value: Any) -> list[Any]:
        return value if isinstance(value, list) else [value]

    @staticmethod
    def _parse_field(value: Any) -> Any:
        if not isinstance(value, str):
            return value
        stripped = value.strip()
        if not stripped:
            return stripped
        try:
            return json.loads(stripped)
        except json.JSONDecodeError:
            return value

    @staticmethod
    def _int(value: Any) -> int | None:
        if value is None or isinstance(value, bool):
            return None
        try:
            return int(value)
        except (TypeError, ValueError, OverflowError):
            return None

    @staticmethod
    def _original_name(path: Path) -> str:
        name = path.name
        # Downloader prefixes names with a random identifier for collision safety.
        if "_" in name:
            prefix, remainder = name.split("_", 1)
            if len(prefix) == 32 and all(ch in "0123456789abcdef" for ch in prefix.lower()):
                return remainder
        return name