Merge branch 'development' of https://github.com/morpheus65535/bazarr into development

pull/2687/head v1.4.5-beta.7
JayZed 2 months ago
commit c2a1e4d62c

@ -62,6 +62,7 @@ If you need something that is not already part of Bazarr, feel free to create a
- Karagarga.in - Karagarga.in
- Ktuvit (Get `hashed_password` using method described [here](https://github.com/XBMCil/service.subtitles.ktuvit)) - Ktuvit (Get `hashed_password` using method described [here](https://github.com/XBMCil/service.subtitles.ktuvit))
- LegendasDivx - LegendasDivx
- Legendas.net
- Napiprojekt - Napiprojekt
- Napisy24 - Napisy24
- Nekur - Nekur

@ -73,6 +73,7 @@ class SystemSettings(Resource):
mustNotContain=str(item['mustNotContain']), mustNotContain=str(item['mustNotContain']),
originalFormat=int(item['originalFormat']) if item['originalFormat'] not in None_Keys else originalFormat=int(item['originalFormat']) if item['originalFormat'] not in None_Keys else
None, None,
tag=item['tag'] if 'tag' in item else None,
) )
.where(TableLanguagesProfiles.profileId == item['profileId'])) .where(TableLanguagesProfiles.profileId == item['profileId']))
existing.remove(item['profileId']) existing.remove(item['profileId'])
@ -89,6 +90,7 @@ class SystemSettings(Resource):
mustNotContain=str(item['mustNotContain']), mustNotContain=str(item['mustNotContain']),
originalFormat=int(item['originalFormat']) if item['originalFormat'] not in None_Keys else originalFormat=int(item['originalFormat']) if item['originalFormat'] not in None_Keys else
None, None,
tag=item['tag'] if 'tag' in item else None,
)) ))
for profileId in existing: for profileId in existing:
# Remove deleted profiles # Remove deleted profiles

@ -6,10 +6,12 @@ import logging
from flask_restx import Resource, Namespace from flask_restx import Resource, Namespace
from tzlocal import get_localzone_name from tzlocal import get_localzone_name
from alembic.migration import MigrationContext
from radarr.info import get_radarr_info from radarr.info import get_radarr_info
from sonarr.info import get_sonarr_info from sonarr.info import get_sonarr_info
from app.get_args import args from app.get_args import args
from app.database import engine, database, select
from init import startTime from init import startTime
from ..utils import authenticate from ..utils import authenticate
@ -34,6 +36,16 @@ class SystemStatus(Resource):
timezone = "Exception while getting time zone name." timezone = "Exception while getting time zone name."
logging.exception("BAZARR is unable to get configured time zone name.") logging.exception("BAZARR is unable to get configured time zone name.")
try:
database_version = ".".join([str(x) for x in engine.dialect.server_version_info])
except Exception:
database_version = ""
try:
database_migration = MigrationContext.configure(engine.connect()).get_current_revision()
except Exception:
database_migration = "unknown"
system_status = {} system_status = {}
system_status.update({'bazarr_version': os.environ["BAZARR_VERSION"]}) system_status.update({'bazarr_version': os.environ["BAZARR_VERSION"]})
system_status.update({'package_version': package_version}) system_status.update({'package_version': package_version})
@ -41,6 +53,8 @@ class SystemStatus(Resource):
system_status.update({'radarr_version': get_radarr_info.version()}) system_status.update({'radarr_version': get_radarr_info.version()})
system_status.update({'operating_system': platform.platform()}) system_status.update({'operating_system': platform.platform()})
system_status.update({'python_version': platform.python_version()}) system_status.update({'python_version': platform.python_version()})
system_status.update({'database_engine': f'{engine.dialect.name.capitalize()} {database_version}'})
system_status.update({'database_migration': database_migration})
system_status.update({'bazarr_directory': os.path.dirname(os.path.dirname(os.path.dirname( system_status.update({'bazarr_directory': os.path.dirname(os.path.dirname(os.path.dirname(
os.path.dirname(__file__))))}) os.path.dirname(__file__))))})
system_status.update({'bazarr_config_directory': args.config_dir}) system_status.update({'bazarr_config_directory': args.config_dir})

@ -31,12 +31,20 @@ def base_url_slash_cleaner(uri):
def validate_ip_address(ip_string): def validate_ip_address(ip_string):
if ip_string == '*':
return True
try: try:
ip_address(ip_string) ip_address(ip_string)
return True return True
except ValueError: except ValueError:
return False return False
def validate_tags(tags):
if not tags:
return True
return all(re.match( r'^[a-z0-9_-]+$', item) for item in tags)
ONE_HUNDRED_YEARS_IN_MINUTES = 52560000 ONE_HUNDRED_YEARS_IN_MINUTES = 52560000
ONE_HUNDRED_YEARS_IN_HOURS = 876000 ONE_HUNDRED_YEARS_IN_HOURS = 876000
@ -67,7 +75,7 @@ validators = [
# general section # general section
Validator('general.flask_secret_key', must_exist=True, default=hexlify(os.urandom(16)).decode(), Validator('general.flask_secret_key', must_exist=True, default=hexlify(os.urandom(16)).decode(),
is_type_of=str), is_type_of=str),
Validator('general.ip', must_exist=True, default='0.0.0.0', is_type_of=str, condition=validate_ip_address), Validator('general.ip', must_exist=True, default='*', is_type_of=str, condition=validate_ip_address),
Validator('general.port', must_exist=True, default=6767, is_type_of=int, gte=1, lte=65535), Validator('general.port', must_exist=True, default=6767, is_type_of=int, gte=1, lte=65535),
Validator('general.base_url', must_exist=True, default='', is_type_of=str), Validator('general.base_url', must_exist=True, default='', is_type_of=str),
Validator('general.path_mappings', must_exist=True, default=[], is_type_of=list), Validator('general.path_mappings', must_exist=True, default=[], is_type_of=list),
@ -88,6 +96,9 @@ validators = [
Validator('general.use_sonarr', must_exist=True, default=False, is_type_of=bool), Validator('general.use_sonarr', must_exist=True, default=False, is_type_of=bool),
Validator('general.use_radarr', must_exist=True, default=False, is_type_of=bool), Validator('general.use_radarr', must_exist=True, default=False, is_type_of=bool),
Validator('general.path_mappings_movie', must_exist=True, default=[], is_type_of=list), Validator('general.path_mappings_movie', must_exist=True, default=[], is_type_of=list),
Validator('general.serie_tag_enabled', must_exist=True, default=False, is_type_of=bool),
Validator('general.movie_tag_enabled', must_exist=True, default=False, is_type_of=bool),
Validator('general.remove_profile_tags', must_exist=True, default=[], is_type_of=list, condition=validate_tags),
Validator('general.serie_default_enabled', must_exist=True, default=False, is_type_of=bool), Validator('general.serie_default_enabled', must_exist=True, default=False, is_type_of=bool),
Validator('general.serie_default_profile', must_exist=True, default='', is_type_of=(int, str)), Validator('general.serie_default_profile', must_exist=True, default='', is_type_of=(int, str)),
Validator('general.movie_default_enabled', must_exist=True, default=False, is_type_of=bool), Validator('general.movie_default_enabled', must_exist=True, default=False, is_type_of=bool),
@ -176,7 +187,7 @@ validators = [
Validator('sonarr.only_monitored', must_exist=True, default=False, is_type_of=bool), Validator('sonarr.only_monitored', must_exist=True, default=False, is_type_of=bool),
Validator('sonarr.series_sync', must_exist=True, default=60, is_type_of=int, Validator('sonarr.series_sync', must_exist=True, default=60, is_type_of=int,
is_in=[15, 60, 180, 360, 720, 1440, 10080, ONE_HUNDRED_YEARS_IN_MINUTES]), is_in=[15, 60, 180, 360, 720, 1440, 10080, ONE_HUNDRED_YEARS_IN_MINUTES]),
Validator('sonarr.excluded_tags', must_exist=True, default=[], is_type_of=list), Validator('sonarr.excluded_tags', must_exist=True, default=[], is_type_of=list, condition=validate_tags),
Validator('sonarr.excluded_series_types', must_exist=True, default=[], is_type_of=list), Validator('sonarr.excluded_series_types', must_exist=True, default=[], is_type_of=list),
Validator('sonarr.use_ffprobe_cache', must_exist=True, default=True, is_type_of=bool), Validator('sonarr.use_ffprobe_cache', must_exist=True, default=True, is_type_of=bool),
Validator('sonarr.exclude_season_zero', must_exist=True, default=False, is_type_of=bool), Validator('sonarr.exclude_season_zero', must_exist=True, default=False, is_type_of=bool),
@ -199,7 +210,7 @@ validators = [
Validator('radarr.only_monitored', must_exist=True, default=False, is_type_of=bool), Validator('radarr.only_monitored', must_exist=True, default=False, is_type_of=bool),
Validator('radarr.movies_sync', must_exist=True, default=60, is_type_of=int, Validator('radarr.movies_sync', must_exist=True, default=60, is_type_of=int,
is_in=[15, 60, 180, 360, 720, 1440, 10080, ONE_HUNDRED_YEARS_IN_MINUTES]), is_in=[15, 60, 180, 360, 720, 1440, 10080, ONE_HUNDRED_YEARS_IN_MINUTES]),
Validator('radarr.excluded_tags', must_exist=True, default=[], is_type_of=list), Validator('radarr.excluded_tags', must_exist=True, default=[], is_type_of=list, condition=validate_tags),
Validator('radarr.use_ffprobe_cache', must_exist=True, default=True, is_type_of=bool), Validator('radarr.use_ffprobe_cache', must_exist=True, default=True, is_type_of=bool),
Validator('radarr.defer_search_signalr', must_exist=True, default=False, is_type_of=bool), Validator('radarr.defer_search_signalr', must_exist=True, default=False, is_type_of=bool),
Validator('radarr.sync_only_monitored_movies', must_exist=True, default=False, is_type_of=bool), Validator('radarr.sync_only_monitored_movies', must_exist=True, default=False, is_type_of=bool),
@ -271,6 +282,10 @@ validators = [
Validator('legendasdivx.password', must_exist=True, default='', is_type_of=str, cast=str), Validator('legendasdivx.password', must_exist=True, default='', is_type_of=str, cast=str),
Validator('legendasdivx.skip_wrong_fps', must_exist=True, default=False, is_type_of=bool), Validator('legendasdivx.skip_wrong_fps', must_exist=True, default=False, is_type_of=bool),
# legendasnet section
Validator('legendasnet.username', must_exist=True, default='', is_type_of=str, cast=str),
Validator('legendasnet.password', must_exist=True, default='', is_type_of=str, cast=str),
# ktuvit section # ktuvit section
Validator('ktuvit.email', must_exist=True, default='', is_type_of=str), Validator('ktuvit.email', must_exist=True, default='', is_type_of=str),
Validator('ktuvit.hashed_password', must_exist=True, default='', is_type_of=str, cast=str), Validator('ktuvit.hashed_password', must_exist=True, default='', is_type_of=str, cast=str),
@ -299,6 +314,12 @@ validators = [
# analytics section # analytics section
Validator('analytics.enabled', must_exist=True, default=True, is_type_of=bool), Validator('analytics.enabled', must_exist=True, default=True, is_type_of=bool),
# jimaku section
Validator('jimaku.api_key', must_exist=True, default='', is_type_of=str),
Validator('jimaku.enable_name_search_fallback', must_exist=True, default=True, is_type_of=bool),
Validator('jimaku.enable_archives_download', must_exist=True, default=False, is_type_of=bool),
Validator('jimaku.enable_ai_subs', must_exist=True, default=False, is_type_of=bool),
# titlovi section # titlovi section
Validator('titlovi.username', must_exist=True, default='', is_type_of=str, cast=str), Validator('titlovi.username', must_exist=True, default='', is_type_of=str, cast=str),
Validator('titlovi.password', must_exist=True, default='', is_type_of=str, cast=str), Validator('titlovi.password', must_exist=True, default='', is_type_of=str, cast=str),
@ -454,6 +475,7 @@ array_keys = ['excluded_tags',
'enabled_integrations', 'enabled_integrations',
'path_mappings', 'path_mappings',
'path_mappings_movie', 'path_mappings_movie',
'remove_profile_tags',
'language_equals', 'language_equals',
'blacklisted_languages', 'blacklisted_languages',
'blacklisted_providers'] 'blacklisted_providers']

@ -379,6 +379,7 @@ def update_profile_id_list():
'mustContain': ast.literal_eval(x.mustContain) if x.mustContain else [], 'mustContain': ast.literal_eval(x.mustContain) if x.mustContain else [],
'mustNotContain': ast.literal_eval(x.mustNotContain) if x.mustNotContain else [], 'mustNotContain': ast.literal_eval(x.mustNotContain) if x.mustNotContain else [],
'originalFormat': x.originalFormat, 'originalFormat': x.originalFormat,
'tag': x.tag,
} for x in database.execute( } for x in database.execute(
select(TableLanguagesProfiles.profileId, select(TableLanguagesProfiles.profileId,
TableLanguagesProfiles.name, TableLanguagesProfiles.name,
@ -386,7 +387,8 @@ def update_profile_id_list():
TableLanguagesProfiles.items, TableLanguagesProfiles.items,
TableLanguagesProfiles.mustContain, TableLanguagesProfiles.mustContain,
TableLanguagesProfiles.mustNotContain, TableLanguagesProfiles.mustNotContain,
TableLanguagesProfiles.originalFormat)) TableLanguagesProfiles.originalFormat,
TableLanguagesProfiles.tag))
.all() .all()
] ]
@ -421,7 +423,7 @@ def get_profile_cutoff(profile_id):
if profile_id and profile_id != 'null': if profile_id and profile_id != 'null':
cutoff_language = [] cutoff_language = []
for profile in profile_id_list: for profile in profile_id_list:
profileId, name, cutoff, items, mustContain, mustNotContain, originalFormat = profile.values() profileId, name, cutoff, items, mustContain, mustNotContain, originalFormat, tag = profile.values()
if cutoff: if cutoff:
if profileId == int(profile_id): if profileId == int(profile_id):
for item in items: for item in items:
@ -511,7 +513,8 @@ def upgrade_languages_profile_hi_values():
TableLanguagesProfiles.items, TableLanguagesProfiles.items,
TableLanguagesProfiles.mustContain, TableLanguagesProfiles.mustContain,
TableLanguagesProfiles.mustNotContain, TableLanguagesProfiles.mustNotContain,
TableLanguagesProfiles.originalFormat) TableLanguagesProfiles.originalFormat,
TableLanguagesProfiles.tag)
))\ ))\
.all(): .all():
items = json.loads(languages_profile.items) items = json.loads(languages_profile.items)
@ -525,3 +528,32 @@ def upgrade_languages_profile_hi_values():
.values({"items": json.dumps(items)}) .values({"items": json.dumps(items)})
.where(TableLanguagesProfiles.profileId == languages_profile.profileId) .where(TableLanguagesProfiles.profileId == languages_profile.profileId)
) )
def fix_languages_profiles_with_duplicate_ids():
languages_profiles = database.execute(
select(TableLanguagesProfiles.profileId, TableLanguagesProfiles.items, TableLanguagesProfiles.cutoff)).all()
for languages_profile in languages_profiles:
if languages_profile.cutoff:
# ignore profiles that have a cutoff set
continue
languages_profile_ids = []
languages_profile_has_duplicate = False
languages_profile_items = json.loads(languages_profile.items)
for items in languages_profile_items:
if items['id'] in languages_profile_ids:
languages_profile_has_duplicate = True
break
else:
languages_profile_ids.append(items['id'])
if languages_profile_has_duplicate:
item_id = 0
for items in languages_profile_items:
item_id += 1
items['id'] = item_id
database.execute(
update(TableLanguagesProfiles)
.values({"items": json.dumps(languages_profile_items)})
.where(TableLanguagesProfiles.profileId == languages_profile.profileId)
)

@ -264,6 +264,10 @@ def get_providers_auth():
'password': settings.legendasdivx.password, 'password': settings.legendasdivx.password,
'skip_wrong_fps': settings.legendasdivx.skip_wrong_fps, 'skip_wrong_fps': settings.legendasdivx.skip_wrong_fps,
}, },
'legendasnet': {
'username': settings.legendasnet.username,
'password': settings.legendasnet.password,
},
'xsubs': { 'xsubs': {
'username': settings.xsubs.username, 'username': settings.xsubs.username,
'password': settings.xsubs.password, 'password': settings.xsubs.password,
@ -285,6 +289,12 @@ def get_providers_auth():
'username': settings.titlovi.username, 'username': settings.titlovi.username,
'password': settings.titlovi.password, 'password': settings.titlovi.password,
}, },
'jimaku': {
'api_key': settings.jimaku.api_key,
'enable_name_search_fallback': settings.jimaku.enable_name_search_fallback,
'enable_archives_download': settings.jimaku.enable_archives_download,
'enable_ai_subs': settings.jimaku.enable_ai_subs,
},
'ktuvit': { 'ktuvit': {
'email': settings.ktuvit.email, 'email': settings.ktuvit.email,
'hashed_password': settings.ktuvit.hashed_password, 'hashed_password': settings.ktuvit.hashed_password,

@ -58,10 +58,13 @@ class NoExceptionFormatter(logging.Formatter):
class UnwantedWaitressMessageFilter(logging.Filter): class UnwantedWaitressMessageFilter(logging.Filter):
def filter(self, record): def filter(self, record):
if settings.general.debug: if settings.general.debug or "BAZARR" in record.msg:
# no filtering in debug mode # no filtering in debug mode or if originating from us
return True return True
if record.levelno < logging.ERROR:
return False
unwantedMessages = [ unwantedMessages = [
"Exception while serving /api/socket.io/", "Exception while serving /api/socket.io/",
['Session is disconnected', 'Session not found'], ['Session is disconnected', 'Session not found'],
@ -161,7 +164,7 @@ def configure_logging(debug=False):
logging.getLogger("websocket").setLevel(logging.CRITICAL) logging.getLogger("websocket").setLevel(logging.CRITICAL)
logging.getLogger("ga4mp.ga4mp").setLevel(logging.ERROR) logging.getLogger("ga4mp.ga4mp").setLevel(logging.ERROR)
logging.getLogger("waitress").setLevel(logging.ERROR) logging.getLogger("waitress").setLevel(logging.INFO)
logging.getLogger("waitress").addFilter(UnwantedWaitressMessageFilter()) logging.getLogger("waitress").addFilter(UnwantedWaitressMessageFilter())
logging.getLogger("knowit").setLevel(logging.CRITICAL) logging.getLogger("knowit").setLevel(logging.CRITICAL)
logging.getLogger("enzyme").setLevel(logging.CRITICAL) logging.getLogger("enzyme").setLevel(logging.CRITICAL)
@ -169,9 +172,14 @@ def configure_logging(debug=False):
logging.getLogger("rebulk").setLevel(logging.WARNING) logging.getLogger("rebulk").setLevel(logging.WARNING)
logging.getLogger("stevedore.extension").setLevel(logging.CRITICAL) logging.getLogger("stevedore.extension").setLevel(logging.CRITICAL)
def empty_file(filename):
# Open the log file in write mode to clear its contents
with open(filename, 'w'):
pass # Just opening and closing the file will clear it
def empty_log(): def empty_log():
fh.doRollover() fh.doRollover()
empty_file(get_log_file_path())
logging.info('BAZARR Log file emptied') logging.info('BAZARR Log file emptied')

@ -50,7 +50,7 @@ class Server:
self.connected = True self.connected = True
except OSError as error: except OSError as error:
if error.errno == errno.EADDRNOTAVAIL: if error.errno == errno.EADDRNOTAVAIL:
logging.exception("BAZARR cannot bind to specified IP, trying with default (0.0.0.0)") logging.exception("BAZARR cannot bind to specified IP, trying with 0.0.0.0")
self.address = '0.0.0.0' self.address = '0.0.0.0'
self.connected = False self.connected = False
super(Server, self).__init__() super(Server, self).__init__()
@ -76,8 +76,7 @@ class Server:
self.shutdown(EXIT_INTERRUPT) self.shutdown(EXIT_INTERRUPT)
def start(self): def start(self):
logging.info(f'BAZARR is started and waiting for request on http://{self.server.effective_host}:' self.server.print_listen("BAZARR is started and waiting for requests on: http://{}:{}")
f'{self.server.effective_port}')
signal.signal(signal.SIGINT, self.interrupt_handler) signal.signal(signal.SIGINT, self.interrupt_handler)
try: try:
self.server.run() self.server.run()

@ -5,7 +5,8 @@ import os
from subzero.language import Language from subzero.language import Language
from app.database import database, insert from app.database import database, insert, update
from sqlalchemy.exc import IntegrityError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -18,7 +19,7 @@ class CustomLanguage:
language = "pt-BR" language = "pt-BR"
official_alpha2 = "pt" official_alpha2 = "pt"
official_alpha3 = "por" official_alpha3 = "por"
name = "Brazilian Portuguese" name = "Portuguese (Brazil)"
iso = "BR" iso = "BR"
_scripts = [] _scripts = []
_possible_matches = ("pt-br", "pob", "pb", "brazilian", "brasil", "brazil") _possible_matches = ("pt-br", "pob", "pb", "brazilian", "brasil", "brazil")
@ -50,13 +51,19 @@ class CustomLanguage:
"""Register the custom language subclasses in the database.""" """Register the custom language subclasses in the database."""
for sub in cls.__subclasses__(): for sub in cls.__subclasses__():
try:
database.execute( database.execute(
insert(table) insert(table)
.values(code3=sub.alpha3, .values(code3=sub.alpha3,
code2=sub.alpha2, code2=sub.alpha2,
name=sub.name, name=sub.name,
enabled=0) enabled=0))
.on_conflict_do_nothing()) except IntegrityError:
database.execute(
update(table)
.values(code2=sub.alpha2,
name=sub.name)
.where(table.code3 == sub.alpha3))
@classmethod @classmethod
def found_external(cls, subtitle, subtitle_path): def found_external(cls, subtitle, subtitle_path):
@ -212,7 +219,7 @@ class LatinAmericanSpanish(CustomLanguage):
language = "es-MX" language = "es-MX"
official_alpha2 = "es" official_alpha2 = "es"
official_alpha3 = "spa" official_alpha3 = "spa"
name = "Latin American Spanish" name = "Spanish (Latino)"
iso = "MX" # Not fair, but ok iso = "MX" # Not fair, but ok
_scripts = ("419",) _scripts = ("419",)
_possible_matches = ( _possible_matches = (

@ -44,6 +44,12 @@ def create_languages_dict():
.values(name='Chinese Simplified') .values(name='Chinese Simplified')
.where(TableSettingsLanguages.code3 == 'zho')) .where(TableSettingsLanguages.code3 == 'zho'))
# replace Modern Greek by Greek to match Sonarr and Radarr languages
database.execute(
update(TableSettingsLanguages)
.values(name='Greek')
.where(TableSettingsLanguages.code3 == 'ell'))
languages_dict = [{ languages_dict = [{
'code3': x.code3, 'code3': x.code3,
'code2': x.code2, 'code2': x.code2,
@ -55,6 +61,19 @@ def create_languages_dict():
.all()] .all()]
def audio_language_from_name(lang):
lang_map = {
'Chinese': 'zh',
}
alpha2_code = lang_map.get(lang, None)
if alpha2_code is None:
return lang
return language_from_alpha2(alpha2_code)
def language_from_alpha2(lang): def language_from_alpha2(lang):
return next((item['name'] for item in languages_dict if item['code2'] == lang[:2]), None) return next((item['name'] for item in languages_dict if item['code2'] == lang[:2]), None)

@ -35,7 +35,8 @@ else:
# there's missing embedded packages after a commit # there's missing embedded packages after a commit
check_if_new_update() check_if_new_update()
from app.database import System, database, update, migrate_db, create_db_revision, upgrade_languages_profile_hi_values # noqa E402 from app.database import (System, database, update, migrate_db, create_db_revision, upgrade_languages_profile_hi_values,
fix_languages_profiles_with_duplicate_ids) # noqa E402
from app.notifier import update_notifier # noqa E402 from app.notifier import update_notifier # noqa E402
from languages.get_languages import load_language_in_db # noqa E402 from languages.get_languages import load_language_in_db # noqa E402
from app.signalr_client import sonarr_signalr_client, radarr_signalr_client # noqa E402 from app.signalr_client import sonarr_signalr_client, radarr_signalr_client # noqa E402
@ -50,6 +51,7 @@ if args.create_db_revision:
else: else:
migrate_db(app) migrate_db(app)
upgrade_languages_profile_hi_values() upgrade_languages_profile_hi_values()
fix_languages_profiles_with_duplicate_ids()
configure_proxy_func() configure_proxy_func()

@ -28,6 +28,11 @@ def trace(message):
logging.debug(FEATURE_PREFIX + message) logging.debug(FEATURE_PREFIX + message)
def get_language_profiles():
return database.execute(
select(TableLanguagesProfiles.profileId, TableLanguagesProfiles.name, TableLanguagesProfiles.tag)).all()
def update_all_movies(): def update_all_movies():
movies_full_scan_subtitles() movies_full_scan_subtitles()
logging.info('BAZARR All existing movie subtitles indexed from disk.') logging.info('BAZARR All existing movie subtitles indexed from disk.')
@ -59,7 +64,7 @@ def update_movie(updated_movie, send_event):
def get_movie_monitored_status(movie_id): def get_movie_monitored_status(movie_id):
existing_movie_monitored = database.execute( existing_movie_monitored = database.execute(
select(TableMovies.monitored) select(TableMovies.monitored)
.where(TableMovies.tmdbId == movie_id))\ .where(TableMovies.tmdbId == str(movie_id)))\
.first() .first()
if existing_movie_monitored is None: if existing_movie_monitored is None:
return True return True
@ -108,6 +113,7 @@ def update_movies(send_event=True):
else: else:
audio_profiles = get_profile_list() audio_profiles = get_profile_list()
tagsDict = get_tags() tagsDict = get_tags()
language_profiles = get_language_profiles()
# Get movies data from radarr # Get movies data from radarr
movies = get_movies_from_radarr_api(apikey_radarr=apikey_radarr) movies = get_movies_from_radarr_api(apikey_radarr=apikey_radarr)
@ -178,6 +184,7 @@ def update_movies(send_event=True):
if str(movie['tmdbId']) in current_movies_id_db: if str(movie['tmdbId']) in current_movies_id_db:
parsed_movie = movieParser(movie, action='update', parsed_movie = movieParser(movie, action='update',
tags_dict=tagsDict, tags_dict=tagsDict,
language_profiles=language_profiles,
movie_default_profile=movie_default_profile, movie_default_profile=movie_default_profile,
audio_profiles=audio_profiles) audio_profiles=audio_profiles)
if not any([parsed_movie.items() <= x for x in current_movies_db_kv]): if not any([parsed_movie.items() <= x for x in current_movies_db_kv]):
@ -186,6 +193,7 @@ def update_movies(send_event=True):
else: else:
parsed_movie = movieParser(movie, action='insert', parsed_movie = movieParser(movie, action='insert',
tags_dict=tagsDict, tags_dict=tagsDict,
language_profiles=language_profiles,
movie_default_profile=movie_default_profile, movie_default_profile=movie_default_profile,
audio_profiles=audio_profiles) audio_profiles=audio_profiles)
add_movie(parsed_movie, send_event) add_movie(parsed_movie, send_event)
@ -247,6 +255,7 @@ def update_one_movie(movie_id, action, defer_search=False):
audio_profiles = get_profile_list() audio_profiles = get_profile_list()
tagsDict = get_tags() tagsDict = get_tags()
language_profiles = get_language_profiles()
try: try:
# Get movie data from radarr api # Get movie data from radarr api
@ -256,10 +265,10 @@ def update_one_movie(movie_id, action, defer_search=False):
return return
else: else:
if action == 'updated' and existing_movie: if action == 'updated' and existing_movie:
movie = movieParser(movie_data, action='update', tags_dict=tagsDict, movie = movieParser(movie_data, action='update', tags_dict=tagsDict, language_profiles=language_profiles,
movie_default_profile=movie_default_profile, audio_profiles=audio_profiles) movie_default_profile=movie_default_profile, audio_profiles=audio_profiles)
elif action == 'updated' and not existing_movie: elif action == 'updated' and not existing_movie:
movie = movieParser(movie_data, action='insert', tags_dict=tagsDict, movie = movieParser(movie_data, action='insert', tags_dict=tagsDict, language_profiles=language_profiles,
movie_default_profile=movie_default_profile, audio_profiles=audio_profiles) movie_default_profile=movie_default_profile, audio_profiles=audio_profiles)
except Exception: except Exception:
logging.exception('BAZARR cannot get movie returned by SignalR feed from Radarr API.') logging.exception('BAZARR cannot get movie returned by SignalR feed from Radarr API.')

@ -3,7 +3,7 @@
import os import os
from app.config import settings from app.config import settings
from languages.get_languages import language_from_alpha2 from languages.get_languages import audio_language_from_name
from radarr.info import get_radarr_info from radarr.info import get_radarr_info
from utilities.video_analyzer import embedded_audio_reader from utilities.video_analyzer import embedded_audio_reader
from utilities.path_mappings import path_mappings from utilities.path_mappings import path_mappings
@ -11,7 +11,17 @@ from utilities.path_mappings import path_mappings
from .converter import RadarrFormatAudioCodec, RadarrFormatVideoCodec from .converter import RadarrFormatAudioCodec, RadarrFormatVideoCodec
def movieParser(movie, action, tags_dict, movie_default_profile, audio_profiles): def get_matching_profile(tags, language_profiles):
matching_profile = None
if len(tags) > 0:
for profileId, name, tag in language_profiles:
if tag in tags:
matching_profile = profileId
break
return matching_profile
def movieParser(movie, action, tags_dict, language_profiles, movie_default_profile, audio_profiles):
if 'movieFile' in movie: if 'movieFile' in movie:
try: try:
overview = str(movie['overview']) overview = str(movie['overview'])
@ -107,9 +117,7 @@ def movieParser(movie, action, tags_dict, movie_default_profile, audio_profiles)
for item in movie['movieFile']['languages']: for item in movie['movieFile']['languages']:
if isinstance(item, dict): if isinstance(item, dict):
if 'name' in item: if 'name' in item:
language = item['name'] language = audio_language_from_name(item['name'])
if item['name'] == 'Portuguese (Brazil)':
language = language_from_alpha2('pb')
audio_language.append(language) audio_language.append(language)
tags = [d['label'] for d in tags_dict if d['id'] in movie['tags']] tags = [d['label'] for d in tags_dict if d['id'] in movie['tags']]
@ -140,6 +148,15 @@ def movieParser(movie, action, tags_dict, movie_default_profile, audio_profiles)
parsed_movie['subtitles'] = '[]' parsed_movie['subtitles'] = '[]'
parsed_movie['profileId'] = movie_default_profile parsed_movie['profileId'] = movie_default_profile
if settings.general.movie_tag_enabled:
tag_profile = get_matching_profile(tags, language_profiles)
if tag_profile:
parsed_movie['profileId'] = tag_profile
remove_profile_tags_list = settings.general.remove_profile_tags
if len(remove_profile_tags_list) > 0:
if set(tags) & set(remove_profile_tags_list):
parsed_movie['profileId'] = None
return parsed_movie return parsed_movie

@ -5,6 +5,7 @@ import os
from app.config import settings from app.config import settings
from app.database import TableShows, database, select from app.database import TableShows, database, select
from constants import MINIMUM_VIDEO_SIZE from constants import MINIMUM_VIDEO_SIZE
from languages.get_languages import audio_language_from_name
from utilities.path_mappings import path_mappings from utilities.path_mappings import path_mappings
from utilities.video_analyzer import embedded_audio_reader from utilities.video_analyzer import embedded_audio_reader
from sonarr.info import get_sonarr_info from sonarr.info import get_sonarr_info
@ -12,7 +13,17 @@ from sonarr.info import get_sonarr_info
from .converter import SonarrFormatVideoCodec, SonarrFormatAudioCodec from .converter import SonarrFormatVideoCodec, SonarrFormatAudioCodec
def seriesParser(show, action, tags_dict, serie_default_profile, audio_profiles): def get_matching_profile(tags, language_profiles):
matching_profile = None
if len(tags) > 0:
for profileId, name, tag in language_profiles:
if tag in tags:
matching_profile = profileId
break
return matching_profile
def seriesParser(show, action, tags_dict, language_profiles, serie_default_profile, audio_profiles):
overview = show['overview'] if 'overview' in show else '' overview = show['overview'] if 'overview' in show else ''
poster = '' poster = ''
fanart = '' fanart = ''
@ -24,9 +35,11 @@ def seriesParser(show, action, tags_dict, serie_default_profile, audio_profiles)
if image['coverType'] == 'fanart': if image['coverType'] == 'fanart':
fanart = image['url'].split('?')[0] fanart = image['url'].split('?')[0]
alternate_titles = None
if show['alternateTitles'] is not None: if show['alternateTitles'] is not None:
alternate_titles = str([item['title'] for item in show['alternateTitles']]) alternate_titles = [item['title'] for item in show['alternateTitles'] if 'title' in item and item['title'] not
in [None, ''] and item["title"] != show["title"]]
else:
alternate_titles = []
tags = [d['label'] for d in tags_dict if d['id'] in show['tags']] tags = [d['label'] for d in tags_dict if d['id'] in show['tags']]
@ -42,8 +55,8 @@ def seriesParser(show, action, tags_dict, serie_default_profile, audio_profiles)
else: else:
audio_language = [] audio_language = []
if action == 'update': parsed_series = {
return {'title': show["title"], 'title': show["title"],
'path': show["path"], 'path': show["path"],
'tvdbId': int(show["tvdbId"]), 'tvdbId': int(show["tvdbId"]),
'sonarrSeriesId': int(show["id"]), 'sonarrSeriesId': int(show["id"]),
@ -53,28 +66,26 @@ def seriesParser(show, action, tags_dict, serie_default_profile, audio_profiles)
'audio_language': str(audio_language), 'audio_language': str(audio_language),
'sortTitle': show['sortTitle'], 'sortTitle': show['sortTitle'],
'year': str(show['year']), 'year': str(show['year']),
'alternativeTitles': alternate_titles, 'alternativeTitles': str(alternate_titles),
'tags': str(tags), 'tags': str(tags),
'seriesType': show['seriesType'], 'seriesType': show['seriesType'],
'imdbId': imdbId, 'imdbId': imdbId,
'monitored': str(bool(show['monitored']))} 'monitored': str(bool(show['monitored']))
else: }
return {'title': show["title"],
'path': show["path"], if action == 'insert':
'tvdbId': show["tvdbId"], parsed_series['profileId'] = serie_default_profile
'sonarrSeriesId': show["id"],
'overview': overview, if settings.general.serie_tag_enabled:
'poster': poster, tag_profile = get_matching_profile(tags, language_profiles)
'fanart': fanart, if tag_profile:
'audio_language': str(audio_language), parsed_series['profileId'] = tag_profile
'sortTitle': show['sortTitle'], remove_profile_tags_list = settings.general.remove_profile_tags
'year': str(show['year']), if len(remove_profile_tags_list) > 0:
'alternativeTitles': alternate_titles, if set(tags) & set(remove_profile_tags_list):
'tags': str(tags), parsed_series['profileId'] = None
'seriesType': show['seriesType'],
'imdbId': imdbId, return parsed_series
'profileId': serie_default_profile,
'monitored': str(bool(show['monitored']))}
def profile_id_to_language(id_, profiles): def profile_id_to_language(id_, profiles):
@ -111,13 +122,13 @@ def episodeParser(episode):
item = episode['episodeFile']['language'] item = episode['episodeFile']['language']
if isinstance(item, dict): if isinstance(item, dict):
if 'name' in item: if 'name' in item:
audio_language.append(item['name']) audio_language.append(audio_language_from_name(item['name']))
elif 'languages' in episode['episodeFile'] and len(episode['episodeFile']['languages']): elif 'languages' in episode['episodeFile'] and len(episode['episodeFile']['languages']):
items = episode['episodeFile']['languages'] items = episode['episodeFile']['languages']
if isinstance(items, list): if isinstance(items, list):
for item in items: for item in items:
if 'name' in item: if 'name' in item:
audio_language.append(item['name']) audio_language.append(audio_language_from_name(item['name']))
else: else:
audio_language = database.execute( audio_language = database.execute(
select(TableShows.audio_language) select(TableShows.audio_language)

@ -26,6 +26,11 @@ def trace(message):
logging.debug(FEATURE_PREFIX + message) logging.debug(FEATURE_PREFIX + message)
def get_language_profiles():
return database.execute(
select(TableLanguagesProfiles.profileId, TableLanguagesProfiles.name, TableLanguagesProfiles.tag)).all()
def get_series_monitored_table(): def get_series_monitored_table():
series_monitored = database.execute( series_monitored = database.execute(
select(TableShows.tvdbId, TableShows.monitored))\ select(TableShows.tvdbId, TableShows.monitored))\
@ -58,6 +63,7 @@ def update_series(send_event=True):
audio_profiles = get_profile_list() audio_profiles = get_profile_list()
tagsDict = get_tags() tagsDict = get_tags()
language_profiles = get_language_profiles()
# Get shows data from Sonarr # Get shows data from Sonarr
series = get_series_from_sonarr_api(apikey_sonarr=apikey_sonarr) series = get_series_from_sonarr_api(apikey_sonarr=apikey_sonarr)
@ -111,6 +117,7 @@ def update_series(send_event=True):
if show['id'] in current_shows_db: if show['id'] in current_shows_db:
updated_series = seriesParser(show, action='update', tags_dict=tagsDict, updated_series = seriesParser(show, action='update', tags_dict=tagsDict,
language_profiles=language_profiles,
serie_default_profile=serie_default_profile, serie_default_profile=serie_default_profile,
audio_profiles=audio_profiles) audio_profiles=audio_profiles)
@ -132,6 +139,7 @@ def update_series(send_event=True):
event_stream(type='series', payload=show['id']) event_stream(type='series', payload=show['id'])
else: else:
added_series = seriesParser(show, action='insert', tags_dict=tagsDict, added_series = seriesParser(show, action='insert', tags_dict=tagsDict,
language_profiles=language_profiles,
serie_default_profile=serie_default_profile, serie_default_profile=serie_default_profile,
audio_profiles=audio_profiles) audio_profiles=audio_profiles)
@ -203,7 +211,7 @@ def update_one_series(series_id, action):
audio_profiles = get_profile_list() audio_profiles = get_profile_list()
tagsDict = get_tags() tagsDict = get_tags()
language_profiles = get_language_profiles()
try: try:
# Get series data from sonarr api # Get series data from sonarr api
series = None series = None
@ -215,10 +223,12 @@ def update_one_series(series_id, action):
else: else:
if action == 'updated' and existing_series: if action == 'updated' and existing_series:
series = seriesParser(series_data[0], action='update', tags_dict=tagsDict, series = seriesParser(series_data[0], action='update', tags_dict=tagsDict,
language_profiles=language_profiles,
serie_default_profile=serie_default_profile, serie_default_profile=serie_default_profile,
audio_profiles=audio_profiles) audio_profiles=audio_profiles)
elif action == 'updated' and not existing_series: elif action == 'updated' and not existing_series:
series = seriesParser(series_data[0], action='insert', tags_dict=tagsDict, series = seriesParser(series_data[0], action='insert', tags_dict=tagsDict,
language_profiles=language_profiles,
serie_default_profile=serie_default_profile, serie_default_profile=serie_default_profile,
audio_profiles=audio_profiles) audio_profiles=audio_profiles)
except Exception: except Exception:

@ -216,7 +216,9 @@ def list_missing_subtitles_movies(no=None, send_event=True):
if cutoff_temp_list: if cutoff_temp_list:
for cutoff_temp in cutoff_temp_list: for cutoff_temp in cutoff_temp_list:
cutoff_language = [cutoff_temp['language'], cutoff_temp['forced'], cutoff_temp['hi']] cutoff_language = {'language': cutoff_temp['language'],
'forced': cutoff_temp['forced'],
'hi': cutoff_temp['hi']}
if cutoff_temp['audio_exclude'] == 'True' and \ if cutoff_temp['audio_exclude'] == 'True' and \
any(x['code2'] == cutoff_temp['language'] for x in any(x['code2'] == cutoff_temp['language'] for x in
get_audio_profile_languages(movie_subtitles.audio_language)): get_audio_profile_languages(movie_subtitles.audio_language)):
@ -224,7 +226,10 @@ def list_missing_subtitles_movies(no=None, send_event=True):
elif cutoff_language in actual_subtitles_list: elif cutoff_language in actual_subtitles_list:
cutoff_met = True cutoff_met = True
# HI is considered as good as normal # HI is considered as good as normal
elif cutoff_language and [cutoff_language[0], 'False', 'True'] in actual_subtitles_list: elif (cutoff_language and
{'language': cutoff_language['language'],
'forced': 'False',
'hi': 'True'} in actual_subtitles_list):
cutoff_met = True cutoff_met = True
if cutoff_met: if cutoff_met:

@ -216,7 +216,9 @@ def list_missing_subtitles(no=None, epno=None, send_event=True):
if cutoff_temp_list: if cutoff_temp_list:
for cutoff_temp in cutoff_temp_list: for cutoff_temp in cutoff_temp_list:
cutoff_language = [cutoff_temp['language'], cutoff_temp['forced'], cutoff_temp['hi']] cutoff_language = {'language': cutoff_temp['language'],
'forced': cutoff_temp['forced'],
'hi': cutoff_temp['hi']}
if cutoff_temp['audio_exclude'] == 'True' and \ if cutoff_temp['audio_exclude'] == 'True' and \
any(x['code2'] == cutoff_temp['language'] for x in any(x['code2'] == cutoff_temp['language'] for x in
get_audio_profile_languages(episode_subtitles.audio_language)): get_audio_profile_languages(episode_subtitles.audio_language)):
@ -224,7 +226,10 @@ def list_missing_subtitles(no=None, epno=None, send_event=True):
elif cutoff_language in actual_subtitles_list: elif cutoff_language in actual_subtitles_list:
cutoff_met = True cutoff_met = True
# HI is considered as good as normal # HI is considered as good as normal
elif [cutoff_language[0], 'False', 'True'] in actual_subtitles_list: elif (cutoff_language and
{'language': cutoff_language['language'],
'forced': 'False',
'hi': 'True'} in actual_subtitles_list):
cutoff_met = True cutoff_met = True
if cutoff_met: if cutoff_met:

@ -2,7 +2,6 @@
import os import os
import logging import logging
import re
from guess_language import guess_language from guess_language import guess_language
from subliminal_patch import core from subliminal_patch import core
@ -136,6 +135,7 @@ def guess_external_subtitles(dest_folder, subtitles, media_type, previously_inde
continue continue
text = text.decode(encoding) text = text.decode(encoding)
if bool(re.search(core.HI_REGEX, text)): if core.parse_for_hi_regex(subtitle_text=text,
alpha3_language=language.alpha3 if hasattr(language, 'alpha3') else None):
subtitles[subtitle] = Language.rebuild(subtitles[subtitle], forced=False, hi=True) subtitles[subtitle] = Language.rebuild(subtitles[subtitle], forced=False, hi=True)
return subtitles return subtitles

@ -4,10 +4,12 @@ from .ffprobe import refine_from_ffprobe
from .database import refine_from_db from .database import refine_from_db
from .arr_history import refine_from_arr_history from .arr_history import refine_from_arr_history
from .anidb import refine_from_anidb from .anidb import refine_from_anidb
from .anilist import refine_from_anilist
registered = { registered = {
"database": refine_from_db, "database": refine_from_db,
"ffprobe": refine_from_ffprobe, "ffprobe": refine_from_ffprobe,
"arr_history": refine_from_arr_history, "arr_history": refine_from_arr_history,
"anidb": refine_from_anidb, "anidb": refine_from_anidb,
"anilist": refine_from_anilist, # Must run AFTER AniDB
} }

@ -20,7 +20,10 @@ except ImportError:
except ImportError: except ImportError:
import xml.etree.ElementTree as etree import xml.etree.ElementTree as etree
refined_providers = {'animetosho'} refined_providers = {'animetosho', 'jimaku'}
providers_requiring_anidb_api = {'animetosho'}
logger = logging.getLogger(__name__)
api_url = 'http://api.anidb.net:9001/httpapi' api_url = 'http://api.anidb.net:9001/httpapi'
@ -41,6 +44,10 @@ class AniDBClient(object):
def is_throttled(self): def is_throttled(self):
return self.cache and self.cache.get('is_throttled') return self.cache and self.cache.get('is_throttled')
@property
def has_api_credentials(self):
return self.api_client_key != '' and self.api_client_key is not None
@property @property
def daily_api_request_count(self): def daily_api_request_count(self):
if not self.cache: if not self.cache:
@ -62,7 +69,9 @@ class AniDBClient(object):
return r.content return r.content
@region.cache_on_arguments(expiration_time=timedelta(days=1).total_seconds()) @region.cache_on_arguments(expiration_time=timedelta(days=1).total_seconds())
def get_series_id(self, mappings, tvdb_series_season, tvdb_series_id, episode): def get_show_information(self, tvdb_series_id, tvdb_series_season, episode):
mappings = etree.fromstring(self.get_series_mappings())
# Enrich the collection of anime with the episode offset # Enrich the collection of anime with the episode offset
animes = [ animes = [
self.AnimeInfo(anime, int(anime.attrib.get('episodeoffset', 0))) self.AnimeInfo(anime, int(anime.attrib.get('episodeoffset', 0)))
@ -71,9 +80,24 @@ class AniDBClient(object):
) )
] ]
is_special_entry = False
if not animes: if not animes:
return None, None # Some entries will store TVDB seasons in a nested mapping list, identifiable by the value 'a' as the season
special_entries = mappings.findall(
f".//anime[@tvdbid='{tvdb_series_id}'][@defaulttvdbseason='a']"
)
if not special_entries:
return None, None, None
is_special_entry = True
for special_entry in special_entries:
mapping_list = special_entry.findall(f".//mapping[@tvdbseason='{tvdb_series_season}']")
if len(mapping_list) > 0:
anidb_id = int(special_entry.attrib.get('anidbid'))
offset = int(mapping_list[0].attrib.get('offset', 0))
if not is_special_entry:
# Sort the anime by offset in ascending order # Sort the anime by offset in ascending order
animes.sort(key=lambda a: a.episode_offset) animes.sort(key=lambda a: a.episode_offset)
@ -83,25 +107,45 @@ class AniDBClient(object):
for index, anime_info in enumerate(animes): for index, anime_info in enumerate(animes):
anime, episode_offset = anime_info anime, episode_offset = anime_info
mapping_list = anime.find('mapping-list')
# Handle mapping list for Specials
if mapping_list:
for mapping in mapping_list.findall("mapping"):
if mapping.text is None:
continue
# Mapping values are usually like ;1-1;2-1;3-1;
for episode_ref in mapping.text.split(';'):
if not episode_ref:
continue
anidb_episode, tvdb_episode = map(int, episode_ref.split('-'))
if tvdb_episode == episode:
anidb_id = int(anime.attrib.get('anidbid')) anidb_id = int(anime.attrib.get('anidbid'))
return anidb_id, anidb_episode, 0
if episode > episode_offset: if episode > episode_offset:
anidb_id = anidb_id anidb_id = int(anime.attrib.get('anidbid'))
offset = episode_offset offset = episode_offset
return anidb_id, episode - offset return anidb_id, episode - offset, offset
@region.cache_on_arguments(expiration_time=timedelta(days=1).total_seconds()) @region.cache_on_arguments(expiration_time=timedelta(days=1).total_seconds())
def get_series_episodes_ids(self, tvdb_series_id, season, episode): def get_episode_ids(self, series_id, episode_no):
mappings = etree.fromstring(self.get_series_mappings())
series_id, episode_no = self.get_series_id(mappings, season, tvdb_series_id, episode)
if not series_id: if not series_id:
return None, None return None
episodes = etree.fromstring(self.get_episodes(series_id)) episodes = etree.fromstring(self.get_episodes(series_id))
return series_id, int(episodes.find(f".//episode[epno='{episode_no}']").attrib.get('id')) episode = episodes.find(f".//episode[epno='{episode_no}']")
if not episode:
return series_id, None
return series_id, int(episode.attrib.get('id'))
@region.cache_on_arguments(expiration_time=REFINER_EXPIRATION_TIME) @region.cache_on_arguments(expiration_time=REFINER_EXPIRATION_TIME)
def get_episodes(self, series_id): def get_episodes(self, series_id):
@ -156,8 +200,6 @@ class AniDBClient(object):
def refine_from_anidb(path, video): def refine_from_anidb(path, video):
if not isinstance(video, Episode) or not video.series_tvdb_id: if not isinstance(video, Episode) or not video.series_tvdb_id:
logging.debug(f'Video is not an Anime TV series, skipping refinement for {video}')
return return
if refined_providers.intersection(settings.general.enabled_providers) and video.series_anidb_id is None: if refined_providers.intersection(settings.general.enabled_providers) and video.series_anidb_id is None:
@ -169,27 +211,35 @@ def refine_anidb_ids(video):
season = video.season if video.season else 0 season = video.season if video.season else 0
if anidb_client.is_throttled: anidb_series_id, anidb_episode_no, anidb_season_episode_offset = anidb_client.get_show_information(
logging.warning(f'API daily limit reached. Skipping refinement for {video.series}') video.series_tvdb_id,
season,
video.episode,
)
if not anidb_series_id:
logger.error(f'Could not find anime series {video.series}')
return video return video
anidb_episode_id = None
if anidb_client.has_api_credentials:
if anidb_client.is_throttled:
logger.warning(f'API daily limit reached. Skipping episode ID refinement for {video.series}')
else:
try: try:
anidb_series_id, anidb_episode_id = anidb_client.get_series_episodes_ids( anidb_episode_id = anidb_client.get_episode_ids(
video.series_tvdb_id, anidb_series_id,
season, video.episode, anidb_episode_no
) )
except TooManyRequests: except TooManyRequests:
logging.error(f'API daily limit reached while refining {video.series}') logger.error(f'API daily limit reached while refining {video.series}')
anidb_client.mark_as_throttled() anidb_client.mark_as_throttled()
else:
return video intersect = providers_requiring_anidb_api.intersection(settings.general.enabled_providers)
if len(intersect) >= 1:
if not anidb_episode_id: logger.warn(f'AniDB API credentials are not fully set up, the following providers may not work: {intersect}')
logging.error(f'Could not find anime series {video.series}')
return video
video.series_anidb_id = anidb_series_id video.series_anidb_id = anidb_series_id
video.series_anidb_episode_id = anidb_episode_id video.series_anidb_episode_id = anidb_episode_id
video.series_anidb_episode_no = anidb_episode_no
video.series_anidb_season_episode_offset = anidb_season_episode_offset

@ -0,0 +1,79 @@
# coding=utf-8
# fmt: off
import logging
import time
import requests
from collections import namedtuple
from datetime import timedelta
from app.config import settings
from subliminal import Episode, region, __short_version__
logger = logging.getLogger(__name__)
refined_providers = {'jimaku'}
class AniListClient(object):
def __init__(self, session=None, timeout=10):
self.session = session or requests.Session()
self.session.timeout = timeout
self.session.headers['Content-Type'] = 'application/json'
self.session.headers['User-Agent'] = 'Subliminal/%s' % __short_version__
@region.cache_on_arguments(expiration_time=timedelta(days=1).total_seconds())
def get_series_mappings(self):
r = self.session.get(
'https://raw.githubusercontent.com/Fribb/anime-lists/master/anime-list-mini.json'
)
r.raise_for_status()
return r.json()
def get_series_id(self, candidate_id_name, candidate_id_value):
anime_list = self.get_series_mappings()
tag_map = {
"series_anidb_id": "anidb_id",
"imdb_id": "imdb_id"
}
mapped_tag = tag_map.get(candidate_id_name, candidate_id_name)
obj = [obj for obj in anime_list if mapped_tag in obj and str(obj[mapped_tag]) == str(candidate_id_value)]
logger.debug(f"Based on '{mapped_tag}': '{candidate_id_value}', anime-list matched: {obj}")
if len(obj) > 0:
return obj[0]["anilist_id"]
else:
logger.debug(f"Could not find corresponding AniList ID with '{mapped_tag}': {candidate_id_value}")
return None
def refine_from_anilist(path, video):
# Safety checks
if isinstance(video, Episode):
if not video.series_anidb_id:
return
if refined_providers.intersection(settings.general.enabled_providers) and video.anilist_id is None:
refine_anilist_ids(video)
def refine_anilist_ids(video):
anilist_client = AniListClient()
if isinstance(video, Episode):
candidate_id_name = "series_anidb_id"
else:
candidate_id_name = "imdb_id"
candidate_id_value = getattr(video, candidate_id_name, None)
if not candidate_id_value:
logger.error(f"Found no value for property {candidate_id_name} of video.")
return video
anilist_id = anilist_client.get_series_id(candidate_id_name, candidate_id_value)
if not anilist_id:
return video
video.anilist_id = anilist_id

@ -36,40 +36,47 @@ def delete_subtitles(media_type, language, forced, hi, media_path, subtitles_pat
language_log += ':forced' language_log += ':forced'
language_string += ' forced' language_string += ' forced'
if media_type == 'series':
pr = path_mappings.path_replace
prr = path_mappings.path_replace_reverse
else:
pr = path_mappings.path_replace_movie
prr = path_mappings.path_replace_reverse_movie
result = ProcessSubtitlesResult(message=f"{language_string} subtitles deleted from disk.", result = ProcessSubtitlesResult(message=f"{language_string} subtitles deleted from disk.",
reversed_path=path_mappings.path_replace_reverse(media_path), reversed_path=prr(media_path),
downloaded_language_code2=language_log, downloaded_language_code2=language_log,
downloaded_provider=None, downloaded_provider=None,
score=None, score=None,
forced=None, forced=None,
subtitle_id=None, subtitle_id=None,
reversed_subtitles_path=path_mappings.path_replace_reverse(subtitles_path), reversed_subtitles_path=prr(subtitles_path),
hearing_impaired=None) hearing_impaired=None)
if media_type == 'series': if media_type == 'series':
try: try:
os.remove(path_mappings.path_replace(subtitles_path)) os.remove(pr(subtitles_path))
except OSError: except OSError:
logging.exception(f'BAZARR cannot delete subtitles file: {subtitles_path}') logging.exception(f'BAZARR cannot delete subtitles file: {subtitles_path}')
store_subtitles(path_mappings.path_replace_reverse(media_path), media_path) store_subtitles(prr(media_path), media_path)
return False return False
else: else:
history_log(0, sonarr_series_id, sonarr_episode_id, result) history_log(0, sonarr_series_id, sonarr_episode_id, result)
store_subtitles(path_mappings.path_replace_reverse(media_path), media_path) store_subtitles(prr(media_path), media_path)
notify_sonarr(sonarr_series_id) notify_sonarr(sonarr_series_id)
event_stream(type='series', action='update', payload=sonarr_series_id) event_stream(type='series', action='update', payload=sonarr_series_id)
event_stream(type='episode-wanted', action='update', payload=sonarr_episode_id) event_stream(type='episode-wanted', action='update', payload=sonarr_episode_id)
return True return True
else: else:
try: try:
os.remove(path_mappings.path_replace_movie(subtitles_path)) os.remove(pr(subtitles_path))
except OSError: except OSError:
logging.exception(f'BAZARR cannot delete subtitles file: {subtitles_path}') logging.exception(f'BAZARR cannot delete subtitles file: {subtitles_path}')
store_subtitles_movie(path_mappings.path_replace_reverse_movie(media_path), media_path) store_subtitles_movie(prr(media_path), media_path)
return False return False
else: else:
history_log_movie(0, radarr_id, result) history_log_movie(0, radarr_id, result)
store_subtitles_movie(path_mappings.path_replace_reverse_movie(media_path), media_path) store_subtitles_movie(prr(media_path), media_path)
notify_radarr(radarr_id) notify_radarr(radarr_id)
event_stream(type='movie-wanted', action='update', payload=radarr_id) event_stream(type='movie-wanted', action='update', payload=radarr_id)
return True return True

@ -97,8 +97,7 @@ class SubSyncer:
result = run(self.args) result = run(self.args)
except Exception: except Exception:
logging.exception( logging.exception(
f'BAZARR an exception occurs during the synchronization process for this subtitles: {self.srtin}') f'BAZARR an exception occurs during the synchronization process for this subtitle file: {self.srtin}')
raise OSError
else: else:
if settings.subsync.debug: if settings.subsync.debug:
return result return result
@ -113,14 +112,19 @@ class SubSyncer:
f"{offset_seconds} seconds and a framerate scale factor of " f"{offset_seconds} seconds and a framerate scale factor of "
f"{f'{framerate_scale_factor:.2f}'}.") f"{f'{framerate_scale_factor:.2f}'}.")
if sonarr_series_id:
prr = path_mappings.path_replace_reverse
else:
prr = path_mappings.path_replace_reverse_movie
result = ProcessSubtitlesResult(message=message, result = ProcessSubtitlesResult(message=message,
reversed_path=path_mappings.path_replace_reverse(self.reference), reversed_path=prr(self.reference),
downloaded_language_code2=srt_lang, downloaded_language_code2=srt_lang,
downloaded_provider=None, downloaded_provider=None,
score=None, score=None,
forced=forced, forced=forced,
subtitle_id=None, subtitle_id=None,
reversed_subtitles_path=srt_path, reversed_subtitles_path=prr(self.srtin),
hearing_impaired=hi) hearing_impaired=hi)
if sonarr_episode_id: if sonarr_episode_id:

@ -6,12 +6,17 @@ import pysubs2
from subliminal_patch.core import get_subtitle_path from subliminal_patch.core import get_subtitle_path
from subzero.language import Language from subzero.language import Language
from deep_translator import GoogleTranslator from deep_translator import GoogleTranslator
from deep_translator.exceptions import TooManyRequests, RequestError, TranslationNotFound
from time import sleep
from concurrent.futures import ThreadPoolExecutor
from languages.custom_lang import CustomLanguage from languages.custom_lang import CustomLanguage
from languages.get_languages import alpha3_from_alpha2, language_from_alpha2, language_from_alpha3 from languages.get_languages import alpha3_from_alpha2, language_from_alpha2, language_from_alpha3
from radarr.history import history_log_movie from radarr.history import history_log_movie
from sonarr.history import history_log from sonarr.history import history_log
from subtitles.processing import ProcessSubtitlesResult from subtitles.processing import ProcessSubtitlesResult
from app.event_handler import show_progress, hide_progress
from utilities.path_mappings import path_mappings
def translate_subtitles_file(video_path, source_srt_file, from_lang, to_lang, forced, hi, media_type, sonarr_series_id, def translate_subtitles_file(video_path, source_srt_file, from_lang, to_lang, forced, hi, media_type, sonarr_series_id,
@ -33,8 +38,6 @@ def translate_subtitles_file(video_path, source_srt_file, from_lang, to_lang, fo
logging.debug(f'BAZARR is translating in {lang_obj} this subtitles {source_srt_file}') logging.debug(f'BAZARR is translating in {lang_obj} this subtitles {source_srt_file}')
max_characters = 5000
dest_srt_file = get_subtitle_path(video_path, dest_srt_file = get_subtitle_path(video_path,
language=lang_obj if isinstance(lang_obj, Language) else lang_obj.subzero_language(), language=lang_obj if isinstance(lang_obj, Language) else lang_obj.subzero_language(),
extension='.srt', extension='.srt',
@ -44,40 +47,53 @@ def translate_subtitles_file(video_path, source_srt_file, from_lang, to_lang, fo
subs = pysubs2.load(source_srt_file, encoding='utf-8') subs = pysubs2.load(source_srt_file, encoding='utf-8')
subs.remove_miscellaneous_events() subs.remove_miscellaneous_events()
lines_list = [x.plaintext for x in subs] lines_list = [x.plaintext for x in subs]
joined_lines_str = '\n\n\n'.join(lines_list) lines_list_len = len(lines_list)
logging.debug(f'BAZARR splitting subtitles into {max_characters} characters blocks')
lines_block_list = []
translated_lines_list = []
while len(joined_lines_str):
partial_lines_str = joined_lines_str[:max_characters]
if len(joined_lines_str) > max_characters: def translate_line(id, line, attempt):
new_partial_lines_str = partial_lines_str.rsplit('\n\n', 1)[0] try:
translated_text = GoogleTranslator(
source='auto',
target=language_code_convert_dict.get(lang_obj.alpha2, lang_obj.alpha2)
).translate(text=line)
except TooManyRequests:
if attempt <= 5:
sleep(1)
super(translate_line(id, line, attempt+1))
else:
logging.debug(f'Too many requests while translating {line}')
translated_lines.append({'id': id, 'line': line})
except (RequestError, TranslationNotFound):
logging.debug(f'Unable to translate line {line}')
translated_lines.append({'id': id, 'line': line})
else: else:
new_partial_lines_str = partial_lines_str translated_lines.append({'id': id, 'line': translated_text})
finally:
show_progress(id=f'translate_progress_{dest_srt_file}',
header=f'Translating subtitles lines to {language_from_alpha3(to_lang)}...',
name='',
value=len(translated_lines),
count=lines_list_len)
lines_block_list.append(new_partial_lines_str) logging.debug(f'BAZARR is sending {lines_list_len} blocks to Google Translate')
joined_lines_str = joined_lines_str.replace(new_partial_lines_str, '')
logging.debug(f'BAZARR is sending {len(lines_block_list)} blocks to Google Translate') pool = ThreadPoolExecutor(max_workers=10)
for block_str in lines_block_list:
try: translated_lines = []
translated_partial_srt_text = GoogleTranslator(source='auto',
target=language_code_convert_dict.get(lang_obj.alpha2, for i, line in enumerate(lines_list):
lang_obj.alpha2) pool.submit(translate_line, i, line, 1)
).translate(text=block_str)
except Exception: pool.shutdown(wait=True)
logging.exception(f'BAZARR Unable to translate subtitles {source_srt_file}')
return False for i, line in enumerate(translated_lines):
else: lines_list[line['id']] = line['line']
translated_partial_srt_list = translated_partial_srt_text.split('\n\n')
translated_lines_list += translated_partial_srt_list hide_progress(id=f'translate_progress_{dest_srt_file}')
logging.debug(f'BAZARR saving translated subtitles to {dest_srt_file}') logging.debug(f'BAZARR saving translated subtitles to {dest_srt_file}')
for i, line in enumerate(subs): for i, line in enumerate(subs):
try: try:
line.plaintext = translated_lines_list[i] line.plaintext = lines_list[i]
except IndexError: except IndexError:
logging.error(f'BAZARR is unable to translate malformed subtitles: {source_srt_file}') logging.error(f'BAZARR is unable to translate malformed subtitles: {source_srt_file}')
return False return False
@ -89,14 +105,19 @@ def translate_subtitles_file(video_path, source_srt_file, from_lang, to_lang, fo
message = f"{language_from_alpha2(from_lang)} subtitles translated to {language_from_alpha3(to_lang)}." message = f"{language_from_alpha2(from_lang)} subtitles translated to {language_from_alpha3(to_lang)}."
if media_type == 'series':
prr = path_mappings.path_replace_reverse
else:
prr = path_mappings.path_replace_reverse_movie
result = ProcessSubtitlesResult(message=message, result = ProcessSubtitlesResult(message=message,
reversed_path=video_path, reversed_path=prr(video_path),
downloaded_language_code2=to_lang, downloaded_language_code2=to_lang,
downloaded_provider=None, downloaded_provider=None,
score=None, score=None,
forced=forced, forced=forced,
subtitle_id=None, subtitle_id=None,
reversed_subtitles_path=dest_srt_file, reversed_subtitles_path=prr(dest_srt_file),
hearing_impaired=hi) hearing_impaired=hi)
if media_type == 'series': if media_type == 'series':

@ -1,7 +1,9 @@
# coding=utf-8 # coding=utf-8
import json
from app.config import settings from app.config import settings
from app.database import TableShowsRootfolder, TableMoviesRootfolder, database, select from app.database import TableShowsRootfolder, TableMoviesRootfolder, TableLanguagesProfiles, database, select
from app.event_handler import event_stream from app.event_handler import event_stream
from .path_mappings import path_mappings from .path_mappings import path_mappings
from sonarr.rootfolder import check_sonarr_rootfolder from sonarr.rootfolder import check_sonarr_rootfolder
@ -47,4 +49,21 @@ def get_health_issues():
health_issues.append({'object': path_mappings.path_replace_movie(item.path), health_issues.append({'object': path_mappings.path_replace_movie(item.path),
'issue': item.error}) 'issue': item.error})
# get languages profiles duplicate ids issues when there's a cutoff set
languages_profiles = database.execute(
select(TableLanguagesProfiles.items, TableLanguagesProfiles.name, TableLanguagesProfiles.cutoff)).all()
for languages_profile in languages_profiles:
if not languages_profile.cutoff:
# ignore profiles that don't have a cutoff set
continue
languages_profile_ids = []
for items in json.loads(languages_profile.items):
if items['id'] in languages_profile_ids:
health_issues.append({'object': languages_profile.name,
'issue': 'This languages profile has duplicate IDs. You need to edit this profile'
' and make sure to select the proper cutoff if required.'})
break
else:
languages_profile_ids.append(items['id'])
return health_issues return health_issues

@ -130,7 +130,8 @@ class Episode(Video):
""" """
def __init__(self, name, series, season, episode, title=None, year=None, original_series=True, tvdb_id=None, def __init__(self, name, series, season, episode, title=None, year=None, original_series=True, tvdb_id=None,
series_tvdb_id=None, series_imdb_id=None, alternative_series=None, series_anidb_id=None, series_tvdb_id=None, series_imdb_id=None, alternative_series=None, series_anidb_id=None,
series_anidb_episode_id=None, **kwargs): series_anidb_episode_id=None, series_anidb_season_episode_offset=None,
anilist_id=None, **kwargs):
super(Episode, self).__init__(name, **kwargs) super(Episode, self).__init__(name, **kwargs)
#: Series of the episode #: Series of the episode
@ -163,8 +164,11 @@ class Episode(Video):
#: Alternative names of the series #: Alternative names of the series
self.alternative_series = alternative_series or [] self.alternative_series = alternative_series or []
#: Anime specific information
self.series_anidb_episode_id = series_anidb_episode_id self.series_anidb_episode_id = series_anidb_episode_id
self.series_anidb_id = series_anidb_id self.series_anidb_id = series_anidb_id
self.series_anidb_season_episode_offset = series_anidb_season_episode_offset
self.anilist_id = anilist_id
@classmethod @classmethod
def fromguess(cls, name, guess): def fromguess(cls, name, guess):
@ -207,10 +211,11 @@ class Movie(Video):
:param str title: title of the movie. :param str title: title of the movie.
:param int year: year of the movie. :param int year: year of the movie.
:param list alternative_titles: alternative titles of the movie :param list alternative_titles: alternative titles of the movie
:param int anilist_id: AniList ID of movie (if Anime)
:param \*\*kwargs: additional parameters for the :class:`Video` constructor. :param \*\*kwargs: additional parameters for the :class:`Video` constructor.
""" """
def __init__(self, name, title, year=None, alternative_titles=None, **kwargs): def __init__(self, name, title, year=None, alternative_titles=None, anilist_id=None, **kwargs):
super(Movie, self).__init__(name, **kwargs) super(Movie, self).__init__(name, **kwargs)
#: Title of the movie #: Title of the movie
@ -222,6 +227,9 @@ class Movie(Video):
#: Alternative titles of the movie #: Alternative titles of the movie
self.alternative_titles = alternative_titles or [] self.alternative_titles = alternative_titles or []
#: AniList ID of the movie
self.anilist_id = anilist_id
@classmethod @classmethod
def fromguess(cls, name, guess): def fromguess(cls, name, guess):
if guess['type'] != 'movie': if guess['type'] != 'movie':

@ -49,7 +49,17 @@ SUBTITLE_EXTENSIONS = ('.srt', '.sub', '.smi', '.txt', '.ssa', '.ass', '.mpl', '
_POOL_LIFETIME = datetime.timedelta(hours=12) _POOL_LIFETIME = datetime.timedelta(hours=12)
HI_REGEX = re.compile(r'[*¶♫♪].{3,}[*¶♫♪]|[\[\(\{].{3,}[\]\)\}](?<!{\\an\d})') HI_REGEX_WITHOUT_PARENTHESIS = re.compile(r'[*¶♫♪].{3,}[*¶♫♪]|[\[\{].{3,}[\]\}](?<!{\\an\d})')
HI_REGEX_WITH_PARENTHESIS = re.compile(r'[*¶♫♪].{3,}[*¶♫♪]|[\[\(\{].{3,}[\]\)\}](?<!{\\an\d})')
HI_REGEX_PARENTHESIS_EXCLUDED_LANGUAGES = ['ara']
def parse_for_hi_regex(subtitle_text, alpha3_language):
if alpha3_language in HI_REGEX_PARENTHESIS_EXCLUDED_LANGUAGES:
return bool(re.search(HI_REGEX_WITHOUT_PARENTHESIS, subtitle_text))
else:
return bool(re.search(HI_REGEX_WITH_PARENTHESIS, subtitle_text))
def remove_crap_from_fn(fn): def remove_crap_from_fn(fn):
@ -1203,7 +1213,10 @@ def save_subtitles(file_path, subtitles, single=False, directory=None, chmod=Non
continue continue
# create subtitle path # create subtitle path
if subtitle.text and bool(re.search(HI_REGEX, subtitle.text)): if subtitle.text and parse_for_hi_regex(subtitle_text=subtitle.text,
alpha3_language=subtitle.language.alpha3 if
(hasattr(subtitle, 'language') and hasattr(subtitle.language, 'alpha3'))
else None):
subtitle.language.hi = True subtitle.language.hi = True
subtitle_path = get_subtitle_path(file_path, None if single else subtitle.language, subtitle_path = get_subtitle_path(file_path, None if single else subtitle.language,
forced_tag=subtitle.language.forced, forced_tag=subtitle.language.forced,

@ -141,7 +141,8 @@ class AnimeToshoProvider(Provider, ProviderSubtitleArchiveMixin):
for subtitle_file in subtitle_files: for subtitle_file in subtitle_files:
hex_id = format(subtitle_file['id'], '08x') hex_id = format(subtitle_file['id'], '08x')
lang = Language.fromalpha3b(subtitle_file['info']['lang']) # Animetosho assumes missing languages as english as fallback when not specified.
lang = Language.fromalpha3b(subtitle_file['info'].get('lang', 'eng'))
# For Portuguese and Portuguese Brazilian they both share the same code, the name is the only # For Portuguese and Portuguese Brazilian they both share the same code, the name is the only
# identifier AnimeTosho provides. Also, some subtitles does not have name, in this case it could # identifier AnimeTosho provides. Also, some subtitles does not have name, in this case it could

@ -5,7 +5,7 @@ from random import randint
import pycountry import pycountry
from requests.cookies import RequestsCookieJar from requests.cookies import RequestsCookieJar
from subliminal.exceptions import AuthenticationError from subliminal.exceptions import AuthenticationError, ProviderError
from subliminal.providers import ParserBeautifulSoup from subliminal.providers import ParserBeautifulSoup
from subliminal_patch.http import RetryingCFSession from subliminal_patch.http import RetryingCFSession
from subliminal_patch.pitcher import store_verification from subliminal_patch.pitcher import store_verification
@ -318,7 +318,7 @@ class AvistazNetworkProviderBase(Provider):
release_name = release['Title'].get_text().strip() release_name = release['Title'].get_text().strip()
lang = lookup_lang(subtitle_cols['Language'].get_text().strip()) lang = lookup_lang(subtitle_cols['Language'].get_text().strip())
download_link = subtitle_cols['Download'].a['href'] download_link = subtitle_cols['Download'].a['href']
uploader_name = subtitle_cols['Uploader'].get_text().strip() uploader_name = subtitle_cols['Uploader'].get_text().strip() if 'Uploader' in subtitle_cols else None
if lang not in languages: if lang not in languages:
continue continue
@ -354,7 +354,10 @@ class AvistazNetworkProviderBase(Provider):
def _parse_release_table(self, html): def _parse_release_table(self, html):
release_data_table = (ParserBeautifulSoup(html, ['html.parser']) release_data_table = (ParserBeautifulSoup(html, ['html.parser'])
.select_one('#content-area > div:nth-child(4) > div.table-responsive > table > tbody')) .select_one('#content-area > div.block > div.table-responsive > table > tbody'))
if release_data_table is None:
raise ProviderError('Unexpected HTML page layout - no release data table found')
rows = {} rows = {}
for tr in release_data_table.find_all('tr', recursive=False): for tr in release_data_table.find_all('tr', recursive=False):

@ -112,7 +112,11 @@ class EmbeddedSubtitlesProvider(Provider):
# Default is True # Default is True
container.FFMPEG_STATS = False container.FFMPEG_STATS = False
tags.LANGUAGE_FALLBACK = self._fallback_lang if self._unknown_as_fallback and self._fallback_lang 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) logger.debug("Language fallback set: %s", tags.LANGUAGE_FALLBACK)
def initialize(self): def initialize(self):
@ -229,6 +233,7 @@ class EmbeddedSubtitlesProvider(Provider):
timeout=self._timeout, timeout=self._timeout,
fallback_to_convert=True, fallback_to_convert=True,
basename_callback=_basename_callback, basename_callback=_basename_callback,
progress_callback=lambda d: logger.debug("Progress: %s", d),
) )
# Add the extracted paths to the containter path key # Add the extracted paths to the containter path key
self._cached_paths[container.path] = extracted self._cached_paths[container.path] = extracted

@ -96,7 +96,12 @@ class HDBitsProvider(Provider):
"https://hdbits.org/api/torrents", json={**self._def_params, **lookup} "https://hdbits.org/api/torrents", json={**self._def_params, **lookup}
) )
response.raise_for_status() response.raise_for_status()
try:
ids = [item["id"] for item in response.json()["data"]] ids = [item["id"] for item in response.json()["data"]]
except KeyError:
logger.debug("No data found")
return []
subtitles = [] subtitles = []
for torrent_id in ids: for torrent_id in ids:

@ -0,0 +1,419 @@
from __future__ import absolute_import
from datetime import timedelta
import logging
import os
import re
import time
from requests import Session
from subliminal import region, __short_version__
from subliminal.cache import REFINER_EXPIRATION_TIME
from subliminal.exceptions import ConfigurationError, AuthenticationError, ServiceUnavailable
from subliminal.utils import sanitize
from subliminal.video import Episode, Movie
from subliminal_patch.providers import Provider
from subliminal_patch.subtitle import Subtitle
from subliminal_patch.exceptions import APIThrottled
from subliminal_patch.providers.utils import get_subtitle_from_archive, get_archive_from_bytes
from urllib.parse import urlencode, urljoin
from guessit import guessit
from subzero.language import Language, FULL_LANGUAGE_LIST
logger = logging.getLogger(__name__)
# Unhandled formats, such files will always get filtered out
unhandled_archive_formats = (".7z",)
accepted_archive_formats = (".zip", ".rar")
class JimakuSubtitle(Subtitle):
'''Jimaku Subtitle.'''
provider_name = 'jimaku'
hash_verifiable = False
def __init__(self, language, video, download_url, filename):
super(JimakuSubtitle, self).__init__(language, page_link=download_url)
self.video = video
self.download_url = download_url
self.filename = filename
self.release_info = filename
self.is_archive = filename.endswith(accepted_archive_formats)
@property
def id(self):
return self.download_url
def get_matches(self, video):
matches = set()
# Episode/Movie specific matches
if isinstance(video, Episode):
if sanitize(video.series) and sanitize(self.video.series) in (
sanitize(name) for name in [video.series] + video.alternative_series):
matches.add('series')
if video.season and self.video.season is None or video.season and video.season == self.video.season:
matches.add('season')
elif isinstance(video, Movie):
if sanitize(video.title) and sanitize(self.video.title) in (
sanitize(name) for name in [video.title] + video.alternative_titles):
matches.add('title')
# General matches
if video.year and video.year == self.video.year:
matches.add('year')
video_type = 'movie' if isinstance(video, Movie) else 'episode'
matches.add(video_type)
guess = guessit(self.filename, {'type': video_type})
for g in guess:
if g[0] == "release_group" or "source":
if video.release_group == g[1]:
matches.add('release_group')
break
# Prioritize .srt by repurposing the audio_codec match
if self.filename.endswith(".srt"):
matches.add('audio_codec')
return matches
class JimakuProvider(Provider):
'''Jimaku Provider.'''
video_types = (Episode, Movie)
api_url = 'https://jimaku.cc/api'
api_ratelimit_max_delay_seconds = 5
api_ratelimit_backoff_limit = 3
corrupted_file_size_threshold = 500
languages = {Language.fromietf("ja")}
def __init__(self, enable_name_search_fallback, enable_archives_download, enable_ai_subs, api_key):
if api_key:
self.api_key = api_key
else:
raise ConfigurationError('Missing api_key.')
self.enable_name_search_fallback = enable_name_search_fallback
self.download_archives = enable_archives_download
self.enable_ai_subs = enable_ai_subs
self.session = None
def initialize(self):
self.session = Session()
self.session.headers['Content-Type'] = 'application/json'
self.session.headers['Authorization'] = self.api_key
self.session.headers['User-Agent'] = os.environ.get("SZ_USER_AGENT")
def terminate(self):
self.session.close()
def _query(self, video):
if isinstance(video, Movie):
media_name = video.title.lower()
elif isinstance(video, Episode):
media_name = video.series.lower()
# With entries that have a season larger than 1, Jimaku appends the corresponding season number to the name.
# We'll reassemble media_name here to account for cases where we can only search by name alone.
season_addendum = str(video.season) if video.season > 1 else None
media_name = f"{media_name} {season_addendum}" if season_addendum else media_name
# Search for entry
searching_for_entry_attempts = 0
additional_url_params = {}
while searching_for_entry_attempts < 2:
searching_for_entry_attempts += 1
url = self._assemble_jimaku_search_url(video, media_name, additional_url_params)
if not url:
return None
searching_for_entry = "query" in url
data = self._search_for_entry(url)
if not data:
if searching_for_entry and searching_for_entry_attempts < 2:
logger.info("Maybe this is live action media? Will retry search without anime parameter...")
additional_url_params = {'anime': "false"}
else:
return None
else:
break
# We only go for the first entry
entry = data[0]
entry_id = entry.get('id')
anilist_id = entry.get('anilist_id', None)
entry_name = entry.get('name')
is_movie = entry.get('flags', {}).get('movie', False)
if isinstance(video, Episode) and is_movie:
logger.warn("Bazarr thinks this is a series, but Jimaku says this is a movie! May not be able to match subtitles...")
logger.info(f"Matched entry: ID: '{entry_id}', anilist_id: '{anilist_id}', name: '{entry_name}', english_name: '{entry.get('english_name')}', movie: {is_movie}")
if entry.get("flags").get("unverified"):
logger.warning(f"This entry '{entry_id}' is unverified, subtitles might be incomplete or have quality issues!")
# Get a list of subtitles for entry
episode_number = video.episode if "episode" in dir(video) else None
url_params = {'episode': episode_number} if isinstance(video, Episode) and not is_movie else {}
only_look_for_archives = False
has_offset = isinstance(video, Episode) and video.series_anidb_season_episode_offset is not None
retry_count = 0
adjusted_ep_num = None
while retry_count <= 1:
# Account for positive episode offset first
if isinstance(video, Episode) and not is_movie and retry_count < 1:
if video.season > 1 and has_offset:
offset_value = video.series_anidb_season_episode_offset
offset_value = offset_value if offset_value > 0 else -offset_value
if episode_number < offset_value:
adjusted_ep_num = episode_number + offset_value
logger.warning(f"Will try using adjusted episode number {adjusted_ep_num} first")
url_params = {'episode': adjusted_ep_num}
url = f"entries/{entry_id}/files"
data = self._search_for_subtitles(url, url_params)
if not data:
if isinstance(video, Episode) and not is_movie and has_offset and retry_count < 1:
logger.warning(f"Found no subtitles for adjusted episode number, but will retry with normal episode number {episode_number}")
url_params = {'episode': episode_number}
elif isinstance(video, Episode) and not is_movie and retry_count < 1:
logger.warning(f"Found no subtitles for episode number {episode_number}, but will retry without 'episode' parameter")
url_params = {}
only_look_for_archives = True
else:
return None
retry_count += 1
else:
if adjusted_ep_num:
video.episode = adjusted_ep_num
logger.debug(f"This videos episode attribute has been updated to: {video.episode}")
break
# Filter subtitles
list_of_subtitles = []
data = [item for item in data if not item['name'].endswith(unhandled_archive_formats)]
# Detect only archives being uploaded
archive_entries = [item for item in data if item['name'].endswith(accepted_archive_formats)]
subtitle_entries = [item for item in data if not item['name'].endswith(accepted_archive_formats)]
has_only_archives = len(archive_entries) > 0 and len(subtitle_entries) == 0
if has_only_archives:
logger.warning("Have only found archived subtitles")
elif only_look_for_archives:
data = [item for item in data if item['name'].endswith(accepted_archive_formats)]
for item in data:
filename = item.get('name')
download_url = item.get('url')
is_archive = filename.endswith(accepted_archive_formats)
# Archives will still be considered if they're the only files available, as is mostly the case for movies.
if is_archive and not has_only_archives and not self.download_archives:
logger.warning(f"Skipping archive '{filename}' because normal subtitles are available instead")
continue
if not self.enable_ai_subs:
p = re.compile(r'[\[\(]?(whisperai)[\]\)]?|[\[\(]whisper[\]\)]', re.IGNORECASE)
if p.search(filename):
logger.warning(f"Skipping subtitle '{filename}' as it's suspected of being AI generated")
continue
sub_languages = self._try_determine_subtitle_languages(filename)
if len(sub_languages) > 1:
logger.warning(f"Skipping subtitle '{filename}' as it's suspected of containing multiple languages")
continue
# Check if file is obviously corrupt. If no size is returned, assume OK
filesize = item.get('size', self.corrupted_file_size_threshold)
if filesize < self.corrupted_file_size_threshold:
logger.warning(f"Skipping possibly corrupt file '{filename}': Filesize is just {filesize} bytes")
continue
if not filename.endswith(unhandled_archive_formats):
lang = sub_languages[0] if len(sub_languages) > 1 else Language("jpn")
list_of_subtitles.append(JimakuSubtitle(lang, video, download_url, filename))
else:
logger.debug(f"Skipping archive '{filename}' as it's not a supported format")
return list_of_subtitles
def list_subtitles(self, video, languages=None):
subtitles = self._query(video)
if not subtitles:
return []
return [s for s in subtitles]
def download_subtitle(self, subtitle: JimakuSubtitle):
target_url = subtitle.download_url
response = self.session.get(target_url, timeout=10)
response.raise_for_status()
if subtitle.is_archive:
archive = get_archive_from_bytes(response.content)
if archive:
if isinstance(subtitle.video, Episode):
subtitle.content = get_subtitle_from_archive(
archive,
episode=subtitle.video.episode,
episode_title=subtitle.video.title
)
else:
subtitle.content = get_subtitle_from_archive(
archive
)
else:
logger.warning("Archive seems to not be an archive! File possibly corrupt?")
return None
else:
subtitle.content = response.content
def _do_jimaku_request(self, url_path, url_params={}):
url = urljoin(f"{self.api_url}/{url_path}", '?' + urlencode(url_params))
retry_count = 0
while retry_count < self.api_ratelimit_backoff_limit:
response = self.session.get(url, timeout=10)
if response.status_code == 429:
reset_time = 5
retry_count + 1
logger.warning(f"Jimaku ratelimit hit, waiting for '{reset_time}' seconds ({retry_count}/{self.api_ratelimit_backoff_limit} tries)")
time.sleep(reset_time)
continue
elif response.status_code == 401:
raise AuthenticationError("Unauthorized. API key possibly invalid")
else:
response.raise_for_status()
data = response.json()
logger.debug(f"Length of response on {url}: {len(data)}")
if len(data) == 0:
logger.error(f"Jimaku returned no items for our our query: {url}")
return None
elif 'error' in data:
raise ServiceUnavailable(f"Jimaku returned an error: '{data.get('error')}', Code: '{data.get('code')}'")
else:
return data
raise APIThrottled(f"Jimaku ratelimit max backoff limit of {self.api_ratelimit_backoff_limit} reached, aborting")
# Wrapper functions to indirectly call _do_jimaku_request with different cache configs
@region.cache_on_arguments(expiration_time=REFINER_EXPIRATION_TIME)
def _search_for_entry(self, url_path, url_params={}):
return self._do_jimaku_request(url_path, url_params)
@region.cache_on_arguments(expiration_time=timedelta(minutes=1).total_seconds())
def _search_for_subtitles(self, url_path, url_params={}):
return self._do_jimaku_request(url_path, url_params)
@staticmethod
def _try_determine_subtitle_languages(filename):
# This is more like a guess and not a 100% fool-proof way of detecting multi-lang subs:
# It assumes that language codes, if present, are in the last metadata group of the subs filename.
# If such codes are not present, or we failed to match any at all, then we'll just assume that the sub is purely Japanese.
default_language = Language("jpn")
dot_delimit = filename.split(".")
bracket_delimit = re.split(r'[\[\]\(\)]+', filename)
candidate_list = list()
if len(dot_delimit) > 2:
candidate_list = dot_delimit[-2]
elif len(bracket_delimit) > 2:
candidate_list = bracket_delimit[-2]
candidates = [] if len(candidate_list) == 0 else re.split(r'[,\-\+\& ]+', candidate_list)
# Discard match group if any candidate...
# ...contains any numbers, as the group is likely encoding information
if any(re.compile(r'\d').search(string) for string in candidates):
return [default_language]
# ...is >= 5 chars long, as the group is likely other unrelated metadata
if any(len(string) >= 5 for string in candidates):
return [default_language]
languages = list()
for candidate in candidates:
candidate = candidate.lower()
if candidate in ["ass", "srt"]:
continue
# Sometimes, languages are hidden in 4 character blocks, i.e. "JPSC"
if len(candidate) == 4:
for addendum in [candidate[:2], candidate[2:]]:
candidates.append(addendum)
continue
# Sometimes, language codes can have additional info such as 'cc' or 'sdh'. For example: "ja[cc]"
if len(dot_delimit) > 2 and any(c in candidate for c in '[]()'):
candidate = re.split(r'[\[\]\(\)]+', candidate)[0]
try:
language_squash = {
"jp": "ja",
"jap": "ja",
"chs": "zho",
"cht": "zho",
"zhi": "zho",
"cn": "zho"
}
candidate = language_squash[candidate] if candidate in language_squash else candidate
if len(candidate) > 2:
language = Language(candidate)
else:
language = Language.fromietf(candidate)
if not any(l.alpha3 == language.alpha3 for l in languages):
languages.append(language)
except:
if candidate in FULL_LANGUAGE_LIST:
# Create a dummy for the unknown language
languages.append(Language("zul"))
if len(languages) > 1:
# Sometimes a metadata group that actually contains info about codecs gets processed as valid languages.
# To prevent false positives, we'll check if Japanese language codes are in the processed languages list.
# If not, then it's likely that we didn't actually match language codes -> Assume Japanese only subtitle.
contains_jpn = any([l for l in languages if l.alpha3 == "jpn"])
return languages if contains_jpn else [Language("jpn")]
else:
return [default_language]
def _assemble_jimaku_search_url(self, video, media_name, additional_params={}):
endpoint = "entries/search"
anilist_id = video.anilist_id
params = {}
if anilist_id:
params = {'anilist_id': anilist_id}
else:
if self.enable_name_search_fallback or isinstance(video, Movie):
params = {'query': media_name}
else:
logger.error(f"Skipping '{media_name}': Got no AniList ID and fuzzy matching using name is disabled")
return None
if additional_params:
params.update(additional_params)
logger.info(f"Will search for entry based on params: {params}")
return urljoin(endpoint, '?' + urlencode(params))

@ -0,0 +1,264 @@
# -*- coding: utf-8 -*-
import logging
import os
import time
import io
import json
from zipfile import ZipFile, is_zipfile
from urllib.parse import urljoin
from requests import Session
from subzero.language import Language
from subliminal import Episode, Movie
from subliminal.exceptions import ConfigurationError, ProviderError, DownloadLimitExceeded
from subliminal_patch.exceptions import APIThrottled
from .mixins import ProviderRetryMixin
from subliminal_patch.subtitle import Subtitle
from subliminal.subtitle import fix_line_ending
from subliminal_patch.providers import Provider
from subliminal_patch.providers import utils
logger = logging.getLogger(__name__)
retry_amount = 3
retry_timeout = 5
class LegendasNetSubtitle(Subtitle):
provider_name = 'legendasnet'
hash_verifiable = False
def __init__(self, language, forced, page_link, download_link, file_id, release_names, uploader,
season=None, episode=None):
super().__init__(language)
language = Language.rebuild(language, forced=forced)
self.season = season
self.episode = episode
self.releases = release_names
self.release_info = ', '.join(release_names)
self.language = language
self.forced = forced
self.file_id = file_id
self.page_link = page_link
self.download_link = download_link
self.uploader = uploader
self.matches = None
@property
def id(self):
return self.file_id
def get_matches(self, video):
matches = set()
# handle movies and series separately
if isinstance(video, Episode):
# series
matches.add('series')
# season
if video.season == self.season:
matches.add('season')
# episode
if video.episode == self.episode:
matches.add('episode')
# imdb
matches.add('series_imdb_id')
else:
# title
matches.add('title')
# imdb
matches.add('imdb_id')
utils.update_matches(matches, video, self.release_info)
self.matches = matches
return matches
class LegendasNetProvider(ProviderRetryMixin, Provider):
"""Legendas.Net Provider"""
server_hostname = 'legendas.net/api'
languages = {Language('por', 'BR')}
video_types = (Episode, Movie)
def __init__(self, username, password):
self.session = Session()
self.session.headers = {'User-Agent': os.environ.get("SZ_USER_AGENT", "Sub-Zero/2")}
self.username = username
self.password = password
self.access_token = None
self.video = None
self._started = None
self.login()
def login(self):
headersList = {
"Accept": "*/*",
"User-Agent": self.session.headers['User-Agent'],
"Content-Type": "application/json"
}
payload = json.dumps({
"email": self.username,
"password": self.password
})
response = self.session.request("POST", self.server_url() + 'login', data=payload, headers=headersList)
if response.status_code != 200:
raise ConfigurationError('Failed to login and retrieve access token')
self.access_token = response.json().get('access_token')
if not self.access_token:
raise ConfigurationError('Access token not found in login response')
self.session.headers.update({'Authorization': f'Bearer {self.access_token}'})
def initialize(self):
self._started = time.time()
def terminate(self):
self.session.close()
def server_url(self):
return f'https://{self.server_hostname}/v1/'
def query(self, languages, video):
self.video = video
# query the server
if isinstance(self.video, Episode):
res = self.retry(
lambda: self.session.get(self.server_url() + 'search/tv',
json={
'name': video.series,
'page': 1,
'per_page': 25,
'tv_episode': video.episode,
'tv_season': video.season,
'imdb_id': video.series_imdb_id
},
headers={'Content-Type': 'application/json'},
timeout=30),
amount=retry_amount,
retry_timeout=retry_timeout
)
else:
res = self.retry(
lambda: self.session.get(self.server_url() + 'search/movie',
json={
'name': video.title,
'page': 1,
'per_page': 25,
'imdb_id': video.imdb_id
},
headers={'Content-Type': 'application/json'},
timeout=30),
amount=retry_amount,
retry_timeout=retry_timeout
)
if res.status_code == 404:
logger.error(f"Endpoint not found: {res.url}")
raise ProviderError("Endpoint not found")
elif res.status_code == 429:
raise APIThrottled("Too many requests")
elif res.status_code == 403:
raise ConfigurationError("Invalid access token")
elif res.status_code != 200:
res.raise_for_status()
subtitles = []
result = res.json()
if ('success' in result and not result['success']) or ('status' in result and not result['status']):
logger.debug(result["error"])
return []
if isinstance(self.video, Episode):
if len(result['tv_shows']):
for item in result['tv_shows']:
subtitle = LegendasNetSubtitle(
language=Language('por', 'BR'),
forced=self._is_forced(item),
page_link=f"https://legendas.net/tv_legenda?movie_id={result['tv_shows'][0]['tmdb_id']}&"
f"legenda_id={item['id']}",
download_link=item['path'],
file_id=item['id'],
release_names=[item.get('release_name', '')],
uploader=item['uploader'],
season=item.get('season', ''),
episode=item.get('episode', '')
)
subtitle.get_matches(self.video)
if subtitle.language in languages:
subtitles.append(subtitle)
else:
if len(result['movies']):
for item in result['movies']:
subtitle = LegendasNetSubtitle(
language=Language('por', 'BR'),
forced=self._is_forced(item),
page_link=f"https://legendas.net/legenda?movie_id={result['movies'][0]['tmdb_id']}&"
f"legenda_id={item['id']}",
download_link=item['path'],
file_id=item['id'],
release_names=[item.get('release_name', '')],
uploader=item['uploader'],
season=None,
episode=None
)
subtitle.get_matches(self.video)
if subtitle.language in languages:
subtitles.append(subtitle)
return subtitles
@staticmethod
def _is_forced(item):
forced_tags = ['forced', 'foreign']
for tag in forced_tags:
if tag in item.get('comment', '').lower():
return True
# nothing match so we consider it as normal subtitles
return False
def list_subtitles(self, video, languages):
return self.query(languages, video)
def download_subtitle(self, subtitle):
logger.debug('Downloading subtitle %r', subtitle)
download_link = urljoin("https://legendas.net", subtitle.download_link)
r = self.retry(
lambda: self.session.get(download_link, timeout=30),
amount=retry_amount,
retry_timeout=retry_timeout
)
if r.status_code == 429:
raise DownloadLimitExceeded("Daily download limit exceeded")
elif r.status_code == 403:
raise ConfigurationError("Invalid access token")
elif r.status_code != 200:
r.raise_for_status()
if not r:
logger.error(f'Could not download subtitle from {download_link}')
subtitle.content = None
return
else:
archive_stream = io.BytesIO(r.content)
if is_zipfile(archive_stream):
archive = ZipFile(archive_stream)
for name in archive.namelist():
subtitle_content = archive.read(name)
subtitle.content = fix_line_ending(subtitle_content)
return
else:
subtitle_content = r.content
subtitle.content = fix_line_ending(subtitle_content)
return

@ -209,7 +209,8 @@ class PodnapisiProvider(_PodnapisiProvider, ProviderSubtitleArchiveMixin):
break break
# exit if no results # exit if no results
if not xml.find('pagination/results') or not int(xml.find('pagination/results').text): if (not xml.find('pagination/results') or not xml.find('pagination/results').text or not
int(xml.find('pagination/results').text)):
logger.debug('No subtitles found') logger.debug('No subtitles found')
break break

@ -277,8 +277,12 @@ class SoustitreseuProvider(Provider, ProviderSubtitleArchiveMixin):
release = name[:-4].lower().rstrip('tag').rstrip('en').rstrip('fr') release = name[:-4].lower().rstrip('tag').rstrip('en').rstrip('fr')
_guess = guessit(release) _guess = guessit(release)
if isinstance(video, Episode): if isinstance(video, Episode):
try:
if video.episode != _guess['episode'] or video.season != _guess['season']: if video.episode != _guess['episode'] or video.season != _guess['season']:
continue continue
except KeyError:
# episode or season are missing from guessit result
continue
matches = set() matches = set()
matches |= guess_matches(video, _guess) matches |= guess_matches(video, _guess)

@ -172,7 +172,7 @@ class SubdivxSubtitlesProvider(Provider):
logger.debug("Query: %s", query) logger.debug("Query: %s", query)
response = self.session.post(search_link, data=payload) response = self.session.post(search_link, data=payload, timeout=30)
if response.status_code == 500: if response.status_code == 500:
logger.debug( logger.debug(

@ -17,8 +17,7 @@ from .mixins import ProviderRetryMixin
from subliminal_patch.subtitle import Subtitle from subliminal_patch.subtitle import Subtitle
from subliminal.subtitle import fix_line_ending from subliminal.subtitle import fix_line_ending
from subliminal_patch.providers import Provider from subliminal_patch.providers import Provider
from subliminal_patch.subtitle import guess_matches from subliminal_patch.providers import utils
from guessit import guessit
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -27,8 +26,6 @@ retry_timeout = 5
language_converters.register('subdl = subliminal_patch.converters.subdl:SubdlConverter') language_converters.register('subdl = subliminal_patch.converters.subdl:SubdlConverter')
supported_languages = list(language_converters['subdl'].to_subdl.keys())
class SubdlSubtitle(Subtitle): class SubdlSubtitle(Subtitle):
provider_name = 'subdl' provider_name = 'subdl'
@ -59,7 +56,6 @@ class SubdlSubtitle(Subtitle):
def get_matches(self, video): def get_matches(self, video):
matches = set() matches = set()
type_ = "movie" if isinstance(video, Movie) else "episode"
# handle movies and series separately # handle movies and series separately
if isinstance(video, Episode): if isinstance(video, Episode):
@ -79,8 +75,7 @@ class SubdlSubtitle(Subtitle):
# imdb # imdb
matches.add('imdb_id') matches.add('imdb_id')
# other properties utils.update_matches(matches, video, self.release_info)
matches |= guess_matches(video, guessit(self.release_info, {"type": type_}))
self.matches = matches self.matches = matches
@ -91,7 +86,7 @@ class SubdlProvider(ProviderRetryMixin, Provider):
"""Subdl Provider""" """Subdl Provider"""
server_hostname = 'api.subdl.com' server_hostname = 'api.subdl.com'
languages = {Language(*lang) for lang in supported_languages} languages = {Language(*lang) for lang in list(language_converters['subdl'].to_subdl.keys())}
languages.update(set(Language.rebuild(lang, forced=True) for lang in languages)) languages.update(set(Language.rebuild(lang, forced=True) for lang in languages))
languages.update(set(Language.rebuild(l, hi=True) for l in languages)) languages.update(set(Language.rebuild(l, hi=True) for l in languages))
@ -130,7 +125,8 @@ class SubdlProvider(ProviderRetryMixin, Provider):
imdb_id = self.video.imdb_id imdb_id = self.video.imdb_id
# be sure to remove duplicates using list(set()) # be sure to remove duplicates using list(set())
langs_list = sorted(list(set([lang.basename.upper() for lang in languages]))) langs_list = sorted(list(set([language_converters['subdl'].convert(lang.alpha3, lang.country, lang.script) for
lang in languages])))
langs = ','.join(langs_list) langs = ','.join(langs_list)
logger.debug(f'Searching for those languages: {langs}') logger.debug(f'Searching for those languages: {langs}')
@ -148,7 +144,9 @@ class SubdlProvider(ProviderRetryMixin, Provider):
('subs_per_page', 30), ('subs_per_page', 30),
('type', 'tv'), ('type', 'tv'),
('comment', 1), ('comment', 1),
('releases', 1)), ('releases', 1),
('bazarr', 1)), # this argument filter incompatible image based or
# txt subtitles
timeout=30), timeout=30),
amount=retry_amount, amount=retry_amount,
retry_timeout=retry_timeout retry_timeout=retry_timeout
@ -163,7 +161,9 @@ class SubdlProvider(ProviderRetryMixin, Provider):
('subs_per_page', 30), ('subs_per_page', 30),
('type', 'movie'), ('type', 'movie'),
('comment', 1), ('comment', 1),
('releases', 1)), ('releases', 1),
('bazarr', 1)), # this argument filter incompatible image based or
# txt subtitles
timeout=30), timeout=30),
amount=retry_amount, amount=retry_amount,
retry_timeout=retry_timeout retry_timeout=retry_timeout
@ -181,7 +181,8 @@ class SubdlProvider(ProviderRetryMixin, Provider):
result = res.json() result = res.json()
if ('success' in result and not result['success']) or ('status' in result and not result['status']): if ('success' in result and not result['success']) or ('status' in result and not result['status']):
raise ProviderError(result['error']) logger.debug(result["error"])
return []
logger.debug(f"Query returned {len(result['subtitles'])} subtitles") logger.debug(f"Query returned {len(result['subtitles'])} subtitles")

@ -132,9 +132,9 @@ _DEFAULT_HEADERS = {
class Subf2mProvider(Provider): class Subf2mProvider(Provider):
provider_name = "subf2m" provider_name = "subf2m"
_movie_title_regex = re.compile(r"^(.+?)( \((\d{4})\))?$") _movie_title_regex = re.compile(r"^(.+?)(\s+\((\d{4})\))?$")
_tv_show_title_regex = re.compile( _tv_show_title_regex = re.compile(
r"^(.+?) [-\(]\s?(.*?) (season|series)\)?( \((\d{4})\))?$" r"^(.+?)\s+[-\(]\s?(.*?)\s+(season|series)\)?(\s+\((\d{4})\))?$"
) )
_tv_show_title_alt_regex = re.compile(r"(.+)\s(\d{1,2})(?:\s|$)") _tv_show_title_alt_regex = re.compile(r"(.+)\s(\d{1,2})(?:\s|$)")
_supported_languages = {} _supported_languages = {}
@ -220,7 +220,7 @@ class Subf2mProvider(Provider):
results = [] results = []
for result in self._gen_results(title): for result in self._gen_results(title):
text = result.text.lower() text = result.text.strip().lower()
match = self._movie_title_regex.match(text) match = self._movie_title_regex.match(text)
if not match: if not match:
continue continue
@ -254,7 +254,7 @@ class Subf2mProvider(Provider):
results = [] results = []
for result in self._gen_results(title): for result in self._gen_results(title):
text = result.text.lower() text = result.text.strip().lower()
match = self._tv_show_title_regex.match(text) match = self._tv_show_title_regex.match(text)
if not match: if not match:

@ -455,7 +455,13 @@ class SuperSubtitlesProvider(Provider, ProviderSubtitleArchiveMixin):
soup = ParserBeautifulSoup(r, ['lxml']) soup = ParserBeautifulSoup(r, ['lxml'])
tables = soup.find_all("table") tables = soup.find_all("table")
try:
tables = tables[0].find_all("tr") tables = tables[0].find_all("tr")
except IndexError:
logger.debug("No tables found for %s", url)
return []
i = 0 i = 0
for table in tables: for table in tables:

@ -65,7 +65,7 @@ def _get_matching_sub(
guess = guessit(sub_name, options=guess_options) guess = guessit(sub_name, options=guess_options)
matched_episode_num = guess.get("episode") matched_episode_num = guess.get("episode")
if matched_episode_num: if not matched_episode_num:
logger.debug("No episode number found in file: %s", sub_name) logger.debug("No episode number found in file: %s", sub_name)
if episode_title is not None: if episode_title is not None:
@ -86,11 +86,13 @@ def _get_matching_sub(
return None return None
def _analize_sub_name(sub_name: str, title_): def _analize_sub_name(sub_name: str, title_: str):
titles = re.split(r"[.-]", os.path.splitext(sub_name)[0]) titles = re.split(r"[\s_\.\+]?[.-][\s_\.\+]?", os.path.splitext(sub_name)[0])
for title in titles: for title in titles:
title = title.strip() title = title.strip()
ratio = SequenceMatcher(None, title, title_).ratio() ratio = SequenceMatcher(None, title.lower(), title_.lower()).ratio()
if ratio > 0.85: if ratio > 0.85:
logger.debug( logger.debug(
"Episode title matched: '%s' -> '%s' [%s]", title, sub_name, ratio "Episode title matched: '%s' -> '%s' [%s]", title, sub_name, ratio

@ -143,7 +143,7 @@ def encode_audio_stream(path, ffmpeg_path, audio_stream_language=None):
logger.debug(f"Whisper will only use the {audio_stream_language} audio stream for {path}") logger.debug(f"Whisper will only use the {audio_stream_language} audio stream for {path}")
inp = inp[f'a:m:language:{audio_stream_language}'] inp = inp[f'a:m:language:{audio_stream_language}']
out, _ = inp.output("-", format="s16le", acodec="pcm_s16le", ac=1, ar=16000) \ out, _ = inp.output("-", format="s16le", acodec="pcm_s16le", ac=1, ar=16000, af="aresample=async=1") \
.run(cmd=[ffmpeg_path, "-nostdin"], capture_stdout=True, capture_stderr=True) .run(cmd=[ffmpeg_path, "-nostdin"], capture_stdout=True, capture_stderr=True)
except ffmpeg.Error as e: except ffmpeg.Error as e:

@ -316,7 +316,7 @@ class ZimukuProvider(Provider):
r = self.yunsuo_bypass(download_link, headers={'Referer': subtitle.page_link}, timeout=30) r = self.yunsuo_bypass(download_link, headers={'Referer': subtitle.page_link}, timeout=30)
r.raise_for_status() r.raise_for_status()
try: try:
filename = r.headers["Content-Disposition"] filename = r.headers["Content-Disposition"].lower()
except KeyError: except KeyError:
logger.debug("Unable to parse subtitles filename. Dropping this subtitles.") logger.debug("Unable to parse subtitles filename. Dropping this subtitles.")
return return

@ -12,6 +12,7 @@ import chardet
import pysrt import pysrt
import pysubs2 import pysubs2
from bs4 import UnicodeDammit from bs4 import UnicodeDammit
from copy import deepcopy
from pysubs2 import SSAStyle from pysubs2 import SSAStyle
from pysubs2.formats.subrip import parse_tags, MAX_REPRESENTABLE_TIME from pysubs2.formats.subrip import parse_tags, MAX_REPRESENTABLE_TIME
from pysubs2.time import ms_to_times from pysubs2.time import ms_to_times
@ -65,6 +66,11 @@ class Subtitle(Subtitle_):
# format = "srt" # default format is srt # format = "srt" # default format is srt
def __init__(self, language, hearing_impaired=False, page_link=None, encoding=None, mods=None, original_format=False): def __init__(self, language, hearing_impaired=False, page_link=None, encoding=None, mods=None, original_format=False):
# language needs to be cloned because it is actually a reference to the provider language object
# if a new copy is not created then all subsequent subtitles for this provider will incorrectly be modified
# at least until Bazarr is restarted or the provider language object is recreated somehow
language = deepcopy(language)
# set subtitle language to hi if it's hearing_impaired # set subtitle language to hi if it's hearing_impaired
if hearing_impaired: if hearing_impaired:
language = Language.rebuild(language, hi=True) language = Language.rebuild(language, hi=True)
@ -275,7 +281,7 @@ class Subtitle(Subtitle_):
return encoding return encoding
def is_valid(self): def is_valid(self):
"""Check if a :attr:`text` is a valid SubRip format. Note that orignal format will pypass the checking """Check if a :attr:`text` is a valid SubRip format. Note that original format will bypass the checking
:return: whether or not the subtitle is valid. :return: whether or not the subtitle is valid.
:rtype: bool :rtype: bool

@ -35,6 +35,8 @@ class Video(Video_):
info_url=None, info_url=None,
series_anidb_id=None, series_anidb_id=None,
series_anidb_episode_id=None, series_anidb_episode_id=None,
series_anidb_season_episode_offset=None,
anilist_id=None,
**kwargs **kwargs
): ):
super(Video, self).__init__( super(Video, self).__init__(
@ -61,3 +63,5 @@ class Video(Video_):
self.info_url = info_url self.info_url = info_url
self.series_anidb_series_id = series_anidb_id, self.series_anidb_series_id = series_anidb_id,
self.series_anidb_episode_id = series_anidb_episode_id, self.series_anidb_episode_id = series_anidb_episode_id,
self.series_anidb_season_episode_offset = series_anidb_season_episode_offset,
self.anilist_id = anilist_id,

@ -162,14 +162,4 @@ class Language(Language_):
return Language(*Language_.fromalpha3b(s).__getstate__()) return Language(*Language_.fromalpha3b(s).__getstate__())
IETF_MATCH = ".+\.([^-.]+)(?:-[A-Za-z]+)?$" ENDSWITH_LANGUAGECODE_RE = re.compile(r"\.([^-.]{2,3})(?:-[A-Za-z]{2,})?$")
ENDSWITH_LANGUAGECODE_RE = re.compile("\.([^-.]{2,3})(?:-[A-Za-z]{2,})?$")
def match_ietf_language(s, ietf=False):
language_match = re.match(".+\.([^\.]+)$" if not ietf
else IETF_MATCH, s)
if language_match and len(language_match.groups()) == 1:
language = language_match.groups()[0]
return language
return s

@ -15,12 +15,11 @@
"@typescript-eslint/no-unused-vars": "warn" "@typescript-eslint/no-unused-vars": "warn"
}, },
"extends": [ "extends": [
"react-app",
"plugin:react-hooks/recommended",
"eslint:recommended", "eslint:recommended",
"plugin:react-hooks/recommended",
"plugin:@typescript-eslint/recommended" "plugin:@typescript-eslint/recommended"
], ],
"plugins": ["testing-library", "simple-import-sort"], "plugins": ["testing-library", "simple-import-sort", "react-refresh"],
"overrides": [ "overrides": [
{ {
"files": [ "files": [
@ -63,6 +62,7 @@
} }
} }
], ],
"parser": "@typescript-eslint/parser",
"parserOptions": { "parserOptions": {
"sourceType": "module", "sourceType": "module",
"ecmaVersion": "latest" "ecmaVersion": "latest"

File diff suppressed because it is too large Load Diff

@ -13,12 +13,12 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@mantine/core": "^7.11.0", "@mantine/core": "^7.12.2",
"@mantine/dropzone": "^7.11.0", "@mantine/dropzone": "^7.12.2",
"@mantine/form": "^7.11.0", "@mantine/form": "^7.12.2",
"@mantine/hooks": "^7.11.0", "@mantine/hooks": "^7.12.2",
"@mantine/modals": "^7.11.0", "@mantine/modals": "^7.12.2",
"@mantine/notifications": "^7.11.0", "@mantine/notifications": "^7.12.2",
"@tanstack/react-query": "^5.40.1", "@tanstack/react-query": "^5.40.1",
"@tanstack/react-table": "^8.19.2", "@tanstack/react-table": "^8.19.2",
"axios": "^1.6.8", "axios": "^1.6.8",
@ -30,10 +30,10 @@
}, },
"devDependencies": { "devDependencies": {
"@fontsource/roboto": "^5.0.12", "@fontsource/roboto": "^5.0.12",
"@fortawesome/fontawesome-svg-core": "^6.5.2", "@fortawesome/fontawesome-svg-core": "^6.6.0",
"@fortawesome/free-brands-svg-icons": "^6.5.2", "@fortawesome/free-brands-svg-icons": "^6.6.0",
"@fortawesome/free-regular-svg-icons": "^6.5.2", "@fortawesome/free-regular-svg-icons": "^6.6.0",
"@fortawesome/free-solid-svg-icons": "^6.5.2", "@fortawesome/free-solid-svg-icons": "^6.6.0",
"@fortawesome/react-fontawesome": "^0.2.2", "@fortawesome/react-fontawesome": "^0.2.2",
"@tanstack/react-query-devtools": "^5.40.1", "@tanstack/react-query-devtools": "^5.40.1",
"@testing-library/jest-dom": "^6.4.2", "@testing-library/jest-dom": "^6.4.2",
@ -42,16 +42,18 @@
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",
"@types/lodash": "^4.17.1", "@types/lodash": "^4.17.1",
"@types/node": "^20.12.6", "@types/node": "^20.12.6",
"@types/react": "^18.3.3", "@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.16.0",
"@typescript-eslint/parser": "^7.16.0",
"@vite-pwa/assets-generator": "^0.2.4", "@vite-pwa/assets-generator": "^0.2.4",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"@vitest/coverage-v8": "^1.4.0", "@vitest/coverage-v8": "^1.4.0",
"@vitest/ui": "^1.2.2", "@vitest/ui": "^1.2.2",
"clsx": "^2.1.0", "clsx": "^2.1.0",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-config-react-app": "^7.0.1",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.7",
"eslint-plugin-simple-import-sort": "^12.1.0", "eslint-plugin-simple-import-sort": "^12.1.0",
"eslint-plugin-testing-library": "^6.2.0", "eslint-plugin-testing-library": "^6.2.0",
"husky": "^9.0.11", "husky": "^9.0.11",
@ -62,7 +64,7 @@
"prettier": "^3.2.5", "prettier": "^3.2.5",
"prettier-plugin-organize-imports": "^3.2.4", "prettier-plugin-organize-imports": "^3.2.4",
"pretty-quick": "^4.0.0", "pretty-quick": "^4.0.0",
"recharts": "^2.12.6", "recharts": "^2.12.7",
"sass": "^1.74.1", "sass": "^1.74.1",
"typescript": "^5.4.4", "typescript": "^5.4.4",
"vite": "^5.2.8", "vite": "^5.2.8",

@ -270,6 +270,7 @@ function useRoutes(): CustomRouteObject[] {
{ {
path: "status", path: "status",
name: "Status", name: "Status",
badge: data?.status,
element: ( element: (
<Lazy> <Lazy>
<SystemStatusView></SystemStatusView> <SystemStatusView></SystemStatusView>
@ -309,6 +310,7 @@ function useRoutes(): CustomRouteObject[] {
data?.sonarr_signalr, data?.sonarr_signalr,
data?.radarr_signalr, data?.radarr_signalr,
data?.announcements, data?.announcements,
data?.status,
radarr, radarr,
sonarr, sonarr,
], ],

@ -25,23 +25,6 @@ const cacheEpisodes = (client: QueryClient, episodes: Item.Episode[]) => {
}); });
}; };
export function useEpisodesByIds(ids: number[]) {
const client = useQueryClient();
const query = useQuery({
queryKey: [QueryKeys.Series, QueryKeys.Episodes, ids],
queryFn: () => api.episodes.byEpisodeId(ids),
});
useEffect(() => {
if (query.isSuccess && query.data) {
cacheEpisodes(client, query.data);
}
}, [query.isSuccess, query.data, client]);
return query;
}
export function useEpisodesBySeriesId(id: number) { export function useEpisodesBySeriesId(id: number) {
const client = useQueryClient(); const client = useQueryClient();
@ -87,10 +70,11 @@ export function useEpisodeAddBlacklist() {
}, },
onSuccess: (_, { seriesId }) => { onSuccess: (_, { seriesId }) => {
client.invalidateQueries({ void client.invalidateQueries({
queryKey: [QueryKeys.Series, QueryKeys.Episodes, QueryKeys.Blacklist], queryKey: [QueryKeys.Series, QueryKeys.Episodes, QueryKeys.Blacklist],
}); });
client.invalidateQueries({
void client.invalidateQueries({
queryKey: [QueryKeys.Series, seriesId], queryKey: [QueryKeys.Series, seriesId],
}); });
}, },
@ -105,8 +89,8 @@ export function useEpisodeDeleteBlacklist() {
mutationFn: (param: { all?: boolean; form?: FormType.DeleteBlacklist }) => mutationFn: (param: { all?: boolean; form?: FormType.DeleteBlacklist }) =>
api.episodes.deleteBlacklist(param.all, param.form), api.episodes.deleteBlacklist(param.all, param.form),
onSuccess: (_) => { onSuccess: () => {
client.invalidateQueries({ void client.invalidateQueries({
queryKey: [QueryKeys.Series, QueryKeys.Episodes, QueryKeys.Blacklist], queryKey: [QueryKeys.Series, QueryKeys.Episodes, QueryKeys.Blacklist],
}); });
}, },

@ -15,23 +15,6 @@ const cacheMovies = (client: QueryClient, movies: Item.Movie[]) => {
}); });
}; };
export function useMoviesByIds(ids: number[]) {
const client = useQueryClient();
const query = useQuery({
queryKey: [QueryKeys.Movies, ...ids],
queryFn: () => api.movies.movies(ids),
});
useEffect(() => {
if (query.isSuccess && query.data) {
cacheMovies(client, query.data);
}
}, [query.isSuccess, query.data, client]);
return query;
}
export function useMovieById(id: number) { export function useMovieById(id: number) {
return useQuery({ return useQuery({
queryKey: [QueryKeys.Movies, id], queryKey: [QueryKeys.Movies, id],
@ -74,12 +57,13 @@ export function useMovieModification() {
onSuccess: (_, form) => { onSuccess: (_, form) => {
form.id.forEach((v) => { form.id.forEach((v) => {
client.invalidateQueries({ void client.invalidateQueries({
queryKey: [QueryKeys.Movies, v], queryKey: [QueryKeys.Movies, v],
}); });
}); });
// TODO: query less // TODO: query less
client.invalidateQueries({ void client.invalidateQueries({
queryKey: [QueryKeys.Movies], queryKey: [QueryKeys.Movies],
}); });
}, },
@ -93,7 +77,7 @@ export function useMovieAction() {
mutationFn: (form: FormType.MoviesAction) => api.movies.action(form), mutationFn: (form: FormType.MoviesAction) => api.movies.action(form),
onSuccess: () => { onSuccess: () => {
client.invalidateQueries({ void client.invalidateQueries({
queryKey: [QueryKeys.Movies], queryKey: [QueryKeys.Movies],
}); });
}, },
@ -125,10 +109,11 @@ export function useMovieAddBlacklist() {
}, },
onSuccess: (_, { id }) => { onSuccess: (_, { id }) => {
client.invalidateQueries({ void client.invalidateQueries({
queryKey: [QueryKeys.Movies, QueryKeys.Blacklist], queryKey: [QueryKeys.Movies, QueryKeys.Blacklist],
}); });
client.invalidateQueries({
void client.invalidateQueries({
queryKey: [QueryKeys.Movies, id], queryKey: [QueryKeys.Movies, id],
}); });
}, },
@ -143,8 +128,8 @@ export function useMovieDeleteBlacklist() {
mutationFn: (param: { all?: boolean; form?: FormType.DeleteBlacklist }) => mutationFn: (param: { all?: boolean; form?: FormType.DeleteBlacklist }) =>
api.movies.deleteBlacklist(param.all, param.form), api.movies.deleteBlacklist(param.all, param.form),
onSuccess: (_, param) => { onSuccess: () => {
client.invalidateQueries({ void client.invalidateQueries({
queryKey: [QueryKeys.Movies, QueryKeys.Blacklist], queryKey: [QueryKeys.Movies, QueryKeys.Blacklist],
}); });
}, },

@ -54,22 +54,27 @@ export function useSettingsMutation() {
mutationFn: (data: LooseObject) => api.system.updateSettings(data), mutationFn: (data: LooseObject) => api.system.updateSettings(data),
onSuccess: () => { onSuccess: () => {
client.invalidateQueries({ void client.invalidateQueries({
queryKey: [QueryKeys.System], queryKey: [QueryKeys.System],
}); });
client.invalidateQueries({
void client.invalidateQueries({
queryKey: [QueryKeys.Series], queryKey: [QueryKeys.Series],
}); });
client.invalidateQueries({
void client.invalidateQueries({
queryKey: [QueryKeys.Episodes], queryKey: [QueryKeys.Episodes],
}); });
client.invalidateQueries({
void client.invalidateQueries({
queryKey: [QueryKeys.Movies], queryKey: [QueryKeys.Movies],
}); });
client.invalidateQueries({
void client.invalidateQueries({
queryKey: [QueryKeys.Wanted], queryKey: [QueryKeys.Wanted],
}); });
client.invalidateQueries({
void client.invalidateQueries({
queryKey: [QueryKeys.Badges], queryKey: [QueryKeys.Badges],
}); });
}, },
@ -101,7 +106,7 @@ export function useDeleteLogs() {
mutationFn: () => api.system.deleteLogs(), mutationFn: () => api.system.deleteLogs(),
onSuccess: () => { onSuccess: () => {
client.invalidateQueries({ void client.invalidateQueries({
queryKey: [QueryKeys.System, QueryKeys.Logs], queryKey: [QueryKeys.System, QueryKeys.Logs],
}); });
}, },
@ -128,11 +133,12 @@ export function useSystemAnnouncementsAddDismiss() {
return api.system.addAnnouncementsDismiss(hash); return api.system.addAnnouncementsDismiss(hash);
}, },
onSuccess: (_, { hash }) => { onSuccess: () => {
client.invalidateQueries({ void client.invalidateQueries({
queryKey: [QueryKeys.System, QueryKeys.Announcements], queryKey: [QueryKeys.System, QueryKeys.Announcements],
}); });
client.invalidateQueries({
void client.invalidateQueries({
queryKey: [QueryKeys.System, QueryKeys.Badges], queryKey: [QueryKeys.System, QueryKeys.Badges],
}); });
}, },
@ -156,10 +162,11 @@ export function useRunTask() {
mutationFn: (id: string) => api.system.runTask(id), mutationFn: (id: string) => api.system.runTask(id),
onSuccess: () => { onSuccess: () => {
client.invalidateQueries({ void client.invalidateQueries({
queryKey: [QueryKeys.System, QueryKeys.Tasks], queryKey: [QueryKeys.System, QueryKeys.Tasks],
}); });
client.invalidateQueries({
void client.invalidateQueries({
queryKey: [QueryKeys.System, QueryKeys.Backups], queryKey: [QueryKeys.System, QueryKeys.Backups],
}); });
}, },
@ -180,7 +187,7 @@ export function useCreateBackups() {
mutationFn: () => api.system.createBackups(), mutationFn: () => api.system.createBackups(),
onSuccess: () => { onSuccess: () => {
client.invalidateQueries({ void client.invalidateQueries({
queryKey: [QueryKeys.System, QueryKeys.Backups], queryKey: [QueryKeys.System, QueryKeys.Backups],
}); });
}, },
@ -194,7 +201,7 @@ export function useRestoreBackups() {
mutationFn: (filename: string) => api.system.restoreBackups(filename), mutationFn: (filename: string) => api.system.restoreBackups(filename),
onSuccess: () => { onSuccess: () => {
client.invalidateQueries({ void client.invalidateQueries({
queryKey: [QueryKeys.System, QueryKeys.Backups], queryKey: [QueryKeys.System, QueryKeys.Backups],
}); });
}, },
@ -208,7 +215,7 @@ export function useDeleteBackups() {
mutationFn: (filename: string) => api.system.deleteBackups(filename), mutationFn: (filename: string) => api.system.deleteBackups(filename),
onSuccess: () => { onSuccess: () => {
client.invalidateQueries({ void client.invalidateQueries({
queryKey: [QueryKeys.System, QueryKeys.Backups], queryKey: [QueryKeys.System, QueryKeys.Backups],
}); });
}, },

@ -47,4 +47,8 @@
} }
} }
} }
.label {
overflow: visible;
}
} }

@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
import { Autocomplete, ComboboxItem, OptionsFilter, Text } from "@mantine/core"; import { Autocomplete, ComboboxItem, OptionsFilter, Text } from "@mantine/core";
import { faSearch } from "@fortawesome/free-solid-svg-icons"; import { faSearch } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { chain, includes } from "lodash";
import { useServerSearch } from "@/apis/hooks"; import { useServerSearch } from "@/apis/hooks";
import { useDebouncedValue } from "@/utilities"; import { useDebouncedValue } from "@/utilities";
@ -15,23 +16,45 @@ function useSearch(query: string) {
const debouncedQuery = useDebouncedValue(query, 500); const debouncedQuery = useDebouncedValue(query, 500);
const { data } = useServerSearch(debouncedQuery, debouncedQuery.length >= 0); const { data } = useServerSearch(debouncedQuery, debouncedQuery.length >= 0);
const duplicates = chain(data)
.groupBy((item) => `${item.title} (${item.year})`)
.filter((group) => group.length > 1)
.map((group) => `${group[0].title} (${group[0].year})`)
.value();
return useMemo<SearchResultItem[]>( return useMemo<SearchResultItem[]>(
() => () =>
data?.map((v) => { data?.map((v) => {
let link: string; const { link, displayName } = (() => {
const hasDuplicate = includes(duplicates, `${v.title} (${v.year})`);
if (v.sonarrSeriesId) { if (v.sonarrSeriesId) {
link = `/series/${v.sonarrSeriesId}`; return {
} else if (v.radarrId) { link: `/series/${v.sonarrSeriesId}`,
link = `/movies/${v.radarrId}`; displayName: hasDuplicate
} else { ? `${v.title} (${v.year}) (S)`
throw new Error("Unknown search result"); : `${v.title} (${v.year})`,
};
}
if (v.radarrId) {
return {
link: `/movies/${v.radarrId}`,
displayName: hasDuplicate
? `${v.title} (${v.year}) (M)`
: `${v.title} (${v.year})`,
};
} }
throw new Error("Unknown search result");
})();
return { return {
value: `${v.title} (${v.year})`, value: displayName,
link, link,
}; };
}) ?? [], }) ?? [],
[data], [data, duplicates],
); );
} }

@ -25,7 +25,7 @@ const TextPopover: FunctionComponent<TextPopoverProps> = ({
opened={hovered} opened={hovered}
label={text} label={text}
{...tooltip} {...tooltip}
style={{ textWrap: "pretty" }} style={{ textWrap: "wrap" }}
> >
<div ref={ref}>{children}</div> <div ref={ref}>{children}</div>
</Tooltip> </Tooltip>

@ -16,7 +16,6 @@ type MutateActionProps<DATA, VAR> = Omit<
function MutateAction<DATA, VAR>({ function MutateAction<DATA, VAR>({
mutation, mutation,
noReset,
onSuccess, onSuccess,
onError, onError,
args, args,

@ -15,7 +15,6 @@ type MutateButtonProps<DATA, VAR> = Omit<
function MutateButton<DATA, VAR>({ function MutateButton<DATA, VAR>({
mutation, mutation,
noReset,
onSuccess, onSuccess,
onError, onError,
args, args,

@ -12,7 +12,7 @@ interface QueryOverlayProps {
const QueryOverlay: FunctionComponent<QueryOverlayProps> = ({ const QueryOverlay: FunctionComponent<QueryOverlayProps> = ({
children, children,
global = false, global = false,
result: { isLoading, isError, error }, result: { isLoading },
}) => { }) => {
return ( return (
<LoadingProvider value={isLoading}> <LoadingProvider value={isLoading}>

@ -1,6 +1,7 @@
import { FunctionComponent } from "react"; import { FunctionComponent } from "react";
import { Badge, BadgeProps, Group, GroupProps } from "@mantine/core"; import { Badge, BadgeProps, Group, GroupProps } from "@mantine/core";
import { BuildKey } from "@/utilities"; import { BuildKey } from "@/utilities";
import { normalizeAudioLanguage } from "@/utilities/languages";
export type AudioListProps = GroupProps & { export type AudioListProps = GroupProps & {
audios: Language.Info[]; audios: Language.Info[];
@ -16,7 +17,7 @@ const AudioList: FunctionComponent<AudioListProps> = ({
<Group gap="xs" {...group}> <Group gap="xs" {...group}>
{audios.map((audio, idx) => ( {audios.map((audio, idx) => (
<Badge color="blue" key={BuildKey(idx, audio.code2)} {...badgeProps}> <Badge color="blue" key={BuildKey(idx, audio.code2)} {...badgeProps}>
{audio.name} {normalizeAudioLanguage(audio.name)}
</Badge> </Badge>
))} ))}
</Group> </Group>

@ -1,9 +1,9 @@
import { FunctionComponent, useEffect, useMemo } from "react"; import React, { FunctionComponent, useEffect, useMemo } from "react";
import { import {
Button, Button,
Checkbox,
Divider, Divider,
MantineColor, MantineColor,
Select,
Stack, Stack,
Text, Text,
} from "@mantine/core"; } from "@mantine/core";
@ -17,8 +17,9 @@ import {
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ColumnDef } from "@tanstack/react-table"; import { ColumnDef } from "@tanstack/react-table";
import { isString } from "lodash"; import { isString, uniqBy } from "lodash";
import { useMovieSubtitleModification } from "@/apis/hooks"; import { useMovieSubtitleModification } from "@/apis/hooks";
import { subtitlesTypeOptions } from "@/components/forms/uploadFormSelectorTypes";
import { Action, Selector } from "@/components/inputs"; import { Action, Selector } from "@/components/inputs";
import SimpleTable from "@/components/tables/SimpleTable"; import SimpleTable from "@/components/tables/SimpleTable";
import TextPopover from "@/components/TextPopover"; import TextPopover from "@/components/TextPopover";
@ -88,7 +89,7 @@ const MovieUploadForm: FunctionComponent<Props> = ({
const languages = useProfileItemsToLanguages(profile); const languages = useProfileItemsToLanguages(profile);
const languageOptions = useSelectorOptions( const languageOptions = useSelectorOptions(
languages, uniqBy(languages, "code2"),
(v) => v.name, (v) => v.name,
(v) => v.code2, (v) => v.code2,
); );
@ -207,34 +208,6 @@ const MovieUploadForm: FunctionComponent<Props> = ({
return <Text className="table-primary">{file.name}</Text>; return <Text className="table-primary">{file.name}</Text>;
}, },
}, },
{
header: "Forced",
accessorKey: "forced",
cell: ({ row: { original, index } }) => {
return (
<Checkbox
checked={original.forced}
onChange={({ currentTarget: { checked } }) => {
action.mutate(index, { ...original, forced: checked });
}}
></Checkbox>
);
},
},
{
header: "HI",
accessorKey: "hi",
cell: ({ row: { original, index } }) => {
return (
<Checkbox
checked={original.hi}
onChange={({ currentTarget: { checked } }) => {
action.mutate(index, { ...original, hi: checked });
}}
></Checkbox>
);
},
},
{ {
header: "Language", header: "Language",
accessorKey: "language", accessorKey: "language",
@ -251,6 +224,61 @@ const MovieUploadForm: FunctionComponent<Props> = ({
); );
}, },
}, },
{
header: () => (
<Selector
options={subtitlesTypeOptions}
value={null}
placeholder="Type"
onChange={(value) => {
if (value) {
action.update((item) => {
switch (value) {
case "hi":
return { ...item, hi: true, forced: false };
case "forced":
return { ...item, hi: false, forced: true };
case "normal":
return { ...item, hi: false, forced: false };
default:
return item;
}
});
}
}}
></Selector>
),
accessorKey: "type",
cell: ({ row: { original, index } }) => {
return (
<Select
value={
subtitlesTypeOptions.find((s) => {
if (original.hi) {
return s.value === "hi";
}
if (original.forced) {
return s.value === "forced";
}
return s.value === "normal";
})?.value
}
data={subtitlesTypeOptions}
onChange={(value) => {
if (value) {
action.mutate(index, {
...original,
hi: value === "hi",
forced: value === "forced",
});
}
}}
></Select>
);
},
},
{ {
id: "action", id: "action",
cell: ({ row: { index } }) => { cell: ({ row: { index } }) => {

@ -3,3 +3,11 @@
padding: 0; padding: 0;
} }
} }
.evenly {
flex-wrap: wrap;
& > div {
flex: 1;
}
}

@ -3,6 +3,7 @@ import {
Accordion, Accordion,
Button, Button,
Checkbox, Checkbox,
Flex,
Select, Select,
Stack, Stack,
Switch, Switch,
@ -72,9 +73,16 @@ const ProfileEditForm: FunctionComponent<Props> = ({
(value) => value.length > 0, (value) => value.length > 0,
"Must have a name", "Must have a name",
), ),
tag: FormUtils.validation((value) => {
if (!value) {
return true;
}
return /^[a-z_0-9-]+$/.test(value);
}, "Only lowercase alphanumeric characters, underscores (_) and hyphens (-) are allowed"),
items: FormUtils.validation( items: FormUtils.validation(
(value) => value.length > 0, (value) => value.length > 0,
"Must contain at lease 1 language", "Must contain at least 1 language",
), ),
}, },
}); });
@ -265,7 +273,24 @@ const ProfileEditForm: FunctionComponent<Props> = ({
})} })}
> >
<Stack> <Stack>
<Flex
direction={{ base: "column", sm: "row" }}
gap="sm"
className={styles.evenly}
>
<TextInput label="Name" {...form.getInputProps("name")}></TextInput> <TextInput label="Name" {...form.getInputProps("name")}></TextInput>
<TextInput
label="Tag"
{...form.getInputProps("tag")}
onBlur={() =>
form.setFieldValue(
"tag",
(prev) =>
prev?.toLowerCase().trim().replace(/\s+/g, "_") ?? undefined,
)
}
></TextInput>
</Flex>
<Accordion <Accordion
multiple multiple
chevronPosition="right" chevronPosition="right"
@ -274,7 +299,6 @@ const ProfileEditForm: FunctionComponent<Props> = ({
> >
<Accordion.Item value="Languages"> <Accordion.Item value="Languages">
<Stack> <Stack>
{form.errors.items}
<SimpleTable <SimpleTable
columns={columns} columns={columns}
data={form.values.items} data={form.values.items}
@ -282,6 +306,7 @@ const ProfileEditForm: FunctionComponent<Props> = ({
<Button fullWidth onClick={addItem}> <Button fullWidth onClick={addItem}>
Add Language Add Language
</Button> </Button>
<Text c="var(--mantine-color-error)">{form.errors.items}</Text>
<Selector <Selector
clearable clearable
label="Cutoff" label="Cutoff"

@ -1,9 +1,9 @@
import { FunctionComponent, useEffect, useMemo } from "react"; import React, { FunctionComponent, useEffect, useMemo } from "react";
import { import {
Button, Button,
Checkbox,
Divider, Divider,
MantineColor, MantineColor,
Select,
Stack, Stack,
Text, Text,
} from "@mantine/core"; } from "@mantine/core";
@ -17,12 +17,13 @@ import {
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ColumnDef } from "@tanstack/react-table"; import { ColumnDef } from "@tanstack/react-table";
import { isString } from "lodash"; import { isString, uniqBy } from "lodash";
import { import {
useEpisodesBySeriesId, useEpisodesBySeriesId,
useEpisodeSubtitleModification, useEpisodeSubtitleModification,
useSubtitleInfos, useSubtitleInfos,
} from "@/apis/hooks"; } from "@/apis/hooks";
import { subtitlesTypeOptions } from "@/components/forms/uploadFormSelectorTypes";
import { Action, Selector } from "@/components/inputs"; import { Action, Selector } from "@/components/inputs";
import SimpleTable from "@/components/tables/SimpleTable"; import SimpleTable from "@/components/tables/SimpleTable";
import TextPopover from "@/components/TextPopover"; import TextPopover from "@/components/TextPopover";
@ -100,7 +101,7 @@ const SeriesUploadForm: FunctionComponent<Props> = ({
const profile = useLanguageProfileBy(series.profileId); const profile = useLanguageProfileBy(series.profileId);
const languages = useProfileItemsToLanguages(profile); const languages = useProfileItemsToLanguages(profile);
const languageOptions = useSelectorOptions( const languageOptions = useSelectorOptions(
languages, uniqBy(languages, "code2"),
(v) => v.name, (v) => v.name,
(v) => v.code2, (v) => v.code2,
); );
@ -235,42 +236,6 @@ const SeriesUploadForm: FunctionComponent<Props> = ({
return <Text className="table-primary">{name}</Text>; return <Text className="table-primary">{name}</Text>;
}, },
}, },
{
header: "Forced",
accessorKey: "forced",
cell: ({ row: { original, index } }) => {
return (
<Checkbox
checked={original.forced}
onChange={({ currentTarget: { checked } }) => {
action.mutate(index, {
...original,
forced: checked,
hi: checked ? false : original.hi,
});
}}
></Checkbox>
);
},
},
{
header: "HI",
accessorKey: "hi",
cell: ({ row: { original, index } }) => {
return (
<Checkbox
checked={original.hi}
onChange={({ currentTarget: { checked } }) => {
action.mutate(index, {
...original,
hi: checked,
forced: checked ? false : original.forced,
});
}}
></Checkbox>
);
},
},
{ {
header: () => ( header: () => (
<Selector <Selector
@ -280,8 +245,7 @@ const SeriesUploadForm: FunctionComponent<Props> = ({
onChange={(value) => { onChange={(value) => {
if (value) { if (value) {
action.update((item) => { action.update((item) => {
item.language = value; return { ...item, language: value };
return item;
}); });
} }
}} }}
@ -301,6 +265,61 @@ const SeriesUploadForm: FunctionComponent<Props> = ({
); );
}, },
}, },
{
header: () => (
<Selector
options={subtitlesTypeOptions}
value={null}
placeholder="Type"
onChange={(value) => {
if (value) {
action.update((item) => {
switch (value) {
case "hi":
return { ...item, hi: true, forced: false };
case "forced":
return { ...item, hi: false, forced: true };
case "normal":
return { ...item, hi: false, forced: false };
default:
return item;
}
});
}
}}
></Selector>
),
accessorKey: "type",
cell: ({ row: { original, index } }) => {
return (
<Select
value={
subtitlesTypeOptions.find((s) => {
if (original.hi) {
return s.value === "hi";
}
if (original.forced) {
return s.value === "forced";
}
return s.value === "normal";
})?.value
}
data={subtitlesTypeOptions}
onChange={(value) => {
if (value) {
action.mutate(index, {
...original,
hi: value === "hi",
forced: value === "forced",
});
}
}}
></Select>
);
},
},
{ {
id: "episode", id: "episode",
header: "Episode", header: "Episode",

@ -0,0 +1,16 @@
import { SelectorOption } from "@/components";
export const subtitlesTypeOptions: SelectorOption<string>[] = [
{
label: "Normal",
value: "normal",
},
{
label: "Hearing-Impaired",
value: "hi",
},
{
label: "Forced",
value: "forced",
},
];

@ -7,7 +7,7 @@ import {
Select, Select,
SelectProps, SelectProps,
} from "@mantine/core"; } from "@mantine/core";
import { isNull, isUndefined, noop } from "lodash"; import { isNull, isUndefined } from "lodash";
import { LOG } from "@/utilities/console"; import { LOG } from "@/utilities/console";
export type SelectorOption<T> = Override< export type SelectorOption<T> = Override<
@ -49,10 +49,7 @@ export type GroupedSelectorProps<T> = Override<
>; >;
export function GroupedSelector<T>({ export function GroupedSelector<T>({
value,
options, options,
getkey = DefaultKeyBuilder,
onOptionSubmit = noop,
...select ...select
}: GroupedSelectorProps<T>) { }: GroupedSelectorProps<T>) {
return ( return (

@ -5,11 +5,8 @@ import { ModalSettings } from "@mantine/modals/lib/context";
import { ModalComponent, ModalIdContext } from "./WithModal"; import { ModalComponent, ModalIdContext } from "./WithModal";
export function useModals() { export function useModals() {
const { const { openContextModal: openMantineContextModal, ...rest } =
openContextModal: openMantineContextModal, useMantineModals();
closeContextModal: closeContextModalRaw,
...rest
} = useMantineModals();
const openContextModal = useCallback( const openContextModal = useCallback(
<ARGS extends {}>( <ARGS extends {}>(
@ -26,7 +23,7 @@ export function useModals() {
[openMantineContextModal], [openMantineContextModal],
); );
const closeContextModal = useCallback( const closeContext = useCallback(
(modal: ModalComponent) => { (modal: ModalComponent) => {
rest.closeModal(modal.modalKey); rest.closeModal(modal.modalKey);
}, },
@ -43,7 +40,7 @@ export function useModals() {
// TODO: Performance // TODO: Performance
return useMemo( return useMemo(
() => ({ openContextModal, closeContextModal, closeSelf, ...rest }), () => ({ openContextModal, closeContext, closeSelf, ...rest }),
[closeContextModal, closeSelf, openContextModal, rest], [closeContext, closeSelf, openContextModal, rest],
); );
} }

@ -40,13 +40,17 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
update: (ids) => { update: (ids) => {
LOG("info", "Invalidating series", ids); LOG("info", "Invalidating series", ids);
ids.forEach((id) => { ids.forEach((id) => {
queryClient.invalidateQueries({ queryKey: [QueryKeys.Series, id] }); void queryClient.invalidateQueries({
queryKey: [QueryKeys.Series, id],
});
}); });
}, },
delete: (ids) => { delete: (ids) => {
LOG("info", "Invalidating series", ids); LOG("info", "Invalidating series", ids);
ids.forEach((id) => { ids.forEach((id) => {
queryClient.invalidateQueries({ queryKey: [QueryKeys.Series, id] }); void queryClient.invalidateQueries({
queryKey: [QueryKeys.Series, id],
});
}); });
}, },
}, },
@ -55,13 +59,17 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
update: (ids) => { update: (ids) => {
LOG("info", "Invalidating movies", ids); LOG("info", "Invalidating movies", ids);
ids.forEach((id) => { ids.forEach((id) => {
queryClient.invalidateQueries({ queryKey: [QueryKeys.Movies, id] }); void queryClient.invalidateQueries({
queryKey: [QueryKeys.Movies, id],
});
}); });
}, },
delete: (ids) => { delete: (ids) => {
LOG("info", "Invalidating movies", ids); LOG("info", "Invalidating movies", ids);
ids.forEach((id) => { ids.forEach((id) => {
queryClient.invalidateQueries({ queryKey: [QueryKeys.Movies, id] }); void queryClient.invalidateQueries({
queryKey: [QueryKeys.Movies, id],
});
}); });
}, },
}, },
@ -78,7 +86,7 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
id, id,
]); ]);
if (episode !== undefined) { if (episode !== undefined) {
queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: [QueryKeys.Series, episode.sonarrSeriesId], queryKey: [QueryKeys.Series, episode.sonarrSeriesId],
}); });
} }
@ -92,7 +100,7 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
id, id,
]); ]);
if (episode !== undefined) { if (episode !== undefined) {
queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: [QueryKeys.Series, episode.sonarrSeriesId], queryKey: [QueryKeys.Series, episode.sonarrSeriesId],
}); });
} }
@ -101,28 +109,28 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
}, },
{ {
key: "episode-wanted", key: "episode-wanted",
update: (ids) => { update: () => {
// Find a better way to update wanted // Find a better way to update wanted
queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: [QueryKeys.Episodes, QueryKeys.Wanted], queryKey: [QueryKeys.Episodes, QueryKeys.Wanted],
}); });
}, },
delete: () => { delete: () => {
queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: [QueryKeys.Episodes, QueryKeys.Wanted], queryKey: [QueryKeys.Episodes, QueryKeys.Wanted],
}); });
}, },
}, },
{ {
key: "movie-wanted", key: "movie-wanted",
update: (ids) => { update: () => {
// Find a better way to update wanted // Find a better way to update wanted
queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: [QueryKeys.Movies, QueryKeys.Wanted], queryKey: [QueryKeys.Movies, QueryKeys.Wanted],
}); });
}, },
delete: () => { delete: () => {
queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: [QueryKeys.Movies, QueryKeys.Wanted], queryKey: [QueryKeys.Movies, QueryKeys.Wanted],
}); });
}, },
@ -130,13 +138,13 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
{ {
key: "settings", key: "settings",
any: () => { any: () => {
queryClient.invalidateQueries({ queryKey: [QueryKeys.System] }); void queryClient.invalidateQueries({ queryKey: [QueryKeys.System] });
}, },
}, },
{ {
key: "languages", key: "languages",
any: () => { any: () => {
queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: [QueryKeys.System, QueryKeys.Languages], queryKey: [QueryKeys.System, QueryKeys.Languages],
}); });
}, },
@ -144,7 +152,7 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
{ {
key: "badges", key: "badges",
any: () => { any: () => {
queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: [QueryKeys.System, QueryKeys.Badges], queryKey: [QueryKeys.System, QueryKeys.Badges],
}); });
}, },
@ -152,7 +160,7 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
{ {
key: "movie-history", key: "movie-history",
any: () => { any: () => {
queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: [QueryKeys.Movies, QueryKeys.History], queryKey: [QueryKeys.Movies, QueryKeys.History],
}); });
}, },
@ -160,7 +168,7 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
{ {
key: "movie-blacklist", key: "movie-blacklist",
any: () => { any: () => {
queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: [QueryKeys.Movies, QueryKeys.Blacklist], queryKey: [QueryKeys.Movies, QueryKeys.Blacklist],
}); });
}, },
@ -168,7 +176,7 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
{ {
key: "episode-history", key: "episode-history",
any: () => { any: () => {
queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: [QueryKeys.Episodes, QueryKeys.History], queryKey: [QueryKeys.Episodes, QueryKeys.History],
}); });
}, },
@ -176,7 +184,7 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
{ {
key: "episode-blacklist", key: "episode-blacklist",
any: () => { any: () => {
queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: [QueryKeys.Episodes, QueryKeys.Blacklist], queryKey: [QueryKeys.Episodes, QueryKeys.Blacklist],
}); });
}, },
@ -184,7 +192,7 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
{ {
key: "reset-episode-wanted", key: "reset-episode-wanted",
any: () => { any: () => {
queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: [QueryKeys.Episodes, QueryKeys.Wanted], queryKey: [QueryKeys.Episodes, QueryKeys.Wanted],
}); });
}, },
@ -192,7 +200,7 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
{ {
key: "reset-movie-wanted", key: "reset-movie-wanted",
any: () => { any: () => {
queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: [QueryKeys.Movies, QueryKeys.Wanted], queryKey: [QueryKeys.Movies, QueryKeys.Wanted],
}); });
}, },
@ -200,7 +208,7 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
{ {
key: "task", key: "task",
any: () => { any: () => {
queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: [QueryKeys.System, QueryKeys.Tasks], queryKey: [QueryKeys.System, QueryKeys.Tasks],
}); });
}, },

@ -6,6 +6,7 @@ import { faBookmark as farBookmark } from "@fortawesome/free-regular-svg-icons";
import { faBookmark, faWrench } from "@fortawesome/free-solid-svg-icons"; import { faBookmark, faWrench } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ColumnDef } from "@tanstack/react-table"; import { ColumnDef } from "@tanstack/react-table";
import { uniqueId } from "lodash";
import { useMovieModification, useMoviesPagination } from "@/apis/hooks"; import { useMovieModification, useMoviesPagination } from "@/apis/hooks";
import { Action } from "@/components"; import { Action } from "@/components";
import { AudioList } from "@/components/bazarr"; import { AudioList } from "@/components/bazarr";
@ -95,7 +96,7 @@ const MovieView: FunctionComponent = () => {
<Badge <Badge
mr="xs" mr="xs"
color="yellow" color="yellow"
key={BuildKey(v.code2, v.hi, v.forced)} key={uniqueId(`${BuildKey(v.code2, v.hi, v.forced)}_`)}
> >
<Language.Text value={v}></Language.Text> <Language.Text value={v}></Language.Text>
</Badge> </Badge>

@ -65,25 +65,34 @@ const SeriesView: FunctionComponent = () => {
cell: (row) => { cell: (row) => {
const { episodeFileCount, episodeMissingCount, profileId, title } = const { episodeFileCount, episodeMissingCount, profileId, title } =
row.row.original; row.row.original;
let progress = 0;
let label = "";
if (episodeFileCount === 0 || !profileId) {
progress = 0.0;
} else {
progress = (1.0 - episodeMissingCount / episodeFileCount) * 100.0;
label = `${
episodeFileCount - episodeMissingCount
}/${episodeFileCount}`;
}
const label = `${episodeFileCount - episodeMissingCount}/${episodeFileCount}`;
return ( return (
<Progress.Root key={title} size="xl"> <Progress.Root key={title} size="xl">
<Progress.Section <Progress.Section
value={progress} value={
episodeFileCount === 0 || !profileId
? 0
: (1.0 - episodeMissingCount / episodeFileCount) * 100.0
}
color={episodeMissingCount === 0 ? "brand" : "yellow"} color={episodeMissingCount === 0 ? "brand" : "yellow"}
> >
<Progress.Label>{label}</Progress.Label> <Progress.Label>{label}</Progress.Label>
</Progress.Section> </Progress.Section>
{episodeMissingCount === episodeFileCount && (
<Progress.Label
styles={{
label: {
position: "absolute",
top: "3px",
left: "50%",
transform: "translateX(-50%)",
},
}}
>
{label}
</Progress.Label>
)}
</Progress.Root> </Progress.Root>
); );
}, },

@ -43,10 +43,10 @@ const SettingsGeneralView: FunctionComponent = () => {
<Section header="Host"> <Section header="Host">
<Text <Text
label="Address" label="Address"
placeholder="0.0.0.0" placeholder="*"
settingKey="settings-general-ip" settingKey="settings-general-ip"
></Text> ></Text>
<Message>Valid IPv4 address or '0.0.0.0' for all interfaces</Message> <Message>Valid IP address or '*' for all interfaces</Message>
<Number <Number
label="Port" label="Port"
placeholder="6767" placeholder="6767"

@ -1,7 +1,9 @@
import { FunctionComponent } from "react"; import { FunctionComponent } from "react";
import { Text as MantineText } from "@mantine/core";
import { useLanguageProfiles, useLanguages } from "@/apis/hooks"; import { useLanguageProfiles, useLanguages } from "@/apis/hooks";
import { import {
Check, Check,
Chips,
CollapseBox, CollapseBox,
Layout, Layout,
Message, Message,
@ -115,6 +117,50 @@ const SettingsLanguagesView: FunctionComponent = () => {
<Section header="Languages Profile"> <Section header="Languages Profile">
<Table></Table> <Table></Table>
</Section> </Section>
<Section header="Tag-Based Automatic Language Profile Selection Settings">
<Message>
If enabled, Bazarr will look at the names of all tags of a Series from
Sonarr (or a Movie from Radarr) to find a matching Bazarr language
profile tag. It will use as the language profile the FIRST tag from
Sonarr/Radarr that matches the tag of a Bazarr language profile
EXACTLY. If multiple tags match, there is no guarantee as to which one
will be used, so choose your tag names carefully. Also, if you update
the tag names in Sonarr/Radarr, Bazarr will detect this and repeat the
matching process for the affected shows. However, if a show's only
matching tag is removed from Sonarr/Radarr, Bazarr will NOT remove the
show's existing language profile for that reason. But if you wish to
have language profiles removed automatically by tag value, simply
enter a list of one or more tags in the{" "}
<MantineText fw={700} span>
Remove Profile Tags
</MantineText>{" "}
entry list below. If your video tag matches one of the tags in that
list, then Bazarr will remove the language profile for that video. If
there is a conflict between profile selection and profile removal,
then profile removal wins out and is performed.
</Message>
<Check
label="Series"
settingKey="settings-general-serie_tag_enabled"
></Check>
<Check
label="Movies"
settingKey="settings-general-movie_tag_enabled"
></Check>
<Chips
label="Remove Profile Tags"
settingKey="settings-general-remove_profile_tags"
sanitizeFn={(values: string[] | null) =>
values?.map((item) =>
item.replace(/[^a-z0-9_-]/gi, "").toLowerCase(),
)
}
></Chips>
<Message>
Enter tag values that will trigger a language profile removal. Leave
empty if you don't want Bazarr to remove language profiles.
</Message>
</Section>
<Section header="Default Settings"> <Section header="Default Settings">
<Check <Check
label="Series" label="Series"

@ -2,7 +2,7 @@ import { FunctionComponent, useCallback, useMemo } from "react";
import { Badge, Button, Group } from "@mantine/core"; import { Badge, Button, Group } from "@mantine/core";
import { faTrash, faWrench } from "@fortawesome/free-solid-svg-icons"; import { faTrash, faWrench } from "@fortawesome/free-solid-svg-icons";
import { ColumnDef } from "@tanstack/react-table"; import { ColumnDef } from "@tanstack/react-table";
import { cloneDeep } from "lodash"; import { cloneDeep, includes, maxBy } from "lodash";
import { Action } from "@/components"; import { Action } from "@/components";
import { import {
anyCutoff, anyCutoff,
@ -65,6 +65,10 @@ const Table: FunctionComponent = () => {
header: "Name", header: "Name",
accessorKey: "name", accessorKey: "name",
}, },
{
header: "Tag",
accessorKey: "tag",
},
{ {
header: "Languages", header: "Languages",
accessorKey: "items", accessorKey: "items",
@ -75,10 +79,10 @@ const Table: FunctionComponent = () => {
}) => { }) => {
return ( return (
<Group gap="xs" wrap="nowrap"> <Group gap="xs" wrap="nowrap">
{items.map((v) => { {items.map((v, i) => {
const isCutoff = v.id === cutoff || cutoff === anyCutoff; const isCutoff = v.id === cutoff || cutoff === anyCutoff;
return ( return (
<ItemBadge key={v.id} cutoff={isCutoff} item={v}></ItemBadge> <ItemBadge key={i} cutoff={isCutoff} item={v}></ItemBadge>
); );
})} })}
</Group> </Group>
@ -144,9 +148,45 @@ const Table: FunctionComponent = () => {
icon={faWrench} icon={faWrench}
c="gray" c="gray"
onClick={() => { onClick={() => {
const lastId = maxBy(profile.items, "id")?.id || 0;
// We once had an issue on the past where there were duplicated
// item ids that needs to become unique upon editing.
const sanitizedProfile = {
...cloneDeep(profile),
items: profile.items.reduce(
(acc, value) => {
const { ids, duplicatedIds, items } = acc;
// We once had an issue on the past where there were duplicated
// item ids that needs to become unique upon editing.
if (includes(ids, value.id)) {
duplicatedIds.push(value.id);
items.push({
...value,
id: lastId + duplicatedIds.length,
});
return acc;
}
ids.push(value.id);
items.push(value);
return acc;
},
{
ids: [] as number[],
duplicatedIds: [] as number[],
items: [] as typeof profile.items,
},
).items,
tag: profile.tag || undefined,
};
modals.openContextModal(ProfileEditModal, { modals.openContextModal(ProfileEditModal, {
languages, languages,
profile: cloneDeep(profile), profile: sanitizedProfile,
onComplete: updateProfile, onComplete: updateProfile,
}); });
}} }}
@ -178,6 +218,7 @@ const Table: FunctionComponent = () => {
const profile = { const profile = {
profileId: nextProfileId, profileId: nextProfileId,
name: "", name: "",
tag: undefined,
items: [], items: [],
cutoff: null, cutoff: null,
mustContain: [], mustContain: [],

@ -108,10 +108,12 @@ export const ProviderView: FunctionComponent<ProviderViewProps> = ({
}) })
.map((v, idx) => ( .map((v, idx) => (
<Card <Card
titleStyles={{ overflow: "hidden", textOverflow: "ellipsis" }}
key={BuildKey(v.key, idx)} key={BuildKey(v.key, idx)}
header={v.name ?? capitalize(v.key)} header={v.name ?? capitalize(v.key)}
description={v.description} description={v.description}
onClick={() => select(v)} onClick={() => select(v)}
lineClamp={2}
></Card> ></Card>
)); ));
} else { } else {

@ -218,6 +218,35 @@ export const ProviderList: Readonly<ProviderInfo[]> = [
}, },
], ],
}, },
{
key: "jimaku",
name: "Jimaku.cc",
description: "Japanese Subtitles Provider",
message:
"API key required. Subtitles stem from various sources and might have quality/timing issues.",
inputs: [
{
type: "password",
key: "api_key",
name: "API key",
},
{
type: "switch",
key: "enable_name_search_fallback",
name: "Search by name if no AniList ID was determined (Less accurate, required for live action)",
},
{
type: "switch",
key: "enable_archives_download",
name: "Also consider archives alongside uncompressed subtitles",
},
{
type: "switch",
key: "enable_ai_subs",
name: "Download AI generated subtitles",
},
],
},
{ key: "hosszupuska", description: "Hungarian Subtitles Provider" }, { key: "hosszupuska", description: "Hungarian Subtitles Provider" },
{ {
key: "karagarga", key: "karagarga",
@ -276,6 +305,21 @@ export const ProviderList: Readonly<ProviderInfo[]> = [
{ type: "switch", key: "skip_wrong_fps", name: "Skip Wrong FPS" }, { type: "switch", key: "skip_wrong_fps", name: "Skip Wrong FPS" },
], ],
}, },
{
key: "legendasnet",
name: "Legendas.net",
description: "Brazilian Subtitles Provider",
inputs: [
{
type: "text",
key: "username",
},
{
type: "password",
key: "password",
},
],
},
{ key: "napiprojekt", description: "Polish Subtitles Provider" }, { key: "napiprojekt", description: "Polish Subtitles Provider" },
{ {
key: "napisy24", key: "napisy24",

@ -54,6 +54,11 @@ const SettingsRadarrView: FunctionComponent = () => {
<Chips <Chips
label="Excluded Tags" label="Excluded Tags"
settingKey="settings-radarr-excluded_tags" settingKey="settings-radarr-excluded_tags"
sanitizeFn={(values: string[] | null) =>
values?.map((item) =>
item.replace(/[^a-z0-9_-]/gi, "").toLowerCase(),
)
}
></Chips> ></Chips>
<Message> <Message>
Movies with those tags (case sensitive) in Radarr will be excluded Movies with those tags (case sensitive) in Radarr will be excluded

@ -56,6 +56,11 @@ const SettingsSonarrView: FunctionComponent = () => {
<Chips <Chips
label="Excluded Tags" label="Excluded Tags"
settingKey="settings-sonarr-excluded_tags" settingKey="settings-sonarr-excluded_tags"
sanitizeFn={(values: string[] | null) =>
values?.map((item) =>
item.replace(/[^a-z0-9_-]/gi, "").toLowerCase(),
)
}
></Chips> ></Chips>
<Message> <Message>
Episodes from series with those tags (case sensitive) in Sonarr will Episodes from series with those tags (case sensitive) in Sonarr will

@ -1,5 +1,5 @@
import { FunctionComponent } from "react"; import React, { FunctionComponent } from "react";
import { Code, Space, Table } from "@mantine/core"; import { Code, Space, Table, Text as MantineText } from "@mantine/core";
import { import {
Check, Check,
CollapseBox, CollapseBox,
@ -115,14 +115,16 @@ const commandOptions: CommandOption[] = [
}, },
]; ];
const commandOptionElements: JSX.Element[] = commandOptions.map((op, idx) => ( const commandOptionElements: React.JSX.Element[] = commandOptions.map(
(op, idx) => (
<tr key={idx}> <tr key={idx}>
<td> <td>
<Code>{op.option}</Code> <Code>{op.option}</Code>
</td> </td>
<td>{op.description}</td> <td>{op.description}</td>
</tr> </tr>
)); ),
);
const SettingsSubtitlesView: FunctionComponent = () => { const SettingsSubtitlesView: FunctionComponent = () => {
return ( return (
@ -436,8 +438,11 @@ const SettingsSubtitlesView: FunctionComponent = () => {
<Slider settingKey="settings-subsync-subsync_threshold"></Slider> <Slider settingKey="settings-subsync-subsync_threshold"></Slider>
<Space /> <Space />
<Message> <Message>
Only series subtitles with scores <b>below</b> this value will be Only series subtitles with scores{" "}
automatically synchronized. <MantineText fw={700} span>
below
</MantineText>{" "}
this value will be automatically synchronized.
</Message> </Message>
</CollapseBox> </CollapseBox>
<Check <Check
@ -451,8 +456,11 @@ const SettingsSubtitlesView: FunctionComponent = () => {
<Slider settingKey="settings-subsync-subsync_movie_threshold"></Slider> <Slider settingKey="settings-subsync-subsync_movie_threshold"></Slider>
<Space /> <Space />
<Message> <Message>
Only movie subtitles with scores <b>below</b> this value will be Only movie subtitles with scores{" "}
automatically synchronized. <MantineText fw={700} span>
below
</MantineText>{" "}
this value will be automatically synchronized.
</Message> </Message>
</CollapseBox> </CollapseBox>
</CollapseBox> </CollapseBox>
@ -478,8 +486,11 @@ const SettingsSubtitlesView: FunctionComponent = () => {
<Slider settingKey="settings-general-postprocessing_threshold"></Slider> <Slider settingKey="settings-general-postprocessing_threshold"></Slider>
<Space /> <Space />
<Message> <Message>
Only series subtitles with scores <b>below</b> this value will be Only series subtitles with scores{" "}
automatically post-processed. <MantineText fw={700} span>
below
</MantineText>{" "}
this value will be automatically post-processed.
</Message> </Message>
</CollapseBox> </CollapseBox>
<Check <Check
@ -493,8 +504,11 @@ const SettingsSubtitlesView: FunctionComponent = () => {
<Slider settingKey="settings-general-postprocessing_threshold_movie"></Slider> <Slider settingKey="settings-general-postprocessing_threshold_movie"></Slider>
<Space /> <Space />
<Message> <Message>
Only movie subtitles with scores <b>below</b> this value will be Only movie subtitles with scores{" "}
automatically post-processed. <MantineText fw={700} span>
below
</MantineText>{" "}
this value will be automatically post-processed.
</Message> </Message>
</CollapseBox> </CollapseBox>
<Text <Text

@ -1,14 +1,23 @@
import { FunctionComponent } from "react"; import { FunctionComponent } from "react";
import { Center, Stack, Text, UnstyledButton } from "@mantine/core"; import {
Center,
MantineStyleProp,
Stack,
Text,
UnstyledButton,
} from "@mantine/core";
import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import TextPopover from "@/components/TextPopover";
import styles from "./Card.module.scss"; import styles from "./Card.module.scss";
interface CardProps { interface CardProps {
header?: string;
description?: string; description?: string;
plus?: boolean; header?: string;
lineClamp?: number | undefined;
onClick?: () => void; onClick?: () => void;
plus?: boolean;
titleStyles?: MantineStyleProp | undefined;
} }
export const Card: FunctionComponent<CardProps> = ({ export const Card: FunctionComponent<CardProps> = ({
@ -16,6 +25,8 @@ export const Card: FunctionComponent<CardProps> = ({
description, description,
plus, plus,
onClick, onClick,
lineClamp,
titleStyles,
}) => { }) => {
return ( return (
<UnstyledButton p="lg" onClick={onClick} className={styles.card}> <UnstyledButton p="lg" onClick={onClick} className={styles.card}>
@ -24,9 +35,15 @@ export const Card: FunctionComponent<CardProps> = ({
<FontAwesomeIcon size="2x" icon={faPlus}></FontAwesomeIcon> <FontAwesomeIcon size="2x" icon={faPlus}></FontAwesomeIcon>
</Center> </Center>
) : ( ) : (
<Stack h="100%" gap={0} align="flex-start"> <Stack h="100%" gap={0}>
<Text fw="bold">{header}</Text> <Text fw="bold" style={titleStyles}>
<Text hidden={description === undefined}>{description}</Text> {header}
</Text>
<TextPopover text={description}>
<Text hidden={description === undefined} lineClamp={lineClamp}>
{description}
</Text>
</TextPopover>
</Stack> </Stack>
)} )}
</UnstyledButton> </UnstyledButton>

@ -2,7 +2,7 @@ import { FunctionComponent, PropsWithChildren, ReactElement } from "react";
import { useForm } from "@mantine/form"; import { useForm } from "@mantine/form";
import { describe, it } from "vitest"; import { describe, it } from "vitest";
import { FormContext, FormValues } from "@/pages/Settings/utilities/FormValues"; import { FormContext, FormValues } from "@/pages/Settings/utilities/FormValues";
import { render, RenderOptions, screen } from "@/tests"; import { render, screen } from "@/tests";
import { Number, Text } from "./forms"; import { Number, Text } from "./forms";
const FormSupport: FunctionComponent<PropsWithChildren> = ({ children }) => { const FormSupport: FunctionComponent<PropsWithChildren> = ({ children }) => {
@ -15,10 +15,8 @@ const FormSupport: FunctionComponent<PropsWithChildren> = ({ children }) => {
return <FormContext.Provider value={form}>{children}</FormContext.Provider>; return <FormContext.Provider value={form}>{children}</FormContext.Provider>;
}; };
const formRender = ( const formRender = (ui: ReactElement) =>
ui: ReactElement, render(<FormSupport>{ui}</FormSupport>);
options?: Omit<RenderOptions, "wrapper">,
) => render(<FormSupport>{ui}</FormSupport>);
describe("Settings form", () => { describe("Settings form", () => {
describe("number component", () => { describe("number component", () => {

@ -1,4 +1,4 @@
import { FunctionComponent, ReactNode, ReactText } from "react"; import { FunctionComponent, ReactNode } from "react";
import { import {
Input, Input,
NumberInput, NumberInput,
@ -49,7 +49,7 @@ export const Number: FunctionComponent<NumberProps> = (props) => {
); );
}; };
export type TextProps = BaseInput<ReactText> & TextInputProps; export type TextProps = BaseInput<string | number> & TextInputProps;
export const Text: FunctionComponent<TextProps> = (props) => { export const Text: FunctionComponent<TextProps> = (props) => {
const { value, update, rest } = useBaseInput(props); const { value, update, rest } = useBaseInput(props);
@ -86,11 +86,7 @@ export interface CheckProps extends BaseInput<boolean> {
inline?: boolean; inline?: boolean;
} }
export const Check: FunctionComponent<CheckProps> = ({ export const Check: FunctionComponent<CheckProps> = ({ label, ...props }) => {
label,
inline,
...props
}) => {
const { value, update, rest } = useBaseInput(props); const { value, update, rest } = useBaseInput(props);
return ( return (
@ -160,13 +156,25 @@ export const Slider: FunctionComponent<SliderProps> = (props) => {
}; };
type ChipsProp = BaseInput<string[]> & type ChipsProp = BaseInput<string[]> &
Omit<ChipInputProps, "onChange" | "data">; Omit<ChipInputProps, "onChange" | "data"> & {
sanitizeFn?: (values: string[] | null) => string[] | undefined;
};
export const Chips: FunctionComponent<ChipsProp> = (props) => { export const Chips: FunctionComponent<ChipsProp> = (props) => {
const { value, update, rest } = useBaseInput(props); const { value, update, rest } = useBaseInput(props);
const handleChange = (value: string[] | null) => {
const sanitizedValues = props.sanitizeFn?.(value) ?? value;
update(sanitizedValues || null);
};
return ( return (
<ChipInput {...rest} value={value ?? []} onChange={update}></ChipInput> <ChipInput
{...rest}
value={value ?? []}
onChange={handleChange}
></ChipInput>
); );
}; };

@ -19,7 +19,7 @@ const Table: FunctionComponent<Props> = ({ announcements }) => {
() => [ () => [
{ {
header: "Since", header: "Since",
accessor: "timestamp", accessorKey: "timestamp",
cell: ({ cell: ({
row: { row: {
original: { timestamp }, original: { timestamp },
@ -30,7 +30,7 @@ const Table: FunctionComponent<Props> = ({ announcements }) => {
}, },
{ {
header: "Announcement", header: "Announcement",
accessor: "text", accessorKey: "text",
cell: ({ cell: ({
row: { row: {
original: { text }, original: { text },
@ -41,7 +41,7 @@ const Table: FunctionComponent<Props> = ({ announcements }) => {
}, },
{ {
header: "More Info", header: "More Info",
accessor: "link", accessorKey: "link",
cell: ({ cell: ({
row: { row: {
original: { link }, original: { link },
@ -56,7 +56,7 @@ const Table: FunctionComponent<Props> = ({ announcements }) => {
}, },
{ {
header: "Dismiss", header: "Dismiss",
accessor: "hash", accessorKey: "hash",
cell: ({ cell: ({
row: { row: {
original: { dismissible, hash }, original: { dismissible, hash },

@ -144,6 +144,8 @@ const SystemStatusView: FunctionComponent = () => {
<Row title="Radarr Version">{status?.radarr_version}</Row> <Row title="Radarr Version">{status?.radarr_version}</Row>
<Row title="Operating System">{status?.operating_system}</Row> <Row title="Operating System">{status?.operating_system}</Row>
<Row title="Python Version">{status?.python_version}</Row> <Row title="Python Version">{status?.python_version}</Row>
<Row title="Database Engine">{status?.database_engine}</Row>
<Row title="Database Version">{status?.database_migration}</Row>
<Row title="Bazarr Directory">{status?.bazarr_directory}</Row> <Row title="Bazarr Directory">{status?.bazarr_directory}</Row>
<Row title="Bazarr Config Directory"> <Row title="Bazarr Config Directory">
{status?.bazarr_config_directory} {status?.bazarr_config_directory}

@ -17,7 +17,7 @@ const Table: FunctionComponent<Props> = ({ tasks }) => {
() => [ () => [
{ {
header: "Name", header: "Name",
accessor: "name", accessorKey: "name",
cell: ({ cell: ({
row: { row: {
original: { name }, original: { name },
@ -28,7 +28,7 @@ const Table: FunctionComponent<Props> = ({ tasks }) => {
}, },
{ {
header: "Interval", header: "Interval",
accessor: "interval", accessorKey: "interval",
cell: ({ cell: ({
row: { row: {
original: { interval }, original: { interval },
@ -39,11 +39,11 @@ const Table: FunctionComponent<Props> = ({ tasks }) => {
}, },
{ {
header: "Next Execution", header: "Next Execution",
accessor: "next_run_in", accessorKey: "next_run_in",
}, },
{ {
header: "Run", header: "Run",
accessor: "job_running", accessorKey: "job_running",
cell: ({ cell: ({
row: { row: {
original: { job_id: jobId, job_running: jobRunning }, original: { job_id: jobId, job_running: jobRunning },

@ -21,7 +21,7 @@ const WantedMoviesView: FunctionComponent = () => {
() => [ () => [
{ {
header: "Name", header: "Name",
accessor: "title", accessorKey: "title",
cell: ({ cell: ({
row: { row: {
original: { title, radarrId }, original: { title, radarrId },
@ -37,7 +37,7 @@ const WantedMoviesView: FunctionComponent = () => {
}, },
{ {
header: "Missing", header: "Missing",
accessor: "missing_subtitles", accessorKey: "missing_subtitles",
cell: ({ cell: ({
row: { row: {
original: { radarrId, missing_subtitles: missingSubtitles }, original: { radarrId, missing_subtitles: missingSubtitles },

@ -31,6 +31,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Language } from "@/components/bazarr"; import { Language } from "@/components/bazarr";
import { BuildKey } from "@/utilities"; import { BuildKey } from "@/utilities";
import { import {
normalizeAudioLanguage,
useLanguageProfileBy, useLanguageProfileBy,
useProfileItemsToLanguages, useProfileItemsToLanguages,
} from "@/utilities/languages"; } from "@/utilities/languages";
@ -87,7 +88,7 @@ const ItemOverview: FunctionComponent<Props> = (props) => {
icon={faMusic} icon={faMusic}
title="Audio Language" title="Audio Language"
> >
{v.name} {normalizeAudioLanguage(v.name)}
</ItemBadge> </ItemBadge>
)) ?? [], )) ?? [],
[item?.audio_language], [item?.audio_language],
@ -142,12 +143,7 @@ const ItemOverview: FunctionComponent<Props> = (props) => {
}} }}
> >
<Grid.Col span={3} visibleFrom="sm"> <Grid.Col span={3} visibleFrom="sm">
<Image <Image src={item?.poster} mx="auto" maw="250px"></Image>
src={item?.poster}
mx="auto"
maw="250px"
fallbackSrc="https://placehold.co/250x250?text=Placeholder"
></Image>
</Grid.Col> </Grid.Col>
<Grid.Col span={8} maw="100%" style={{ overflow: "hidden" }}> <Grid.Col span={8} maw="100%" style={{ overflow: "hidden" }}>
<Stack align="flex-start" gap="xs" mx={6}> <Stack align="flex-start" gap="xs" mx={6}>

@ -40,6 +40,7 @@ declare namespace Language {
mustContain: string[]; mustContain: string[];
mustNotContain: string[]; mustNotContain: string[];
originalFormat: boolean | null; originalFormat: boolean | null;
tag: string | undefined;
} }
} }

@ -62,6 +62,7 @@ declare namespace Settings {
postprocessing_cmd?: string; postprocessing_cmd?: string;
postprocessing_threshold: number; postprocessing_threshold: number;
postprocessing_threshold_movie: number; postprocessing_threshold_movie: number;
remove_profile_tags: string[];
single_language: boolean; single_language: boolean;
subfolder: string; subfolder: string;
subfolder_custom?: string; subfolder_custom?: string;

@ -20,6 +20,8 @@ declare namespace System {
bazarr_config_directory: string; bazarr_config_directory: string;
bazarr_directory: string; bazarr_directory: string;
bazarr_version: string; bazarr_version: string;
database_engine: string;
database_migration: string;
operating_system: string; operating_system: string;
package_version: string; package_version: string;
python_version: string; python_version: string;

@ -51,3 +51,7 @@ export function useLanguageFromCode3(code3: string) {
[data, code3], [data, code3],
); );
} }
export const normalizeAudioLanguage = (name: string) => {
return name === "Chinese Simplified" ? "Chinese" : name;
};

@ -36,6 +36,9 @@ export default defineConfig(async ({ mode, command }) => {
enableBuild: false, enableBuild: false,
}), }),
VitePWA({ VitePWA({
workbox: {
globIgnores: ["index.html"],
},
registerType: "autoUpdate", registerType: "autoUpdate",
includeAssets: [ includeAssets: [
`${imagesFolder}/favicon.ico`, `${imagesFolder}/favicon.ico`,

@ -1,20 +0,0 @@
argparse is (c) 2006-2009 Steven J. Bethard <steven.bethard@gmail.com>.
The argparse module was contributed to Python as of Python 2.7 and thus
was licensed under the Python license. Same license applies to all files in
the argparse package project.
For details about the Python License, please see doc/Python-License.txt.
History
-------
Before (and including) argparse 1.1, the argparse package was licensed under
Apache License v2.0.
After argparse 1.1, all project files from the argparse project were deleted
due to license compatibility issues between Apache License 2.0 and GNU GPL v2.
The project repository then had a clean start with some files taken from
Python 2.7.1, so definitely all files are under Python License now.

@ -1,84 +0,0 @@
Metadata-Version: 2.1
Name: argparse
Version: 1.4.0
Summary: Python command-line parsing library
Home-page: https://github.com/ThomasWaldmann/argparse/
Author: Thomas Waldmann
Author-email: tw@waldmann-edv.de
License: Python Software Foundation License
Keywords: argparse command line parser parsing
Platform: any
Classifier: Development Status :: 5 - Production/Stable
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Python Software Foundation License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 2
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 2.3
Classifier: Programming Language :: Python :: 2.4
Classifier: Programming Language :: Python :: 2.5
Classifier: Programming Language :: Python :: 2.6
Classifier: Programming Language :: Python :: 2.7
Classifier: Programming Language :: Python :: 3.0
Classifier: Programming Language :: Python :: 3.1
Classifier: Programming Language :: Python :: 3.2
Classifier: Programming Language :: Python :: 3.3
Classifier: Programming Language :: Python :: 3.4
Classifier: Topic :: Software Development
License-File: LICENSE.txt
The argparse module makes it easy to write user friendly command line
interfaces.
The program defines what arguments it requires, and argparse will figure out
how to parse those out of sys.argv. The argparse module also automatically
generates help and usage messages and issues errors when users give the
program invalid arguments.
As of Python >= 2.7 and >= 3.2, the argparse module is maintained within the
Python standard library. For users who still need to support Python < 2.7 or
< 3.2, it is also provided as a separate package, which tries to stay
compatible with the module in the standard library, but also supports older
Python versions.
Also, we can fix bugs here for users who are stuck on some non-current python
version, like e.g. 3.2.3 (which has bugs that were fixed in a later 3.2.x
release).
argparse is licensed under the Python license, for details see LICENSE.txt.
Compatibility
-------------
argparse should work on Python >= 2.3, it was tested on:
* 2.3, 2.4, 2.5, 2.6 and 2.7
* 3.1, 3.2, 3.3, 3.4
Installation
------------
Try one of these:
python setup.py install
easy_install argparse
pip install argparse
putting argparse.py in some directory listed in sys.path should also work
Bugs
----
If you find a bug in argparse (pypi), please try to reproduce it with latest
python 2.7 and 3.4 (and use argparse from stdlib).
If it happens there also, please file a bug in the python.org issue tracker.
If it does not happen there, file a bug in the argparse package issue tracker.

@ -1,8 +0,0 @@
argparse-1.4.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
argparse-1.4.0.dist-info/LICENSE.txt,sha256=bVBNRcTRCfkl7wWJYLbRzicSu2tXk-kmv8FRcWrHQEg,741
argparse-1.4.0.dist-info/METADATA,sha256=yZGPMA4uvkui2P7qaaiI89zqwjDbyFcehJG4j5Pk8Yk,2816
argparse-1.4.0.dist-info/RECORD,,
argparse-1.4.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
argparse-1.4.0.dist-info/WHEEL,sha256=P2T-6epvtXQ2cBOE_U1K4_noqlJFN3tj15djMgEu4NM,110
argparse-1.4.0.dist-info/top_level.txt,sha256=TgiWrQsF0mKWwqS2KHLORD0ZtqYHPRGdCAAzKwtVvJ4,9
argparse.py,sha256=0ksYqisQDQvhoiuo19JERCSpg51tc641GFJIx7pTA0g,89214

@ -1,6 +0,0 @@
Wheel-Version: 1.0
Generator: bdist_wheel (0.41.3)
Root-Is-Purelib: true
Tag: py2-none-any
Tag: py3-none-any

File diff suppressed because it is too large Load Diff

@ -1,13 +0,0 @@
fese-0.2.9.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
fese-0.2.9.dist-info/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
fese-0.2.9.dist-info/METADATA,sha256=nJz9q6FwX7fqmsO3jgM0ZgV0gsCeILWoxVRUqCbJkFI,655
fese-0.2.9.dist-info/RECORD,,
fese-0.2.9.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
fese-0.2.9.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
fese-0.2.9.dist-info/top_level.txt,sha256=ra2BuARVEUZpk76YpHnjVoqjR2FxvzhCdmW2OyBWGzE,5
fese/__init__.py,sha256=_YUpx7sq26ioEp5LZOEKa-0MrRHQUuRuDCs0EQ6Amv4,150
fese/container.py,sha256=sLuxP0vlba4iGVohGfYtd-QcjQ-YxMU6lqMOM-Wtqlc,10340
fese/disposition.py,sha256=hv4YmXpsvKmUdpeWvSrZkhKgtZLZ8t56dmwMddsqxus,2156
fese/exceptions.py,sha256=VZaubpq8SPpkUGp28Ryebsf9YzqbKK62nni6YZgDPYI,372
fese/stream.py,sha256=Hgf6-amksHpuhSoY6SL6C3q4YtGCuRHl4fusBWE9nBE,4866
fese/tags.py,sha256=qKkcjJmCKgnXIbZ9x-nngCNYAfv5cbJZ4A6EP0ckZME,5454

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save