Files
alfred/alfred/infrastructure/subtitle/rule_repository.py
T
francwa e45465d52d 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.
2026-05-14 05:01:59 +02:00

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}