e45465d52d
Destination resolution
- Replace the single ResolveDestinationUseCase with four dedicated
functions, one per release type:
resolve_season_destination (pack season, folder move)
resolve_episode_destination (single episode, file move)
resolve_movie_destination (movie, file move)
resolve_series_destination (multi-season pack, folder move)
- Each returns a dedicated DTO carrying only the fields relevant to
that release type — no more polymorphic ResolvedDestination with
half the fields unused depending on the case.
- Looser series folder matching: exact computed-name match is reused
silently; any deviation (different group, multiple candidates) now
prompts the user with all options including the computed name.
Agent tools
- Four new tools wrapping the use cases above; old resolve_destination
removed from the registry.
- New move_to_destination tool: create_folder + move, chained — used
after a resolve_* call to perform the actual relocation.
- Low-level filesystem_operations module (create_folder, move via mv)
for instant same-FS renames (ZFS).
Prompt & persona
- New PromptBuilder (alfred/agent/prompt.py) replacing prompts.py:
identity + personality block, situational expressions, memory
schema, episodic/STM/config context, tool catalogue.
- Per-user expression system: knowledge/users/common.yaml +
{username}.yaml are merged at runtime; one phrase per situation
(greeting/success/error/...) is sampled into the system prompt.
qBittorrent integration
- Credentials now come from settings (qbittorrent_url/username/password)
instead of hardcoded defaults.
- New client methods: find_by_name, set_location, recheck — the trio
needed to update a torrent's save path and re-verify after a move.
- Host→container path translation settings (qbittorrent_host_path /
qbittorrent_container_path) for docker-mounted setups.
Subtitles
- Identifier: strip parenthesized qualifiers (simplified, brazil…) at
tokenization; new _tokenize_suffix used for the episode_subfolder
pattern so episode-stem tokens no longer pollute language detection.
- Placer: extract _build_dest_name so it can be reused by the new
dry_run path in ManageSubtitlesUseCase.
- Knowledge: add yue, ell, ind, msa, rus, vie, heb, tam, tel, tha,
hin, ukr; add 'fre' to fra; add 'simplified'/'traditional' to zho.
Misc
- LTM workspace: add 'trash' folder slot.
- Default LLM provider switched to deepseek.
- testing/debug_release.py: CLI to parse a release, hit TMDB, and
dry-run the destination resolution end-to-end.
323 lines
13 KiB
Python
323 lines
13 KiB
Python
"""
|
|
Tests for alfred.application.filesystem.resolve_destination
|
|
|
|
Uses a real temp filesystem + a real Memory instance (via conftest fixtures).
|
|
No network calls — TMDB data is passed in directly.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
|
|
from alfred.application.filesystem.resolve_destination import (
|
|
ResolveDestinationUseCase,
|
|
_find_existing_series_folders,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _use_case():
|
|
return ResolveDestinationUseCase()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Movies
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestResolveMovie:
|
|
def test_basic_movie(self, memory_configured):
|
|
result = _use_case().execute(
|
|
release_name="Another.Round.2020.1080p.BluRay.x264-YTS",
|
|
source_file="/downloads/Another.Round.2020.1080p.BluRay.x264-YTS/Another.Round.2020.1080p.BluRay.x264-YTS.mp4",
|
|
tmdb_title="Another Round",
|
|
tmdb_year=2020,
|
|
)
|
|
assert result.status == "ok"
|
|
assert "Another.Round.2020" in result.series_folder_name
|
|
assert "1080p.BluRay.x264-YTS" in result.series_folder_name
|
|
assert result.filename.endswith(".mp4")
|
|
assert result.season_folder is None
|
|
|
|
def test_movie_library_file_path_is_inside_series_folder(self, memory_configured):
|
|
result = _use_case().execute(
|
|
release_name="Revolver.2005.1080p.BluRay.x265-RARBG",
|
|
source_file="/downloads/Revolver.2005.1080p.BluRay.x265-RARBG.mkv",
|
|
tmdb_title="Revolver",
|
|
tmdb_year=2005,
|
|
)
|
|
assert result.status == "ok"
|
|
assert result.library_file.startswith(result.series_folder)
|
|
|
|
def test_movie_library_not_set(self, memory):
|
|
# memory has no library paths configured
|
|
result = _use_case().execute(
|
|
release_name="Revolver.2005.1080p.BluRay.x265-RARBG",
|
|
source_file="/downloads/Revolver.2005.1080p.BluRay.x265-RARBG.mkv",
|
|
tmdb_title="Revolver",
|
|
tmdb_year=2005,
|
|
)
|
|
assert result.status == "error"
|
|
assert result.error == "library_not_set"
|
|
|
|
def test_movie_folder_marked_new(self, memory_configured):
|
|
# No existing folder → is_new_series_folder = True
|
|
result = _use_case().execute(
|
|
release_name="Godzilla.Minus.One.2023.1080p.BluRay.x265-YTS",
|
|
source_file="/downloads/Godzilla.Minus.One.2023.1080p.BluRay.x265-YTS.mp4",
|
|
tmdb_title="Godzilla Minus One",
|
|
tmdb_year=2023,
|
|
)
|
|
assert result.status == "ok"
|
|
assert result.is_new_series_folder is True
|
|
|
|
def test_movie_sanitises_forbidden_chars_in_title(self, memory_configured):
|
|
result = _use_case().execute(
|
|
release_name="Alien.Earth.2024.1080p.WEBRip.x265-KONTRAST",
|
|
source_file="/downloads/Alien.Earth.2024.1080p.WEBRip.x265-KONTRAST.mkv",
|
|
tmdb_title="Alien: Earth",
|
|
tmdb_year=2024,
|
|
)
|
|
assert result.status == "ok"
|
|
assert ":" not in result.series_folder_name
|
|
|
|
def test_to_dict_ok(self, memory_configured):
|
|
result = _use_case().execute(
|
|
release_name="Revolver.2005.1080p.BluRay.x265-RARBG",
|
|
source_file="/downloads/Revolver.mkv",
|
|
tmdb_title="Revolver",
|
|
tmdb_year=2005,
|
|
)
|
|
d = result.to_dict()
|
|
assert d["status"] == "ok"
|
|
assert "library_file" in d
|
|
assert "series_folder_name" in d
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TV shows — no existing folder
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestResolveTVShowNewFolder:
|
|
def test_oz_s01_creates_new_folder(self, memory_configured):
|
|
result = _use_case().execute(
|
|
release_name="Oz.S01.1080p.WEBRip.x265-KONTRAST",
|
|
source_file="/downloads/Oz.S01.1080p.WEBRip.x265-KONTRAST/Oz.S01E01.1080p.WEBRip.x265-KONTRAST.mp4",
|
|
tmdb_title="Oz",
|
|
tmdb_year=1997,
|
|
)
|
|
assert result.status == "ok"
|
|
assert result.is_new_series_folder is True
|
|
assert result.series_folder_name == "Oz.1997.1080p.WEBRip.x265-KONTRAST"
|
|
assert result.season_folder_name == "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
|
|
|
def test_tv_library_not_set(self, memory):
|
|
result = _use_case().execute(
|
|
release_name="Oz.S01.1080p.WEBRip.x265-KONTRAST",
|
|
source_file="/downloads/Oz.S01E01.mp4",
|
|
tmdb_title="Oz",
|
|
tmdb_year=1997,
|
|
)
|
|
assert result.status == "error"
|
|
assert result.error == "library_not_set"
|
|
|
|
def test_single_episode_filename(self, memory_configured):
|
|
result = _use_case().execute(
|
|
release_name="Fallout.2024.S02E01.1080p.x265-ELiTE",
|
|
source_file="/downloads/Fallout.2024.S02E01.1080p.x265-ELiTE.mkv",
|
|
tmdb_title="Fallout",
|
|
tmdb_year=2024,
|
|
tmdb_episode_title="The Beginning",
|
|
)
|
|
assert result.status == "ok"
|
|
assert "S02E01" in result.filename
|
|
assert "The.Beginning" in result.filename
|
|
assert result.filename.endswith(".mkv")
|
|
|
|
def test_season_pack_filename_is_folder_name_plus_ext(self, memory_configured):
|
|
result = _use_case().execute(
|
|
release_name="Oz.S01.1080p.WEBRip.x265-KONTRAST",
|
|
source_file="/downloads/Oz.S01.1080p.WEBRip.x265-KONTRAST/Oz.S01E01.mp4",
|
|
tmdb_title="Oz",
|
|
tmdb_year=1997,
|
|
)
|
|
assert result.status == "ok"
|
|
# Season pack: filename = season_folder_name + ext
|
|
assert result.filename == result.season_folder_name + ".mp4"
|
|
|
|
def test_library_file_is_inside_season_folder(self, memory_configured):
|
|
result = _use_case().execute(
|
|
release_name="Oz.S01.1080p.WEBRip.x265-KONTRAST",
|
|
source_file="/downloads/Oz.S01E01.mp4",
|
|
tmdb_title="Oz",
|
|
tmdb_year=1997,
|
|
)
|
|
assert result.library_file.startswith(result.season_folder)
|
|
assert result.season_folder.startswith(result.series_folder)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TV shows — existing folder matching
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestResolveTVShowExistingFolder:
|
|
def _make_series_folder(self, tv_root, name):
|
|
"""Create a series folder in the tv library."""
|
|
path = tv_root / name
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
return path
|
|
|
|
def test_uses_existing_single_folder(self, memory_configured, app_temp):
|
|
"""When exactly one folder matches title+year, use it regardless of group."""
|
|
from alfred.infrastructure.persistence import get_memory
|
|
|
|
mem = get_memory()
|
|
tv_root = Path(mem.ltm.library_paths.get("tv_show"))
|
|
|
|
existing = tv_root / "Oz.1997.1080p.WEBRip.x265-RARBG"
|
|
existing.mkdir(parents=True, exist_ok=True)
|
|
|
|
result = _use_case().execute(
|
|
release_name="Oz.S02.1080p.WEBRip.x265-KONTRAST",
|
|
source_file="/downloads/Oz.S02E01.mp4",
|
|
tmdb_title="Oz",
|
|
tmdb_year=1997,
|
|
)
|
|
assert result.status == "ok"
|
|
assert result.series_folder_name == "Oz.1997.1080p.WEBRip.x265-RARBG"
|
|
assert result.is_new_series_folder is False
|
|
|
|
def test_needs_clarification_on_multiple_folders(self, memory_configured, app_temp):
|
|
"""When multiple folders match, return needs_clarification with options."""
|
|
from alfred.infrastructure.persistence import get_memory
|
|
|
|
mem = get_memory()
|
|
tv_root = Path(mem.ltm.library_paths.get("tv_show"))
|
|
|
|
(tv_root / "Slow.Horses.2022.1080p.WEBRip.x265-RARBG").mkdir(
|
|
parents=True, exist_ok=True
|
|
)
|
|
(tv_root / "Slow.Horses.2022.1080p.WEBRip.x265-KONTRAST").mkdir(
|
|
parents=True, exist_ok=True
|
|
)
|
|
|
|
result = _use_case().execute(
|
|
release_name="Slow.Horses.S05.1080p.WEBRip.x265-KONTRAST",
|
|
source_file="/downloads/Slow.Horses.S05E01.mkv",
|
|
tmdb_title="Slow Horses",
|
|
tmdb_year=2022,
|
|
)
|
|
assert result.status == "needs_clarification"
|
|
assert result.question is not None
|
|
assert len(result.options) == 2
|
|
assert "Slow.Horses.2022.1080p.WEBRip.x265-RARBG" in result.options
|
|
assert "Slow.Horses.2022.1080p.WEBRip.x265-KONTRAST" in result.options
|
|
|
|
def test_confirmed_folder_bypasses_detection(self, memory_configured, app_temp):
|
|
"""confirmed_folder skips the folder search."""
|
|
from alfred.infrastructure.persistence import get_memory
|
|
|
|
mem = get_memory()
|
|
tv_root = Path(mem.ltm.library_paths.get("tv_show"))
|
|
chosen = "Slow.Horses.2022.1080p.WEBRip.x265-RARBG"
|
|
(tv_root / chosen).mkdir(parents=True, exist_ok=True)
|
|
|
|
result = _use_case().execute(
|
|
release_name="Slow.Horses.S05.1080p.WEBRip.x265-KONTRAST",
|
|
source_file="/downloads/Slow.Horses.S05E01.mkv",
|
|
tmdb_title="Slow Horses",
|
|
tmdb_year=2022,
|
|
confirmed_folder=chosen,
|
|
)
|
|
assert result.status == "ok"
|
|
assert result.series_folder_name == chosen
|
|
|
|
def test_to_dict_needs_clarification(self, memory_configured, app_temp):
|
|
from alfred.infrastructure.persistence import get_memory
|
|
|
|
mem = get_memory()
|
|
tv_root = Path(mem.ltm.library_paths.get("tv_show"))
|
|
(tv_root / "Oz.1997.1080p.WEBRip.x265-RARBG").mkdir(parents=True, exist_ok=True)
|
|
(tv_root / "Oz.1997.1080p.WEBRip.x265-KONTRAST").mkdir(
|
|
parents=True, exist_ok=True
|
|
)
|
|
|
|
result = _use_case().execute(
|
|
release_name="Oz.S03.1080p.WEBRip.x265-KONTRAST",
|
|
source_file="/downloads/Oz.S03E01.mp4",
|
|
tmdb_title="Oz",
|
|
tmdb_year=1997,
|
|
)
|
|
d = result.to_dict()
|
|
assert d["status"] == "needs_clarification"
|
|
assert "question" in d
|
|
assert isinstance(d["options"], list)
|
|
|
|
def test_to_dict_error(self, memory):
|
|
result = _use_case().execute(
|
|
release_name="Oz.S01.1080p.WEBRip.x265-KONTRAST",
|
|
source_file="/downloads/Oz.S01E01.mp4",
|
|
tmdb_title="Oz",
|
|
tmdb_year=1997,
|
|
)
|
|
d = result.to_dict()
|
|
assert d["status"] == "error"
|
|
assert "error" in d
|
|
assert "message" in d
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _find_existing_series_folders
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFindExistingSeriesFolders:
|
|
def test_empty_library(self, tmp_path):
|
|
assert _find_existing_series_folders(tmp_path, "Oz", 1997) == []
|
|
|
|
def test_nonexistent_root(self, tmp_path):
|
|
assert _find_existing_series_folders(tmp_path / "nope", "Oz", 1997) == []
|
|
|
|
def test_single_match(self, tmp_path):
|
|
(tmp_path / "Oz.1997.1080p.WEBRip.x265-KONTRAST").mkdir()
|
|
result = _find_existing_series_folders(tmp_path, "Oz", 1997)
|
|
assert result == ["Oz.1997.1080p.WEBRip.x265-KONTRAST"]
|
|
|
|
def test_multiple_matches(self, tmp_path):
|
|
(tmp_path / "Oz.1997.1080p.WEBRip.x265-KONTRAST").mkdir()
|
|
(tmp_path / "Oz.1997.1080p.WEBRip.x265-RARBG").mkdir()
|
|
result = _find_existing_series_folders(tmp_path, "Oz", 1997)
|
|
assert len(result) == 2
|
|
assert sorted(result) == result # sorted
|
|
|
|
def test_no_match_different_year(self, tmp_path):
|
|
(tmp_path / "Oz.1997.1080p.WEBRip.x265-KONTRAST").mkdir()
|
|
result = _find_existing_series_folders(tmp_path, "Oz", 2000)
|
|
assert result == []
|
|
|
|
def test_no_match_different_title(self, tmp_path):
|
|
(tmp_path / "Oz.1997.1080p.WEBRip.x265-KONTRAST").mkdir()
|
|
result = _find_existing_series_folders(tmp_path, "Breaking Bad", 2008)
|
|
assert result == []
|
|
|
|
def test_ignores_files_not_dirs(self, tmp_path):
|
|
(tmp_path / "Oz.1997.1080p.WEBRip.x265-KONTRAST").mkdir()
|
|
(tmp_path / "Oz.1997.some.file.txt").touch()
|
|
result = _find_existing_series_folders(tmp_path, "Oz", 1997)
|
|
assert len(result) == 1
|
|
|
|
def test_case_insensitive_prefix(self, tmp_path):
|
|
# Folder stored with mixed case
|
|
(tmp_path / "OZ.1997.1080p.WEBRip.x265-KONTRAST").mkdir()
|
|
result = _find_existing_series_folders(tmp_path, "Oz", 1997)
|
|
assert len(result) == 1
|
|
|
|
def test_title_with_special_chars_sanitised(self, tmp_path):
|
|
# "Star Wars: Andor" → sanitised (colon removed) + spaces→dots → "Star.Wars.Andor.2022"
|
|
(tmp_path / "Star.Wars.Andor.2022.1080p.WEBRip.x265-GROUP").mkdir()
|
|
result = _find_existing_series_folders(tmp_path, "Star Wars: Andor", 2022)
|
|
assert len(result) == 1
|