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
+17 -5
View File
@@ -2,22 +2,20 @@
Tests for alfred.agent.registry — tool registration and JSON schema generation.
"""
import pytest
from alfred.agent.registry import Tool, _create_tool_from_function, make_tools
from alfred.settings import settings
# ---------------------------------------------------------------------------
# _create_tool_from_function
# ---------------------------------------------------------------------------
class TestCreateToolFromFunction:
class TestCreateToolFromFunction:
def test_name_from_function(self):
def my_tool(x: str) -> dict:
"""Does something."""
return {}
tool = _create_tool_from_function(my_tool)
assert tool.name == "my_tool"
@@ -28,12 +26,14 @@ class TestCreateToolFromFunction:
More details here.
"""
return {}
tool = _create_tool_from_function(my_tool)
assert tool.description == "First line description."
def test_description_fallback_to_name(self):
def no_doc(x: str) -> dict:
return {}
tool = _create_tool_from_function(no_doc)
assert tool.description == "no_doc"
@@ -41,6 +41,7 @@ class TestCreateToolFromFunction:
def tool(a: str, b: int) -> dict:
"""Tool."""
return {}
t = _create_tool_from_function(tool)
assert "a" in t.parameters["required"]
assert "b" in t.parameters["required"]
@@ -49,6 +50,7 @@ class TestCreateToolFromFunction:
def tool(a: str, b: str = "default") -> dict:
"""Tool."""
return {}
t = _create_tool_from_function(tool)
assert "a" in t.parameters["required"]
assert "b" not in t.parameters["required"]
@@ -57,6 +59,7 @@ class TestCreateToolFromFunction:
def tool(a: str, b: str | None = None) -> dict:
"""Tool."""
return {}
t = _create_tool_from_function(tool)
assert "b" not in t.parameters["required"]
@@ -64,6 +67,7 @@ class TestCreateToolFromFunction:
def tool(x: str) -> dict:
"""T."""
return {}
t = _create_tool_from_function(tool)
assert t.parameters["properties"]["x"]["type"] == "string"
@@ -71,6 +75,7 @@ class TestCreateToolFromFunction:
def tool(x: int) -> dict:
"""T."""
return {}
t = _create_tool_from_function(tool)
assert t.parameters["properties"]["x"]["type"] == "integer"
@@ -78,6 +83,7 @@ class TestCreateToolFromFunction:
def tool(x: float) -> dict:
"""T."""
return {}
t = _create_tool_from_function(tool)
assert t.parameters["properties"]["x"]["type"] == "number"
@@ -85,6 +91,7 @@ class TestCreateToolFromFunction:
def tool(x: bool) -> dict:
"""T."""
return {}
t = _create_tool_from_function(tool)
assert t.parameters["properties"]["x"]["type"] == "boolean"
@@ -92,6 +99,7 @@ class TestCreateToolFromFunction:
def tool(x: list) -> dict:
"""T."""
return {}
t = _create_tool_from_function(tool)
assert t.parameters["properties"]["x"]["type"] == "string"
@@ -99,6 +107,7 @@ class TestCreateToolFromFunction:
def tool(x) -> dict:
"""T."""
return {}
t = _create_tool_from_function(tool)
assert t.parameters["properties"]["x"]["type"] == "string"
@@ -107,6 +116,7 @@ class TestCreateToolFromFunction:
def tool(self, x: str) -> dict:
"""T."""
return {}
t = _create_tool_from_function(MyClass().tool)
assert "self" not in t.parameters["properties"]
@@ -114,6 +124,7 @@ class TestCreateToolFromFunction:
def tool(a: str, b: int = 0) -> dict:
"""T."""
return {}
t = _create_tool_from_function(tool)
assert t.parameters["type"] == "object"
assert "properties" in t.parameters
@@ -123,6 +134,7 @@ class TestCreateToolFromFunction:
def tool(x: str) -> dict:
"""T."""
return {"x": x}
t = _create_tool_from_function(tool)
assert t.func("hello") == {"x": "hello"}
@@ -131,8 +143,8 @@ class TestCreateToolFromFunction:
# make_tools
# ---------------------------------------------------------------------------
class TestMakeTools:
class TestMakeTools:
def test_returns_dict(self):
tools = make_tools(settings)
assert isinstance(tools, dict)
-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()
+70 -27
View File
@@ -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"
+7 -7
View File
@@ -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):
+3 -5
View File
@@ -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"],
+19 -10
View File
@@ -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",
-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"
+21 -15
View File
@@ -16,7 +16,6 @@ from bootstrap import (
load_env_file,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@@ -171,11 +170,11 @@ class TestBuildUris:
build_uris(alfred_file, secrets_file)
uri = load_env_file(secrets_file)["MONGO_URI"]
assert "alfred" in uri # user
assert "cafebabe" in uri # password from secrets
assert "mongodb" in uri # host
assert "27017" in uri # port
assert "mydb" in uri # dbname
assert "alfred" in uri # user
assert "cafebabe" in uri # password from secrets
assert "mongodb" in uri # host
assert "27017" in uri # port
assert "mydb" in uri # dbname
assert "authSource=admin" in uri
def test_postgres_uri_contains_all_components(self, alfred_file, secrets_file):
@@ -183,7 +182,7 @@ class TestBuildUris:
uri = load_env_file(secrets_file)["POSTGRES_URI"]
assert "alfred" in uri
assert "f00dface" in uri # password from secrets
assert "f00dface" in uri # password from secrets
assert "vectordb" in uri
assert "5432" in uri
assert uri.startswith("postgresql://")
@@ -191,7 +190,9 @@ class TestBuildUris:
def test_uri_is_updated_when_host_changes(self, tmp_path, secrets_file):
"""If MONGO_HOST changes in .env.alfred, the URI must reflect it."""
alfred = tmp_path / ".env.alfred"
alfred.write_text(ALFRED_ENV.replace("MONGO_HOST=mongodb", "MONGO_HOST=newhost"))
alfred.write_text(
ALFRED_ENV.replace("MONGO_HOST=mongodb", "MONGO_HOST=newhost")
)
build_uris(alfred, secrets_file)
uri = load_env_file(secrets_file)["MONGO_URI"]
@@ -217,7 +218,9 @@ class TestBuildUris:
uri_v1 = load_env_file(secrets_file)["MONGO_URI"]
alfred_v2 = tmp_path / "alfred_v2"
alfred_v2.write_text(ALFRED_ENV.replace("MONGO_DB_NAME=mydb", "MONGO_DB_NAME=otherdb"))
alfred_v2.write_text(
ALFRED_ENV.replace("MONGO_DB_NAME=mydb", "MONGO_DB_NAME=otherdb")
)
build_uris(alfred_v2, secrets_file)
uri_v2 = load_env_file(secrets_file)["MONGO_URI"]
@@ -265,12 +268,15 @@ class TestCopyExampleIfMissing:
class TestExtractPythonVersion:
@pytest.mark.parametrize("spec,expected_full,expected_short", [
("==3.14.3", "3.14.3", "3.14"),
("^3.12.0", "3.12.0", "3.12"),
("~3.11.1", "3.11.1", "3.11"),
("3.10.5", "3.10.5", "3.10"),
])
@pytest.mark.parametrize(
"spec,expected_full,expected_short",
[
("==3.14.3", "3.14.3", "3.14"),
("^3.12.0", "3.12.0", "3.12"),
("~3.11.1", "3.11.1", "3.11"),
("3.10.5", "3.10.5", "3.10"),
],
)
def test_parses_version_specifiers(self, spec, expected_full, expected_short):
full, short = extract_python_version(spec)
assert full == expected_full
+1
View File
@@ -1,6 +1,7 @@
"""Tests for PromptBuilder."""
from alfred.agent.prompts import PromptBuilder
from alfred.agent.registry import make_tools
from alfred.settings import settings
+1
View File
@@ -1,6 +1,7 @@
"""Critical tests for prompt builder - Tests that would have caught bugs."""
from alfred.agent.prompts import PromptBuilder
from alfred.agent.registry import make_tools
from alfred.settings import settings
+1
View File
@@ -1,6 +1,7 @@
"""Edge case tests for PromptBuilder."""
from alfred.agent.prompts import PromptBuilder
from alfred.agent.registry import make_tools
from alfred.settings import settings
+1 -1
View File
@@ -3,8 +3,8 @@
import inspect
import pytest
from alfred.agent.prompts import PromptBuilder
from alfred.agent.registry import Tool, _create_tool_from_function, make_tools
from alfred.settings import settings
+1 -3
View File
@@ -1,12 +1,9 @@
"""Tests for language tools."""
import pytest
from alfred.agent.tools.language import set_language
class TestSetLanguage:
def test_success_returns_ok(self, memory):
result = set_language("fr")
assert result["status"] == "ok"
@@ -20,6 +17,7 @@ class TestSetLanguage:
set_language("es")
# Verify it's stored in STM
from alfred.infrastructure.persistence import get_memory
mem = get_memory()
assert mem.stm.language == "es"
+15 -6
View File
@@ -4,15 +4,14 @@ Tests for alfred.agent.workflows.loader.WorkflowLoader
import pytest
import yaml
from pathlib import Path
from alfred.agent.workflows.loader import WorkflowLoader
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def workflows_dir(tmp_path):
"""A temp directory pre-populated with one valid workflow YAML."""
@@ -32,6 +31,7 @@ def workflows_dir(tmp_path):
def loader_from_dir(workflows_dir, monkeypatch):
"""WorkflowLoader pointed at our temp dir."""
import alfred.agent.workflows.loader as loader_module
monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", workflows_dir)
return WorkflowLoader()
@@ -40,8 +40,8 @@ def loader_from_dir(workflows_dir, monkeypatch):
# Real loader (loads actual YAML files from the repo)
# ---------------------------------------------------------------------------
class TestRealWorkflows:
class TestRealWorkflows:
def test_organize_media_loaded(self):
loader = WorkflowLoader()
assert "organize_media" in loader.names()
@@ -96,8 +96,8 @@ class TestRealWorkflows:
# WorkflowLoader mechanics (via monkeypatched dir)
# ---------------------------------------------------------------------------
class TestLoaderMechanics:
class TestLoaderMechanics:
def test_get_returns_workflow(self, loader_from_dir):
wf = loader_from_dir.get("test_workflow")
assert wf is not None
@@ -119,6 +119,7 @@ class TestLoaderMechanics:
def test_uses_yaml_name_field(self, tmp_path, monkeypatch):
"""name from YAML content takes priority over filename stem."""
import alfred.agent.workflows.loader as loader_module
monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", tmp_path)
wf = {"name": "my_custom_name", "steps": []}
@@ -130,6 +131,7 @@ class TestLoaderMechanics:
def test_falls_back_to_stem_when_no_name(self, tmp_path, monkeypatch):
import alfred.agent.workflows.loader as loader_module
monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", tmp_path)
(tmp_path / "my_workflow.yaml").write_text(yaml.dump({"steps": []}))
@@ -138,6 +140,7 @@ class TestLoaderMechanics:
def test_skips_malformed_yaml(self, tmp_path, monkeypatch):
import alfred.agent.workflows.loader as loader_module
monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", tmp_path)
(tmp_path / "valid.yaml").write_text(yaml.dump({"name": "valid", "steps": []}))
@@ -150,10 +153,15 @@ class TestLoaderMechanics:
def test_deterministic_load_order(self, tmp_path, monkeypatch):
"""Files loaded in sorted order — later file wins on name collision."""
import alfred.agent.workflows.loader as loader_module
monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", tmp_path)
(tmp_path / "a_workflow.yaml").write_text(yaml.dump({"name": "duplicate", "version": 1}))
(tmp_path / "b_workflow.yaml").write_text(yaml.dump({"name": "duplicate", "version": 2}))
(tmp_path / "a_workflow.yaml").write_text(
yaml.dump({"name": "duplicate", "version": 1})
)
(tmp_path / "b_workflow.yaml").write_text(
yaml.dump({"name": "duplicate", "version": 2})
)
loader = WorkflowLoader()
# b_workflow loaded last → version 2 wins
@@ -161,6 +169,7 @@ class TestLoaderMechanics:
def test_empty_directory(self, tmp_path, monkeypatch):
import alfred.agent.workflows.loader as loader_module
monkeypatch.setattr(loader_module, "_WORKFLOWS_DIR", tmp_path)
loader = WorkflowLoader()