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
-2
View File
@@ -2,7 +2,6 @@
import shutil
import tempfile
from pathlib import Path
import pytest
@@ -25,7 +24,6 @@ def memory_configured(app_temp, tmp_path):
Fresh Memory with library_paths and workspace configured using the real API.
Replaces the broken memory_with_config from root conftest for these tests.
"""
import tempfile, os
storage = tempfile.mkdtemp()
mem = Memory(storage_dir=storage)
set_memory(mem)
+17 -9
View File
@@ -2,10 +2,6 @@
Tests for alfred.application.filesystem.create_seed_links.CreateSeedLinksUseCase
"""
import os
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from alfred.application.filesystem.create_seed_links import CreateSeedLinksUseCase
@@ -32,7 +28,12 @@ def seed_env(tmp_path_factory):
"""
d = tmp_path_factory.mktemp("seed_env")
lib_dir = d / "tv" / "Oz.1997.1080p.WEBRip.x265-KONTRAST" / "Oz.S01.1080p.WEBRip.x265-KONTRAST"
lib_dir = (
d
/ "tv"
/ "Oz.1997.1080p.WEBRip.x265-KONTRAST"
/ "Oz.S01.1080p.WEBRip.x265-KONTRAST"
)
lib_dir.mkdir(parents=True)
lib_video = lib_dir / "Oz.S01E01.1080p.WEBRip.x265-KONTRAST.mp4"
lib_video.write_bytes(b"video")
@@ -43,7 +44,9 @@ def seed_env(tmp_path_factory):
(dl / "[TGx]info.txt").write_text("tgx")
subs = dl / "Subs" / "Oz.S01E01.1080p.WEBRip.x265-KONTRAST"
subs.mkdir(parents=True)
(subs / "2_eng,English [CC][SDH].srt").write_text("1\n00:00:01 --> 00:00:02\nHello\n")
(subs / "2_eng,English [CC][SDH].srt").write_text(
"1\n00:00:01 --> 00:00:02\nHello\n"
)
torrents = d / "torrents"
torrents.mkdir()
@@ -55,10 +58,13 @@ def seed_env(tmp_path_factory):
# Happy path
# ---------------------------------------------------------------------------
class TestCreateSeedLinksHappyPath:
def test_ok_when_torrent_folder_configured(self, use_case, seed_env, memory_configured):
class TestCreateSeedLinksHappyPath:
def test_ok_when_torrent_folder_configured(
self, use_case, seed_env, memory_configured
):
from alfred.infrastructure.persistence import get_memory
mem = get_memory()
lib_video, dl, torrents = seed_env
mem.ltm.workspace.torrent = str(torrents)
@@ -73,6 +79,7 @@ class TestCreateSeedLinksHappyPath:
def test_to_dict_ok(self, use_case, seed_env, memory_configured):
from alfred.infrastructure.persistence import get_memory
mem = get_memory()
lib_video, dl, torrents = seed_env
mem.ltm.workspace.torrent = str(torrents)
@@ -89,8 +96,8 @@ class TestCreateSeedLinksHappyPath:
# Error: torrent folder not configured
# ---------------------------------------------------------------------------
class TestCreateSeedLinksErrors:
class TestCreateSeedLinksErrors:
def test_error_when_torrent_not_configured(self, use_case, seed_env, memory):
lib_video, dl, _ = seed_env
result = use_case.execute(str(lib_video), str(dl))
@@ -109,6 +116,7 @@ class TestCreateSeedLinksErrors:
def test_error_delegates_to_file_manager(self, memory_configured):
"""FileManager errors are propagated correctly."""
from alfred.infrastructure.persistence import get_memory
mem = get_memory()
# torrent already configured by memory_configured fixture
# library_file does not exist → should propagate error from FileManager
@@ -1,31 +1,31 @@
"""Tests for ListFolderUseCase and MoveMediaUseCase."""
import pytest
from unittest.mock import MagicMock
from alfred.application.filesystem.list_folder import ListFolderUseCase
from alfred.application.filesystem.move_media import MoveMediaUseCase
# ---------------------------------------------------------------------------
# ListFolderUseCase
# ---------------------------------------------------------------------------
class TestListFolderUseCase:
class TestListFolderUseCase:
def _use_case(self, fm_result):
fm = MagicMock()
fm.list_folder.return_value = fm_result
return ListFolderUseCase(fm)
def test_success_returns_response(self):
uc = self._use_case({
"status": "ok",
"folder_type": "download",
"path": ".",
"entries": ["movie.mkv", "show/"],
"count": 2,
})
uc = self._use_case(
{
"status": "ok",
"folder_type": "download",
"path": ".",
"entries": ["movie.mkv", "show/"],
"count": 2,
}
)
resp = uc.execute("download")
assert resp.status == "ok"
assert resp.folder_type == "download"
@@ -34,11 +34,13 @@ class TestListFolderUseCase:
assert resp.count == 2
def test_error_propagates(self):
uc = self._use_case({
"status": "error",
"error": "folder_not_set",
"message": "Download folder not configured.",
})
uc = self._use_case(
{
"status": "error",
"error": "folder_not_set",
"message": "Download folder not configured.",
}
)
resp = uc.execute("download")
assert resp.status == "error"
assert resp.error == "folder_not_set"
@@ -60,30 +62,37 @@ class TestListFolderUseCase:
def test_default_path_is_dot(self):
fm = MagicMock()
fm.list_folder.return_value = {
"status": "ok", "folder_type": "download",
"path": ".", "entries": [], "count": 0,
"status": "ok",
"folder_type": "download",
"path": ".",
"entries": [],
"count": 0,
}
uc = ListFolderUseCase(fm)
uc.execute("download")
fm.list_folder.assert_called_once_with("download", ".")
def test_success_response_has_no_error(self):
uc = self._use_case({
"status": "ok",
"folder_type": "movie",
"path": ".",
"entries": [],
"count": 0,
})
uc = self._use_case(
{
"status": "ok",
"folder_type": "movie",
"path": ".",
"entries": [],
"count": 0,
}
)
resp = uc.execute("movie")
assert resp.error is None
def test_error_response_has_no_entries(self):
uc = self._use_case({
"status": "error",
"error": "not_found",
"message": "Path does not exist",
})
uc = self._use_case(
{
"status": "error",
"error": "not_found",
"message": "Path does not exist",
}
)
resp = uc.execute("download", "some/path")
assert resp.entries is None
assert resp.count is None
@@ -93,8 +102,8 @@ class TestListFolderUseCase:
# MoveMediaUseCase
# ---------------------------------------------------------------------------
class TestMoveMediaUseCase:
class TestMoveMediaUseCase:
def _use_case(self, fm_result):
fm = MagicMock()
fm.move_file.return_value = fm_result
@@ -103,13 +112,15 @@ class TestMoveMediaUseCase:
def test_success_returns_response(self, tmp_path):
src = str(tmp_path / "src.mkv")
dst = str(tmp_path / "dst.mkv")
uc = self._use_case({
"status": "ok",
"source": src,
"destination": dst,
"filename": "dst.mkv",
"size": 1024,
})
uc = self._use_case(
{
"status": "ok",
"source": src,
"destination": dst,
"filename": "dst.mkv",
"size": 1024,
}
)
resp = uc.execute(src, dst)
assert resp.status == "ok"
assert resp.source == src
@@ -118,11 +129,13 @@ class TestMoveMediaUseCase:
assert resp.size == 1024
def test_error_propagates(self, tmp_path):
uc = self._use_case({
"status": "error",
"error": "source_not_found",
"message": "Source does not exist: /ghost.mkv",
})
uc = self._use_case(
{
"status": "error",
"error": "source_not_found",
"message": "Source does not exist: /ghost.mkv",
}
)
resp = uc.execute("/ghost.mkv", str(tmp_path / "dst.mkv"))
assert resp.status == "error"
assert resp.error == "source_not_found"
@@ -132,19 +145,24 @@ class TestMoveMediaUseCase:
dst = "/movies/Movie.2024/movie.mkv"
fm = MagicMock()
fm.move_file.return_value = {
"status": "ok", "source": src, "destination": dst,
"filename": "movie.mkv", "size": 1,
"status": "ok",
"source": src,
"destination": dst,
"filename": "movie.mkv",
"size": 1,
}
uc = MoveMediaUseCase(fm)
uc.execute(src, dst)
fm.move_file.assert_called_once_with(src, dst)
def test_error_response_has_no_paths(self):
uc = self._use_case({
"status": "error",
"error": "destination_exists",
"message": "File already exists",
})
uc = self._use_case(
{
"status": "error",
"error": "destination_exists",
"message": "File already exists",
}
)
resp = uc.execute("/src.mkv", "/dst.mkv")
assert resp.source is None
assert resp.destination is None
@@ -153,13 +171,15 @@ class TestMoveMediaUseCase:
def test_to_dict_success(self, tmp_path):
src = "/downloads/movie.mkv"
dst = "/movies/movie.mkv"
uc = self._use_case({
"status": "ok",
"source": src,
"destination": dst,
"filename": "movie.mkv",
"size": 2048,
})
uc = self._use_case(
{
"status": "ok",
"source": src,
"destination": dst,
"filename": "movie.mkv",
"size": 2048,
}
)
resp = uc.execute(src, dst)
d = resp.to_dict()
assert d["status"] == "ok"
@@ -167,11 +187,13 @@ class TestMoveMediaUseCase:
assert d["size"] == 2048
def test_to_dict_error(self):
uc = self._use_case({
"status": "error",
"error": "link_failed",
"message": "Cross-device link not permitted",
})
uc = self._use_case(
{
"status": "error",
"error": "link_failed",
"message": "Cross-device link not permitted",
}
)
resp = uc.execute("/src.mkv", "/dst.mkv")
d = resp.to_dict()
assert d["status"] == "error"
+19 -12
View File
@@ -7,18 +7,16 @@ No network calls — TMDB data is passed in directly.
from pathlib import Path
import pytest
from alfred.application.filesystem.resolve_destination import (
ResolveDestinationUseCase,
_find_existing_series_folders,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _use_case():
return ResolveDestinationUseCase()
@@ -27,8 +25,8 @@ def _use_case():
# Movies
# ---------------------------------------------------------------------------
class TestResolveMovie:
class TestResolveMovie:
def test_basic_movie(self, memory_configured):
result = _use_case().execute(
release_name="Another.Round.2020.1080p.BluRay.x264-YTS",
@@ -101,8 +99,8 @@ class TestResolveMovie:
# TV shows — no existing folder
# ---------------------------------------------------------------------------
class TestResolveTVShowNewFolder:
class TestResolveTVShowNewFolder:
def test_oz_s01_creates_new_folder(self, memory_configured):
result = _use_case().execute(
release_name="Oz.S01.1080p.WEBRip.x265-KONTRAST",
@@ -164,11 +162,10 @@ class TestResolveTVShowNewFolder:
# TV shows — existing folder matching
# ---------------------------------------------------------------------------
class TestResolveTVShowExistingFolder:
class TestResolveTVShowExistingFolder:
def _make_series_folder(self, tv_root, name):
"""Create a series folder in the tv library."""
import os
path = tv_root / name
path.mkdir(parents=True, exist_ok=True)
return path
@@ -176,6 +173,7 @@ class TestResolveTVShowExistingFolder:
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"))
@@ -195,11 +193,16 @@ class TestResolveTVShowExistingFolder:
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)
(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",
@@ -216,6 +219,7 @@ class TestResolveTVShowExistingFolder:
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"
@@ -233,10 +237,13 @@ class TestResolveTVShowExistingFolder:
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)
(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",
@@ -266,8 +273,8 @@ class TestResolveTVShowExistingFolder:
# _find_existing_series_folders
# ---------------------------------------------------------------------------
class TestFindExistingSeriesFolders:
class TestFindExistingSeriesFolders:
def test_empty_library(self, tmp_path):
assert _find_existing_series_folders(tmp_path, "Oz", 1997) == []
@@ -284,7 +291,7 @@ class TestFindExistingSeriesFolders:
(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
assert sorted(result) == result # sorted
def test_no_match_different_year(self, tmp_path):
(tmp_path / "Oz.1997.1080p.WEBRip.x265-KONTRAST").mkdir()