chore: apply pre-commit auto-fixes (trim trailing whitespace, EOF)
This commit is contained in:
@@ -192,9 +192,7 @@ class Agent:
|
|||||||
if cache_key_value is not None:
|
if cache_key_value is not None:
|
||||||
cached = memory.stm.tool_results.get(tool_name, cache_key_value)
|
cached = memory.stm.tool_results.get(tool_name, cache_key_value)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
logger.info(
|
logger.info(f"Tool cache HIT: {tool_name}[{cache_key_value}]")
|
||||||
f"Tool cache HIT: {tool_name}[{cache_key_value}]"
|
|
||||||
)
|
|
||||||
self._post_tool_side_effects(tool_name, args, cached, from_cache=True)
|
self._post_tool_side_effects(tool_name, args, cached, from_cache=True)
|
||||||
return {**cached, "_from_cache": True}
|
return {**cached, "_from_cache": True}
|
||||||
|
|
||||||
|
|||||||
@@ -165,7 +165,9 @@ EXPRESSIONS À UTILISER (une par situation, naturellement intégrées dans ta r
|
|||||||
lines.append(" Steps:")
|
lines.append(" Steps:")
|
||||||
for step in steps:
|
for step in steps:
|
||||||
step_id = step.get("id", "?")
|
step_id = step.get("id", "?")
|
||||||
step_tool = step.get("tool") or ("ask_user" if step.get("ask_user") else "—")
|
step_tool = step.get("tool") or (
|
||||||
|
"ask_user" if step.get("ask_user") else "—"
|
||||||
|
)
|
||||||
lines.append(f" - {step_id} ({step_tool})")
|
lines.append(f" - {step_id} ({step_tool})")
|
||||||
lines.append(" Call end_workflow(reason) when done, cancelled, or off-topic.")
|
lines.append(" Call end_workflow(reason) when done, cancelled, or off-topic.")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|||||||
+15
-10
@@ -27,9 +27,9 @@ class ToolSpecError(ValueError):
|
|||||||
class ParameterSpec:
|
class ParameterSpec:
|
||||||
"""Semantic description of a single tool parameter."""
|
"""Semantic description of a single tool parameter."""
|
||||||
|
|
||||||
description: str # Short: what the value represents.
|
description: str # Short: what the value represents.
|
||||||
why_needed: str # Why the tool needs this — drives LLM reasoning.
|
why_needed: str # Why the tool needs this — drives LLM reasoning.
|
||||||
example: str | None = None # Concrete example value, shown to the LLM.
|
example: str | None = None # Concrete example value, shown to the LLM.
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, name: str, data: dict) -> ParameterSpec:
|
def from_dict(cls, name: str, data: dict) -> ParameterSpec:
|
||||||
@@ -38,7 +38,9 @@ class ParameterSpec:
|
|||||||
return cls(
|
return cls(
|
||||||
description=str(data["description"]).strip(),
|
description=str(data["description"]).strip(),
|
||||||
why_needed=str(data["why_needed"]).strip(),
|
why_needed=str(data["why_needed"]).strip(),
|
||||||
example=str(data["example"]).strip() if data.get("example") is not None else None,
|
example=str(data["example"]).strip()
|
||||||
|
if data.get("example") is not None
|
||||||
|
else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -54,7 +56,9 @@ class ReturnsSpec:
|
|||||||
_require(data, "description", f"returns.{key}")
|
_require(data, "description", f"returns.{key}")
|
||||||
fields = data.get("fields") or {}
|
fields = data.get("fields") or {}
|
||||||
if not isinstance(fields, dict):
|
if not isinstance(fields, dict):
|
||||||
raise ToolSpecError(f"returns.{key}.fields must be a dict, got {type(fields).__name__}")
|
raise ToolSpecError(
|
||||||
|
f"returns.{key}.fields must be a dict, got {type(fields).__name__}"
|
||||||
|
)
|
||||||
return cls(
|
return cls(
|
||||||
description=str(data["description"]).strip(),
|
description=str(data["description"]).strip(),
|
||||||
fields={str(k): str(v).strip() for k, v in fields.items()},
|
fields={str(k): str(v).strip() for k, v in fields.items()},
|
||||||
@@ -78,14 +82,14 @@ class ToolSpec:
|
|||||||
"""Full semantic spec for one tool."""
|
"""Full semantic spec for one tool."""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
summary: str # One-liner — becomes Tool.description.
|
summary: str # One-liner — becomes Tool.description.
|
||||||
description: str # Longer paragraph.
|
description: str # Longer paragraph.
|
||||||
when_to_use: str
|
when_to_use: str
|
||||||
when_not_to_use: str | None
|
when_not_to_use: str | None
|
||||||
next_steps: str | None
|
next_steps: str | None
|
||||||
parameters: dict[str, ParameterSpec] # name -> ParameterSpec
|
parameters: dict[str, ParameterSpec] # name -> ParameterSpec
|
||||||
returns: dict[str, ReturnsSpec] # status_key -> ReturnsSpec
|
returns: dict[str, ReturnsSpec] # status_key -> ReturnsSpec
|
||||||
cache: CacheSpec | None = None # If present, tool is cached.
|
cache: CacheSpec | None = None # If present, tool is cached.
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_yaml_path(cls, path: Path) -> ToolSpec:
|
def from_yaml_path(cls, path: Path) -> ToolSpec:
|
||||||
@@ -200,6 +204,7 @@ class ToolSpec:
|
|||||||
# Helpers
|
# Helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _require(data: dict, key: str, where: str) -> None:
|
def _require(data: dict, key: str, where: str) -> None:
|
||||||
if data.get(key) is None or (isinstance(data[key], str) and not data[key].strip()):
|
if data.get(key) is None or (isinstance(data[key], str) and not data[key].strip()):
|
||||||
raise ToolSpecError(f"{where}: missing required field '{key}'")
|
raise ToolSpecError(f"{where}: missing required field '{key}'")
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ def _sanitize(text: str) -> str:
|
|||||||
return _WIN_FORBIDDEN.sub("", text)
|
return _WIN_FORBIDDEN.sub("", text)
|
||||||
|
|
||||||
|
|
||||||
def _find_existing_tvshow_folders(tv_root: Path, tmdb_title: str, tmdb_year: int) -> list[str]:
|
def _find_existing_tvshow_folders(
|
||||||
|
tv_root: Path, tmdb_title: str, tmdb_year: int
|
||||||
|
) -> list[str]:
|
||||||
"""Return folder names in tv_root that match title + year prefix."""
|
"""Return folder names in tv_root that match title + year prefix."""
|
||||||
if not tv_root.exists():
|
if not tv_root.exists():
|
||||||
return []
|
return []
|
||||||
@@ -52,9 +54,11 @@ def _get_tv_root() -> Path | None:
|
|||||||
# Internal sentinel + series-folder resolver (shared by the 3 TV use cases)
|
# Internal sentinel + series-folder resolver (shared by the 3 TV use cases)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class _Clarification:
|
class _Clarification:
|
||||||
"""Module-private sentinel signalling that user input is needed."""
|
"""Module-private sentinel signalling that user input is needed."""
|
||||||
|
|
||||||
question: str
|
question: str
|
||||||
options: list[str]
|
options: list[str]
|
||||||
|
|
||||||
@@ -99,6 +103,7 @@ def _resolve_series_folder(
|
|||||||
# DTOs
|
# DTOs
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class _ResolvedDestinationBase:
|
class _ResolvedDestinationBase:
|
||||||
"""
|
"""
|
||||||
@@ -109,7 +114,7 @@ class _ResolvedDestinationBase:
|
|||||||
and a to_dict() that delegates the non-ok cases via _base_dict().
|
and a to_dict() that delegates the non-ok cases via _base_dict().
|
||||||
"""
|
"""
|
||||||
|
|
||||||
status: str # "ok" | "needs_clarification" | "error"
|
status: str # "ok" | "needs_clarification" | "error"
|
||||||
|
|
||||||
# needs_clarification
|
# needs_clarification
|
||||||
question: str | None = None
|
question: str | None = None
|
||||||
@@ -124,7 +129,11 @@ class _ResolvedDestinationBase:
|
|||||||
if self.status == "error":
|
if self.status == "error":
|
||||||
return {"status": self.status, "error": self.error, "message": self.message}
|
return {"status": self.status, "error": self.error, "message": self.message}
|
||||||
if self.status == "needs_clarification":
|
if self.status == "needs_clarification":
|
||||||
return {"status": self.status, "question": self.question, "options": self.options or []}
|
return {
|
||||||
|
"status": self.status,
|
||||||
|
"question": self.question,
|
||||||
|
"options": self.options or [],
|
||||||
|
}
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -155,7 +164,7 @@ class ResolvedEpisodeDestination(_ResolvedDestinationBase):
|
|||||||
|
|
||||||
series_folder: str | None = None
|
series_folder: str | None = None
|
||||||
season_folder: str | None = None
|
season_folder: str | None = None
|
||||||
library_file: str | None = None # full path to destination .mkv
|
library_file: str | None = None # full path to destination .mkv
|
||||||
series_folder_name: str | None = None
|
series_folder_name: str | None = None
|
||||||
season_folder_name: str | None = None
|
season_folder_name: str | None = None
|
||||||
filename: str | None = None
|
filename: str | None = None
|
||||||
@@ -216,6 +225,7 @@ class ResolvedSeriesDestination(_ResolvedDestinationBase):
|
|||||||
# Use cases
|
# Use cases
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def resolve_season_destination(
|
def resolve_season_destination(
|
||||||
release_name: str,
|
release_name: str,
|
||||||
tmdb_title: str,
|
tmdb_title: str,
|
||||||
@@ -231,14 +241,17 @@ def resolve_season_destination(
|
|||||||
tv_root = _get_tv_root()
|
tv_root = _get_tv_root()
|
||||||
if not tv_root:
|
if not tv_root:
|
||||||
return ResolvedSeasonDestination(
|
return ResolvedSeasonDestination(
|
||||||
status="error", error="library_not_set",
|
status="error",
|
||||||
|
error="library_not_set",
|
||||||
message="TV show library path is not configured.",
|
message="TV show library path is not configured.",
|
||||||
)
|
)
|
||||||
|
|
||||||
parsed = parse_release(release_name)
|
parsed = parse_release(release_name)
|
||||||
computed_name = _sanitize(parsed.show_folder_name(tmdb_title, tmdb_year))
|
computed_name = _sanitize(parsed.show_folder_name(tmdb_title, tmdb_year))
|
||||||
|
|
||||||
resolved = _resolve_series_folder(tv_root, tmdb_title, tmdb_year, computed_name, confirmed_folder)
|
resolved = _resolve_series_folder(
|
||||||
|
tv_root, tmdb_title, tmdb_year, computed_name, confirmed_folder
|
||||||
|
)
|
||||||
if isinstance(resolved, _Clarification):
|
if isinstance(resolved, _Clarification):
|
||||||
return ResolvedSeasonDestination(
|
return ResolvedSeasonDestination(
|
||||||
status="needs_clarification",
|
status="needs_clarification",
|
||||||
@@ -277,7 +290,8 @@ def resolve_episode_destination(
|
|||||||
tv_root = _get_tv_root()
|
tv_root = _get_tv_root()
|
||||||
if not tv_root:
|
if not tv_root:
|
||||||
return ResolvedEpisodeDestination(
|
return ResolvedEpisodeDestination(
|
||||||
status="error", error="library_not_set",
|
status="error",
|
||||||
|
error="library_not_set",
|
||||||
message="TV show library path is not configured.",
|
message="TV show library path is not configured.",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -285,7 +299,9 @@ def resolve_episode_destination(
|
|||||||
ext = Path(source_file).suffix
|
ext = Path(source_file).suffix
|
||||||
computed_name = _sanitize(parsed.show_folder_name(tmdb_title, tmdb_year))
|
computed_name = _sanitize(parsed.show_folder_name(tmdb_title, tmdb_year))
|
||||||
|
|
||||||
resolved = _resolve_series_folder(tv_root, tmdb_title, tmdb_year, computed_name, confirmed_folder)
|
resolved = _resolve_series_folder(
|
||||||
|
tv_root, tmdb_title, tmdb_year, computed_name, confirmed_folder
|
||||||
|
)
|
||||||
if isinstance(resolved, _Clarification):
|
if isinstance(resolved, _Clarification):
|
||||||
return ResolvedEpisodeDestination(
|
return ResolvedEpisodeDestination(
|
||||||
status="needs_clarification",
|
status="needs_clarification",
|
||||||
@@ -328,7 +344,8 @@ def resolve_movie_destination(
|
|||||||
movies_root = memory.ltm.library_paths.get("movie")
|
movies_root = memory.ltm.library_paths.get("movie")
|
||||||
if not movies_root:
|
if not movies_root:
|
||||||
return ResolvedMovieDestination(
|
return ResolvedMovieDestination(
|
||||||
status="error", error="library_not_set",
|
status="error",
|
||||||
|
error="library_not_set",
|
||||||
message="Movie library path is not configured.",
|
message="Movie library path is not configured.",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -365,14 +382,17 @@ def resolve_series_destination(
|
|||||||
tv_root = _get_tv_root()
|
tv_root = _get_tv_root()
|
||||||
if not tv_root:
|
if not tv_root:
|
||||||
return ResolvedSeriesDestination(
|
return ResolvedSeriesDestination(
|
||||||
status="error", error="library_not_set",
|
status="error",
|
||||||
|
error="library_not_set",
|
||||||
message="TV show library path is not configured.",
|
message="TV show library path is not configured.",
|
||||||
)
|
)
|
||||||
|
|
||||||
parsed = parse_release(release_name)
|
parsed = parse_release(release_name)
|
||||||
computed_name = _sanitize(parsed.show_folder_name(tmdb_title, tmdb_year))
|
computed_name = _sanitize(parsed.show_folder_name(tmdb_title, tmdb_year))
|
||||||
|
|
||||||
resolved = _resolve_series_folder(tv_root, tmdb_title, tmdb_year, computed_name, confirmed_folder)
|
resolved = _resolve_series_folder(
|
||||||
|
tv_root, tmdb_title, tmdb_year, computed_name, confirmed_folder
|
||||||
|
)
|
||||||
if isinstance(resolved, _Clarification):
|
if isinstance(resolved, _Clarification):
|
||||||
return ResolvedSeriesDestination(
|
return ResolvedSeriesDestination(
|
||||||
status="needs_clarification",
|
status="needs_clarification",
|
||||||
|
|||||||
@@ -75,8 +75,15 @@ class LanguageRegistry:
|
|||||||
self._load()
|
self._load()
|
||||||
|
|
||||||
def _load(self) -> None:
|
def _load(self) -> None:
|
||||||
builtin = _load_yaml(_BUILTIN_ROOT / "iso_languages.yaml").get("languages", {}) or {}
|
builtin = (
|
||||||
learned = _load_yaml(_LEARNED_ROOT / "iso_languages_learned.yaml").get("languages", {}) or {}
|
_load_yaml(_BUILTIN_ROOT / "iso_languages.yaml").get("languages", {}) or {}
|
||||||
|
)
|
||||||
|
learned = (
|
||||||
|
_load_yaml(_LEARNED_ROOT / "iso_languages_learned.yaml").get(
|
||||||
|
"languages", {}
|
||||||
|
)
|
||||||
|
or {}
|
||||||
|
)
|
||||||
merged = _merge_language_entries(builtin, learned)
|
merged = _merge_language_entries(builtin, learned)
|
||||||
|
|
||||||
for iso, entry in merged.items():
|
for iso, entry in merged.items():
|
||||||
|
|||||||
@@ -155,7 +155,9 @@ class Language:
|
|||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
if not isinstance(self.iso, str) or not self.iso:
|
if not isinstance(self.iso, str) or not self.iso:
|
||||||
raise ValidationError(f"Language.iso must be a non-empty string, got {self.iso!r}")
|
raise ValidationError(
|
||||||
|
f"Language.iso must be a non-empty string, got {self.iso!r}"
|
||||||
|
)
|
||||||
if len(self.iso) != 3:
|
if len(self.iso) != 3:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
f"Language.iso must be a 3-letter ISO 639-2/B code, got {self.iso!r}"
|
f"Language.iso must be a 3-letter ISO 639-2/B code, got {self.iso!r}"
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ class SubtitleKnowledgeBase:
|
|||||||
self._build()
|
self._build()
|
||||||
|
|
||||||
def _build(self) -> None: # noqa: PLR0912 — straight-line YAML projection
|
def _build(self) -> None: # noqa: PLR0912 — straight-line YAML projection
|
||||||
|
|
||||||
data = self._loader.subtitles()
|
data = self._loader.subtitles()
|
||||||
|
|
||||||
self._formats: dict[str, SubtitleFormat] = {}
|
self._formats: dict[str, SubtitleFormat] = {}
|
||||||
|
|||||||
@@ -302,7 +302,9 @@ class SubtitleIdentifier:
|
|||||||
raw_tokens=tokens,
|
raw_tokens=tokens,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _disambiguate_by_size(self, tracks: list[SubtitleCandidate]) -> list[SubtitleCandidate]:
|
def _disambiguate_by_size(
|
||||||
|
self, tracks: list[SubtitleCandidate]
|
||||||
|
) -> list[SubtitleCandidate]:
|
||||||
"""
|
"""
|
||||||
When multiple tracks share the same language and type is UNKNOWN/STANDARD,
|
When multiple tracks share the same language and type is UNKNOWN/STANDARD,
|
||||||
the one with the most entries (lines) is SDH, the smallest is FORCED if
|
the one with the most entries (lines) is SDH, the smallest is FORCED if
|
||||||
|
|||||||
@@ -124,7 +124,9 @@ class Episode:
|
|||||||
return f"S{self.season_number.value:02d}E{self.episode_number.value:02d} - {self.title}"
|
return f"S{self.season_number.value:02d}E{self.episode_number.value:02d} - {self.title}"
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"Episode(S{self.season_number.value:02d}E{self.episode_number.value:02d})"
|
return (
|
||||||
|
f"Episode(S{self.season_number.value:02d}E{self.episode_number.value:02d})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ════════════════════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
@@ -167,9 +169,7 @@ class Season:
|
|||||||
f"expected_episodes must be >= 0, got {self.expected_episodes}"
|
f"expected_episodes must be >= 0, got {self.expected_episodes}"
|
||||||
)
|
)
|
||||||
if self.aired_episodes is not None and self.aired_episodes < 0:
|
if self.aired_episodes is not None and self.aired_episodes < 0:
|
||||||
raise ValueError(
|
raise ValueError(f"aired_episodes must be >= 0, got {self.aired_episodes}")
|
||||||
f"aired_episodes must be >= 0, got {self.aired_episodes}"
|
|
||||||
)
|
|
||||||
if (
|
if (
|
||||||
self.expected_episodes is not None
|
self.expected_episodes is not None
|
||||||
and self.aired_episodes is not None
|
and self.aired_episodes is not None
|
||||||
@@ -191,7 +191,11 @@ class Season:
|
|||||||
|
|
||||||
def _effective_aired(self) -> int | None:
|
def _effective_aired(self) -> int | None:
|
||||||
"""``aired_episodes`` if set, else fall back to ``expected_episodes``."""
|
"""``aired_episodes`` if set, else fall back to ``expected_episodes``."""
|
||||||
return self.aired_episodes if self.aired_episodes is not None else self.expected_episodes
|
return (
|
||||||
|
self.aired_episodes
|
||||||
|
if self.aired_episodes is not None
|
||||||
|
else self.expected_episodes
|
||||||
|
)
|
||||||
|
|
||||||
def is_complete(self) -> bool:
|
def is_complete(self) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -259,7 +263,9 @@ class Season:
|
|||||||
return f"Season {self.season_number.value}"
|
return f"Season {self.season_number.value}"
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"Season(number={self.season_number.value}, episodes={len(self.episodes)})"
|
return (
|
||||||
|
f"Season(number={self.season_number.value}, episodes={len(self.episodes)})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ════════════════════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
@@ -298,13 +304,17 @@ class TVShow:
|
|||||||
if isinstance(self.imdb_id, str):
|
if isinstance(self.imdb_id, str):
|
||||||
self.imdb_id = ImdbId(self.imdb_id)
|
self.imdb_id = ImdbId(self.imdb_id)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"imdb_id must be ImdbId or str, got {type(self.imdb_id)}")
|
raise ValueError(
|
||||||
|
f"imdb_id must be ImdbId or str, got {type(self.imdb_id)}"
|
||||||
|
)
|
||||||
|
|
||||||
if not isinstance(self.status, ShowStatus):
|
if not isinstance(self.status, ShowStatus):
|
||||||
if isinstance(self.status, str):
|
if isinstance(self.status, str):
|
||||||
self.status = ShowStatus.from_string(self.status)
|
self.status = ShowStatus.from_string(self.status)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"status must be ShowStatus or str, got {type(self.status)}")
|
raise ValueError(
|
||||||
|
f"status must be ShowStatus or str, got {type(self.status)}"
|
||||||
|
)
|
||||||
|
|
||||||
if self.expected_seasons is not None and self.expected_seasons < 0:
|
if self.expected_seasons is not None and self.expected_seasons < 0:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
@@ -380,7 +390,10 @@ class TVShow:
|
|||||||
# We also need to consider whether seasons themselves are missing.
|
# We also need to consider whether seasons themselves are missing.
|
||||||
# If expected_seasons is known and we have fewer seasons than expected,
|
# If expected_seasons is known and we have fewer seasons than expected,
|
||||||
# the missing seasons may have aired episodes → cannot claim COMPLETE.
|
# the missing seasons may have aired episodes → cannot claim COMPLETE.
|
||||||
if self.expected_seasons is not None and len(self.seasons) < self.expected_seasons:
|
if (
|
||||||
|
self.expected_seasons is not None
|
||||||
|
and len(self.seasons) < self.expected_seasons
|
||||||
|
):
|
||||||
return CollectionStatus.PARTIAL
|
return CollectionStatus.PARTIAL
|
||||||
|
|
||||||
return CollectionStatus.COMPLETE
|
return CollectionStatus.COMPLETE
|
||||||
@@ -397,7 +410,9 @@ class TVShow:
|
|||||||
def missing_episodes(self) -> list[tuple[SeasonNumber, EpisodeNumber]]:
|
def missing_episodes(self) -> list[tuple[SeasonNumber, EpisodeNumber]]:
|
||||||
"""All aired-but-not-owned ``(season, episode)`` pairs across the show."""
|
"""All aired-but-not-owned ``(season, episode)`` pairs across the show."""
|
||||||
result: list[tuple[SeasonNumber, EpisodeNumber]] = []
|
result: list[tuple[SeasonNumber, EpisodeNumber]] = []
|
||||||
for season_number, season in sorted(self.seasons.items(), key=lambda kv: kv[0].value):
|
for season_number, season in sorted(
|
||||||
|
self.seasons.items(), key=lambda kv: kv[0].value
|
||||||
|
):
|
||||||
for ep_number in season.missing_episodes():
|
for ep_number in season.missing_episodes():
|
||||||
result.append((season_number, ep_number))
|
result.append((season_number, ep_number))
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -104,8 +104,8 @@ class CollectionStatus(Enum):
|
|||||||
the TVShow aggregate as separate flags, not in this enum.
|
the TVShow aggregate as separate flags, not in this enum.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
EMPTY = "empty" # 0 episode owned
|
EMPTY = "empty" # 0 episode owned
|
||||||
PARTIAL = "partial" # some aired episodes are missing
|
PARTIAL = "partial" # some aired episodes are missing
|
||||||
COMPLETE = "complete" # all aired-to-date episodes are owned
|
COMPLETE = "complete" # all aired-to-date episodes are owned
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,4 @@ class ToolResultsCache:
|
|||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
# Surface only the index (tool + keys), not the payloads — payloads
|
# Surface only the index (tool + keys), not the payloads — payloads
|
||||||
# can be large and the prompt only needs to know what's available.
|
# can be large and the prompt only needs to know what's available.
|
||||||
return {
|
return {tool: list(bucket.keys()) for tool, bucket in self.results.items()}
|
||||||
tool: list(bucket.keys()) for tool, bucket in self.results.items()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -83,9 +83,7 @@ class SubtitleMetadataStore:
|
|||||||
entry["episode"] = episode
|
entry["episode"] = episode
|
||||||
|
|
||||||
self._store.append_subtitle_history_entry(entry)
|
self._store.append_subtitle_history_entry(entry)
|
||||||
marker = (
|
marker = f"S{season:02d}E{episode:02d}" if season and episode else "movie"
|
||||||
f"S{season:02d}E{episode:02d}" if season and episode else "movie"
|
|
||||||
)
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"SubtitleMetadataStore: appended history "
|
f"SubtitleMetadataStore: appended history "
|
||||||
f"({marker}) — {len(tracks_data)} track(s)"
|
f"({marker}) — {len(tracks_data)} track(s)"
|
||||||
|
|||||||
@@ -185,9 +185,7 @@ class TestCompleteHappyPath:
|
|||||||
|
|
||||||
class TestCompleteErrors:
|
class TestCompleteErrors:
|
||||||
def test_timeout_wrapped(self, client):
|
def test_timeout_wrapped(self, client):
|
||||||
with patch(
|
with patch("alfred.agent.llm.ollama.requests.post", side_effect=Timeout("t")):
|
||||||
"alfred.agent.llm.ollama.requests.post", side_effect=Timeout("t")
|
|
||||||
):
|
|
||||||
with pytest.raises(LLMAPIError, match="timeout"):
|
with pytest.raises(LLMAPIError, match="timeout"):
|
||||||
client.complete([{"role": "user", "content": "q"}])
|
client.complete([{"role": "user", "content": "q"}])
|
||||||
|
|
||||||
|
|||||||
@@ -136,17 +136,13 @@ class TestAudio:
|
|||||||
assert p.audio_channels == "5.1"
|
assert p.audio_channels == "5.1"
|
||||||
|
|
||||||
def test_channel_count_unknown_falls_back(self):
|
def test_channel_count_unknown_falls_back(self):
|
||||||
info = MediaInfo(
|
info = MediaInfo(audio_tracks=[AudioTrack(0, "aac", 4, "quad", "eng")])
|
||||||
audio_tracks=[AudioTrack(0, "aac", 4, "quad", "eng")]
|
|
||||||
)
|
|
||||||
p = _bare()
|
p = _bare()
|
||||||
enrich_from_probe(p, info)
|
enrich_from_probe(p, info)
|
||||||
assert p.audio_channels == "4ch"
|
assert p.audio_channels == "4ch"
|
||||||
|
|
||||||
def test_unknown_audio_codec_uppercased(self):
|
def test_unknown_audio_codec_uppercased(self):
|
||||||
info = MediaInfo(
|
info = MediaInfo(audio_tracks=[AudioTrack(0, "newcodec", 2, "stereo", "eng")])
|
||||||
audio_tracks=[AudioTrack(0, "newcodec", 2, "stereo", "eng")]
|
|
||||||
)
|
|
||||||
p = _bare()
|
p = _bare()
|
||||||
enrich_from_probe(p, info)
|
enrich_from_probe(p, info)
|
||||||
assert p.audio_codec == "NEWCODEC"
|
assert p.audio_codec == "NEWCODEC"
|
||||||
@@ -158,9 +154,7 @@ class TestAudio:
|
|||||||
assert p.audio_channels is None
|
assert p.audio_channels is None
|
||||||
|
|
||||||
def test_does_not_overwrite_existing_audio_fields(self):
|
def test_does_not_overwrite_existing_audio_fields(self):
|
||||||
info = MediaInfo(
|
info = MediaInfo(audio_tracks=[AudioTrack(0, "ac3", 6, "5.1", "eng")])
|
||||||
audio_tracks=[AudioTrack(0, "ac3", 6, "5.1", "eng")]
|
|
||||||
)
|
|
||||||
p = _bare(audio_codec="DTS-HD.MA", audio_channels="7.1")
|
p = _bare(audio_codec="DTS-HD.MA", audio_channels="7.1")
|
||||||
enrich_from_probe(p, info)
|
enrich_from_probe(p, info)
|
||||||
assert p.audio_codec == "DTS-HD.MA"
|
assert p.audio_codec == "DTS-HD.MA"
|
||||||
|
|||||||
@@ -142,7 +142,9 @@ class TestHelpers:
|
|||||||
def test_pair_placed_falls_back_to_positional(self):
|
def test_pair_placed_falls_back_to_positional(self):
|
||||||
# Placed source path doesn't match any track.file_path → fallback uses tracks[0].
|
# Placed source path doesn't match any track.file_path → fallback uses tracks[0].
|
||||||
t = _track(file_path=Path("/in/known.srt"))
|
t = _track(file_path=Path("/in/known.srt"))
|
||||||
p = PlacedTrack(source=Path("/in/ghost.srt"), destination=Path("/x"), filename="x")
|
p = PlacedTrack(
|
||||||
|
source=Path("/in/ghost.srt"), destination=Path("/x"), filename="x"
|
||||||
|
)
|
||||||
pairs = _pair_placed_with_tracks([p], [t])
|
pairs = _pair_placed_with_tracks([p], [t])
|
||||||
assert pairs == [(p, t)]
|
assert pairs == [(p, t)]
|
||||||
|
|
||||||
|
|||||||
@@ -78,7 +78,9 @@ class TestFindExistingTvshowFolders:
|
|||||||
(tmp_path / "oz.1997.bluray-OTHER").mkdir()
|
(tmp_path / "oz.1997.bluray-OTHER").mkdir()
|
||||||
(tmp_path / "OtherShow.1999").mkdir()
|
(tmp_path / "OtherShow.1999").mkdir()
|
||||||
out = _find_existing_tvshow_folders(tmp_path, "Oz", 1997)
|
out = _find_existing_tvshow_folders(tmp_path, "Oz", 1997)
|
||||||
assert out == ["Oz.1997.WEBRip-KONTRAST", "oz.1997.bluray-OTHER"] or set(out) == {
|
assert out == ["Oz.1997.WEBRip-KONTRAST", "oz.1997.bluray-OTHER"] or set(
|
||||||
|
out
|
||||||
|
) == {
|
||||||
"Oz.1997.WEBRip-KONTRAST",
|
"Oz.1997.WEBRip-KONTRAST",
|
||||||
"oz.1997.bluray-OTHER",
|
"oz.1997.bluray-OTHER",
|
||||||
}
|
}
|
||||||
@@ -103,28 +105,42 @@ class TestResolveSeriesFolderInternal:
|
|||||||
def test_confirmed_folder_when_exists(self, tmp_path):
|
def test_confirmed_folder_when_exists(self, tmp_path):
|
||||||
(tmp_path / "Oz.1997.X-GRP").mkdir()
|
(tmp_path / "Oz.1997.X-GRP").mkdir()
|
||||||
out = _resolve_series_folder(
|
out = _resolve_series_folder(
|
||||||
tmp_path, "Oz", 1997, "Oz.1997.WEBRip-KONTRAST", confirmed_folder="Oz.1997.X-GRP"
|
tmp_path,
|
||||||
|
"Oz",
|
||||||
|
1997,
|
||||||
|
"Oz.1997.WEBRip-KONTRAST",
|
||||||
|
confirmed_folder="Oz.1997.X-GRP",
|
||||||
)
|
)
|
||||||
assert out == ("Oz.1997.X-GRP", False)
|
assert out == ("Oz.1997.X-GRP", False)
|
||||||
|
|
||||||
def test_confirmed_folder_when_new(self, tmp_path):
|
def test_confirmed_folder_when_new(self, tmp_path):
|
||||||
out = _resolve_series_folder(
|
out = _resolve_series_folder(
|
||||||
tmp_path, "Oz", 1997, "Oz.1997.WEBRip-KONTRAST", confirmed_folder="Oz.1997.New-X"
|
tmp_path,
|
||||||
|
"Oz",
|
||||||
|
1997,
|
||||||
|
"Oz.1997.WEBRip-KONTRAST",
|
||||||
|
confirmed_folder="Oz.1997.New-X",
|
||||||
)
|
)
|
||||||
assert out == ("Oz.1997.New-X", True)
|
assert out == ("Oz.1997.New-X", True)
|
||||||
|
|
||||||
def test_no_existing_returns_computed_as_new(self, tmp_path):
|
def test_no_existing_returns_computed_as_new(self, tmp_path):
|
||||||
out = _resolve_series_folder(tmp_path, "Oz", 1997, "Oz.1997.WEBRip-KONTRAST", None)
|
out = _resolve_series_folder(
|
||||||
|
tmp_path, "Oz", 1997, "Oz.1997.WEBRip-KONTRAST", None
|
||||||
|
)
|
||||||
assert out == ("Oz.1997.WEBRip-KONTRAST", True)
|
assert out == ("Oz.1997.WEBRip-KONTRAST", True)
|
||||||
|
|
||||||
def test_single_existing_matching_computed_returns_existing(self, tmp_path):
|
def test_single_existing_matching_computed_returns_existing(self, tmp_path):
|
||||||
(tmp_path / "Oz.1997.WEBRip-KONTRAST").mkdir()
|
(tmp_path / "Oz.1997.WEBRip-KONTRAST").mkdir()
|
||||||
out = _resolve_series_folder(tmp_path, "Oz", 1997, "Oz.1997.WEBRip-KONTRAST", None)
|
out = _resolve_series_folder(
|
||||||
|
tmp_path, "Oz", 1997, "Oz.1997.WEBRip-KONTRAST", None
|
||||||
|
)
|
||||||
assert out == ("Oz.1997.WEBRip-KONTRAST", False)
|
assert out == ("Oz.1997.WEBRip-KONTRAST", False)
|
||||||
|
|
||||||
def test_single_existing_different_name_returns_clarification(self, tmp_path):
|
def test_single_existing_different_name_returns_clarification(self, tmp_path):
|
||||||
(tmp_path / "Oz.1997.BluRay-OTHER").mkdir()
|
(tmp_path / "Oz.1997.BluRay-OTHER").mkdir()
|
||||||
out = _resolve_series_folder(tmp_path, "Oz", 1997, "Oz.1997.WEBRip-KONTRAST", None)
|
out = _resolve_series_folder(
|
||||||
|
tmp_path, "Oz", 1997, "Oz.1997.WEBRip-KONTRAST", None
|
||||||
|
)
|
||||||
assert isinstance(out, _Clarification)
|
assert isinstance(out, _Clarification)
|
||||||
assert "Oz" in out.question
|
assert "Oz" in out.question
|
||||||
assert "Oz.1997.BluRay-OTHER" in out.options
|
assert "Oz.1997.BluRay-OTHER" in out.options
|
||||||
@@ -185,7 +201,9 @@ class TestSeason:
|
|||||||
assert out.series_folder_name.startswith("Oz.1997")
|
assert out.series_folder_name.startswith("Oz.1997")
|
||||||
assert out.season_folder_name.startswith("Oz.S03")
|
assert out.season_folder_name.startswith("Oz.S03")
|
||||||
assert out.series_folder == str(tv / out.series_folder_name)
|
assert out.series_folder == str(tv / out.series_folder_name)
|
||||||
assert out.season_folder == str(tv / out.series_folder_name / out.season_folder_name)
|
assert out.season_folder == str(
|
||||||
|
tv / out.series_folder_name / out.season_folder_name
|
||||||
|
)
|
||||||
|
|
||||||
def test_clarification_path(self, cfg_memory):
|
def test_clarification_path(self, cfg_memory):
|
||||||
_, tv, _ = cfg_memory
|
_, tv, _ = cfg_memory
|
||||||
@@ -222,32 +240,29 @@ class TestEpisode:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_ok_path_without_episode_title(self, cfg_memory):
|
def test_ok_path_without_episode_title(self, cfg_memory):
|
||||||
out = resolve_episode_destination(
|
out = resolve_episode_destination(REL_EPISODE, "/dl/source.mkv", "Oz", 1997)
|
||||||
REL_EPISODE, "/dl/source.mkv", "Oz", 1997
|
|
||||||
)
|
|
||||||
assert out.status == "ok"
|
assert out.status == "ok"
|
||||||
# No '..' from blank ep title.
|
# No '..' from blank ep title.
|
||||||
assert ".." not in out.filename
|
assert ".." not in out.filename
|
||||||
|
|
||||||
def test_extension_taken_from_source_file(self, cfg_memory):
|
def test_extension_taken_from_source_file(self, cfg_memory):
|
||||||
out = resolve_episode_destination(
|
out = resolve_episode_destination(REL_EPISODE, "/dl/source.mp4", "Oz", 1997)
|
||||||
REL_EPISODE, "/dl/source.mp4", "Oz", 1997
|
|
||||||
)
|
|
||||||
assert out.filename.endswith(".mp4")
|
assert out.filename.endswith(".mp4")
|
||||||
|
|
||||||
def test_clarification_path(self, cfg_memory):
|
def test_clarification_path(self, cfg_memory):
|
||||||
_, tv, _ = cfg_memory
|
_, tv, _ = cfg_memory
|
||||||
(tv / "Oz.1997.BluRay-OTHER").mkdir()
|
(tv / "Oz.1997.BluRay-OTHER").mkdir()
|
||||||
out = resolve_episode_destination(
|
out = resolve_episode_destination(REL_EPISODE, "/dl/source.mkv", "Oz", 1997)
|
||||||
REL_EPISODE, "/dl/source.mkv", "Oz", 1997
|
|
||||||
)
|
|
||||||
assert out.status == "needs_clarification"
|
assert out.status == "needs_clarification"
|
||||||
|
|
||||||
def test_confirmed_folder_threaded_through(self, cfg_memory):
|
def test_confirmed_folder_threaded_through(self, cfg_memory):
|
||||||
_, tv, _ = cfg_memory
|
_, tv, _ = cfg_memory
|
||||||
(tv / "Oz.1997.BluRay-OTHER").mkdir()
|
(tv / "Oz.1997.BluRay-OTHER").mkdir()
|
||||||
out = resolve_episode_destination(
|
out = resolve_episode_destination(
|
||||||
REL_EPISODE, "/dl/source.mkv", "Oz", 1997,
|
REL_EPISODE,
|
||||||
|
"/dl/source.mkv",
|
||||||
|
"Oz",
|
||||||
|
1997,
|
||||||
confirmed_folder="Oz.1997.BluRay-OTHER",
|
confirmed_folder="Oz.1997.BluRay-OTHER",
|
||||||
)
|
)
|
||||||
assert out.status == "ok"
|
assert out.status == "ok"
|
||||||
@@ -340,13 +355,21 @@ class TestDTOToDict:
|
|||||||
d = ResolvedSeasonDestination(
|
d = ResolvedSeasonDestination(
|
||||||
status="error", error="library_not_set", message="missing"
|
status="error", error="library_not_set", message="missing"
|
||||||
).to_dict()
|
).to_dict()
|
||||||
assert d == {"status": "error", "error": "library_not_set", "message": "missing"}
|
assert d == {
|
||||||
|
"status": "error",
|
||||||
|
"error": "library_not_set",
|
||||||
|
"message": "missing",
|
||||||
|
}
|
||||||
|
|
||||||
def test_season_clarification(self):
|
def test_season_clarification(self):
|
||||||
d = ResolvedSeasonDestination(
|
d = ResolvedSeasonDestination(
|
||||||
status="needs_clarification", question="which?", options=["a", "b"]
|
status="needs_clarification", question="which?", options=["a", "b"]
|
||||||
).to_dict()
|
).to_dict()
|
||||||
assert d == {"status": "needs_clarification", "question": "which?", "options": ["a", "b"]}
|
assert d == {
|
||||||
|
"status": "needs_clarification",
|
||||||
|
"question": "which?",
|
||||||
|
"options": ["a", "b"],
|
||||||
|
}
|
||||||
|
|
||||||
def test_episode_ok(self):
|
def test_episode_ok(self):
|
||||||
d = ResolvedEpisodeDestination(
|
d = ResolvedEpisodeDestination(
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ from alfred.domain.shared.media import AudioTrack, MediaInfo, SubtitleTrack, Vid
|
|||||||
|
|
||||||
class TestTracks:
|
class TestTracks:
|
||||||
def test_audio_track_defaults(self):
|
def test_audio_track_defaults(self):
|
||||||
t = AudioTrack(index=0, codec="aac", channels=2, channel_layout="stereo",
|
t = AudioTrack(
|
||||||
language="eng")
|
index=0, codec="aac", channels=2, channel_layout="stereo", language="eng"
|
||||||
|
)
|
||||||
assert t.is_default is False
|
assert t.is_default is False
|
||||||
|
|
||||||
def test_subtitle_track_defaults(self):
|
def test_subtitle_track_defaults(self):
|
||||||
@@ -36,7 +37,9 @@ class TestTracks:
|
|||||||
|
|
||||||
class TestVideoTrackResolution:
|
class TestVideoTrackResolution:
|
||||||
def test_no_dimensions(self):
|
def test_no_dimensions(self):
|
||||||
assert VideoTrack(index=0, codec=None, width=None, height=None).resolution is None
|
assert (
|
||||||
|
VideoTrack(index=0, codec=None, width=None, height=None).resolution is None
|
||||||
|
)
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"w,expected",
|
"w,expected",
|
||||||
@@ -50,11 +53,16 @@ class TestVideoTrackResolution:
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_width_priority(self, w, expected):
|
def test_width_priority(self, w, expected):
|
||||||
assert VideoTrack(index=0, codec=None, width=w, height=1080).resolution == expected
|
assert (
|
||||||
|
VideoTrack(index=0, codec=None, width=w, height=1080).resolution == expected
|
||||||
|
)
|
||||||
|
|
||||||
def test_widescreen_scope_crop(self):
|
def test_widescreen_scope_crop(self):
|
||||||
# 1920x960 (scope crop) → still 1080p because width-priority
|
# 1920x960 (scope crop) → still 1080p because width-priority
|
||||||
assert VideoTrack(index=0, codec=None, width=1920, height=960).resolution == "1080p"
|
assert (
|
||||||
|
VideoTrack(index=0, codec=None, width=1920, height=960).resolution
|
||||||
|
== "1080p"
|
||||||
|
)
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"h,expected",
|
"h,expected",
|
||||||
@@ -67,11 +75,15 @@ class TestVideoTrackResolution:
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_height_fallback_when_width_missing(self, h, expected):
|
def test_height_fallback_when_width_missing(self, h, expected):
|
||||||
assert VideoTrack(index=0, codec=None, width=None, height=h).resolution == expected
|
assert (
|
||||||
|
VideoTrack(index=0, codec=None, width=None, height=h).resolution == expected
|
||||||
|
)
|
||||||
|
|
||||||
def test_width_below_buckets_falls_to_height(self):
|
def test_width_below_buckets_falls_to_height(self):
|
||||||
# width=320 falls below every bucket; falls back to f"{h}p"
|
# width=320 falls below every bucket; falls back to f"{h}p"
|
||||||
assert VideoTrack(index=0, codec=None, width=320, height=240).resolution == "240p"
|
assert (
|
||||||
|
VideoTrack(index=0, codec=None, width=320, height=240).resolution == "240p"
|
||||||
|
)
|
||||||
|
|
||||||
def test_width_only_below_buckets(self):
|
def test_width_only_below_buckets(self):
|
||||||
# width=200, no height → f"{w}w" sentinel
|
# width=200, no height → f"{w}w" sentinel
|
||||||
@@ -127,9 +139,7 @@ class TestAudioLanguages:
|
|||||||
assert m.audio_languages == []
|
assert m.audio_languages == []
|
||||||
|
|
||||||
def test_is_multi_audio_false_single_lang(self):
|
def test_is_multi_audio_false_single_lang(self):
|
||||||
m = MediaInfo(
|
m = MediaInfo(audio_tracks=[AudioTrack(0, "aac", 2, "stereo", "eng")])
|
||||||
audio_tracks=[AudioTrack(0, "aac", 2, "stereo", "eng")]
|
|
||||||
)
|
|
||||||
assert m.is_multi_audio is False
|
assert m.is_multi_audio is False
|
||||||
|
|
||||||
def test_is_multi_audio_true(self):
|
def test_is_multi_audio_true(self):
|
||||||
|
|||||||
@@ -49,8 +49,11 @@ def identifier(kb):
|
|||||||
return SubtitleIdentifier(kb)
|
return SubtitleIdentifier(kb)
|
||||||
|
|
||||||
|
|
||||||
def _pattern(strategy: ScanStrategy, root_folder: str | None = None,
|
def _pattern(
|
||||||
detection: TypeDetectionMethod = TypeDetectionMethod.TOKEN_IN_NAME) -> SubtitlePattern:
|
strategy: ScanStrategy,
|
||||||
|
root_folder: str | None = None,
|
||||||
|
detection: TypeDetectionMethod = TypeDetectionMethod.TOKEN_IN_NAME,
|
||||||
|
) -> SubtitlePattern:
|
||||||
return SubtitlePattern(
|
return SubtitlePattern(
|
||||||
id=f"test-{strategy.value}",
|
id=f"test-{strategy.value}",
|
||||||
description="",
|
description="",
|
||||||
@@ -70,9 +73,7 @@ class TestTokenize:
|
|||||||
assert _tokenize("Show.S01E01.French") == ["show", "s01e01", "french"]
|
assert _tokenize("Show.S01E01.French") == ["show", "s01e01", "french"]
|
||||||
|
|
||||||
def test_mixed_separators(self):
|
def test_mixed_separators(self):
|
||||||
assert _tokenize("Show_S01-E01 French") == [
|
assert _tokenize("Show_S01-E01 French") == ["show", "s01", "e01", "french"]
|
||||||
"show", "s01", "e01", "french"
|
|
||||||
]
|
|
||||||
|
|
||||||
def test_strips_parenthesized(self):
|
def test_strips_parenthesized(self):
|
||||||
assert _tokenize("episode (Brazil).French") == ["episode", "french"]
|
assert _tokenize("episode (Brazil).French") == ["episode", "french"]
|
||||||
|
|||||||
@@ -77,9 +77,7 @@ class TestUnresolved:
|
|||||||
|
|
||||||
def test_threshold_exact_passes(self, matcher):
|
def test_threshold_exact_passes(self, matcher):
|
||||||
t = _track(confidence=0.7)
|
t = _track(confidence=0.7)
|
||||||
rules = SubtitleMatchingRules(
|
rules = SubtitleMatchingRules(min_confidence=0.7, preferred_languages=["fra"])
|
||||||
min_confidence=0.7, preferred_languages=["fra"]
|
|
||||||
)
|
|
||||||
matched, unresolved = matcher.match([t], rules)
|
matched, unresolved = matcher.match([t], rules)
|
||||||
assert matched == [t]
|
assert matched == [t]
|
||||||
|
|
||||||
@@ -92,17 +90,13 @@ class TestUnresolved:
|
|||||||
class TestLanguageFilter:
|
class TestLanguageFilter:
|
||||||
def test_preferred_languages_filters_out(self, matcher):
|
def test_preferred_languages_filters_out(self, matcher):
|
||||||
t_eng = _track(lang=ENG)
|
t_eng = _track(lang=ENG)
|
||||||
rules = SubtitleMatchingRules(
|
rules = SubtitleMatchingRules(preferred_languages=["fra"], min_confidence=0.0)
|
||||||
preferred_languages=["fra"], min_confidence=0.0
|
|
||||||
)
|
|
||||||
matched, _ = matcher.match([t_eng], rules)
|
matched, _ = matcher.match([t_eng], rules)
|
||||||
assert matched == []
|
assert matched == []
|
||||||
|
|
||||||
def test_preferred_language_match_passes(self, matcher):
|
def test_preferred_language_match_passes(self, matcher):
|
||||||
t_fra = _track(lang=FRA)
|
t_fra = _track(lang=FRA)
|
||||||
rules = SubtitleMatchingRules(
|
rules = SubtitleMatchingRules(preferred_languages=["fra"], min_confidence=0.0)
|
||||||
preferred_languages=["fra"], min_confidence=0.0
|
|
||||||
)
|
|
||||||
matched, _ = matcher.match([t_fra], rules)
|
matched, _ = matcher.match([t_fra], rules)
|
||||||
assert matched == [t_fra]
|
assert matched == [t_fra]
|
||||||
|
|
||||||
@@ -118,17 +112,13 @@ class TestLanguageFilter:
|
|||||||
class TestFormatFilter:
|
class TestFormatFilter:
|
||||||
def test_format_outside_preferred_filtered(self, matcher):
|
def test_format_outside_preferred_filtered(self, matcher):
|
||||||
t = _track(fmt=ASS)
|
t = _track(fmt=ASS)
|
||||||
rules = SubtitleMatchingRules(
|
rules = SubtitleMatchingRules(preferred_formats=["srt"], min_confidence=0.0)
|
||||||
preferred_formats=["srt"], min_confidence=0.0
|
|
||||||
)
|
|
||||||
matched, _ = matcher.match([t], rules)
|
matched, _ = matcher.match([t], rules)
|
||||||
assert matched == []
|
assert matched == []
|
||||||
|
|
||||||
def test_no_format_attribute_filtered_when_pref_set(self, matcher):
|
def test_no_format_attribute_filtered_when_pref_set(self, matcher):
|
||||||
t = _track(fmt=None)
|
t = _track(fmt=None)
|
||||||
rules = SubtitleMatchingRules(
|
rules = SubtitleMatchingRules(preferred_formats=["srt"], min_confidence=0.0)
|
||||||
preferred_formats=["srt"], min_confidence=0.0
|
|
||||||
)
|
|
||||||
matched, _ = matcher.match([t], rules)
|
matched, _ = matcher.match([t], rules)
|
||||||
assert matched == []
|
assert matched == []
|
||||||
|
|
||||||
@@ -144,9 +134,7 @@ class TestTypeFilter:
|
|||||||
|
|
||||||
def test_allowed_type_passes(self, matcher):
|
def test_allowed_type_passes(self, matcher):
|
||||||
t = _track(stype=SubtitleType.STANDARD)
|
t = _track(stype=SubtitleType.STANDARD)
|
||||||
rules = SubtitleMatchingRules(
|
rules = SubtitleMatchingRules(allowed_types=["standard"], min_confidence=0.0)
|
||||||
allowed_types=["standard"], min_confidence=0.0
|
|
||||||
)
|
|
||||||
matched, _ = matcher.match([t], rules)
|
matched, _ = matcher.match([t], rules)
|
||||||
assert matched == [t]
|
assert matched == [t]
|
||||||
|
|
||||||
|
|||||||
@@ -83,11 +83,15 @@ class TestSubtitleCandidateDestName:
|
|||||||
assert t.destination_name == "fra.sdh.srt"
|
assert t.destination_name == "fra.sdh.srt"
|
||||||
|
|
||||||
def test_forced(self):
|
def test_forced(self):
|
||||||
t = SubtitleCandidate(language=FRA, format=SRT, subtitle_type=SubtitleType.FORCED)
|
t = SubtitleCandidate(
|
||||||
|
language=FRA, format=SRT, subtitle_type=SubtitleType.FORCED
|
||||||
|
)
|
||||||
assert t.destination_name == "fra.forced.srt"
|
assert t.destination_name == "fra.forced.srt"
|
||||||
|
|
||||||
def test_unknown_treated_as_standard(self):
|
def test_unknown_treated_as_standard(self):
|
||||||
t = SubtitleCandidate(language=FRA, format=SRT, subtitle_type=SubtitleType.UNKNOWN)
|
t = SubtitleCandidate(
|
||||||
|
language=FRA, format=SRT, subtitle_type=SubtitleType.UNKNOWN
|
||||||
|
)
|
||||||
# UNKNOWN doesn't add a suffix → same as standard.
|
# UNKNOWN doesn't add a suffix → same as standard.
|
||||||
assert t.destination_name == "fra.srt"
|
assert t.destination_name == "fra.srt"
|
||||||
|
|
||||||
@@ -110,7 +114,9 @@ class TestSubtitleCandidateDestName:
|
|||||||
|
|
||||||
class TestSubtitleCandidateRepr:
|
class TestSubtitleCandidateRepr:
|
||||||
def test_embedded_repr(self):
|
def test_embedded_repr(self):
|
||||||
t = SubtitleCandidate(language=FRA, format=None, is_embedded=True, confidence=1.0)
|
t = SubtitleCandidate(
|
||||||
|
language=FRA, format=None, is_embedded=True, confidence=1.0
|
||||||
|
)
|
||||||
r = repr(t)
|
r = repr(t)
|
||||||
assert "fra" in r
|
assert "fra" in r
|
||||||
assert "embedded" in r
|
assert "embedded" in r
|
||||||
@@ -118,9 +124,7 @@ class TestSubtitleCandidateRepr:
|
|||||||
def test_external_repr_uses_filename(self, tmp_path):
|
def test_external_repr_uses_filename(self, tmp_path):
|
||||||
f = tmp_path / "fr.srt"
|
f = tmp_path / "fr.srt"
|
||||||
f.write_text("")
|
f.write_text("")
|
||||||
t = SubtitleCandidate(
|
t = SubtitleCandidate(language=FRA, format=SRT, file_path=f, confidence=0.85)
|
||||||
language=FRA, format=SRT, file_path=f, confidence=0.85
|
|
||||||
)
|
|
||||||
r = repr(t)
|
r = repr(t)
|
||||||
assert "fra" in r
|
assert "fra" in r
|
||||||
assert "fr.srt" in r
|
assert "fr.srt" in r
|
||||||
@@ -159,7 +163,9 @@ class TestMediaSubtitleMetadata:
|
|||||||
def test_unresolved_tracks_only_external_with_none_lang(self):
|
def test_unresolved_tracks_only_external_with_none_lang(self):
|
||||||
# An embedded with None language must NOT appear in unresolved_tracks
|
# An embedded with None language must NOT appear in unresolved_tracks
|
||||||
# (the property only iterates external_tracks).
|
# (the property only iterates external_tracks).
|
||||||
embedded_unknown = SubtitleCandidate(language=None, format=None, is_embedded=True)
|
embedded_unknown = SubtitleCandidate(
|
||||||
|
language=None, format=None, is_embedded=True
|
||||||
|
)
|
||||||
external_known = SubtitleCandidate(
|
external_known = SubtitleCandidate(
|
||||||
language=FRA, format=SRT, file_path=Path("/a.srt")
|
language=FRA, format=SRT, file_path=Path("/a.srt")
|
||||||
)
|
)
|
||||||
@@ -184,10 +190,16 @@ class TestAvailableSubtitles:
|
|||||||
def test_dedup_by_lang_and_type(self):
|
def test_dedup_by_lang_and_type(self):
|
||||||
ENG = SubtitleLanguage(code="eng", tokens=["en"])
|
ENG = SubtitleLanguage(code="eng", tokens=["en"])
|
||||||
tracks = [
|
tracks = [
|
||||||
SubtitleCandidate(language=FRA, format=SRT, subtitle_type=SubtitleType.STANDARD),
|
SubtitleCandidate(
|
||||||
SubtitleCandidate(language=FRA, format=SRT, subtitle_type=SubtitleType.STANDARD),
|
language=FRA, format=SRT, subtitle_type=SubtitleType.STANDARD
|
||||||
|
),
|
||||||
|
SubtitleCandidate(
|
||||||
|
language=FRA, format=SRT, subtitle_type=SubtitleType.STANDARD
|
||||||
|
),
|
||||||
SubtitleCandidate(language=FRA, format=SRT, subtitle_type=SubtitleType.SDH),
|
SubtitleCandidate(language=FRA, format=SRT, subtitle_type=SubtitleType.SDH),
|
||||||
SubtitleCandidate(language=ENG, format=SRT, subtitle_type=SubtitleType.STANDARD),
|
SubtitleCandidate(
|
||||||
|
language=ENG, format=SRT, subtitle_type=SubtitleType.STANDARD
|
||||||
|
),
|
||||||
]
|
]
|
||||||
result = available_subtitles(tracks)
|
result = available_subtitles(tracks)
|
||||||
keys = [(t.language.code, t.subtitle_type) for t in result]
|
keys = [(t.language.code, t.subtitle_type) for t in result]
|
||||||
|
|||||||
@@ -182,11 +182,13 @@ class TestEpisode:
|
|||||||
assert e.has_audio_in("ger") is False
|
assert e.has_audio_in("ger") is False
|
||||||
|
|
||||||
def test_has_audio_in_with_language(self):
|
def test_has_audio_in_with_language(self):
|
||||||
lang = Language(iso="fre", english_name="French", native_name="Français",
|
lang = Language(
|
||||||
aliases=("fr", "fra", "french"))
|
iso="fre",
|
||||||
e = self._ep(
|
english_name="French",
|
||||||
audio_tracks=[AudioTrack(0, "ac3", 6, "5.1", "fr")]
|
native_name="Français",
|
||||||
|
aliases=("fr", "fra", "french"),
|
||||||
)
|
)
|
||||||
|
e = self._ep(audio_tracks=[AudioTrack(0, "ac3", 6, "5.1", "fr")])
|
||||||
# str query "fre" wouldn't match "fr" directly — but Language does cross-format
|
# str query "fre" wouldn't match "fr" directly — but Language does cross-format
|
||||||
assert e.has_audio_in(lang) is True
|
assert e.has_audio_in(lang) is True
|
||||||
assert e.has_audio_in("fre") is False # direct compare misses
|
assert e.has_audio_in("fre") is False # direct compare misses
|
||||||
@@ -205,9 +207,7 @@ class TestEpisode:
|
|||||||
# ── Subtitle helpers ───────────────────────────────────────────────
|
# ── Subtitle helpers ───────────────────────────────────────────────
|
||||||
|
|
||||||
def test_has_subtitles_in(self):
|
def test_has_subtitles_in(self):
|
||||||
e = self._ep(
|
e = self._ep(subtitle_tracks=[SubtitleTrack(0, "subrip", "fre")])
|
||||||
subtitle_tracks=[SubtitleTrack(0, "subrip", "fre")]
|
|
||||||
)
|
|
||||||
assert e.has_subtitles_in("fre") is True
|
assert e.has_subtitles_in("fre") is True
|
||||||
assert e.has_subtitles_in("eng") is False
|
assert e.has_subtitles_in("eng") is False
|
||||||
|
|
||||||
|
|||||||
@@ -79,9 +79,7 @@ def client():
|
|||||||
|
|
||||||
class TestInit:
|
class TestInit:
|
||||||
def test_explicit_args(self):
|
def test_explicit_args(self):
|
||||||
c = QBittorrentClient(
|
c = QBittorrentClient(host="http://x:1", username="u", password="p", timeout=99)
|
||||||
host="http://x:1", username="u", password="p", timeout=99
|
|
||||||
)
|
|
||||||
assert c.host == "http://x:1"
|
assert c.host == "http://x:1"
|
||||||
assert c.username == "u"
|
assert c.username == "u"
|
||||||
assert c.password == "p"
|
assert c.password == "p"
|
||||||
@@ -265,9 +263,7 @@ class TestMutations:
|
|||||||
def test_delete_no_files_default(self, client):
|
def test_delete_no_files_default(self, client):
|
||||||
self._ok(client)
|
self._ok(client)
|
||||||
client.delete_torrent("hash1")
|
client.delete_torrent("hash1")
|
||||||
assert (
|
assert client.session.post.call_args.kwargs["data"]["deleteFiles"] == "false"
|
||||||
client.session.post.call_args.kwargs["data"]["deleteFiles"] == "false"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_pause(self, client):
|
def test_pause(self, client):
|
||||||
self._ok(client)
|
self._ok(client)
|
||||||
@@ -328,9 +324,7 @@ class TestFindByName:
|
|||||||
|
|
||||||
def test_case_insensitive_match(self, client):
|
def test_case_insensitive_match(self, client):
|
||||||
client._authenticated = True
|
client._authenticated = True
|
||||||
client.session.get.return_value = _resp(
|
client.session.get.return_value = _resp([_torrent_dict("foundation.s01")])
|
||||||
[_torrent_dict("foundation.s01")]
|
|
||||||
)
|
|
||||||
result = client.find_by_name("Foundation.S01")
|
result = client.find_by_name("Foundation.S01")
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result.name == "foundation.s01"
|
assert result.name == "foundation.s01"
|
||||||
|
|||||||
@@ -231,11 +231,7 @@ class TestSearchMedia:
|
|||||||
def test_tv_uses_name_field(self, mock_get, client):
|
def test_tv_uses_name_field(self, mock_get, client):
|
||||||
mock_get.side_effect = [
|
mock_get.side_effect = [
|
||||||
_ok_response(
|
_ok_response(
|
||||||
{
|
{"results": [{"id": 1396, "media_type": "tv", "name": "Breaking Bad"}]}
|
||||||
"results": [
|
|
||||||
{"id": 1396, "media_type": "tv", "name": "Breaking Bad"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
),
|
),
|
||||||
_ok_response({"imdb_id": "tt0903747"}),
|
_ok_response({"imdb_id": "tt0903747"}),
|
||||||
]
|
]
|
||||||
@@ -281,9 +277,7 @@ class TestSearchMedia:
|
|||||||
def test_external_ids_failure_returns_result_without_imdb(self, mock_get, client):
|
def test_external_ids_failure_returns_result_without_imdb(self, mock_get, client):
|
||||||
# Second call (external IDs) fails — the search should still succeed.
|
# Second call (external IDs) fails — the search should still succeed.
|
||||||
mock_get.side_effect = [
|
mock_get.side_effect = [
|
||||||
_ok_response(
|
_ok_response({"results": [{"id": 1, "media_type": "movie", "title": "X"}]}),
|
||||||
{"results": [{"id": 1, "media_type": "movie", "title": "X"}]}
|
|
||||||
),
|
|
||||||
Timeout("slow"),
|
Timeout("slow"),
|
||||||
]
|
]
|
||||||
result = client.search_media("X")
|
result = client.search_media("X")
|
||||||
|
|||||||
@@ -160,13 +160,15 @@ class TestUpdateParseAndProbe:
|
|||||||
class TestUpdateTmdb:
|
class TestUpdateTmdb:
|
||||||
def test_promotes_identity_to_top_level(self, tmp_path):
|
def test_promotes_identity_to_top_level(self, tmp_path):
|
||||||
s = MetadataStore(tmp_path)
|
s = MetadataStore(tmp_path)
|
||||||
s.update_tmdb({
|
s.update_tmdb(
|
||||||
"status": "ok",
|
{
|
||||||
"imdb_id": "tt1375666",
|
"status": "ok",
|
||||||
"tmdb_id": 27205,
|
"imdb_id": "tt1375666",
|
||||||
"media_type": "movie",
|
"tmdb_id": 27205,
|
||||||
"title": "Inception",
|
"media_type": "movie",
|
||||||
})
|
"title": "Inception",
|
||||||
|
}
|
||||||
|
)
|
||||||
data = s.load()
|
data = s.load()
|
||||||
assert data["imdb_id"] == "tt1375666"
|
assert data["imdb_id"] == "tt1375666"
|
||||||
assert data["tmdb_id"] == 27205
|
assert data["tmdb_id"] == 27205
|
||||||
|
|||||||
@@ -38,17 +38,23 @@ def _write(path: Path, data: dict) -> None:
|
|||||||
|
|
||||||
class TestFilterOverride:
|
class TestFilterOverride:
|
||||||
def test_keeps_only_valid_keys(self):
|
def test_keeps_only_valid_keys(self):
|
||||||
out = _filter_override({
|
out = _filter_override(
|
||||||
"languages": ["fra"],
|
{
|
||||||
"formats": ["srt"],
|
"languages": ["fra"],
|
||||||
"types": ["standard"],
|
"formats": ["srt"],
|
||||||
"format_priority": ["srt"],
|
"types": ["standard"],
|
||||||
"min_confidence": 0.8,
|
"format_priority": ["srt"],
|
||||||
"unknown_key": "ignored",
|
"min_confidence": 0.8,
|
||||||
"another": 42,
|
"unknown_key": "ignored",
|
||||||
})
|
"another": 42,
|
||||||
|
}
|
||||||
|
)
|
||||||
assert set(out) == {
|
assert set(out) == {
|
||||||
"languages", "formats", "types", "format_priority", "min_confidence"
|
"languages",
|
||||||
|
"formats",
|
||||||
|
"types",
|
||||||
|
"format_priority",
|
||||||
|
"min_confidence",
|
||||||
}
|
}
|
||||||
assert "unknown_key" not in out
|
assert "unknown_key" not in out
|
||||||
|
|
||||||
@@ -113,9 +119,7 @@ class TestLoad:
|
|||||||
{"override": {"min_confidence": 0.99}},
|
{"override": {"min_confidence": 0.99}},
|
||||||
)
|
)
|
||||||
repo = RuleSetRepository(tmp_path)
|
repo = RuleSetRepository(tmp_path)
|
||||||
rules = repo.load(
|
rules = repo.load(release_group="GRP", subtitle_preferences=prefs).resolve()
|
||||||
release_group="GRP", subtitle_preferences=prefs
|
|
||||||
).resolve()
|
|
||||||
# All three levels visible — local overrides on top
|
# All three levels visible — local overrides on top
|
||||||
assert rules.preferred_languages == ["jpn"]
|
assert rules.preferred_languages == ["jpn"]
|
||||||
assert rules.format_priority == ["ass"]
|
assert rules.format_priority == ["ass"]
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ FRA = SubtitleLanguage(code="fra", tokens=["fr"])
|
|||||||
ENG = SubtitleLanguage(code="eng", tokens=["en"])
|
ENG = SubtitleLanguage(code="eng", tokens=["en"])
|
||||||
|
|
||||||
|
|
||||||
def _track(lang=FRA, *, embedded: bool = False, confidence: float = 0.92) -> SubtitleCandidate:
|
def _track(
|
||||||
|
lang=FRA, *, embedded: bool = False, confidence: float = 0.92
|
||||||
|
) -> SubtitleCandidate:
|
||||||
return SubtitleCandidate(
|
return SubtitleCandidate(
|
||||||
language=lang,
|
language=lang,
|
||||||
format=SRT,
|
format=SRT,
|
||||||
|
|||||||
Reference in New Issue
Block a user