From e62dc90bd183129ff834b1e3ec40866598aa4a50 Mon Sep 17 00:00:00 2001 From: Francwa Date: Thu, 21 May 2026 07:33:53 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 11 +++++++++++ alfred/application/release/enrich_from_probe.py | 9 +++------ alfred/domain/release/parser/pipeline.py | 4 ---- alfred/domain/release/services.py | 1 - alfred/domain/release/value_objects.py | 10 +++++++++- tests/application/test_enrich_from_probe.py | 10 +++++----- tests/domain/release/test_parser_v2_easy.py | 4 ++-- tests/domain/release/test_parser_v2_scoring.py | 3 --- tests/domain/test_release_fixtures.py | 4 +++- 9 files changed, 33 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3878aa0..04c6275 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,17 @@ callers). ### 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 valid levels (global, release_group, movie, show, season, episode) was documented only in a docstring comment and validated nowhere. diff --git a/alfred/application/release/enrich_from_probe.py b/alfred/application/release/enrich_from_probe.py index dd66400..1779046 100644 --- a/alfred/application/release/enrich_from_probe.py +++ b/alfred/application/release/enrich_from_probe.py @@ -81,9 +81,6 @@ def enrich_from_probe(parsed: ParsedRelease, info: MediaInfo) -> None: if lang.lower() != "und" and lang.upper() not in existing: parsed.languages.append(lang) - # Re-derive tech_string so filename builders see the enriched - # quality/source/codec. Built the same way as in the parser pipeline: - # the non-None parts joined by dots, in order. - parsed.tech_string = ".".join( - p for p in (parsed.quality, parsed.source, parsed.codec) if p - ) + # tech_string is a derived property on ParsedRelease — it always + # reflects the current quality/source/codec, so there's nothing to + # refresh after enrichment. diff --git a/alfred/domain/release/parser/pipeline.py b/alfred/domain/release/parser/pipeline.py index bea3f42..be69729 100644 --- a/alfred/domain/release/parser/pipeline.py +++ b/alfred/domain/release/parser/pipeline.py @@ -713,9 +713,6 @@ def assemble( if distributor is None: 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 # generic tech-based fallback. We look across all tokens (not just # annotated ones) because these markers may be tagged UNKNOWN by the @@ -754,7 +751,6 @@ def assemble( "source": source, "codec": codec, "group": group, - "tech_string": tech_string, "media_type": media_type, "site_tag": site_tag, "languages": languages, diff --git a/alfred/domain/release/services.py b/alfred/domain/release/services.py index 7ad31ce..50351a5 100644 --- a/alfred/domain/release/services.py +++ b/alfred/domain/release/services.py @@ -73,7 +73,6 @@ def parse_release( source=None, codec=None, group="UNKNOWN", - tech_string="", media_type=MediaTypeToken.UNKNOWN, site_tag=site_tag, parse_path=ParsePath.AI, diff --git a/alfred/domain/release/value_objects.py b/alfred/domain/release/value_objects.py index de0de47..dca2807 100644 --- a/alfred/domain/release/value_objects.py +++ b/alfred/domain/release/value_objects.py @@ -123,7 +123,6 @@ class ParsedRelease: source: str | None # WEBRip, BluRay, … codec: str | None # x265, HEVC, … group: str # release group, "UNKNOWN" if missing - tech_string: str # quality.source.codec joined with dots media_type: MediaTypeToken = MediaTypeToken.UNKNOWN site_tag: str | None = ( None # site watermark stripped from name, e.g. "TGx", "OxTorrent.vc" @@ -179,6 +178,15 @@ class ParsedRelease: def is_season_pack(self) -> bool: 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: """ Build the series root folder name. diff --git a/tests/application/test_enrich_from_probe.py b/tests/application/test_enrich_from_probe.py index 078364e..9893614 100644 --- a/tests/application/test_enrich_from_probe.py +++ b/tests/application/test_enrich_from_probe.py @@ -46,7 +46,6 @@ def _bare(**overrides) -> ParsedRelease: source=None, codec=None, group="UNKNOWN", - tech_string="", ) defaults.update(overrides) return ParsedRelease(**defaults) @@ -218,8 +217,9 @@ class TestLanguages: class TestTechString: - """tech_string drives the filename builders; it must be re-derived - whenever quality / source / codec change.""" + """tech_string is a derived property on ParsedRelease: it always + 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): p = _bare() @@ -239,9 +239,9 @@ class TestTechString: assert p.tech_string == "1080p.BluRay.x265" 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.tech_string = "2160p.WEB-DL.x265" + assert p.tech_string == "2160p.WEB-DL.x265" enrich_from_probe(p, MediaInfo()) assert p.tech_string == "2160p.WEB-DL.x265" diff --git a/tests/domain/release/test_parser_v2_easy.py b/tests/domain/release/test_parser_v2_easy.py index f3ed482..027f276 100644 --- a/tests/domain/release/test_parser_v2_easy.py +++ b/tests/domain/release/test_parser_v2_easy.py @@ -123,7 +123,6 @@ class TestAssemble: assert fields["source"] == "WEBRip" assert fields["codec"] == "x265" assert fields["group"] == "KONTRAST" - assert fields["tech_string"] == "1080p.WEBRip.x265" assert fields["media_type"] == "movie" assert fields["site_tag"] is None @@ -150,7 +149,8 @@ class TestAssemble: assert fields["season"] == 2 assert fields["episode"] is None # season pack 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" diff --git a/tests/domain/release/test_parser_v2_scoring.py b/tests/domain/release/test_parser_v2_scoring.py index 4d4a4a6..85a19d5 100644 --- a/tests/domain/release/test_parser_v2_scoring.py +++ b/tests/domain/release/test_parser_v2_scoring.py @@ -78,7 +78,6 @@ def _movie(year: int = 2020, **overrides) -> ParsedRelease: source="BluRay", codec="x264", group="GROUP", - tech_string="1080p.BluRay.x264", media_type=MediaTypeToken.MOVIE, parse_path=ParsePath.DIRECT, ) @@ -120,7 +119,6 @@ class TestComputeScore: source="WEBRip", codec="x265", group="KONTRAST", - tech_string="1080p.WEBRip.x265", media_type=MediaTypeToken.TV_SHOW, parse_path=ParsePath.DIRECT, ) @@ -231,7 +229,6 @@ class TestCollectors: source=None, codec=None, group="UNKNOWN", - tech_string="", media_type=MediaTypeToken.UNKNOWN, parse_path=ParsePath.DIRECT, ) diff --git a/tests/domain/test_release_fixtures.py b/tests/domain/test_release_fixtures.py index 499912a..424850a 100644 --- a/tests/domain/test_release_fixtures.py +++ b/tests/domain/test_release_fixtures.py @@ -44,8 +44,10 @@ def test_parse_matches_fixture(fixture: ReleaseFixture, tmp_path) -> None: parsed, _report = parse_release(fixture.release_name, _KB) 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["tech_string"] = parsed.tech_string for field, expected in fixture.expected_parsed.items(): assert field in result, (