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.
137 lines
3.9 KiB
Python
137 lines
3.9 KiB
Python
"""Tests for shared domain value objects: ImdbId, FilePath, FileSize."""
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from alfred.domain.shared.exceptions import ValidationError
|
|
from alfred.domain.shared.value_objects import FilePath, FileSize, ImdbId
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ImdbId
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestImdbId:
|
|
def test_valid_7_digits(self):
|
|
id_ = ImdbId("tt1375666")
|
|
assert str(id_) == "tt1375666"
|
|
|
|
def test_valid_8_digits(self):
|
|
id_ = ImdbId("tt12345678")
|
|
assert str(id_) == "tt12345678"
|
|
|
|
def test_empty_raises(self):
|
|
with pytest.raises(ValidationError):
|
|
ImdbId("")
|
|
|
|
def test_no_tt_prefix_raises(self):
|
|
with pytest.raises(ValidationError):
|
|
ImdbId("1375666")
|
|
|
|
def test_too_few_digits_raises(self):
|
|
with pytest.raises(ValidationError):
|
|
ImdbId("tt12345") # only 5 digits
|
|
|
|
def test_too_many_digits_raises(self):
|
|
with pytest.raises(ValidationError):
|
|
ImdbId("tt123456789") # 9 digits
|
|
|
|
def test_non_string_raises(self):
|
|
with pytest.raises(ValidationError):
|
|
ImdbId(1375666) # type: ignore
|
|
|
|
def test_repr(self):
|
|
assert "tt1375666" in repr(ImdbId("tt1375666"))
|
|
|
|
def test_equality(self):
|
|
assert ImdbId("tt1375666") == ImdbId("tt1375666")
|
|
assert ImdbId("tt1375666") != ImdbId("tt0903747")
|
|
|
|
def test_hashable(self):
|
|
# Frozen dataclass should be hashable
|
|
ids = {ImdbId("tt1375666"), ImdbId("tt0903747")}
|
|
assert len(ids) == 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# FilePath
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFilePath:
|
|
def test_from_string(self, tmp_path):
|
|
p = FilePath(str(tmp_path))
|
|
assert isinstance(p.value, Path)
|
|
|
|
def test_from_path(self, tmp_path):
|
|
p = FilePath(tmp_path)
|
|
assert p.value == tmp_path
|
|
|
|
def test_invalid_type_raises(self):
|
|
with pytest.raises(ValidationError):
|
|
FilePath(123) # type: ignore
|
|
|
|
def test_exists_true(self, tmp_path):
|
|
p = FilePath(tmp_path)
|
|
assert p.exists()
|
|
|
|
def test_exists_false(self, tmp_path):
|
|
p = FilePath(tmp_path / "nonexistent")
|
|
assert not p.exists()
|
|
|
|
def test_is_file(self, tmp_path):
|
|
f = tmp_path / "file.txt"
|
|
f.write_text("x")
|
|
assert FilePath(f).is_file()
|
|
assert not FilePath(tmp_path).is_file()
|
|
|
|
def test_is_dir(self, tmp_path):
|
|
assert FilePath(tmp_path).is_dir()
|
|
|
|
def test_str(self, tmp_path):
|
|
p = FilePath(tmp_path)
|
|
assert str(p) == str(tmp_path)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# FileSize
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFileSize:
|
|
def test_bytes(self):
|
|
s = FileSize(500)
|
|
assert s.bytes == 500
|
|
|
|
def test_negative_raises(self):
|
|
with pytest.raises(ValidationError):
|
|
FileSize(-1)
|
|
|
|
def test_non_integer_raises(self):
|
|
with pytest.raises(ValidationError):
|
|
FileSize(1.5) # type: ignore
|
|
|
|
def test_zero_is_valid(self):
|
|
s = FileSize(0)
|
|
assert s.bytes == 0
|
|
|
|
def test_human_readable_bytes(self):
|
|
assert FileSize(500).to_human_readable() == "500 B"
|
|
|
|
def test_human_readable_kb(self):
|
|
result = FileSize(2048).to_human_readable()
|
|
assert "KB" in result
|
|
|
|
def test_human_readable_mb(self):
|
|
result = FileSize(5 * 1024 * 1024).to_human_readable()
|
|
assert "MB" in result
|
|
|
|
def test_human_readable_gb(self):
|
|
result = FileSize(2 * 1024**3).to_human_readable()
|
|
assert "GB" in result
|
|
|
|
def test_str_is_human_readable(self):
|
|
s = FileSize(1024)
|
|
assert str(s) == s.to_human_readable()
|