refactor(subtitles): inject MediaProber/FilesystemScanner ports into domain services
Domain services no longer call subprocess or pathlib directly. Introduces two Protocol ports in domain/shared/ports/: MediaProber.list_subtitle_streams(video) -> list[SubtitleStreamInfo] FilesystemScanner.scan_dir / stat / read_text -> list[FileEntry] | ... Concrete adapters live in infrastructure/: FfprobeMediaProber (wraps subprocess + ffprobe + JSON) PathlibFilesystemScanner (wraps pathlib + os reads) SubtitleIdentifier and PatternDetector now take (kb, prober, scanner) at construction time. Their internals work over FileEntry snapshots and SubtitleStreamInfo records — no more ad-hoc Path.is_file/iterdir/stat or embedded subprocess.run loops. _count_entries now takes raw SRT text (returned by scanner.read_text) so SRT-only entry counting stays out of the FS layer. manage_subtitles use case instantiates the two adapters once and injects them into both services. Tests pass real adapters and patch `alfred.infrastructure.probe.ffprobe_prober.subprocess.run` for the ffprobe-failure cases. _classify_single tests build FileEntry via a small helper. Domain is now free of subprocess / direct filesystem reads in the subtitle pipeline. The only remaining I/O hooks are FilePath VO convenience methods (exists/is_file/is_dir) which stay as a deliberate affordance on the value object.
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
"""Ports — Protocol interfaces the domain depends on.
|
||||
|
||||
Adapters live in ``alfred/infrastructure/`` and implement these protocols.
|
||||
Domain code never imports infrastructure; it accepts a port via constructor
|
||||
injection and calls it. Tests can pass in-memory fakes that satisfy the
|
||||
Protocol without going through real I/O.
|
||||
"""
|
||||
|
||||
from .filesystem_scanner import FileEntry, FilesystemScanner
|
||||
from .media_prober import MediaProber, SubtitleStreamInfo
|
||||
|
||||
__all__ = [
|
||||
"FileEntry",
|
||||
"FilesystemScanner",
|
||||
"MediaProber",
|
||||
"SubtitleStreamInfo",
|
||||
]
|
||||
@@ -0,0 +1,59 @@
|
||||
"""FilesystemScanner port — abstracts filesystem inspection.
|
||||
|
||||
The domain never calls ``Path.iterdir``, ``Path.is_file``, ``Path.stat`` or
|
||||
``open()`` directly. It asks the scanner for a ``FileEntry`` snapshot and
|
||||
reasons from there. One scan = one I/O round-trip; no callbacks back to disk.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FileEntry:
|
||||
"""Frozen snapshot of one filesystem entry, taken at scan time.
|
||||
|
||||
The entry carries enough metadata for the domain to classify and order
|
||||
files without re-querying the OS. ``size_kb`` is ``None`` for directories
|
||||
and for files whose size could not be read.
|
||||
"""
|
||||
|
||||
path: Path
|
||||
is_file: bool
|
||||
is_dir: bool
|
||||
size_kb: float | None
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self.path.name
|
||||
|
||||
@property
|
||||
def stem(self) -> str:
|
||||
return self.path.stem
|
||||
|
||||
@property
|
||||
def suffix(self) -> str:
|
||||
return self.path.suffix
|
||||
|
||||
|
||||
class FilesystemScanner(Protocol):
|
||||
"""Read-only filesystem inspection."""
|
||||
|
||||
def scan_dir(self, path: Path) -> list[FileEntry]:
|
||||
"""Return sorted entries directly inside ``path``.
|
||||
|
||||
Returns an empty list when ``path`` is not a directory or is
|
||||
unreadable. Adapters must not raise.
|
||||
"""
|
||||
...
|
||||
|
||||
def stat(self, path: Path) -> FileEntry | None:
|
||||
"""Stat a single path; ``None`` when it doesn't exist or is unreadable."""
|
||||
...
|
||||
|
||||
def read_text(self, path: Path, encoding: str = "utf-8") -> str | None:
|
||||
"""Read a text file in one go; ``None`` on any error."""
|
||||
...
|
||||
@@ -0,0 +1,39 @@
|
||||
"""MediaProber port — abstracts media stream inspection (e.g. ffprobe).
|
||||
|
||||
The adapter (typically wrapping ffprobe) maps low-level container metadata
|
||||
into the small set of stream attributes the domain reasons about. Replacing
|
||||
ffprobe with another tool only requires a new adapter — domain stays put.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SubtitleStreamInfo:
|
||||
"""A single embedded subtitle stream, as seen by the prober.
|
||||
|
||||
``language`` is the raw language tag emitted by the container (typically
|
||||
ISO 639-2 like ``"fre"``, ``"eng"``); may be empty/None when the stream
|
||||
has no language tag. The domain resolves it to a canonical ``Language``
|
||||
via the knowledge base.
|
||||
"""
|
||||
|
||||
language: str | None
|
||||
is_hearing_impaired: bool
|
||||
is_forced: bool
|
||||
|
||||
|
||||
class MediaProber(Protocol):
|
||||
"""Inspect a media file's stream metadata."""
|
||||
|
||||
def list_subtitle_streams(self, video: Path) -> list[SubtitleStreamInfo]:
|
||||
"""Return all subtitle streams in ``video``.
|
||||
|
||||
Returns an empty list when the file is missing, unreadable, or has
|
||||
no subtitle streams. Adapters must not raise.
|
||||
"""
|
||||
...
|
||||
Reference in New Issue
Block a user