refactor(tracks): name and match resolutions by one standard-height rule

standard_resolution() replaces the aspect-ratio logic in naming and is shared by -q matching, export names and progress/hybrid labels, so 1920x1088, 1916x1076, portrait and DCI sizes get standard names.
This commit is contained in:
imSp4rky
2026-10-31 22:05:26 -06:00
parent d5a8ed2a39
commit 86f26c31d5
13 changed files with 190 additions and 84 deletions
+11 -2
View File
@@ -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.
-1
View File
@@ -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,
-1
View File
@@ -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,
+4 -4
View File
@@ -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]
+119
View File
@@ -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)
-1
View File
@@ -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,
+8 -28
View File
@@ -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:
+4 -3
View File
@@ -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)
+3 -9
View File
@@ -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:
+4 -30
View File
@@ -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)),
}
)
+6 -2
View File
@@ -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)))
+8 -3
View File
@@ -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]
+23
View File
@@ -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.