Merge branch 'refactor/flatten-shared-media'
This commit is contained in:
@@ -57,6 +57,14 @@ callers).
|
|||||||
|
|
||||||
### Internal
|
### Internal
|
||||||
|
|
||||||
|
- **Flattened `alfred.domain.shared.media/` package into a single
|
||||||
|
`media.py` module.** The 6-file package (audio, video, subtitle,
|
||||||
|
info, matching, tracks_mixin + `__init__`) collapsed into one ~250
|
||||||
|
LoC module. All 12 import sites continue to resolve unchanged
|
||||||
|
(`from alfred.domain.shared.media import AudioTrack, MediaInfo, …`)
|
||||||
|
since Python treats `media.py` and `media/__init__.py`
|
||||||
|
interchangeably for import paths. Easier to scan when the whole
|
||||||
|
bounded-context fits on one screen.
|
||||||
- **`SubtitleKnowledgeBase` types `language_registry` against the
|
- **`SubtitleKnowledgeBase` types `language_registry` against the
|
||||||
`LanguageRepository` port** instead of the concrete `LanguageRegistry`
|
`LanguageRepository` port** instead of the concrete `LanguageRegistry`
|
||||||
class. The default constructor still instantiates the concrete adapter
|
class. The default constructor still instantiates the concrete adapter
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
"""Media — file-level track types (video/audio/subtitle) and MediaInfo container.
|
||||||
|
|
||||||
|
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``
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from .value_objects import Language
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AudioTrack",
|
||||||
|
"MediaInfo",
|
||||||
|
"MediaWithTracks",
|
||||||
|
"SubtitleTrack",
|
||||||
|
"VideoTrack",
|
||||||
|
"track_lang_matches",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Track types — one frozen dataclass per stream kind
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AudioTrack:
|
||||||
|
"""A single audio track as reported by ffprobe."""
|
||||||
|
|
||||||
|
index: int
|
||||||
|
codec: str | None # aac, ac3, eac3, dts, truehd, flac, …
|
||||||
|
channels: int | None # 2, 6 (5.1), 8 (7.1), …
|
||||||
|
channel_layout: str | None # stereo, 5.1, 7.1, …
|
||||||
|
language: str | None # ISO 639-2: fre, eng, und, …
|
||||||
|
is_default: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SubtitleTrack:
|
||||||
|
"""A single embedded subtitle track as reported by ffprobe."""
|
||||||
|
|
||||||
|
index: int
|
||||||
|
codec: str | None # subrip, ass, hdmv_pgs_subtitle, …
|
||||||
|
language: str | None # ISO 639-2: fre, eng, und, …
|
||||||
|
is_default: bool = False
|
||||||
|
is_forced: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class VideoTrack:
|
||||||
|
"""A single video track as reported by ffprobe.
|
||||||
|
|
||||||
|
A media file typically has one video track but can have several (alt
|
||||||
|
camera angles, attached thumbnail images reported as still-image streams,
|
||||||
|
etc.), hence the list[VideoTrack] on MediaInfo.
|
||||||
|
"""
|
||||||
|
|
||||||
|
index: int
|
||||||
|
codec: str | None # h264, hevc, av1, …
|
||||||
|
width: int | None
|
||||||
|
height: int | None
|
||||||
|
is_default: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def resolution(self) -> str | None:
|
||||||
|
"""
|
||||||
|
Best-effort resolution string: 2160p, 1080p, 720p, …
|
||||||
|
|
||||||
|
Width takes priority over height to handle widescreen/cinema crops
|
||||||
|
(e.g. 1920×960 scope → 1080p, not 720p). Falls back to height when
|
||||||
|
width is unavailable.
|
||||||
|
"""
|
||||||
|
match (self.width, self.height):
|
||||||
|
case (None, None):
|
||||||
|
return None
|
||||||
|
case (w, h) if w is not None:
|
||||||
|
match True:
|
||||||
|
case _ if w >= 3840:
|
||||||
|
return "2160p"
|
||||||
|
case _ if w >= 1920:
|
||||||
|
return "1080p"
|
||||||
|
case _ if w >= 1280:
|
||||||
|
return "720p"
|
||||||
|
case _ if w >= 720:
|
||||||
|
return "576p"
|
||||||
|
case _ if w >= 640:
|
||||||
|
return "480p"
|
||||||
|
case _:
|
||||||
|
return f"{h}p" if h else f"{w}w"
|
||||||
|
case (None, h):
|
||||||
|
match True:
|
||||||
|
case _ if h >= 2160:
|
||||||
|
return "2160p"
|
||||||
|
case _ if h >= 1080:
|
||||||
|
return "1080p"
|
||||||
|
case _ if h >= 720:
|
||||||
|
return "720p"
|
||||||
|
case _ if h >= 576:
|
||||||
|
return "576p"
|
||||||
|
case _ if h >= 480:
|
||||||
|
return "480p"
|
||||||
|
case _:
|
||||||
|
return f"{h}p"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# MediaInfo — assembles video/audio/subtitle tracks for a media file
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MediaInfo:
|
||||||
|
"""
|
||||||
|
File-level media metadata extracted by ffprobe — immutable snapshot.
|
||||||
|
|
||||||
|
Symmetric design: every stream type is a tuple of typed track objects
|
||||||
|
(immutable on purpose — a MediaInfo is a frozen view of one ffprobe run,
|
||||||
|
not a mutable collection to append to).
|
||||||
|
Backwards-compatible flat accessors (``resolution``, ``width``, …) read
|
||||||
|
from the first video track when present.
|
||||||
|
"""
|
||||||
|
|
||||||
|
video_tracks: tuple[VideoTrack, ...] = field(default_factory=tuple)
|
||||||
|
audio_tracks: tuple[AudioTrack, ...] = field(default_factory=tuple)
|
||||||
|
subtitle_tracks: tuple[SubtitleTrack, ...] = field(default_factory=tuple)
|
||||||
|
|
||||||
|
# File-level (from ffprobe ``format`` block, not from any single stream)
|
||||||
|
duration_seconds: float | None = None
|
||||||
|
bitrate_kbps: int | None = None
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
|
# Video conveniences — read the first video track
|
||||||
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@property
|
||||||
|
def primary_video(self) -> VideoTrack | None:
|
||||||
|
return self.video_tracks[0] if self.video_tracks else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def width(self) -> int | None:
|
||||||
|
v = self.primary_video
|
||||||
|
return v.width if v else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def height(self) -> int | None:
|
||||||
|
v = self.primary_video
|
||||||
|
return v.height if v else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def video_codec(self) -> str | None:
|
||||||
|
v = self.primary_video
|
||||||
|
return v.codec if v else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def resolution(self) -> str | None:
|
||||||
|
v = self.primary_video
|
||||||
|
return v.resolution if v else None
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
|
# Audio conveniences
|
||||||
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@property
|
||||||
|
def audio_languages(self) -> list[str]:
|
||||||
|
"""Unique audio languages across all tracks (ISO 639-2)."""
|
||||||
|
seen: set[str] = set()
|
||||||
|
result: list[str] = []
|
||||||
|
for track in self.audio_tracks:
|
||||||
|
if track.language and track.language not in seen:
|
||||||
|
seen.add(track.language)
|
||||||
|
result.append(track.language)
|
||||||
|
return result
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_multi_audio(self) -> bool:
|
||||||
|
"""True if more than one audio language is present."""
|
||||||
|
return len(self.audio_languages) > 1
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Language matching — shared helper + mixin
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def track_lang_matches(track_lang: str | None, query: str | Language) -> bool:
|
||||||
|
"""
|
||||||
|
Match a track's language string against a query (contract "C+").
|
||||||
|
|
||||||
|
* ``Language`` query → matches if the track string is any known
|
||||||
|
representation of that Language (delegates to ``Language.matches``).
|
||||||
|
Powerful, cross-format mode.
|
||||||
|
* ``str`` query → case-insensitive direct comparison against
|
||||||
|
``track_lang``. Simple, no normalization, no registry lookup.
|
||||||
|
|
||||||
|
Callers needing cross-format resolution (``"fr"`` ↔ ``"fre"`` ↔
|
||||||
|
``"french"``) should resolve their string through a ``LanguageRegistry``
|
||||||
|
once and pass the resulting ``Language``.
|
||||||
|
"""
|
||||||
|
if track_lang is None:
|
||||||
|
return False
|
||||||
|
if isinstance(query, Language):
|
||||||
|
return query.matches(track_lang)
|
||||||
|
if isinstance(query, str):
|
||||||
|
return track_lang.lower().strip() == query.lower().strip()
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class MediaWithTracks:
|
||||||
|
"""
|
||||||
|
Mixin providing audio/subtitle helpers for entities with track collections.
|
||||||
|
|
||||||
|
Hosts must expose two attributes:
|
||||||
|
|
||||||
|
* ``audio_tracks: list[AudioTrack]``
|
||||||
|
* ``subtitle_tracks: list[SubtitleTrack]``
|
||||||
|
|
||||||
|
The helpers follow the "C+" matching contract: pass a :class:`Language`
|
||||||
|
for cross-format matching, or a ``str`` for case-insensitive comparison.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# These attributes are provided by the host entity (Movie, Episode, …).
|
||||||
|
# Declared here only for type-checkers and to make the contract explicit.
|
||||||
|
audio_tracks: list[AudioTrack]
|
||||||
|
subtitle_tracks: list[SubtitleTrack]
|
||||||
|
|
||||||
|
# ── Audio helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def has_audio_in(self, lang: str | Language) -> bool:
|
||||||
|
"""True if at least one audio track is in the given language."""
|
||||||
|
return any(track_lang_matches(t.language, lang) for t in self.audio_tracks)
|
||||||
|
|
||||||
|
def audio_languages(self) -> list[str]:
|
||||||
|
"""Unique audio languages across all tracks, in track order."""
|
||||||
|
seen: set[str] = set()
|
||||||
|
result: list[str] = []
|
||||||
|
for t in self.audio_tracks:
|
||||||
|
if t.language and t.language not in seen:
|
||||||
|
seen.add(t.language)
|
||||||
|
result.append(t.language)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ── Subtitle helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def has_subtitles_in(self, lang: str | Language) -> bool:
|
||||||
|
"""True if at least one subtitle track is in the given language."""
|
||||||
|
return any(track_lang_matches(t.language, lang) for t in self.subtitle_tracks)
|
||||||
|
|
||||||
|
def has_forced_subs(self) -> bool:
|
||||||
|
"""True if at least one subtitle track is flagged as forced."""
|
||||||
|
return any(t.is_forced for t in self.subtitle_tracks)
|
||||||
|
|
||||||
|
def subtitle_languages(self) -> list[str]:
|
||||||
|
"""Unique subtitle languages across all tracks, in track order."""
|
||||||
|
seen: set[str] = set()
|
||||||
|
result: list[str] = []
|
||||||
|
for t in self.subtitle_tracks:
|
||||||
|
if t.language and t.language not in seen:
|
||||||
|
seen.add(t.language)
|
||||||
|
result.append(t.language)
|
||||||
|
return result
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
"""Media — file-level track types (video/audio/subtitle) and MediaInfo container.
|
|
||||||
|
|
||||||
These are the **container-view** dataclasses, populated from ffprobe output and
|
|
||||||
used across the project to describe the content of a media file.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from .audio import AudioTrack
|
|
||||||
from .info import MediaInfo
|
|
||||||
from .matching import track_lang_matches
|
|
||||||
from .subtitle import SubtitleTrack
|
|
||||||
from .tracks_mixin import MediaWithTracks
|
|
||||||
from .video import VideoTrack
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"AudioTrack",
|
|
||||||
"MediaInfo",
|
|
||||||
"MediaWithTracks",
|
|
||||||
"SubtitleTrack",
|
|
||||||
"VideoTrack",
|
|
||||||
"track_lang_matches",
|
|
||||||
]
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
"""AudioTrack — a single audio stream as reported by ffprobe."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class AudioTrack:
|
|
||||||
"""A single audio track as reported by ffprobe."""
|
|
||||||
|
|
||||||
index: int
|
|
||||||
codec: str | None # aac, ac3, eac3, dts, truehd, flac, …
|
|
||||||
channels: int | None # 2, 6 (5.1), 8 (7.1), …
|
|
||||||
channel_layout: str | None # stereo, 5.1, 7.1, …
|
|
||||||
language: str | None # ISO 639-2: fre, eng, und, …
|
|
||||||
is_default: bool = False
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
"""MediaInfo — assembles video, audio and subtitle tracks for a media file."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
|
|
||||||
from .audio import AudioTrack
|
|
||||||
from .subtitle import SubtitleTrack
|
|
||||||
from .video import VideoTrack
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class MediaInfo:
|
|
||||||
"""
|
|
||||||
File-level media metadata extracted by ffprobe — immutable snapshot.
|
|
||||||
|
|
||||||
Symmetric design: every stream type is a tuple of typed track objects
|
|
||||||
(immutable on purpose — a MediaInfo is a frozen view of one ffprobe run,
|
|
||||||
not a mutable collection to append to).
|
|
||||||
Backwards-compatible flat accessors (``resolution``, ``width``, …) read
|
|
||||||
from the first video track when present.
|
|
||||||
"""
|
|
||||||
|
|
||||||
video_tracks: tuple[VideoTrack, ...] = field(default_factory=tuple)
|
|
||||||
audio_tracks: tuple[AudioTrack, ...] = field(default_factory=tuple)
|
|
||||||
subtitle_tracks: tuple[SubtitleTrack, ...] = field(default_factory=tuple)
|
|
||||||
|
|
||||||
# File-level (from ffprobe ``format`` block, not from any single stream)
|
|
||||||
duration_seconds: float | None = None
|
|
||||||
bitrate_kbps: int | None = None
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────
|
|
||||||
# Video conveniences — read the first video track
|
|
||||||
# ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@property
|
|
||||||
def primary_video(self) -> VideoTrack | None:
|
|
||||||
return self.video_tracks[0] if self.video_tracks else None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def width(self) -> int | None:
|
|
||||||
v = self.primary_video
|
|
||||||
return v.width if v else None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def height(self) -> int | None:
|
|
||||||
v = self.primary_video
|
|
||||||
return v.height if v else None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def video_codec(self) -> str | None:
|
|
||||||
v = self.primary_video
|
|
||||||
return v.codec if v else None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def resolution(self) -> str | None:
|
|
||||||
v = self.primary_video
|
|
||||||
return v.resolution if v else None
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────
|
|
||||||
# Audio conveniences
|
|
||||||
# ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@property
|
|
||||||
def audio_languages(self) -> list[str]:
|
|
||||||
"""Unique audio languages across all tracks (ISO 639-2)."""
|
|
||||||
seen: set[str] = set()
|
|
||||||
result: list[str] = []
|
|
||||||
for track in self.audio_tracks:
|
|
||||||
if track.language and track.language not in seen:
|
|
||||||
seen.add(track.language)
|
|
||||||
result.append(track.language)
|
|
||||||
return result
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_multi_audio(self) -> bool:
|
|
||||||
"""True if more than one audio language is present."""
|
|
||||||
return len(self.audio_languages) > 1
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
"""Language-matching helper shared by media-bearing entities.
|
|
||||||
|
|
||||||
Both ``Episode`` and ``Movie`` carry ``audio_tracks`` / ``subtitle_tracks`` and
|
|
||||||
need to answer "do I have audio in language X?". The matching contract is the
|
|
||||||
same in both cases — keep it in one place.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from ..value_objects import Language
|
|
||||||
|
|
||||||
|
|
||||||
def track_lang_matches(track_lang: str | None, query: str | Language) -> bool:
|
|
||||||
"""
|
|
||||||
Match a track's language string against a query (contract "C+").
|
|
||||||
|
|
||||||
* ``Language`` query → matches if the track string is any known
|
|
||||||
representation of that Language (delegates to ``Language.matches``).
|
|
||||||
Powerful, cross-format mode.
|
|
||||||
* ``str`` query → case-insensitive direct comparison against
|
|
||||||
``track_lang``. Simple, no normalization, no registry lookup.
|
|
||||||
|
|
||||||
Callers needing cross-format resolution (``"fr"`` ↔ ``"fre"`` ↔
|
|
||||||
``"french"``) should resolve their string through a ``LanguageRegistry``
|
|
||||||
once and pass the resulting ``Language``.
|
|
||||||
"""
|
|
||||||
if track_lang is None:
|
|
||||||
return False
|
|
||||||
if isinstance(query, Language):
|
|
||||||
return query.matches(track_lang)
|
|
||||||
if isinstance(query, str):
|
|
||||||
return track_lang.lower().strip() == query.lower().strip()
|
|
||||||
return False
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
"""SubtitleTrack — a single embedded subtitle stream as reported by ffprobe.
|
|
||||||
|
|
||||||
This is the **container-view** representation (ffprobe output) used uniformly
|
|
||||||
across the project to describe a subtitle stream embedded in a media file.
|
|
||||||
|
|
||||||
Not to be confused with ``alfred.domain.subtitles.entities.SubtitleCandidate``
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class SubtitleTrack:
|
|
||||||
"""A single embedded subtitle track as reported by ffprobe."""
|
|
||||||
|
|
||||||
index: int
|
|
||||||
codec: str | None # subrip, ass, hdmv_pgs_subtitle, …
|
|
||||||
language: str | None # ISO 639-2: fre, eng, und, …
|
|
||||||
is_default: bool = False
|
|
||||||
is_forced: bool = False
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
"""Mixin shared by entities that carry audio + subtitle tracks.
|
|
||||||
|
|
||||||
Both ``Movie`` and ``Episode`` carry a ``list[AudioTrack]`` plus a
|
|
||||||
``list[SubtitleTrack]`` and answer the same 5 queries about them (language
|
|
||||||
presence, unique languages, forced flag). Keep that behavior in one place so a
|
|
||||||
fix in one is a fix in both.
|
|
||||||
|
|
||||||
The mixin is plain Python (no dataclass machinery) so it composes cleanly with
|
|
||||||
``@dataclass`` entities — it only reads ``self.audio_tracks`` and
|
|
||||||
``self.subtitle_tracks`` which the host class provides as fields.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from ..value_objects import Language
|
|
||||||
from .matching import track_lang_matches
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from .audio import AudioTrack
|
|
||||||
from .subtitle import SubtitleTrack
|
|
||||||
|
|
||||||
|
|
||||||
class MediaWithTracks:
|
|
||||||
"""
|
|
||||||
Mixin providing audio/subtitle helpers for entities with track collections.
|
|
||||||
|
|
||||||
Hosts must expose two attributes:
|
|
||||||
|
|
||||||
* ``audio_tracks: list[AudioTrack]``
|
|
||||||
* ``subtitle_tracks: list[SubtitleTrack]``
|
|
||||||
|
|
||||||
The helpers follow the "C+" matching contract: pass a :class:`Language`
|
|
||||||
for cross-format matching, or a ``str`` for case-insensitive comparison.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# These attributes are provided by the host entity (Movie, Episode, …).
|
|
||||||
# Declared here only for type-checkers and to make the contract explicit.
|
|
||||||
audio_tracks: list["AudioTrack"]
|
|
||||||
subtitle_tracks: list["SubtitleTrack"]
|
|
||||||
|
|
||||||
# ── Audio helpers ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def has_audio_in(self, lang: str | Language) -> bool:
|
|
||||||
"""True if at least one audio track is in the given language."""
|
|
||||||
return any(track_lang_matches(t.language, lang) for t in self.audio_tracks)
|
|
||||||
|
|
||||||
def audio_languages(self) -> list[str]:
|
|
||||||
"""Unique audio languages across all tracks, in track order."""
|
|
||||||
seen: set[str] = set()
|
|
||||||
result: list[str] = []
|
|
||||||
for t in self.audio_tracks:
|
|
||||||
if t.language and t.language not in seen:
|
|
||||||
seen.add(t.language)
|
|
||||||
result.append(t.language)
|
|
||||||
return result
|
|
||||||
|
|
||||||
# ── Subtitle helpers ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def has_subtitles_in(self, lang: str | Language) -> bool:
|
|
||||||
"""True if at least one subtitle track is in the given language."""
|
|
||||||
return any(track_lang_matches(t.language, lang) for t in self.subtitle_tracks)
|
|
||||||
|
|
||||||
def has_forced_subs(self) -> bool:
|
|
||||||
"""True if at least one subtitle track is flagged as forced."""
|
|
||||||
return any(t.is_forced for t in self.subtitle_tracks)
|
|
||||||
|
|
||||||
def subtitle_languages(self) -> list[str]:
|
|
||||||
"""Unique subtitle languages across all tracks, in track order."""
|
|
||||||
seen: set[str] = set()
|
|
||||||
result: list[str] = []
|
|
||||||
for t in self.subtitle_tracks:
|
|
||||||
if t.language and t.language not in seen:
|
|
||||||
seen.add(t.language)
|
|
||||||
result.append(t.language)
|
|
||||||
return result
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
"""VideoTrack — a single video stream as reported by ffprobe."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class VideoTrack:
|
|
||||||
"""A single video track as reported by ffprobe.
|
|
||||||
|
|
||||||
A media file typically has one video track but can have several (alt
|
|
||||||
camera angles, attached thumbnail images reported as still-image streams,
|
|
||||||
etc.), hence the list[VideoTrack] on MediaInfo.
|
|
||||||
"""
|
|
||||||
|
|
||||||
index: int
|
|
||||||
codec: str | None # h264, hevc, av1, …
|
|
||||||
width: int | None
|
|
||||||
height: int | None
|
|
||||||
is_default: bool = False
|
|
||||||
|
|
||||||
@property
|
|
||||||
def resolution(self) -> str | None:
|
|
||||||
"""
|
|
||||||
Best-effort resolution string: 2160p, 1080p, 720p, …
|
|
||||||
|
|
||||||
Width takes priority over height to handle widescreen/cinema crops
|
|
||||||
(e.g. 1920×960 scope → 1080p, not 720p). Falls back to height when
|
|
||||||
width is unavailable.
|
|
||||||
"""
|
|
||||||
match (self.width, self.height):
|
|
||||||
case (None, None):
|
|
||||||
return None
|
|
||||||
case (w, h) if w is not None:
|
|
||||||
match True:
|
|
||||||
case _ if w >= 3840:
|
|
||||||
return "2160p"
|
|
||||||
case _ if w >= 1920:
|
|
||||||
return "1080p"
|
|
||||||
case _ if w >= 1280:
|
|
||||||
return "720p"
|
|
||||||
case _ if w >= 720:
|
|
||||||
return "576p"
|
|
||||||
case _ if w >= 640:
|
|
||||||
return "480p"
|
|
||||||
case _:
|
|
||||||
return f"{h}p" if h else f"{w}w"
|
|
||||||
case (None, h):
|
|
||||||
match True:
|
|
||||||
case _ if h >= 2160:
|
|
||||||
return "2160p"
|
|
||||||
case _ if h >= 1080:
|
|
||||||
return "1080p"
|
|
||||||
case _ if h >= 720:
|
|
||||||
return "720p"
|
|
||||||
case _ if h >= 576:
|
|
||||||
return "576p"
|
|
||||||
case _ if h >= 480:
|
|
||||||
return "480p"
|
|
||||||
case _:
|
|
||||||
return f"{h}p"
|
|
||||||
Reference in New Issue
Block a user