feat: split resolve_destination, persona-driven prompts, qBittorrent relocation
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.
This commit is contained in:
@@ -5,23 +5,30 @@ Real-data cases sourced from /mnt/testipool/downloads/.
|
||||
Covers: parsing, normalisation, naming methods, edge cases.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from alfred.domain.release import ParsedRelease, parse_release
|
||||
from alfred.domain.release import parse_release
|
||||
from alfred.domain.release.services import _normalise
|
||||
from alfred.domain.release.value_objects import _sanitise_for_fs, _strip_episode_from_normalised
|
||||
|
||||
from alfred.domain.release.value_objects import (
|
||||
_sanitise_for_fs,
|
||||
_strip_episode_from_normalised,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _normalise
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNormalise:
|
||||
def test_dots_unchanged(self):
|
||||
assert _normalise("Oz.S01.1080p.WEBRip.x265-KONTRAST") == "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
||||
assert (
|
||||
_normalise("Oz.S01.1080p.WEBRip.x265-KONTRAST")
|
||||
== "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
||||
)
|
||||
|
||||
def test_spaces_become_dots(self):
|
||||
assert _normalise("Oz S01 1080p WEBRip x265-KONTRAST") == "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
||||
assert (
|
||||
_normalise("Oz S01 1080p WEBRip x265-KONTRAST")
|
||||
== "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
||||
)
|
||||
|
||||
def test_double_dots_collapsed(self):
|
||||
assert _normalise("Oz..S01..1080p") == "Oz.S01.1080p"
|
||||
@@ -31,7 +38,9 @@ class TestNormalise:
|
||||
|
||||
def test_mixed_spaces_and_dots(self):
|
||||
# "Archer 2009 S14E09E10E11 Into the Cold 1080p HULU WEB-DL DDP5 1 H 264-NTb"
|
||||
result = _normalise("Archer 2009 S14E09E10E11 Into the Cold 1080p HULU WEB-DL DDP5 1 H 264-NTb")
|
||||
result = _normalise(
|
||||
"Archer 2009 S14E09E10E11 Into the Cold 1080p HULU WEB-DL DDP5 1 H 264-NTb"
|
||||
)
|
||||
assert " " not in result
|
||||
assert ".." not in result
|
||||
|
||||
@@ -40,6 +49,7 @@ class TestNormalise:
|
||||
# _sanitise_for_fs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSanitiseForFs:
|
||||
def test_clean_string_unchanged(self):
|
||||
assert _sanitise_for_fs("Oz.S01.1080p-KONTRAST") == "Oz.S01.1080p-KONTRAST"
|
||||
@@ -65,28 +75,38 @@ class TestSanitiseForFs:
|
||||
# _strip_episode_from_normalised
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStripEpisode:
|
||||
def test_strips_single_episode(self):
|
||||
assert _strip_episode_from_normalised("Oz.S01E01.1080p.WEBRip.x265-KONTRAST") \
|
||||
== "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
||||
assert (
|
||||
_strip_episode_from_normalised("Oz.S01E01.1080p.WEBRip.x265-KONTRAST")
|
||||
== "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
||||
)
|
||||
|
||||
def test_strips_multi_episode(self):
|
||||
assert _strip_episode_from_normalised("Archer.S14E09E10E11.1080p.HULU.WEB-DL-NTb") \
|
||||
== "Archer.S14.1080p.HULU.WEB-DL-NTb"
|
||||
assert (
|
||||
_strip_episode_from_normalised("Archer.S14E09E10E11.1080p.HULU.WEB-DL-NTb")
|
||||
== "Archer.S14.1080p.HULU.WEB-DL-NTb"
|
||||
)
|
||||
|
||||
def test_season_pack_unchanged(self):
|
||||
assert _strip_episode_from_normalised("Oz.S01.1080p.WEBRip.x265-KONTRAST") \
|
||||
== "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
||||
assert (
|
||||
_strip_episode_from_normalised("Oz.S01.1080p.WEBRip.x265-KONTRAST")
|
||||
== "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
||||
)
|
||||
|
||||
def test_case_insensitive(self):
|
||||
assert _strip_episode_from_normalised("oz.s01e01.1080p-KONTRAST") \
|
||||
== "oz.s01.1080p-KONTRAST"
|
||||
assert (
|
||||
_strip_episode_from_normalised("oz.s01e01.1080p-KONTRAST")
|
||||
== "oz.s01.1080p-KONTRAST"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_release — Season packs (dots)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSeasonPackDots:
|
||||
"""Real cases: Oz.S01-S06 KONTRAST, Archer S03 EDGE2020, etc."""
|
||||
|
||||
@@ -135,13 +155,17 @@ class TestSeasonPackDots:
|
||||
assert p.group == "RARBG"
|
||||
|
||||
def test_gilmore_girls_s01_s07_repack(self):
|
||||
p = parse_release("Gilmore.Girls.Complete.S01-S07.REPACK.1080p.WEB-DL.x265.10bit.HEVC-MONOLITH")
|
||||
p = parse_release(
|
||||
"Gilmore.Girls.Complete.S01-S07.REPACK.1080p.WEB-DL.x265.10bit.HEVC-MONOLITH"
|
||||
)
|
||||
# Season range — we parse the first season number found
|
||||
assert p.season == 1
|
||||
assert p.group == "MONOLITH"
|
||||
|
||||
def test_plot_against_america_4k(self):
|
||||
p = parse_release("The.Plot.Against.America.S01.2160p.MAX.WEB-DL.x265.10bit.HDR.DDP5.1.x265-SH3LBY")
|
||||
p = parse_release(
|
||||
"The.Plot.Against.America.S01.2160p.MAX.WEB-DL.x265.10bit.HDR.DDP5.1.x265-SH3LBY"
|
||||
)
|
||||
assert p.title == "The.Plot.Against.America"
|
||||
assert p.season == 1
|
||||
assert p.quality == "2160p"
|
||||
@@ -165,6 +189,7 @@ class TestSeasonPackDots:
|
||||
# parse_release — Single episodes (dots)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSingleEpisodeDots:
|
||||
"""Real cases: Fallout S02Exx ELiTE, Mare of Easttown PSA, etc."""
|
||||
|
||||
@@ -211,19 +236,23 @@ class TestSingleEpisodeDots:
|
||||
# parse_release — Multi-episode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMultiEpisode:
|
||||
def test_archer_triple_episode(self):
|
||||
# "Archer 2009 S14E09E10E11 Into the Cold 1080p HULU WEB-DL DDP5 1 H 264-NTb"
|
||||
p = parse_release("Archer.2009.S14E09E10E11.Into.the.Cold.1080p.HULU.WEB-DL.DDP5.1.H.264-NTb")
|
||||
p = parse_release(
|
||||
"Archer.2009.S14E09E10E11.Into.the.Cold.1080p.HULU.WEB-DL.DDP5.1.H.264-NTb"
|
||||
)
|
||||
assert p.season == 14
|
||||
assert p.episode == 9
|
||||
assert p.episode_end == 10 # only first E-pair captured by regex group 2+3
|
||||
assert p.episode_end == 10 # only first E-pair captured by regex group 2+3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_release — Movies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMovies:
|
||||
def test_another_round_yts(self):
|
||||
# "Another Round (2020) [1080p] [BluRay] [YTS.MX]" → normalised
|
||||
@@ -276,6 +305,7 @@ class TestMovies:
|
||||
# parse_release — Space-separated (no dots)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSpaceSeparated:
|
||||
def test_oz_spaces(self):
|
||||
p = parse_release("Oz S01 1080p WEBRip x265-KONTRAST")
|
||||
@@ -285,7 +315,9 @@ class TestSpaceSeparated:
|
||||
assert p.group == "KONTRAST"
|
||||
|
||||
def test_archer_spaces(self):
|
||||
p = parse_release("Archer 2009 S14E09E10E11 Into the Cold 1080p HULU WEB-DL DDP5 1 H 264-NTb")
|
||||
p = parse_release(
|
||||
"Archer 2009 S14E09E10E11 Into the Cold 1080p HULU WEB-DL DDP5 1 H 264-NTb"
|
||||
)
|
||||
assert p.season == 14
|
||||
assert p.episode == 9
|
||||
assert p.group == "NTb"
|
||||
@@ -295,6 +327,7 @@ class TestSpaceSeparated:
|
||||
# parse_release — tech_string
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTechString:
|
||||
def test_full_tech(self):
|
||||
p = parse_release("Oz.S01.1080p.WEBRip.x265-KONTRAST")
|
||||
@@ -312,7 +345,9 @@ class TestTechString:
|
||||
assert "Unknown" in folder
|
||||
|
||||
def test_4k_hdr(self):
|
||||
p = parse_release("The.Plot.Against.America.S01.2160p.MAX.WEB-DL.x265.10bit.HDR.DDP5.1-SH3LBY")
|
||||
p = parse_release(
|
||||
"The.Plot.Against.America.S01.2160p.MAX.WEB-DL.x265.10bit.HDR.DDP5.1-SH3LBY"
|
||||
)
|
||||
assert p.quality == "2160p"
|
||||
|
||||
|
||||
@@ -320,8 +355,8 @@ class TestTechString:
|
||||
# ParsedRelease — naming methods
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNamingMethods:
|
||||
|
||||
class TestNamingMethods:
|
||||
def test_show_folder_name(self):
|
||||
p = parse_release("Oz.S01.1080p.WEBRip.x265-KONTRAST")
|
||||
assert p.show_folder_name("Oz", 1997) == "Oz.1997.1080p.WEBRip.x265-KONTRAST"
|
||||
@@ -370,7 +405,10 @@ class TestNamingMethods:
|
||||
|
||||
def test_movie_folder_name(self):
|
||||
p = parse_release("Another.Round.2020.1080p.BluRay.x264-YTS")
|
||||
assert p.movie_folder_name("Another Round", 2020) == "Another.Round.2020.1080p.BluRay.x264-YTS"
|
||||
assert (
|
||||
p.movie_folder_name("Another Round", 2020)
|
||||
== "Another.Round.2020.1080p.BluRay.x264-YTS"
|
||||
)
|
||||
|
||||
def test_movie_filename(self):
|
||||
p = parse_release("Another.Round.2020.1080p.BluRay.x264-YTS")
|
||||
@@ -379,13 +417,16 @@ class TestNamingMethods:
|
||||
|
||||
def test_movie_folder_same_as_show_folder(self):
|
||||
p = parse_release("Revolver.2005.1080p.BluRay.x265-RARBG")
|
||||
assert p.movie_folder_name("Revolver", 2005) == p.show_folder_name("Revolver", 2005)
|
||||
assert p.movie_folder_name("Revolver", 2005) == p.show_folder_name(
|
||||
"Revolver", 2005
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ParsedRelease — is_movie / is_season_pack
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMediaTypeFlags:
|
||||
def test_season_pack_is_not_movie(self):
|
||||
p = parse_release("Oz.S01.1080p.WEBRip.x265-KONTRAST")
|
||||
@@ -412,11 +453,13 @@ class TestMediaTypeFlags:
|
||||
# Tricky real-world releases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRealWorldEdgeCases:
|
||||
|
||||
class TestRealWorldEdgeCases:
|
||||
def test_angel_integrale_multi(self):
|
||||
# "Angel.1999.INTEGRALE.MULTI.1080p.WEBRip.10bits.x265.DD-Jarod"
|
||||
p = parse_release("Angel.1999.INTEGRALE.MULTI.1080p.WEBRip.10bits.x265.DD-Jarod")
|
||||
p = parse_release(
|
||||
"Angel.1999.INTEGRALE.MULTI.1080p.WEBRip.10bits.x265.DD-Jarod"
|
||||
)
|
||||
assert p.year == 1999
|
||||
assert p.quality == "1080p"
|
||||
assert p.source == "WEBRip"
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
"""Tests for shared domain value objects: ImdbId, FilePath, FileSize."""
|
||||
|
||||
import pytest
|
||||
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:
|
||||
|
||||
class TestImdbId:
|
||||
def test_valid_7_digits(self):
|
||||
id_ = ImdbId("tt1375666")
|
||||
assert str(id_) == "tt1375666"
|
||||
@@ -31,7 +31,7 @@ class TestImdbId:
|
||||
|
||||
def test_too_few_digits_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
ImdbId("tt12345") # only 5 digits
|
||||
ImdbId("tt12345") # only 5 digits
|
||||
|
||||
def test_too_many_digits_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
@@ -58,8 +58,8 @@ class TestImdbId:
|
||||
# FilePath
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFilePath:
|
||||
|
||||
class TestFilePath:
|
||||
def test_from_string(self, tmp_path):
|
||||
p = FilePath(str(tmp_path))
|
||||
assert isinstance(p.value, Path)
|
||||
@@ -98,8 +98,8 @@ class TestFilePath:
|
||||
# FileSize
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFileSize:
|
||||
|
||||
class TestFileSize:
|
||||
def test_bytes(self):
|
||||
s = FileSize(500)
|
||||
assert s.bytes == 500
|
||||
@@ -128,7 +128,7 @@ class TestFileSize:
|
||||
assert "MB" in result
|
||||
|
||||
def test_human_readable_gb(self):
|
||||
result = FileSize(2 * 1024 ** 3).to_human_readable()
|
||||
result = FileSize(2 * 1024**3).to_human_readable()
|
||||
assert "GB" in result
|
||||
|
||||
def test_str_is_human_readable(self):
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Tests for SubtitleScanner and _classify helper."""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
from alfred.domain.subtitles.scanner import (
|
||||
@@ -9,13 +8,12 @@ from alfred.domain.subtitles.scanner import (
|
||||
_classify,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _classify — unit tests for the filename parser
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestClassify:
|
||||
|
||||
class TestClassify:
|
||||
def test_iso_lang_code(self, tmp_path):
|
||||
p = tmp_path / "fr.srt"
|
||||
p.write_text("")
|
||||
@@ -86,8 +84,8 @@ class TestClassify:
|
||||
# SubtitleCandidate.destination_name
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSubtitleCandidateDestinationName:
|
||||
|
||||
class TestSubtitleCandidateDestinationName:
|
||||
def _make(self, lang="fr", is_sdh=False, is_forced=False, ext=".srt", path=None):
|
||||
return SubtitleCandidate(
|
||||
source_path=path or Path("/fake/fr.srt"),
|
||||
@@ -117,8 +115,8 @@ class TestSubtitleCandidateDestinationName:
|
||||
# SubtitleScanner — integration with real filesystem
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSubtitleScanner:
|
||||
|
||||
class TestSubtitleScanner:
|
||||
def _scanner(self, languages=None, min_size_kb=0, keep_sdh=True, keep_forced=True):
|
||||
return SubtitleScanner(
|
||||
languages=languages or ["fr", "en"],
|
||||
|
||||
@@ -6,13 +6,12 @@ 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:
|
||||
|
||||
class TestShowStatus:
|
||||
def test_from_string_ongoing(self):
|
||||
assert ShowStatus.from_string("ongoing") == ShowStatus.ONGOING
|
||||
|
||||
@@ -32,8 +31,8 @@ class TestShowStatus:
|
||||
# SeasonNumber
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSeasonNumber:
|
||||
|
||||
class TestSeasonNumber:
|
||||
def test_valid_season(self):
|
||||
s = SeasonNumber(1)
|
||||
assert s.value == 1
|
||||
@@ -67,8 +66,8 @@ class TestSeasonNumber:
|
||||
# EpisodeNumber
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEpisodeNumber:
|
||||
|
||||
class TestEpisodeNumber:
|
||||
def test_valid_episode(self):
|
||||
e = EpisodeNumber(1)
|
||||
assert e.value == 1
|
||||
@@ -95,10 +94,14 @@ class TestEpisodeNumber:
|
||||
# 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)
|
||||
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()
|
||||
@@ -108,6 +111,7 @@ class TestTVShow:
|
||||
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):
|
||||
@@ -151,8 +155,8 @@ class TestTVShow:
|
||||
# Season entity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSeason:
|
||||
|
||||
class TestSeason:
|
||||
def test_basic_creation(self):
|
||||
s = Season(show_imdb_id="tt0903747", season_number=1, episode_count=7)
|
||||
assert s.episode_count == 7
|
||||
@@ -171,7 +175,12 @@ class TestSeason:
|
||||
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")
|
||||
s = Season(
|
||||
show_imdb_id="tt0903747",
|
||||
season_number=1,
|
||||
episode_count=7,
|
||||
name="Pilot Season",
|
||||
)
|
||||
assert "Pilot Season" in str(s)
|
||||
|
||||
|
||||
@@ -179,8 +188,8 @@ class TestSeason:
|
||||
# Episode entity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEpisode:
|
||||
|
||||
class TestEpisode:
|
||||
def test_basic_creation(self):
|
||||
e = Episode(
|
||||
show_imdb_id="tt0903747",
|
||||
|
||||
Reference in New Issue
Block a user