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:
2026-05-14 05:01:59 +02:00
parent 1723b9fa53
commit e45465d52d
81 changed files with 2904 additions and 896 deletions
-1
View File
@@ -2,7 +2,6 @@
import shutil
import tempfile
from pathlib import Path
import pytest
+19 -9
View File
@@ -5,13 +5,12 @@ Uses real temp filesystem. No mocks on os.link — we test the actual behavior.
"""
import os
import stat
from pathlib import Path
import pytest
from alfred.infrastructure.filesystem.file_manager import FileManager
from alfred.infrastructure.filesystem.exceptions import PathTraversalError
from alfred.infrastructure.filesystem.file_manager import FileManager
@pytest.fixture
@@ -23,8 +22,8 @@ def fm():
# copy_file (hard-link)
# ---------------------------------------------------------------------------
class TestCopyFile:
class TestCopyFile:
def test_creates_hard_link(self, fm, tmp_path):
src = tmp_path / "source.mkv"
src.write_bytes(b"video data")
@@ -80,8 +79,8 @@ class TestCopyFile:
# move_file
# ---------------------------------------------------------------------------
class TestMoveFile:
class TestMoveFile:
def test_moves_file(self, fm, tmp_path):
src = tmp_path / "episode.mkv"
src.write_bytes(b"video")
@@ -132,8 +131,8 @@ class TestMoveFile:
# create_seed_links
# ---------------------------------------------------------------------------
class TestCreateSeedLinks:
class TestCreateSeedLinks:
def _setup(self, tmp_path):
"""Create realistic download + library + torrent structure."""
download = tmp_path / "downloads" / "Oz.S01.1080p.WEBRip.x265-KONTRAST"
@@ -146,7 +145,12 @@ class TestCreateSeedLinks:
subs.mkdir(parents=True)
(subs / "2_eng.srt").write_text("subtitle content")
library = tmp_path / "tv" / "Oz.1997.1080p.WEBRip.x265-KONTRAST" / "Oz.S01.1080p.WEBRip.x265-KONTRAST"
library = (
tmp_path
/ "tv"
/ "Oz.1997.1080p.WEBRip.x265-KONTRAST"
/ "Oz.S01.1080p.WEBRip.x265-KONTRAST"
)
library.mkdir(parents=True)
lib_video = library / "Oz.S01E01.1080p.WEBRip.x265-KONTRAST.mp4"
# Hard-link the video to simulate post-move state
@@ -188,7 +192,13 @@ class TestCreateSeedLinks:
lib_video, download, torrents = self._setup(tmp_path)
fm.create_seed_links(str(lib_video), str(download), str(torrents))
srt = torrents / "Oz.S01.1080p.WEBRip.x265-KONTRAST" / "Subs" / "Oz.S01E01.1080p.WEBRip.x265-KONTRAST" / "2_eng.srt"
srt = (
torrents
/ "Oz.S01.1080p.WEBRip.x265-KONTRAST"
/ "Subs"
/ "Oz.S01E01.1080p.WEBRip.x265-KONTRAST"
/ "2_eng.srt"
)
assert srt.exists()
def test_returns_copied_and_skipped(self, fm, tmp_path):
@@ -270,8 +280,8 @@ class TestCreateSeedLinks:
# list_folder
# ---------------------------------------------------------------------------
class TestListFolder:
class TestListFolder:
def test_lists_entries(self, fm, memory_configured, infra_temp):
result = fm.list_folder("download")
assert result["status"] == "ok"
@@ -300,8 +310,8 @@ class TestListFolder:
# _sanitize_path
# ---------------------------------------------------------------------------
class TestSanitizePath:
class TestSanitizePath:
def test_normal_path(self, fm):
assert fm._sanitize_path("some/path") == "some/path"