File size: 20,502 Bytes
b3ca762
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import asyncio
import logging
import math
import time

from fastapi import Request, Response, HTTPException

from mediaflow_proxy.drm.decrypter import decrypt_segment, process_drm_init_segment
from mediaflow_proxy.utils.crypto_utils import encryption_handler
from mediaflow_proxy.utils.http_utils import (
    encode_mediaflow_proxy_url,
    get_original_scheme,
    ProxyRequestHeaders,
    apply_header_manipulation,
)
from mediaflow_proxy.utils.dash_prebuffer import dash_prebuffer
from mediaflow_proxy.utils.cache_utils import get_cached_processed_init, set_cached_processed_init
from mediaflow_proxy.utils.m3u8_processor import SkipSegmentFilter
from mediaflow_proxy.configs import settings

logger = logging.getLogger(__name__)


async def process_manifest(
    request: Request,
    mpd_dict: dict,
    proxy_headers: ProxyRequestHeaders,
    key_id: str = None,
    key: str = None,
    resolution: str = None,
    skip_segments: list = None,
) -> Response:
    """
    Processes the MPD manifest and converts it to an HLS manifest.

    Args:
        request (Request): The incoming HTTP request.
        mpd_dict (dict): The MPD manifest data.
        proxy_headers (ProxyRequestHeaders): The headers to include in the request.
        key_id (str, optional): The DRM key ID. Defaults to None.
        key (str, optional): The DRM key. Defaults to None.
        resolution (str, optional): Target resolution (e.g., '1080p', '720p'). Defaults to None.
        skip_segments (list, optional): List of time segments to skip. Each item should have 'start' and 'end' keys.

    Returns:
        Response: The HLS manifest as an HTTP response.
    """
    hls_content = build_hls(mpd_dict, request, key_id, key, resolution, skip_segments)

    # Start DASH pre-buffering in background if enabled
    if settings.enable_dash_prebuffer:
        # Extract headers for pre-buffering
        headers = {}
        for key, value in request.query_params.items():
            if key.startswith("h_"):
                headers[key[2:]] = value

        # Get the original MPD URL from the request
        mpd_url = request.query_params.get("d", "")
        if mpd_url:
            # Start pre-buffering in background
            asyncio.create_task(dash_prebuffer.prebuffer_dash_manifest(mpd_url, headers))

    return Response(content=hls_content, media_type="application/vnd.apple.mpegurl", headers=proxy_headers.response)


async def process_playlist(
    request: Request,
    mpd_dict: dict,
    profile_id: str,
    proxy_headers: ProxyRequestHeaders,
    skip_segments: list = None,
    start_offset: float = None,
) -> Response:
    """
    Processes the MPD manifest and converts it to an HLS playlist for a specific profile.

    Args:
        request (Request): The incoming HTTP request.
        mpd_dict (dict): The MPD manifest data.
        profile_id (str): The profile ID to generate the playlist for.
        proxy_headers (ProxyRequestHeaders): The headers to include in the request.
        skip_segments (list, optional): List of time segments to skip. Each item should have 'start' and 'end' keys.
        start_offset (float, optional): Start offset in seconds for live streams.

    Returns:
        Response: The HLS playlist as an HTTP response.

    Raises:
        HTTPException: If the profile is not found in the MPD manifest.
    """
    matching_profiles = [p for p in mpd_dict["profiles"] if p["id"] == profile_id]
    if not matching_profiles:
        raise HTTPException(status_code=404, detail="Profile not found")

    hls_content = build_hls_playlist(mpd_dict, matching_profiles, request, skip_segments, start_offset)

    # Trigger prebuffering of upcoming segments for live streams
    if settings.enable_dash_prebuffer and mpd_dict.get("isLive", False):
        # Extract headers for pre-buffering
        headers = {}
        for key, value in request.query_params.items():
            if key.startswith("h_"):
                headers[key[2:]] = value

        # Use the new prefetch method for live playlists
        asyncio.create_task(dash_prebuffer.prefetch_for_live_playlist(matching_profiles, headers))

    # Don't include propagate headers for playlists - they should only apply to segments
    response_headers = apply_header_manipulation({}, proxy_headers, include_propagate=False)
    return Response(content=hls_content, media_type="application/vnd.apple.mpegurl", headers=response_headers)


async def process_segment(
    init_content: bytes,
    segment_content: bytes,
    mimetype: str,
    proxy_headers: ProxyRequestHeaders,
    key_id: str = None,
    key: str = None,
    use_map: bool = False,
) -> Response:
    """
    Processes and decrypts a media segment.

    Args:
        init_content (bytes): The initialization segment content.
        segment_content (bytes): The media segment content.
        mimetype (str): The MIME type of the segment.
        proxy_headers (ProxyRequestHeaders): The headers to include in the request.
        key_id (str, optional): The DRM key ID. Defaults to None.
        key (str, optional): The DRM key. Defaults to None.
        use_map (bool, optional): If True, init segment is served separately via EXT-X-MAP,
            so don't concatenate init with segment. Defaults to False.

    Returns:
        Response: The decrypted segment as an HTTP response.
    """
    if key_id and key:
        # For DRM protected content
        now = time.time()
        decrypted_content = decrypt_segment(init_content, segment_content, key_id, key, include_init=not use_map)
        logger.info(f"Decryption of {mimetype} segment took {time.time() - now:.4f} seconds")
    else:
        # For non-DRM protected content
        if use_map:
            # Init is served separately via EXT-X-MAP
            decrypted_content = segment_content
        else:
            # Concatenate init and segment content
            decrypted_content = init_content + segment_content

    response_headers = apply_header_manipulation({}, proxy_headers)
    return Response(content=decrypted_content, media_type=mimetype, headers=response_headers)


async def process_init_segment(
    init_content: bytes,
    mimetype: str,
    proxy_headers: ProxyRequestHeaders,
    key_id: str = None,
    key: str = None,
    init_url: str = None,
) -> Response:
    """
    Processes an initialization segment for EXT-X-MAP.

    Args:
        init_content (bytes): The initialization segment content.
        mimetype (str): The MIME type of the segment.
        proxy_headers (ProxyRequestHeaders): The headers to include in the request.
        key_id (str, optional): The DRM key ID. Defaults to None.
        key (str, optional): The DRM key. Defaults to None.
        init_url (str, optional): The init URL for caching. Defaults to None.

    Returns:
        Response: The processed init segment as an HTTP response.
    """
    if key_id and key:
        # Check if we have a cached processed version
        if init_url:
            cached_processed = await get_cached_processed_init(init_url, key_id)
            if cached_processed:
                logger.debug(f"Using cached processed init segment for {init_url}")
                response_headers = apply_header_manipulation({}, proxy_headers)
                return Response(content=cached_processed, media_type=mimetype, headers=response_headers)

        # For DRM protected content, we need to process the init segment
        # to remove encryption-related boxes but keep the moov structure
        now = time.time()
        processed_content = process_drm_init_segment(init_content, key_id, key)
        logger.info(f"Processing of {mimetype} init segment took {time.time() - now:.4f} seconds")

        # Cache the processed init segment
        if init_url:
            await set_cached_processed_init(init_url, key_id, processed_content, ttl=3600)
    else:
        # For non-DRM protected content, just return the init segment as-is
        processed_content = init_content

    response_headers = apply_header_manipulation({}, proxy_headers)
    return Response(content=processed_content, media_type=mimetype, headers=response_headers)


def build_hls(
    mpd_dict: dict,
    request: Request,
    key_id: str = None,
    key: str = None,
    resolution: str = None,
    skip_segments: list = None,
) -> str:
    """
    Builds an HLS manifest from the MPD manifest.

    Args:
        mpd_dict (dict): The MPD manifest data.
        request (Request): The incoming HTTP request.
        key_id (str, optional): The DRM key ID. Defaults to None.
        key (str, optional): The DRM key. Defaults to None.
        resolution (str, optional): Target resolution (e.g., '1080p', '720p'). Defaults to None.
        skip_segments (list, optional): List of time segments to skip. Each item should have 'start' and 'end' keys.

    Returns:
        str: The HLS manifest as a string.
    """
    hls = ["#EXTM3U", "#EXT-X-VERSION:6"]
    query_params = dict(request.query_params)

    # Preserve skip parameter in query params so it propagates to playlists
    if skip_segments:
        # Convert back to compact format for URL
        skip_str = ",".join(f"{s['start']}-{s['end']}" for s in skip_segments)
        query_params["skip"] = skip_str
    has_encrypted = query_params.pop("has_encrypted", False)

    video_profiles = {}
    audio_profiles = {}

    # Get the base URL for the playlist_endpoint endpoint
    proxy_url = request.url_for("playlist_endpoint")
    proxy_url = str(proxy_url.replace(scheme=get_original_scheme(request)))

    for profile in mpd_dict["profiles"]:
        query_params.update({"profile_id": profile["id"], "key_id": key_id or "", "key": key or ""})
        playlist_url = encode_mediaflow_proxy_url(
            proxy_url,
            query_params=query_params,
            encryption_handler=encryption_handler if has_encrypted else None,
        )

        if "video" in profile["mimeType"]:
            video_profiles[profile["id"]] = (profile, playlist_url)
        elif "audio" in profile["mimeType"]:
            audio_profiles[profile["id"]] = (profile, playlist_url)

    # Filter video profiles by resolution if specified
    if resolution and video_profiles:
        video_profiles = _filter_video_profiles_by_resolution(video_profiles, resolution)

    # Add audio streams
    for i, (profile, playlist_url) in enumerate(audio_profiles.values()):
        is_default = "YES" if i == 0 else "NO"  # Set the first audio track as default
        hls.append(
            f'#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="{profile["id"]}",DEFAULT={is_default},AUTOSELECT={is_default},LANGUAGE="{profile.get("lang", "und")}",URI="{playlist_url}"'
        )

    # Add video streams
    for profile, playlist_url in video_profiles.values():
        # Only add AUDIO attribute if there are audio profiles available
        audio_attr = ',AUDIO="audio"' if audio_profiles else ""
        hls.append(
            f'#EXT-X-STREAM-INF:BANDWIDTH={profile["bandwidth"]},RESOLUTION={profile["width"]}x{profile["height"]},CODECS="{profile["codecs"]}",FRAME-RATE={profile["frameRate"]}{audio_attr}'
        )
        hls.append(playlist_url)

    return "\n".join(hls)


def _filter_video_profiles_by_resolution(video_profiles: dict, target_resolution: str) -> dict:
    """
    Filter video profiles to select the one matching the target resolution.
    Falls back to closest lower resolution if exact match not found.

    Args:
        video_profiles: Dictionary of profile_id -> (profile, playlist_url).
        target_resolution: Target resolution string (e.g., '1080p', '720p').

    Returns:
        Filtered dictionary with only the selected profile.
    """
    # Parse target height from "1080p" -> 1080
    target_height = int(target_resolution.rstrip("p"))

    # Convert to list and sort by height descending
    profiles_list = [
        (profile_id, profile, playlist_url)
        for profile_id, (profile, playlist_url) in video_profiles.items()
        if profile.get("height", 0) > 0
    ]

    if not profiles_list:
        logger.warning("No video profiles with valid height found, returning all profiles")
        return video_profiles

    sorted_profiles = sorted(profiles_list, key=lambda x: x[1]["height"], reverse=True)

    # Find exact match or closest lower
    selected = None
    for profile_id, profile, playlist_url in sorted_profiles:
        if profile["height"] <= target_height:
            selected = (profile_id, profile, playlist_url)
            break

    # If all profiles are higher than target, use lowest available
    if selected is None:
        selected = sorted_profiles[-1]

    profile_id, profile, playlist_url = selected
    logger.info(
        f"Selected MPD video profile with resolution {profile['width']}x{profile['height']} for target {target_resolution}"
    )

    return {profile_id: (profile, playlist_url)}


def build_hls_playlist(
    mpd_dict: dict, profiles: list[dict], request: Request, skip_segments: list = None, start_offset: float = None
) -> str:
    """
    Builds an HLS playlist from the MPD manifest for specific profiles.

    Args:
        mpd_dict (dict): The MPD manifest data.
        profiles (list[dict]): The profiles to include in the playlist.
        request (Request): The incoming HTTP request.
        skip_segments (list, optional): List of time segments to skip. Each item should have 'start' and 'end' keys.
        start_offset (float, optional): Start offset in seconds for live streams. Defaults to settings.livestream_start_offset for live.

    Returns:
        str: The HLS playlist as a string.
    """
    hls = ["#EXTM3U", "#EXT-X-VERSION:6"]

    added_segments = 0
    skipped_segments = 0
    is_live = mpd_dict.get("isLive", False)

    # Inject EXT-X-START for live streams (enables prebuffering by starting behind live edge)
    # User-provided start_offset always takes precedence; otherwise use default for live streams only
    effective_start_offset = (
        start_offset if start_offset is not None else (settings.livestream_start_offset if is_live else None)
    )
    if effective_start_offset is not None:
        hls.append(f"#EXT-X-START:TIME-OFFSET={effective_start_offset:.1f},PRECISE=YES")

    # Initialize skip filter if skip_segments provided
    skip_filter = SkipSegmentFilter(skip_segments) if skip_segments else None

    # Use EXT-X-MAP for live streams to avoid duplicate moov atoms
    use_map = is_live

    proxy_url = request.url_for("segment_endpoint")
    proxy_url = str(proxy_url.replace(scheme=get_original_scheme(request)))

    # Get init endpoint URL for EXT-X-MAP
    init_proxy_url = request.url_for("init_endpoint")
    init_proxy_url = str(init_proxy_url.replace(scheme=get_original_scheme(request)))

    for index, profile in enumerate(profiles):
        segments = profile["segments"]
        if not segments:
            logger.warning(f"No segments found for profile {profile['id']}")
            continue

        if is_live:
            depth = max(settings.mpd_live_playlist_depth, 1)
            trimmed_segments = segments[-depth:]
        else:
            trimmed_segments = segments

        # Add headers for only the first profile
        if index == 0:
            first_segment = trimmed_segments[0]
            extinf_values = [f["extinf"] for f in trimmed_segments if "extinf" in f]
            target_duration = math.ceil(max(extinf_values)) if extinf_values else 3

            # Align HLS media sequence with MPD-provided numbering when available
            mpd_start_number = profile.get("segment_template_start_number")
            sequence = first_segment.get("number")

            if sequence is None:
                # Fallback to MPD template start number
                if mpd_start_number is not None:
                    sequence = mpd_start_number
                else:
                    # As a last resort, derive from timeline information
                    time_val = first_segment.get("time")
                    duration_val = first_segment.get("duration_mpd_timescale")
                    if time_val is not None and duration_val and duration_val > 0:
                        sequence = math.floor(time_val / duration_val)
                    else:
                        sequence = 1

            hls.extend(
                [
                    f"#EXT-X-TARGETDURATION:{target_duration}",
                    f"#EXT-X-MEDIA-SEQUENCE:{sequence}",
                ]
            )
            # For live streams, don't set PLAYLIST-TYPE to allow sliding window
            if not is_live:
                hls.append("#EXT-X-PLAYLIST-TYPE:VOD")

        init_url = profile["initUrl"]
        # For SegmentBase profiles, we may have byte range for initialization segment
        init_range = profile.get("initRange")

        query_params = dict(request.query_params)
        query_params.pop("profile_id", None)
        query_params.pop("d", None)
        has_encrypted = query_params.pop("has_encrypted", False)

        # Add EXT-X-MAP for init segment (for live streams or when beneficial)
        if use_map:
            init_query_params = {
                "init_url": init_url,
                "mime_type": profile["mimeType"],
                "is_live": "true" if is_live else "false",
            }
            if init_range:
                init_query_params["init_range"] = init_range
            # Add key parameters
            if query_params.get("key_id"):
                init_query_params["key_id"] = query_params["key_id"]
            if query_params.get("key"):
                init_query_params["key"] = query_params["key"]
            # Add api_password for authentication
            if query_params.get("api_password"):
                init_query_params["api_password"] = query_params["api_password"]

            init_map_url = encode_mediaflow_proxy_url(
                init_proxy_url,
                query_params=init_query_params,
                encryption_handler=encryption_handler if has_encrypted else None,
            )
            hls.append(f'#EXT-X-MAP:URI="{init_map_url}"')

        need_discontinuity = False
        for segment in trimmed_segments:
            duration = segment["extinf"]

            # Check if this segment should be skipped
            if skip_filter:
                if skip_filter.should_skip_segment(duration):
                    skip_filter.advance_time(duration)
                    skipped_segments += 1
                    need_discontinuity = True
                    continue
                skip_filter.advance_time(duration)

            # Add discontinuity marker after skipped segments
            if need_discontinuity:
                hls.append("#EXT-X-DISCONTINUITY")
                need_discontinuity = False

            program_date_time = segment.get("program_date_time")
            if program_date_time:
                hls.append(f"#EXT-X-PROGRAM-DATE-TIME:{program_date_time}")
            hls.append(f"#EXTINF:{duration:.3f},")

            segment_query_params = {
                "init_url": init_url,
                "segment_url": segment["media"],
                "mime_type": profile["mimeType"],
                "is_live": "true" if is_live else "false",
            }

            # Add use_map flag so segment endpoint knows not to include init
            if use_map:
                segment_query_params["use_map"] = "true"

            # Add byte range parameters for SegmentBase
            if init_range:
                segment_query_params["init_range"] = init_range
            # Segment may also have its own range (for SegmentBase)
            if "initRange" in segment:
                segment_query_params["init_range"] = segment["initRange"]

            query_params.update(segment_query_params)
            hls.append(
                encode_mediaflow_proxy_url(
                    proxy_url,
                    query_params=query_params,
                    encryption_handler=encryption_handler if has_encrypted else None,
                )
            )
            added_segments += 1

    if not mpd_dict["isLive"]:
        hls.append("#EXT-X-ENDLIST")

    if skip_filter and skipped_segments > 0:
        logger.info(f"Added {added_segments} segments to HLS playlist (skipped {skipped_segments} segments)")
    else:
        logger.info(f"Added {added_segments} segments to HLS playlist")
    return "\n".join(hls)