test(release): adapt suite to explicit ReleaseKnowledge injection
- test_release.py / test_release_fixtures.py: module-level _KB = YamlReleaseKnowledge() + thin _parse(name) helper threading it into parse_release. test_show_folder_name_strips_windows_chars renamed to test_show_folder_name_uses_already_safe_title to reflect the Option B contract (caller sanitizes via kb.sanitize_for_fs). - test_detect_media_type.py: same _KB pattern, all detect_media_type(parsed, path) calls now pass kb. - test_filesystem_extras.py: find_video_file(path) calls now pass kb. - test_enrich_from_probe.py: _bare() helper adds the new title_sanitized field. - test_resolve_destination.py: drop _sanitize import + TestSanitize class (helper deleted), add tmdb_title_safe arg to _resolve_series_folder calls. 987 passed, 8 skipped.
This commit is contained in:
@@ -20,16 +20,19 @@ import pytest
|
|||||||
|
|
||||||
from alfred.application.filesystem.detect_media_type import detect_media_type
|
from alfred.application.filesystem.detect_media_type import detect_media_type
|
||||||
from alfred.domain.release.services import parse_release
|
from alfred.domain.release.services import parse_release
|
||||||
|
from alfred.infrastructure.knowledge.release_kb import YamlReleaseKnowledge
|
||||||
|
|
||||||
|
_KB = YamlReleaseKnowledge()
|
||||||
|
|
||||||
|
|
||||||
def _parsed(media_type: str = "movie"):
|
def _parsed(media_type: str = "movie"):
|
||||||
"""Build a ParsedRelease with the requested media_type via the real parser."""
|
"""Build a ParsedRelease with the requested media_type via the real parser."""
|
||||||
if media_type == "tv_show":
|
if media_type == "tv_show":
|
||||||
return parse_release("Show.S01E01.1080p-GRP")
|
return parse_release("Show.S01E01.1080p-GRP", _KB)
|
||||||
if media_type == "movie":
|
if media_type == "movie":
|
||||||
return parse_release("Movie.2020.1080p-GRP")
|
return parse_release("Movie.2020.1080p-GRP", _KB)
|
||||||
# "unknown" / other — feed a name the parser can't classify
|
# "unknown" / other — feed a name the parser can't classify
|
||||||
return parse_release("randomthing")
|
return parse_release("randomthing", _KB)
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -41,30 +44,30 @@ class TestFile:
|
|||||||
def test_video_file_preserves_parsed_type(self, tmp_path: Path):
|
def test_video_file_preserves_parsed_type(self, tmp_path: Path):
|
||||||
f = tmp_path / "x.mkv"
|
f = tmp_path / "x.mkv"
|
||||||
f.write_bytes(b"")
|
f.write_bytes(b"")
|
||||||
assert detect_media_type(_parsed("movie"), f) == "movie"
|
assert detect_media_type(_parsed("movie"), f, _KB) == "movie"
|
||||||
|
|
||||||
def test_video_file_preserves_tv_type(self, tmp_path: Path):
|
def test_video_file_preserves_tv_type(self, tmp_path: Path):
|
||||||
f = tmp_path / "ep.mp4"
|
f = tmp_path / "ep.mp4"
|
||||||
f.write_bytes(b"")
|
f.write_bytes(b"")
|
||||||
assert detect_media_type(_parsed("tv_show"), f) == "tv_show"
|
assert detect_media_type(_parsed("tv_show"), f, _KB) == "tv_show"
|
||||||
|
|
||||||
def test_non_video_file_returns_other(self, tmp_path: Path):
|
def test_non_video_file_returns_other(self, tmp_path: Path):
|
||||||
f = tmp_path / "x.iso"
|
f = tmp_path / "x.iso"
|
||||||
f.write_bytes(b"")
|
f.write_bytes(b"")
|
||||||
assert detect_media_type(_parsed("movie"), f) == "other"
|
assert detect_media_type(_parsed("movie"), f, _KB) == "other"
|
||||||
|
|
||||||
@pytest.mark.parametrize("ext", [".rar", ".zip", ".7z", ".exe", ".dmg"])
|
@pytest.mark.parametrize("ext", [".rar", ".zip", ".7z", ".exe", ".dmg"])
|
||||||
def test_various_non_video_extensions(self, tmp_path: Path, ext):
|
def test_various_non_video_extensions(self, tmp_path: Path, ext):
|
||||||
f = tmp_path / f"x{ext}"
|
f = tmp_path / f"x{ext}"
|
||||||
f.write_bytes(b"")
|
f.write_bytes(b"")
|
||||||
assert detect_media_type(_parsed("movie"), f) == "other"
|
assert detect_media_type(_parsed("movie"), f, _KB) == "other"
|
||||||
|
|
||||||
def test_metadata_only_file_keeps_parsed_type(self, tmp_path: Path):
|
def test_metadata_only_file_keeps_parsed_type(self, tmp_path: Path):
|
||||||
# Metadata extension is stripped from conclusive set — no video, no
|
# Metadata extension is stripped from conclusive set — no video, no
|
||||||
# non-video → falls through to parsed.media_type.
|
# non-video → falls through to parsed.media_type.
|
||||||
f = tmp_path / "x.nfo"
|
f = tmp_path / "x.nfo"
|
||||||
f.write_bytes(b"")
|
f.write_bytes(b"")
|
||||||
assert detect_media_type(_parsed("movie"), f) == "movie"
|
assert detect_media_type(_parsed("movie"), f, _KB) == "movie"
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -75,27 +78,27 @@ class TestFile:
|
|||||||
class TestFolder:
|
class TestFolder:
|
||||||
def test_folder_with_video_keeps_parsed_type(self, tmp_path: Path):
|
def test_folder_with_video_keeps_parsed_type(self, tmp_path: Path):
|
||||||
(tmp_path / "main.mkv").write_bytes(b"")
|
(tmp_path / "main.mkv").write_bytes(b"")
|
||||||
assert detect_media_type(_parsed("movie"), tmp_path) == "movie"
|
assert detect_media_type(_parsed("movie"), tmp_path, _KB) == "movie"
|
||||||
|
|
||||||
def test_folder_only_non_video_returns_other(self, tmp_path: Path):
|
def test_folder_only_non_video_returns_other(self, tmp_path: Path):
|
||||||
(tmp_path / "disc.iso").write_bytes(b"")
|
(tmp_path / "disc.iso").write_bytes(b"")
|
||||||
(tmp_path / "part.rar").write_bytes(b"")
|
(tmp_path / "part.rar").write_bytes(b"")
|
||||||
assert detect_media_type(_parsed("movie"), tmp_path) == "other"
|
assert detect_media_type(_parsed("movie"), tmp_path, _KB) == "other"
|
||||||
|
|
||||||
def test_folder_mixed_returns_unknown(self, tmp_path: Path):
|
def test_folder_mixed_returns_unknown(self, tmp_path: Path):
|
||||||
(tmp_path / "main.mkv").write_bytes(b"")
|
(tmp_path / "main.mkv").write_bytes(b"")
|
||||||
(tmp_path / "extras.iso").write_bytes(b"")
|
(tmp_path / "extras.iso").write_bytes(b"")
|
||||||
assert detect_media_type(_parsed("movie"), tmp_path) == "unknown"
|
assert detect_media_type(_parsed("movie"), tmp_path, _KB) == "unknown"
|
||||||
|
|
||||||
def test_empty_folder_keeps_parsed_type(self, tmp_path: Path):
|
def test_empty_folder_keeps_parsed_type(self, tmp_path: Path):
|
||||||
assert detect_media_type(_parsed("tv_show"), tmp_path) == "tv_show"
|
assert detect_media_type(_parsed("tv_show"), tmp_path, _KB) == "tv_show"
|
||||||
|
|
||||||
def test_folder_only_metadata_keeps_parsed_type(self, tmp_path: Path):
|
def test_folder_only_metadata_keeps_parsed_type(self, tmp_path: Path):
|
||||||
(tmp_path / "info.nfo").write_bytes(b"")
|
(tmp_path / "info.nfo").write_bytes(b"")
|
||||||
(tmp_path / "cover.jpg").write_bytes(b"")
|
(tmp_path / "cover.jpg").write_bytes(b"")
|
||||||
(tmp_path / "subs.srt").write_bytes(b"")
|
(tmp_path / "subs.srt").write_bytes(b"")
|
||||||
# All metadata → conclusive set empty → falls through.
|
# All metadata → conclusive set empty → falls through.
|
||||||
assert detect_media_type(_parsed("movie"), tmp_path) == "movie"
|
assert detect_media_type(_parsed("movie"), tmp_path, _KB) == "movie"
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -109,18 +112,18 @@ class TestMetadataIgnored:
|
|||||||
(tmp_path / "info.nfo").write_bytes(b"")
|
(tmp_path / "info.nfo").write_bytes(b"")
|
||||||
(tmp_path / "cover.jpg").write_bytes(b"")
|
(tmp_path / "cover.jpg").write_bytes(b"")
|
||||||
(tmp_path / "subs.srt").write_bytes(b"")
|
(tmp_path / "subs.srt").write_bytes(b"")
|
||||||
assert detect_media_type(_parsed("movie"), tmp_path) == "movie"
|
assert detect_media_type(_parsed("movie"), tmp_path, _KB) == "movie"
|
||||||
|
|
||||||
def test_non_video_plus_metadata_still_other(self, tmp_path: Path):
|
def test_non_video_plus_metadata_still_other(self, tmp_path: Path):
|
||||||
(tmp_path / "disc.iso").write_bytes(b"")
|
(tmp_path / "disc.iso").write_bytes(b"")
|
||||||
(tmp_path / "info.nfo").write_bytes(b"")
|
(tmp_path / "info.nfo").write_bytes(b"")
|
||||||
assert detect_media_type(_parsed("movie"), tmp_path) == "other"
|
assert detect_media_type(_parsed("movie"), tmp_path, _KB) == "other"
|
||||||
|
|
||||||
def test_case_insensitive_extensions(self, tmp_path: Path):
|
def test_case_insensitive_extensions(self, tmp_path: Path):
|
||||||
# Suffix is lowercased before classification.
|
# Suffix is lowercased before classification.
|
||||||
f = tmp_path / "X.MKV"
|
f = tmp_path / "X.MKV"
|
||||||
f.write_bytes(b"")
|
f.write_bytes(b"")
|
||||||
assert detect_media_type(_parsed("movie"), f) == "movie"
|
assert detect_media_type(_parsed("movie"), f, _KB) == "movie"
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -132,11 +135,11 @@ class TestMissing:
|
|||||||
def test_nonexistent_path_keeps_parsed_type(self, tmp_path: Path):
|
def test_nonexistent_path_keeps_parsed_type(self, tmp_path: Path):
|
||||||
missing = tmp_path / "does_not_exist.mkv"
|
missing = tmp_path / "does_not_exist.mkv"
|
||||||
# Doesn't exist → empty extension set → falls through.
|
# Doesn't exist → empty extension set → falls through.
|
||||||
assert detect_media_type(_parsed("movie"), missing) == "movie"
|
assert detect_media_type(_parsed("movie"), missing, _KB) == "movie"
|
||||||
|
|
||||||
def test_nonexistent_folder_keeps_parsed_type(self, tmp_path: Path):
|
def test_nonexistent_folder_keeps_parsed_type(self, tmp_path: Path):
|
||||||
missing = tmp_path / "ghost"
|
missing = tmp_path / "ghost"
|
||||||
assert detect_media_type(_parsed("tv_show"), missing) == "tv_show"
|
assert detect_media_type(_parsed("tv_show"), missing, _KB) == "tv_show"
|
||||||
|
|
||||||
def test_subfolder_not_recursed(self, tmp_path: Path):
|
def test_subfolder_not_recursed(self, tmp_path: Path):
|
||||||
# _collect_extensions scans only the first level — files inside
|
# _collect_extensions scans only the first level — files inside
|
||||||
@@ -145,4 +148,4 @@ class TestMissing:
|
|||||||
sub.mkdir()
|
sub.mkdir()
|
||||||
(sub / "deep.mkv").write_bytes(b"")
|
(sub / "deep.mkv").write_bytes(b"")
|
||||||
# Top level has no files at all → empty → falls through to parsed type.
|
# Top level has no files at all → empty → falls through to parsed type.
|
||||||
assert detect_media_type(_parsed("movie"), tmp_path) == "movie"
|
assert detect_media_type(_parsed("movie"), tmp_path, _KB) == "movie"
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ def _bare(**overrides) -> ParsedRelease:
|
|||||||
raw="X",
|
raw="X",
|
||||||
normalised="X",
|
normalised="X",
|
||||||
title="X",
|
title="X",
|
||||||
|
title_sanitized="X",
|
||||||
year=None,
|
year=None,
|
||||||
season=None,
|
season=None,
|
||||||
episode=None,
|
episode=None,
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ Four use cases compute library paths from a release name + TMDB metadata:
|
|||||||
|
|
||||||
Coverage:
|
Coverage:
|
||||||
|
|
||||||
- ``TestSanitize`` — Windows-forbidden chars stripped.
|
|
||||||
- ``TestFindExistingTvshowFolders`` — empty root, prefix match (case + space → dot).
|
- ``TestFindExistingTvshowFolders`` — empty root, prefix match (case + space → dot).
|
||||||
- ``TestResolveSeriesFolderInternal`` — confirmed_folder, no existing, single match,
|
- ``TestResolveSeriesFolderInternal`` — confirmed_folder, no existing, single match,
|
||||||
ambiguous → _Clarification.
|
ambiguous → _Clarification.
|
||||||
@@ -32,7 +31,6 @@ from alfred.application.filesystem.resolve_destination import (
|
|||||||
_Clarification,
|
_Clarification,
|
||||||
_find_existing_tvshow_folders,
|
_find_existing_tvshow_folders,
|
||||||
_resolve_series_folder,
|
_resolve_series_folder,
|
||||||
_sanitize,
|
|
||||||
resolve_episode_destination,
|
resolve_episode_destination,
|
||||||
resolve_movie_destination,
|
resolve_movie_destination,
|
||||||
resolve_season_destination,
|
resolve_season_destination,
|
||||||
@@ -51,15 +49,6 @@ REL_SERIES = "Oz.Complete.Series.1080p.WEBRip.x265-KONTRAST"
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
class TestSanitize:
|
|
||||||
def test_passthrough_safe_chars(self):
|
|
||||||
assert _sanitize("Oz.1997.1080p-GRP") == "Oz.1997.1080p-GRP"
|
|
||||||
|
|
||||||
def test_strips_windows_forbidden(self):
|
|
||||||
# ? : * " < > | \
|
|
||||||
assert _sanitize('a?b:c*d"e<f>g|h\\i') == "abcdefghi"
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# _find_existing_tvshow_folders #
|
# _find_existing_tvshow_folders #
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -107,6 +96,7 @@ class TestResolveSeriesFolderInternal:
|
|||||||
out = _resolve_series_folder(
|
out = _resolve_series_folder(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
"Oz",
|
"Oz",
|
||||||
|
"Oz",
|
||||||
1997,
|
1997,
|
||||||
"Oz.1997.WEBRip-KONTRAST",
|
"Oz.1997.WEBRip-KONTRAST",
|
||||||
confirmed_folder="Oz.1997.X-GRP",
|
confirmed_folder="Oz.1997.X-GRP",
|
||||||
@@ -117,6 +107,7 @@ class TestResolveSeriesFolderInternal:
|
|||||||
out = _resolve_series_folder(
|
out = _resolve_series_folder(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
"Oz",
|
"Oz",
|
||||||
|
"Oz",
|
||||||
1997,
|
1997,
|
||||||
"Oz.1997.WEBRip-KONTRAST",
|
"Oz.1997.WEBRip-KONTRAST",
|
||||||
confirmed_folder="Oz.1997.New-X",
|
confirmed_folder="Oz.1997.New-X",
|
||||||
@@ -125,21 +116,21 @@ class TestResolveSeriesFolderInternal:
|
|||||||
|
|
||||||
def test_no_existing_returns_computed_as_new(self, tmp_path):
|
def test_no_existing_returns_computed_as_new(self, tmp_path):
|
||||||
out = _resolve_series_folder(
|
out = _resolve_series_folder(
|
||||||
tmp_path, "Oz", 1997, "Oz.1997.WEBRip-KONTRAST", None
|
tmp_path, "Oz", "Oz", 1997, "Oz.1997.WEBRip-KONTRAST", None
|
||||||
)
|
)
|
||||||
assert out == ("Oz.1997.WEBRip-KONTRAST", True)
|
assert out == ("Oz.1997.WEBRip-KONTRAST", True)
|
||||||
|
|
||||||
def test_single_existing_matching_computed_returns_existing(self, tmp_path):
|
def test_single_existing_matching_computed_returns_existing(self, tmp_path):
|
||||||
(tmp_path / "Oz.1997.WEBRip-KONTRAST").mkdir()
|
(tmp_path / "Oz.1997.WEBRip-KONTRAST").mkdir()
|
||||||
out = _resolve_series_folder(
|
out = _resolve_series_folder(
|
||||||
tmp_path, "Oz", 1997, "Oz.1997.WEBRip-KONTRAST", None
|
tmp_path, "Oz", "Oz", 1997, "Oz.1997.WEBRip-KONTRAST", None
|
||||||
)
|
)
|
||||||
assert out == ("Oz.1997.WEBRip-KONTRAST", False)
|
assert out == ("Oz.1997.WEBRip-KONTRAST", False)
|
||||||
|
|
||||||
def test_single_existing_different_name_returns_clarification(self, tmp_path):
|
def test_single_existing_different_name_returns_clarification(self, tmp_path):
|
||||||
(tmp_path / "Oz.1997.BluRay-OTHER").mkdir()
|
(tmp_path / "Oz.1997.BluRay-OTHER").mkdir()
|
||||||
out = _resolve_series_folder(
|
out = _resolve_series_folder(
|
||||||
tmp_path, "Oz", 1997, "Oz.1997.WEBRip-KONTRAST", None
|
tmp_path, "Oz", "Oz", 1997, "Oz.1997.WEBRip-KONTRAST", None
|
||||||
)
|
)
|
||||||
assert isinstance(out, _Clarification)
|
assert isinstance(out, _Clarification)
|
||||||
assert "Oz" in out.question
|
assert "Oz" in out.question
|
||||||
@@ -149,7 +140,7 @@ class TestResolveSeriesFolderInternal:
|
|||||||
def test_multiple_existing_returns_clarification(self, tmp_path):
|
def test_multiple_existing_returns_clarification(self, tmp_path):
|
||||||
(tmp_path / "Oz.1997.A-GRP").mkdir()
|
(tmp_path / "Oz.1997.A-GRP").mkdir()
|
||||||
(tmp_path / "Oz.1997.B-GRP").mkdir()
|
(tmp_path / "Oz.1997.B-GRP").mkdir()
|
||||||
out = _resolve_series_folder(tmp_path, "Oz", 1997, "Oz.1997.A-GRP", None)
|
out = _resolve_series_folder(tmp_path, "Oz", "Oz", 1997, "Oz.1997.A-GRP", None)
|
||||||
assert isinstance(out, _Clarification)
|
assert isinstance(out, _Clarification)
|
||||||
# Computed already in existing → not duplicated.
|
# Computed already in existing → not duplicated.
|
||||||
assert out.options.count("Oz.1997.A-GRP") == 1
|
assert out.options.count("Oz.1997.A-GRP") == 1
|
||||||
|
|||||||
@@ -20,13 +20,20 @@ import pytest
|
|||||||
|
|
||||||
from alfred.domain.release.services import parse_release
|
from alfred.domain.release.services import parse_release
|
||||||
from alfred.domain.release.value_objects import ParsedRelease
|
from alfred.domain.release.value_objects import ParsedRelease
|
||||||
|
from alfred.infrastructure.knowledge.release_kb import YamlReleaseKnowledge
|
||||||
|
|
||||||
|
_KB = YamlReleaseKnowledge()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse(name: str) -> ParsedRelease:
|
||||||
|
return parse_release(name, _KB)
|
||||||
|
|
||||||
|
|
||||||
class TestParseTVEpisode:
|
class TestParseTVEpisode:
|
||||||
"""Single-episode TV releases."""
|
"""Single-episode TV releases."""
|
||||||
|
|
||||||
def test_basic_tv_episode(self):
|
def test_basic_tv_episode(self):
|
||||||
r = parse_release("Oz.S03E01.1080p.WEBRip.x265-KONTRAST")
|
r = _parse("Oz.S03E01.1080p.WEBRip.x265-KONTRAST")
|
||||||
assert r.title == "Oz"
|
assert r.title == "Oz"
|
||||||
assert r.season == 3
|
assert r.season == 3
|
||||||
assert r.episode == 1
|
assert r.episode == 1
|
||||||
@@ -40,27 +47,27 @@ class TestParseTVEpisode:
|
|||||||
assert r.is_season_pack is False
|
assert r.is_season_pack is False
|
||||||
|
|
||||||
def test_multi_episode(self):
|
def test_multi_episode(self):
|
||||||
r = parse_release("Archer.S14E09E10.1080p.WEB.x265-GRP")
|
r = _parse("Archer.S14E09E10.1080p.WEB.x265-GRP")
|
||||||
assert r.season == 14
|
assert r.season == 14
|
||||||
assert r.episode == 9
|
assert r.episode == 9
|
||||||
assert r.episode_end == 10
|
assert r.episode_end == 10
|
||||||
|
|
||||||
def test_nxnn_alt_form(self):
|
def test_nxnn_alt_form(self):
|
||||||
# Alt season/episode form: 1x05 instead of S01E05.
|
# Alt season/episode form: 1x05 instead of S01E05.
|
||||||
r = parse_release("Some.Show.1x05.720p.HDTV.x264-GRP")
|
r = _parse("Some.Show.1x05.720p.HDTV.x264-GRP")
|
||||||
assert r.season == 1
|
assert r.season == 1
|
||||||
assert r.episode == 5
|
assert r.episode == 5
|
||||||
assert r.episode_end is None
|
assert r.episode_end is None
|
||||||
assert r.media_type == "tv_show"
|
assert r.media_type == "tv_show"
|
||||||
|
|
||||||
def test_nxnnxnn_multi_episode_alt_form(self):
|
def test_nxnnxnn_multi_episode_alt_form(self):
|
||||||
r = parse_release("Some.Show.2x07x08.1080p.WEB.x265-GRP")
|
r = _parse("Some.Show.2x07x08.1080p.WEB.x265-GRP")
|
||||||
assert r.season == 2
|
assert r.season == 2
|
||||||
assert r.episode == 7
|
assert r.episode == 7
|
||||||
assert r.episode_end == 8
|
assert r.episode_end == 8
|
||||||
|
|
||||||
def test_season_pack(self):
|
def test_season_pack(self):
|
||||||
r = parse_release("Oz.S03.1080p.WEBRip.x265-KONTRAST")
|
r = _parse("Oz.S03.1080p.WEBRip.x265-KONTRAST")
|
||||||
assert r.season == 3
|
assert r.season == 3
|
||||||
assert r.episode is None
|
assert r.episode is None
|
||||||
assert r.is_season_pack is True
|
assert r.is_season_pack is True
|
||||||
@@ -71,7 +78,7 @@ class TestParseMovie:
|
|||||||
"""Movie releases."""
|
"""Movie releases."""
|
||||||
|
|
||||||
def test_basic_movie(self):
|
def test_basic_movie(self):
|
||||||
r = parse_release("Inception.2010.1080p.BluRay.x264-GROUP")
|
r = _parse("Inception.2010.1080p.BluRay.x264-GROUP")
|
||||||
assert r.title == "Inception"
|
assert r.title == "Inception"
|
||||||
assert r.year == 2010
|
assert r.year == 2010
|
||||||
assert r.season is None
|
assert r.season is None
|
||||||
@@ -83,13 +90,13 @@ class TestParseMovie:
|
|||||||
assert r.media_type == "movie"
|
assert r.media_type == "movie"
|
||||||
|
|
||||||
def test_movie_multi_word_title(self):
|
def test_movie_multi_word_title(self):
|
||||||
r = parse_release("The.Dark.Knight.2008.2160p.UHD.BluRay.x265-TERMINAL")
|
r = _parse("The.Dark.Knight.2008.2160p.UHD.BluRay.x265-TERMINAL")
|
||||||
assert r.title == "The.Dark.Knight"
|
assert r.title == "The.Dark.Knight"
|
||||||
assert r.year == 2008
|
assert r.year == 2008
|
||||||
assert r.quality == "2160p"
|
assert r.quality == "2160p"
|
||||||
|
|
||||||
def test_movie_without_year_still_movie_if_tech_present(self):
|
def test_movie_without_year_still_movie_if_tech_present(self):
|
||||||
r = parse_release("UntitledFilm.1080p.WEBRip.x264-GRP")
|
r = _parse("UntitledFilm.1080p.WEBRip.x264-GRP")
|
||||||
# No season, no year, but tech markers → still movie
|
# No season, no year, but tech markers → still movie
|
||||||
assert r.media_type == "movie"
|
assert r.media_type == "movie"
|
||||||
assert r.year is None
|
assert r.year is None
|
||||||
@@ -99,39 +106,39 @@ class TestParseEdgeCases:
|
|||||||
"""Site tags, malformed names, and unknown media types."""
|
"""Site tags, malformed names, and unknown media types."""
|
||||||
|
|
||||||
def test_site_tag_prefix_stripped(self):
|
def test_site_tag_prefix_stripped(self):
|
||||||
r = parse_release("[ OxTorrent.vc ] The.Title.S01E01.1080p.WEB.x265-GRP")
|
r = _parse("[ OxTorrent.vc ] The.Title.S01E01.1080p.WEB.x265-GRP")
|
||||||
assert r.site_tag == "OxTorrent.vc"
|
assert r.site_tag == "OxTorrent.vc"
|
||||||
assert r.parse_path == "sanitized"
|
assert r.parse_path == "sanitized"
|
||||||
assert r.season == 1
|
assert r.season == 1
|
||||||
assert r.episode == 1
|
assert r.episode == 1
|
||||||
|
|
||||||
def test_site_tag_suffix_stripped(self):
|
def test_site_tag_suffix_stripped(self):
|
||||||
r = parse_release("The.Title.S01E01.1080p.WEB.x265-NTb[TGx]")
|
r = _parse("The.Title.S01E01.1080p.WEB.x265-NTb[TGx]")
|
||||||
assert r.site_tag == "TGx"
|
assert r.site_tag == "TGx"
|
||||||
# Suffix-tagged names are well-formed (only [] in tag → after strip clean)
|
# Suffix-tagged names are well-formed (only [] in tag → after strip clean)
|
||||||
assert r.season == 1
|
assert r.season == 1
|
||||||
|
|
||||||
def test_irrecoverably_malformed(self):
|
def test_irrecoverably_malformed(self):
|
||||||
# @ is a forbidden char and not stripped by _sanitize → stays malformed
|
# @ is a forbidden char and not stripped by _sanitize → stays malformed
|
||||||
r = parse_release("foo@bar@baz")
|
r = _parse("foo@bar@baz")
|
||||||
assert r.media_type == "unknown"
|
assert r.media_type == "unknown"
|
||||||
assert r.parse_path == "ai"
|
assert r.parse_path == "ai"
|
||||||
assert r.group == "UNKNOWN"
|
assert r.group == "UNKNOWN"
|
||||||
|
|
||||||
def test_empty_unknown_when_no_evidence(self):
|
def test_empty_unknown_when_no_evidence(self):
|
||||||
r = parse_release("Some.Random.Title")
|
r = _parse("Some.Random.Title")
|
||||||
# No season, no year, no tech markers → unknown
|
# No season, no year, no tech markers → unknown
|
||||||
assert r.media_type == "unknown"
|
assert r.media_type == "unknown"
|
||||||
|
|
||||||
def test_missing_group_defaults_to_unknown(self):
|
def test_missing_group_defaults_to_unknown(self):
|
||||||
r = parse_release("Movie.2020.1080p.WEBRip.x265")
|
r = _parse("Movie.2020.1080p.WEBRip.x265")
|
||||||
# No "-GROUP" suffix → group = "UNKNOWN"
|
# No "-GROUP" suffix → group = "UNKNOWN"
|
||||||
assert r.group == "UNKNOWN"
|
assert r.group == "UNKNOWN"
|
||||||
|
|
||||||
def test_yts_bracket_release(self):
|
def test_yts_bracket_release(self):
|
||||||
# YTS-style: spaces, parens for year, multiple bracketed tech tokens.
|
# YTS-style: spaces, parens for year, multiple bracketed tech tokens.
|
||||||
# The tokenizer must handle ' ', '(', ')', '[', ']' transparently.
|
# The tokenizer must handle ' ', '(', ')', '[', ']' transparently.
|
||||||
r = parse_release("The Father (2020) [1080p] [WEBRip] [5.1] [YTS.MX]")
|
r = _parse("The Father (2020) [1080p] [WEBRip] [5.1] [YTS.MX]")
|
||||||
assert r.title == "The.Father"
|
assert r.title == "The.Father"
|
||||||
assert r.year == 2020
|
assert r.year == 2020
|
||||||
assert r.quality == "1080p"
|
assert r.quality == "1080p"
|
||||||
@@ -141,7 +148,7 @@ class TestParseEdgeCases:
|
|||||||
|
|
||||||
def test_human_friendly_spaces(self):
|
def test_human_friendly_spaces(self):
|
||||||
# Spaces as separators (no brackets).
|
# Spaces as separators (no brackets).
|
||||||
r = parse_release("Inception 2010 1080p BluRay x264-GROUP")
|
r = _parse("Inception 2010 1080p BluRay x264-GROUP")
|
||||||
assert r.title == "Inception"
|
assert r.title == "Inception"
|
||||||
assert r.year == 2010
|
assert r.year == 2010
|
||||||
assert r.quality == "1080p"
|
assert r.quality == "1080p"
|
||||||
@@ -151,7 +158,7 @@ class TestParseEdgeCases:
|
|||||||
|
|
||||||
def test_underscore_separators(self):
|
def test_underscore_separators(self):
|
||||||
# Old usenet style: underscores between tokens.
|
# Old usenet style: underscores between tokens.
|
||||||
r = parse_release("Some_Show_S01E01_1080p_WEB_x265-GRP")
|
r = _parse("Some_Show_S01E01_1080p_WEB_x265-GRP")
|
||||||
assert r.season == 1
|
assert r.season == 1
|
||||||
assert r.episode == 1
|
assert r.episode == 1
|
||||||
assert r.quality == "1080p"
|
assert r.quality == "1080p"
|
||||||
@@ -162,15 +169,15 @@ class TestParseAudioVideoEdition:
|
|||||||
"""Audio, video metadata, edition extraction."""
|
"""Audio, video metadata, edition extraction."""
|
||||||
|
|
||||||
def test_audio_codec_and_channels(self):
|
def test_audio_codec_and_channels(self):
|
||||||
r = parse_release("Movie.2020.1080p.BluRay.DTS.5.1.x264-GRP")
|
r = _parse("Movie.2020.1080p.BluRay.DTS.5.1.x264-GRP")
|
||||||
assert r.audio_channels == "5.1"
|
assert r.audio_channels == "5.1"
|
||||||
|
|
||||||
def test_language_token(self):
|
def test_language_token(self):
|
||||||
r = parse_release("Movie.2020.MULTI.1080p.WEBRip.x265-GRP")
|
r = _parse("Movie.2020.MULTI.1080p.WEBRip.x265-GRP")
|
||||||
assert "MULTI" in r.languages
|
assert "MULTI" in r.languages
|
||||||
|
|
||||||
def test_edition_token(self):
|
def test_edition_token(self):
|
||||||
r = parse_release("Movie.2020.UNRATED.1080p.BluRay.x264-GRP")
|
r = _parse("Movie.2020.UNRATED.1080p.BluRay.x264-GRP")
|
||||||
assert r.edition == "UNRATED"
|
assert r.edition == "UNRATED"
|
||||||
|
|
||||||
|
|
||||||
@@ -178,19 +185,21 @@ class TestParsedReleaseFolderNames:
|
|||||||
"""Helpers that build filesystem-safe folder/filenames."""
|
"""Helpers that build filesystem-safe folder/filenames."""
|
||||||
|
|
||||||
def _parsed_tv(self) -> ParsedRelease:
|
def _parsed_tv(self) -> ParsedRelease:
|
||||||
return parse_release("Oz.S03E01.1080p.WEBRip.x265-KONTRAST")
|
return _parse("Oz.S03E01.1080p.WEBRip.x265-KONTRAST")
|
||||||
|
|
||||||
def _parsed_movie(self) -> ParsedRelease:
|
def _parsed_movie(self) -> ParsedRelease:
|
||||||
return parse_release("Inception.2010.1080p.BluRay.x264-GROUP")
|
return _parse("Inception.2010.1080p.BluRay.x264-GROUP")
|
||||||
|
|
||||||
def test_show_folder_name(self):
|
def test_show_folder_name(self):
|
||||||
r = self._parsed_tv()
|
r = self._parsed_tv()
|
||||||
assert r.show_folder_name("Oz", 1997) == "Oz.1997.1080p.WEBRip.x265-KONTRAST"
|
assert r.show_folder_name("Oz", 1997) == "Oz.1997.1080p.WEBRip.x265-KONTRAST"
|
||||||
|
|
||||||
def test_show_folder_name_strips_windows_chars(self):
|
def test_show_folder_name_uses_already_safe_title(self):
|
||||||
|
# Option B: callers sanitize at the use-case boundary via
|
||||||
|
# kb.sanitize_for_fs(...) before passing the title in.
|
||||||
r = self._parsed_tv()
|
r = self._parsed_tv()
|
||||||
# Colons and question marks are Windows-forbidden — must be stripped.
|
safe = _KB.sanitize_for_fs("Oz: The Series?")
|
||||||
result = r.show_folder_name("Oz: The Series?", 1997)
|
result = r.show_folder_name(safe, 1997)
|
||||||
assert ":" not in result
|
assert ":" not in result
|
||||||
assert "?" not in result
|
assert "?" not in result
|
||||||
|
|
||||||
@@ -202,7 +211,7 @@ class TestParsedReleaseFolderNames:
|
|||||||
assert "E01" not in result
|
assert "E01" not in result
|
||||||
|
|
||||||
def test_season_folder_name_multi_episode(self):
|
def test_season_folder_name_multi_episode(self):
|
||||||
r = parse_release("Archer.S14E09E10E11.1080p.WEB.x265-GRP")
|
r = _parse("Archer.S14E09E10E11.1080p.WEB.x265-GRP")
|
||||||
result = r.season_folder_name()
|
result = r.season_folder_name()
|
||||||
assert "S14" in result
|
assert "S14" in result
|
||||||
assert "E09" not in result
|
assert "E09" not in result
|
||||||
@@ -251,21 +260,21 @@ class TestParsedReleaseInvariants:
|
|||||||
|
|
||||||
def test_raw_is_preserved(self):
|
def test_raw_is_preserved(self):
|
||||||
raw = "Oz.S03E01.1080p.WEBRip.x265-KONTRAST"
|
raw = "Oz.S03E01.1080p.WEBRip.x265-KONTRAST"
|
||||||
r = parse_release(raw)
|
r = _parse(raw)
|
||||||
assert r.raw == raw
|
assert r.raw == raw
|
||||||
|
|
||||||
def test_languages_defaults_to_empty_list_not_none(self):
|
def test_languages_defaults_to_empty_list_not_none(self):
|
||||||
r = parse_release("Movie.2020.1080p.BluRay.x264-GRP")
|
r = _parse("Movie.2020.1080p.BluRay.x264-GRP")
|
||||||
# __post_init__ ensures languages is a list, never None
|
# __post_init__ ensures languages is a list, never None
|
||||||
assert r.languages == []
|
assert r.languages == []
|
||||||
|
|
||||||
def test_tech_string_joined(self):
|
def test_tech_string_joined(self):
|
||||||
r = parse_release("Movie.2020.1080p.BluRay.x264-GRP")
|
r = _parse("Movie.2020.1080p.BluRay.x264-GRP")
|
||||||
assert r.tech_string == "1080p.BluRay.x264"
|
assert r.tech_string == "1080p.BluRay.x264"
|
||||||
|
|
||||||
def test_tech_string_partial(self):
|
def test_tech_string_partial(self):
|
||||||
# Codec-only release (no quality/source): tech_string == codec
|
# Codec-only release (no quality/source): tech_string == codec
|
||||||
r = parse_release("Show.S01E01.x265-GRP")
|
r = _parse("Show.S01E01.x265-GRP")
|
||||||
assert r.tech_string == "x265"
|
assert r.tech_string == "x265"
|
||||||
assert r.codec == "x265"
|
assert r.codec == "x265"
|
||||||
assert r.quality is None
|
assert r.quality is None
|
||||||
@@ -280,4 +289,4 @@ class TestParsedReleaseInvariants:
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_media_type_inference(self, name, expected_type):
|
def test_media_type_inference(self, name, expected_type):
|
||||||
assert parse_release(name).media_type == expected_type
|
assert _parse(name).media_type == expected_type
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ from dataclasses import asdict
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from alfred.domain.release.services import parse_release
|
from alfred.domain.release.services import parse_release
|
||||||
|
from alfred.infrastructure.knowledge.release_kb import YamlReleaseKnowledge
|
||||||
from tests.fixtures.releases.conftest import ReleaseFixture, discover_fixtures
|
from tests.fixtures.releases.conftest import ReleaseFixture, discover_fixtures
|
||||||
|
|
||||||
|
_KB = YamlReleaseKnowledge()
|
||||||
FIXTURES = discover_fixtures()
|
FIXTURES = discover_fixtures()
|
||||||
|
|
||||||
|
|
||||||
@@ -34,9 +36,9 @@ def test_parse_matches_fixture(fixture: ReleaseFixture, tmp_path) -> None:
|
|||||||
# plausible filesystem paths. Catches typos / missing leading dirs early.
|
# plausible filesystem paths. Catches typos / missing leading dirs early.
|
||||||
fixture.materialize(tmp_path)
|
fixture.materialize(tmp_path)
|
||||||
|
|
||||||
result = asdict(parse_release(fixture.release_name))
|
result = asdict(parse_release(fixture.release_name, _KB))
|
||||||
# ``is_season_pack`` is a @property — asdict() does not include it.
|
# ``is_season_pack`` is a @property — asdict() does not include it.
|
||||||
result["is_season_pack"] = parse_release(fixture.release_name).is_season_pack
|
result["is_season_pack"] = parse_release(fixture.release_name, _KB).is_season_pack
|
||||||
|
|
||||||
for field, expected in fixture.expected_parsed.items():
|
for field, expected in fixture.expected_parsed.items():
|
||||||
assert field in result, (
|
assert field in result, (
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ from alfred.infrastructure.filesystem.filesystem_operations import (
|
|||||||
)
|
)
|
||||||
from alfred.infrastructure.filesystem.find_video import find_video_file
|
from alfred.infrastructure.filesystem.find_video import find_video_file
|
||||||
from alfred.infrastructure.filesystem.organizer import MediaOrganizer
|
from alfred.infrastructure.filesystem.organizer import MediaOrganizer
|
||||||
|
from alfred.infrastructure.knowledge.release_kb import YamlReleaseKnowledge
|
||||||
|
|
||||||
|
_KB = YamlReleaseKnowledge()
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# ffprobe.probe #
|
# ffprobe.probe #
|
||||||
@@ -263,35 +266,35 @@ class TestFindVideo:
|
|||||||
def test_returns_file_directly_when_video(self, tmp_path):
|
def test_returns_file_directly_when_video(self, tmp_path):
|
||||||
f = tmp_path / "Movie.mkv"
|
f = tmp_path / "Movie.mkv"
|
||||||
f.write_bytes(b"")
|
f.write_bytes(b"")
|
||||||
assert find_video_file(f) == f
|
assert find_video_file(f, _KB) == f
|
||||||
|
|
||||||
def test_returns_none_when_file_is_not_video(self, tmp_path):
|
def test_returns_none_when_file_is_not_video(self, tmp_path):
|
||||||
f = tmp_path / "notes.txt"
|
f = tmp_path / "notes.txt"
|
||||||
f.write_text("x")
|
f.write_text("x")
|
||||||
assert find_video_file(f) is None
|
assert find_video_file(f, _KB) is None
|
||||||
|
|
||||||
def test_returns_none_when_folder_has_no_video(self, tmp_path):
|
def test_returns_none_when_folder_has_no_video(self, tmp_path):
|
||||||
(tmp_path / "a.txt").write_text("x")
|
(tmp_path / "a.txt").write_text("x")
|
||||||
assert find_video_file(tmp_path) is None
|
assert find_video_file(tmp_path, _KB) is None
|
||||||
|
|
||||||
def test_returns_first_sorted_video(self, tmp_path):
|
def test_returns_first_sorted_video(self, tmp_path):
|
||||||
(tmp_path / "B.mkv").write_bytes(b"")
|
(tmp_path / "B.mkv").write_bytes(b"")
|
||||||
(tmp_path / "A.mkv").write_bytes(b"")
|
(tmp_path / "A.mkv").write_bytes(b"")
|
||||||
(tmp_path / "C.mkv").write_bytes(b"")
|
(tmp_path / "C.mkv").write_bytes(b"")
|
||||||
found = find_video_file(tmp_path)
|
found = find_video_file(tmp_path, _KB)
|
||||||
assert found.name == "A.mkv"
|
assert found.name == "A.mkv"
|
||||||
|
|
||||||
def test_recurses_into_subfolders(self, tmp_path):
|
def test_recurses_into_subfolders(self, tmp_path):
|
||||||
sub = tmp_path / "sub"
|
sub = tmp_path / "sub"
|
||||||
sub.mkdir()
|
sub.mkdir()
|
||||||
(sub / "X.mkv").write_bytes(b"")
|
(sub / "X.mkv").write_bytes(b"")
|
||||||
found = find_video_file(tmp_path)
|
found = find_video_file(tmp_path, _KB)
|
||||||
assert found is not None and found.name == "X.mkv"
|
assert found is not None and found.name == "X.mkv"
|
||||||
|
|
||||||
def test_case_insensitive_extension(self, tmp_path):
|
def test_case_insensitive_extension(self, tmp_path):
|
||||||
f = tmp_path / "Movie.MKV"
|
f = tmp_path / "Movie.MKV"
|
||||||
f.write_bytes(b"")
|
f.write_bytes(b"")
|
||||||
assert find_video_file(f) == f
|
assert find_video_file(f, _KB) == f
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
Reference in New Issue
Block a user