git commit -m "feat: major architectural refactor

- Refactor memory system (episodic/STM/LTM with components)
- Implement complete subtitle domain (scanner, matcher, placer)
- Add YAML workflow infrastructure
- Externalize knowledge base (patterns, release groups)
- Add comprehensive testing suite
- Create manual testing CLIs"
This commit is contained in:
2026-05-11 21:33:37 +02:00
parent 62b5d0b998
commit de02bdea06
103 changed files with 8559 additions and 1346 deletions
View File
+41
View File
@@ -0,0 +1,41 @@
"""Fixtures for application-layer tests."""
import shutil
import tempfile
from pathlib import Path
import pytest
from alfred.infrastructure.persistence import Memory, set_memory
@pytest.fixture
def app_temp(tmp_path):
"""Real folder structure: downloads, movies, tv_shows, torrents."""
(tmp_path / "downloads").mkdir()
(tmp_path / "movies").mkdir()
(tmp_path / "tv_shows").mkdir()
(tmp_path / "torrents").mkdir()
return tmp_path
@pytest.fixture
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)
mem.ltm.workspace.download = str(app_temp / "downloads")
mem.ltm.workspace.torrent = str(app_temp / "torrents")
mem.ltm.library_paths.set("movie", str(app_temp / "movies"))
mem.ltm.library_paths.set("tv_show", str(app_temp / "tv_shows"))
mem.save()
yield mem
shutil.rmtree(storage, ignore_errors=True)
+117
View File
@@ -0,0 +1,117 @@
"""
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
from alfred.infrastructure.filesystem.file_manager import FileManager
@pytest.fixture
def fm():
return FileManager()
@pytest.fixture
def use_case(fm):
return CreateSeedLinksUseCase(fm)
@pytest.fixture
def seed_env(tmp_path_factory):
"""
Realistic post-move environment (uses its own tmp dir, independent of app_temp):
- library video file (hard-linked from original)
- original download folder with remaining files
- torrents root folder
"""
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.mkdir(parents=True)
lib_video = lib_dir / "Oz.S01E01.1080p.WEBRip.x265-KONTRAST.mp4"
lib_video.write_bytes(b"video")
dl = d / "downloads" / "Oz.S01.1080p.WEBRip.x265-KONTRAST"
dl.mkdir(parents=True)
(dl / "KONTRAST.txt").write_text("release notes")
(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")
torrents = d / "torrents"
torrents.mkdir()
return lib_video, dl, torrents
# ---------------------------------------------------------------------------
# Happy path
# ---------------------------------------------------------------------------
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)
mem.save()
result = use_case.execute(str(lib_video), str(dl))
assert result.status == "ok"
assert result.torrent_subfolder is not None
assert result.linked_file is not None
assert result.copied_count > 0
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)
mem.save()
d = use_case.execute(str(lib_video), str(dl)).to_dict()
assert d["status"] == "ok"
assert "torrent_subfolder" in d
assert "copied_files" in d
assert isinstance(d["copied_files"], list)
# ---------------------------------------------------------------------------
# Error: torrent folder not configured
# ---------------------------------------------------------------------------
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))
assert result.status == "error"
assert result.error == "torrent_folder_not_set"
assert result.message is not None
def test_to_dict_error(self, use_case, seed_env, memory):
lib_video, dl, _ = seed_env
d = use_case.execute(str(lib_video), str(dl)).to_dict()
assert d["status"] == "error"
assert "error" in d
assert "message" in d
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
uc = CreateSeedLinksUseCase(FileManager())
result = uc.execute("/nonexistent/lib.mkv", "/nonexistent/dl")
assert result.status == "error"
@@ -0,0 +1,179 @@
"""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:
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,
})
resp = uc.execute("download")
assert resp.status == "ok"
assert resp.folder_type == "download"
assert resp.path == "."
assert resp.entries == ["movie.mkv", "show/"]
assert resp.count == 2
def test_error_propagates(self):
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"
assert resp.message == "Download folder not configured."
def test_delegates_folder_type_and_path(self):
fm = MagicMock()
fm.list_folder.return_value = {
"status": "ok",
"folder_type": "tv_show",
"path": "Breaking Bad",
"entries": [],
"count": 0,
}
uc = ListFolderUseCase(fm)
uc.execute("tv_show", "Breaking Bad")
fm.list_folder.assert_called_once_with("tv_show", "Breaking Bad")
def test_default_path_is_dot(self):
fm = MagicMock()
fm.list_folder.return_value = {
"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,
})
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",
})
resp = uc.execute("download", "some/path")
assert resp.entries is None
assert resp.count is None
# ---------------------------------------------------------------------------
# MoveMediaUseCase
# ---------------------------------------------------------------------------
class TestMoveMediaUseCase:
def _use_case(self, fm_result):
fm = MagicMock()
fm.move_file.return_value = fm_result
return MoveMediaUseCase(fm)
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,
})
resp = uc.execute(src, dst)
assert resp.status == "ok"
assert resp.source == src
assert resp.destination == dst
assert resp.filename == "dst.mkv"
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",
})
resp = uc.execute("/ghost.mkv", str(tmp_path / "dst.mkv"))
assert resp.status == "error"
assert resp.error == "source_not_found"
def test_delegates_to_file_manager(self, tmp_path):
src = "/downloads/movie.mkv"
dst = "/movies/Movie.2024/movie.mkv"
fm = MagicMock()
fm.move_file.return_value = {
"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",
})
resp = uc.execute("/src.mkv", "/dst.mkv")
assert resp.source is None
assert resp.destination is None
assert resp.filename is None
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,
})
resp = uc.execute(src, dst)
d = resp.to_dict()
assert d["status"] == "ok"
assert d["filename"] == "movie.mkv"
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",
})
resp = uc.execute("/src.mkv", "/dst.mkv")
d = resp.to_dict()
assert d["status"] == "error"
assert "error" in d
assert "message" in d
@@ -0,0 +1,315 @@
"""
Tests for alfred.application.filesystem.resolve_destination
Uses a real temp filesystem + a real Memory instance (via conftest fixtures).
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()
# ---------------------------------------------------------------------------
# Movies
# ---------------------------------------------------------------------------
class TestResolveMovie:
def test_basic_movie(self, memory_configured):
result = _use_case().execute(
release_name="Another.Round.2020.1080p.BluRay.x264-YTS",
source_file="/downloads/Another.Round.2020.1080p.BluRay.x264-YTS/Another.Round.2020.1080p.BluRay.x264-YTS.mp4",
tmdb_title="Another Round",
tmdb_year=2020,
)
assert result.status == "ok"
assert "Another.Round.2020" in result.series_folder_name
assert "1080p.BluRay.x264-YTS" in result.series_folder_name
assert result.filename.endswith(".mp4")
assert result.season_folder is None
def test_movie_library_file_path_is_inside_series_folder(self, memory_configured):
result = _use_case().execute(
release_name="Revolver.2005.1080p.BluRay.x265-RARBG",
source_file="/downloads/Revolver.2005.1080p.BluRay.x265-RARBG.mkv",
tmdb_title="Revolver",
tmdb_year=2005,
)
assert result.status == "ok"
assert result.library_file.startswith(result.series_folder)
def test_movie_library_not_set(self, memory):
# memory has no library paths configured
result = _use_case().execute(
release_name="Revolver.2005.1080p.BluRay.x265-RARBG",
source_file="/downloads/Revolver.2005.1080p.BluRay.x265-RARBG.mkv",
tmdb_title="Revolver",
tmdb_year=2005,
)
assert result.status == "error"
assert result.error == "library_not_set"
def test_movie_folder_marked_new(self, memory_configured):
# No existing folder → is_new_series_folder = True
result = _use_case().execute(
release_name="Godzilla.Minus.One.2023.1080p.BluRay.x265-YTS",
source_file="/downloads/Godzilla.Minus.One.2023.1080p.BluRay.x265-YTS.mp4",
tmdb_title="Godzilla Minus One",
tmdb_year=2023,
)
assert result.status == "ok"
assert result.is_new_series_folder is True
def test_movie_sanitises_forbidden_chars_in_title(self, memory_configured):
result = _use_case().execute(
release_name="Alien.Earth.2024.1080p.WEBRip.x265-KONTRAST",
source_file="/downloads/Alien.Earth.2024.1080p.WEBRip.x265-KONTRAST.mkv",
tmdb_title="Alien: Earth",
tmdb_year=2024,
)
assert result.status == "ok"
assert ":" not in result.series_folder_name
def test_to_dict_ok(self, memory_configured):
result = _use_case().execute(
release_name="Revolver.2005.1080p.BluRay.x265-RARBG",
source_file="/downloads/Revolver.mkv",
tmdb_title="Revolver",
tmdb_year=2005,
)
d = result.to_dict()
assert d["status"] == "ok"
assert "library_file" in d
assert "series_folder_name" in d
# ---------------------------------------------------------------------------
# TV shows — no existing folder
# ---------------------------------------------------------------------------
class TestResolveTVShowNewFolder:
def test_oz_s01_creates_new_folder(self, memory_configured):
result = _use_case().execute(
release_name="Oz.S01.1080p.WEBRip.x265-KONTRAST",
source_file="/downloads/Oz.S01.1080p.WEBRip.x265-KONTRAST/Oz.S01E01.1080p.WEBRip.x265-KONTRAST.mp4",
tmdb_title="Oz",
tmdb_year=1997,
)
assert result.status == "ok"
assert result.is_new_series_folder is True
assert result.series_folder_name == "Oz.1997.1080p.WEBRip.x265-KONTRAST"
assert result.season_folder_name == "Oz.S01.1080p.WEBRip.x265-KONTRAST"
def test_tv_library_not_set(self, memory):
result = _use_case().execute(
release_name="Oz.S01.1080p.WEBRip.x265-KONTRAST",
source_file="/downloads/Oz.S01E01.mp4",
tmdb_title="Oz",
tmdb_year=1997,
)
assert result.status == "error"
assert result.error == "library_not_set"
def test_single_episode_filename(self, memory_configured):
result = _use_case().execute(
release_name="Fallout.2024.S02E01.1080p.x265-ELiTE",
source_file="/downloads/Fallout.2024.S02E01.1080p.x265-ELiTE.mkv",
tmdb_title="Fallout",
tmdb_year=2024,
tmdb_episode_title="The Beginning",
)
assert result.status == "ok"
assert "S02E01" in result.filename
assert "The.Beginning" in result.filename
assert result.filename.endswith(".mkv")
def test_season_pack_filename_is_folder_name_plus_ext(self, memory_configured):
result = _use_case().execute(
release_name="Oz.S01.1080p.WEBRip.x265-KONTRAST",
source_file="/downloads/Oz.S01.1080p.WEBRip.x265-KONTRAST/Oz.S01E01.mp4",
tmdb_title="Oz",
tmdb_year=1997,
)
assert result.status == "ok"
# Season pack: filename = season_folder_name + ext
assert result.filename == result.season_folder_name + ".mp4"
def test_library_file_is_inside_season_folder(self, memory_configured):
result = _use_case().execute(
release_name="Oz.S01.1080p.WEBRip.x265-KONTRAST",
source_file="/downloads/Oz.S01E01.mp4",
tmdb_title="Oz",
tmdb_year=1997,
)
assert result.library_file.startswith(result.season_folder)
assert result.season_folder.startswith(result.series_folder)
# ---------------------------------------------------------------------------
# TV shows — existing folder matching
# ---------------------------------------------------------------------------
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
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"))
existing = tv_root / "Oz.1997.1080p.WEBRip.x265-RARBG"
existing.mkdir(parents=True, exist_ok=True)
result = _use_case().execute(
release_name="Oz.S02.1080p.WEBRip.x265-KONTRAST",
source_file="/downloads/Oz.S02E01.mp4",
tmdb_title="Oz",
tmdb_year=1997,
)
assert result.status == "ok"
assert result.series_folder_name == "Oz.1997.1080p.WEBRip.x265-RARBG"
assert result.is_new_series_folder is False
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)
result = _use_case().execute(
release_name="Slow.Horses.S05.1080p.WEBRip.x265-KONTRAST",
source_file="/downloads/Slow.Horses.S05E01.mkv",
tmdb_title="Slow Horses",
tmdb_year=2022,
)
assert result.status == "needs_clarification"
assert result.question is not None
assert len(result.options) == 2
assert "Slow.Horses.2022.1080p.WEBRip.x265-RARBG" in result.options
assert "Slow.Horses.2022.1080p.WEBRip.x265-KONTRAST" in result.options
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"
(tv_root / chosen).mkdir(parents=True, exist_ok=True)
result = _use_case().execute(
release_name="Slow.Horses.S05.1080p.WEBRip.x265-KONTRAST",
source_file="/downloads/Slow.Horses.S05E01.mkv",
tmdb_title="Slow Horses",
tmdb_year=2022,
confirmed_folder=chosen,
)
assert result.status == "ok"
assert result.series_folder_name == chosen
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)
result = _use_case().execute(
release_name="Oz.S03.1080p.WEBRip.x265-KONTRAST",
source_file="/downloads/Oz.S03E01.mp4",
tmdb_title="Oz",
tmdb_year=1997,
)
d = result.to_dict()
assert d["status"] == "needs_clarification"
assert "question" in d
assert isinstance(d["options"], list)
def test_to_dict_error(self, memory):
result = _use_case().execute(
release_name="Oz.S01.1080p.WEBRip.x265-KONTRAST",
source_file="/downloads/Oz.S01E01.mp4",
tmdb_title="Oz",
tmdb_year=1997,
)
d = result.to_dict()
assert d["status"] == "error"
assert "error" in d
assert "message" in d
# ---------------------------------------------------------------------------
# _find_existing_series_folders
# ---------------------------------------------------------------------------
class TestFindExistingSeriesFolders:
def test_empty_library(self, tmp_path):
assert _find_existing_series_folders(tmp_path, "Oz", 1997) == []
def test_nonexistent_root(self, tmp_path):
assert _find_existing_series_folders(tmp_path / "nope", "Oz", 1997) == []
def test_single_match(self, tmp_path):
(tmp_path / "Oz.1997.1080p.WEBRip.x265-KONTRAST").mkdir()
result = _find_existing_series_folders(tmp_path, "Oz", 1997)
assert result == ["Oz.1997.1080p.WEBRip.x265-KONTRAST"]
def test_multiple_matches(self, tmp_path):
(tmp_path / "Oz.1997.1080p.WEBRip.x265-KONTRAST").mkdir()
(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
def test_no_match_different_year(self, tmp_path):
(tmp_path / "Oz.1997.1080p.WEBRip.x265-KONTRAST").mkdir()
result = _find_existing_series_folders(tmp_path, "Oz", 2000)
assert result == []
def test_no_match_different_title(self, tmp_path):
(tmp_path / "Oz.1997.1080p.WEBRip.x265-KONTRAST").mkdir()
result = _find_existing_series_folders(tmp_path, "Breaking Bad", 2008)
assert result == []
def test_ignores_files_not_dirs(self, tmp_path):
(tmp_path / "Oz.1997.1080p.WEBRip.x265-KONTRAST").mkdir()
(tmp_path / "Oz.1997.some.file.txt").touch()
result = _find_existing_series_folders(tmp_path, "Oz", 1997)
assert len(result) == 1
def test_case_insensitive_prefix(self, tmp_path):
# Folder stored with mixed case
(tmp_path / "OZ.1997.1080p.WEBRip.x265-KONTRAST").mkdir()
result = _find_existing_series_folders(tmp_path, "Oz", 1997)
assert len(result) == 1
def test_title_with_special_chars_sanitised(self, tmp_path):
# "Star Wars: Andor" → sanitised (colon removed) + spaces→dots → "Star.Wars.Andor.2022"
(tmp_path / "Star.Wars.Andor.2022.1080p.WEBRip.x265-GROUP").mkdir()
result = _find_existing_series_folders(tmp_path, "Star Wars: Andor", 2022)
assert len(result) == 1