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
|
### 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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user