refactor(subtitles): rename SubtitleCandidate → SubtitleScanResult

The old name conflated 'might become a placed subtitle' with 'what a
scan pass produced'. The class is the output of a scan/identify pass —
language/format may still be None while classification is in progress,
confidence reflects classifier certainty, raw_tokens holds filename
fragments under analysis. SubtitleScanResult says that directly.

Pure rename + refreshed docstring; no behavior change. Touches the
domain entity, the matcher/identifier/utils services, the
manage_subtitles use case, the placer, the metadata store, the
shared-media cross-ref comment, and 7 test modules.
This commit is contained in:
2026-05-21 08:05:46 +02:00
parent 5107cb32c0
commit 88f156b7a4
16 changed files with 111 additions and 97 deletions
+12
View File
@@ -68,6 +68,18 @@ callers).
### Changed ### Changed
- **`SubtitleCandidate` renamed to `SubtitleScanResult`.** The old name
conflated "this might become a placed subtitle" with "this is what a
scan pass produced". The class is the output of a scan/identify pass
— language/format may still be `None`, confidence reflects how sure
the classifier is, and `raw_tokens` holds the filename fragments
under analysis. `SubtitleScanResult` says that directly. Pure rename
with a refreshed docstring in `alfred/domain/subtitles/entities.py`;
no behavior change. Touches the domain entity + `__init__` export,
the matcher / identifier / utils services, the manage_subtitles use
case, the placer, the metadata store, the shared-media cross-ref
comment, and the seven test modules that imported the type.
- **`ParsedRelease` is now frozen; enrichment passes return new - **`ParsedRelease` is now frozen; enrichment passes return new
instances.** The VO was mutable so `detect_media_type` and instances.** The VO was mutable so `detect_media_type` and
`enrich_from_probe` could patch fields in place — a code smell in a `enrich_from_probe` could patch fields in place — a code smell in a
@@ -4,7 +4,7 @@ import logging
from pathlib import Path from pathlib import Path
from alfred.domain.shared.value_objects import ImdbId from alfred.domain.shared.value_objects import ImdbId
from alfred.domain.subtitles.entities import SubtitleCandidate from alfred.domain.subtitles.entities import SubtitleScanResult
from alfred.domain.subtitles.services.identifier import SubtitleIdentifier from alfred.domain.subtitles.services.identifier import SubtitleIdentifier
from alfred.domain.subtitles.services.matcher import SubtitleMatcher from alfred.domain.subtitles.services.matcher import SubtitleMatcher
from alfred.domain.subtitles.services.pattern_detector import PatternDetector from alfred.domain.subtitles.services.pattern_detector import PatternDetector
@@ -278,7 +278,7 @@ class ManageSubtitlesUseCase:
def _to_unresolved_dto( def _to_unresolved_dto(
track: SubtitleCandidate, min_confidence: float = 0.7 track: SubtitleScanResult, min_confidence: float = 0.7
) -> UnresolvedTrack: ) -> UnresolvedTrack:
reason = "unknown_language" if track.language is None else "low_confidence" reason = "unknown_language" if track.language is None else "low_confidence"
return UnresolvedTrack( return UnresolvedTrack(
@@ -291,10 +291,10 @@ def _to_unresolved_dto(
def _pair_placed_with_tracks( def _pair_placed_with_tracks(
placed: list[PlacedTrack], placed: list[PlacedTrack],
tracks: list[SubtitleCandidate], tracks: list[SubtitleScanResult],
) -> list[tuple[PlacedTrack, SubtitleCandidate]]: ) -> list[tuple[PlacedTrack, SubtitleScanResult]]:
""" """
Pair each PlacedTrack with its originating SubtitleCandidate by source path. Pair each PlacedTrack with its originating SubtitleScanResult by source path.
Falls back to positional matching if paths don't align. Falls back to positional matching if paths don't align.
""" """
track_by_path = {t.file_path: t for t in tracks if t.file_path} track_by_path = {t.file_path: t for t in tracks if t.file_path}
+6 -6
View File
@@ -5,13 +5,13 @@ import os
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from alfred.domain.subtitles.entities import SubtitleCandidate from alfred.domain.subtitles.entities import SubtitleScanResult
from alfred.domain.subtitles.value_objects import SubtitleType from alfred.domain.subtitles.value_objects import SubtitleType
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _build_dest_name(track: SubtitleCandidate, video_stem: str) -> str: def _build_dest_name(track: SubtitleScanResult, video_stem: str) -> str:
""" """
Build the destination filename for a subtitle track. Build the destination filename for a subtitle track.
@@ -41,7 +41,7 @@ class PlacedTrack:
@dataclass @dataclass
class PlaceResult: class PlaceResult:
placed: list[PlacedTrack] placed: list[PlacedTrack]
skipped: list[tuple[SubtitleCandidate, str]] # (track, reason) skipped: list[tuple[SubtitleScanResult, str]] # (track, reason)
@property @property
def placed_count(self) -> int: def placed_count(self) -> int:
@@ -54,7 +54,7 @@ class PlaceResult:
class SubtitlePlacer: class SubtitlePlacer:
""" """
Hard-links matched SubtitleCandidate files next to a destination video. Hard-links matched SubtitleScanResult files next to a destination video.
Uses the same hard-link strategy as FileManager.copy_file: Uses the same hard-link strategy as FileManager.copy_file:
instant, no data duplication, qBittorrent keeps seeding. instant, no data duplication, qBittorrent keeps seeding.
@@ -64,11 +64,11 @@ class SubtitlePlacer:
def place( def place(
self, self,
tracks: list[SubtitleCandidate], tracks: list[SubtitleScanResult],
destination_video: Path, destination_video: Path,
) -> PlaceResult: ) -> PlaceResult:
placed: list[PlacedTrack] = [] placed: list[PlacedTrack] = []
skipped: list[tuple[SubtitleCandidate, str]] = [] skipped: list[tuple[SubtitleScanResult, str]] = []
dest_dir = destination_video.parent dest_dir = destination_video.parent
+1 -1
View File
@@ -3,7 +3,7 @@
These are the **container-view** dataclasses, populated from ffprobe output and These are the **container-view** dataclasses, populated from ffprobe output and
used across the project to describe the content of a media file. used across the project to describe the content of a media file.
Not to be confused with ``alfred.domain.subtitles.entities.SubtitleCandidate`` Not to be confused with ``alfred.domain.subtitles.entities.SubtitleScanResult``
which models a subtitle being **scanned/matched** (with confidence, raw tokens, which models a subtitle being **scanned/matched** (with confidence, raw tokens,
file path, etc.). The two coexist by design — they describe the same real-world file path, etc.). The two coexist by design — they describe the same real-world
concept seen from two different bounded contexts. concept seen from two different bounded contexts.
+2 -2
View File
@@ -1,7 +1,7 @@
"""Subtitles domain — subtitle identification, classification and placement.""" """Subtitles domain — subtitle identification, classification and placement."""
from .aggregates import SubtitleRuleSet from .aggregates import SubtitleRuleSet
from .entities import MediaSubtitleMetadata, SubtitleCandidate from .entities import MediaSubtitleMetadata, SubtitleScanResult
from .exceptions import SubtitleNotFound from .exceptions import SubtitleNotFound
from .services import PatternDetector, SubtitleIdentifier, SubtitleMatcher from .services import PatternDetector, SubtitleIdentifier, SubtitleMatcher
from .value_objects import ( from .value_objects import (
@@ -17,7 +17,7 @@ from .value_objects import (
) )
__all__ = [ __all__ = [
"SubtitleCandidate", "SubtitleScanResult",
"MediaSubtitleMetadata", "MediaSubtitleMetadata",
"SubtitleRuleSet", "SubtitleRuleSet",
"SubtitleIdentifier", "SubtitleIdentifier",
+14 -12
View File
@@ -12,16 +12,18 @@ from .value_objects import (
@dataclass @dataclass
class SubtitleCandidate: class SubtitleScanResult:
""" """
A subtitle being scanned and matched — either an external file or an embedded stream. A subtitle observed during a scan — either an external file or an embedded stream.
Unlike ``alfred.domain.shared.media.SubtitleTrack`` (the pure container-view Unlike ``alfred.domain.shared.media.SubtitleTrack`` (the pure container-view
populated from ffprobe), a SubtitleCandidate carries the **flow state** of the populated from ffprobe), a ``SubtitleScanResult`` carries the **flow state**
subtitle matching pipeline: language/format are typed value objects that may of the subtitle matching pipeline: language/format are typed value objects
be ``None`` while classification is in progress, ``confidence`` reflects how that may be ``None`` while classification is in progress, ``confidence``
certain we are, and ``raw_tokens`` holds the filename fragments still under reflects how certain we are, and ``raw_tokens`` holds the filename fragments
analysis. State evolves: unknown → resolved after user clarification. still under analysis. State evolves: unknown → resolved after user
clarification. The name reflects this — it's the **output of a scan pass**,
not a value object.
""" """
# Classification (may be None if not yet resolved) # Classification (may be None if not yet resolved)
@@ -72,7 +74,7 @@ class SubtitleCandidate:
if self.is_embedded if self.is_embedded
else str(self.file_path.name if self.file_path else "?") else str(self.file_path.name if self.file_path else "?")
) )
return f"SubtitleCandidate({lang}, {self.subtitle_type.value}, {fmt}, src={src}, conf={self.confidence:.2f})" return f"SubtitleScanResult({lang}, {self.subtitle_type.value}, {fmt}, src={src}, conf={self.confidence:.2f})"
@dataclass @dataclass
@@ -84,14 +86,14 @@ class MediaSubtitleMetadata:
media_id: ImdbId | None media_id: ImdbId | None
media_type: str # "movie" | "tv_show" media_type: str # "movie" | "tv_show"
embedded_tracks: list[SubtitleCandidate] = field(default_factory=list) embedded_tracks: list[SubtitleScanResult] = field(default_factory=list)
external_tracks: list[SubtitleCandidate] = field(default_factory=list) external_tracks: list[SubtitleScanResult] = field(default_factory=list)
release_group: str | None = None release_group: str | None = None
detected_pattern_id: str | None = None # pattern id from knowledge base detected_pattern_id: str | None = None # pattern id from knowledge base
pattern_confirmed: bool = False pattern_confirmed: bool = False
@property @property
def all_tracks(self) -> list[SubtitleCandidate]: def all_tracks(self) -> list[SubtitleScanResult]:
return self.embedded_tracks + self.external_tracks return self.embedded_tracks + self.external_tracks
@property @property
@@ -99,5 +101,5 @@ class MediaSubtitleMetadata:
return len(self.embedded_tracks) + len(self.external_tracks) return len(self.embedded_tracks) + len(self.external_tracks)
@property @property
def unresolved_tracks(self) -> list[SubtitleCandidate]: def unresolved_tracks(self) -> list[SubtitleScanResult]:
return [t for t in self.external_tracks if t.language is None] return [t for t in self.external_tracks if t.language is None]
+11 -11
View File
@@ -7,7 +7,7 @@ from pathlib import Path
from ...shared.ports import FilesystemScanner, MediaProber from ...shared.ports import FilesystemScanner, MediaProber
from ..ports import SubtitleKnowledge from ..ports import SubtitleKnowledge
from ...shared.value_objects import ImdbId from ...shared.value_objects import ImdbId
from ..entities import MediaSubtitleMetadata, SubtitleCandidate from ..entities import MediaSubtitleMetadata, SubtitleScanResult
from ..value_objects import ScanStrategy, SubtitlePattern, SubtitleType from ..value_objects import ScanStrategy, SubtitlePattern, SubtitleType
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -94,7 +94,7 @@ class SubtitleIdentifier:
# Embedded tracks — via MediaProber # Embedded tracks — via MediaProber
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _scan_embedded(self, video_path: Path) -> list[SubtitleCandidate]: def _scan_embedded(self, video_path: Path) -> list[SubtitleScanResult]:
streams = self.prober.list_subtitle_streams(video_path) streams = self.prober.list_subtitle_streams(video_path)
tracks = [] tracks = []
@@ -111,7 +111,7 @@ class SubtitleIdentifier:
stype = SubtitleType.STANDARD stype = SubtitleType.STANDARD
tracks.append( tracks.append(
SubtitleCandidate( SubtitleScanResult(
language=lang, language=lang,
format=None, format=None,
subtitle_type=stype, subtitle_type=stype,
@@ -131,7 +131,7 @@ class SubtitleIdentifier:
def _scan_external( def _scan_external(
self, video_path: Path, pattern: SubtitlePattern self, video_path: Path, pattern: SubtitlePattern
) -> list[SubtitleCandidate]: ) -> list[SubtitleScanResult]:
strategy = pattern.scan_strategy strategy = pattern.scan_strategy
episode_stem: str | None = None episode_stem: str | None = None
@@ -200,7 +200,7 @@ class SubtitleIdentifier:
entries: list, entries: list,
pattern: SubtitlePattern, pattern: SubtitlePattern,
episode_stem: str | None = None, episode_stem: str | None = None,
) -> list[SubtitleCandidate]: ) -> list[SubtitleScanResult]:
tracks = [ tracks = [
self._classify_single(entry, episode_stem=episode_stem) for entry in entries self._classify_single(entry, episode_stem=episode_stem) for entry in entries
] ]
@@ -214,7 +214,7 @@ class SubtitleIdentifier:
def _classify_single( def _classify_single(
self, entry, episode_stem: str | None = None self, entry, episode_stem: str | None = None
) -> SubtitleCandidate: ) -> SubtitleScanResult:
fmt = self.kb.format_for_extension(entry.suffix) fmt = self.kb.format_for_extension(entry.suffix)
tokens = ( tokens = (
_tokenize_suffix(entry.stem, episode_stem) _tokenize_suffix(entry.stem, episode_stem)
@@ -253,7 +253,7 @@ class SubtitleIdentifier:
if entry.suffix.lower() == ".srt": if entry.suffix.lower() == ".srt":
entry_count = _count_entries(self.scanner.read_text(entry.path)) entry_count = _count_entries(self.scanner.read_text(entry.path))
return SubtitleCandidate( return SubtitleScanResult(
language=language, language=language,
format=fmt, format=fmt,
subtitle_type=subtitle_type, subtitle_type=subtitle_type,
@@ -266,8 +266,8 @@ class SubtitleIdentifier:
) )
def _disambiguate_by_size( def _disambiguate_by_size(
self, tracks: list[SubtitleCandidate] self, tracks: list[SubtitleScanResult]
) -> list[SubtitleCandidate]: ) -> list[SubtitleScanResult]:
""" """
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
@@ -277,7 +277,7 @@ class SubtitleIdentifier:
""" """
# Group by language code # Group by language code
lang_groups: dict[str, list[SubtitleCandidate]] = {} lang_groups: dict[str, list[SubtitleScanResult]] = {}
for track in tracks: for track in tracks:
key = track.language.code if track.language else "__unknown__" key = track.language.code if track.language else "__unknown__"
lang_groups.setdefault(key, []).append(track) lang_groups.setdefault(key, []).append(track)
@@ -306,6 +306,6 @@ class SubtitleIdentifier:
return result return result
def _set_type(self, track: SubtitleCandidate, stype: SubtitleType) -> None: def _set_type(self, track: SubtitleScanResult, stype: SubtitleType) -> None:
"""Mutate track type in-place.""" """Mutate track type in-place."""
track.subtitle_type = stype track.subtitle_type = stype
+12 -12
View File
@@ -2,7 +2,7 @@
import logging import logging
from ..entities import SubtitleCandidate from ..entities import SubtitleScanResult
from ..value_objects import SubtitleMatchingRules from ..value_objects import SubtitleMatchingRules
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -10,7 +10,7 @@ logger = logging.getLogger(__name__)
class SubtitleMatcher: class SubtitleMatcher:
""" """
Filters a list of SubtitleCandidate against effective SubtitleMatchingRules. Filters a list of SubtitleScanResult against effective SubtitleMatchingRules.
Returns matched tracks (pass all filters, confidence >= min_confidence) Returns matched tracks (pass all filters, confidence >= min_confidence)
and unresolved tracks (need user clarification). and unresolved tracks (need user clarification).
@@ -21,14 +21,14 @@ class SubtitleMatcher:
def match( def match(
self, self,
tracks: list[SubtitleCandidate], tracks: list[SubtitleScanResult],
rules: SubtitleMatchingRules, rules: SubtitleMatchingRules,
) -> tuple[list[SubtitleCandidate], list[SubtitleCandidate]]: ) -> tuple[list[SubtitleScanResult], list[SubtitleScanResult]]:
""" """
Returns (matched, unresolved). Returns (matched, unresolved).
""" """
matched: list[SubtitleCandidate] = [] matched: list[SubtitleScanResult] = []
unresolved: list[SubtitleCandidate] = [] unresolved: list[SubtitleScanResult] = []
for track in tracks: for track in tracks:
if track.is_embedded: if track.is_embedded:
@@ -51,7 +51,7 @@ class SubtitleMatcher:
return matched, unresolved return matched, unresolved
def _passes_filters( def _passes_filters(
self, track: SubtitleCandidate, rules: SubtitleMatchingRules self, track: SubtitleScanResult, rules: SubtitleMatchingRules
) -> bool: ) -> bool:
# Language filter # Language filter
if rules.preferred_languages: if rules.preferred_languages:
@@ -76,14 +76,14 @@ class SubtitleMatcher:
def _resolve_conflicts( def _resolve_conflicts(
self, self,
tracks: list[SubtitleCandidate], tracks: list[SubtitleScanResult],
rules: SubtitleMatchingRules, rules: SubtitleMatchingRules,
) -> list[SubtitleCandidate]: ) -> list[SubtitleScanResult]:
""" """
When multiple tracks have same language + type, keep only the best one When multiple tracks have same language + type, keep only the best one
according to format_priority. If no format_priority applies, keep the first. according to format_priority. If no format_priority applies, keep the first.
""" """
seen: dict[tuple, SubtitleCandidate] = {} seen: dict[tuple, SubtitleScanResult] = {}
for track in tracks: for track in tracks:
lang = track.language.code if track.language else None lang = track.language.code if track.language else None
@@ -106,8 +106,8 @@ class SubtitleMatcher:
def _prefer( def _prefer(
self, self,
candidate: SubtitleCandidate, candidate: SubtitleScanResult,
existing: SubtitleCandidate, existing: SubtitleScanResult,
format_priority: list[str], format_priority: list[str],
) -> bool: ) -> bool:
"""Return True if candidate is preferable to existing.""" """Return True if candidate is preferable to existing."""
+3 -3
View File
@@ -1,9 +1,9 @@
"""Subtitle service utilities.""" """Subtitle service utilities."""
from ..entities import SubtitleCandidate from ..entities import SubtitleScanResult
def available_subtitles(tracks: list[SubtitleCandidate]) -> list[SubtitleCandidate]: def available_subtitles(tracks: list[SubtitleScanResult]) -> list[SubtitleScanResult]:
""" """
Return the distinct subtitle tracks available, deduped by (language, type). Return the distinct subtitle tracks available, deduped by (language, type).
@@ -11,7 +11,7 @@ def available_subtitles(tracks: list[SubtitleCandidate]) -> list[SubtitleCandida
preferences — e.g. eng, eng.sdh, fra all show up as separate entries. preferences — e.g. eng, eng.sdh, fra all show up as separate entries.
""" """
seen: set[tuple] = set() seen: set[tuple] = set()
result: list[SubtitleCandidate] = [] result: list[SubtitleScanResult] = []
for track in tracks: for track in tracks:
lang = track.language.code if track.language else None lang = track.language.code if track.language else None
key = (lang, track.subtitle_type) key = (lang, track.subtitle_type)
@@ -13,7 +13,7 @@ from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from alfred.domain.subtitles.entities import SubtitleCandidate from alfred.domain.subtitles.entities import SubtitleScanResult
from alfred.application.subtitles.placer import PlacedTrack from alfred.application.subtitles.placer import PlacedTrack
from alfred.infrastructure.metadata.store import MetadataStore from alfred.infrastructure.metadata.store import MetadataStore
@@ -25,7 +25,7 @@ class SubtitleMetadataStore:
Subtitle-pipeline view of the per-release `.alfred/metadata.yaml`. Subtitle-pipeline view of the per-release `.alfred/metadata.yaml`.
Backed by a generic MetadataStore; this class only knows how to build Backed by a generic MetadataStore; this class only knows how to build
a subtitle_history entry from PlacedTrack/SubtitleCandidate pairs. a subtitle_history entry from PlacedTrack/SubtitleScanResult pairs.
""" """
def __init__(self, library_root: Path): def __init__(self, library_root: Path):
@@ -45,7 +45,7 @@ class SubtitleMetadataStore:
def append_history( def append_history(
self, self,
placed_pairs: list[tuple[PlacedTrack, SubtitleCandidate]], placed_pairs: list[tuple[PlacedTrack, SubtitleScanResult]],
season: int | None = None, season: int | None = None,
episode: int | None = None, episode: int | None = None,
release_group: str | None = None, release_group: str | None = None,
+3 -3
View File
@@ -40,7 +40,7 @@ from alfred.application.filesystem.manage_subtitles import (
_to_imdb_id, _to_imdb_id,
_to_unresolved_dto, _to_unresolved_dto,
) )
from alfred.domain.subtitles.entities import MediaSubtitleMetadata, SubtitleCandidate from alfred.domain.subtitles.entities import MediaSubtitleMetadata, SubtitleScanResult
from alfred.application.subtitles.placer import PlacedTrack, PlaceResult from alfred.application.subtitles.placer import PlacedTrack, PlaceResult
from alfred.domain.subtitles.value_objects import ( from alfred.domain.subtitles.value_objects import (
ScanStrategy, ScanStrategy,
@@ -63,8 +63,8 @@ def _track(
is_embedded: bool = False, is_embedded: bool = False,
raw_tokens: list[str] | None = None, raw_tokens: list[str] | None = None,
file_size_kb: float | None = None, file_size_kb: float | None = None,
) -> SubtitleCandidate: ) -> SubtitleScanResult:
return SubtitleCandidate( return SubtitleScanResult(
language=lang, language=lang,
format=fmt, format=fmt,
subtitle_type=stype, subtitle_type=stype,
+3 -3
View File
@@ -21,7 +21,7 @@ from unittest.mock import patch
import pytest import pytest
from alfred.domain.subtitles.entities import SubtitleCandidate from alfred.domain.subtitles.entities import SubtitleScanResult
from alfred.application.subtitles.placer import ( from alfred.application.subtitles.placer import (
PlacedTrack, PlacedTrack,
PlaceResult, PlaceResult,
@@ -46,8 +46,8 @@ def _track(
fmt=SRT, fmt=SRT,
stype=SubtitleType.STANDARD, stype=SubtitleType.STANDARD,
is_embedded: bool = False, is_embedded: bool = False,
) -> SubtitleCandidate: ) -> SubtitleScanResult:
return SubtitleCandidate( return SubtitleScanResult(
language=lang, language=lang,
format=fmt, format=fmt,
subtitle_type=stype, subtitle_type=stype,
+3 -3
View File
@@ -23,7 +23,7 @@ from unittest.mock import patch
import pytest import pytest
from alfred.domain.shared.ports import FileEntry from alfred.domain.shared.ports import FileEntry
from alfred.domain.subtitles.entities import SubtitleCandidate from alfred.domain.subtitles.entities import SubtitleScanResult
from alfred.domain.subtitles.services.identifier import ( from alfred.domain.subtitles.services.identifier import (
SubtitleIdentifier, SubtitleIdentifier,
_count_entries, _count_entries,
@@ -310,8 +310,8 @@ class TestSizeDisambiguation:
detection=TypeDetectionMethod.SIZE_AND_COUNT, detection=TypeDetectionMethod.SIZE_AND_COUNT,
) )
def _track(self, lang_code: str, entries: int) -> SubtitleCandidate: def _track(self, lang_code: str, entries: int) -> SubtitleScanResult:
return SubtitleCandidate( return SubtitleScanResult(
language=SubtitleLanguage(code=lang_code, tokens=[lang_code]), language=SubtitleLanguage(code=lang_code, tokens=[lang_code]),
format=None, format=None,
subtitle_type=SubtitleType.UNKNOWN, subtitle_type=SubtitleType.UNKNOWN,
+3 -3
View File
@@ -18,7 +18,7 @@ from __future__ import annotations
import pytest import pytest
from alfred.domain.subtitles.entities import SubtitleCandidate from alfred.domain.subtitles.entities import SubtitleScanResult
from alfred.domain.subtitles.services.matcher import SubtitleMatcher from alfred.domain.subtitles.services.matcher import SubtitleMatcher
from alfred.domain.subtitles.value_objects import ( from alfred.domain.subtitles.value_objects import (
SubtitleFormat, SubtitleFormat,
@@ -40,8 +40,8 @@ def _track(
stype: SubtitleType = SubtitleType.STANDARD, stype: SubtitleType = SubtitleType.STANDARD,
confidence: float = 1.0, confidence: float = 1.0,
is_embedded: bool = False, is_embedded: bool = False,
) -> SubtitleCandidate: ) -> SubtitleScanResult:
return SubtitleCandidate( return SubtitleScanResult(
language=lang, language=lang,
format=fmt, format=fmt,
subtitle_type=stype, subtitle_type=stype,
+27 -27
View File
@@ -5,9 +5,9 @@ uncovered:
- ``TestSubtitleFormat`` — extension matching (case-insensitive). - ``TestSubtitleFormat`` — extension matching (case-insensitive).
- ``TestSubtitleLanguage`` — token matching (case-insensitive). - ``TestSubtitleLanguage`` — token matching (case-insensitive).
- ``TestSubtitleCandidateDestName`` — ``destination_name`` property: - ``TestSubtitleScanResultDestName`` — ``destination_name`` property:
standard / SDH / forced naming, error on missing language or format. standard / SDH / forced naming, error on missing language or format.
- ``TestSubtitleCandidateRepr`` — debug repr for embedded vs external. - ``TestSubtitleScanResultRepr`` — debug repr for embedded vs external.
- ``TestMediaSubtitleMetadata`` — ``all_tracks`` / ``total_count`` / - ``TestMediaSubtitleMetadata`` — ``all_tracks`` / ``total_count`` /
``unresolved_tracks``. ``unresolved_tracks``.
- ``TestAvailableSubtitles`` — utility dedup by (lang, type). - ``TestAvailableSubtitles`` — utility dedup by (lang, type).
@@ -24,7 +24,7 @@ from pathlib import Path
import pytest import pytest
from alfred.domain.subtitles.aggregates import SubtitleRuleSet from alfred.domain.subtitles.aggregates import SubtitleRuleSet
from alfred.domain.subtitles.entities import MediaSubtitleMetadata, SubtitleCandidate from alfred.domain.subtitles.entities import MediaSubtitleMetadata, SubtitleScanResult
from alfred.domain.subtitles.services.utils import available_subtitles from alfred.domain.subtitles.services.utils import available_subtitles
from alfred.domain.subtitles.value_objects import ( from alfred.domain.subtitles.value_objects import (
RuleScope, RuleScope,
@@ -74,7 +74,7 @@ class TestSubtitleLanguage:
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# SubtitleCandidate # # SubtitleScanResult #
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -82,50 +82,50 @@ SRT = SubtitleFormat(id="srt", extensions=[".srt"])
FRA = SubtitleLanguage(code="fra", tokens=["fr", "fre"]) FRA = SubtitleLanguage(code="fra", tokens=["fr", "fre"])
class TestSubtitleCandidateDestName: class TestSubtitleScanResultDestName:
def test_standard(self): def test_standard(self):
t = SubtitleCandidate( t = SubtitleScanResult(
language=FRA, format=SRT, subtitle_type=SubtitleType.STANDARD language=FRA, format=SRT, subtitle_type=SubtitleType.STANDARD
) )
assert t.destination_name == "fra.srt" assert t.destination_name == "fra.srt"
def test_sdh(self): def test_sdh(self):
t = SubtitleCandidate(language=FRA, format=SRT, subtitle_type=SubtitleType.SDH) t = SubtitleScanResult(language=FRA, format=SRT, subtitle_type=SubtitleType.SDH)
assert t.destination_name == "fra.sdh.srt" assert t.destination_name == "fra.sdh.srt"
def test_forced(self): def test_forced(self):
t = SubtitleCandidate( t = SubtitleScanResult(
language=FRA, format=SRT, subtitle_type=SubtitleType.FORCED 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( t = SubtitleScanResult(
language=FRA, format=SRT, subtitle_type=SubtitleType.UNKNOWN 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"
def test_missing_language_raises(self): def test_missing_language_raises(self):
t = SubtitleCandidate(language=None, format=SRT) t = SubtitleScanResult(language=None, format=SRT)
with pytest.raises(ValueError, match="language or format missing"): with pytest.raises(ValueError, match="language or format missing"):
t.destination_name t.destination_name
def test_missing_format_raises(self): def test_missing_format_raises(self):
t = SubtitleCandidate(language=FRA, format=None) t = SubtitleScanResult(language=FRA, format=None)
with pytest.raises(ValueError, match="language or format missing"): with pytest.raises(ValueError, match="language or format missing"):
t.destination_name t.destination_name
def test_extension_dot_stripped(self): def test_extension_dot_stripped(self):
# Format extension is ".srt" — leading dot must not be duplicated. # Format extension is ".srt" — leading dot must not be duplicated.
t = SubtitleCandidate(language=FRA, format=SRT) t = SubtitleScanResult(language=FRA, format=SRT)
assert t.destination_name.endswith(".srt") assert t.destination_name.endswith(".srt")
assert ".." not in t.destination_name assert ".." not in t.destination_name
class TestSubtitleCandidateRepr: class TestSubtitleScanResultRepr:
def test_embedded_repr(self): def test_embedded_repr(self):
t = SubtitleCandidate( t = SubtitleScanResult(
language=FRA, format=None, is_embedded=True, confidence=1.0 language=FRA, format=None, is_embedded=True, confidence=1.0
) )
r = repr(t) r = repr(t)
@@ -135,14 +135,14 @@ 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(language=FRA, format=SRT, file_path=f, confidence=0.85) t = SubtitleScanResult(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
assert "0.85" in r assert "0.85" in r
def test_unresolved_repr(self): def test_unresolved_repr(self):
t = SubtitleCandidate(language=None, format=None) t = SubtitleScanResult(language=None, format=None)
r = repr(t) r = repr(t)
assert "?" in r assert "?" in r
@@ -160,8 +160,8 @@ class TestMediaSubtitleMetadata:
assert m.unresolved_tracks == [] assert m.unresolved_tracks == []
def test_aggregates_embedded_and_external(self): def test_aggregates_embedded_and_external(self):
e = SubtitleCandidate(language=FRA, format=None, is_embedded=True) e = SubtitleScanResult(language=FRA, format=None, is_embedded=True)
x = SubtitleCandidate(language=FRA, format=SRT, file_path=Path("/x.srt")) x = SubtitleScanResult(language=FRA, format=SRT, file_path=Path("/x.srt"))
m = MediaSubtitleMetadata( m = MediaSubtitleMetadata(
media_id=None, media_id=None,
media_type="movie", media_type="movie",
@@ -174,13 +174,13 @@ 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( embedded_unknown = SubtitleScanResult(
language=None, format=None, is_embedded=True language=None, format=None, is_embedded=True
) )
external_known = SubtitleCandidate( external_known = SubtitleScanResult(
language=FRA, format=SRT, file_path=Path("/a.srt") language=FRA, format=SRT, file_path=Path("/a.srt")
) )
external_unknown = SubtitleCandidate( external_unknown = SubtitleScanResult(
language=None, format=SRT, file_path=Path("/b.srt") language=None, format=SRT, file_path=Path("/b.srt")
) )
m = MediaSubtitleMetadata( m = MediaSubtitleMetadata(
@@ -201,14 +201,14 @@ 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( SubtitleScanResult(
language=FRA, format=SRT, subtitle_type=SubtitleType.STANDARD language=FRA, format=SRT, subtitle_type=SubtitleType.STANDARD
), ),
SubtitleCandidate( SubtitleScanResult(
language=FRA, format=SRT, subtitle_type=SubtitleType.STANDARD language=FRA, format=SRT, subtitle_type=SubtitleType.STANDARD
), ),
SubtitleCandidate(language=FRA, format=SRT, subtitle_type=SubtitleType.SDH), SubtitleScanResult(language=FRA, format=SRT, subtitle_type=SubtitleType.SDH),
SubtitleCandidate( SubtitleScanResult(
language=ENG, format=SRT, subtitle_type=SubtitleType.STANDARD language=ENG, format=SRT, subtitle_type=SubtitleType.STANDARD
), ),
] ]
@@ -222,10 +222,10 @@ class TestAvailableSubtitles:
def test_none_language_treated_as_key(self): def test_none_language_treated_as_key(self):
# Tracks with no language form a single None-keyed bucket. # Tracks with no language form a single None-keyed bucket.
t1 = SubtitleCandidate( t1 = SubtitleScanResult(
language=None, format=SRT, subtitle_type=SubtitleType.UNKNOWN language=None, format=SRT, subtitle_type=SubtitleType.UNKNOWN
) )
t2 = SubtitleCandidate( t2 = SubtitleScanResult(
language=None, format=SRT, subtitle_type=SubtitleType.UNKNOWN language=None, format=SRT, subtitle_type=SubtitleType.UNKNOWN
) )
result = available_subtitles([t1, t2]) result = available_subtitles([t1, t2])
@@ -16,7 +16,7 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from alfred.domain.subtitles.entities import SubtitleCandidate from alfred.domain.subtitles.entities import SubtitleScanResult
from alfred.application.subtitles.placer import PlacedTrack from alfred.application.subtitles.placer import PlacedTrack
from alfred.domain.subtitles.value_objects import ( from alfred.domain.subtitles.value_objects import (
SubtitleFormat, SubtitleFormat,
@@ -32,8 +32,8 @@ ENG = SubtitleLanguage(code="eng", tokens=["en"])
def _track( def _track(
lang=FRA, *, embedded: bool = False, confidence: float = 0.92 lang=FRA, *, embedded: bool = False, confidence: float = 0.92
) -> SubtitleCandidate: ) -> SubtitleScanResult:
return SubtitleCandidate( return SubtitleScanResult(
language=lang, language=lang,
format=SRT, format=SRT,
subtitle_type=SubtitleType.STANDARD, subtitle_type=SubtitleType.STANDARD,