chore: sprint cleanup — language unification, parser unification, fossils removal

Several weeks of work accumulated without being committed. Grouped here for
clarity; see CHANGELOG.md [Unreleased] for the user-facing summary.

Highlights
----------

P1 #2 — ISO 639-2/B canonical migration
- New Language VO + LanguageRegistry (alfred/domain/shared/knowledge/).
- iso_languages.yaml as single source of truth for language codes.
- SubtitleKnowledgeBase now delegates lookup to LanguageRegistry; subtitles.yaml
  only declares subtitle-specific tokens (vostfr, vf, vff, …).
- SubtitlePreferences default → ["fre", "eng"]; subtitle filenames written as
  {iso639_2b}.srt (legacy fr.srt still read via alias).
- Scanner: dropped _LANG_KEYWORDS / _SDH_TOKENS / _FORCED_TOKENS /
  SUBTITLE_EXTENSIONS hardcoded dicts.
- Fixed: 'hi' token no longer marks SDH (conflicted with Hindi alias).
- Added settings.min_movie_size_bytes (was a module constant).

P1 #3 — Release parser unification + data-driven tokenizer
- parse_release() is now the single source of truth for release-name parsing.
- alfred/knowledge/release/separators.yaml declares the token separators used
  by the tokenizer (., space, [, ], (, ), _). New conventions can be added
  without code changes.
- Tokenizer now splits on any configured separator instead of name.split('.').
  Releases like 'The Father (2020) [1080p] [WEBRip] [5.1] [YTS.MX]' parse via
  the direct path without sanitization fallback.
- Site-tag extraction always runs first; well-formedness only rejects truly
  forbidden chars.
- _parse_season_episode() extended with NxNN / NxNNxNN alt forms.
- Removed dead helpers: _sanitize, _normalize.

Domain cleanup
- Deleted fossil services with zero production callers:
    alfred/domain/movies/services.py
    alfred/domain/tv_shows/services.py
    alfred/domain/subtitles/services.py (replaced by subtitles/services/ package)
    alfred/domain/subtitles/repositories.py
- Split monolithic subtitle services into a package (identifier, matcher,
  placer, pattern_detector, utils) + dedicated knowledge/ package.
- MediaInfo split into dedicated package (alfred/domain/shared/media/:
  audio, video, subtitle, info, matching).

Persistence cleanup
- Removed dead JSON repositories (movie/subtitle/tvshow_repository.py).

Tests
- Major expansion of the test suite organized to mirror the source tree.
- Removed obsolete *_edge_cases test files superseded by structured tests.
- Suite: 990 passed, 8 skipped.

Misc
- .gitignore: exclude env_backup/ and *.bak.
- Adjustments across agent/llm, app.py, application/filesystem, and
  infrastructure/filesystem to align with the new domain layout.
This commit is contained in:
2026-05-17 23:38:00 +02:00
parent ba6f016d49
commit e07c9ec77b
99 changed files with 8833 additions and 6533 deletions
+54 -37
View File
@@ -14,11 +14,12 @@ from alfred.domain.subtitles.scanner import (
class TestClassify:
def test_iso_lang_code(self, tmp_path):
def test_iso_lang_code_639_1_alias(self, tmp_path):
# ``fr`` is an alias of the canonical ISO 639-2/B code ``fre``.
p = tmp_path / "fr.srt"
p.write_text("")
lang, is_sdh, is_forced = _classify(p)
assert lang == "fr"
assert lang == "fre"
assert not is_sdh
assert not is_forced
@@ -26,35 +27,39 @@ class TestClassify:
p = tmp_path / "english.srt"
p.write_text("")
lang, _, _ = _classify(p)
assert lang == "en"
assert lang == "eng"
def test_french_keyword(self, tmp_path):
p = tmp_path / "Show.S01E01.French.srt"
p.write_text("")
lang, _, _ = _classify(p)
assert lang == "fr"
assert lang == "fre"
def test_vostfr_is_french(self, tmp_path):
p = tmp_path / "Show.S01E01.VOSTFR.srt"
p.write_text("")
lang, _, _ = _classify(p)
assert lang == "fr"
assert lang == "fre"
def test_sdh_token(self, tmp_path):
p = tmp_path / "fr.sdh.srt"
p = tmp_path / "fre.sdh.srt"
p.write_text("")
lang, is_sdh, _ = _classify(p)
assert lang == "fr"
assert lang == "fre"
assert is_sdh
def test_hi_alias_for_sdh(self, tmp_path):
def test_hi_no_longer_marks_sdh(self, tmp_path):
# ``hi`` is the ISO 639-1 alias for Hindi; it must not mark a file as
# SDH any more (regression of the previous collision between SDH and
# Hindi tokens). Use ``sdh`` / ``cc`` / ``hearing`` to flag SDH instead.
p = tmp_path / "en.hi.srt"
p.write_text("")
_, is_sdh, _ = _classify(p)
assert is_sdh
lang, is_sdh, _ = _classify(p)
assert lang == "eng"
assert not is_sdh
def test_forced_token(self, tmp_path):
p = tmp_path / "fr.forced.srt"
p = tmp_path / "fre.forced.srt"
p.write_text("")
_, _, is_forced = _classify(p)
assert is_forced
@@ -66,17 +71,17 @@ class TestClassify:
assert lang is None
def test_dot_separator(self, tmp_path):
p = tmp_path / "fr.sdh.srt"
p = tmp_path / "fre.sdh.srt"
p.write_text("")
lang, is_sdh, _ = _classify(p)
assert lang == "fr"
assert lang == "fre"
assert is_sdh
def test_hyphen_separator(self, tmp_path):
p = tmp_path / "fr-forced.srt"
p = tmp_path / "fre-forced.srt"
p.write_text("")
lang, _, is_forced = _classify(p)
assert lang == "fr"
assert lang == "fre"
assert is_forced
@@ -86,9 +91,9 @@ class TestClassify:
class TestSubtitleCandidateDestinationName:
def _make(self, lang="fr", is_sdh=False, is_forced=False, ext=".srt", path=None):
def _make(self, lang="fre", is_sdh=False, is_forced=False, ext=".srt", path=None):
return SubtitleCandidate(
source_path=path or Path("/fake/fr.srt"),
source_path=path or Path("/fake/fre.srt"),
language=lang,
is_sdh=is_sdh,
is_forced=is_forced,
@@ -96,19 +101,19 @@ class TestSubtitleCandidateDestinationName:
)
def test_standard(self):
assert self._make().destination_name == "fr.srt"
assert self._make().destination_name == "fre.srt"
def test_sdh(self):
assert self._make(is_sdh=True).destination_name == "fr.sdh.srt"
assert self._make(is_sdh=True).destination_name == "fre.sdh.srt"
def test_forced(self):
assert self._make(is_forced=True).destination_name == "fr.forced.srt"
assert self._make(is_forced=True).destination_name == "fre.forced.srt"
def test_ass_extension(self):
assert self._make(ext=".ass").destination_name == "fr.ass"
assert self._make(ext=".ass").destination_name == "fre.ass"
def test_english_standard(self):
assert self._make(lang="en").destination_name == "en.srt"
assert self._make(lang="eng").destination_name == "eng.srt"
# ---------------------------------------------------------------------------
@@ -119,7 +124,7 @@ class TestSubtitleCandidateDestinationName:
class TestSubtitleScanner:
def _scanner(self, languages=None, min_size_kb=0, keep_sdh=True, keep_forced=True):
return SubtitleScanner(
languages=languages or ["fr", "en"],
languages=languages or ["fre", "eng"],
min_size_kb=min_size_kb,
keep_sdh=keep_sdh,
keep_forced=keep_forced,
@@ -131,31 +136,43 @@ class TestSubtitleScanner:
return video
def test_finds_adjacent_subtitle(self, tmp_path):
video = self._video(tmp_path)
(tmp_path / "fre.srt").write_text("subtitle content")
candidates = self._scanner().scan(video)
assert len(candidates) == 1
assert candidates[0].language == "fre"
def test_finds_adjacent_subtitle_legacy_639_1(self, tmp_path):
# Reading existing media libraries: ``fr.srt`` is still recognized as
# French and classified canonically as ``fre`` — covers user libraries
# written before the ISO 639-2/B migration.
video = self._video(tmp_path)
(tmp_path / "fr.srt").write_text("subtitle content")
candidates = self._scanner().scan(video)
assert len(candidates) == 1
assert candidates[0].language == "fr"
assert candidates[0].language == "fre"
def test_finds_multiple_languages(self, tmp_path):
video = self._video(tmp_path)
(tmp_path / "fr.srt").write_text("fr subtitle")
(tmp_path / "en.srt").write_text("en subtitle")
(tmp_path / "fre.srt").write_text("fr subtitle")
(tmp_path / "eng.srt").write_text("en subtitle")
candidates = self._scanner().scan(video)
langs = {c.language for c in candidates}
assert langs == {"fr", "en"}
assert langs == {"fre", "eng"}
def test_scans_subs_subfolder(self, tmp_path):
video = self._video(tmp_path)
subs = tmp_path / "Subs"
subs.mkdir()
(subs / "fr.srt").write_text("subtitle")
(subs / "fre.srt").write_text("subtitle")
candidates = self._scanner().scan(video)
assert any(c.language == "fr" for c in candidates)
assert any(c.language == "fre" for c in candidates)
def test_filters_unknown_language(self, tmp_path):
video = self._video(tmp_path)
@@ -166,14 +183,14 @@ class TestSubtitleScanner:
def test_filters_wrong_language(self, tmp_path):
video = self._video(tmp_path)
(tmp_path / "de.srt").write_text("german subtitle")
(tmp_path / "ger.srt").write_text("german subtitle")
candidates = self._scanner(languages=["fr"]).scan(video)
candidates = self._scanner(languages=["fre"]).scan(video)
assert len(candidates) == 0
def test_filters_too_small_file(self, tmp_path):
video = self._video(tmp_path)
small = tmp_path / "fr.srt"
small = tmp_path / "fre.srt"
small.write_bytes(b"x") # 1 byte, well below any min_size_kb
candidates = self._scanner(min_size_kb=10).scan(video)
@@ -181,21 +198,21 @@ class TestSubtitleScanner:
def test_filters_sdh_when_not_wanted(self, tmp_path):
video = self._video(tmp_path)
(tmp_path / "fr.sdh.srt").write_text("sdh subtitle")
(tmp_path / "fre.sdh.srt").write_text("sdh subtitle")
candidates = self._scanner(keep_sdh=False).scan(video)
assert len(candidates) == 0
def test_filters_forced_when_not_wanted(self, tmp_path):
video = self._video(tmp_path)
(tmp_path / "fr.forced.srt").write_text("forced subtitle")
(tmp_path / "fre.forced.srt").write_text("forced subtitle")
candidates = self._scanner(keep_forced=False).scan(video)
assert len(candidates) == 0
def test_keeps_sdh_when_wanted(self, tmp_path):
video = self._video(tmp_path)
(tmp_path / "fr.sdh.srt").write_text("sdh subtitle")
(tmp_path / "fre.sdh.srt").write_text("sdh subtitle")
candidates = self._scanner(keep_sdh=True).scan(video)
assert len(candidates) == 1
@@ -203,8 +220,8 @@ class TestSubtitleScanner:
def test_ignores_non_subtitle_files(self, tmp_path):
video = self._video(tmp_path)
(tmp_path / "fr.nfo").write_text("nfo file")
(tmp_path / "fr.jpg").write_bytes(b"image")
(tmp_path / "fre.nfo").write_text("nfo file")
(tmp_path / "fre.jpg").write_bytes(b"image")
candidates = self._scanner().scan(video)
assert len(candidates) == 0