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.
233 lines
7.0 KiB
Python
233 lines
7.0 KiB
Python
"""Tests for TV Show domain — entities and value objects."""
|
|
|
|
import pytest
|
|
|
|
from alfred.domain.shared.exceptions import ValidationError
|
|
from alfred.domain.tv_shows.entities import Episode, Season, TVShow
|
|
from alfred.domain.tv_shows.value_objects import EpisodeNumber, SeasonNumber, ShowStatus
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ShowStatus
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestShowStatus:
|
|
def test_from_string_ongoing(self):
|
|
assert ShowStatus.from_string("ongoing") == ShowStatus.ONGOING
|
|
|
|
def test_from_string_ended(self):
|
|
assert ShowStatus.from_string("ended") == ShowStatus.ENDED
|
|
|
|
def test_from_string_case_insensitive(self):
|
|
assert ShowStatus.from_string("ONGOING") == ShowStatus.ONGOING
|
|
assert ShowStatus.from_string("Ended") == ShowStatus.ENDED
|
|
|
|
def test_from_string_unknown(self):
|
|
assert ShowStatus.from_string("cancelled") == ShowStatus.UNKNOWN
|
|
assert ShowStatus.from_string("") == ShowStatus.UNKNOWN
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SeasonNumber
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSeasonNumber:
|
|
def test_valid_season(self):
|
|
s = SeasonNumber(1)
|
|
assert s.value == 1
|
|
|
|
def test_season_zero_is_specials(self):
|
|
s = SeasonNumber(0)
|
|
assert s.is_special()
|
|
|
|
def test_normal_season_not_special(self):
|
|
assert not SeasonNumber(3).is_special()
|
|
|
|
def test_negative_raises(self):
|
|
with pytest.raises(ValidationError):
|
|
SeasonNumber(-1)
|
|
|
|
def test_too_high_raises(self):
|
|
with pytest.raises(ValidationError):
|
|
SeasonNumber(101)
|
|
|
|
def test_non_integer_raises(self):
|
|
with pytest.raises((ValidationError, TypeError)):
|
|
SeasonNumber("1") # type: ignore
|
|
|
|
def test_str_and_int(self):
|
|
s = SeasonNumber(5)
|
|
assert str(s) == "5"
|
|
assert int(s) == 5
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# EpisodeNumber
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestEpisodeNumber:
|
|
def test_valid_episode(self):
|
|
e = EpisodeNumber(1)
|
|
assert e.value == 1
|
|
|
|
def test_zero_raises(self):
|
|
with pytest.raises(ValidationError):
|
|
EpisodeNumber(0)
|
|
|
|
def test_negative_raises(self):
|
|
with pytest.raises(ValidationError):
|
|
EpisodeNumber(-5)
|
|
|
|
def test_too_high_raises(self):
|
|
with pytest.raises(ValidationError):
|
|
EpisodeNumber(1001)
|
|
|
|
def test_str_and_int(self):
|
|
e = EpisodeNumber(12)
|
|
assert str(e) == "12"
|
|
assert int(e) == 12
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TVShow entity
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestTVShow:
|
|
def _make(
|
|
self, imdb_id="tt0903747", title="Breaking Bad", seasons=5, status="ended"
|
|
):
|
|
return TVShow(
|
|
imdb_id=imdb_id, title=title, seasons_count=seasons, status=status
|
|
)
|
|
|
|
def test_basic_creation(self):
|
|
show = self._make()
|
|
assert show.title == "Breaking Bad"
|
|
assert show.seasons_count == 5
|
|
|
|
def test_coerces_string_imdb_id(self):
|
|
show = self._make()
|
|
from alfred.domain.shared.value_objects import ImdbId
|
|
|
|
assert isinstance(show.imdb_id, ImdbId)
|
|
|
|
def test_coerces_string_status(self):
|
|
show = self._make(status="ongoing")
|
|
assert show.status == ShowStatus.ONGOING
|
|
|
|
def test_is_ongoing(self):
|
|
show = self._make(status="ongoing")
|
|
assert show.is_ongoing()
|
|
assert not show.is_ended()
|
|
|
|
def test_is_ended(self):
|
|
show = self._make(status="ended")
|
|
assert show.is_ended()
|
|
assert not show.is_ongoing()
|
|
|
|
def test_negative_seasons_raises(self):
|
|
with pytest.raises(ValueError):
|
|
TVShow(imdb_id="tt0903747", title="X", seasons_count=-1, status="ended")
|
|
|
|
def test_invalid_imdb_id_type_raises(self):
|
|
with pytest.raises(ValueError):
|
|
TVShow(imdb_id=12345, title="X", seasons_count=1, status="ended") # type: ignore
|
|
|
|
def test_get_folder_name_replaces_spaces(self):
|
|
show = self._make(title="Breaking Bad")
|
|
assert show.get_folder_name() == "Breaking.Bad"
|
|
|
|
def test_get_folder_name_strips_special_chars(self):
|
|
show = self._make(title="It's Always Sunny")
|
|
name = show.get_folder_name()
|
|
assert "'" not in name
|
|
|
|
def test_str_repr(self):
|
|
show = self._make()
|
|
assert "Breaking Bad" in str(show)
|
|
assert "tt0903747" in repr(show)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Season entity
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSeason:
|
|
def test_basic_creation(self):
|
|
s = Season(show_imdb_id="tt0903747", season_number=1, episode_count=7)
|
|
assert s.episode_count == 7
|
|
|
|
def test_get_folder_name_normal(self):
|
|
s = Season(show_imdb_id="tt0903747", season_number=2, episode_count=13)
|
|
assert s.get_folder_name() == "Season 02"
|
|
|
|
def test_get_folder_name_specials(self):
|
|
s = Season(show_imdb_id="tt0903747", season_number=0, episode_count=3)
|
|
assert s.get_folder_name() == "Specials"
|
|
assert s.is_special()
|
|
|
|
def test_negative_episode_count_raises(self):
|
|
with pytest.raises(ValueError):
|
|
Season(show_imdb_id="tt0903747", season_number=1, episode_count=-1)
|
|
|
|
def test_str(self):
|
|
s = Season(
|
|
show_imdb_id="tt0903747",
|
|
season_number=1,
|
|
episode_count=7,
|
|
name="Pilot Season",
|
|
)
|
|
assert "Pilot Season" in str(s)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Episode entity
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestEpisode:
|
|
def test_basic_creation(self):
|
|
e = Episode(
|
|
show_imdb_id="tt0903747",
|
|
season_number=1,
|
|
episode_number=1,
|
|
title="Pilot",
|
|
)
|
|
assert e.title == "Pilot"
|
|
|
|
def test_get_filename_format(self):
|
|
e = Episode(
|
|
show_imdb_id="tt0903747",
|
|
season_number=1,
|
|
episode_number=5,
|
|
title="Gray Matter",
|
|
)
|
|
filename = e.get_filename()
|
|
assert filename.startswith("S01E05")
|
|
assert "Gray.Matter" in filename
|
|
|
|
def test_has_file_false_when_no_path(self):
|
|
e = Episode(
|
|
show_imdb_id="tt0903747",
|
|
season_number=1,
|
|
episode_number=1,
|
|
title="Pilot",
|
|
)
|
|
assert not e.has_file()
|
|
assert not e.is_downloaded()
|
|
|
|
def test_str_format(self):
|
|
e = Episode(
|
|
show_imdb_id="tt0903747",
|
|
season_number=2,
|
|
episode_number=3,
|
|
title="Bit by a Dead Bee",
|
|
)
|
|
s = str(e)
|
|
assert "S02E03" in s
|
|
assert "Bit by a Dead Bee" in s
|