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:
@@ -68,6 +68,18 @@ callers).
|
||||
|
||||
### 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
|
||||
instances.** The VO was mutable so `detect_media_type` and
|
||||
`enrich_from_probe` could patch fields in place — a code smell in a
|
||||
|
||||
@@ -4,7 +4,7 @@ import logging
|
||||
from pathlib import Path
|
||||
|
||||
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.matcher import SubtitleMatcher
|
||||
from alfred.domain.subtitles.services.pattern_detector import PatternDetector
|
||||
@@ -278,7 +278,7 @@ class ManageSubtitlesUseCase:
|
||||
|
||||
|
||||
def _to_unresolved_dto(
|
||||
track: SubtitleCandidate, min_confidence: float = 0.7
|
||||
track: SubtitleScanResult, min_confidence: float = 0.7
|
||||
) -> UnresolvedTrack:
|
||||
reason = "unknown_language" if track.language is None else "low_confidence"
|
||||
return UnresolvedTrack(
|
||||
@@ -291,10 +291,10 @@ def _to_unresolved_dto(
|
||||
|
||||
def _pair_placed_with_tracks(
|
||||
placed: list[PlacedTrack],
|
||||
tracks: list[SubtitleCandidate],
|
||||
) -> list[tuple[PlacedTrack, SubtitleCandidate]]:
|
||||
tracks: list[SubtitleScanResult],
|
||||
) -> 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.
|
||||
"""
|
||||
track_by_path = {t.file_path: t for t in tracks if t.file_path}
|
||||
|
||||
@@ -5,13 +5,13 @@ import os
|
||||
from dataclasses import dataclass
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
@@ -41,7 +41,7 @@ class PlacedTrack:
|
||||
@dataclass
|
||||
class PlaceResult:
|
||||
placed: list[PlacedTrack]
|
||||
skipped: list[tuple[SubtitleCandidate, str]] # (track, reason)
|
||||
skipped: list[tuple[SubtitleScanResult, str]] # (track, reason)
|
||||
|
||||
@property
|
||||
def placed_count(self) -> int:
|
||||
@@ -54,7 +54,7 @@ class PlaceResult:
|
||||
|
||||
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:
|
||||
instant, no data duplication, qBittorrent keeps seeding.
|
||||
@@ -64,11 +64,11 @@ class SubtitlePlacer:
|
||||
|
||||
def place(
|
||||
self,
|
||||
tracks: list[SubtitleCandidate],
|
||||
tracks: list[SubtitleScanResult],
|
||||
destination_video: Path,
|
||||
) -> PlaceResult:
|
||||
placed: list[PlacedTrack] = []
|
||||
skipped: list[tuple[SubtitleCandidate, str]] = []
|
||||
skipped: list[tuple[SubtitleScanResult, str]] = []
|
||||
|
||||
dest_dir = destination_video.parent
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
These are the **container-view** dataclasses, populated from ffprobe output and
|
||||
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,
|
||||
file path, etc.). The two coexist by design — they describe the same real-world
|
||||
concept seen from two different bounded contexts.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Subtitles domain — subtitle identification, classification and placement."""
|
||||
|
||||
from .aggregates import SubtitleRuleSet
|
||||
from .entities import MediaSubtitleMetadata, SubtitleCandidate
|
||||
from .entities import MediaSubtitleMetadata, SubtitleScanResult
|
||||
from .exceptions import SubtitleNotFound
|
||||
from .services import PatternDetector, SubtitleIdentifier, SubtitleMatcher
|
||||
from .value_objects import (
|
||||
@@ -17,7 +17,7 @@ from .value_objects import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"SubtitleCandidate",
|
||||
"SubtitleScanResult",
|
||||
"MediaSubtitleMetadata",
|
||||
"SubtitleRuleSet",
|
||||
"SubtitleIdentifier",
|
||||
|
||||
@@ -12,16 +12,18 @@ from .value_objects import (
|
||||
|
||||
|
||||
@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
|
||||
populated from ffprobe), a SubtitleCandidate carries the **flow state** of the
|
||||
subtitle matching pipeline: language/format are typed value objects that may
|
||||
be ``None`` while classification is in progress, ``confidence`` reflects how
|
||||
certain we are, and ``raw_tokens`` holds the filename fragments still under
|
||||
analysis. State evolves: unknown → resolved after user clarification.
|
||||
populated from ffprobe), a ``SubtitleScanResult`` carries the **flow state**
|
||||
of the subtitle matching pipeline: language/format are typed value objects
|
||||
that may be ``None`` while classification is in progress, ``confidence``
|
||||
reflects how certain we are, and ``raw_tokens`` holds the filename fragments
|
||||
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)
|
||||
@@ -72,7 +74,7 @@ class SubtitleCandidate:
|
||||
if self.is_embedded
|
||||
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
|
||||
@@ -84,14 +86,14 @@ class MediaSubtitleMetadata:
|
||||
|
||||
media_id: ImdbId | None
|
||||
media_type: str # "movie" | "tv_show"
|
||||
embedded_tracks: list[SubtitleCandidate] = field(default_factory=list)
|
||||
external_tracks: list[SubtitleCandidate] = field(default_factory=list)
|
||||
embedded_tracks: list[SubtitleScanResult] = field(default_factory=list)
|
||||
external_tracks: list[SubtitleScanResult] = field(default_factory=list)
|
||||
release_group: str | None = None
|
||||
detected_pattern_id: str | None = None # pattern id from knowledge base
|
||||
pattern_confirmed: bool = False
|
||||
|
||||
@property
|
||||
def all_tracks(self) -> list[SubtitleCandidate]:
|
||||
def all_tracks(self) -> list[SubtitleScanResult]:
|
||||
return self.embedded_tracks + self.external_tracks
|
||||
|
||||
@property
|
||||
@@ -99,5 +101,5 @@ class MediaSubtitleMetadata:
|
||||
return len(self.embedded_tracks) + len(self.external_tracks)
|
||||
|
||||
@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]
|
||||
|
||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
||||
from ...shared.ports import FilesystemScanner, MediaProber
|
||||
from ..ports import SubtitleKnowledge
|
||||
from ...shared.value_objects import ImdbId
|
||||
from ..entities import MediaSubtitleMetadata, SubtitleCandidate
|
||||
from ..entities import MediaSubtitleMetadata, SubtitleScanResult
|
||||
from ..value_objects import ScanStrategy, SubtitlePattern, SubtitleType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -94,7 +94,7 @@ class SubtitleIdentifier:
|
||||
# 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)
|
||||
|
||||
tracks = []
|
||||
@@ -111,7 +111,7 @@ class SubtitleIdentifier:
|
||||
stype = SubtitleType.STANDARD
|
||||
|
||||
tracks.append(
|
||||
SubtitleCandidate(
|
||||
SubtitleScanResult(
|
||||
language=lang,
|
||||
format=None,
|
||||
subtitle_type=stype,
|
||||
@@ -131,7 +131,7 @@ class SubtitleIdentifier:
|
||||
|
||||
def _scan_external(
|
||||
self, video_path: Path, pattern: SubtitlePattern
|
||||
) -> list[SubtitleCandidate]:
|
||||
) -> list[SubtitleScanResult]:
|
||||
strategy = pattern.scan_strategy
|
||||
episode_stem: str | None = None
|
||||
|
||||
@@ -200,7 +200,7 @@ class SubtitleIdentifier:
|
||||
entries: list,
|
||||
pattern: SubtitlePattern,
|
||||
episode_stem: str | None = None,
|
||||
) -> list[SubtitleCandidate]:
|
||||
) -> list[SubtitleScanResult]:
|
||||
tracks = [
|
||||
self._classify_single(entry, episode_stem=episode_stem) for entry in entries
|
||||
]
|
||||
@@ -214,7 +214,7 @@ class SubtitleIdentifier:
|
||||
|
||||
def _classify_single(
|
||||
self, entry, episode_stem: str | None = None
|
||||
) -> SubtitleCandidate:
|
||||
) -> SubtitleScanResult:
|
||||
fmt = self.kb.format_for_extension(entry.suffix)
|
||||
tokens = (
|
||||
_tokenize_suffix(entry.stem, episode_stem)
|
||||
@@ -253,7 +253,7 @@ class SubtitleIdentifier:
|
||||
if entry.suffix.lower() == ".srt":
|
||||
entry_count = _count_entries(self.scanner.read_text(entry.path))
|
||||
|
||||
return SubtitleCandidate(
|
||||
return SubtitleScanResult(
|
||||
language=language,
|
||||
format=fmt,
|
||||
subtitle_type=subtitle_type,
|
||||
@@ -266,8 +266,8 @@ class SubtitleIdentifier:
|
||||
)
|
||||
|
||||
def _disambiguate_by_size(
|
||||
self, tracks: list[SubtitleCandidate]
|
||||
) -> list[SubtitleCandidate]:
|
||||
self, tracks: list[SubtitleScanResult]
|
||||
) -> list[SubtitleScanResult]:
|
||||
"""
|
||||
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
|
||||
@@ -277,7 +277,7 @@ class SubtitleIdentifier:
|
||||
"""
|
||||
|
||||
# Group by language code
|
||||
lang_groups: dict[str, list[SubtitleCandidate]] = {}
|
||||
lang_groups: dict[str, list[SubtitleScanResult]] = {}
|
||||
for track in tracks:
|
||||
key = track.language.code if track.language else "__unknown__"
|
||||
lang_groups.setdefault(key, []).append(track)
|
||||
@@ -306,6 +306,6 @@ class SubtitleIdentifier:
|
||||
|
||||
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."""
|
||||
track.subtitle_type = stype
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import logging
|
||||
|
||||
from ..entities import SubtitleCandidate
|
||||
from ..entities import SubtitleScanResult
|
||||
from ..value_objects import SubtitleMatchingRules
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -10,7 +10,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
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)
|
||||
and unresolved tracks (need user clarification).
|
||||
@@ -21,14 +21,14 @@ class SubtitleMatcher:
|
||||
|
||||
def match(
|
||||
self,
|
||||
tracks: list[SubtitleCandidate],
|
||||
tracks: list[SubtitleScanResult],
|
||||
rules: SubtitleMatchingRules,
|
||||
) -> tuple[list[SubtitleCandidate], list[SubtitleCandidate]]:
|
||||
) -> tuple[list[SubtitleScanResult], list[SubtitleScanResult]]:
|
||||
"""
|
||||
Returns (matched, unresolved).
|
||||
"""
|
||||
matched: list[SubtitleCandidate] = []
|
||||
unresolved: list[SubtitleCandidate] = []
|
||||
matched: list[SubtitleScanResult] = []
|
||||
unresolved: list[SubtitleScanResult] = []
|
||||
|
||||
for track in tracks:
|
||||
if track.is_embedded:
|
||||
@@ -51,7 +51,7 @@ class SubtitleMatcher:
|
||||
return matched, unresolved
|
||||
|
||||
def _passes_filters(
|
||||
self, track: SubtitleCandidate, rules: SubtitleMatchingRules
|
||||
self, track: SubtitleScanResult, rules: SubtitleMatchingRules
|
||||
) -> bool:
|
||||
# Language filter
|
||||
if rules.preferred_languages:
|
||||
@@ -76,14 +76,14 @@ class SubtitleMatcher:
|
||||
|
||||
def _resolve_conflicts(
|
||||
self,
|
||||
tracks: list[SubtitleCandidate],
|
||||
tracks: list[SubtitleScanResult],
|
||||
rules: SubtitleMatchingRules,
|
||||
) -> list[SubtitleCandidate]:
|
||||
) -> list[SubtitleScanResult]:
|
||||
"""
|
||||
When multiple tracks have same language + type, keep only the best one
|
||||
according to format_priority. If no format_priority applies, keep the first.
|
||||
"""
|
||||
seen: dict[tuple, SubtitleCandidate] = {}
|
||||
seen: dict[tuple, SubtitleScanResult] = {}
|
||||
|
||||
for track in tracks:
|
||||
lang = track.language.code if track.language else None
|
||||
@@ -106,8 +106,8 @@ class SubtitleMatcher:
|
||||
|
||||
def _prefer(
|
||||
self,
|
||||
candidate: SubtitleCandidate,
|
||||
existing: SubtitleCandidate,
|
||||
candidate: SubtitleScanResult,
|
||||
existing: SubtitleScanResult,
|
||||
format_priority: list[str],
|
||||
) -> bool:
|
||||
"""Return True if candidate is preferable to existing."""
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""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).
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
seen: set[tuple] = set()
|
||||
result: list[SubtitleCandidate] = []
|
||||
result: list[SubtitleScanResult] = []
|
||||
for track in tracks:
|
||||
lang = track.language.code if track.language else None
|
||||
key = (lang, track.subtitle_type)
|
||||
|
||||
@@ -13,7 +13,7 @@ from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
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.infrastructure.metadata.store import MetadataStore
|
||||
|
||||
@@ -25,7 +25,7 @@ class SubtitleMetadataStore:
|
||||
Subtitle-pipeline view of the per-release `.alfred/metadata.yaml`.
|
||||
|
||||
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):
|
||||
@@ -45,7 +45,7 @@ class SubtitleMetadataStore:
|
||||
|
||||
def append_history(
|
||||
self,
|
||||
placed_pairs: list[tuple[PlacedTrack, SubtitleCandidate]],
|
||||
placed_pairs: list[tuple[PlacedTrack, SubtitleScanResult]],
|
||||
season: int | None = None,
|
||||
episode: int | None = None,
|
||||
release_group: str | None = None,
|
||||
|
||||
@@ -40,7 +40,7 @@ from alfred.application.filesystem.manage_subtitles import (
|
||||
_to_imdb_id,
|
||||
_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.domain.subtitles.value_objects import (
|
||||
ScanStrategy,
|
||||
@@ -63,8 +63,8 @@ def _track(
|
||||
is_embedded: bool = False,
|
||||
raw_tokens: list[str] | None = None,
|
||||
file_size_kb: float | None = None,
|
||||
) -> SubtitleCandidate:
|
||||
return SubtitleCandidate(
|
||||
) -> SubtitleScanResult:
|
||||
return SubtitleScanResult(
|
||||
language=lang,
|
||||
format=fmt,
|
||||
subtitle_type=stype,
|
||||
|
||||
@@ -21,7 +21,7 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from alfred.domain.subtitles.entities import SubtitleCandidate
|
||||
from alfred.domain.subtitles.entities import SubtitleScanResult
|
||||
from alfred.application.subtitles.placer import (
|
||||
PlacedTrack,
|
||||
PlaceResult,
|
||||
@@ -46,8 +46,8 @@ def _track(
|
||||
fmt=SRT,
|
||||
stype=SubtitleType.STANDARD,
|
||||
is_embedded: bool = False,
|
||||
) -> SubtitleCandidate:
|
||||
return SubtitleCandidate(
|
||||
) -> SubtitleScanResult:
|
||||
return SubtitleScanResult(
|
||||
language=lang,
|
||||
format=fmt,
|
||||
subtitle_type=stype,
|
||||
|
||||
@@ -23,7 +23,7 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
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 (
|
||||
SubtitleIdentifier,
|
||||
_count_entries,
|
||||
@@ -310,8 +310,8 @@ class TestSizeDisambiguation:
|
||||
detection=TypeDetectionMethod.SIZE_AND_COUNT,
|
||||
)
|
||||
|
||||
def _track(self, lang_code: str, entries: int) -> SubtitleCandidate:
|
||||
return SubtitleCandidate(
|
||||
def _track(self, lang_code: str, entries: int) -> SubtitleScanResult:
|
||||
return SubtitleScanResult(
|
||||
language=SubtitleLanguage(code=lang_code, tokens=[lang_code]),
|
||||
format=None,
|
||||
subtitle_type=SubtitleType.UNKNOWN,
|
||||
|
||||
@@ -18,7 +18,7 @@ from __future__ import annotations
|
||||
|
||||
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.value_objects import (
|
||||
SubtitleFormat,
|
||||
@@ -40,8 +40,8 @@ def _track(
|
||||
stype: SubtitleType = SubtitleType.STANDARD,
|
||||
confidence: float = 1.0,
|
||||
is_embedded: bool = False,
|
||||
) -> SubtitleCandidate:
|
||||
return SubtitleCandidate(
|
||||
) -> SubtitleScanResult:
|
||||
return SubtitleScanResult(
|
||||
language=lang,
|
||||
format=fmt,
|
||||
subtitle_type=stype,
|
||||
|
||||
@@ -5,9 +5,9 @@ uncovered:
|
||||
|
||||
- ``TestSubtitleFormat`` — extension 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.
|
||||
- ``TestSubtitleCandidateRepr`` — debug repr for embedded vs external.
|
||||
- ``TestSubtitleScanResultRepr`` — debug repr for embedded vs external.
|
||||
- ``TestMediaSubtitleMetadata`` — ``all_tracks`` / ``total_count`` /
|
||||
``unresolved_tracks``.
|
||||
- ``TestAvailableSubtitles`` — utility dedup by (lang, type).
|
||||
@@ -24,7 +24,7 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
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.value_objects import (
|
||||
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"])
|
||||
|
||||
|
||||
class TestSubtitleCandidateDestName:
|
||||
class TestSubtitleScanResultDestName:
|
||||
def test_standard(self):
|
||||
t = SubtitleCandidate(
|
||||
t = SubtitleScanResult(
|
||||
language=FRA, format=SRT, subtitle_type=SubtitleType.STANDARD
|
||||
)
|
||||
assert t.destination_name == "fra.srt"
|
||||
|
||||
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"
|
||||
|
||||
def test_forced(self):
|
||||
t = SubtitleCandidate(
|
||||
t = SubtitleScanResult(
|
||||
language=FRA, format=SRT, subtitle_type=SubtitleType.FORCED
|
||||
)
|
||||
assert t.destination_name == "fra.forced.srt"
|
||||
|
||||
def test_unknown_treated_as_standard(self):
|
||||
t = SubtitleCandidate(
|
||||
t = SubtitleScanResult(
|
||||
language=FRA, format=SRT, subtitle_type=SubtitleType.UNKNOWN
|
||||
)
|
||||
# UNKNOWN doesn't add a suffix → same as standard.
|
||||
assert t.destination_name == "fra.srt"
|
||||
|
||||
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"):
|
||||
t.destination_name
|
||||
|
||||
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"):
|
||||
t.destination_name
|
||||
|
||||
def test_extension_dot_stripped(self):
|
||||
# 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 ".." not in t.destination_name
|
||||
|
||||
|
||||
class TestSubtitleCandidateRepr:
|
||||
class TestSubtitleScanResultRepr:
|
||||
def test_embedded_repr(self):
|
||||
t = SubtitleCandidate(
|
||||
t = SubtitleScanResult(
|
||||
language=FRA, format=None, is_embedded=True, confidence=1.0
|
||||
)
|
||||
r = repr(t)
|
||||
@@ -135,14 +135,14 @@ class TestSubtitleCandidateRepr:
|
||||
def test_external_repr_uses_filename(self, tmp_path):
|
||||
f = tmp_path / "fr.srt"
|
||||
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)
|
||||
assert "fra" in r
|
||||
assert "fr.srt" in r
|
||||
assert "0.85" in r
|
||||
|
||||
def test_unresolved_repr(self):
|
||||
t = SubtitleCandidate(language=None, format=None)
|
||||
t = SubtitleScanResult(language=None, format=None)
|
||||
r = repr(t)
|
||||
assert "?" in r
|
||||
|
||||
@@ -160,8 +160,8 @@ class TestMediaSubtitleMetadata:
|
||||
assert m.unresolved_tracks == []
|
||||
|
||||
def test_aggregates_embedded_and_external(self):
|
||||
e = SubtitleCandidate(language=FRA, format=None, is_embedded=True)
|
||||
x = SubtitleCandidate(language=FRA, format=SRT, file_path=Path("/x.srt"))
|
||||
e = SubtitleScanResult(language=FRA, format=None, is_embedded=True)
|
||||
x = SubtitleScanResult(language=FRA, format=SRT, file_path=Path("/x.srt"))
|
||||
m = MediaSubtitleMetadata(
|
||||
media_id=None,
|
||||
media_type="movie",
|
||||
@@ -174,13 +174,13 @@ class TestMediaSubtitleMetadata:
|
||||
def test_unresolved_tracks_only_external_with_none_lang(self):
|
||||
# An embedded with None language must NOT appear in unresolved_tracks
|
||||
# (the property only iterates external_tracks).
|
||||
embedded_unknown = SubtitleCandidate(
|
||||
embedded_unknown = SubtitleScanResult(
|
||||
language=None, format=None, is_embedded=True
|
||||
)
|
||||
external_known = SubtitleCandidate(
|
||||
external_known = SubtitleScanResult(
|
||||
language=FRA, format=SRT, file_path=Path("/a.srt")
|
||||
)
|
||||
external_unknown = SubtitleCandidate(
|
||||
external_unknown = SubtitleScanResult(
|
||||
language=None, format=SRT, file_path=Path("/b.srt")
|
||||
)
|
||||
m = MediaSubtitleMetadata(
|
||||
@@ -201,14 +201,14 @@ class TestAvailableSubtitles:
|
||||
def test_dedup_by_lang_and_type(self):
|
||||
ENG = SubtitleLanguage(code="eng", tokens=["en"])
|
||||
tracks = [
|
||||
SubtitleCandidate(
|
||||
SubtitleScanResult(
|
||||
language=FRA, format=SRT, subtitle_type=SubtitleType.STANDARD
|
||||
),
|
||||
SubtitleCandidate(
|
||||
SubtitleScanResult(
|
||||
language=FRA, format=SRT, subtitle_type=SubtitleType.STANDARD
|
||||
),
|
||||
SubtitleCandidate(language=FRA, format=SRT, subtitle_type=SubtitleType.SDH),
|
||||
SubtitleCandidate(
|
||||
SubtitleScanResult(language=FRA, format=SRT, subtitle_type=SubtitleType.SDH),
|
||||
SubtitleScanResult(
|
||||
language=ENG, format=SRT, subtitle_type=SubtitleType.STANDARD
|
||||
),
|
||||
]
|
||||
@@ -222,10 +222,10 @@ class TestAvailableSubtitles:
|
||||
|
||||
def test_none_language_treated_as_key(self):
|
||||
# Tracks with no language form a single None-keyed bucket.
|
||||
t1 = SubtitleCandidate(
|
||||
t1 = SubtitleScanResult(
|
||||
language=None, format=SRT, subtitle_type=SubtitleType.UNKNOWN
|
||||
)
|
||||
t2 = SubtitleCandidate(
|
||||
t2 = SubtitleScanResult(
|
||||
language=None, format=SRT, subtitle_type=SubtitleType.UNKNOWN
|
||||
)
|
||||
result = available_subtitles([t1, t2])
|
||||
|
||||
@@ -16,7 +16,7 @@ from __future__ import annotations
|
||||
|
||||
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.domain.subtitles.value_objects import (
|
||||
SubtitleFormat,
|
||||
@@ -32,8 +32,8 @@ ENG = SubtitleLanguage(code="eng", tokens=["en"])
|
||||
|
||||
def _track(
|
||||
lang=FRA, *, embedded: bool = False, confidence: float = 0.92
|
||||
) -> SubtitleCandidate:
|
||||
return SubtitleCandidate(
|
||||
) -> SubtitleScanResult:
|
||||
return SubtitleScanResult(
|
||||
language=lang,
|
||||
format=SRT,
|
||||
subtitle_type=SubtitleType.STANDARD,
|
||||
|
||||
Reference in New Issue
Block a user