• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

EsupPortail / Esup-Pod / 28925191300

08 Jul 2026 07:24AM UTC coverage: 69.222%. First build
28925191300

Pull #1427

github

web-flow
Secure user provided values (#1489)

Secure some user provided values in video/utils
Pull Request #1427: [Release] 4.3.0

915 of 1270 new or added lines in 31 files covered. (72.05%)

13744 of 19855 relevant lines covered (69.22%)

0.69 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

99.1
/pod/video_encode_transcript/runner_manager.py
1
"""Runner Manager orchestration helpers for encoding and transcription tasks in Esup-Pod.
2

3
This module builds task payloads, dispatches them to available runner managers,
4
and keeps local task rows synchronized with runner-side task status.
5
"""
6

7
import json
1✔
8
import logging
1✔
9
import os
1✔
10
from typing import Any, Literal, Optional, TypeAlias, TypedDict, Union, cast
1✔
11

12
import requests
1✔
13
from django.conf import settings
1✔
14
from django.contrib.sites.models import Site
1✔
15
from django.shortcuts import get_object_or_404
1✔
16
from django.utils.translation import gettext_lazy as _
1✔
17

18
from pod.cut.models import CutVideo
1✔
19
from pod.recorder.models import Recording
1✔
20
from pod.video.models import Video
1✔
21
from pod.video_encode_transcript.models import RunnerManager, Task
1✔
22
from pod.video_encode_transcript.runner_manager_utils import (
1✔
23
    store_before_remote_encoding_recording,
24
    store_before_remote_encoding_video,
25
)
26

27
from .utils import change_encoding_step
1✔
28

29
if __name__ == "__main__":
1✔
30
    from encoding_utils import get_list_rendition
1✔
31
else:
32
    from .encoding_utils import get_list_rendition
1✔
33

34
log = logging.getLogger(__name__)
1✔
35

36
DEBUG = getattr(settings, "DEBUG", True)
1✔
37

38
# Settings for template customization
39
TEMPLATE_VISIBLE_SETTINGS = getattr(
1✔
40
    settings,
41
    "TEMPLATE_VISIBLE_SETTINGS",
42
    {
43
        "TITLE_SITE": "Pod",
44
        "TITLE_ETB": "University name",
45
        "LOGO_SITE": "img/logoPod.svg",
46
        "LOGO_ETB": "img/esup-pod.svg",
47
        "LOGO_PLAYER": "img/pod_favicon.svg",
48
        "LINK_PLAYER": "",
49
        "LINK_PLAYER_NAME": _("Home"),
50
        "FOOTER_TEXT": ("",),
51
        "FAVICON": "img/pod_favicon.svg",
52
        "CSS_OVERRIDE": "",
53
        "PRE_HEADER_TEMPLATE": "",
54
        "POST_FOOTER_TEMPLATE": "",
55
        "TRACKING_TEMPLATE": "",
56
    },
57
)
58
__TITLE_SITE__ = (
1✔
59
    TEMPLATE_VISIBLE_SETTINGS["TITLE_SITE"]
60
    if (TEMPLATE_VISIBLE_SETTINGS.get("TITLE_SITE"))
61
    else "Pod"
62
)
63
__TITLE_ETB__ = (
1✔
64
    TEMPLATE_VISIBLE_SETTINGS["TITLE_ETB"]
65
    if (TEMPLATE_VISIBLE_SETTINGS.get("TITLE_ETB"))
66
    else "University name"
67
)
68

69
VERSION = getattr(settings, "VERSION", "4.X")
1✔
70

71
SECURE_SSL_REDIRECT = getattr(settings, "SECURE_SSL_REDIRECT", False)
1✔
72

73

74
SourceType = Literal["video", "recording"]
1✔
75
TaskType = Literal["encoding", "studio", "transcription"]
1✔
76
ParametersDict: TypeAlias = dict[str, Any]
1✔
77
HeadersDict: TypeAlias = dict[str, str]
1✔
78

79

80
class RunnerManagerTaskPayload(TypedDict):
1✔
81
    """Task payload expected by the Runner Manager API."""
82

83
    etab_name: str
1✔
84
    app_name: str
1✔
85
    app_version: str
1✔
86
    task_type: TaskType
1✔
87
    source_url: str
1✔
88
    notify_url: str
1✔
89
    parameters: ParametersDict
1✔
90

91

92
class RunnerManagerResponse(TypedDict, total=False):
1✔
93
    """Relevant fields returned by the Runner Manager API."""
94

95
    task_id: str
1✔
96
    status: str
1✔
97

98

99
def _build_rendition_parameters() -> ParametersDict:
1✔
100
    """Return rendition parameters serialized for the runner payload."""
101
    list_rendition = get_list_rendition()
1✔
102
    str_resolution: dict[str, dict[str, Any]] = {
1✔
103
        str(k): {
104
            "resolution": v["resolution"],
105
            "video_bitrate": v["video_bitrate"],
106
            "audio_bitrate": v["audio_bitrate"],
107
            "encode_mp4": v["encode_mp4"],
108
        }
109
        for k, v in list_rendition.items()
110
    }
111
    return {"rendition": json.dumps(str_resolution)}
1✔
112

113

114
def _attach_cut_info(parameters: ParametersDict, video: Video) -> None:
1✔
115
    """Attach cut information to parameters if it exists for the given video."""
116
    try:
1✔
117
        cut_video = CutVideo.objects.get(video=video)
1✔
118
        str_cut_info = {
1✔
119
            "start": str(cut_video.start),
120
            "end": str(cut_video.end),
121
            "initial_duration": str(cut_video.duration),
122
        }
123
        parameters["cut"] = json.dumps(str_cut_info)
1✔
124
    except CutVideo.DoesNotExist:
1✔
125
        pass
1✔
126

127

128
def _build_base_url(site: Site) -> str:
1✔
129
    """Build the public base URL for a given Django site."""
130
    url_scheme = "https" if SECURE_SSL_REDIRECT else "http"
1✔
131
    return f"{url_scheme}://{site.domain}"
1✔
132

133

134
def _build_video_source_url(video: Video, base_url: str) -> str:
1✔
135
    """Build the public source URL for a video file."""
136
    return f"{base_url}/media/{video.video}"
1✔
137

138

139
def _build_transcription_source_url(video: Video, base_url: str) -> str:
1✔
140
    """Build the preferred source URL for transcription tasks."""
141
    mp3 = video.get_video_mp3() if hasattr(video, "get_video_mp3") else None
1✔
142
    mp3file = getattr(mp3, "source_file", None) if mp3 else None
1✔
143
    if mp3file is not None and getattr(mp3file, "url", None):
1✔
144
        return f"{base_url}{mp3file.url}"
1✔
145
    return _build_video_source_url(video, base_url)
1✔
146

147

148
def _build_studio_source_url(recording: Recording, base_url: str) -> str:
1✔
149
    """Build the public source URL for a studio XML file."""
150
    source_file = recording.source_file
1✔
151
    media_url = getattr(settings, "MEDIA_URL", "/media/").rstrip("/")
1✔
152
    try:
1✔
153
        rel_path = os.path.relpath(
1✔
154
            str(source_file), str(getattr(settings, "MEDIA_ROOT", ""))
155
        )
156
    except Exception:
1✔
157
        rel_path = str(source_file)
1✔
158
    rel_path = rel_path.lstrip("/")
1✔
159
    return f"{base_url}{media_url}/{rel_path}"
1✔
160

161

162
def _build_media_asset_url(base_url: str, asset_name: str) -> str:
1✔
163
    """Build a public media URL for a stored asset name."""
164
    return f"{base_url}/media/{asset_name}"
1✔
165

166

167
def _extend_with_watermark_info(
1✔
168
    dressing_info: ParametersDict, dressing: Any, base_url: str
169
) -> None:
170
    """Append watermark-related dressing metadata when available."""
171
    if not dressing.watermark:
1✔
NEW
172
        return
×
173

174
    log.info("Dressing watermark found")
1✔
175
    dressing_info["watermark"] = _build_media_asset_url(
1✔
176
        base_url, str(dressing.watermark.file.name)
177
    )
178
    dressing_info["watermark_position"] = dressing.position
1✔
179
    dressing_info["watermark_opacity"] = str(dressing.opacity)
1✔
180

181

182
def _extend_with_credit_info(
1✔
183
    dressing_info: ParametersDict, credit_name: str, credit, base_url: str
184
) -> None:
185
    """Append opening/ending credit metadata when available."""
186
    if not credit:
1✔
NEW
187
        return
×
188

189
    log.info("Dressing %s found", credit_name.replace("_", " "))
1✔
190
    dressing_info[credit_name] = credit.slug
1✔
191
    dressing_info[f"{credit_name}_video"] = _build_media_asset_url(
1✔
192
        base_url, str(credit.video.name)
193
    )
194
    dressing_info[f"{credit_name}_video_duration"] = str(credit.duration)
1✔
195

196

197
def _attach_dressing_info(
1✔
198
    parameters: ParametersDict, video: Video, base_url: Optional[str] = None
199
) -> None:
200
    """Attach dressing information to parameters if available for the given video."""
201
    try:
1✔
202
        from pod.dressing.models import Dressing
1✔
203

204
        if base_url is None:
1✔
205
            site = Site.objects.get_current()
1✔
206
            base_url = _build_base_url(site)
1✔
207

208
        if not Dressing.objects.filter(videos=video).exists():
1✔
209
            return
1✔
210

211
        log.info("Dressing found for video id: %s", video.id)
1✔
212
        dressing = Dressing.objects.get(videos=video)
1✔
213
        if not dressing:
1✔
NEW
214
            return
×
215

216
        dressing_info: ParametersDict = {}
1✔
217
        _extend_with_watermark_info(dressing_info, dressing, base_url)
1✔
218
        _extend_with_credit_info(
1✔
219
            dressing_info, "opening_credits", dressing.opening_credits, base_url
220
        )
221
        _extend_with_credit_info(
1✔
222
            dressing_info, "ending_credits", dressing.ending_credits, base_url
223
        )
224

225
        if dressing_info:
1✔
226
            parameters["dressing"] = json.dumps(dressing_info)
1✔
227
    except Exception as exc:
1✔
228
        log.error(f"Error obtaining dressing for video {video.id}: {str(exc)}")
1✔
229

230

231
def _attach_video_metadata(parameters: ParametersDict, video: Video) -> None:
1✔
232
    """Attach lightweight video metadata when available."""
233
    video_id = getattr(video, "id", None)
1✔
234
    if video_id is not None:
1✔
235
        parameters["video_id"] = int(video_id)
1✔
236

237
    video_slug = getattr(video, "slug", None)
1✔
238
    if video_slug:
1✔
239
        parameters["video_slug"] = str(video_slug)
1✔
240

241
    video_title = getattr(video, "title", None)
1✔
242
    if video_title:
1✔
243
        parameters["video_title"] = str(video_title)
1✔
244

245

246
def _resolve_source_language(video: Video) -> str:
1✔
247
    """Return the video's source language or auto-detection fallback."""
248
    source_language = getattr(video, "main_lang", None)
1✔
249
    if isinstance(source_language, str) and source_language:
1✔
250
        return source_language.strip() or "auto"
1✔
251
    return "auto"
1✔
252

253

254
def _prepare_encoding_parameters(
1✔
255
    video: Optional[Video] = None,
256
    base_url: Optional[str] = None,
257
) -> ParametersDict:
258
    """Prepare encoding parameters for video or recording.
259

260
    Args:
261
        video: Video object (for video encoding).
262
               For studio recordings, pass None as video-specific metadata,
263
               cut and dressing info do not apply.
264
        base_url: Explicit base URL to use for dressing asset URLs.
265
                  Defaults to the current site URL when omitted.
266

267
    Returns:
268
        Dictionary with rendition and, when a video is provided, optional
269
        cut info, optional dressing info, and video metadata
270
        (video_id, video_slug, video_title).
271
    """
272
    parameters = _build_rendition_parameters()
1✔
273

274
    if video:
1✔
275
        _attach_cut_info(parameters, video)
1✔
276
        if base_url is None:
1✔
277
            _attach_dressing_info(parameters, video)
1✔
278
        else:
279
            _attach_dressing_info(parameters, video, base_url=base_url)
1✔
280
        _attach_video_metadata(parameters, video)
1✔
281

282
    return parameters
1✔
283

284

285
def _prepare_task_data(
1✔
286
    source_url: str,
287
    base_url: str,
288
    parameters: ParametersDict,
289
    task_type: TaskType,
290
) -> RunnerManagerTaskPayload:
291
    """Prepare task payload for runner manager.
292

293
    Args:
294
        source_url: URL to the source file (video or XML)
295
        base_url: Base URL of the site
296
        parameters: Encoding parameters
297

298
    Returns:
299
        Dictionary with task data
300
    """
301
    return {
1✔
302
        "etab_name": f"{__TITLE_ETB__} / {__TITLE_SITE__}",
303
        "app_name": "Esup-Pod",
304
        "app_version": VERSION,
305
        "task_type": task_type,
306
        "source_url": source_url,
307
        "notify_url": f"{base_url}/runner/notify_task_end/",
308
        "parameters": parameters,
309
    }
310

311

312
# ---- Runner manager helpers (module-level to keep complexity low) ----
313
def _rotate_same_priority_runner_managers(
1✔
314
    runner_managers: list[RunnerManager],
315
) -> list[RunnerManager]:
316
    """Rotate a same-priority runner manager list using last assigned task."""
317
    if len(runner_managers) <= 1:
1✔
318
        return runner_managers
1✔
319

320
    runner_manager_ids = [rm.id for rm in runner_managers]
1✔
321
    last_runner_manager_id = (
1✔
322
        Task.objects.filter(runner_manager_id__in=runner_manager_ids)
323
        .order_by("-date_added", "-id")
324
        .values_list("runner_manager_id", flat=True)
325
        .first()
326
    )
327
    if not last_runner_manager_id or last_runner_manager_id not in runner_manager_ids:
1✔
328
        return runner_managers
1✔
329

330
    last_index = runner_manager_ids.index(last_runner_manager_id)
1✔
331
    return runner_managers[last_index + 1 :] + runner_managers[: last_index + 1]
1✔
332

333

334
def _get_runner_managers(site: Site) -> list[RunnerManager]:
1✔
335
    """Return active site runner managers ordered by priority with round-robin per priority."""
336
    ordered_runner_managers = list(
1✔
337
        RunnerManager.objects.filter(site=site, is_active=True).order_by("priority", "id")
338
    )
339
    if len(ordered_runner_managers) <= 1:
1✔
340
        return ordered_runner_managers
1✔
341

342
    runner_managers: list[RunnerManager] = []
1✔
343
    current_priority: Optional[int] = None
1✔
344
    current_group: list[RunnerManager] = []
1✔
345
    # Apply round-robin only inside groups that share the same priority level.
346
    for runner_manager in ordered_runner_managers:
1✔
347
        if current_priority is None or runner_manager.priority == current_priority:
1✔
348
            current_group.append(runner_manager)
1✔
349
            current_priority = runner_manager.priority
1✔
350
            continue
1✔
351

352
        runner_managers.extend(_rotate_same_priority_runner_managers(current_group))
1✔
353
        current_group = [runner_manager]
1✔
354
        current_priority = runner_manager.priority
1✔
355

356
    if current_group:
1✔
357
        runner_managers.extend(_rotate_same_priority_runner_managers(current_group))
1✔
358

359
    return runner_managers
1✔
360

361

362
def _ids_for(
1✔
363
    source_type: SourceType, source_id: Union[int, str]
364
) -> tuple[Optional[int], Optional[int]]:
365
    """Return (video_id, recording_id) tuple based on source type."""
366
    return (int(source_id), None) if source_type == "video" else (None, int(source_id))
1✔
367

368

369
def _execute_url(rm: RunnerManager) -> str:
1✔
370
    """Build the execute endpoint URL for the given runner manager."""
371
    base = rm.url if rm.url.endswith("/") else rm.url + "/"
1✔
372
    return base + "task/execute"
1✔
373

374

375
def _headers(rm: RunnerManager) -> HeadersDict:
1✔
376
    """Build authentication and content headers for the runner manager API."""
377
    return {
1✔
378
        "Accept": "application/json",
379
        "Content-Type": "application/json",
380
        "Authorization": f"Bearer {rm.token}",
381
    }
382

383

384
def _try_send_to_rm(
1✔
385
    rm: RunnerManager, payload: RunnerManagerTaskPayload
386
) -> Optional[requests.Response]:
387
    """Try to POST the payload to a runner manager; log and return None on failure."""
388
    try:
1✔
389
        return requests.post(
1✔
390
            _execute_url(rm), data=json.dumps(payload), headers=_headers(rm), timeout=30
391
        )
392
    except requests.RequestException as exc:
1✔
393
        log.warning(
1✔
394
            f"Cannot reach runner manager {rm.name}: {str(exc)}. Trying next one."
395
        )
396
        return None
1✔
397

398

399
def _parse_runner_response(
1✔
400
    rm: RunnerManager, response: requests.Response
401
) -> Optional[RunnerManagerResponse]:
402
    """Parse and validate a runner manager HTTP response.
403

404
    Returns None when the response body is not a valid JSON payload expected from
405
    the runner manager (for example an HTML error page returned by a reverse proxy
406
    with HTTP 200).
407
    """
408
    if not response.content:
1✔
409
        return {}
1✔
410

411
    try:
1✔
412
        payload = response.json()
1✔
413
    except ValueError:
1✔
414
        content_type = response.headers.get("Content-Type", "")
1✔
415
        log.warning(
1✔
416
            "Runner manager %s returned HTTP 200 with non-JSON body "
417
            "(Content-Type=%s). Trying next one.",
418
            rm.name,
419
            content_type or "unknown",
420
        )
421
        return None
1✔
422

423
    if not isinstance(payload, dict):
1✔
424
        log.warning(
1✔
425
            "Runner manager %s returned an unexpected JSON payload type (%s). "
426
            "Trying next one.",
427
            rm.name,
428
            type(payload).__name__,
429
        )
430
        return None
1✔
431

432
    return cast(RunnerManagerResponse, payload)
1✔
433

434

435
def _prestore_encoding_if_needed(
1✔
436
    *,
437
    task_type: TaskType,
438
    source_type: SourceType,
439
    video_id: Optional[int],
440
    recording_id: Optional[int],
441
    rm: RunnerManager,
442
    data: RunnerManagerTaskPayload,
443
) -> None:
444
    """Run pre-store steps for encoding/studio tasks.
445

446
    Does nothing for transcription tasks.
447
    """
448
    if task_type not in ("encoding", "studio"):
1✔
449
        return
1✔
450
    execute_url = _execute_url(rm)
1✔
451
    if source_type == "video":
1✔
452
        if video_id is not None:
1✔
453
            store_before_remote_encoding_video(video_id, execute_url, data)
1✔
454
        else:
455
            log.warning(
1✔
456
                "Unexpected None video_id for source_type 'video' while preparing store_before_remote_encoding_video."
457
            )
458
    elif source_type == "recording":
1✔
459
        if recording_id is not None:
1✔
460
            store_before_remote_encoding_recording(recording_id, execute_url, data)
1✔
461
        else:
462
            log.warning(
1✔
463
                "Unexpected None recording_id for source_type 'recording' while preparing store_before_remote_encoding_recording."
464
            )
465

466

467
def _submit_to_runner_manager(
1✔
468
    rm: RunnerManager,
469
    data: RunnerManagerTaskPayload,
470
    task_type: TaskType,
471
    source_type: SourceType,
472
    video_id: Optional[int],
473
    recording_id: Optional[int],
474
) -> bool:
475
    """Submit payload to one runner manager and handle response and pre-store."""
476
    response = _try_send_to_rm(rm, data)
1✔
477
    if response is None:
1✔
478
        return False
1✔
479
    if response.status_code != 200:
1✔
480
        log.warning(
1✔
481
            f"Runner manager {rm.name} returned status code {response.status_code}. Trying next one."
482
        )
483
        return False
1✔
484
    log.info(
1✔
485
        f"Runner manager {rm.name} is available to process {task_type} for {source_type} {video_id or recording_id}."
486
    )
487
    payload = _parse_runner_response(rm, response)
1✔
488
    if payload is None:
1✔
489
        return False
1✔
490

491
    _update_task_from_response(video_id, recording_id, task_type, rm, payload)
1✔
492
    _prestore_encoding_if_needed(
1✔
493
        task_type=task_type,
494
        source_type=source_type,
495
        video_id=video_id,
496
        recording_id=recording_id,
497
        rm=rm,
498
        data=data,
499
    )
500
    return True
1✔
501

502

503
def _submit_to_runner_managers(
1✔
504
    *,
505
    runner_managers: list[RunnerManager],
506
    data: RunnerManagerTaskPayload,
507
    task_type: TaskType,
508
    source_type: SourceType,
509
    source_id: Union[int, str],
510
) -> bool:
511
    """Try runner managers in order and stop on the first successful submission."""
512
    video_id, recording_id = _ids_for(source_type, source_id)
1✔
513

514
    for rm in runner_managers:
1✔
515
        if _submit_to_runner_manager(
1✔
516
            rm,
517
            data=data,
518
            task_type=task_type,
519
            source_type=source_type,
520
            video_id=video_id,
521
            recording_id=recording_id,
522
        ):
523
            return True
1✔
524

525
    log.warning(
1✔
526
        f"No runner manager available to process {task_type} for {source_type} {source_id}. "
527
        f"Task will remain pending and will be retried by the process_tasks command."
528
    )
529
    return False
1✔
530

531

532
def submit_task_to_runner_managers(
1✔
533
    *,
534
    runner_managers: list[RunnerManager],
535
    task_type: TaskType,
536
    source_type: SourceType,
537
    source_id: Union[int, str],
538
    source_url: str,
539
    base_url: str,
540
    parameters: ParametersDict,
541
) -> bool:
542
    """Build a runner payload and submit it to the given runner managers."""
543
    data = _prepare_task_data(
1✔
544
        source_url=source_url,
545
        base_url=base_url,
546
        parameters=parameters,
547
        task_type=task_type,
548
    )
549
    return _submit_to_runner_managers(
1✔
550
        runner_managers=runner_managers,
551
        data=data,
552
        task_type=task_type,
553
        source_type=source_type,
554
        source_id=source_id,
555
    )
556

557

558
def _update_task_pending(
1✔
559
    source_type: SourceType, source_id: Union[int, str], task_type: TaskType
560
) -> tuple[Optional[int], Optional[int]]:
561
    """Create or set a pending task for the given source and return (video_id, recording_id)."""
562
    video_id, recording_id = _ids_for(source_type, source_id)
1✔
563
    log.info(
1✔
564
        "Update task to pending for video_id: %s, recording_id: %s",
565
        video_id,
566
        recording_id,
567
    )
568
    _edit_task(
1✔
569
        video_id=video_id,
570
        recording_id=recording_id,
571
        type=task_type,
572
        status="pending",
573
        runner_manager_id=None,
574
        task_id=None,
575
    )
576
    return video_id, recording_id
1✔
577

578

579
def _update_task_from_response(
1✔
580
    video_id: Optional[int],
581
    recording_id: Optional[int],
582
    task_type: TaskType,
583
    rm: RunnerManager,
584
    response_json: RunnerManagerResponse,
585
) -> None:
586
    """Update the task row using the response payload returned by the runner manager."""
587
    task_id = response_json.get("task_id")
1✔
588
    status = str(response_json.get("status", "pending"))
1✔
589
    log.info(
1✔
590
        "Update task for video_id=%s, recording_id=%s, task_type=%s with response_json=%s",
591
        video_id,
592
        recording_id,
593
        task_type,
594
        response_json,
595
    )
596
    _edit_task(
1✔
597
        video_id=video_id,
598
        recording_id=recording_id,
599
        type=task_type,
600
        status=status,
601
        runner_manager_id=rm.id,
602
        task_id=task_id,
603
    )
604

605

606
def _send_task_to_runner_manager(
1✔
607
    *,
608
    task_type: TaskType,
609
    source_id: Union[int, str],
610
    source_type: SourceType,
611
    source_url: str,
612
    base_url: str,
613
    parameters: ParametersDict,
614
) -> bool:
615
    """Submit a task to the Runner Manager and update the DB task row.
616

617
    - task_type: one of "encoding", "studio", "transcription"
618
    - source_type: "video" or "recording" (used to resolve ids and pre-store behavior)
619
    """
620

621
    try:
1✔
622
        # Keep a local pending row even when no runner is currently available.
623
        # This allows process_tasks to retry submission later.
624
        _update_task_pending(source_type, source_id, task_type)
1✔
625

626
        site = Site.objects.get_current()
1✔
627
        runner_managers_list = _get_runner_managers(site)
1✔
628
        if not runner_managers_list:
1✔
629
            log.warning(
1✔
630
                f"No active runner manager defined for site {site.domain}. Cannot process {task_type} for {source_type} {source_id}."
631
            )
632
            return False
1✔
633

634
        # Build payload and try immediate submission
635
        data = _prepare_task_data(source_url, base_url, parameters, task_type)
1✔
636

637
        return _submit_to_runner_managers(
1✔
638
            runner_managers=runner_managers_list,
639
            data=data,
640
            task_type=task_type,
641
            source_type=source_type,
642
            source_id=source_id,
643
        )
644

645
    except Exception as exc:
1✔
646
        log.error(
1✔
647
            f"Error to process {task_type} for {source_type} {source_id}: {str(exc)}"
648
        )
649
        return False
1✔
650

651

652
def encode_video(video_id: int) -> None:
1✔
653
    """Start video encoding with runner manager."""
654
    log.info("Start encoding, with runner manager, for id: %s" % video_id)
1✔
655
    try:
1✔
656
        site = Site.objects.get_current()
1✔
657
        # Get video info
658
        video = get_object_or_404(Video, id=video_id)
1✔
659
        # Build content URL
660
        base_url = _build_base_url(site)
1✔
661
        content_url = _build_video_source_url(video, base_url)
1✔
662

663
        # Prepare encoding parameters
664
        parameters = _prepare_encoding_parameters(video=video)
1✔
665

666
        # Send encoding task to runner manager
667
        _send_task_to_runner_manager(
1✔
668
            task_type="encoding",
669
            source_id=video_id,
670
            source_type="video",
671
            source_url=content_url,
672
            base_url=base_url,
673
            parameters=parameters,
674
        )
675

676
    except Exception as exc:
1✔
677
        log.error(
1✔
678
            'Error to encode video "%(id)s": %(exc)s' % {"id": video_id, "exc": str(exc)}
679
        )
680

681

682
def encode_studio_recording(recording_id: int) -> None:
1✔
683
    """Start encoding studio recording with runner manager.
684

685
    This function handles encoding of studio recordings by passing the XML
686
    source file URL to the runner manager.
687
    """
688
    log.info(
1✔
689
        "Start encoding, with runner manager, for studio recording id %s" % recording_id
690
    )
691
    try:
1✔
692
        site = Site.objects.get_current()
1✔
693
        # Get studio recording
694
        recording = Recording.objects.get(id=recording_id)
1✔
695
        base_url = _build_base_url(site)
1✔
696
        source_url = _build_studio_source_url(recording, base_url)
1✔
697

698
        # Prepare encoding parameters (no specific cut info for studio recordings)
699
        parameters = _prepare_encoding_parameters(video=None)
1✔
700

701
        # Send studio task to runner manager
702
        _send_task_to_runner_manager(
1✔
703
            task_type="studio",
704
            source_id=recording_id,
705
            source_type="recording",
706
            source_url=source_url,
707
            base_url=base_url,
708
            parameters=parameters,
709
        )
710

711
    except Recording.DoesNotExist:
1✔
712
        log.error(f"Recording {recording_id} not found.")
1✔
713
    except Exception as exc:
1✔
714
        log.error(f"Error to encode recording {recording_id}: {str(exc)}")
1✔
715

716

717
def transcript_video(video_id: int) -> None:
1✔
718
    """Start video transcription with runner manager."""
719
    log.info("Start transcription, with runner manager, for id: %s" % video_id)
1✔
720
    try:
1✔
721
        site = Site.objects.get_current()
1✔
722
        # Get video info
723
        video = get_object_or_404(Video, id=video_id)
1✔
724
        base_url = _build_base_url(site)
1✔
725
        content_url = _build_transcription_source_url(video, base_url)
1✔
726

727
        # Prepare transcript parameters
728
        parameters = _prepare_transcription_parameters(video=video)
1✔
729

730
        # Mark video as encoding in progress
731
        Video.objects.filter(id=video_id).update(encoding_in_progress=True)
1✔
732

733
        # Update encoding step to transcripting audio
734
        change_encoding_step(video_id, 5, "transcripting audio")
1✔
735

736
        # Send transcription task to runner manager
737
        _send_task_to_runner_manager(
1✔
738
            task_type="transcription",
739
            source_id=video_id,
740
            source_type="video",
741
            source_url=content_url,
742
            base_url=base_url,
743
            parameters=parameters,
744
        )
745

746
    except Exception as exc:
1✔
747
        log.error(
1✔
748
            'Error to transcribe video "%(id)s": %(exc)s'
749
            % {"id": video_id, "exc": str(exc)}
750
        )
751

752

753
def _prepare_transcription_parameters(video: Video) -> ParametersDict:
1✔
754
    """Prepare parameters for a transcription task.
755

756
    Args:
757
        video: `Video` instance to transcribe.
758

759
    Returns:
760
        Parameter dictionary for the Runner Manager.
761
    """
762
    source_language = _resolve_source_language(video)
1✔
763
    try:
1✔
764
        from .transcript import resolve_transcription_language
1✔
765

766
        # Requested language (video `transcript` field)
767
        lang = resolve_transcription_language(video)
1✔
768

769
        # Options from settings (optional on runner side)
770
        transcription_type = getattr(settings, "TRANSCRIPTION_TYPE", None)
1✔
771
        normalize = bool(getattr(settings, "TRANSCRIPTION_NORMALIZE", False))
1✔
772

773
        params: ParametersDict = {
1✔
774
            # Final subtitle/VTT language requested by the user
775
            "language": lang,
776
            # Main audio/source language, supported by Esup-Runner since version 1.5
777
            "source_language": source_language,
778
            # Duration may help runner to tune/optimize
779
            "duration": float(getattr(video, "duration", 0) or 0),
780
            # Text normalization (punctuation/casing) on runner side if supported
781
            "normalize": normalize,
782
        }
783
        _attach_video_metadata(params, video)
1✔
784
        # If needed in future, we can add model size or other options here
785
        if transcription_type:
1✔
786
            params["model_type"] = transcription_type
1✔
787
            # Possibility to add model size if needed in future
788
            # params["model"] = "medium"
789

790
        return params
1✔
791
    except Exception:
1✔
792
        # Keep legacy key name for backward compatibility with older runners.
793
        params: ParametersDict = {
1✔
794
            "lang": getattr(video, "transcript", "") or "",
795
            "source_language": source_language,
796
        }
797
        _attach_video_metadata(params, video)
1✔
798
        return params
1✔
799

800

801
def submit_encoding_task(
1✔
802
    video: Video, site: Site, runner_managers: list[RunnerManager]
803
) -> bool:
804
    """Submit a pending video encoding task to the provided runner managers."""
805
    base_url = _build_base_url(site)
1✔
806
    source_url = _build_video_source_url(video, base_url)
1✔
807
    parameters = _prepare_encoding_parameters(video=video, base_url=base_url)
1✔
808
    return submit_task_to_runner_managers(
1✔
809
        runner_managers=runner_managers,
810
        task_type="encoding",
811
        source_type="video",
812
        source_id=video.id,
813
        source_url=source_url,
814
        base_url=base_url,
815
        parameters=parameters,
816
    )
817

818

819
def submit_transcription_task(
1✔
820
    video: Video, site: Site, runner_managers: list[RunnerManager]
821
) -> bool:
822
    """Submit a pending video transcription task to the provided runner managers."""
823
    base_url = _build_base_url(site)
1✔
824
    source_url = _build_transcription_source_url(video, base_url)
1✔
825
    parameters = _prepare_transcription_parameters(video=video)
1✔
826
    return submit_task_to_runner_managers(
1✔
827
        runner_managers=runner_managers,
828
        task_type="transcription",
829
        source_type="video",
830
        source_id=video.id,
831
        source_url=source_url,
832
        base_url=base_url,
833
        parameters=parameters,
834
    )
835

836

837
def submit_studio_task(
1✔
838
    recording: Recording, site: Site, runner_managers: list[RunnerManager]
839
) -> bool:
840
    """Submit a pending studio encoding task to the provided runner managers."""
841
    base_url = _build_base_url(site)
1✔
842
    source_url = _build_studio_source_url(recording, base_url)
1✔
843
    parameters = _prepare_encoding_parameters(video=None)
1✔
844
    return submit_task_to_runner_managers(
1✔
845
        runner_managers=runner_managers,
846
        task_type="studio",
847
        source_type="recording",
848
        source_id=recording.id,
849
        source_url=source_url,
850
        base_url=base_url,
851
        parameters=parameters,
852
    )
853

854

855
def _edit_task(
1✔
856
    video_id: Optional[int],
857
    type: str,
858
    status: str,
859
    runner_manager_id: Optional[int] = None,
860
    task_id: Optional[str] = None,
861
    recording_id: Optional[int] = None,
862
) -> None:
863
    """Edit or create a task for a video or studio recording."""
864
    try:
1✔
865
        from .task_queue import refresh_pending_task_ranks
1✔
866

867
        log.info(
1✔
868
            f"Edit or create a task: {video_id} {type} {runner_manager_id} {status} {task_id}"
869
        )
870
        # Check if a task already exists for this video and type with pending status
871
        # Build base queryset depending on source type
872
        if type == "studio":
1✔
873
            tasks_list = list(
1✔
874
                Task.objects.filter(
875
                    recording_id=recording_id,
876
                    type=type,
877
                    status="pending",
878
                )
879
            )
880
        else:
881
            tasks_list = list(
1✔
882
                Task.objects.filter(
883
                    video_id=video_id,
884
                    type=type,
885
                    status="pending",
886
                )
887
            )
888
        if not tasks_list:
1✔
889
            # Create new task
890
            task = Task(
1✔
891
                video_id=video_id if type != "studio" else None,
892
                recording_id=recording_id if type == "studio" else None,
893
                type=type,
894
                runner_manager_id=runner_manager_id,
895
                status=status,
896
                task_id=task_id,
897
            )
898
            task.save()
1✔
899
        else:
900
            # Edit existing task
901
            task = tasks_list[0]
1✔
902
            task.status = status
1✔
903
            if runner_manager_id is not None:
1✔
904
                task.runner_manager_id = runner_manager_id
1✔
905
            if task_id is not None:
1✔
906
                task.task_id = task_id
1✔
907
            # Keep association fields as-is
908
            task.save()
1✔
909

910
        refresh_pending_task_ranks()
1✔
911

912
    except Exception as exc:
1✔
913
        log.error(
1✔
914
            f"Unable to edit a task (video_id={video_id}, recording_id={recording_id}): {str(exc)}"
915
        )
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc