e6ee700825
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.
67 lines
2.0 KiB
Python
67 lines
2.0 KiB
Python
"""PathlibFilesystemScanner — FilesystemScanner adapter backed by pathlib."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
from alfred.domain.shared.ports import FileEntry
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class PathlibFilesystemScanner:
|
|
"""Read-only filesystem scanner using ``pathlib``.
|
|
|
|
Implements :class:`alfred.domain.shared.ports.FilesystemScanner`
|
|
structurally. Never raises — failures are logged and surfaced as
|
|
empty results.
|
|
"""
|
|
|
|
def scan_dir(self, path: Path) -> list[FileEntry]:
|
|
try:
|
|
if not path.is_dir():
|
|
return []
|
|
children = sorted(path.iterdir())
|
|
except OSError as e:
|
|
logger.debug(f"PathlibFilesystemScanner: scan_dir failed for {path}: {e}")
|
|
return []
|
|
|
|
entries: list[FileEntry] = []
|
|
for child in children:
|
|
entry = self._make_entry(child)
|
|
if entry is not None:
|
|
entries.append(entry)
|
|
return entries
|
|
|
|
def stat(self, path: Path) -> FileEntry | None:
|
|
return self._make_entry(path)
|
|
|
|
def read_text(self, path: Path, encoding: str = "utf-8") -> str | None:
|
|
try:
|
|
with open(path, encoding=encoding, errors="replace") as f:
|
|
return f.read()
|
|
except OSError as e:
|
|
logger.debug(f"PathlibFilesystemScanner: read_text failed for {path}: {e}")
|
|
return None
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
def _make_entry(self, path: Path) -> FileEntry | None:
|
|
try:
|
|
is_file = path.is_file()
|
|
is_dir = path.is_dir()
|
|
except OSError:
|
|
return None
|
|
if not (is_file or is_dir):
|
|
return None
|
|
|
|
size_kb: float | None = None
|
|
if is_file:
|
|
try:
|
|
size_kb = path.stat().st_size / 1024
|
|
except OSError:
|
|
size_kb = None
|
|
|
|
return FileEntry(path=path, is_file=is_file, is_dir=is_dir, size_kb=size_kb)
|