From b304f6f1efecdfa5b258138029b54460267e8032 Mon Sep 17 00:00:00 2001 From: JayZed Date: Wed, 24 Jul 2024 13:09:30 -0400 Subject: [PATCH] Added new feature: Tag-Based Automatic Language Profile Selection --- bazarr/api/system/settings.py | 2 + bazarr/app/config.py | 2 + bazarr/app/database.py | 9 ++- bazarr/radarr/sync/movies.py | 13 +++- bazarr/radarr/sync/parser.py | 17 ++++- bazarr/sonarr/sync/parser.py | 72 ++++++++++--------- bazarr/sonarr/sync/series.py | 12 +++- .../forms/ProfileEditForm.module.scss | 8 +++ .../src/components/forms/ProfileEditForm.tsx | 31 +++++++- .../src/pages/Settings/Languages/index.tsx | 22 ++++++ .../src/pages/Settings/Languages/table.tsx | 5 ++ frontend/src/types/api.d.ts | 1 + 12 files changed, 150 insertions(+), 44 deletions(-) diff --git a/bazarr/api/system/settings.py b/bazarr/api/system/settings.py index 103df6304..126e2f7a5 100644 --- a/bazarr/api/system/settings.py +++ b/bazarr/api/system/settings.py @@ -73,6 +73,7 @@ class SystemSettings(Resource): mustNotContain=str(item['mustNotContain']), originalFormat=int(item['originalFormat']) if item['originalFormat'] not in None_Keys else None, + tag=item['tag'] if 'tag' in item else None, ) .where(TableLanguagesProfiles.profileId == item['profileId'])) existing.remove(item['profileId']) @@ -89,6 +90,7 @@ class SystemSettings(Resource): mustNotContain=str(item['mustNotContain']), originalFormat=int(item['originalFormat']) if item['originalFormat'] not in None_Keys else None, + tag=item['tag'] if 'tag' in item else None, )) for profileId in existing: # Remove deleted profiles diff --git a/bazarr/app/config.py b/bazarr/app/config.py index f5203da84..aebdf5dc3 100644 --- a/bazarr/app/config.py +++ b/bazarr/app/config.py @@ -88,6 +88,8 @@ validators = [ 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.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.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.movie_default_enabled', must_exist=True, default=False, is_type_of=bool), diff --git a/bazarr/app/database.py b/bazarr/app/database.py index fa612c4eb..3780befea 100644 --- a/bazarr/app/database.py +++ b/bazarr/app/database.py @@ -379,6 +379,7 @@ def update_profile_id_list(): 'mustContain': ast.literal_eval(x.mustContain) if x.mustContain else [], 'mustNotContain': ast.literal_eval(x.mustNotContain) if x.mustNotContain else [], 'originalFormat': x.originalFormat, + 'tag': x.tag, } for x in database.execute( select(TableLanguagesProfiles.profileId, TableLanguagesProfiles.name, @@ -386,7 +387,8 @@ def update_profile_id_list(): TableLanguagesProfiles.items, TableLanguagesProfiles.mustContain, TableLanguagesProfiles.mustNotContain, - TableLanguagesProfiles.originalFormat)) + TableLanguagesProfiles.originalFormat, + TableLanguagesProfiles.tag)) .all() ] @@ -421,7 +423,7 @@ def get_profile_cutoff(profile_id): if profile_id and profile_id != 'null': cutoff_language = [] 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 profileId == int(profile_id): for item in items: @@ -511,7 +513,8 @@ def upgrade_languages_profile_hi_values(): TableLanguagesProfiles.items, TableLanguagesProfiles.mustContain, TableLanguagesProfiles.mustNotContain, - TableLanguagesProfiles.originalFormat) + TableLanguagesProfiles.originalFormat, + TableLanguagesProfiles.tag) ))\ .all(): items = json.loads(languages_profile.items) diff --git a/bazarr/radarr/sync/movies.py b/bazarr/radarr/sync/movies.py index 82416cffb..6273a0a8d 100644 --- a/bazarr/radarr/sync/movies.py +++ b/bazarr/radarr/sync/movies.py @@ -28,6 +28,11 @@ def trace(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(): movies_full_scan_subtitles() logging.info('BAZARR All existing movie subtitles indexed from disk.') @@ -108,6 +113,7 @@ def update_movies(send_event=True): else: audio_profiles = get_profile_list() tagsDict = get_tags() + language_profiles = get_language_profiles() # Get movies data from 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: parsed_movie = movieParser(movie, action='update', tags_dict=tagsDict, + language_profiles=language_profiles, movie_default_profile=movie_default_profile, audio_profiles=audio_profiles) 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: parsed_movie = movieParser(movie, action='insert', tags_dict=tagsDict, + language_profiles=language_profiles, movie_default_profile=movie_default_profile, audio_profiles=audio_profiles) 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() tagsDict = get_tags() + language_profiles = get_language_profiles() try: # Get movie data from radarr api @@ -256,10 +265,10 @@ def update_one_movie(movie_id, action, defer_search=False): return else: 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) 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) except Exception: logging.exception('BAZARR cannot get movie returned by SignalR feed from Radarr API.') diff --git a/bazarr/radarr/sync/parser.py b/bazarr/radarr/sync/parser.py index 9648152c2..d5f3e08c8 100644 --- a/bazarr/radarr/sync/parser.py +++ b/bazarr/radarr/sync/parser.py @@ -11,7 +11,17 @@ from utilities.path_mappings import path_mappings 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: try: overview = str(movie['overview']) @@ -140,6 +150,11 @@ def movieParser(movie, action, tags_dict, movie_default_profile, audio_profiles) parsed_movie['subtitles'] = '[]' 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 + return parsed_movie diff --git a/bazarr/sonarr/sync/parser.py b/bazarr/sonarr/sync/parser.py index d8fce1697..32980cbab 100644 --- a/bazarr/sonarr/sync/parser.py +++ b/bazarr/sonarr/sync/parser.py @@ -12,7 +12,17 @@ from sonarr.info import get_sonarr_info 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 '' poster = '' fanart = '' @@ -42,39 +52,33 @@ def seriesParser(show, action, tags_dict, serie_default_profile, audio_profiles) else: audio_language = [] - if action == 'update': - return {'title': show["title"], - 'path': show["path"], - 'tvdbId': int(show["tvdbId"]), - 'sonarrSeriesId': int(show["id"]), - 'overview': overview, - 'poster': poster, - 'fanart': fanart, - 'audio_language': str(audio_language), - 'sortTitle': show['sortTitle'], - 'year': str(show['year']), - 'alternativeTitles': alternate_titles, - 'tags': str(tags), - 'seriesType': show['seriesType'], - 'imdbId': imdbId, - 'monitored': str(bool(show['monitored']))} - else: - return {'title': show["title"], - 'path': show["path"], - 'tvdbId': show["tvdbId"], - 'sonarrSeriesId': show["id"], - 'overview': overview, - 'poster': poster, - 'fanart': fanart, - 'audio_language': str(audio_language), - 'sortTitle': show['sortTitle'], - 'year': str(show['year']), - 'alternativeTitles': alternate_titles, - 'tags': str(tags), - 'seriesType': show['seriesType'], - 'imdbId': imdbId, - 'profileId': serie_default_profile, - 'monitored': str(bool(show['monitored']))} + parsed_series = { + 'title': show["title"], + 'path': show["path"], + 'tvdbId': int(show["tvdbId"]), + 'sonarrSeriesId': int(show["id"]), + 'overview': overview, + 'poster': poster, + 'fanart': fanart, + 'audio_language': str(audio_language), + 'sortTitle': show['sortTitle'], + 'year': str(show['year']), + 'alternativeTitles': alternate_titles, + 'tags': str(tags), + 'seriesType': show['seriesType'], + 'imdbId': imdbId, + 'monitored': str(bool(show['monitored'])) + } + + if action == 'insert': + parsed_series['profileId'] = serie_default_profile + + if settings.general.serie_tag_enabled: + tag_profile = get_matching_profile(tags, language_profiles) + if tag_profile: + parsed_series['profileId'] = tag_profile + + return parsed_series def profile_id_to_language(id_, profiles): diff --git a/bazarr/sonarr/sync/series.py b/bazarr/sonarr/sync/series.py index 96a00009c..f8bd84990 100644 --- a/bazarr/sonarr/sync/series.py +++ b/bazarr/sonarr/sync/series.py @@ -26,6 +26,11 @@ def trace(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(): series_monitored = database.execute( select(TableShows.tvdbId, TableShows.monitored))\ @@ -58,6 +63,7 @@ def update_series(send_event=True): audio_profiles = get_profile_list() tagsDict = get_tags() + language_profiles = get_language_profiles() # Get shows data from 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: updated_series = seriesParser(show, action='update', tags_dict=tagsDict, + language_profiles=language_profiles, serie_default_profile=serie_default_profile, audio_profiles=audio_profiles) @@ -132,6 +139,7 @@ def update_series(send_event=True): event_stream(type='series', payload=show['id']) else: added_series = seriesParser(show, action='insert', tags_dict=tagsDict, + language_profiles=language_profiles, serie_default_profile=serie_default_profile, audio_profiles=audio_profiles) @@ -203,7 +211,7 @@ def update_one_series(series_id, action): audio_profiles = get_profile_list() tagsDict = get_tags() - + language_profiles = get_language_profiles() try: # Get series data from sonarr api series = None @@ -215,10 +223,12 @@ def update_one_series(series_id, action): else: if action == 'updated' and existing_series: series = seriesParser(series_data[0], action='update', tags_dict=tagsDict, + language_profiles=language_profiles, serie_default_profile=serie_default_profile, audio_profiles=audio_profiles) elif action == 'updated' and not existing_series: series = seriesParser(series_data[0], action='insert', tags_dict=tagsDict, + language_profiles=language_profiles, serie_default_profile=serie_default_profile, audio_profiles=audio_profiles) except Exception: diff --git a/frontend/src/components/forms/ProfileEditForm.module.scss b/frontend/src/components/forms/ProfileEditForm.module.scss index d98b850ff..3d4a8e177 100644 --- a/frontend/src/components/forms/ProfileEditForm.module.scss +++ b/frontend/src/components/forms/ProfileEditForm.module.scss @@ -3,3 +3,11 @@ padding: 0; } } + +.evenly { + flex-wrap: wrap; + + & > div { + flex: 1; + } +} diff --git a/frontend/src/components/forms/ProfileEditForm.tsx b/frontend/src/components/forms/ProfileEditForm.tsx index 75e2f9df7..267951fcb 100644 --- a/frontend/src/components/forms/ProfileEditForm.tsx +++ b/frontend/src/components/forms/ProfileEditForm.tsx @@ -3,6 +3,7 @@ import { Accordion, Button, Checkbox, + Flex, Select, Stack, Switch, @@ -72,9 +73,16 @@ const ProfileEditForm: FunctionComponent = ({ (value) => value.length > 0, "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( (value) => value.length > 0, - "Must contain at lease 1 language", + "Must contain at least 1 language", ), }, }); @@ -265,7 +273,24 @@ const ProfileEditForm: FunctionComponent = ({ })} > - + + + + form.setFieldValue( + "tag", + (prev) => + prev?.toLowerCase().trim().replace(/\s+/g, "_") ?? undefined, + ) + } + > + = ({ > - {form.errors.items} = ({ + {form.errors.items} {
+
+ + 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 mutiple 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, but keep it, as is. + + + +
{ header: "Name", accessorKey: "name", }, + { + header: "Tag", + accessorKey: "tag", + }, { header: "Languages", accessorKey: "items", @@ -178,6 +182,7 @@ const Table: FunctionComponent = () => { const profile = { profileId: nextProfileId, name: "", + tag: undefined, items: [], cutoff: null, mustContain: [], diff --git a/frontend/src/types/api.d.ts b/frontend/src/types/api.d.ts index 069be3029..e8bd4483e 100644 --- a/frontend/src/types/api.d.ts +++ b/frontend/src/types/api.d.ts @@ -40,6 +40,7 @@ declare namespace Language { mustContain: string[]; mustNotContain: string[]; originalFormat: boolean | null; + tag: string | undefined; } }