refactor(release): make tech_string a derived property
ParsedRelease.tech_string was a stored str field re-computed in two
places (assemble() at parse time, enrich_from_probe() after the probe).
The second site was a reactive fix (e79ca46) for filename builders that
saw a stale value. Turn it into an @property so it stays in sync with
quality/source/codec by construction.
- Drop the field from the dataclass + the key from assemble()'s dict.
- Drop tech_string="" from parse_release's malformed-name fallback.
- Drop the manual recomputation at the end of enrich_from_probe.
- Inject the property into asdict() result in the fixtures runner
(same treatment as is_season_pack).
- Update tests that passed tech_string= to the constructor; rewrite the
TestTechString case that mutated p.tech_string manually.
This commit is contained in:
@@ -57,6 +57,17 @@ callers).
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- **`ParsedRelease.tech_string` is now a derived `@property`**
|
||||||
|
(`alfred/domain/release/value_objects.py`). It computes
|
||||||
|
`quality.source.codec` joined by dots on every access, so it stays in
|
||||||
|
sync with the underlying fields by construction. The stored field is
|
||||||
|
gone from the dataclass, the dict returned by `assemble()` no longer
|
||||||
|
carries the key, `parse_release`'s malformed-name fallback drops the
|
||||||
|
`tech_string=""` kwarg, and `enrich_from_probe` no longer re-derives
|
||||||
|
it after filling `quality`/`source`/`codec`. Closes the
|
||||||
|
parser/enrichment double-source-of-truth that `e79ca46` had to fix
|
||||||
|
reactively. The fixtures runner now injects `tech_string` alongside
|
||||||
|
`is_season_pack` since `asdict()` skips properties.
|
||||||
- **`RuleScope.level` is now an enum (`RuleScopeLevel`).** The set of
|
- **`RuleScope.level` is now an enum (`RuleScopeLevel`).** The set of
|
||||||
valid levels (global, release_group, movie, show, season, episode)
|
valid levels (global, release_group, movie, show, season, episode)
|
||||||
was documented only in a docstring comment and validated nowhere.
|
was documented only in a docstring comment and validated nowhere.
|
||||||
|
|||||||
@@ -81,9 +81,6 @@ def enrich_from_probe(parsed: ParsedRelease, info: MediaInfo) -> None:
|
|||||||
if lang.lower() != "und" and lang.upper() not in existing:
|
if lang.lower() != "und" and lang.upper() not in existing:
|
||||||
parsed.languages.append(lang)
|
parsed.languages.append(lang)
|
||||||
|
|
||||||
# Re-derive tech_string so filename builders see the enriched
|
# tech_string is a derived property on ParsedRelease — it always
|
||||||
# quality/source/codec. Built the same way as in the parser pipeline:
|
# reflects the current quality/source/codec, so there's nothing to
|
||||||
# the non-None parts joined by dots, in order.
|
# refresh after enrichment.
|
||||||
parsed.tech_string = ".".join(
|
|
||||||
p for p in (parsed.quality, parsed.source, parsed.codec) if p
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -713,9 +713,6 @@ def assemble(
|
|||||||
if distributor is None:
|
if distributor is None:
|
||||||
distributor = tok.text.upper()
|
distributor = tok.text.upper()
|
||||||
|
|
||||||
tech_parts = [p for p in (quality, source, codec) if p]
|
|
||||||
tech_string = ".".join(tech_parts)
|
|
||||||
|
|
||||||
# Media type heuristic. Doc/concert/integrale tokens win over the
|
# Media type heuristic. Doc/concert/integrale tokens win over the
|
||||||
# generic tech-based fallback. We look across all tokens (not just
|
# generic tech-based fallback. We look across all tokens (not just
|
||||||
# annotated ones) because these markers may be tagged UNKNOWN by the
|
# annotated ones) because these markers may be tagged UNKNOWN by the
|
||||||
@@ -754,7 +751,6 @@ def assemble(
|
|||||||
"source": source,
|
"source": source,
|
||||||
"codec": codec,
|
"codec": codec,
|
||||||
"group": group,
|
"group": group,
|
||||||
"tech_string": tech_string,
|
|
||||||
"media_type": media_type,
|
"media_type": media_type,
|
||||||
"site_tag": site_tag,
|
"site_tag": site_tag,
|
||||||
"languages": languages,
|
"languages": languages,
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ def parse_release(
|
|||||||
source=None,
|
source=None,
|
||||||
codec=None,
|
codec=None,
|
||||||
group="UNKNOWN",
|
group="UNKNOWN",
|
||||||
tech_string="",
|
|
||||||
media_type=MediaTypeToken.UNKNOWN,
|
media_type=MediaTypeToken.UNKNOWN,
|
||||||
site_tag=site_tag,
|
site_tag=site_tag,
|
||||||
parse_path=ParsePath.AI,
|
parse_path=ParsePath.AI,
|
||||||
|
|||||||
@@ -123,7 +123,6 @@ class ParsedRelease:
|
|||||||
source: str | None # WEBRip, BluRay, …
|
source: str | None # WEBRip, BluRay, …
|
||||||
codec: str | None # x265, HEVC, …
|
codec: str | None # x265, HEVC, …
|
||||||
group: str # release group, "UNKNOWN" if missing
|
group: str # release group, "UNKNOWN" if missing
|
||||||
tech_string: str # quality.source.codec joined with dots
|
|
||||||
media_type: MediaTypeToken = MediaTypeToken.UNKNOWN
|
media_type: MediaTypeToken = MediaTypeToken.UNKNOWN
|
||||||
site_tag: str | None = (
|
site_tag: str | None = (
|
||||||
None # site watermark stripped from name, e.g. "TGx", "OxTorrent.vc"
|
None # site watermark stripped from name, e.g. "TGx", "OxTorrent.vc"
|
||||||
@@ -179,6 +178,15 @@ class ParsedRelease:
|
|||||||
def is_season_pack(self) -> bool:
|
def is_season_pack(self) -> bool:
|
||||||
return self.season is not None and self.episode is None
|
return self.season is not None and self.episode is None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tech_string(self) -> str:
|
||||||
|
"""``quality.source.codec`` joined by dots, skipping ``None`` parts.
|
||||||
|
|
||||||
|
Derived on every access so it stays in sync with the underlying
|
||||||
|
fields — no manual refresh needed after enrichment.
|
||||||
|
"""
|
||||||
|
return ".".join(p for p in (self.quality, self.source, self.codec) if p)
|
||||||
|
|
||||||
def show_folder_name(self, tmdb_title_safe: str, tmdb_year: int) -> str:
|
def show_folder_name(self, tmdb_title_safe: str, tmdb_year: int) -> str:
|
||||||
"""
|
"""
|
||||||
Build the series root folder name.
|
Build the series root folder name.
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ def _bare(**overrides) -> ParsedRelease:
|
|||||||
source=None,
|
source=None,
|
||||||
codec=None,
|
codec=None,
|
||||||
group="UNKNOWN",
|
group="UNKNOWN",
|
||||||
tech_string="",
|
|
||||||
)
|
)
|
||||||
defaults.update(overrides)
|
defaults.update(overrides)
|
||||||
return ParsedRelease(**defaults)
|
return ParsedRelease(**defaults)
|
||||||
@@ -218,8 +217,9 @@ class TestLanguages:
|
|||||||
|
|
||||||
|
|
||||||
class TestTechString:
|
class TestTechString:
|
||||||
"""tech_string drives the filename builders; it must be re-derived
|
"""tech_string is a derived property on ParsedRelease: it always
|
||||||
whenever quality / source / codec change."""
|
reflects the current quality/source/codec. Enrichment never writes
|
||||||
|
it directly — it stays in sync by construction."""
|
||||||
|
|
||||||
def test_rebuilt_from_filled_quality_and_codec(self):
|
def test_rebuilt_from_filled_quality_and_codec(self):
|
||||||
p = _bare()
|
p = _bare()
|
||||||
@@ -239,9 +239,9 @@ class TestTechString:
|
|||||||
assert p.tech_string == "1080p.BluRay.x265"
|
assert p.tech_string == "1080p.BluRay.x265"
|
||||||
|
|
||||||
def test_unchanged_when_no_enrichable_video_info(self):
|
def test_unchanged_when_no_enrichable_video_info(self):
|
||||||
# No video info → nothing to fill → tech_string stays as it was.
|
# No video info → nothing to fill → derived tech_string stays as it was.
|
||||||
p = _bare(quality="2160p", source="WEB-DL", codec="x265")
|
p = _bare(quality="2160p", source="WEB-DL", codec="x265")
|
||||||
p.tech_string = "2160p.WEB-DL.x265"
|
assert p.tech_string == "2160p.WEB-DL.x265"
|
||||||
enrich_from_probe(p, MediaInfo())
|
enrich_from_probe(p, MediaInfo())
|
||||||
assert p.tech_string == "2160p.WEB-DL.x265"
|
assert p.tech_string == "2160p.WEB-DL.x265"
|
||||||
|
|
||||||
|
|||||||
@@ -123,7 +123,6 @@ class TestAssemble:
|
|||||||
assert fields["source"] == "WEBRip"
|
assert fields["source"] == "WEBRip"
|
||||||
assert fields["codec"] == "x265"
|
assert fields["codec"] == "x265"
|
||||||
assert fields["group"] == "KONTRAST"
|
assert fields["group"] == "KONTRAST"
|
||||||
assert fields["tech_string"] == "1080p.WEBRip.x265"
|
|
||||||
assert fields["media_type"] == "movie"
|
assert fields["media_type"] == "movie"
|
||||||
assert fields["site_tag"] is None
|
assert fields["site_tag"] is None
|
||||||
|
|
||||||
@@ -150,7 +149,8 @@ class TestAssemble:
|
|||||||
assert fields["season"] == 2
|
assert fields["season"] == 2
|
||||||
assert fields["episode"] is None # season pack
|
assert fields["episode"] is None # season pack
|
||||||
assert fields["source"] is None # ELiTE omits it
|
assert fields["source"] is None # ELiTE omits it
|
||||||
assert fields["tech_string"] == "1080p.x265"
|
assert fields["quality"] == "1080p"
|
||||||
|
assert fields["codec"] == "x265"
|
||||||
assert fields["group"] == "ELiTE"
|
assert fields["group"] == "ELiTE"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -78,7 +78,6 @@ def _movie(year: int = 2020, **overrides) -> ParsedRelease:
|
|||||||
source="BluRay",
|
source="BluRay",
|
||||||
codec="x264",
|
codec="x264",
|
||||||
group="GROUP",
|
group="GROUP",
|
||||||
tech_string="1080p.BluRay.x264",
|
|
||||||
media_type=MediaTypeToken.MOVIE,
|
media_type=MediaTypeToken.MOVIE,
|
||||||
parse_path=ParsePath.DIRECT,
|
parse_path=ParsePath.DIRECT,
|
||||||
)
|
)
|
||||||
@@ -120,7 +119,6 @@ class TestComputeScore:
|
|||||||
source="WEBRip",
|
source="WEBRip",
|
||||||
codec="x265",
|
codec="x265",
|
||||||
group="KONTRAST",
|
group="KONTRAST",
|
||||||
tech_string="1080p.WEBRip.x265",
|
|
||||||
media_type=MediaTypeToken.TV_SHOW,
|
media_type=MediaTypeToken.TV_SHOW,
|
||||||
parse_path=ParsePath.DIRECT,
|
parse_path=ParsePath.DIRECT,
|
||||||
)
|
)
|
||||||
@@ -231,7 +229,6 @@ class TestCollectors:
|
|||||||
source=None,
|
source=None,
|
||||||
codec=None,
|
codec=None,
|
||||||
group="UNKNOWN",
|
group="UNKNOWN",
|
||||||
tech_string="",
|
|
||||||
media_type=MediaTypeToken.UNKNOWN,
|
media_type=MediaTypeToken.UNKNOWN,
|
||||||
parse_path=ParsePath.DIRECT,
|
parse_path=ParsePath.DIRECT,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -44,8 +44,10 @@ def test_parse_matches_fixture(fixture: ReleaseFixture, tmp_path) -> None:
|
|||||||
|
|
||||||
parsed, _report = parse_release(fixture.release_name, _KB)
|
parsed, _report = parse_release(fixture.release_name, _KB)
|
||||||
result = asdict(parsed)
|
result = asdict(parsed)
|
||||||
# ``is_season_pack`` is a @property — asdict() does not include it.
|
# ``is_season_pack`` and ``tech_string`` are @property values —
|
||||||
|
# ``asdict()`` does not include them.
|
||||||
result["is_season_pack"] = parsed.is_season_pack
|
result["is_season_pack"] = parsed.is_season_pack
|
||||||
|
result["tech_string"] = parsed.tech_string
|
||||||
|
|
||||||
for field, expected in fixture.expected_parsed.items():
|
for field, expected in fixture.expected_parsed.items():
|
||||||
assert field in result, (
|
assert field in result, (
|
||||||
|
|||||||
Reference in New Issue
Block a user