diff --git a/docs/guide/output-and-naming.md b/docs/guide/output-and-naming.md index fe94af87..039a3549 100644 --- a/docs/guide/output-and-naming.md +++ b/docs/guide/output-and-naming.md @@ -105,8 +105,8 @@ Every variable below is valid in both output and folder templates. unshackle tak | `title_type` | Media kind of the title | `movie`, `series`, `music` | | `year` | Release year | `2024` | | `source` | Service tag / class name (empty with `--no-source`) | `EXAMPLE` | -| `quality` | Resolution with scan suffix | `1080p`, `2160p`, `576i` | -| `resolution` | Resolution number only | `1080` | +| `quality` | Standard resolution with scan suffix | `1080p`, `2160p`, `576i` | +| `resolution` | Standard resolution number only | `1080` | | `video` | Video codec | `H.264`, `H.265` | | `hdr` | Dynamic-range label | `HDR`, `HDR10P`, `DV`, `DV.HDR`, `DV.HDR10P`, `HLG` | | `hfr` | High frame rate marker (frame rate > 30) | `HFR` | @@ -115,6 +115,15 @@ Every variable below is valid in both output and folder templates. unshackle tak | `repack` | `REPACK` when the `--repack` flag is used | `REPACK` | | `lang_tag` | Result of your `language_tags` rules, if configured | `MULTi` | +!!! note "`quality` and `resolution` use the standard height" + unshackle names a video by the nearest standard height (4320, 2160, 1440, 1080, 720, 576, 540, + 480, 360 or 240) when it is within 10%. It measures the short side and the height that a 16:9 + picture of the same width has, and uses the larger one. So a cropped 1920x800, a padded + 1920x1088 and a 1080x1920 portrait video are all `1080p`. A short side within 16 lines of + 2160, 1440 or 1080 gives that height, so an ultrawide 2560x1080 is `1080p`. A size with no + standard height near it, such as 1600x900, keeps its own height. `dl -q` and the + `dl --export` file name use the same rule. + !!! note "How `language_tags` rules match" unshackle tries the rules in order, and they compare languages with the same [matching rules](downloading.md#exact-vs-fuzzy-matching) as `-l`: a rule condition of `en` matches `en-US`, `en-GB`, and any other `en-*` form, but `es-419` matches only `es-419`. Within a single rule, **all** conditions must match (AND logic) for it to apply. The first rule that fully matches wins. If none match, unshackle removes `{lang_tag?}` from the filename cleanly. diff --git a/tests/core/test_audio_channel_naming.py b/tests/core/test_audio_channel_naming.py index 945b5ed6..e517e367 100644 --- a/tests/core/test_audio_channel_naming.py +++ b/tests/core/test_audio_channel_naming.py @@ -76,7 +76,6 @@ def test_codec_names_renames_video_and_each_hdr_part(monkeypatch: pytest.MonkeyP height=2160, format="HEVC", scan_type="Progressive", - other_display_aspect_ratio=["16:9"], hdr_format_commercial="HDR10+", hdr_format="Dolby Vision, Version 1.0, Profile 8.1", transfer_characteristics=None, diff --git a/tests/core/test_hdr10p_naming.py b/tests/core/test_hdr10p_naming.py index d6eb9a06..4203c1ef 100644 --- a/tests/core/test_hdr10p_naming.py +++ b/tests/core/test_hdr10p_naming.py @@ -32,7 +32,6 @@ def hdr_tag( height=1080, format="AV1", scan_type="Progressive", - other_display_aspect_ratio=["16:9"], hdr_format_commercial=commercial, hdr_format=hdr_format, hdr_format_profile=profile, diff --git a/tests/core/test_hybrid_hdr10plus.py b/tests/core/test_hybrid_hdr10plus.py index e6587bcc..34f27540 100644 --- a/tests/core/test_hybrid_hdr10plus.py +++ b/tests/core/test_hybrid_hdr10plus.py @@ -418,7 +418,7 @@ def test_a_conversion_run_removes_its_rpus_so_the_next_base_makes_its_own( monkeypatch.setattr(Hybrid, "level_6", level_6) monkeypatch.setattr(hybrid_mod.console, "print", lambda *a, **k: None) - Hybrid([SimpleNamespace(id="b", path=base, height=2160, range=Video.Range.HDR10P)], "TEST") + Hybrid([SimpleNamespace(id="b", path=base, width=None, height=2160, range=Video.Range.HDR10P)], "TEST") assert seen == {"stale_l6": False, "active_fraction": 0.9} assert not (tmp_path / "RPU.bin").exists() and not (tmp_path / "RPU_L6.bin").exists() @@ -446,8 +446,8 @@ def test_a_failed_resolution_removes_its_crop_edits_and_keeps_the_dv_files( monkeypatch.setattr(Hybrid, "injecting", failed_inject) monkeypatch.setattr(hybrid_mod.console, "print", lambda *a, **k: None) videos = [ - SimpleNamespace(id="b", path=tmp_path / "base.mkv", height=2160, range=Video.Range.HDR10), - SimpleNamespace(id="d", path=tmp_path / "dv.mkv", height=1080, range=Video.Range.DV), + SimpleNamespace(id="b", path=tmp_path / "base.mkv", width=None, height=2160, range=Video.Range.HDR10), + SimpleNamespace(id="d", path=tmp_path / "dv.mkv", width=None, height=1080, range=Video.Range.DV), ] with pytest.raises(ValueError): @@ -559,7 +559,7 @@ def test_hybrid_asks_for_hdr10plus_only_for_a_conversion_without_dv( def test_the_hybrid_step_gets_the_base_alone_when_there_is_no_dv(tmp_path: Path) -> None: - base = SimpleNamespace(id="base", path=tmp_path / "base.mp4", height=2160, range=Video.Range.HDR10P) + base = SimpleNamespace(id="base", path=tmp_path / "base.mp4", width=None, height=2160, range=Video.Range.HDR10P) tracks = hybrid_source_tracks(base, None) # type: ignore[arg-type] diff --git a/tests/core/test_resolution_naming.py b/tests/core/test_resolution_naming.py new file mode 100644 index 00000000..d4e99d53 --- /dev/null +++ b/tests/core/test_resolution_naming.py @@ -0,0 +1,119 @@ +"""Tests for the standard resolution in the filename context, the export name and -q matching.""" + +from __future__ import annotations + +import types +from typing import Optional + +import pytest + +from unshackle.core.config import config +from unshackle.core.export_name import resolution +from unshackle.core.titles.movie import Movie +from unshackle.core.tracks import Video +from unshackle.core.tracks.tracks import at_resolution +from unshackle.core.utilities import standard_resolution + + +class DummyService: + pass + + +@pytest.fixture(autouse=True) +def clean_config(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(config, "tag_rules", []) + monkeypatch.setattr(config, "language_tags", {}) + monkeypatch.setattr(config, "codec_names", {}) + + +def quality(width: Optional[int], height: Optional[int], scan_type: str = "Progressive") -> str: + movie = Movie(id_="movie-0001", service=DummyService, name="The Film", year=2024, language="en") + video = types.SimpleNamespace( + width=width, + height=height, + format="AVC", + scan_type=scan_type, + hdr_format_commercial=None, + hdr_format=None, + transfer_characteristics="BT.709", + transfer_characteristics_original=None, + frame_rate="25.000", + ) + media_info = types.SimpleNamespace(video_tracks=[video], audio_tracks=[]) + return str(movie.build_base_template_context(media_info)["quality"]) + + +@pytest.mark.parametrize( + ("width", "height", "expected"), + [ + (3840, 2160, 2160), + (1920, 1080, 1080), + (1280, 720, 720), + (2560, 1440, 1440), + (1920, 1088, 1080), # padded to a multiple of 16 + (960, 544, 540), + (640, 368, 360), + (1916, 1076, 1080), # a few lines short + (3836, 2156, 2160), + (1920, 800, 1080), # scope crop + (1920, 1036, 1080), # flat crop + (3840, 1600, 2160), + (1280, 544, 720), + (4096, 2160, 2160), # DCI + (4096, 1716, 2160), + (2048, 858, 1080), + (1440, 1080, 1080), # 4:3 pillarbox + (960, 720, 720), + (720, 576, 576), # anamorphic SD + (704, 576, 576), + (720, 480, 480), + (854, 480, 480), + (1080, 1920, 1080), # portrait + (1080, 1350, 1080), + (720, 1280, 720), + (2560, 1080, 1080), # ultrawide + (3440, 1440, 1440), + (1600, 900, 900), # no standard height near it + ], +) +def test_standard_resolution(width: int, height: int, expected: int) -> None: + assert standard_resolution(width, height) == expected + + +def test_a_picture_with_one_side_uses_it() -> None: + assert standard_resolution(None, 1088) == 1080 + + +def test_a_picture_with_no_size_has_no_resolution() -> None: + assert standard_resolution(None, None) is None + + +def test_the_filename_quality_uses_the_standard_resolution() -> None: + assert quality(1920, 1088) == "1080p" + assert quality(1920, 800) == "1080p" + + +def test_an_interlaced_picture_keeps_the_i_suffix() -> None: + assert quality(1920, 1088, scan_type="Interlaced") == "1080i" + + +def test_a_video_track_with_no_size_gives_an_empty_quality() -> None: + assert quality(None, None) == "" + + +def video(width: int, height: int) -> Video: + return Video(url="https://example.test/v.mpd", language="en", codec=Video.Codec.AVC, width=width, height=height) + + +def test_the_export_name_uses_the_standard_resolution() -> None: + assert resolution(video(1080, 1920)) == 1080 + assert resolution(video(4096, 2160)) == 2160 + + +@pytest.mark.parametrize(("width", "height"), [(1920, 1080), (1920, 800), (1916, 1076), (1920, 1088)]) +def test_q_1080_matches_odd_1080p_encodes(width: int, height: int) -> None: + assert at_resolution(video(width, height), 1080) + + +def test_q_1080_does_not_match_720p() -> None: + assert not at_resolution(video(1280, 720), 1080) diff --git a/tests/core/test_tag_rules_integration.py b/tests/core/test_tag_rules_integration.py index fda75cc8..a8e09f86 100644 --- a/tests/core/test_tag_rules_integration.py +++ b/tests/core/test_tag_rules_integration.py @@ -104,7 +104,6 @@ def test_a_quality_rule_matches_the_built_context(monkeypatch: pytest.MonkeyPatc height=2160, format="HEVC", scan_type="Progressive", - other_display_aspect_ratio=["16:9"], hdr_format_commercial=None, hdr_format=None, transfer_characteristics=None, diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 62e93cd0..04c179e3 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -120,6 +120,7 @@ from unshackle.core.utilities import ( partition_exclusions, plain_subtitle_tags, resolve_sort_langs, + standard_resolution, suggest_font_packages, time_elapsed_since, ) @@ -2723,9 +2724,7 @@ class dl: v for v in non_hybrid_tracks if v.range in non_hybrid_ranges ] for resolution in quality: - if any(v.height == resolution for v in deliverable_pool): - continue - if any(v.width and int(v.width * 9 / 16) == resolution for v in deliverable_pool): + if any(at_resolution(v, resolution) for v in deliverable_pool): continue missing_resolutions.append(resolution) @@ -2741,12 +2740,7 @@ class dl: if non_hybrid_ranges and non_hybrid_tracks: non_hybrid_selected = [ - v - for v in non_hybrid_tracks - if any( - v.height == res or (v.width and int(v.width * (9 / 16)) == res) - for res in quality - ) + v for v in non_hybrid_tracks if any(at_resolution(v, res) for res in quality) ] title.tracks.videos = Tracks.merge_video_selections( hybrid_selected, non_hybrid_selected @@ -2758,9 +2752,7 @@ class dl: title.tracks.by_resolutions(quality) for resolution in quality: - if any(v.height == resolution for v in title.tracks.videos): - continue - if any(v.width and int(v.width * 9 / 16) == resolution for v in title.tracks.videos): + if any(at_resolution(v, resolution) for v in title.tracks.videos): continue missing_resolutions.append(resolution) @@ -2818,11 +2810,7 @@ class dl: candidates = [ t for t in non_hybrid_tracks - if ( - not resolution - or t.height == resolution - or (t.width and int(t.width * (9 / 16)) == resolution) - ) + if (not resolution or at_resolution(t, resolution)) and (not color_range or t.range == color_range) and (not codec or t.codec == codec) and (vlang is None or str(t.language) == vlang) @@ -2851,11 +2839,7 @@ class dl: candidates = [ t for t in title.tracks.videos - if ( - not resolution - or t.height == resolution - or (t.width and int(t.width * (9 / 16)) == resolution) - ) + if (not resolution or at_resolution(t, resolution)) and (not color_range or t.range == color_range) and (not codec or t.codec == codec) and (vlang is None or str(t.language) == vlang) @@ -2893,11 +2877,7 @@ class dl: candidates = [ t for t in fallback_pool - if ( - not resolution - or t.height == resolution - or (t.width and int(t.width * (9 / 16)) == resolution) - ) + if (not resolution or at_resolution(t, resolution)) and (not color_range or t.range == color_range) and (not codec or t.codec == codec) and (vlang is None or str(t.language) == vlang) @@ -3653,7 +3633,7 @@ class dl: head = next((v for v in video_tracks if v), None) if head: if len(quality) > 1: - task_description += f" {head.height}p" + task_description += f" {standard_resolution(head.width, head.height)}p" if len(range_) > 1: task_description += f" {head.range.name}" if len(vcodec) > 1: diff --git a/unshackle/core/api/progress.py b/unshackle/core/api/progress.py index 5351aca2..106606f1 100644 --- a/unshackle/core/api/progress.py +++ b/unshackle/core/api/progress.py @@ -10,6 +10,7 @@ import time from typing import Any, Callable, Optional from unshackle.core.constants import AnyTrack +from unshackle.core.utilities import standard_resolution JOB_PROGRESS_TERMINAL_STATES = {"Downloaded", "Decrypted", "[yellow]SKIPPED"} PROGRESS_EMIT_INTERVAL = 0.25 @@ -26,9 +27,9 @@ def track_progress_label(track: AnyTrack) -> str: track_type = type(track).__name__ if track_type == "Video": parts = ["video"] - height = getattr(track, "height", None) - if height: - parts.append(f"{height}p") + resolution = standard_resolution(getattr(track, "width", None), getattr(track, "height", None)) + if resolution: + parts.append(f"{resolution}p") track_range = getattr(track, "range", None) if track_range is not None: parts.append(track_range.value) diff --git a/unshackle/core/export_name.py b/unshackle/core/export_name.py index be2b81c0..57e81ff7 100644 --- a/unshackle/core/export_name.py +++ b/unshackle/core/export_name.py @@ -15,9 +15,8 @@ from typing import Any, Optional from unshackle.core.constants import AnyTrack from unshackle.core.titles import Episode, Movie, Song, Title_T from unshackle.core.tracks import Audio, Video -from unshackle.core.utilities import sanitize_filename +from unshackle.core.utilities import sanitize_filename, standard_resolution -STANDARD_HEIGHTS = (4320, 2160, 1440, 1080, 720, 576, 480, 360) MANIFEST_ORDER = (Video.Descriptor.DASH, Video.Descriptor.HLS, Video.Descriptor.ISM, Video.Descriptor.URL) MAX_SCOPE_PARTS = 3 MAX_TITLE_LENGTH = 100 @@ -107,13 +106,8 @@ def scope_part(titles: Sequence[Title_T], seasons: Mapping[int, Collection[int]] def resolution(track: Video) -> Optional[int]: - """The resolution a release name uses: a letterboxed 1920x800 is 1080p, from its width.""" - if not track.height: - return None - height = track.height - if track.width and track.width > height: - height = max(height, track.width * 9 // 16) - return next((std for std in STANDARD_HEIGHTS if abs(height - std) <= 16), height) + """The resolution a release name uses, the same as the {resolution} template variable.""" + return standard_resolution(track.width, track.height) if track.height else None def audio_token(track: Audio) -> str: diff --git a/unshackle/core/titles/title.py b/unshackle/core/titles/title.py index c7a9432b..3ad1006f 100644 --- a/unshackle/core/titles/title.py +++ b/unshackle/core/titles/title.py @@ -14,7 +14,7 @@ from unshackle.core.constants import ( VIDEO_CODEC_MAP, ) from unshackle.core.tracks import Tracks, Video -from unshackle.core.utilities import spoken_language +from unshackle.core.utilities import spoken_language, standard_resolution def rename(name: Optional[str]) -> str: @@ -119,40 +119,14 @@ class Title: context["edition"] = " ".join(first_track.edition) if primary_video_track: - width = getattr(primary_video_track, "width", primary_video_track.height) - resolution = min(width, primary_video_track.height) - try: - dar = getattr(primary_video_track, "other_display_aspect_ratio", None) or [] - if dar and dar[0]: - aspect_ratio = [int(float(plane)) for plane in str(dar[0]).split(":")] - if len(aspect_ratio) == 1: - aspect_ratio.append(1) - ratio = aspect_ratio[0] / aspect_ratio[1] - if ratio not in (16 / 9, 4 / 3, 9 / 16, 3 / 4): - if abs(width - 3840) <= 50: - width = 3840 - elif abs(width - 2560) <= 50: - width = 2560 - elif abs(width - 1920) <= 50 or abs(width - 1620) <= 50: - width = 1920 - elif abs(width - 1280) <= 50 or abs(width - 1080) <= 50: - width = 1280 - - resolution = int(max(width, primary_video_track.height) * (9 / 16)) - - track_height = primary_video_track.height - if abs(resolution - track_height) <= 10 or track_height in (2160, 1440, 1080, 720, 480): - resolution = track_height - # garbage DAR strings or a missing height fall back to the plain min(width, height) - except (ValueError, TypeError, ZeroDivisionError): - pass + resolution = standard_resolution(primary_video_track.width, primary_video_track.height) scan_suffix = "i" if str(getattr(primary_video_track, "scan_type", "")).lower() == "interlaced" else "p" context.update( { - "quality": f"{resolution}{scan_suffix}", - "resolution": str(resolution), + "quality": f"{resolution}{scan_suffix}" if resolution else "", + "resolution": str(resolution) if resolution else "", "video": rename(VIDEO_CODEC_MAP.get(primary_video_track.format, primary_video_track.format)), } ) diff --git a/unshackle/core/tracks/hybrid.py b/unshackle/core/tracks/hybrid.py index 73533828..a5c16880 100644 --- a/unshackle/core/tracks/hybrid.py +++ b/unshackle/core/tracks/hybrid.py @@ -14,7 +14,7 @@ from rich.rule import Rule from unshackle.core.binaries import FFMPEG, FFProbe, HDR10PlusTool from unshackle.core.config import config from unshackle.core.console import console -from unshackle.core.utilities import log_event +from unshackle.core.utilities import log_event, standard_resolution from unshackle.core.utils import dovi from unshackle.core.utils.subprocess import run_step @@ -136,7 +136,11 @@ class Hybrid: hdr10_track = next((v for v in videos if v.range == Video.Range.HDR10), None) hdr10p_track = next((v for v in videos if v.range == Video.Range.HDR10P), None) track_for_res = hdr10_track or hdr10p_track - self.resolution = f"{track_for_res.height}p" if track_for_res and track_for_res.height else "Unknown" + self.resolution = ( + f"{standard_resolution(track_for_res.width, track_for_res.height)}p" + if track_for_res and track_for_res.height + else "Unknown" + ) console.print(Padding(Rule(f"[rule.text]HDR10+DV Hybrid ({self.resolution})"), (1, 2))) diff --git a/unshackle/core/tracks/tracks.py b/unshackle/core/tracks/tracks.py index 46f2287b..2db964d2 100644 --- a/unshackle/core/tracks/tracks.py +++ b/unshackle/core/tracks/tracks.py @@ -34,6 +34,7 @@ from unshackle.core.utilities import ( matching_languages, original_tag, sanitize_filename, + standard_resolution, valid_language, ) from unshackle.core.utils.collections import as_list, flatten @@ -44,8 +45,12 @@ MP4BOX_OPEN_FAILED = re.compile(r"error while opening|Error opening file|Invalid def at_resolution(video: Video, res: int) -> bool: - """Whether a track has this height, or this height from a 16:9 width (a letterboxed encode).""" - return video.height == res or bool(video.width and int(video.width * 9 / 16) == res) + """Whether a track has this height, this height from a 16:9 width, or this standard resolution (a padded encode).""" + return ( + video.height == res + or bool(video.width and int(video.width * 9 / 16) == res) + or standard_resolution(video.width, video.height) == res + ) def file_track_ids(path: Path) -> dict[str, int]: @@ -573,7 +578,7 @@ class Tracks: 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] + matches = [x for x in group if at_resolution(x, 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] diff --git a/unshackle/core/utilities.py b/unshackle/core/utilities.py index 1e4685ad..15d1f551 100644 --- a/unshackle/core/utilities.py +++ b/unshackle/core/utilities.py @@ -122,6 +122,29 @@ def import_module_by_path(path: Path) -> ModuleType: return module +RESOLUTION_TIERS = (4320, 2160, 1440, 1080, 720, 576, 540, 480, 360, 240) + + +def standard_resolution(width: Optional[int], height: Optional[int]) -> Optional[int]: + """The resolution a release name uses: the nearest standard height within 10%, from the short side or a 16:9 width. + + A cropped 1920x800, a padded 1920x1088 and a 1080x1920 portrait picture are all 1080. A size with no + standard height near it keeps its own, and a picture with no size gives None. + """ + sides = [side for side in (width, height) if side] + if not sides: + return None + short, long = min(sides), max(sides) + # naming policy: a short side near 2160, 1440 or 1080 names the picture, so an ultrawide 2560x1080 is 1080 + # as other media tools name it, even though a 1440 scope crop has the same size + by_height = next((tier for tier in (2160, 1440, 1080) if abs(short - tier) <= 16), None) + if by_height: + return by_height + equivalent = max(short, long * 9 / 16) + tier = min(RESOLUTION_TIERS, key=lambda t: abs(t - equivalent)) + return tier if abs(tier - equivalent) <= tier / 10 else round(equivalent) + + def sanitize_filename(filename: str, spacer: str = ".", unicode: Optional[bool] = None) -> str: """ Sanitise a string to be filename safe.