diff --git a/docs/guide/downloading.md b/docs/guide/downloading.md index 4e34b980..84890e5b 100644 --- a/docs/guide/downloading.md +++ b/docs/guide/downloading.md @@ -86,7 +86,10 @@ unshackle dl -q 2160,1080,720 EXAMPLE 81234567 !!! note "16:9 canvas matching" Resolution matching is by track **height** first. If no track height matches exactly, unshackle falls back to a 16:9-canvas match, so `-q 1080` also selects an - anamorphic `1920×804` track (computed as `int(width × 9 / 16)`). + anamorphic `1920×804` track (computed as `int(width × 9 / 16)`). unshackle applies the + fallback to each range and codec on its own. A service that crops only its HDR10 tracks + therefore keeps its `3840×1920` HDR10 track, even when another range has an exact + `2160` height match. ### Best-available and worst diff --git a/tests/tracks/test_track_selection_fixes.py b/tests/tracks/test_track_selection_fixes.py index aefbbbc3..c3d8023a 100644 --- a/tests/tracks/test_track_selection_fixes.py +++ b/tests/tracks/test_track_selection_fixes.py @@ -3,7 +3,8 @@ Cover the pure ``Tracks`` helpers behind the ``dl`` selection layer: - ``Tracks.by_resolutions``: the 16:9 canvas fallback must tolerate tracks with - no known width instead of crashing on ``int(None * 9 / 16)``. + no known width instead of crashing on ``int(None * 9 / 16)``, and it applies to + each range and codec on its own. - ``Tracks.sort_videos``: resolution outranks bitrate, and an unknown-bitrate track can still win on resolution. - ``Tracks.sort_audio``: descriptive-last, Atmos, codec_priority then bitrate; @@ -22,13 +23,14 @@ def make_video( bitrate: int | None, width: int | None = None, codec: Video.Codec = Video.Codec.HEVC, + range_: Video.Range = Video.Range.SDR, ) -> Video: return Video( id_=track_id, url=f"https://example.test/{track_id}.m3u8", language="en", codec=codec, - range_=Video.Range.SDR, + range_=range_, width=width, height=height, bitrate=bitrate, @@ -71,6 +73,21 @@ def test_by_resolutions_tolerates_width_none_track() -> None: assert [t.id for t in tracks.videos] == ["canvas-720"] +def test_by_resolutions_keeps_cropped_variants_of_other_ranges() -> None: + """A DV track with an exact height must not remove the cropped HDR10 and SDR tracks.""" + tracks = Tracks() + tracks.videos = [ + make_video("dv-2160", height=2160, width=3840, bitrate=20_000_000, range_=Video.Range.DV), + make_video("hdr10-2160", height=1920, width=3840, bitrate=20_000_000, range_=Video.Range.HDR10), + make_video("sdr-2160", height=1920, width=3840, bitrate=15_000_000, range_=Video.Range.SDR), + make_video("dv-1080", height=1080, width=1920, bitrate=5_000_000, range_=Video.Range.DV), + ] + + tracks.by_resolutions([2160]) + + assert [t.id for t in tracks.videos] == ["dv-2160", "hdr10-2160", "sdr-2160"] + + # sort_videos: resolution before bitrate diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 59842de3..9c38660d 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -3473,7 +3473,16 @@ class dl: temp_path.unlink(missing_ok=True) except PermissionError: self.log.warning(f"Failed to delete temp file (in use?): {temp_path}") - for leftover in ("HDR10-DV.hevc", "DV.hevc", "RPU.bin", "RPU_UNT.bin", "RPU_L6.bin"): + for leftover in ( + "HDR10-DV.hevc", + "HDR10.hevc", + "DV.hevc", + "RPU.bin", + "RPU_UNT.bin", + "RPU_L5.bin", + "RPU_L6.bin", + "L5.json", + ): leftover_path = config.directories.temp / leftover try: leftover_path.unlink(missing_ok=True) diff --git a/unshackle/core/tracks/tracks.py b/unshackle/core/tracks/tracks.py index 3468f0ae..5a767d1a 100644 --- a/unshackle/core/tracks/tracks.py +++ b/unshackle/core/tracks/tracks.py @@ -455,19 +455,18 @@ class Tracks: return select def by_resolutions(self, resolutions: list[int], per_resolution: int = 0) -> None: - # Note: Do not merge these list comprehensions. They must be done separately so the results - # from the 16:9 canvas check is only used if there's no exact height resolution match. - selected = [] + groups: dict[tuple, list[Video]] = {} + for video in self.videos: + groups.setdefault((video.range, video.codec), []).append(video) + + keep: set[int] = set() for resolution in resolutions: - matches = [ # exact matches - x for x in self.videos if x.height == resolution - ] - if not matches: - matches = [ # 16:9 canvas matches - x for x in self.videos if x.width and int(x.width * (9 / 16)) == resolution - ] - selected.extend(matches[: per_resolution or None]) - self.videos = selected + for group in groups.values(): + matches = [x for x in group if x.height == resolution] + if not matches: + matches = [x for x in group if x.width and int(x.width * (9 / 16)) == resolution] + keep.update(id(x) for x in matches[: per_resolution or None]) + self.videos = [x for x in self.videos if id(x) in keep] @staticmethod def by_language(