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:
@@ -0,0 +1,345 @@
|
||||
"""Tests for ``alfred.domain.subtitles.services.identifier``.
|
||||
|
||||
Coverage:
|
||||
|
||||
- ``TestTokenize`` — ``_tokenize`` strips parentheses and splits on
|
||||
``[.\\s_-]``; ``_tokenize_suffix`` peels the episode stem prefix.
|
||||
- ``TestCountEntries`` — last-cue-number heuristic for SRT files.
|
||||
- ``TestEmbedded`` — ffprobe is mocked; dispositions map to SDH/FORCED
|
||||
/ STANDARD; non-existent file → empty list; ffprobe error → empty.
|
||||
- ``TestAdjacent`` — adjacent strategy: only known extensions, excludes
|
||||
the video file itself.
|
||||
- ``TestFlat`` — Subs/ folder adjacent or at release root.
|
||||
- ``TestEpisodeSubfolder`` — Subs/{stem}/*.srt; tokens after prefix.
|
||||
- ``TestClassify`` — language + type token detection, confidence math.
|
||||
- ``TestSizeDisambiguation`` — size_and_count post-processing rules
|
||||
(2-track → standard+sdh; 3+ → forced + standard + sdh).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from alfred.domain.subtitles.entities import SubtitleCandidate
|
||||
from alfred.domain.subtitles.knowledge.base import SubtitleKnowledgeBase
|
||||
from alfred.domain.subtitles.services.identifier import (
|
||||
SubtitleIdentifier,
|
||||
_count_entries,
|
||||
_tokenize,
|
||||
_tokenize_suffix,
|
||||
)
|
||||
from alfred.domain.subtitles.value_objects import (
|
||||
ScanStrategy,
|
||||
SubtitleLanguage,
|
||||
SubtitlePattern,
|
||||
SubtitleType,
|
||||
TypeDetectionMethod,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def kb():
|
||||
return SubtitleKnowledgeBase()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def identifier(kb):
|
||||
return SubtitleIdentifier(kb)
|
||||
|
||||
|
||||
def _pattern(strategy: ScanStrategy, root_folder: str | None = None,
|
||||
detection: TypeDetectionMethod = TypeDetectionMethod.TOKEN_IN_NAME) -> SubtitlePattern:
|
||||
return SubtitlePattern(
|
||||
id=f"test-{strategy.value}",
|
||||
description="",
|
||||
scan_strategy=strategy,
|
||||
root_folder=root_folder,
|
||||
type_detection=detection,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# _tokenize / _tokenize_suffix #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestTokenize:
|
||||
def test_basic_dotted(self):
|
||||
assert _tokenize("Show.S01E01.French") == ["show", "s01e01", "french"]
|
||||
|
||||
def test_mixed_separators(self):
|
||||
assert _tokenize("Show_S01-E01 French") == [
|
||||
"show", "s01", "e01", "french"
|
||||
]
|
||||
|
||||
def test_strips_parenthesized(self):
|
||||
assert _tokenize("episode (Brazil).French") == ["episode", "french"]
|
||||
|
||||
def test_empty_string(self):
|
||||
assert _tokenize("") == []
|
||||
|
||||
def test_suffix_strips_episode_prefix(self):
|
||||
out = _tokenize_suffix("Show.S01E01.English", "Show.S01E01")
|
||||
assert out == ["english"]
|
||||
|
||||
def test_suffix_falls_back_when_no_prefix(self):
|
||||
# filename doesn't start with episode_stem → full tokenize.
|
||||
out = _tokenize_suffix("Other.srt", "Show.S01E01")
|
||||
assert "other" in out
|
||||
|
||||
def test_suffix_falls_back_when_suffix_is_empty(self):
|
||||
# Suffix would tokenize to nothing → fall back to full stem.
|
||||
out = _tokenize_suffix("Show.S01E01", "Show.S01E01")
|
||||
# full tokenize of "Show.S01E01" → ['show', 's01e01']
|
||||
assert out == ["show", "s01e01"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# _count_entries #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestCountEntries:
|
||||
def test_last_cue_number(self, tmp_path):
|
||||
srt = tmp_path / "x.srt"
|
||||
srt.write_text(
|
||||
"1\n00:00:01,000 --> 00:00:02,000\nHello\n\n"
|
||||
"2\n00:00:03,000 --> 00:00:04,000\nWorld\n\n"
|
||||
"42\n00:00:05,000 --> 00:00:06,000\nLast\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert _count_entries(srt) == 42
|
||||
|
||||
def test_missing_file_returns_zero(self, tmp_path):
|
||||
assert _count_entries(tmp_path / "nope.srt") == 0
|
||||
|
||||
def test_empty_file_returns_zero(self, tmp_path):
|
||||
f = tmp_path / "x.srt"
|
||||
f.write_text("")
|
||||
assert _count_entries(f) == 0
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Embedded scan #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestEmbedded:
|
||||
def test_missing_file_returns_empty(self, identifier, tmp_path):
|
||||
assert identifier._scan_embedded(tmp_path / "missing.mkv") == []
|
||||
|
||||
def test_ffprobe_failure_returns_empty(self, identifier, tmp_path):
|
||||
video = tmp_path / "v.mkv"
|
||||
video.write_bytes(b"")
|
||||
with patch(
|
||||
"alfred.domain.subtitles.services.identifier.subprocess.run",
|
||||
side_effect=FileNotFoundError("no ffprobe"),
|
||||
):
|
||||
assert identifier._scan_embedded(video) == []
|
||||
|
||||
def test_disposition_to_subtitle_type(self, identifier, tmp_path):
|
||||
video = tmp_path / "v.mkv"
|
||||
video.write_bytes(b"")
|
||||
fake_output = (
|
||||
'{"streams":['
|
||||
'{"tags":{"language":"eng"},"disposition":{"hearing_impaired":1}},'
|
||||
'{"tags":{"language":"fre"},"disposition":{"forced":1}},'
|
||||
'{"tags":{"language":"spa"},"disposition":{}},'
|
||||
'{"tags":{},"disposition":{}}'
|
||||
"]}"
|
||||
)
|
||||
|
||||
class FakeResult:
|
||||
stdout = fake_output
|
||||
|
||||
with patch(
|
||||
"alfred.domain.subtitles.services.identifier.subprocess.run",
|
||||
return_value=FakeResult(),
|
||||
):
|
||||
tracks = identifier._scan_embedded(video)
|
||||
|
||||
assert len(tracks) == 4
|
||||
assert tracks[0].subtitle_type == SubtitleType.SDH
|
||||
assert tracks[0].language.code == "eng"
|
||||
assert tracks[1].subtitle_type == SubtitleType.FORCED
|
||||
assert tracks[1].language.code == "fre"
|
||||
assert tracks[2].subtitle_type == SubtitleType.STANDARD
|
||||
assert tracks[3].language is None # no language tag
|
||||
for t in tracks:
|
||||
assert t.is_embedded is True
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Adjacent / Flat / Episode subfolder discovery #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestAdjacent:
|
||||
def test_finds_only_known_subtitle_extensions(self, identifier, tmp_path):
|
||||
video = tmp_path / "Show.S01E01.mkv"
|
||||
video.write_bytes(b"")
|
||||
(tmp_path / "Show.S01E01.English.srt").write_text("")
|
||||
(tmp_path / "Show.S01E01.French.ass").write_text("")
|
||||
# Non-subtitle files must be ignored.
|
||||
(tmp_path / "Show.S01E01.nfo").write_text("")
|
||||
(tmp_path / "cover.jpg").write_bytes(b"")
|
||||
result = identifier._find_adjacent(video)
|
||||
names = sorted(p.name for p in result)
|
||||
assert names == ["Show.S01E01.English.srt", "Show.S01E01.French.ass"]
|
||||
|
||||
def test_excludes_the_video_file(self, identifier, tmp_path):
|
||||
# An adjacent file with the *same stem* as the video would be the
|
||||
# video itself (e.g. a .mkv named like the .srt). Not expected here,
|
||||
# but the implementation guards via `p.stem != video.stem`.
|
||||
video = tmp_path / "Show.S01E01.mkv"
|
||||
video.write_bytes(b"")
|
||||
(tmp_path / "Show.S01E01.srt").write_text("") # same stem
|
||||
# Same stem → excluded; only subs with a different stem are returned.
|
||||
assert identifier._find_adjacent(video) == []
|
||||
|
||||
|
||||
class TestFlat:
|
||||
def test_subs_folder_adjacent(self, identifier, tmp_path):
|
||||
video = tmp_path / "Show.S01E01.mkv"
|
||||
video.write_bytes(b"")
|
||||
subs = tmp_path / "Subs"
|
||||
subs.mkdir()
|
||||
(subs / "English.srt").write_text("")
|
||||
result = identifier._find_flat(video, "Subs")
|
||||
assert len(result) == 1
|
||||
|
||||
def test_subs_folder_at_release_root_fallback(self, identifier, tmp_path):
|
||||
season = tmp_path / "Season.1"
|
||||
season.mkdir()
|
||||
video = season / "Show.S01E01.mkv"
|
||||
video.write_bytes(b"")
|
||||
subs = tmp_path / "Subs"
|
||||
subs.mkdir()
|
||||
(subs / "English.srt").write_text("")
|
||||
result = identifier._find_flat(video, "Subs")
|
||||
assert len(result) == 1
|
||||
|
||||
def test_no_subs_folder_returns_empty(self, identifier, tmp_path):
|
||||
video = tmp_path / "v.mkv"
|
||||
video.write_bytes(b"")
|
||||
assert identifier._find_flat(video, "Subs") == []
|
||||
|
||||
|
||||
class TestEpisodeSubfolder:
|
||||
def test_found_and_stem_returned(self, identifier, tmp_path):
|
||||
video = tmp_path / "Show.S01E01.mkv"
|
||||
video.write_bytes(b"")
|
||||
subs = tmp_path / "Subs" / "Show.S01E01"
|
||||
subs.mkdir(parents=True)
|
||||
(subs / "2_English.srt").write_text("")
|
||||
files, stem = identifier._find_episode_subfolder(video, "Subs")
|
||||
assert len(files) == 1
|
||||
assert stem == "Show.S01E01"
|
||||
|
||||
def test_not_found(self, identifier, tmp_path):
|
||||
video = tmp_path / "Show.S01E01.mkv"
|
||||
video.write_bytes(b"")
|
||||
files, stem = identifier._find_episode_subfolder(video, "Subs")
|
||||
assert files == []
|
||||
assert stem == "Show.S01E01"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Classification #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestClassify:
|
||||
def test_classifies_language_and_format(self, identifier, tmp_path):
|
||||
f = tmp_path / "Show.S01E01.English.srt"
|
||||
f.write_text("1\n00:00:01,000 --> 00:00:02,000\nHi\n")
|
||||
track = identifier._classify_single(f)
|
||||
assert track.language.code == "eng"
|
||||
assert track.format.id == "srt"
|
||||
assert track.confidence > 0
|
||||
assert track.is_embedded is False
|
||||
|
||||
def test_classifies_type_token(self, identifier, tmp_path):
|
||||
f = tmp_path / "Show.S01E01.English.sdh.srt"
|
||||
f.write_text("")
|
||||
track = identifier._classify_single(f)
|
||||
assert track.subtitle_type == SubtitleType.SDH
|
||||
|
||||
def test_unknown_tokens_lower_confidence(self, identifier, tmp_path):
|
||||
f = tmp_path / "Show.S01E01.gibberish.srt"
|
||||
f.write_text("")
|
||||
track = identifier._classify_single(f)
|
||||
# No lang/type recognized → confidence is 0 or very low.
|
||||
assert track.language is None
|
||||
assert track.confidence < 0.5
|
||||
|
||||
def test_episode_stem_prefix_stripped(self, identifier, tmp_path):
|
||||
f = tmp_path / "Show.S01E01.English.srt"
|
||||
f.write_text("")
|
||||
track = identifier._classify_single(f, episode_stem="Show.S01E01")
|
||||
# Only "english" remains as meaningful token → confidence == 1.0
|
||||
assert track.language.code == "eng"
|
||||
assert track.confidence == 1.0
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# size_and_count post-processing #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestSizeDisambiguation:
|
||||
@pytest.fixture
|
||||
def pattern_size(self):
|
||||
return _pattern(
|
||||
ScanStrategy.FLAT,
|
||||
root_folder="Subs",
|
||||
detection=TypeDetectionMethod.SIZE_AND_COUNT,
|
||||
)
|
||||
|
||||
def _track(self, lang_code: str, entries: int) -> SubtitleCandidate:
|
||||
return SubtitleCandidate(
|
||||
language=SubtitleLanguage(code=lang_code, tokens=[lang_code]),
|
||||
format=None,
|
||||
subtitle_type=SubtitleType.UNKNOWN,
|
||||
entry_count=entries,
|
||||
)
|
||||
|
||||
def test_two_tracks_split_into_standard_and_sdh(self, identifier, pattern_size):
|
||||
t1 = self._track("eng", 800)
|
||||
t2 = self._track("eng", 1200)
|
||||
result = identifier._disambiguate_by_size([t1, t2])
|
||||
# Sorted ascending → smaller=standard, larger=sdh
|
||||
types = sorted([t.subtitle_type for t in result], key=lambda s: s.value)
|
||||
assert SubtitleType.STANDARD in types
|
||||
assert SubtitleType.SDH in types
|
||||
|
||||
def test_three_tracks_split_into_forced_standard_sdh(self, identifier):
|
||||
t_small = self._track("eng", 50)
|
||||
t_mid = self._track("eng", 600)
|
||||
t_large = self._track("eng", 1200)
|
||||
result = identifier._disambiguate_by_size([t_large, t_small, t_mid])
|
||||
# Sorted ascending → smallest=forced, middle=standard, largest=sdh
|
||||
by_count = sorted(result, key=lambda t: t.entry_count)
|
||||
assert by_count[0].subtitle_type == SubtitleType.FORCED
|
||||
assert by_count[1].subtitle_type == SubtitleType.STANDARD
|
||||
assert by_count[2].subtitle_type == SubtitleType.SDH
|
||||
|
||||
def test_single_track_untouched(self, identifier):
|
||||
t = self._track("eng", 800)
|
||||
result = identifier._disambiguate_by_size([t])
|
||||
assert result == [t]
|
||||
assert t.subtitle_type == SubtitleType.UNKNOWN
|
||||
|
||||
def test_different_languages_grouped_independently(self, identifier):
|
||||
# Two eng + one fra → fra is alone, eng pair gets split.
|
||||
eng_small = self._track("eng", 800)
|
||||
eng_large = self._track("eng", 1500)
|
||||
fra_solo = self._track("fra", 1000)
|
||||
result = identifier._disambiguate_by_size([eng_small, eng_large, fra_solo])
|
||||
# fra solo stays UNKNOWN
|
||||
assert fra_solo.subtitle_type == SubtitleType.UNKNOWN
|
||||
# eng pair gets STANDARD + SDH
|
||||
assert eng_small.subtitle_type == SubtitleType.STANDARD
|
||||
assert eng_large.subtitle_type == SubtitleType.SDH
|
||||
Reference in New Issue
Block a user