Skip to content

Commit

Permalink
Skip subtitles with wrong FPS (#1212)
Browse files Browse the repository at this point in the history
* match fps with opensubtitles.com provider

* add a skip-wrong-fps cli option

* fix docs

* add news

* add non-strict fps_matches
  • Loading branch information
getzze authored Feb 26, 2025
1 parent c42166f commit cb1309f
Show file tree
Hide file tree
Showing 6 changed files with 56 additions and 5 deletions.
1 change: 1 addition & 0 deletions changelog.d/748.change.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add a skip-wrong-fps cli option to completely skip subtitles with FPS different from the video FPS (if detected)
9 changes: 9 additions & 0 deletions src/subliminal/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,13 @@ def cache(ctx: click.Context, clear_subliminal: bool) -> None:
),
)
@click.option('-f', '--force', is_flag=True, default=False, help='Force download even if a subtitle already exist.')
@click.option(
'-w',
'--skip-wrong-fps',
is_flag=True,
default=False,
help='Skip subtitles with an FPS that do not match the video (if it can be detected).',
)
@click.option(
'-fo',
'--foreign-only',
Expand Down Expand Up @@ -562,6 +569,7 @@ def download(
subtitle_format: str | None,
single: bool,
force: bool,
skip_wrong_fps: bool,
hearing_impaired: tuple[bool | None, ...],
foreign_only: tuple[bool | None, ...],
min_score: int,
Expand Down Expand Up @@ -751,6 +759,7 @@ def download(
min_score=scores['hash'] * min_score // 100,
hearing_impaired=hearing_impaired_flag,
foreign_only=foreign_only_flag,
skip_wrong_fps=skip_wrong_fps,
only_one=single,
ignore_subtitles=ignore_subtitles,
)
Expand Down
10 changes: 10 additions & 0 deletions src/subliminal/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
provider_manager,
refiner_manager,
)
from .matches import fps_matches
from .score import compute_score as default_compute_score
from .subtitle import SUBTITLE_EXTENSIONS, LanguageType, Subtitle
from .utils import get_age, handle_exception
Expand Down Expand Up @@ -219,6 +220,7 @@ def download_best_subtitles(
min_score: int = 0,
hearing_impaired: bool | None = None,
foreign_only: bool | None = None,
skip_wrong_fps: bool = False,
only_one: bool = False,
compute_score: ComputeScore | None = None,
ignore_subtitles: Sequence[str] | None = None,
Expand All @@ -234,6 +236,7 @@ def download_best_subtitles(
:param int min_score: minimum score for a subtitle to be downloaded.
:param (bool | None) hearing_impaired: hearing impaired preference (yes/no/indifferent).
:param (bool | None) foreign_only: foreign only preference (yes/no/indifferent).
:param bool skip_wrong_fps: skip subtitles with an FPS that do not match the video (False).
:param bool only_one: download only one subtitle, not one per language.
:param compute_score: function that takes `subtitle` and `video` as positional arguments,
and returns the score.
Expand All @@ -248,6 +251,10 @@ def download_best_subtitles(
# ignore subtitles
subtitles = [s for s in subtitles if s.id not in ignore_subtitles]

# skip subtitles that do not match the FPS of the video (if defined)
if skip_wrong_fps and video.frame_rate is not None and video.frame_rate > 0:
subtitles = [s for s in subtitles if fps_matches(video, fps=s.fps, strict=False)]

# sort by hearing impaired and foreign only
language_type = LanguageType.from_flags(hearing_impaired=hearing_impaired, foreign_only=foreign_only)
if language_type != LanguageType.UNKNOWN:
Expand Down Expand Up @@ -725,6 +732,7 @@ def download_best_subtitles(
min_score: int = 0,
hearing_impaired: bool | None = None,
foreign_only: bool | None = None,
skip_wrong_fps: bool = False,
only_one: bool = False,
compute_score: ComputeScore | None = None,
pool_class: type[ProviderPool] = ProviderPool,
Expand All @@ -741,6 +749,7 @@ def download_best_subtitles(
:param int min_score: minimum score for a subtitle to be downloaded.
:param (bool | None) hearing_impaired: hearing impaired preference (yes/no/indifferent).
:param (bool | None) foreign_only: foreign only preference (yes/no/indifferent).
:param bool skip_wrong_fps: skip subtitles with an FPS that do not match the video (False).
:param bool only_one: download only one subtitle, not one per language.
:param compute_score: function that takes `subtitle` and `video` as positional arguments,
`hearing_impaired` as keyword argument and returns the score.
Expand Down Expand Up @@ -776,6 +785,7 @@ def download_best_subtitles(
min_score=min_score,
hearing_impaired=hearing_impaired,
foreign_only=foreign_only,
skip_wrong_fps=skip_wrong_fps,
only_one=only_one,
compute_score=compute_score,
)
Expand Down
32 changes: 29 additions & 3 deletions src/subliminal/matches.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,29 @@ def country_matches(video: Video, *, country: Country | None = None, partial: bo
return False # pragma: no cover


def fps_matches(video: Video, *, fps: float | None = None, strict: bool = True, **kwargs: Any) -> bool:
"""Whether the video matches the `fps`.
Frame rates are considered equal if the relative difference is less than 0.1 percent.
:param video: the video.
:type video: :class:`~subliminal.video.Video`
:param str fps: the video frame rate.
:param bool strict: if strict, an absence of information is a non-match.
:return: whether there's a match
:rtype: bool
"""
# make the difference a bit more than 0.1% to be sure
relative_diff = 0.0011
# if video and subtitle fps are defined, return True if the match, otherwise False
if video.frame_rate is not None and video.frame_rate > 0 and fps is not None and fps > 0:
return bool(abs(video.frame_rate - fps) / video.frame_rate < relative_diff)

# if information is missing, return True only if not strict
return not strict


def release_group_matches(video: Video, *, release_group: str | None = None, **kwargs: Any) -> bool:
"""Whether the video matches the `release_group`.
Expand Down Expand Up @@ -238,6 +261,7 @@ def audio_codec_matches(video: Video, *, audio_codec: str | None = None, **kwarg
'episode': episode_matches,
'year': year_matches,
'country': country_matches,
'fps': fps_matches,
'release_group': release_group_matches,
'streaming_service': streaming_service_matches,
'resolution': resolution_matches,
Expand All @@ -247,23 +271,25 @@ def audio_codec_matches(video: Video, *, audio_codec: str | None = None, **kwarg
}


def guess_matches(video: Video, guess: Mapping[str, Any], *, partial: bool = False) -> set[str]:
def guess_matches(video: Video, guess: Mapping[str, Any], *, partial: bool = False, strict: bool = True) -> set[str]:
"""Get matches between a `video` and a `guess`.
If a guess is `partial`, the absence information won't be counted as a match.
If a guess is `partial`, the absence of information won't be counted as a match.
If a match is `strict`, the absence of information will be counted as a non-match.
:param video: the video.
:type video: :class:`~subliminal.video.Video`
:param guess: the guess.
:type guess: dict
:param bool partial: whether or not the guess is partial.
:param bool strict: whether or not the match is strict.
:return: matches between the `video` and the `guess`.
:rtype: set
"""
matches = set()
for key in score_keys:
if key in matches_manager and matches_manager[key](video, partial=partial, **guess):
if key in matches_manager and matches_manager[key](video, partial=partial, strict=strict, **guess):
matches.add(key)

return matches
7 changes: 6 additions & 1 deletion src/subliminal/providers/opensubtitlescom.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ class OpenSubtitlesComSubtitle(Subtitle):
moviehash_match: bool
file_id: int
file_name: str
fps: float | None

def __init__(
self,
Expand All @@ -194,6 +195,7 @@ def __init__(
series_tmdb_id: str | None = None,
download_count: int | None = None,
machine_translated: bool | None = None,
fps: float | None = None,
imdb_match: bool = False,
tmdb_match: bool = False,
moviehash_match: bool = False,
Expand All @@ -205,6 +207,7 @@ def __init__(
subtitle_id,
hearing_impaired=hearing_impaired,
foreign_only=foreign_only,
fps=fps,
page_link=None,
encoding='utf-8',
)
Expand Down Expand Up @@ -248,7 +251,7 @@ def from_response(
moviehash_match = bool(attributes.get('moviehash_match', False))
download_count = int(attributes.get('download_count'))
machine_translated = bool(int(attributes.get('machine_translated')))
# fps = float(attributes.get('fps'))
fps: float | None = float(attributes.get('fps')) or None
# from_trusted = bool(int(attributes.get('from_trusted')))
# uploader_rank = str(attributes.get('uploader', {}).get("rank"))
# foreign_parts_only = bool(int(attributes.get('foreign_parts_only')))
Expand Down Expand Up @@ -290,6 +293,7 @@ def from_response(
series_tmdb_id=series_tmdb_id,
download_count=download_count,
machine_translated=machine_translated,
fps=fps,
imdb_match=imdb_match,
tmdb_match=tmdb_match,
moviehash_match=moviehash_match,
Expand Down Expand Up @@ -322,6 +326,7 @@ def get_matches(self, video: Video) -> set[str]:
'year': self.movie_year,
'season': self.series_season,
'episode': self.series_episode,
'fps': self.fps,
},
)

Expand Down
2 changes: 1 addition & 1 deletion src/subliminal/subtitle.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ def __init__(
self.subtitle_id = subtitle_id
self.page_link = page_link
self.subtitle_format = subtitle_format
self.fps = fps
self.fps = fps if fps is not None and fps > 0 else None
self.embedded = embedded

self.language_type = LanguageType.from_flags(hearing_impaired=hearing_impaired, foreign_only=foreign_only)
Expand Down

0 comments on commit cb1309f

Please sign in to comment.