Merge branch 'development' into libs_update

# Conflicts:
#	custom_libs/subliminal_patch/providers/avistaz.py
#	custom_libs/subliminal_patch/providers/avistaz_network.py
#	custom_libs/subliminal_patch/providers/cinemaz.py
This commit is contained in:
morpheus65535 2024-02-19 21:26:09 -05:00
commit 3a199f64c6
29 changed files with 1048 additions and 170 deletions

View File

@ -2,9 +2,10 @@
import io
import os
import re
from flask_restx import Resource, Namespace, fields, marshal
from app.config import settings
from app.logger import empty_log
from app.get_args import args
@ -29,12 +30,62 @@ class SystemLogs(Resource):
def get(self):
"""List log entries"""
logs = []
include = str(settings.log.include_filter)
exclude = str(settings.log.exclude_filter)
ignore_case = settings.log.ignore_case
regex = settings.log.use_regex
if regex:
# pre-compile regular expressions for better performance
if ignore_case:
flags = re.IGNORECASE
else:
flags = 0
if len(include) > 0:
try:
include_compiled = re.compile(include, flags)
except:
include_compiled = None
if len(exclude) > 0:
try:
exclude_compiled = re.compile(exclude, flags)
except:
exclude_compiled = None
elif ignore_case:
include = include.casefold()
exclude = exclude.casefold()
with io.open(os.path.join(args.config_dir, 'log', 'bazarr.log'), encoding='UTF-8') as file:
raw_lines = file.read()
lines = raw_lines.split('|\n')
for line in lines:
if line == '':
continue
if ignore_case and not regex:
compare_line = line.casefold()
else:
compare_line = line
if len(include) > 0:
if regex:
if include_compiled is None:
# if invalid re, keep the line
keep = True
else:
keep = include_compiled.search(compare_line)
else:
keep = include in compare_line
if not keep:
continue
if len(exclude) > 0:
if regex:
if exclude_compiled is None:
# if invalid re, keep the line
skip = False
else:
skip = exclude_compiled.search(compare_line)
else:
skip = exclude in compare_line
if skip:
continue
raw_message = line.split('|')
raw_message_len = len(raw_message)
if raw_message_len > 3:

View File

@ -4,6 +4,7 @@ import hashlib
import os
import ast
import logging
import re
from urllib.parse import quote_plus
from subliminal.cache import region
@ -123,6 +124,12 @@ validators = [
Validator('general.skip_hashing', must_exist=True, default=False, is_type_of=bool),
Validator('general.language_equals', must_exist=True, default=[], is_type_of=list),
# log section
Validator('log.include_filter', must_exist=True, default='', is_type_of=str, cast=str),
Validator('log.exclude_filter', must_exist=True, default='', is_type_of=str, cast=str),
Validator('log.ignore_case', must_exist=True, default=False, is_type_of=bool),
Validator('log.use_regex', must_exist=True, default=False, is_type_of=bool),
# auth section
Validator('auth.apikey', must_exist=True, default=hexlify(os.urandom(16)).decode(), is_type_of=str),
Validator('auth.type', must_exist=True, default=None, is_type_of=(NoneType, str),
@ -216,6 +223,14 @@ validators = [
Validator('addic7ed.user_agent', must_exist=True, default='', is_type_of=str),
Validator('addic7ed.vip', must_exist=True, default=False, is_type_of=bool),
# avistaz section
Validator('avistaz.cookies', must_exist=True, default='', is_type_of=str),
Validator('avistaz.user_agent', must_exist=True, default='', is_type_of=str),
# cinemaz section
Validator('cinemaz.cookies', must_exist=True, default='', is_type_of=str),
Validator('cinemaz.user_agent', must_exist=True, default='', is_type_of=str),
# podnapisi section
Validator('podnapisi.verify_ssl', must_exist=True, default=True, is_type_of=bool),
@ -284,7 +299,8 @@ validators = [
Validator('embeddedsubtitles.included_codecs', must_exist=True, default=[], is_type_of=list),
Validator('embeddedsubtitles.hi_fallback', must_exist=True, default=False, is_type_of=bool),
Validator('embeddedsubtitles.timeout', must_exist=True, default=600, is_type_of=int, gte=1),
Validator('embeddedsubtitles.unknown_as_english', must_exist=True, default=False, is_type_of=bool),
Validator('embeddedsubtitles.unknown_as_fallback', must_exist=True, default=False, is_type_of=bool),
Validator('embeddedsubtitles.fallback_lang', must_exist=True, default='en', is_type_of=str, cast=str),
# karagarga section
Validator('karagarga.username', must_exist=True, default='', is_type_of=str, cast=str),
@ -422,7 +438,7 @@ array_keys = ['excluded_tags',
empty_values = ['', 'None', 'null', 'undefined', None, []]
str_keys = ['chmod']
str_keys = ['chmod', 'log_include_filter', 'log_exclude_filter']
# Increase Sonarr and Radarr sync interval since we now use SignalR feed to update in real time
if settings.sonarr.series_sync < 15:
@ -441,6 +457,12 @@ if settings.general.wanted_search_frequency == 3:
if settings.general.wanted_search_frequency_movie == 3:
settings.general.wanted_search_frequency_movie = 6
# backward compatibility embeddedsubtitles provider
if hasattr(settings.embeddedsubtitles, 'unknown_as_english'):
if settings.embeddedsubtitles.unknown_as_english:
settings.embeddedsubtitles.unknown_as_fallback = True
settings.embeddedsubtitles.fallback_lang = 'en'
del settings.embeddedsubtitles.unknown_as_english
# save updated settings to file
write_config()
@ -463,6 +485,24 @@ def get_settings():
settings_to_return[k].update({subk: subv})
return settings_to_return
def validate_log_regex():
# handle bug in dynaconf that changes strings to numbers, so change them back to str
if not isinstance(settings.log.include_filter, str):
settings.log.include_filter = str(settings.log.include_filter)
if not isinstance(settings.log.exclude_filter, str):
settings.log.exclude_filter = str(settings.log.exclude_filter)
if (settings.log.use_regex):
# compile any regular expressions specified to see if they are valid
# if invalid, tell the user which one
try:
re.compile(settings.log.include_filter)
except:
raise ValidationError(f"Include filter: invalid regular expression: {settings.log.include_filter}")
try:
re.compile(settings.log.exclude_filter)
except:
raise ValidationError(f"Exclude filter: invalid regular expression: {settings.log.exclude_filter}")
def save_settings(settings_items):
configure_debug = False
@ -480,7 +520,8 @@ def save_settings(settings_items):
undefined_subtitles_track_default_changed = False
audio_tracks_parsing_changed = False
reset_providers = False
check_log_regex = False
# Subzero Mods
update_subzero = False
subzero_mods = get_array_from(settings.general.subzero_mods)
@ -616,12 +657,10 @@ def save_settings(settings_items):
if key != settings.opensubtitlescom.username:
reset_providers = True
region.delete('oscom_token')
region.delete('oscom_server')
elif key == 'settings-opensubtitlescom-password':
if key != settings.opensubtitlescom.password:
reset_providers = True
region.delete('oscom_token')
region.delete('oscom_server')
if key == 'settings-subscene-username':
if key != settings.subscene.username:
@ -704,6 +743,7 @@ def save_settings(settings_items):
try:
settings.validators.validate()
validate_log_regex()
except ValidationError:
settings.reload()
raise

View File

@ -229,6 +229,14 @@ def get_providers_auth():
'user_agent': settings.addic7ed.user_agent,
'is_vip': settings.addic7ed.vip,
},
'avistaz': {
'cookies': settings.avistaz.cookies,
'user_agent': settings.avistaz.user_agent,
},
'cinemaz': {
'cookies': settings.cinemaz.cookies,
'user_agent': settings.cinemaz.user_agent,
},
'opensubtitles': {
'username': settings.opensubtitles.username,
'password': settings.opensubtitles.password,
@ -293,7 +301,8 @@ def get_providers_auth():
'ffprobe_path': _FFPROBE_BINARY,
'ffmpeg_path': _FFMPEG_BINARY,
'timeout': settings.embeddedsubtitles.timeout,
'unknown_as_english': settings.embeddedsubtitles.unknown_as_english,
'unknown_as_fallback': settings.embeddedsubtitles.unknown_as_fallback,
'fallback_lang': settings.embeddedsubtitles.fallback_lang,
},
'karagarga': {
'username': settings.karagarga.username,

View File

@ -69,8 +69,6 @@ database.execute(
# Load languages in database
load_language_in_db()
login_auth = settings.auth.type
update_notifier()
if not args.no_signalr:

View File

@ -93,3 +93,32 @@ def get_movies_from_radarr_api(apikey_radarr, radarr_id=None):
return r.json()
else:
return
def get_history_from_radarr_api(apikey_radarr, movie_id):
url_radarr_api_history = f"{url_api_radarr()}history?eventType=1&movieIds={movie_id}&apikey={apikey_radarr}"
try:
r = requests.get(url_radarr_api_history, timeout=int(settings.sonarr.http_timeout), verify=False,
headers=headers)
r.raise_for_status()
except requests.exceptions.HTTPError:
logging.exception("BAZARR Error trying to get history from Radarr. Http error.")
return
except requests.exceptions.ConnectionError:
logging.exception("BAZARR Error trying to get history from Radarr. Connection Error.")
return
except requests.exceptions.Timeout:
logging.exception("BAZARR Error trying to get history from Radarr. Timeout Error.")
return
except requests.exceptions.RequestException:
logging.exception("BAZARR Error trying to get history from Radarr.")
return
except Exception as e:
logging.exception(f"Exception raised while getting history from Radarr API: {e}")
return
else:
if r.status_code == 200:
return r.json()
else:
return

View File

@ -166,3 +166,32 @@ def get_episodesFiles_from_sonarr_api(apikey_sonarr, series_id=None, episode_fil
return r.json()
else:
return
def get_history_from_sonarr_api(apikey_sonarr, episode_id):
url_sonarr_api_history = f"{url_api_sonarr()}history?eventType=1&episodeId={episode_id}&apikey={apikey_sonarr}"
try:
r = requests.get(url_sonarr_api_history, timeout=int(settings.sonarr.http_timeout), verify=False,
headers=headers)
r.raise_for_status()
except requests.exceptions.HTTPError:
logging.exception("BAZARR Error trying to get history from Sonarr. Http error.")
return
except requests.exceptions.ConnectionError:
logging.exception("BAZARR Error trying to get history from Sonarr. Connection Error.")
return
except requests.exceptions.Timeout:
logging.exception("BAZARR Error trying to get history from Sonarr. Timeout Error.")
return
except requests.exceptions.RequestException:
logging.exception("BAZARR Error trying to get history from Sonarr.")
return
except Exception as e:
logging.exception(f"Exception raised while getting history from Sonarr API: {e}")
return
else:
if r.status_code == 200:
return r.json()
else:
return

View File

@ -2,5 +2,10 @@
from .ffprobe import refine_from_ffprobe
from .database import refine_from_db
from .arr_history import refine_from_arr_history
registered = {"database": refine_from_db, "ffprobe": refine_from_ffprobe}
registered = {
"database": refine_from_db,
"ffprobe": refine_from_ffprobe,
"arr_history": refine_from_arr_history,
}

View File

@ -0,0 +1,32 @@
# coding=utf-8
# fmt: off
import logging
from app.config import settings
from radarr.sync.utils import get_history_from_radarr_api
from sonarr.sync.utils import get_history_from_sonarr_api
from subliminal import Episode, Movie
refined_providers = {'avistaz', 'cinemaz'}
def refine_from_arr_history(path, video):
if refined_providers.intersection(settings.general.enabled_providers) and video.info_url is None:
refine_info_url(video)
def refine_info_url(video):
if isinstance(video, Episode) and video.sonarrEpisodeId:
history = get_history_from_sonarr_api(settings.sonarr.apikey, video.sonarrEpisodeId)
elif isinstance(video, Movie) and video.radarrId:
history = get_history_from_radarr_api(settings.radarr.apikey, video.radarrId)
else:
return
for grab in history['records']:
# take the latest grab for the episode
if 'nzbInfoUrl' in grab['data'] and grab['data']['nzbInfoUrl']:
video.info_url = grab['data']['nzbInfoUrl']
logging.debug(f'Refining {video} with Info URL: {video.info_url}')
break

View File

@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
from __future__ import absolute_import
from .avistaz_network import AvistazNetworkProviderBase
class AvistazProvider(AvistazNetworkProviderBase):
"""AvistaZ.to Provider."""
server_url = 'https://avistaz.to/'
provider_name = 'avistaz'

View File

@ -0,0 +1,373 @@
import logging
import time
from http.cookies import SimpleCookie
from random import randint
import pycountry
from requests.cookies import RequestsCookieJar
from subliminal.exceptions import AuthenticationError
from subliminal.providers import ParserBeautifulSoup
from subliminal_patch.http import RetryingCFSession
from subliminal_patch.pitcher import store_verification
from subliminal_patch.providers import Provider
from subliminal_patch.subtitle import Subtitle
from subzero.language import Language
from .utils import get_archive_from_bytes, get_subtitle_from_archive, FIRST_THOUSAND_OR_SO_USER_AGENTS as AGENT_LIST
logger = logging.getLogger(__name__)
supported_languages_names = [
"Abkhazian",
"Afar",
"Afrikaans",
"Akan",
"Albanian",
"Amharic",
"Arabic",
"Aragonese",
"Armenian",
"Assamese",
"Avaric",
"Avestan",
"Aymara",
"Azerbaijani",
"Bambara",
"Bashkir",
"Basque",
"Belarusian",
"Bengali",
"Bihari languages",
"Bislama",
"Bokmål, Norwegian",
"Bosnian",
"Brazilian Portuguese",
"Breton",
"Bulgarian",
"Burmese",
"Cantonese",
"Catalan",
"Central Khmer",
"Chamorro",
"Chechen",
"Chichewa",
"Chinese",
"Church Slavic",
"Chuvash",
"Cornish",
"Corsican",
"Cree",
"Croatian",
"Czech",
"Danish",
"Dhivehi",
"Dutch",
"Dzongkha",
"English",
"Esperanto",
"Estonian",
"Ewe",
"Faroese",
"Fijian",
"Filipino",
"Finnish",
"French",
"Fulah",
"Gaelic",
"Galician",
"Ganda",
"Georgian",
"German",
"Greek",
"Guarani",
"Gujarati",
"Haitian",
"Hausa",
"Hebrew",
"Herero",
"Hindi",
"Hiri Motu",
"Hungarian",
"Icelandic",
"Ido",
"Igbo",
"Indonesian",
"Interlingua",
"Interlingue",
"Inuktitut",
"Inupiaq",
"Irish",
"Italian",
"Japanese",
"Javanese",
"Kalaallisut",
"Kannada",
"Kanuri",
"Kashmiri",
"Kazakh",
"Kikuyu",
"Kinyarwanda",
"Kirghiz",
"Komi",
"Kongo",
"Korean",
"Kuanyama",
"Kurdish",
"Lao",
"Latin",
"Latvian",
"Limburgan",
"Lingala",
"Lithuanian",
"Luba-Katanga",
"Luxembourgish",
"Macedonian",
"Malagasy",
"Malay",
"Malayalam",
"Maltese",
"Mandarin",
"Manx",
"Maori",
"Marathi",
"Marshallese",
"Mongolian",
"Moore",
"Nauru",
"Navajo",
"Ndebele, North",
"Ndebele, South",
"Ndonga",
"Nepali",
"Northern Sami",
"Norwegian",
"Norwegian Nynorsk",
"Occitan (post 1500)",
"Ojibwa",
"Oriya",
"Oromo",
"Ossetian",
"Pali",
"Panjabi",
"Persian",
"Polish",
"Portuguese",
"Pushto",
"Quechua",
"Romanian",
"Romansh",
"Rundi",
"Russian",
"Samoan",
"Sango",
"Sanskrit",
"Sardinian",
"Serbian",
"Shona",
"Sichuan Yi",
"Sindhi",
"Sinhala",
"Slovak",
"Slovenian",
"Somali",
"Sotho, Southern",
"Spanish",
"Sundanese",
"Swahili",
"Swati",
"Swedish",
"Tagalog",
"Tahitian",
"Tajik",
"Tamil",
"Tatar",
"Telugu",
"Thai",
"Tibetan",
"Tigrinya",
"Tongan",
"Tsonga",
"Tswana",
"Turkish",
"Turkmen",
"Twi",
"Uighur",
"Ukrainian",
"Urdu",
"Uzbek",
"Venda",
"Vietnamese",
"Volapük",
"Walloon",
"Welsh",
"Western Frisian",
"Wolof",
"Xhosa",
"Yiddish",
"Yoruba",
"Zhuang",
"Zulu"
]
class AvistazNetworkSubtitle(Subtitle):
"""AvistaZ.to Subtitle."""
provider_name = None
def __init__(self, provider_name, page_link, download_link, language, video, filename, release, uploader):
super().__init__(language, page_link=page_link)
self.provider_name = provider_name
self.hearing_impaired = None
self.language = language
self.filename = filename
self.release_info = release
self.page_link = page_link
self.download_link = download_link
self.video = video
self.matches = None
self.content = None
self.uploader = uploader
self.encoding = None
@property
def id(self):
return self.filename
def get_matches(self, video):
# we download subtitles directly from the
# release page, so it's always a perfect match
self.matches = {'hash'}
return self.matches
def lookup_lang(name):
try:
return Language(pycountry.languages.lookup(name).alpha_3)
except:
return None
class AvistazNetworkProviderBase(Provider):
"""AvistaZ Network base provider"""
subtitle_class = AvistazNetworkSubtitle
languages = set(filter(None, map(lookup_lang, supported_languages_names)))
languages.update(set(Language.rebuild(L, hi=True) for L in languages))
server_url = None
provider_name = None
hash_verifiable = True
def __init__(self, cookies, user_agent=None):
self.session = None
self.cookies = cookies
self.user_agent = user_agent
def initialize(self):
self.session = RetryingCFSession()
if self.user_agent:
self.session.headers['User-Agent'] = self.user_agent
else:
self.session.headers['User-Agent'] = AGENT_LIST[randint(0, len(AGENT_LIST) - 1)]
if self.cookies:
self.session.cookies = RequestsCookieJar()
simple_cookie = SimpleCookie()
simple_cookie.load(self.cookies)
for k, v in simple_cookie.items():
self.session.cookies.set(k, v.value)
rr = self.session.get(self.server_url + 'rules', allow_redirects=False, timeout=10,
headers={"Referer": self.server_url})
if rr.status_code in [302, 404, 403]:
logger.info('Cookies expired')
raise AuthenticationError("cookies not valid anymore")
store_verification(self.provider_name, self.session)
logger.debug('Cookies valid')
time.sleep(2)
return True
def terminate(self):
self.session.close()
def list_subtitles(self, video, languages):
if video.info_url is None or not video.info_url.startswith(self.server_url):
logger.debug('%s not downloaded from %s. Skipped', video, self.server_url)
return []
html = self._query_info_url(video.info_url)
if html is None:
logger.debug('%s release page not found. Release might have been removed', video)
return []
release = self._parse_release_table(html)
if release['Subtitles'].table is None:
logger.debug('No subtitles found for %s', video)
return []
subtitle_columns = list(map(lambda x: x.get_text(), release['Subtitles'].thead.find_all('th')))
subtitles = []
for row in release['Subtitles'].tbody.find_all('tr', recursive=False):
subtitle_cols = self._parse_subtitle_row(row, subtitle_columns)
release_name = release['Title'].get_text().strip()
lang = lookup_lang(subtitle_cols['Language'].get_text().strip())
download_link = subtitle_cols['Download'].a['href']
uploader_name = subtitle_cols['Uploader'].get_text().strip()
if lang not in languages:
continue
subtitles.append(self.subtitle_class(
provider_name=self.provider_name,
page_link=video.info_url,
download_link=download_link,
language=lang,
video=video,
filename=download_link.split('/')[-1],
release=release_name,
uploader=uploader_name,
))
return subtitles
def _query_info_url(self, info_url):
response = self.session.get(info_url, timeout=30)
if response.status_code == 404:
return None
else:
response.raise_for_status()
return response.content.decode('utf-8', 'ignore')
def _parse_subtitle_row(self, row, subtitle_columns):
columns = {}
for i, data in enumerate(row.find_all('td', recursive=False)):
columns[subtitle_columns[i]] = data
return columns
def _parse_release_table(self, html):
release_data_table = (ParserBeautifulSoup(html, ['html.parser'])
.select_one('#content-area > div:nth-child(4) > div.table-responsive > table > tbody'))
rows = {}
for tr in release_data_table.find_all('tr', recursive=False):
rows[tr.td.get_text()] = tr.select_one('td:nth-child(2)', recursive=False)
return rows
def download_subtitle(self, subtitle):
response = self.session.get(subtitle.download_link)
response.raise_for_status()
if subtitle.filename.endswith((".zip", ".rar")):
archive = get_archive_from_bytes(response.content)
subtitle.content = get_subtitle_from_archive(
archive, episode=subtitle.video.episode
)
else:
subtitle.content = response.content

View File

@ -0,0 +1,9 @@
from __future__ import absolute_import
from subliminal_patch.providers.avistaz_network import AvistazNetworkProviderBase
class CinemazProvider(AvistazNetworkProviderBase):
"""CinemaZ.to Provider."""
server_url = 'https://cinemaz.to/'
provider_name = 'cinemaz'

View File

@ -85,7 +85,8 @@ class EmbeddedSubtitlesProvider(Provider):
ffmpeg_path=None,
hi_fallback=False,
timeout=600,
unknown_as_english=False,
unknown_as_fallback=False,
fallback_lang="en",
):
self._included_codecs = set(included_codecs or _ALLOWED_CODECS)
@ -97,7 +98,8 @@ class EmbeddedSubtitlesProvider(Provider):
cache_dir or tempfile.gettempdir(), self.__class__.__name__.lower()
)
self._hi_fallback = hi_fallback
self._unknown_as_english = unknown_as_english
self._unknown_as_fallback = unknown_as_fallback
self._fallback_lang = fallback_lang
self._cached_paths = {}
self._timeout = int(timeout)
@ -110,7 +112,7 @@ class EmbeddedSubtitlesProvider(Provider):
# Default is True
container.FFMPEG_STATS = False
tags.LANGUAGE_FALLBACK = "en" if self._unknown_as_english else None
tags.LANGUAGE_FALLBACK = self._fallback_lang if self._unknown_as_fallback and self._fallback_lang else None
logger.debug("Language fallback set: %s", tags.LANGUAGE_FALLBACK)
def initialize(self):

View File

@ -3,11 +3,9 @@ from __future__ import absolute_import
import logging
import re
import time
from requests import Session
from subliminal import __short_version__
from subliminal.providers import ParserBeautifulSoup
from subliminal.video import Episode
from subliminal.video import Movie
from subliminal_patch.exceptions import APIThrottled
@ -80,12 +78,16 @@ class SubdivxSubtitle(Subtitle):
# episode
if isinstance(video, Episode):
# already matched within provider
matches.update(["title", "series", "season", "episode", "year"])
matches.update(["title", "series", "season", "episode"])
if str(video.year) in self.release_info:
matches.update(["year"])
# movie
elif isinstance(video, Movie):
# already matched within provider
matches.update(["title", "year"])
matches.update(["title"])
if str(video.year) in self.release_info:
matches.update(["year"])
update_matches(matches, video, self._description)
@ -122,79 +124,102 @@ class SubdivxSubtitlesProvider(Provider):
def query(self, video, languages):
subtitles = []
# Determine if the video is a movie or a TV episode and set the search accordingly
if isinstance(video, Episode):
# For TV episodes, use alternative_series if available
titles_to_search = [video.series] + getattr(video, 'alternative_series', [])
# TODO: cache pack queries (TV SHOW S01).
# Too many redundant server calls.
for query in (
f"{video.series} S{video.season:02}E{video.episode:02}",
f"{video.series} S{video.season:02}",
):
subtitles += self._handle_multi_page_search(query, video)
# For TV episodes, construct queries with each main and alternative series title
for title in titles_to_search:
# Perform the existing search logic for each title
subtitles += self._handle_search(f"{title} S{video.season:02}E{video.episode:02}", video)
# Try only with series title
if len(subtitles) <= 5:
subtitles += self._handle_multi_page_search(video.series, video, 1)
# If nothing found under SxxExx, try with only season number
if not subtitles:
for title in titles_to_search:
# Perform the existing search logic for each title
subtitles += self._handle_search(f"{title} S{video.season:02}", video)
# If still nothing found, try with only series title (each main and alternative series title)
if not subtitles:
for title in titles_to_search:
subtitles += self._handle_search(title, video, 1)
# Try with episode title as last resort
if not subtitles and video.title != video.series:
subtitles += self._handle_multi_page_search(video.title, video, 1)
subtitles += self._handle_search(video.title, video, 1)
# Additional logic for handling insufficient subtitle results can go here
else:
for query in (video.title, f"{video.title} ({video.year})"):
subtitles += self._handle_multi_page_search(query, video)
# Second query is a fallback
if subtitles:
break
# For movies, use alternative_titles if available
titles_to_search = [video.title] + getattr(video, 'alternative_titles', [])
# For movies, first search with the title (each main and alternative movie title)
for title in titles_to_search:
subtitles += self._handle_search(title, video)
# Then, if available, search with title and year
if video.year:
for title in titles_to_search:
subtitles += self._handle_search(f"{title} ({video.year})", video)
# Additional logic for handling insufficient subtitle results can go here
return subtitles
def _handle_multi_page_search(self, query, video, max_loops=2):
params = {
"buscar2": query,
"accion": "5",
"masdesc": "",
"subtitulos": "1",
"realiza_b": "1",
"pg": 1,
def _handle_search(self, query, video):
# URL for the POST request
search_link = f"{_SERVER_URL}/inc/ajax.php"
# Payload for POST
payload = {
'tabla': 'resultados',
'filtros': '', # Not used now
'buscar': query
}
logger.debug("Query: %s", query)
loops = 1
max_loops_not_met = True
while max_loops_not_met:
max_loops_not_met = loops < max_loops
page_subtitles, last_page = self._get_page_subtitles(params, video)
logger.debug("Yielding %d subtitles [loop #%d]", len(page_subtitles), loops)
yield from page_subtitles
if last_page:
logger.debug("Last page for '%s' query. Breaking loop", query)
break
loops += 1
params["pg"] += 1 # search next page
time.sleep(self.multi_result_throttle)
if not max_loops_not_met:
logger.debug("Max loops limit exceeded (%d)", max_loops)
def _get_page_subtitles(self, params, video):
search_link = f"{_SERVER_URL}/index.php"
response = self.session.get(
search_link, params=params, allow_redirects=True, timeout=20
)
try:
page_subtitles, last_page = self._parse_subtitles_page(video, response)
except Exception as error:
logger.error(f"Error parsing subtitles list: {error}")
# Make the POST request
response = self.session.post(search_link, data=payload)
if response.status_code == 500:
logger.debug("Error 500 (probably bad encoding of query causing issue on provider side): %s", query)
return []
return page_subtitles, last_page
# Ensure it was successful
response.raise_for_status()
# Processing the JSON result
subtitles = []
data = response.json()
# Iterate over each subtitle in the response
for item in data['aaData']:
# Extract the relevant information
id_subtitulo = item['id']
# There is no direct link to view sub details, this is just the download link
page_link = f"{_SERVER_URL}/descargar.php?id={id_subtitulo}"
title = item['titulo']
description = item['descripcion']
uploader = item['nick']
# Build the download URL - assuming RAR for now
download_url = f"{_SERVER_URL}/descargar.php?id={id_subtitulo}"
language = Language('spa', 'MX') # Subdivx is always latin spanish
# Create the SubdivxSubtitle instance
subtitle = self.subtitle_class(language, video, page_link, title, description, uploader, download_url)
subtitles.append(subtitle)
return subtitles # The JSON contains all subs, not paged
def list_subtitles(self, video, languages):
return self.query(video, languages)
@ -222,60 +247,6 @@ class SubdivxSubtitlesProvider(Provider):
subtitle.content = get_subtitle_from_archive(archive, episode=episode)
def _parse_subtitles_page(self, video, response):
subtitles = []
page_soup = ParserBeautifulSoup(
response.content.decode("utf-8", "ignore"), ["lxml", "html.parser"]
)
title_soups = page_soup.find_all("div", {"id": "menu_detalle_buscador"})
body_soups = page_soup.find_all("div", {"id": "buscador_detalle"})
title_checker = _check_episode if isinstance(video, Episode) else _check_movie
for subtitle in range(0, len(title_soups)):
title_soup, body_soup = title_soups[subtitle], body_soups[subtitle]
# title
title = _clean_title(title_soup.find("a").text)
if _UNSUPPORTED_RE.search(title):
logger.debug("Skipping unsupported subtitles: %s", title)
continue
if not title_checker(video, title):
continue
# Data
datos = body_soup.find("div", {"id": "buscador_detalle_sub_datos"}).text
# Ignore multi-disc and non-srt subtitles
if not any(item in datos for item in ("Cds:</b> 1", "SubRip")):
continue
# description
sub_details = body_soup.find("div", {"id": "buscador_detalle_sub"}).text
description = sub_details.replace(",", " ")
# language
spain = (
"/pais/7.gif" in datos
or _SPANISH_RE.search(description.lower()) is not None
)
language = Language.fromalpha2("es") if spain else Language("spa", "MX")
# uploader
uploader = body_soup.find("a", {"class": "link1"}).text
download_url = _get_download_url(body_soup)
page_link = title_soup.find("a")["href"]
subtitle = self.subtitle_class(
language, video, page_link, title, description, uploader, download_url
)
logger.debug("Found subtitle %r", subtitle)
subtitles.append(subtitle)
return subtitles, len(title_soups) < 100
def _clean_title(title):
"""
@ -288,17 +259,6 @@ def _clean_title(title):
return title
def _get_download_url(data):
try:
return [
a_.get("href")
for a_ in data.find_all("a")
if "bajar.php" in a_.get("href", "n/a")
][0]
except IndexError:
return None
def _check_episode(video, title):
ep_num = _EPISODE_NUM_RE.search(title)
season_num = _SEASON_NUM_RE.search(title)
@ -307,16 +267,22 @@ def _check_episode(video, title):
# Only check if both video and Subdivx's title have year metadata
if year is not None and video.year:
year = int(year.group("x"))
# Tolerancy of 1 year difference
# Tolerance of 1 year difference
if abs(year - (video.year or 0)) > 1:
logger.debug("Series year doesn't match: %s", title)
return False
# Prepare a list of all series names to check against (include alternative series names)
series_names = [video.series] + getattr(video, 'alternative_series', [])
# Normalize the title for comparison
normalized_title = _clean_title(title).lower()
# Check if the normalized title contains any of the series names (main or alternative)
series_clean_match = any(series_name.lower() in normalized_title for series_name in series_names)
# Include matches where the episode title is present
if (
video.series.lower() in title.lower()
and (video.title or "").lower() in title.lower()
):
if series_clean_match and (video.title or "").lower() in title.lower():
logger.debug("Episode title found in title: %s ~ %s", video.title, title)
return True
@ -337,7 +303,7 @@ def _check_episode(video, title):
distance = abs(len(series_title) - len(video.series))
series_matched = distance < 4 and ep_matches
series_matched = (distance < 4 and ep_matches) or series_clean_match
logger.debug(
"Series matched? %s [%s -> %s] [title distance: %d]",

View File

@ -126,7 +126,7 @@ class TitrariProvider(Provider, ProviderSubtitleArchiveMixin):
video_types = (Episode, Movie)
api_url = 'https://www.titrari.ro/'
# query_advanced_search = 'cautarepreaavansata'
query_advanced_search = "cautarecutare"
query_advanced_search = "maicauta"
def __init__(self):
self.session = None

View File

@ -14,6 +14,7 @@ class Video(Video_):
season_fully_aired = None
audio_languages = None
external_subtitle_languages = None
info_url = None
def __init__(
self,
@ -31,6 +32,7 @@ class Video(Video_):
streaming_service=None,
edition=None,
other=None,
info_url=None,
**kwargs
):
super(Video, self).__init__(
@ -54,3 +56,4 @@ class Video(Video_):
self.edition = edition
self.original_path = name
self.other = other
self.info_url = info_url

View File

@ -1,6 +1,6 @@
import { useIsLoading } from "@/contexts";
import { Group, Pagination, Text } from "@mantine/core";
import { FunctionComponent } from "react";
import { FunctionComponent, useEffect } from "react";
interface Props {
count: number;
index: number;
@ -22,6 +22,11 @@ const PageControl: FunctionComponent<Props> = ({
const isLoading = useIsLoading();
// Jump to first page if total page count changes
useEffect(() => {
goto(0);
}, [total, goto]);
return (
<Group p={16} position="apart">
<Text size="sm">

View File

@ -1,4 +1,5 @@
import { ScrollToTop } from "@/utilities";
import { usePageSize } from "@/utilities/storage";
import { useEffect } from "react";
import { usePagination, useTable } from "react-table";
import BaseTable from "./BaseTable";
@ -22,6 +23,9 @@ export default function PageTable<T extends object>(props: Props<T>) {
...(plugins ?? [])
);
// use page size as specified in UI settings
instance.state.pageSize = usePageSize();
if (instanceRef) {
instanceRef.current = instance;
}

View File

@ -18,6 +18,8 @@ import { useLanguageProfileBy } from "@/utilities/languages";
import {
faAdjust,
faBriefcase,
faCircleChevronDown,
faCircleChevronRight,
faCloudUploadAlt,
faHdd,
faSearch,
@ -28,11 +30,23 @@ import { Container, Group, Stack } from "@mantine/core";
import { Dropzone } from "@mantine/dropzone";
import { useDocumentTitle } from "@mantine/hooks";
import { showNotification } from "@mantine/notifications";
import { FunctionComponent, useCallback, useMemo, useRef } from "react";
import {
FunctionComponent,
useCallback,
useMemo,
useRef,
useState,
} from "react";
import { Navigate, useParams } from "react-router-dom";
import Table from "./table";
const SeriesEpisodesView: FunctionComponent = () => {
const [state, setState] = useState({
expand: false,
buttonText: "Expand All",
initial: true,
});
const params = useParams();
const id = Number.parseInt(params.id as string);
@ -94,6 +108,12 @@ const SeriesEpisodesView: FunctionComponent = () => {
return <Navigate to={RouterNames.NotFound}></Navigate>;
}
const toggleState = () => {
state.expand
? setState({ expand: false, buttonText: "Expand All", initial: false })
: setState({ expand: true, buttonText: "Collapse All", initial: false });
};
return (
<Container px={0} fluid>
<QueryOverlay result={seriesQuery}>
@ -189,12 +209,22 @@ const SeriesEpisodesView: FunctionComponent = () => {
>
Edit Series
</Toolbox.Button>
<Toolbox.Button
icon={state.expand ? faCircleChevronRight : faCircleChevronDown}
onClick={() => {
toggleState();
}}
>
{state.buttonText}
</Toolbox.Button>
</Group>
</Toolbox>
<Stack>
<ItemOverview item={series ?? null} details={details}></ItemOverview>
<QueryOverlay result={episodesQuery}>
<Table
expand={state.expand}
initial={state.initial}
episodes={episodes ?? null}
profile={profile}
disabled={hasTask || !series || series.profileId === null}

View File

@ -31,9 +31,17 @@ interface Props {
episodes: Item.Episode[] | null;
disabled?: boolean;
profile?: Language.Profile;
expand?: boolean;
initial?: boolean;
}
const Table: FunctionComponent<Props> = ({ episodes, profile, disabled }) => {
const Table: FunctionComponent<Props> = ({
episodes,
profile,
disabled,
expand,
initial,
}) => {
const onlyDesired = useShowOnlyDesired();
const profileItems = useProfileItemsToLanguages(profile);
@ -212,9 +220,18 @@ const Table: FunctionComponent<Props> = ({ episodes, profile, disabled }) => {
useEffect(() => {
if (instance.current) {
instance.current.toggleRowExpanded([`season:${maxSeason}`], true);
if (initial) {
// expand the last/current season on initial display
instance.current.toggleRowExpanded([`season:${maxSeason}`], true);
// make sure season 0 is collapsed
instance.current.toggleRowExpanded([`season:0`], false);
} else {
if (expand !== undefined) {
instance.current.toggleAllRowsExpanded(expand);
}
}
}
}, [maxSeason]);
}, [maxSeason, expand, initial]);
return (
<GroupTable

View File

@ -70,9 +70,6 @@ const SettingsGeneralView: FunctionComponent = () => {
options={securityOptions}
placeholder="No Authentication"
settingKey="settings-auth-type"
settingOptions={{
onSubmit: (v) => (v === null ? "None" : v),
}}
></Selector>
<CollapseBox settingKey="settings-auth-type">
<Text label="Username" settingKey="settings-auth-username"></Text>
@ -138,9 +135,6 @@ const SettingsGeneralView: FunctionComponent = () => {
settingKey="settings-proxy-type"
placeholder="No Proxy"
options={proxyOptions}
settingOptions={{
onSubmit: (v) => (v === null ? "None" : v),
}}
></Selector>
<CollapseBox
settingKey="settings-proxy-type"
@ -183,6 +177,22 @@ const SettingsGeneralView: FunctionComponent = () => {
<Section header="Logging">
<Check label="Debug" settingKey="settings-general-debug"></Check>
<Message>Debug logging should only be enabled temporarily</Message>
<Text
label="Include Filter"
settingKey="settings-log-include_filter"
></Text>
<Text
label="Exclude Filter"
settingKey="settings-log-exclude_filter"
></Text>
<Check
label="Use Regular Expressions (Regex)"
settingKey="settings-log-use_regex"
></Check>
<Check
label="Ignore Case"
settingKey="settings-log-ignore_case"
></Check>
</Section>
<Section header="Backups">
<File

View File

@ -24,7 +24,6 @@ const SettingsProvidersView: FunctionComponent = () => {
label={"Choose the anti-captcha provider you want to use"}
placeholder="Select a provider"
settingKey="settings-general-anti_captcha_provider"
settingOptions={{ onSubmit: (v) => (v === undefined ? "None" : v) }}
options={antiCaptchaOption}
></Selector>
<Message></Message>

View File

@ -68,6 +68,24 @@ export const ProviderList: Readonly<ProviderInfo[]> = [
name: "Argenteam Dump",
description: "Subtitles dump of the now extinct Argenteam",
},
{
key: "avistaz",
name: "AvistaZ",
description:
"avistaz.to - AvistaZ is an Asian torrent tracker for HD movies, TV shows and music",
inputs: [
{
type: "text",
key: "cookies",
name: "Cookies, e.g., PHPSESSID=abc; wikisubtitlesuser=xyz; wikisubtitlespass=efg",
},
{
type: "text",
key: "user_agent",
name: "User-Agent, e.g., Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:95.0) Gecko/20100101 Firefox/95.0",
},
],
},
{
key: "assrt",
description: "Chinese Subtitles Provider",
@ -96,6 +114,24 @@ export const ProviderList: Readonly<ProviderInfo[]> = [
description:
"Provider removed from Bazarr because it was causing too many issues.\nIt will always return no subtitles.",
},
{
key: "cinemaz",
name: "CinemaZ",
description:
"cinemaz.to - CinemaZ is a private torrent tracker which is dedicated to little-known and cult films that you will not find on other popular torrent resources.",
inputs: [
{
type: "text",
key: "cookies",
name: "Cookies, e.g., PHPSESSID=abc; wikisubtitlesuser=xyz; wikisubtitlespass=efg",
},
{
type: "text",
key: "user_agent",
name: "User-Agent, e.g., Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:95.0) Gecko/20100101 Firefox/95.0",
},
],
},
{
key: "embeddedsubtitles",
name: "Embedded Subtitles",
@ -120,8 +156,14 @@ export const ProviderList: Readonly<ProviderInfo[]> = [
},
{
type: "switch",
key: "unknown_as_english",
name: "Use subtitles with unknown info/language as english",
key: "unknown_as_fallback",
name: "Use subtitles with unknown info/language as fallback language",
},
{
type: "text",
key: "fallback_lang",
name: "Fallback language",
defaultValue: "en",
},
],
message:

View File

@ -0,0 +1,99 @@
import { useSettingsMutation, useSystemSettings } from "@/apis/hooks";
import { LoadingProvider } from "@/contexts";
import { useOnValueChange } from "@/utilities";
import { LOG } from "@/utilities/console";
import {
Button,
Container,
Divider,
Group,
LoadingOverlay,
Space,
} from "@mantine/core";
import { useForm } from "@mantine/form";
import { FunctionComponent, ReactNode, useCallback, useMemo } from "react";
import { FormContext, FormValues, runHooks } from "../utilities/FormValues";
import { SettingsProvider } from "../utilities/SettingsProvider";
interface Props {
children: ReactNode;
callbackModal: (value: boolean) => void;
}
const LayoutModal: FunctionComponent<Props> = (props) => {
const { children, callbackModal } = props;
const { data: settings, isLoading, isRefetching } = useSystemSettings();
const { mutate, isLoading: isMutating } = useSettingsMutation();
const form = useForm<FormValues>({
initialValues: {
settings: {},
hooks: {},
},
});
useOnValueChange(isRefetching, (value) => {
if (!value) {
form.reset();
}
});
const submit = useCallback(
(values: FormValues) => {
const { settings, hooks } = values;
if (Object.keys(settings).length > 0) {
const settingsToSubmit = { ...settings };
runHooks(hooks, settingsToSubmit);
LOG("info", "submitting settings", settingsToSubmit);
mutate(settingsToSubmit);
// wait for settings to be validated before callback
// let the user see the spinning indicator on the Save button before the modal closes
setTimeout(() => {
callbackModal(true);
}, 500);
}
},
[mutate, callbackModal]
);
const totalStagedCount = useMemo(() => {
return Object.keys(form.values.settings).length;
}, [form.values.settings]);
return (
<SettingsProvider value={settings ?? null}>
<LoadingProvider value={isLoading || isMutating}>
<form onSubmit={form.onSubmit(submit)} style={{ position: "relative" }}>
<LoadingOverlay visible={settings === undefined}></LoadingOverlay>
<FormContext.Provider value={form}>
<Container size="xl" mx={0}>
{children}
</Container>
</FormContext.Provider>
<Space h="md" />
<Divider></Divider>
<Space h="md" />
<Group position="right">
<Button
type="submit"
disabled={totalStagedCount === 0}
loading={isMutating}
>
Save
</Button>
<Button
onClick={() => {
callbackModal(true);
}}
>
Close
</Button>
</Group>
</form>
</LoadingProvider>
</SettingsProvider>
);
};
export default LayoutModal;

View File

@ -65,6 +65,7 @@ export const URLTestButton: FunctionComponent<{
export * from "./Card";
export * from "./Layout";
export { default as Layout } from "./Layout";
export { default as LayoutModal } from "./LayoutModal";
export * from "./Message";
export * from "./Section";
export * from "./collapse";

View File

@ -1,10 +1,17 @@
import { useDeleteLogs, useSystemLogs } from "@/apis/hooks";
import { useDeleteLogs, useSystemLogs, useSystemSettings } from "@/apis/hooks";
import { Toolbox } from "@/components";
import { QueryOverlay } from "@/components/async";
import { Check, LayoutModal, Message, Text } from "@/pages/Settings/components";
import { Environment } from "@/utilities";
import { faDownload, faSync, faTrash } from "@fortawesome/free-solid-svg-icons";
import { Container, Group } from "@mantine/core";
import {
faDownload,
faFilter,
faSync,
faTrash,
} from "@fortawesome/free-solid-svg-icons";
import { Badge, Container, Group, Stack } from "@mantine/core";
import { useDocumentTitle } from "@mantine/hooks";
import { useModals } from "@mantine/modals";
import { FunctionComponent, useCallback } from "react";
import Table from "./table";
@ -20,6 +27,61 @@ const SystemLogsView: FunctionComponent = () => {
useDocumentTitle("Logs - Bazarr (System)");
const { data: settings } = useSystemSettings();
const modals = useModals();
const suffix = () => {
const include = settings?.log.include_filter;
const exclude = settings?.log.exclude_filter;
const includeIndex = include !== "" && include !== undefined ? 1 : 0;
const excludeIndex = exclude !== "" && exclude !== undefined ? 1 : 0;
const filters = [
["", "I"],
["E", "I/E"],
];
const filterStr = filters[excludeIndex][includeIndex];
const debugStr = settings?.general.debug ? "Debug" : "";
const spaceStr = debugStr !== "" && filterStr !== "" ? " " : "";
const suffixStr = debugStr + spaceStr + filterStr;
return suffixStr;
};
const openFilterModal = () => {
const callbackModal = (close: boolean) => {
if (close) {
modals.closeModal(id);
}
};
const id = modals.openModal({
title: "Set Log Debug and Filter Options",
children: (
<LayoutModal callbackModal={callbackModal}>
<Stack>
<Check label="Debug" settingKey="settings-general-debug"></Check>
<Message>Debug logging should only be enabled temporarily</Message>
<Text
label="Include Filter"
settingKey="settings-log-include_filter"
></Text>
<Text
label="Exclude Filter"
settingKey="settings-log-exclude_filter"
></Text>
<Check
label="Use Regular Expressions (Regex)"
settingKey="settings-log-use_regex"
></Check>
<Check
label="Ignore Case"
settingKey="settings-log-ignore_case"
></Check>
</Stack>
</LayoutModal>
),
});
};
return (
<Container fluid px={0}>
<QueryOverlay result={logs}>
@ -42,6 +104,22 @@ const SystemLogsView: FunctionComponent = () => {
>
Empty
</Toolbox.Button>
<Toolbox.Button
loading={isLoading}
icon={faFilter}
onClick={openFilterModal}
rightIcon={
suffix() !== "" ? (
<Badge size="xs" radius="sm">
{suffix()}
</Badge>
) : (
<></>
)
}
>
Filter
</Toolbox.Button>
</Group>
</Toolbox>
<Table logs={data ?? []}></Table>

View File

@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-empty-interface */
interface Settings {
general: Settings.General;
log: Settings.Log;
proxy: Settings.Proxy;
auth: Settings.Auth;
subsync: Settings.Subsync;
@ -82,6 +83,13 @@ declare namespace Settings {
wanted_search_frequency_movie: number;
}
interface Log {
include_filter: string;
exclude_filter: string;
ignore_case: boolean;
use_regex: boolean;
}
interface Proxy {
exclude: string[];
type?: string;

View File

@ -0,0 +1,28 @@
"""Alter table_languages_profiles.originalFormat type to from bool to int
Revision ID: b183a2ac0dd1
Revises: 30f37e2e15e1
Create Date: 2024-02-16 10:32:39.123456
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'b183a2ac0dd1'
down_revision = '30f37e2e15e1'
branch_labels = None
depends_on = None
bind = op.get_context().bind
def upgrade():
if bind.engine.name == 'postgresql':
with op.batch_alter_table('table_languages_profiles') as batch_op:
batch_op.alter_column('originalFormat', type_=sa.Integer())
def downgrade():
pass

View File

@ -35,7 +35,8 @@ def test_get_providers_auth_embeddedsubtitles():
assert isinstance(item["ffprobe_path"], str)
assert isinstance(item["ffmpeg_path"], str)
assert isinstance(item["timeout"], str)
assert isinstance(item["unknown_as_english"], bool)
assert isinstance(item["unknown_as_fallback"], bool)
assert isinstance(item["fallback_lang"], str)
def test_get_providers_auth_karagarga():

View File

@ -126,8 +126,8 @@ def fake_streams():
@pytest.mark.parametrize("tags_", [{}, {"language": "und", "title": "Unknown"}])
def test_list_subtitles_unknown_as_english(mocker, tags_, video_single_language):
with EmbeddedSubtitlesProvider(unknown_as_english=True) as provider:
def test_list_subtitles_unknown_as_fallback(mocker, tags_, video_single_language):
with EmbeddedSubtitlesProvider(unknown_as_fallback=True, fallback_lang="en") as provider:
fake = FFprobeSubtitleStream(
{"index": 3, "codec_name": "subrip", "tags": tags_}
)
@ -141,10 +141,10 @@ def test_list_subtitles_unknown_as_english(mocker, tags_, video_single_language)
assert len(result) == 1
def test_list_subtitles_unknown_as_english_w_real_english_subtitles(
def test_list_subtitles_unknown_as_fallback_w_real_english_subtitles(
video_single_language, mocker
):
with EmbeddedSubtitlesProvider(unknown_as_english=True) as provider:
with EmbeddedSubtitlesProvider(unknown_as_fallback=True, fallback_lang="en") as provider:
fakes = [
FFprobeSubtitleStream(
{"index": 3, "codec_name": "subrip", "tags": {"language": "und"}}
@ -164,8 +164,8 @@ def test_list_subtitles_unknown_as_english_w_real_english_subtitles(
@pytest.mark.parametrize("tags_", [{}, {"language": "und", "title": "Unknown"}])
def test_list_subtitles_unknown_as_english_disabled(tags_):
with EmbeddedSubtitlesProvider(unknown_as_english=False):
def test_list_subtitles_unknown_as_fallback_disabled(tags_):
with EmbeddedSubtitlesProvider(unknown_as_fallback=False,fallback_lang="en"):
with pytest.raises(LanguageNotFound):
assert FFprobeSubtitleStream(
{"index": 3, "codec_name": "subrip", "tags": tags_}