e45465d52d
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.
127 lines
4.4 KiB
Python
127 lines
4.4 KiB
Python
"""RuleSetRepository — loads SubtitleRuleSet from .alfred/ YAML files."""
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING
|
|
|
|
import yaml
|
|
|
|
from alfred.domain.subtitles.aggregates import SubtitleRuleSet
|
|
from alfred.domain.subtitles.value_objects import RuleScope
|
|
|
|
if TYPE_CHECKING:
|
|
from alfred.infrastructure.persistence.memory.ltm.components.subtitle_preferences import (
|
|
SubtitlePreferences,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _load_yaml(path: Path) -> dict:
|
|
if not path.exists():
|
|
return {}
|
|
try:
|
|
with open(path, encoding="utf-8") as f:
|
|
return yaml.safe_load(f) or {}
|
|
except Exception as e:
|
|
logger.warning(f"RuleSetRepository: could not read {path}: {e}")
|
|
return {}
|
|
|
|
|
|
class RuleSetRepository:
|
|
"""
|
|
Builds a fully chained SubtitleRuleSet by reading YAML from .alfred/.
|
|
|
|
Inheritance chain:
|
|
global (hardcoded defaults)
|
|
└── release_group (.alfred/release_groups/{GROUP}.yaml)
|
|
└── local (.alfred/rules.yaml)
|
|
|
|
Rules are delta-only — None means "inherit from parent".
|
|
The repository only creates intermediate nodes when the corresponding
|
|
file exists and contains an override section.
|
|
"""
|
|
|
|
def __init__(self, library_root: Path):
|
|
self._alfred_dir = library_root / ".alfred"
|
|
|
|
def load(
|
|
self,
|
|
release_group: str | None = None,
|
|
subtitle_preferences: SubtitlePreferences | None = None,
|
|
) -> SubtitleRuleSet:
|
|
"""
|
|
Build and return the resolved RuleSet chain.
|
|
|
|
If subtitle_preferences is provided, it seeds the global base rule set
|
|
from LTM (overriding the hardcoded DEFAULT_RULES).
|
|
Returns global default if no overrides exist.
|
|
"""
|
|
base = SubtitleRuleSet.global_default()
|
|
if subtitle_preferences is not None:
|
|
base.override(
|
|
languages=subtitle_preferences.languages,
|
|
formats=subtitle_preferences.formats,
|
|
types=subtitle_preferences.types,
|
|
)
|
|
current = base
|
|
|
|
# Release group level
|
|
if release_group:
|
|
rg_path = self._alfred_dir / "release_groups" / f"{release_group}.yaml"
|
|
rg_data = _load_yaml(rg_path).get("override", {})
|
|
if rg_data:
|
|
rg_ruleset = SubtitleRuleSet(
|
|
scope=RuleScope(level="release_group", identifier=release_group),
|
|
parent=current,
|
|
)
|
|
rg_ruleset.override(**_filter_override(rg_data))
|
|
current = rg_ruleset
|
|
logger.debug(
|
|
f"RuleSetRepository: loaded release_group override for '{release_group}'"
|
|
)
|
|
|
|
# Local (show/movie) level
|
|
local_data = _load_yaml(self._alfred_dir / "rules.yaml").get("override", {})
|
|
if local_data:
|
|
local_ruleset = SubtitleRuleSet(
|
|
scope=RuleScope(level="show"),
|
|
parent=current,
|
|
)
|
|
local_ruleset.override(**_filter_override(local_data))
|
|
current = local_ruleset
|
|
logger.debug("RuleSetRepository: loaded local rules.yaml override")
|
|
|
|
return current
|
|
|
|
def save_local(self, delta: dict) -> None:
|
|
"""Write or update .alfred/rules.yaml with override delta."""
|
|
self._alfred_dir.mkdir(parents=True, exist_ok=True)
|
|
path = self._alfred_dir / "rules.yaml"
|
|
existing = _load_yaml(path)
|
|
existing_override = existing.get("override", {})
|
|
existing_override.update(delta)
|
|
data = {"override": existing_override}
|
|
tmp = path.with_suffix(".yaml.tmp")
|
|
try:
|
|
with open(tmp, "w", encoding="utf-8") as f:
|
|
yaml.safe_dump(
|
|
data,
|
|
f,
|
|
allow_unicode=True,
|
|
default_flow_style=False,
|
|
sort_keys=False,
|
|
)
|
|
tmp.rename(path)
|
|
logger.info(f"RuleSetRepository: saved local rules to {path}")
|
|
except Exception as e:
|
|
logger.error(f"RuleSetRepository: could not write {path}: {e}")
|
|
tmp.unlink(missing_ok=True)
|
|
raise
|
|
|
|
|
|
def _filter_override(data: dict) -> dict:
|
|
"""Keep only keys that SubtitleRuleSet.override() accepts."""
|
|
valid = {"languages", "formats", "types", "format_priority", "min_confidence"}
|
|
return {k: v for k, v in data.items() if k in valid}
|