diff --git a/tests/remote/unit/test_remote_service_helpers.py b/tests/remote/unit/test_remote_service_helpers.py index 14aca5b4..499077a2 100644 --- a/tests/remote/unit/test_remote_service_helpers.py +++ b/tests/remote/unit/test_remote_service_helpers.py @@ -146,6 +146,35 @@ def test_match_track_by_attributes_when_id_missing() -> None: assert match_track(remote, [local]) is local +def test_match_track_by_attributes_keeps_range_and_bitrate_apart() -> None: + sdr = deserialize_video( + {"id": "a", "codec": "HEVC", "width": 1920, "height": 960, "language": "en", "range": "SDR", "bitrate": 8000000} + ) + hdr = deserialize_video( + { + "id": "b", + "codec": "HEVC", + "width": 1920, + "height": 960, + "language": "en", + "range": "HDR10", + "bitrate": 5000000, + } + ) + remote = deserialize_video( + { + "id": "z", + "codec": "HEVC", + "width": 1920, + "height": 960, + "language": "en", + "range": "HDR10", + "bitrate": 5000000, + } + ) + assert match_track(remote, [sdr, hdr]) is hdr + + def test_match_track_no_candidates_returns_none() -> None: remote = deserialize_video({"id": "X", "codec": "AVC", "width": 1, "height": 1}) assert match_track(remote, []) is None @@ -287,3 +316,42 @@ def test_load_cache_files_wiring(tmp_path, monkeypatch) -> None: # A profile with no credentials entry falls back to the default credential sent = RemoteService.load_cache_files(stub, "missing") assert set(sent) == {"tokens_default", "session_guid"} + + +def test_resolve_manifest_data_matches_across_per_range_manifests() -> None: + """One ISM manifest per range. The HDR10 track must take the quality level from its + own manifest, not the same-sized one in the SDR manifest, in any manifest order.""" + import base64 + import zlib + + from tests.core.test_ism_init import VIDEO_HEVC_PQ_CPD, VIDEO_HEVC_SDR_CPD + from tests.core.test_ism_range import manifest_xml + from unshackle.core.manifests import ISM + from unshackle.core.remote_service import resolve_manifest_data + + urls = {"sdr": "https://x/sdr/manifest", "hdr": "https://x/hdr/manifest"} + xml = {"sdr": manifest_xml(VIDEO_HEVC_SDR_CPD), "hdr": manifest_xml(VIDEO_HEVC_PQ_CPD)} + local = {k: ISM.from_text(xml[k], url=urls[k]).to_tracks(language="en").videos[0] for k in xml} + assert local["sdr"].range == Video.Range.SDR and local["hdr"].range == Video.Range.HDR10 + + remote_hdr = deserialize_video( + { + "id": str(local["hdr"].id), + "codec": "HEVC", + "width": 3840, + "height": 2160, + "language": "en", + "range": "HDR10", + "bitrate_bps": 15000000, + } + ) + tracks = build_tracks({"video": [], "audio": [], "subtitles": []}) + tracks.videos.append(remote_hdr) + manifests = [ + {"type": "ism", "url": urls[k], "data": base64.b64encode(zlib.compress(xml[k].encode())).decode()} + for k in ("sdr", "hdr") + ] + + resolve_manifest_data(tracks, manifests) + + assert remote_hdr.data["ism"]["quality_level"].get("CodecPrivateData") == VIDEO_HEVC_PQ_CPD diff --git a/unshackle/core/remote_service.py b/unshackle/core/remote_service.py index e0935ce5..2ed724aa 100644 --- a/unshackle/core/remote_service.py +++ b/unshackle/core/remote_service.py @@ -321,6 +321,7 @@ def resolve_manifest_data(tracks: Tracks, manifests: list) -> None: log_m = logging.getLogger("remote_service") all_tracks = list(tracks.videos) + list(tracks.audio) + list(tracks.subtitles) + local_all: list = [] for manifest_info in manifests: m_type = manifest_info.get("type") @@ -351,20 +352,20 @@ def resolve_manifest_data(tracks: Tracks, manifests: list) -> None: else: continue - local_all = list(local_tracks.videos) + list(local_tracks.audio) + list(local_tracks.subtitles) - for remote_track in all_tracks: - if remote_track.data.get(m_type): - continue - matched = match_track(remote_track, local_all) - if matched and matched.data.get(m_type): - remote_track.data.update(matched.data) - remote_track.descriptor = matched.descriptor - if matched.drm and not remote_track.drm: - remote_track.drm = matched.drm - + local_all += list(local_tracks.videos) + list(local_tracks.audio) + list(local_tracks.subtitles) except Exception as e: log_m.warning("Failed to re-parse %s manifest from %s: %s", m_type, m_url, e) + for remote_track in all_tracks: + if any(remote_track.data.get(key) for key in ("dash", "ism")): + continue + matched = match_track(remote_track, local_all) + if matched and any(matched.data.get(key) for key in ("dash", "ism")): + remote_track.data.update(matched.data) + remote_track.descriptor = matched.descriptor + if matched.drm and not remote_track.drm: + remote_track.drm = matched.drm + def same_bitrate(local: Optional[int], remote: Optional[int]) -> bool: """Equal to the nearest kb/s. A server that predates ``bitrate_bps`` rounds to kb/s in transit.""" @@ -386,7 +387,12 @@ def match_track(remote_track: Track, local_tracks: list) -> Optional[Track]: if lt.codec != remote_track.codec or str(lt.language) != str(remote_track.language): continue if hasattr(lt, "width") and hasattr(remote_track, "width"): - if lt.width == remote_track.width and lt.height == remote_track.height: + if ( + lt.width == remote_track.width + and lt.height == remote_track.height + and lt.range == remote_track.range + and same_bitrate(lt.bitrate, remote_track.bitrate) + ): return lt elif hasattr(lt, "channels") and hasattr(remote_track, "channels"): if same_bitrate(lt.bitrate, remote_track.bitrate): diff --git a/unshackle/core/tracks/track.py b/unshackle/core/tracks/track.py index ec765649..604af6dc 100644 --- a/unshackle/core/tracks/track.py +++ b/unshackle/core/tracks/track.py @@ -1016,7 +1016,8 @@ class Track: ] ) if bsf: - args.extend(["-bsf:v", bsf]) + # ffmpeg exits 0 after dropping packets the bsf cannot parse + args.extend(["-bsf:v", bsf, "-xerror"]) args.append(str(output_path)) subprocess.run( diff --git a/unshackle/core/tracks/video.py b/unshackle/core/tracks/video.py index 0eb7e85b..4285eb4b 100644 --- a/unshackle/core/tracks/video.py +++ b/unshackle/core/tracks/video.py @@ -497,6 +497,8 @@ class Video(Track): "-hide_banner", "-loglevel", "error", + # ffmpeg exits 0 after dropping packets the bsf cannot parse + "-xerror", "-i", str(original_path), "-codec",