diff --git a/.github/workflows/schedule.yaml b/.github/workflows/schedule.yaml index 93a1a1ecd..7601b4012 100644 --- a/.github/workflows/schedule.yaml +++ b/.github/workflows/schedule.yaml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Execute - uses: benc-uk/workflow-dispatch@v1.2.3 + uses: benc-uk/workflow-dispatch@v1.2.4 with: workflow: "release_beta_to_dev" token: ${{ secrets.WF_GITHUB_TOKEN }} diff --git a/bazarr.py b/bazarr.py index 7e75272b0..2a1adea1d 100644 --- a/bazarr.py +++ b/bazarr.py @@ -25,8 +25,8 @@ def check_python_version(): print("Python " + minimum_py3_str + " or greater required. " "Current version is " + platform.python_version() + ". Please upgrade Python.") exit_program(EXIT_PYTHON_UPGRADE_NEEDED) - elif int(python_version[0]) == 3 and int(python_version[1]) > 11: - print("Python version greater than 3.11.x is unsupported. Current version is " + platform.python_version() + + elif int(python_version[0]) == 3 and int(python_version[1]) > 12: + print("Python version greater than 3.12.x is unsupported. Current version is " + platform.python_version() + ". Keep in mind that even if it works, you're on your own.") elif (int(python_version[0]) == minimum_py3_tuple[0] and int(python_version[1]) < minimum_py3_tuple[1]) or \ (int(python_version[0]) != minimum_py3_tuple[0]): diff --git a/bazarr/api/episodes/history.py b/bazarr/api/episodes/history.py index 026397363..9855ce222 100644 --- a/bazarr/api/episodes/history.py +++ b/bazarr/api/episodes/history.py @@ -95,13 +95,10 @@ class EpisodesHistory(Resource): TableHistory.matched, TableHistory.not_matched, TableEpisodes.subtitles.label('external_subtitles'), - upgradable_episodes_not_perfect.c.id.label('upgradable'), blacklisted_subtitles.c.subs_id.label('blacklisted')) \ .select_from(TableHistory) \ .join(TableShows, onclause=TableHistory.sonarrSeriesId == TableShows.sonarrSeriesId) \ .join(TableEpisodes, onclause=TableHistory.sonarrEpisodeId == TableEpisodes.sonarrEpisodeId) \ - .join(upgradable_episodes_not_perfect, onclause=TableHistory.id == upgradable_episodes_not_perfect.c.id, - isouter=True) \ .join(blacklisted_subtitles, onclause=TableHistory.subs_id == blacklisted_subtitles.c.subs_id, isouter=True) \ .where(reduce(operator.and_, query_conditions)) \ @@ -120,6 +117,7 @@ class EpisodesHistory(Resource): 'sonarrSeriesId': x.sonarrSeriesId, 'path': x.path, 'language': x.language, + 'profileId': x.profileId, 'score': x.score, 'tags': x.tags, 'action': x.action, @@ -130,24 +128,29 @@ class EpisodesHistory(Resource): 'matches': x.matched, 'dont_matches': x.not_matched, 'external_subtitles': [y[1] for y in ast.literal_eval(x.external_subtitles) if y[1]], - 'upgradable': bool(x.upgradable) if _language_still_desired(x.language, x.profileId) else False, 'blacklisted': bool(x.blacklisted), } for x in database.execute(stmt).all()] for item in episode_history: - original_video_path = item['path'] - original_subtitle_path = item['subtitles_path'] + # is this language still desired or should we simply skip this subtitles from upgrade logic? + still_desired = _language_still_desired(item['language'], item['profileId']) + item.update(postprocess(item)) - # Mark not upgradable if score is perfect or if video/subtitles file doesn't exist anymore + # Mark upgradable and get original_id + item.update({'original_id': upgradable_episodes_not_perfect.get(item['id'])}) + item.update({'upgradable': bool(item['original_id'])}) + + # Mark not upgradable if video/subtitles file doesn't exist anymore or if language isn't desired anymore if item['upgradable']: - if original_subtitle_path not in item['external_subtitles'] or \ - not item['video_path'] == original_video_path: + if (item['subtitles_path'] not in item['external_subtitles'] or item['video_path'] != item['path'] or + not still_desired): item.update({"upgradable": False}) del item['path'] del item['video_path'] del item['external_subtitles'] + del item['profileId'] if item['score']: item['score'] = f"{round((int(item['score']) * 100 / 360), 2)}%" diff --git a/bazarr/api/episodes/wanted.py b/bazarr/api/episodes/wanted.py index ae7337751..caa656024 100644 --- a/bazarr/api/episodes/wanted.py +++ b/bazarr/api/episodes/wanted.py @@ -48,7 +48,8 @@ class EpisodesWanted(Resource): args = self.get_request_parser.parse_args() episodeid = args.get('episodeid[]') - wanted_conditions = [(TableEpisodes.missing_subtitles != '[]')] + wanted_conditions = [(TableEpisodes.missing_subtitles.is_not(None)), + (TableEpisodes.missing_subtitles != '[]')] if len(episodeid) > 0: wanted_conditions.append((TableEpisodes.sonarrEpisodeId in episodeid)) start = 0 diff --git a/bazarr/api/movies/history.py b/bazarr/api/movies/history.py index d7e7d6783..3106d1441 100644 --- a/bazarr/api/movies/history.py +++ b/bazarr/api/movies/history.py @@ -90,12 +90,9 @@ class MoviesHistory(Resource): TableHistoryMovie.not_matched, TableMovies.profileId, TableMovies.subtitles.label('external_subtitles'), - upgradable_movies_not_perfect.c.id.label('upgradable'), blacklisted_subtitles.c.subs_id.label('blacklisted')) \ .select_from(TableHistoryMovie) \ .join(TableMovies) \ - .join(upgradable_movies_not_perfect, onclause=TableHistoryMovie.id == upgradable_movies_not_perfect.c.id, - isouter=True) \ .join(blacklisted_subtitles, onclause=TableHistoryMovie.subs_id == blacklisted_subtitles.c.subs_id, isouter=True) \ .where(reduce(operator.and_, query_conditions)) \ @@ -112,6 +109,7 @@ class MoviesHistory(Resource): 'monitored': x.monitored, 'path': x.path, 'language': x.language, + 'profileId': x.profileId, 'tags': x.tags, 'score': x.score, 'subs_id': x.subs_id, @@ -121,24 +119,29 @@ class MoviesHistory(Resource): 'matches': x.matched, 'dont_matches': x.not_matched, 'external_subtitles': [y[1] for y in ast.literal_eval(x.external_subtitles) if y[1]], - 'upgradable': bool(x.upgradable) if _language_still_desired(x.language, x.profileId) else False, 'blacklisted': bool(x.blacklisted), } for x in database.execute(stmt).all()] for item in movie_history: - original_video_path = item['path'] - original_subtitle_path = item['subtitles_path'] + # is this language still desired or should we simply skip this subtitles from upgrade logic? + still_desired = _language_still_desired(item['language'], item['profileId']) + item.update(postprocess(item)) - # Mark not upgradable if score or if video/subtitles file doesn't exist anymore + # Mark upgradable and get original_id + item.update({'original_id': upgradable_movies_not_perfect.get(item['id'])}) + item.update({'upgradable': bool(item['original_id'])}) + + # Mark not upgradable if video/subtitles file doesn't exist anymore or if language isn't desired anymore if item['upgradable']: - if original_subtitle_path not in item['external_subtitles'] or \ - not item['video_path'] == original_video_path: + if (item['subtitles_path'] not in item['external_subtitles'] or item['video_path'] != item['path'] or + not still_desired): item.update({"upgradable": False}) del item['path'] del item['video_path'] del item['external_subtitles'] + del item['profileId'] if item['score']: item['score'] = f"{round((int(item['score']) * 100 / 120), 2)}%" diff --git a/bazarr/api/movies/wanted.py b/bazarr/api/movies/wanted.py index 7ee648fc5..406c1c6b4 100644 --- a/bazarr/api/movies/wanted.py +++ b/bazarr/api/movies/wanted.py @@ -45,7 +45,8 @@ class MoviesWanted(Resource): args = self.get_request_parser.parse_args() radarrid = args.get("radarrid[]") - wanted_conditions = [(TableMovies.missing_subtitles != '[]')] + wanted_conditions = [(TableMovies.missing_subtitles.is_not(None)), + (TableMovies.missing_subtitles != '[]')] if len(radarrid) > 0: wanted_conditions.append((TableMovies.radarrId.in_(radarrid))) start = 0 diff --git a/bazarr/api/providers/providers_episodes.py b/bazarr/api/providers/providers_episodes.py index 9d880717e..9cf365300 100644 --- a/bazarr/api/providers/providers_episodes.py +++ b/bazarr/api/providers/providers_episodes.py @@ -11,7 +11,7 @@ from subtitles.manual import manual_search, manual_download_subtitle from sonarr.history import history_log from app.config import settings from app.notifier import send_notifications -from subtitles.indexer.series import store_subtitles +from subtitles.indexer.series import store_subtitles, list_missing_subtitles from subtitles.processing import ProcessSubtitlesResult from ..utils import authenticate @@ -50,18 +50,27 @@ class ProviderEpisodes(Resource): """Search manually for an episode subtitles""" args = self.get_request_parser.parse_args() sonarrEpisodeId = args.get('episodeid') - episodeInfo = database.execute( - select(TableEpisodes.path, - TableEpisodes.sceneName, - TableShows.title, - TableShows.profileId) - .select_from(TableEpisodes) - .join(TableShows) - .where(TableEpisodes.sonarrEpisodeId == sonarrEpisodeId)) \ - .first() + stmt = select(TableEpisodes.path, + TableEpisodes.sceneName, + TableShows.title, + TableShows.profileId, + TableEpisodes.subtitles, + TableEpisodes.missing_subtitles) \ + .select_from(TableEpisodes) \ + .join(TableShows) \ + .where(TableEpisodes.sonarrEpisodeId == sonarrEpisodeId) + episodeInfo = database.execute(stmt).first() if not episodeInfo: return 'Episode not found', 404 + elif episodeInfo.subtitles is None: + # subtitles indexing for this episode is incomplete, we'll do it again + store_subtitles(episodeInfo.path, path_mappings.path_replace(episodeInfo.path)) + episodeInfo = database.execute(stmt).first() + elif episodeInfo.missing_subtitles is None: + # missing subtitles calculation for this episode is incomplete, we'll do it again + list_missing_subtitles(epno=sonarrEpisodeId) + episodeInfo = database.execute(stmt).first() title = episodeInfo.title episodePath = path_mappings.path_replace(episodeInfo.path) diff --git a/bazarr/api/providers/providers_movies.py b/bazarr/api/providers/providers_movies.py index 92b6f9995..464ee1fac 100644 --- a/bazarr/api/providers/providers_movies.py +++ b/bazarr/api/providers/providers_movies.py @@ -11,7 +11,7 @@ from subtitles.manual import manual_search, manual_download_subtitle from radarr.history import history_log_movie from app.config import settings from app.notifier import send_notifications_movie -from subtitles.indexer.movies import store_subtitles_movie +from subtitles.indexer.movies import store_subtitles_movie, list_missing_subtitles_movies from subtitles.processing import ProcessSubtitlesResult from ..utils import authenticate @@ -51,16 +51,25 @@ class ProviderMovies(Resource): """Search manually for a movie subtitles""" args = self.get_request_parser.parse_args() radarrId = args.get('radarrid') - movieInfo = database.execute( - select(TableMovies.title, - TableMovies.path, - TableMovies.sceneName, - TableMovies.profileId) - .where(TableMovies.radarrId == radarrId)) \ - .first() + stmt = select(TableMovies.title, + TableMovies.path, + TableMovies.sceneName, + TableMovies.profileId, + TableMovies.subtitles, + TableMovies.missing_subtitles) \ + .where(TableMovies.radarrId == radarrId) + movieInfo = database.execute(stmt).first() if not movieInfo: return 'Movie not found', 404 + elif movieInfo.subtitles is None: + # subtitles indexing for this movie is incomplete, we'll do it again + store_subtitles_movie(movieInfo.path, path_mappings.path_replace_movie(movieInfo.path)) + movieInfo = database.execute(stmt).first() + elif movieInfo.missing_subtitles is None: + # missing subtitles calculation for this movie is incomplete, we'll do it again + list_missing_subtitles_movies(no=radarrId) + movieInfo = database.execute(stmt).first() title = movieInfo.title moviePath = path_mappings.path_replace_movie(movieInfo.path) diff --git a/bazarr/api/series/series.py b/bazarr/api/series/series.py index 13bda6f0d..a16db2322 100644 --- a/bazarr/api/series/series.py +++ b/bazarr/api/series/series.py @@ -34,9 +34,11 @@ class Series(Resource): 'alternativeTitles': fields.List(fields.String), 'audio_language': fields.Nested(get_audio_language_model), 'episodeFileCount': fields.Integer(default=0), + 'ended': fields.Boolean(), 'episodeMissingCount': fields.Integer(default=0), 'fanart': fields.String(), 'imdbId': fields.String(), + 'lastAired': fields.String(), 'monitored': fields.Boolean(), 'overview': fields.String(), 'path': fields.String(), @@ -73,7 +75,8 @@ class Series(Resource): .group_by(TableShows.sonarrSeriesId)\ .subquery() - episodes_missing_conditions = [(TableEpisodes.missing_subtitles != '[]')] + episodes_missing_conditions = [(TableEpisodes.missing_subtitles.is_not(None)), + (TableEpisodes.missing_subtitles != '[]')] episodes_missing_conditions += get_exclusion_clause('series') episodeMissingCount = select(TableShows.sonarrSeriesId, @@ -99,6 +102,8 @@ class Series(Resource): TableShows.tags, TableShows.title, TableShows.year, + TableShows.ended, + TableShows.lastAired, episodeFileCount.c.episodeFileCount, episodeMissingCount.c.episodeMissingCount) \ .select_from(TableShows) \ @@ -127,6 +132,8 @@ class Series(Resource): 'tags': x.tags, 'title': x.title, 'year': x.year, + 'ended': x.ended, + 'lastAired': x.lastAired, 'episodeFileCount': x.episodeFileCount, 'episodeMissingCount': x.episodeMissingCount, }) for x in database.execute(stmt).all()] diff --git a/bazarr/api/system/searches.py b/bazarr/api/system/searches.py index a5a3a4960..9bf10ec7a 100644 --- a/bazarr/api/system/searches.py +++ b/bazarr/api/system/searches.py @@ -3,7 +3,7 @@ from flask_restx import Resource, Namespace, reqparse from unidecode import unidecode -from app.config import settings +from app.config import base_url, settings from app.database import TableShows, TableMovies, database, select from ..utils import authenticate @@ -34,6 +34,7 @@ class Searches(Resource): search_list += database.execute( select(TableShows.title, TableShows.sonarrSeriesId, + TableShows.poster, TableShows.year) .order_by(TableShows.title)) \ .all() @@ -43,6 +44,7 @@ class Searches(Resource): search_list += database.execute( select(TableMovies.title, TableMovies.radarrId, + TableMovies.poster, TableMovies.year) .order_by(TableMovies.title)) \ .all() @@ -58,8 +60,11 @@ class Searches(Resource): if hasattr(x, 'sonarrSeriesId'): result['sonarrSeriesId'] = x.sonarrSeriesId + result['poster'] = f"{base_url}/images/series{x.poster}" if x.poster else None + else: result['radarrId'] = x.radarrId + result['poster'] = f"{base_url}/images/movies{x.poster}" if x.poster else None results.append(result) diff --git a/bazarr/api/utils.py b/bazarr/api/utils.py index 534b4b3e8..75d3bd9bd 100644 --- a/bazarr/api/utils.py +++ b/bazarr/api/utils.py @@ -134,10 +134,21 @@ def postprocess(item): if item.get('path'): item['path'] = path_replace(item['path']) + if item.get('video_path'): + # Provide mapped video path for history + item['video_path'] = path_replace(item['video_path']) + if item.get('subtitles_path'): # Provide mapped subtitles path item['subtitles_path'] = path_replace(item['subtitles_path']) + if item.get('external_subtitles'): + # Provide mapped external subtitles paths for history + if isinstance(item['external_subtitles'], str): + item['external_subtitles'] = ast.literal_eval(item['external_subtitles']) + for i, subs in enumerate(item['external_subtitles']): + item['external_subtitles'][i] = path_replace(subs) + # map poster and fanart to server proxy if item.get('poster') is not None: poster = item['poster'] diff --git a/bazarr/app/config.py b/bazarr/app/config.py index 15e9959f6..585b73729 100644 --- a/bazarr/app/config.py +++ b/bazarr/app/config.py @@ -239,6 +239,10 @@ validators = [ Validator('opensubtitlescom.use_hash', must_exist=True, default=True, is_type_of=bool), Validator('opensubtitlescom.include_ai_translated', must_exist=True, default=False, is_type_of=bool), + # napiprojekt section + Validator('napiprojekt.only_authors', must_exist=True, default=False, is_type_of=bool), + Validator('napiprojekt.only_real_names', must_exist=True, default=False, is_type_of=bool), + # addic7ed section Validator('addic7ed.username', must_exist=True, default='', is_type_of=str, cast=str), Validator('addic7ed.password', must_exist=True, default='', is_type_of=str, cast=str), @@ -274,6 +278,7 @@ validators = [ Validator('whisperai.endpoint', must_exist=True, default='http://127.0.0.1:9000', is_type_of=str), Validator('whisperai.response', must_exist=True, default=5, is_type_of=int, gte=1), Validator('whisperai.timeout', must_exist=True, default=3600, is_type_of=int, gte=1), + Validator('whisperai.pass_video_name', must_exist=True, default=False, is_type_of=bool), Validator('whisperai.loglevel', must_exist=True, default='INFO', is_type_of=str, is_in=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']), @@ -328,6 +333,7 @@ validators = [ Validator('titulky.username', must_exist=True, default='', is_type_of=str, cast=str), Validator('titulky.password', must_exist=True, default='', is_type_of=str, cast=str), Validator('titulky.approved_only', must_exist=True, default=False, is_type_of=bool), + Validator('titulky.skip_wrong_fps', must_exist=True, default=False, is_type_of=bool), # embeddedsubtitles section Validator('embeddedsubtitles.included_codecs', must_exist=True, default=[], is_type_of=list), diff --git a/bazarr/app/database.py b/bazarr/app/database.py index b931c5f0f..6166e4258 100644 --- a/bazarr/app/database.py +++ b/bazarr/app/database.py @@ -136,6 +136,7 @@ class TableEpisodes(Base): audio_codec = mapped_column(Text) audio_language = mapped_column(Text) + created_at_timestamp = mapped_column(DateTime) episode = mapped_column(Integer, nullable=False) episode_file_id = mapped_column(Integer) failedAttempts = mapped_column(Text) @@ -152,6 +153,7 @@ class TableEpisodes(Base): sonarrSeriesId = mapped_column(Integer, ForeignKey('table_shows.sonarrSeriesId', ondelete='CASCADE')) subtitles = mapped_column(Text) title = mapped_column(Text, nullable=False) + updated_at_timestamp = mapped_column(DateTime) video_codec = mapped_column(Text) @@ -213,6 +215,7 @@ class TableMovies(Base): alternativeTitles = mapped_column(Text) audio_codec = mapped_column(Text) audio_language = mapped_column(Text) + created_at_timestamp = mapped_column(DateTime) failedAttempts = mapped_column(Text) fanart = mapped_column(Text) ffprobe_cache = mapped_column(LargeBinary) @@ -234,6 +237,7 @@ class TableMovies(Base): tags = mapped_column(Text) title = mapped_column(Text, nullable=False) tmdbId = mapped_column(Text, nullable=False, unique=True) + updated_at_timestamp = mapped_column(DateTime) video_codec = mapped_column(Text) year = mapped_column(Text) @@ -271,8 +275,11 @@ class TableShows(Base): tvdbId = mapped_column(Integer) alternativeTitles = mapped_column(Text) audio_language = mapped_column(Text) + created_at_timestamp = mapped_column(DateTime) + ended = mapped_column(Text) fanart = mapped_column(Text) imdbId = mapped_column(Text) + lastAired = mapped_column(Text) monitored = mapped_column(Text) overview = mapped_column(Text) path = mapped_column(Text, nullable=False, unique=True) @@ -283,6 +290,7 @@ class TableShows(Base): sortTitle = mapped_column(Text) tags = mapped_column(Text) title = mapped_column(Text, nullable=False) + updated_at_timestamp = mapped_column(DateTime) year = mapped_column(Text) diff --git a/bazarr/app/get_providers.py b/bazarr/app/get_providers.py index 125b24d40..a4316a9d5 100644 --- a/bazarr/app/get_providers.py +++ b/bazarr/app/get_providers.py @@ -30,7 +30,6 @@ from radarr.blacklist import blacklist_log_movie from sonarr.blacklist import blacklist_log from utilities.analytics import event_tracker - _TRACEBACK_RE = re.compile(r'File "(.*?providers[\\/].*?)", line (\d+)') @@ -41,7 +40,7 @@ def time_until_midnight(timezone): """ now_in_tz = datetime.datetime.now(tz=timezone) midnight = now_in_tz.replace(hour=0, minute=0, second=0, microsecond=0) + \ - datetime.timedelta(days=1) + datetime.timedelta(days=1) return midnight - now_in_tz @@ -91,7 +90,7 @@ def provider_throttle_map(): }, "opensubtitlescom": { TooManyRequests: (datetime.timedelta(minutes=1), "1 minute"), - DownloadLimitExceeded: (datetime.timedelta(hours=24), "24 hours"), + DownloadLimitExceeded: (datetime.timedelta(hours=6), "6 hours"), }, "addic7ed": { DownloadLimitExceeded: (datetime.timedelta(hours=3), "3 hours"), @@ -101,6 +100,9 @@ def provider_throttle_map(): "titlovi": { TooManyRequests: (datetime.timedelta(minutes=5), "5 minutes"), }, + "titrari": { + TooManyRequests: (datetime.timedelta(minutes=10), "10 minutes"), + }, "titulky": { DownloadLimitExceeded: ( titulky_limit_reset_timedelta(), @@ -254,6 +256,8 @@ def get_providers_auth(): 'include_ai_translated': settings.opensubtitlescom.include_ai_translated, 'api_key': 's38zmzVlW7IlYruWi7mHwDYl2SfMQoC1' }, + 'napiprojekt': {'only_authors': settings.napiprojekt.only_authors, + 'only_real_names': settings.napiprojekt.only_real_names}, 'podnapisi': { 'only_foreign': False, # fixme 'also_foreign': False, # fixme @@ -284,6 +288,7 @@ def get_providers_auth(): 'username': settings.titulky.username, 'password': settings.titulky.password, 'approved_only': settings.titulky.approved_only, + 'skip_wrong_fps': settings.titulky.skip_wrong_fps, }, 'titlovi': { 'username': settings.titlovi.username, @@ -329,6 +334,7 @@ def get_providers_auth(): 'timeout': settings.whisperai.timeout, 'ffmpeg_path': _FFMPEG_BINARY, 'loglevel': settings.whisperai.loglevel, + 'pass_video_name': settings.whisperai.pass_video_name, }, "animetosho": { 'search_threshold': settings.animetosho.search_threshold, @@ -367,7 +373,7 @@ def provider_throttle(name, exception, ids=None, language=None): cls = valid_cls throttle_data = provider_throttle_map().get(name, provider_throttle_map()["default"]).get(cls, None) or \ - provider_throttle_map()["default"].get(cls, None) + provider_throttle_map()["default"].get(cls, None) if throttle_data: throttle_delta, throttle_description = throttle_data @@ -377,7 +383,8 @@ def provider_throttle(name, exception, ids=None, language=None): throttle_until = datetime.datetime.now() + throttle_delta if cls_name not in VALID_COUNT_EXCEPTIONS or throttled_count(name): - if cls_name == 'ValueError' and isinstance(exception.args, tuple) and len(exception.args) and exception.args[0].startswith('unsupported pickle protocol'): + if cls_name == 'ValueError' and isinstance(exception.args, tuple) and len(exception.args) and exception.args[ + 0].startswith('unsupported pickle protocol'): for fn in subliminal_cache_region.backend.all_filenames: try: os.remove(fn) diff --git a/bazarr/app/server.py b/bazarr/app/server.py index 4ffe436ce..85c2eb680 100644 --- a/bazarr/app/server.py +++ b/bazarr/app/server.py @@ -49,12 +49,12 @@ class Server: threads=100) self.connected = True except OSError as error: - if error.errno == errno.EADDRNOTAVAIL: + if error.errno == 49: logging.exception("BAZARR cannot bind to specified IP, trying with 0.0.0.0") self.address = '0.0.0.0' self.connected = False super(Server, self).__init__() - elif error.errno == errno.EADDRINUSE: + elif error.errno == 48: if self.port != '6767': logging.exception("BAZARR cannot bind to specified TCP port, trying with default (6767)") self.port = '6767' @@ -64,6 +64,11 @@ class Server: logging.exception("BAZARR cannot bind to default TCP port (6767) because it's already in use, " "exiting...") self.shutdown(EXIT_PORT_ALREADY_IN_USE_ERROR) + elif error.errno == 97: + logging.exception("BAZARR cannot bind to IPv6 (*), trying with 0.0.0.0") + self.address = '0.0.0.0' + self.connected = False + super(Server, self).__init__() else: logging.exception("BAZARR cannot start because of unhandled exception.") self.shutdown() diff --git a/bazarr/radarr/history.py b/bazarr/radarr/history.py index 79b301c2d..d0cfe78c7 100644 --- a/bazarr/radarr/history.py +++ b/bazarr/radarr/history.py @@ -6,7 +6,7 @@ from app.database import TableHistoryMovie, database, insert from app.event_handler import event_stream -def history_log_movie(action, radarr_id, result, fake_provider=None, fake_score=None): +def history_log_movie(action, radarr_id, result, fake_provider=None, fake_score=None, upgraded_from_id=None): description = result.message video_path = result.path language = result.language_code @@ -31,6 +31,7 @@ def history_log_movie(action, radarr_id, result, fake_provider=None, fake_score= subs_id=subs_id, subtitles_path=subtitles_path, matched=str(matched) if matched else None, - not_matched=str(not_matched) if not_matched else None + not_matched=str(not_matched) if not_matched else None, + upgradedFromId=upgraded_from_id, )) event_stream(type='movie-history') diff --git a/bazarr/radarr/sync/movies.py b/bazarr/radarr/sync/movies.py index a5ecf0416..c4cb5ab96 100644 --- a/bazarr/radarr/sync/movies.py +++ b/bazarr/radarr/sync/movies.py @@ -5,6 +5,7 @@ import logging from constants import MINIMUM_VIDEO_SIZE from sqlalchemy.exc import IntegrityError +from datetime import datetime from app.config import settings from utilities.path_mappings import path_mappings @@ -49,6 +50,7 @@ def get_movie_file_size_from_db(movie_path): # Update movies in DB def update_movie(updated_movie, send_event): try: + updated_movie['updated_at_timestamp'] = datetime.now() database.execute( update(TableMovies).values(updated_movie) .where(TableMovies.tmdbId == updated_movie['tmdbId'])) @@ -75,6 +77,7 @@ def get_movie_monitored_status(movie_id): # Insert new movies in DB def add_movie(added_movie, send_event): try: + added_movie['created_at_timestamp'] = datetime.now() database.execute( insert(TableMovies) .values(added_movie)) @@ -203,7 +206,11 @@ def update_movies(send_event=True): files_missing += 1 if send_event: - hide_progress(id='movies_progress') + show_progress(id='movies_progress', + header='Syncing movies...', + name='', + value=movies_count, + count=movies_count) trace(f"Skipped {files_missing} file missing movies out of {movies_count}") if sync_monitored: @@ -296,6 +303,7 @@ def update_one_movie(movie_id, action, defer_search=False): # Update existing movie in DB elif movie and existing_movie: try: + movie['updated_at_timestamp'] = datetime.now() database.execute( update(TableMovies) .values(movie) @@ -312,6 +320,7 @@ def update_one_movie(movie_id, action, defer_search=False): # Insert new movie in DB elif movie and not existing_movie: try: + movie['created_at_timestamp'] = datetime.now() database.execute( insert(TableMovies) .values(movie)) diff --git a/bazarr/sonarr/history.py b/bazarr/sonarr/history.py index 7d85ca0ab..c3f285aab 100644 --- a/bazarr/sonarr/history.py +++ b/bazarr/sonarr/history.py @@ -6,7 +6,8 @@ from app.database import TableHistory, database, insert from app.event_handler import event_stream -def history_log(action, sonarr_series_id, sonarr_episode_id, result, fake_provider=None, fake_score=None): +def history_log(action, sonarr_series_id, sonarr_episode_id, result, fake_provider=None, fake_score=None, + upgraded_from_id=None): description = result.message video_path = result.path language = result.language_code @@ -32,6 +33,7 @@ def history_log(action, sonarr_series_id, sonarr_episode_id, result, fake_provid subs_id=subs_id, subtitles_path=subtitles_path, matched=str(matched) if matched else None, - not_matched=str(not_matched) if not_matched else None + not_matched=str(not_matched) if not_matched else None, + upgradedFromId=upgraded_from_id, )) event_stream(type='episode-history') diff --git a/bazarr/sonarr/sync/episodes.py b/bazarr/sonarr/sync/episodes.py index ed1679ff8..e2623ce71 100644 --- a/bazarr/sonarr/sync/episodes.py +++ b/bazarr/sonarr/sync/episodes.py @@ -5,6 +5,7 @@ import logging from constants import MINIMUM_VIDEO_SIZE from sqlalchemy.exc import IntegrityError +from datetime import datetime from app.database import database, TableShows, TableEpisodes, delete, update, insert, select from app.config import settings @@ -145,10 +146,27 @@ def sync_episodes(series_id, send_event=True): if send_event: event_stream(type='episode', action='delete', payload=removed_episode) + # Insert new episodes in DB + if len(episodes_to_add): + for added_episode in episodes_to_add: + try: + added_episode['created_at_timestamp'] = datetime.now() + database.execute(insert(TableEpisodes).values(added_episode)) + except IntegrityError as e: + logging.error(f"BAZARR cannot insert episodes because of {e}. We'll try to update it instead.") + del added_episode['created_at_timestamp'] + episodes_to_update.append(added_episode) + else: + store_subtitles(added_episode['path'], path_mappings.path_replace(added_episode['path'])) + + if send_event: + event_stream(type='episode', payload=added_episode['sonarrEpisodeId']) + # Update existing episodes in DB if len(episodes_to_update): for updated_episode in episodes_to_update: try: + updated_episode['updated_at_timestamp'] = datetime.now() database.execute(update(TableEpisodes) .values(updated_episode) .where(TableEpisodes.sonarrEpisodeId == updated_episode['sonarrEpisodeId'])) @@ -160,19 +178,6 @@ def sync_episodes(series_id, send_event=True): if send_event: event_stream(type='episode', action='update', payload=updated_episode['sonarrEpisodeId']) - # Insert new episodes in DB - if len(episodes_to_add): - for added_episode in episodes_to_add: - try: - database.execute(insert(TableEpisodes).values(added_episode)) - except IntegrityError as e: - logging.error(f"BAZARR cannot insert episodes because of {e}") - else: - store_subtitles(added_episode['path'], path_mappings.path_replace(added_episode['path'])) - - if send_event: - event_stream(type='episode', payload=added_episode['sonarrEpisodeId']) - logging.debug(f'BAZARR All episodes from series ID {series_id} synced from Sonarr into database.') @@ -225,6 +230,7 @@ def sync_one_episode(episode_id, defer_search=False): # Update existing episodes in DB elif episode and existing_episode: try: + episode['updated_at_timestamp'] = datetime.now() database.execute( update(TableEpisodes) .values(episode) @@ -240,6 +246,7 @@ def sync_one_episode(episode_id, defer_search=False): # Insert new episodes in DB elif episode and not existing_episode: try: + episode['created_at_timestamp'] = datetime.now() database.execute( insert(TableEpisodes) .values(episode)) diff --git a/bazarr/sonarr/sync/parser.py b/bazarr/sonarr/sync/parser.py index 27da32117..4e7dd4997 100644 --- a/bazarr/sonarr/sync/parser.py +++ b/bazarr/sonarr/sync/parser.py @@ -2,6 +2,8 @@ import os +from dateutil import parser + from app.config import settings from app.database import TableShows, database, select from constants import MINIMUM_VIDEO_SIZE @@ -45,6 +47,10 @@ def seriesParser(show, action, tags_dict, language_profiles, serie_default_profi imdbId = show['imdbId'] if 'imdbId' in show else None + ended = 'True' if 'ended' in show and show['ended'] else 'False' + + lastAired = parser.parse(show['lastAired']).strftime("%Y-%m-%d") if 'lastAired' in show and show['lastAired'] else None + audio_language = [] if not settings.general.parse_embedded_audio_track: if get_sonarr_info.is_legacy(): @@ -56,22 +62,24 @@ def seriesParser(show, action, tags_dict, language_profiles, serie_default_profi audio_language = [] 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': str(alternate_titles), - 'tags': str(tags), - 'seriesType': show['seriesType'], - 'imdbId': imdbId, - 'monitored': str(bool(show['monitored'])) - } + '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': str(alternate_titles), + 'tags': str(tags), + 'seriesType': show['seriesType'], + 'imdbId': imdbId, + 'monitored': str(bool(show['monitored'])), + 'ended': ended, + 'lastAired': lastAired, + } if action == 'insert': parsed_series['profileId'] = serie_default_profile diff --git a/bazarr/sonarr/sync/series.py b/bazarr/sonarr/sync/series.py index f8bd84990..065fdaa21 100644 --- a/bazarr/sonarr/sync/series.py +++ b/bazarr/sonarr/sync/series.py @@ -3,6 +3,7 @@ import logging from sqlalchemy.exc import IntegrityError +from datetime import datetime from app.config import settings from subtitles.indexer.series import list_missing_subtitles @@ -127,6 +128,7 @@ def update_series(send_event=True): .first(): try: trace(f"Updating {show['title']}") + updated_series['updated_at_timestamp'] = datetime.now() database.execute( update(TableShows) .values(updated_series) @@ -145,6 +147,7 @@ def update_series(send_event=True): try: trace(f"Inserting {show['title']}") + added_series['created_at_timestamp'] = datetime.now() database.execute( insert(TableShows) .values(added_series)) @@ -175,7 +178,11 @@ def update_series(send_event=True): event_stream(type='series', action='delete', payload=series) if send_event: - hide_progress(id='series_progress') + show_progress(id='series_progress', + header='Syncing series...', + name='', + value=series_count, + count=series_count) if sync_monitored: trace(f"skipped {skipped_count} unmonitored series out of {i}") @@ -238,6 +245,7 @@ def update_one_series(series_id, action): # Update existing series in DB if action == 'updated' and existing_series: try: + series['updated_at_timestamp'] = datetime.now() database.execute( update(TableShows) .values(series) @@ -252,6 +260,7 @@ def update_one_series(series_id, action): # Insert new series in DB elif action == 'updated' and not existing_series: try: + series['created_at_timestamp'] = datetime.now() database.execute( insert(TableShows) .values(series)) diff --git a/bazarr/subtitles/download.py b/bazarr/subtitles/download.py index 5f588fbf6..440e3e6f5 100644 --- a/bazarr/subtitles/download.py +++ b/bazarr/subtitles/download.py @@ -13,7 +13,7 @@ from subliminal_patch.core_persistent import download_best_subtitles from subliminal_patch.score import ComputeScore from app.config import settings, get_scores, get_array_from -from app.database import TableEpisodes, TableMovies, database, select +from app.database import TableEpisodes, TableMovies, database, select, get_profiles_list from utilities.path_mappings import path_mappings from utilities.helper import get_target_folder, force_unicode from languages.get_languages import alpha3_from_alpha2 @@ -24,8 +24,8 @@ from .processing import process_subtitle @update_pools -def generate_subtitles(path, languages, audio_language, sceneName, title, media_type, forced_minimum_score=None, - is_upgrade=False, profile_id=None, check_if_still_required=False, +def generate_subtitles(path, languages, audio_language, sceneName, title, media_type, profile_id, + forced_minimum_score=None, is_upgrade=False, check_if_still_required=False, previous_subtitles_to_delete=None): if not languages: return None @@ -41,6 +41,8 @@ def generate_subtitles(path, languages, audio_language, sceneName, title, media_ providers = pool.providers language_set = _get_language_obj(languages=languages) + profile = get_profiles_list(profile_id=profile_id) + original_format = profile['originalFormat'] hi_required = "force HI" if any([x.hi for x in language_set]) else False also_forced = any([x.forced for x in language_set]) forced_required = all([x.forced for x in language_set]) @@ -72,7 +74,8 @@ def generate_subtitles(path, languages, audio_language, sceneName, title, media_ pool_instance=pool, min_score=int(min_score), hearing_impaired=hi_required, - compute_score=ComputeScore(get_scores())) + compute_score=ComputeScore(get_scores()), + use_original_format=original_format in (1, "1", "True", True)) if downloaded_subtitles: for video, subtitles in downloaded_subtitles.items(): @@ -100,7 +103,7 @@ def generate_subtitles(path, languages, audio_language, sceneName, title, media_ tags=None, # fixme directory=fld, chmod=chmod, - formats=tuple(subtitle_formats), + formats=subtitle_formats, path_decoder=force_unicode ) except Exception as e: diff --git a/bazarr/subtitles/indexer/movies.py b/bazarr/subtitles/indexer/movies.py index 563a68cff..6502a4354 100644 --- a/bazarr/subtitles/indexer/movies.py +++ b/bazarr/subtitles/indexer/movies.py @@ -292,7 +292,11 @@ def movies_full_scan_subtitles(use_cache=None): count=count_movies) store_subtitles_movie(movie.path, path_mappings.path_replace_movie(movie.path), use_cache=use_cache) - hide_progress(id='movies_disk_scan') + show_progress(id='movies_disk_scan', + header='Full disk scan...', + name='Movies subtitles', + value=count_movies, + count=count_movies) gc.collect() diff --git a/bazarr/subtitles/indexer/series.py b/bazarr/subtitles/indexer/series.py index 64af2e4c3..1d1d98ef8 100644 --- a/bazarr/subtitles/indexer/series.py +++ b/bazarr/subtitles/indexer/series.py @@ -294,7 +294,11 @@ def series_full_scan_subtitles(use_cache=None): count=count_episodes) store_subtitles(episode.path, path_mappings.path_replace(episode.path), use_cache=use_cache) - hide_progress(id='episodes_disk_scan') + show_progress(id='episodes_disk_scan', + header='Full disk scan...', + name='Episodes subtitles', + value=count_episodes, + count=count_episodes) gc.collect() diff --git a/bazarr/subtitles/indexer/utils.py b/bazarr/subtitles/indexer/utils.py index 63b9dee6d..4e7c339a6 100644 --- a/bazarr/subtitles/indexer/utils.py +++ b/bazarr/subtitles/indexer/utils.py @@ -135,7 +135,9 @@ def guess_external_subtitles(dest_folder, subtitles, media_type, previously_inde continue text = text.decode(encoding) - 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) + if os.path.splitext(subtitle_path)[1] == 'srt': + 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) return subtitles diff --git a/bazarr/subtitles/manual.py b/bazarr/subtitles/manual.py index 5d642b577..3ddd59c74 100644 --- a/bazarr/subtitles/manual.py +++ b/bazarr/subtitles/manual.py @@ -158,7 +158,7 @@ def manual_download_subtitle(path, audio_language, hi, forced, subtitle, provide subtitle.language.forced = True else: subtitle.language.forced = False - if use_original_format in ("1", "True"): + if use_original_format in (1, "1", "True", True): subtitle.use_original_format = True subtitle.mods = get_array_from(settings.general.subzero_mods) diff --git a/bazarr/subtitles/mass_download/movies.py b/bazarr/subtitles/mass_download/movies.py index 25fa04364..da3d7538d 100644 --- a/bazarr/subtitles/mass_download/movies.py +++ b/bazarr/subtitles/mass_download/movies.py @@ -9,7 +9,7 @@ import os from functools import reduce from utilities.path_mappings import path_mappings -from subtitles.indexer.movies import store_subtitles_movie +from subtitles.indexer.movies import store_subtitles_movie, list_missing_subtitles_movies from radarr.history import history_log_movie from app.notifier import send_notifications_movie from app.get_providers import get_providers @@ -22,20 +22,30 @@ from ..download import generate_subtitles def movies_download_subtitles(no): conditions = [(TableMovies.radarrId == no)] conditions += get_exclusion_clause('movie') - movie = database.execute( - select(TableMovies.path, - TableMovies.missing_subtitles, - TableMovies.audio_language, - TableMovies.radarrId, - TableMovies.sceneName, - TableMovies.title, - TableMovies.tags, - TableMovies.monitored) - .where(reduce(operator.and_, conditions))) \ - .first() + stmt = select(TableMovies.path, + TableMovies.missing_subtitles, + TableMovies.audio_language, + TableMovies.radarrId, + TableMovies.sceneName, + TableMovies.title, + TableMovies.tags, + TableMovies.monitored, + TableMovies.profileId, + TableMovies.subtitles) \ + .where(reduce(operator.and_, conditions)) + movie = database.execute(stmt).first() + if not movie: - logging.debug("BAZARR no movie with that radarrId can be found in database:", str(no)) + logging.debug(f"BAZARR no movie with that radarrId can be found in database: {no}") return + elif movie.subtitles is None: + # subtitles indexing for this movie is incomplete, we'll do it again + store_subtitles_movie(movie.path, path_mappings.path_replace_movie(movie.path)) + movie = database.execute(stmt).first() + elif movie.missing_subtitles is None: + # missing subtitles calculation for this movie is incomplete, we'll do it again + list_missing_subtitles_movies(no=no) + movie = database.execute(stmt).first() moviePath = path_mappings.path_replace_movie(movie.path) @@ -79,6 +89,7 @@ def movies_download_subtitles(no): str(movie.sceneName), movie.title, 'movie', + movie.profileId, check_if_still_required=True): if result: @@ -88,4 +99,8 @@ def movies_download_subtitles(no): history_log_movie(1, no, result) send_notifications_movie(no, result.message) - hide_progress(id=f'movie_search_progress_{no}') + show_progress(id=f'movie_search_progress_{no}', + header='Searching missing subtitles...', + name=movie.title, + value=count_movie, + count=count_movie) diff --git a/bazarr/subtitles/mass_download/series.py b/bazarr/subtitles/mass_download/series.py index 3a9d998ca..64672cd8f 100644 --- a/bazarr/subtitles/mass_download/series.py +++ b/bazarr/subtitles/mass_download/series.py @@ -9,7 +9,7 @@ import os from functools import reduce from utilities.path_mappings import path_mappings -from subtitles.indexer.series import store_subtitles +from subtitles.indexer.series import store_subtitles, list_missing_subtitles from sonarr.history import history_log from app.notifier import send_notifications from app.get_providers import get_providers @@ -32,18 +32,12 @@ def series_download_subtitles(no): (TableEpisodes.missing_subtitles != '[]')] conditions += get_exclusion_clause('series') episodes_details = database.execute( - select(TableEpisodes.path, - TableEpisodes.missing_subtitles, - TableEpisodes.monitored, - TableEpisodes.sonarrEpisodeId, - TableEpisodes.sceneName, - TableShows.tags, - TableShows.seriesType, - TableEpisodes.audio_language, + select(TableEpisodes.sonarrEpisodeId, TableShows.title, TableEpisodes.season, TableEpisodes.episode, - TableEpisodes.title.label('episodeTitle')) + TableEpisodes.title.label('episodeTitle'), + TableEpisodes.missing_subtitles) .select_from(TableEpisodes) .join(TableShows) .where(reduce(operator.and_, conditions))) \ @@ -65,110 +59,100 @@ def series_download_subtitles(no): value=i, count=count_episodes_details) - audio_language_list = get_audio_profile_languages(episode.audio_language) - if len(audio_language_list) > 0: - audio_language = audio_language_list[0]['name'] - else: - audio_language = 'None' - - languages = [] - for language in ast.literal_eval(episode.missing_subtitles): - if language is not None: - hi_ = "True" if language.endswith(':hi') else "False" - forced_ = "True" if language.endswith(':forced') else "False" - languages.append((language.split(":")[0], hi_, forced_)) - - if not languages: - continue - - for result in generate_subtitles(path_mappings.path_replace(episode.path), - languages, - audio_language, - str(episode.sceneName), - episode.title, - 'series', - check_if_still_required=True): - if result: - if isinstance(result, tuple) and len(result): - result = result[0] - store_subtitles(episode.path, path_mappings.path_replace(episode.path)) - history_log(1, no, episode.sonarrEpisodeId, result) - send_notifications(no, episode.sonarrEpisodeId, result.message) + episode_download_subtitles(no=episode.sonarrEpisodeId, send_progress=False, providers_list=providers_list) else: logging.info("BAZARR All providers are throttled") break - hide_progress(id=f'series_search_progress_{no}') + show_progress(id=f'series_search_progress_{no}', + header='Searching missing subtitles...', + name='', + value=count_episodes_details, + count=count_episodes_details) -def episode_download_subtitles(no, send_progress=False): +def episode_download_subtitles(no, send_progress=False, providers_list=None): conditions = [(TableEpisodes.sonarrEpisodeId == no)] conditions += get_exclusion_clause('series') - episodes_details = database.execute( - select(TableEpisodes.path, - TableEpisodes.missing_subtitles, - TableEpisodes.monitored, - TableEpisodes.sonarrEpisodeId, - TableEpisodes.sceneName, - TableShows.tags, - TableShows.title, - TableShows.sonarrSeriesId, - TableEpisodes.audio_language, - TableShows.seriesType, - TableEpisodes.title.label('episodeTitle'), - TableEpisodes.season, - TableEpisodes.episode) - .select_from(TableEpisodes) - .join(TableShows) - .where(reduce(operator.and_, conditions))) \ - .all() - if not episodes_details: + stmt = select(TableEpisodes.path, + TableEpisodes.missing_subtitles, + TableEpisodes.monitored, + TableEpisodes.sonarrEpisodeId, + TableEpisodes.sceneName, + TableShows.tags, + TableShows.title, + TableShows.sonarrSeriesId, + TableEpisodes.audio_language, + TableShows.seriesType, + TableEpisodes.title.label('episodeTitle'), + TableEpisodes.season, + TableEpisodes.episode, + TableShows.profileId, + TableEpisodes.subtitles) \ + .select_from(TableEpisodes) \ + .join(TableShows) \ + .where(reduce(operator.and_, conditions)) + episode = database.execute(stmt).first() + + if not episode: logging.debug("BAZARR no episode with that sonarrEpisodeId can be found in database:", str(no)) return - - for episode in episodes_details: + elif episode.subtitles is None: + # subtitles indexing for this episode is incomplete, we'll do it again + store_subtitles(episode.path, path_mappings.path_replace_movie(episode.path)) + episode = database.execute(stmt).first() + elif episode.missing_subtitles is None: + # missing subtitles calculation for this episode is incomplete, we'll do it again + list_missing_subtitles(epno=no) + episode = database.execute(stmt).first() + + if not providers_list: providers_list = get_providers() - if providers_list: - if send_progress: - show_progress(id=f'episode_search_progress_{no}', - header='Searching missing subtitles...', - name=f'{episode.title} - S{episode.season:02d}E{episode.episode:02d} - {episode.episodeTitle}', - value=0, - count=1) - - audio_language_list = get_audio_profile_languages(episode.audio_language) - if len(audio_language_list) > 0: - audio_language = audio_language_list[0]['name'] - else: - audio_language = 'None' - - languages = [] - for language in ast.literal_eval(episode.missing_subtitles): - if language is not None: - hi_ = "True" if language.endswith(':hi') else "False" - forced_ = "True" if language.endswith(':forced') else "False" - languages.append((language.split(":")[0], hi_, forced_)) - - if not languages: - continue - - for result in generate_subtitles(path_mappings.path_replace(episode.path), - languages, - audio_language, - str(episode.sceneName), - episode.title, - 'series', - check_if_still_required=True): - if result: - if isinstance(result, tuple) and len(result): - result = result[0] - store_subtitles(episode.path, path_mappings.path_replace(episode.path)) - history_log(1, episode.sonarrSeriesId, episode.sonarrEpisodeId, result) - send_notifications(episode.sonarrSeriesId, episode.sonarrEpisodeId, result.message) - - if send_progress: - hide_progress(id=f'episode_search_progress_{no}') + if providers_list: + if send_progress: + show_progress(id=f'episode_search_progress_{no}', + header='Searching missing subtitles...', + name=f'{episode.title} - S{episode.season:02d}E{episode.episode:02d} - {episode.episodeTitle}', + value=0, + count=1) + + audio_language_list = get_audio_profile_languages(episode.audio_language) + if len(audio_language_list) > 0: + audio_language = audio_language_list[0]['name'] else: - logging.info("BAZARR All providers are throttled") - break + audio_language = 'None' + + languages = [] + for language in ast.literal_eval(episode.missing_subtitles): + if language is not None: + hi_ = "True" if language.endswith(':hi') else "False" + forced_ = "True" if language.endswith(':forced') else "False" + languages.append((language.split(":")[0], hi_, forced_)) + + if not languages: + return + + for result in generate_subtitles(path_mappings.path_replace(episode.path), + languages, + audio_language, + str(episode.sceneName), + episode.title, + 'series', + episode.profileId, + check_if_still_required=True): + if result: + if isinstance(result, tuple) and len(result): + result = result[0] + store_subtitles(episode.path, path_mappings.path_replace(episode.path)) + history_log(1, episode.sonarrSeriesId, episode.sonarrEpisodeId, result) + send_notifications(episode.sonarrSeriesId, episode.sonarrEpisodeId, result.message) + + if send_progress: + show_progress(id=f'episode_search_progress_{no}', + header='Searching missing subtitles...', + name=f'{episode.title} - S{episode.season:02d}E{episode.episode:02d} - {episode.episodeTitle}', + value=1, + count=1) + else: + logging.info("BAZARR All providers are throttled") diff --git a/bazarr/subtitles/refiners/anidb.py b/bazarr/subtitles/refiners/anidb.py index c066cd598..93b00bf82 100644 --- a/bazarr/subtitles/refiners/anidb.py +++ b/bazarr/subtitles/refiners/anidb.py @@ -91,11 +91,11 @@ class AniDBClient(object): 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)) + anidb_id = int(special_entry.attrib.get('anidbid')) + offset = int(mapping_list[0].attrib.get('offset', 0)) if len(mapping_list) > 0 else 0 if not is_special_entry: # Sort the anime by offset in ascending order @@ -111,7 +111,7 @@ class AniDBClient(object): mapping_list = anime.find('mapping-list') # Handle mapping list for Specials - if mapping_list: + if mapping_list is not None: for mapping in mapping_list.findall("mapping"): if mapping.text is None: continue @@ -176,7 +176,7 @@ class AniDBClient(object): episode_elements = xml_root.find('episodes') - if not episode_elements: + if episode_elements is None: raise ValueError return etree.tostring(episode_elements, encoding='utf8', method='xml') diff --git a/bazarr/subtitles/refiners/anilist.py b/bazarr/subtitles/refiners/anilist.py index 4a008a5e1..1c409286d 100644 --- a/bazarr/subtitles/refiners/anilist.py +++ b/bazarr/subtitles/refiners/anilist.py @@ -43,10 +43,15 @@ class AniListClient(object): logger.debug(f"Based on '{mapped_tag}': '{candidate_id_value}', anime-list matched: {obj}") if len(obj) > 0: - return obj[0]["anilist_id"] + anilist_id = obj[0].get("anilist_id") + if not anilist_id: + logger.error("This entry does not have an AniList ID") + + return anilist_id else: logger.debug(f"Could not find corresponding AniList ID with '{mapped_tag}': {candidate_id_value}") - return None + + return None def refine_from_anilist(path, video): diff --git a/bazarr/subtitles/refiners/database.py b/bazarr/subtitles/refiners/database.py index 9eef9f2d8..2b91b0967 100644 --- a/bazarr/subtitles/refiners/database.py +++ b/bazarr/subtitles/refiners/database.py @@ -40,10 +40,8 @@ def refine_from_db(path, video): if data: video.series = _TITLE_RE.sub('', data.seriesTitle) - if not video.season and data.season: - video.season = int(data.season) - if not video.episode and data.episode: - video.episode = int(data.episode) + video.season = int(data.season) + video.episode = int(data.episode) video.title = data.episodeTitle # Only refine year as a fallback diff --git a/bazarr/subtitles/refiners/ffprobe.py b/bazarr/subtitles/refiners/ffprobe.py index 3fc21bd92..0f1a7a98b 100644 --- a/bazarr/subtitles/refiners/ffprobe.py +++ b/bazarr/subtitles/refiners/ffprobe.py @@ -2,8 +2,10 @@ # fmt: off import logging +import json from subliminal import Movie +from guessit.jsonutils import GuessitEncoder from utilities.path_mappings import path_mappings from app.database import TableEpisodes, TableMovies, database, select @@ -37,10 +39,12 @@ def refine_from_ffprobe(path, video): return video if data['ffprobe']: - logging.debug('FFprobe found: %s', data['ffprobe']) + logging.debug('FFprobe found: %s', json.dumps(data['ffprobe'], cls=GuessitEncoder, indent=4, + ensure_ascii=False)) parser_data = data['ffprobe'] elif data['mediainfo']: - logging.debug('Mediainfo found: %s', data['mediainfo']) + logging.debug('Mediainfo found: %s', json.dumps(data['mediainfo'], cls=GuessitEncoder, indent=4, + ensure_ascii=False)) parser_data = data['mediainfo'] else: parser_data = {} diff --git a/bazarr/subtitles/sync.py b/bazarr/subtitles/sync.py index 4726d245f..d2ac761f5 100644 --- a/bazarr/subtitles/sync.py +++ b/bazarr/subtitles/sync.py @@ -3,8 +3,10 @@ import logging import gc +import os from app.config import settings +from app.event_handler import show_progress, hide_progress from subtitles.tools.subsyncer import SubSyncer @@ -40,7 +42,22 @@ def sync_subtitles(video_path, srt_path, srt_lang, forced, hi, percent_score, so 'sonarr_episode_id': sonarr_episode_id, 'radarr_id': radarr_id, } - subsync.sync(**sync_kwargs) + subtitles_filename = os.path.basename(srt_path) + show_progress(id=f'subsync_{subtitles_filename}', + header='Syncing Subtitle', + name=srt_path, + value=0, + count=1) + try: + subsync.sync(**sync_kwargs) + except Exception: + hide_progress(id=f'subsync_{subtitles_filename}') + else: + show_progress(id=f'subsync_{subtitles_filename}', + header='Syncing Subtitle', + name=srt_path, + value=1, + count=1) del subsync gc.collect() return True diff --git a/bazarr/subtitles/tools/translate.py b/bazarr/subtitles/tools/translate.py index 935d3ebd1..0bb4c2830 100644 --- a/bazarr/subtitles/tools/translate.py +++ b/bazarr/subtitles/tools/translate.py @@ -94,12 +94,20 @@ def translate_subtitles_file(video_path, source_srt_file, from_lang, to_lang, fo for i, line in enumerate(translated_lines): lines_list[line['id']] = line['line'] - hide_progress(id=f'translate_progress_{dest_srt_file}') + show_progress(id=f'translate_progress_{dest_srt_file}', + header=f'Translating subtitles lines to {language_from_alpha3(to_lang)}...', + name='', + value=lines_list_len, + count=lines_list_len) logging.debug(f'BAZARR saving translated subtitles to {dest_srt_file}') for i, line in enumerate(subs): try: - line.plaintext = lines_list[i] + if lines_list[i]: + line.plaintext = lines_list[i] + else: + # we assume that there was nothing to translate if Google returns None. ex.: "♪♪" + continue except IndexError: logging.error(f'BAZARR is unable to translate malformed subtitles: {source_srt_file}') return False diff --git a/bazarr/subtitles/upgrade.py b/bazarr/subtitles/upgrade.py index 1c565bd0d..46f00fb57 100644 --- a/bazarr/subtitles/upgrade.py +++ b/bazarr/subtitles/upgrade.py @@ -44,8 +44,8 @@ def upgrade_subtitles(): 'sonarrSeriesId': x.sonarrSeriesId, 'subtitles_path': x.subtitles_path, 'path': x.path, + 'profileId': x.profileId, 'external_subtitles': [y[1] for y in ast.literal_eval(x.external_subtitles) if y[1]], - 'upgradable': bool(x.upgradable), } for x in database.execute( select(TableHistory.id, TableShows.title.label('seriesTitle'), @@ -62,22 +62,30 @@ def upgrade_subtitles(): TableHistory.subtitles_path, TableEpisodes.path, TableShows.profileId, - TableEpisodes.subtitles.label('external_subtitles'), - episodes_to_upgrade.c.id.label('upgradable')) + TableEpisodes.subtitles.label('external_subtitles')) .select_from(TableHistory) .join(TableShows, onclause=TableHistory.sonarrSeriesId == TableShows.sonarrSeriesId) - .join(TableEpisodes, onclause=TableHistory.sonarrEpisodeId == TableEpisodes.sonarrEpisodeId) - .join(episodes_to_upgrade, onclause=TableHistory.id == episodes_to_upgrade.c.id, isouter=True) - .where(episodes_to_upgrade.c.id.is_not(None))) + .join(TableEpisodes, onclause=TableHistory.sonarrEpisodeId == TableEpisodes.sonarrEpisodeId)) .all() if _language_still_desired(x.language, x.profileId) and - x.subtitles_path in x.external_subtitles and x.video_path == x.path ] for item in episodes_data: + # do not consider subtitles that do not exist on disk anymore + if item['subtitles_path'] not in item['external_subtitles']: + continue + + # Mark upgradable and get original_id + item.update({'original_id': episodes_to_upgrade.get(item['id'])}) + item.update({'upgradable': bool(item['original_id'])}) + + # cleanup the unused attributes del item['path'] del item['external_subtitles'] + # Make sure to keep only upgradable episode subtitles + episodes_data = [x for x in episodes_data if 'upgradable' in x and x['upgradable']] + count_episode_to_upgrade = len(episodes_data) for i, episode in enumerate(episodes_data): @@ -94,6 +102,8 @@ def upgrade_subtitles(): return language, is_forced, is_hi = parse_language_string(episode['language']) + if is_hi and not _is_hi_required(language, episode['profileId']): + is_hi = 'False' audio_language_list = get_audio_profile_languages(episode['audio_language']) if len(audio_language_list) > 0: @@ -107,6 +117,7 @@ def upgrade_subtitles(): str(episode['sceneName']), episode['seriesTitle'], 'series', + episode['profileId'], forced_minimum_score=int(episode['score']), is_upgrade=True, previous_subtitles_to_delete=path_mappings.path_replace( @@ -118,14 +129,20 @@ def upgrade_subtitles(): if isinstance(result, tuple) and len(result): result = result[0] store_subtitles(episode['video_path'], path_mappings.path_replace(episode['video_path'])) - history_log(3, episode['sonarrSeriesId'], episode['sonarrEpisodeId'], result) + history_log(3, episode['sonarrSeriesId'], episode['sonarrEpisodeId'], result, + upgraded_from_id=episode['original_id']) send_notifications(episode['sonarrSeriesId'], episode['sonarrEpisodeId'], result.message) - hide_progress(id='upgrade_episodes_progress') + show_progress(id='upgrade_episodes_progress', + header='Upgrading episodes subtitles...', + name='', + value=count_episode_to_upgrade, + count=count_episode_to_upgrade) if use_radarr: movies_to_upgrade = get_upgradable_movies_subtitles() movies_data = [{ + 'id': x.id, 'title': x.title, 'language': x.language, 'audio_language': x.audio_language, @@ -134,11 +151,12 @@ def upgrade_subtitles(): 'score': x.score, 'radarrId': x.radarrId, 'path': x.path, + 'profileId': x.profileId, 'subtitles_path': x.subtitles_path, 'external_subtitles': [y[1] for y in ast.literal_eval(x.external_subtitles) if y[1]], - 'upgradable': bool(x.upgradable), } for x in database.execute( - select(TableMovies.title, + select(TableHistoryMovie.id, + TableMovies.title, TableHistoryMovie.language, TableMovies.audio_language, TableHistoryMovie.video_path, @@ -148,21 +166,29 @@ def upgrade_subtitles(): TableHistoryMovie.subtitles_path, TableMovies.path, TableMovies.profileId, - TableMovies.subtitles.label('external_subtitles'), - movies_to_upgrade.c.id.label('upgradable')) + TableMovies.subtitles.label('external_subtitles')) .select_from(TableHistoryMovie) - .join(TableMovies, onclause=TableHistoryMovie.radarrId == TableMovies.radarrId) - .join(movies_to_upgrade, onclause=TableHistoryMovie.id == movies_to_upgrade.c.id, isouter=True) - .where(movies_to_upgrade.c.id.is_not(None))) + .join(TableMovies, onclause=TableHistoryMovie.radarrId == TableMovies.radarrId)) .all() if _language_still_desired(x.language, x.profileId) and - x.subtitles_path in x.external_subtitles and x.video_path == x.path ] for item in movies_data: + # do not consider subtitles that do not exist on disk anymore + if item['subtitles_path'] not in item['external_subtitles']: + continue + + # Mark upgradable and get original_id + item.update({'original_id': movies_to_upgrade.get(item['id'])}) + item.update({'upgradable': bool(item['original_id'])}) + + # cleanup the unused attributes del item['path'] del item['external_subtitles'] + # Make sure to keep only upgradable movie subtitles + movies_data = [x for x in movies_data if 'upgradable' in x and x['upgradable']] + count_movie_to_upgrade = len(movies_data) for i, movie in enumerate(movies_data): @@ -179,6 +205,8 @@ def upgrade_subtitles(): return language, is_forced, is_hi = parse_language_string(movie['language']) + if is_hi and not _is_hi_required(language, movie['profileId']): + is_hi = 'False' audio_language_list = get_audio_profile_languages(movie['audio_language']) if len(audio_language_list) > 0: @@ -192,6 +220,7 @@ def upgrade_subtitles(): str(movie['sceneName']), movie['title'], 'movie', + movie['profileId'], forced_minimum_score=int(movie['score']), is_upgrade=True, previous_subtitles_to_delete=path_mappings.path_replace_movie( @@ -203,10 +232,14 @@ def upgrade_subtitles(): result = result[0] store_subtitles_movie(movie['video_path'], path_mappings.path_replace_movie(movie['video_path'])) - history_log_movie(3, movie['radarrId'], result) + history_log_movie(3, movie['radarrId'], result, upgraded_from_id=movie['original_id']) send_notifications_movie(movie['radarrId'], result.message) - hide_progress(id='upgrade_movies_progress') + show_progress(id='upgrade_movies_progress', + header='Upgrading movies subtitles...', + name='', + value=count_movie_to_upgrade, + count=count_movie_to_upgrade) logging.info('BAZARR Finished searching for Subtitles to upgrade. Check History for more information.') @@ -243,10 +276,10 @@ def parse_language_string(language_string): def get_upgradable_episode_subtitles(): if not settings.general.upgrade_subs: # return an empty set of rows - return select(TableHistory.id) \ - .where(TableHistory.id.is_(None)) \ - .subquery() + logging.debug("Subtitles upgrade is disabled so we wont go further.") + return {} + logging.debug("Determining upgradable episode subtitles") max_id_timestamp = select(TableHistory.video_path, TableHistory.language, func.max(TableHistory.timestamp).label('timestamp')) \ @@ -255,31 +288,76 @@ def get_upgradable_episode_subtitles(): .subquery() minimum_timestamp, query_actions = get_queries_condition_parameters() + logging.debug(f"Minimum timestamp used for subtitles upgrade: {minimum_timestamp}") + logging.debug(f"These actions are considered for subtitles upgrade: {query_actions}") upgradable_episodes_conditions = [(TableHistory.action.in_(query_actions)), (TableHistory.timestamp > minimum_timestamp), TableHistory.score.is_not(None), (TableHistory.score < 357)] upgradable_episodes_conditions += get_exclusion_clause('series') - return select(TableHistory.id)\ - .select_from(TableHistory) \ + subtitles_to_upgrade = database.execute( + select(TableHistory.id, + TableHistory.video_path, + TableHistory.language, + TableHistory.upgradedFromId) + .select_from(TableHistory) + .join(TableShows, onclause=TableHistory.sonarrSeriesId == TableShows.sonarrSeriesId) + .join(TableEpisodes, onclause=TableHistory.sonarrEpisodeId == TableEpisodes.sonarrEpisodeId) .join(max_id_timestamp, onclause=and_(TableHistory.video_path == max_id_timestamp.c.video_path, TableHistory.language == max_id_timestamp.c.language, - max_id_timestamp.c.timestamp == TableHistory.timestamp)) \ - .join(TableShows, onclause=TableHistory.sonarrSeriesId == TableShows.sonarrSeriesId) \ - .join(TableEpisodes, onclause=TableHistory.sonarrEpisodeId == TableEpisodes.sonarrEpisodeId) \ - .where(reduce(operator.and_, upgradable_episodes_conditions)) \ - .order_by(TableHistory.timestamp.desc())\ - .subquery() + max_id_timestamp.c.timestamp == TableHistory.timestamp)) + .where(reduce(operator.and_, upgradable_episodes_conditions)) + .order_by(TableHistory.timestamp.desc())) \ + .all() + logging.debug(f"{len(subtitles_to_upgrade)} subtitles are candidates and we've selected the latest timestamp for " + f"each of them.") + + query_actions_without_upgrade = [x for x in query_actions if x != 3] + upgradable_episode_subtitles = {} + for subtitle_to_upgrade in subtitles_to_upgrade: + # check if we have the original subtitles id in database and use it instead of guessing + if subtitle_to_upgrade.upgradedFromId: + upgradable_episode_subtitles.update({subtitle_to_upgrade.id: subtitle_to_upgrade.upgradedFromId}) + logging.debug(f"The original subtitles ID for TableHistory ID {subtitle_to_upgrade.id} stored in DB is: " + f"{subtitle_to_upgrade.upgradedFromId}") + continue + + # if not, we have to try to guess the original subtitles id + logging.debug("We don't have the original subtitles ID for this subtitle so we'll have to guess it.") + potential_parents = database.execute( + select(TableHistory.id, TableHistory.action) + .where(TableHistory.video_path == subtitle_to_upgrade.video_path, + TableHistory.language == subtitle_to_upgrade.language,) + .order_by(TableHistory.timestamp.desc()) + ).all() + + logging.debug(f"The potential original subtitles IDs for TableHistory ID {subtitle_to_upgrade.id} are: " + f"{[x.id for x in potential_parents]}") + confirmed_parent = None + for potential_parent in potential_parents: + if potential_parent.action in query_actions_without_upgrade: + confirmed_parent = potential_parent.id + logging.debug(f"This ID is the first one to match selected query actions so it's been selected as " + f"original subtitles ID: {potential_parent.id}") + break + + if confirmed_parent not in upgradable_episode_subtitles.values(): + logging.debug("We haven't defined this ID as original subtitles ID for any other ID so we'll add it to " + "upgradable episode subtitles.") + upgradable_episode_subtitles.update({subtitle_to_upgrade.id: confirmed_parent}) + + logging.debug(f"We've found {len(upgradable_episode_subtitles)} episode subtitles IDs to be upgradable") + return upgradable_episode_subtitles def get_upgradable_movies_subtitles(): if not settings.general.upgrade_subs: # return an empty set of rows - return select(TableHistoryMovie.id) \ - .where(TableHistoryMovie.id.is_(None)) \ - .subquery() + logging.debug("Subtitles upgrade is disabled so we won't go further.") + return {} + logging.debug("Determining upgradable movie subtitles") max_id_timestamp = select(TableHistoryMovie.video_path, TableHistoryMovie.language, func.max(TableHistoryMovie.timestamp).label('timestamp')) \ @@ -288,21 +366,66 @@ def get_upgradable_movies_subtitles(): .subquery() minimum_timestamp, query_actions = get_queries_condition_parameters() + logging.debug(f"Minimum timestamp used for subtitles upgrade: {minimum_timestamp}") + logging.debug(f"These actions are considered for subtitles upgrade: {query_actions}") upgradable_movies_conditions = [(TableHistoryMovie.action.in_(query_actions)), (TableHistoryMovie.timestamp > minimum_timestamp), TableHistoryMovie.score.is_not(None), (TableHistoryMovie.score < 117)] upgradable_movies_conditions += get_exclusion_clause('movie') - return select(TableHistoryMovie.id) \ - .select_from(TableHistoryMovie) \ + subtitles_to_upgrade = database.execute( + select(TableHistoryMovie.id, + TableHistoryMovie.video_path, + TableHistoryMovie.language, + TableHistoryMovie.upgradedFromId) + .select_from(TableHistoryMovie) + .join(TableMovies, onclause=TableHistoryMovie.radarrId == TableMovies.radarrId) .join(max_id_timestamp, onclause=and_(TableHistoryMovie.video_path == max_id_timestamp.c.video_path, TableHistoryMovie.language == max_id_timestamp.c.language, - max_id_timestamp.c.timestamp == TableHistoryMovie.timestamp)) \ - .join(TableMovies, onclause=TableHistoryMovie.radarrId == TableMovies.radarrId) \ - .where(reduce(operator.and_, upgradable_movies_conditions)) \ - .order_by(TableHistoryMovie.timestamp.desc()) \ - .subquery() + max_id_timestamp.c.timestamp == TableHistoryMovie.timestamp)) + .where(reduce(operator.and_, upgradable_movies_conditions)) + .order_by(TableHistoryMovie.timestamp.desc())) \ + .all() + logging.debug(f"{len(subtitles_to_upgrade)} subtitles are candidates and we've selected the latest timestamp for " + f"each of them.") + + query_actions_without_upgrade = [x for x in query_actions if x != 3] + upgradable_movie_subtitles = {} + for subtitle_to_upgrade in subtitles_to_upgrade: + # check if we have the original subtitles id in database and use it instead of guessing + if subtitle_to_upgrade.upgradedFromId: + upgradable_movie_subtitles.update({subtitle_to_upgrade.id: subtitle_to_upgrade.upgradedFromId}) + logging.debug(f"The original subtitles ID for TableHistoryMovie ID {subtitle_to_upgrade.id} stored in DB " + f"is: {subtitle_to_upgrade.upgradedFromId}") + continue + + # if not, we have to try to guess the original subtitles id + logging.debug("We don't have the original subtitles ID for this subtitle so we'll have to guess it.") + potential_parents = database.execute( + select(TableHistoryMovie.id, TableHistoryMovie.action) + .where(TableHistoryMovie.video_path == subtitle_to_upgrade.video_path, + TableHistoryMovie.language == subtitle_to_upgrade.language, ) + .order_by(TableHistoryMovie.timestamp.desc()) + ).all() + + logging.debug(f"The potential original subtitles IDs for TableHistoryMovie ID {subtitle_to_upgrade.id} are: " + f"{[x.id for x in potential_parents]}") + confirmed_parent = None + for potential_parent in potential_parents: + if potential_parent.action in query_actions_without_upgrade: + confirmed_parent = potential_parent.id + logging.debug(f"This ID is the newest one to match selected query actions so it's been selected as " + f"original subtitles ID: {potential_parent.id}") + break + + if confirmed_parent not in upgradable_movie_subtitles.values(): + logging.debug("We haven't defined this ID as original subtitles ID for any other ID so we'll add it to " + "upgradable episode subtitles.") + upgradable_movie_subtitles.update({subtitle_to_upgrade.id: confirmed_parent}) + + logging.debug(f"We've found {len(upgradable_movie_subtitles)} movie subtitles IDs to be upgradable") + return upgradable_movie_subtitles def _language_still_desired(language, profile_id): @@ -327,3 +450,11 @@ def _language_from_items(items): results.append(item['language']) results.append(f'{item["language"]}:hi') return results + + +def _is_hi_required(language, profile_id): + profile = get_profiles_list(profile_id=profile_id) + for item in profile['items']: + if language.split(':')[0] == item['language'] and item['hi'] == 'True': + return True + return False diff --git a/bazarr/subtitles/utils.py b/bazarr/subtitles/utils.py index 436bc7b52..5370c4bec 100644 --- a/bazarr/subtitles/utils.py +++ b/bazarr/subtitles/utils.py @@ -3,9 +3,11 @@ import logging import os +import json from subzero.language import Language from subzero.video import parse_video +from guessit.jsonutils import GuessitEncoder from app.config import settings from languages.custom_lang import CustomLanguage @@ -26,33 +28,31 @@ def get_video(path, title, sceneName, providers=None, media_type="movie"): :return: `Video` instance """ hints = {"title": title, "type": "movie" if media_type == "movie" else "episode"} - used_scene_name = False - original_path = path - original_name = os.path.basename(path) - hash_from = None - if sceneName != "None": - # use the sceneName but keep the folder structure for better guessing - path = os.path.join(os.path.dirname(path), sceneName + os.path.splitext(path)[1]) - used_scene_name = True - hash_from = original_path try: + logging.debug(f'BAZARR guessing video object using video file path: {path}') skip_hashing = settings.general.skip_hashing - video = parse_video(path, hints=hints, skip_hashing=skip_hashing, dry_run=used_scene_name, providers=providers, - hash_from=hash_from) - video.used_scene_name = used_scene_name - video.original_name = original_name - video.original_path = original_path + video = parse_video(path, hints=hints, skip_hashing=skip_hashing, dry_run=False, providers=providers) + if sceneName != "None": + # refine the video object using the sceneName and update the video object accordingly + scenename_with_extension = sceneName + os.path.splitext(path)[1] + logging.debug(f'BAZARR guessing video object using scene name: {scenename_with_extension}') + scenename_video = parse_video(scenename_with_extension, hints=hints, dry_run=True) + refine_video_with_scenename(initial_video=video, scenename_video=scenename_video) + logging.debug('BAZARR resulting video object once refined using scene name: %s', + json.dumps(vars(video), cls=GuessitEncoder, indent=4, ensure_ascii=False)) for key, refiner in registered_refiners.items(): logging.debug("Running refiner: %s", key) - refiner(original_path, video) + refiner(path, video) - logging.debug('BAZARR is using these video object properties: %s', vars(video)) + logging.debug('BAZARR is using these video object properties: %s', json.dumps(vars(video), + cls=GuessitEncoder, indent=4, + ensure_ascii=False)) return video except Exception as error: - logging.exception("BAZARR Error (%s) trying to get video information for this file: %s", error, original_path) + logging.exception("BAZARR Error (%s) trying to get video information for this file: %s", error, path) def _get_download_code3(subtitle): @@ -100,3 +100,10 @@ def _set_forced_providers(pool, also_forced=False, forced_required=False): "opensubtitles": {'also_foreign': also_forced, "only_foreign": forced_required} } ) + + +def refine_video_with_scenename(initial_video, scenename_video): + for key, value in vars(scenename_video).items(): + if value and getattr(initial_video, key) in [None, (), {}, []]: + setattr(initial_video, key, value) + return initial_video diff --git a/bazarr/subtitles/wanted/movies.py b/bazarr/subtitles/wanted/movies.py index 16c363386..a6892b79a 100644 --- a/bazarr/subtitles/wanted/movies.py +++ b/bazarr/subtitles/wanted/movies.py @@ -8,7 +8,7 @@ import operator from functools import reduce from utilities.path_mappings import path_mappings -from subtitles.indexer.movies import store_subtitles_movie +from subtitles.indexer.movies import store_subtitles_movie, list_missing_subtitles_movies from radarr.history import history_log_movie from app.notifier import send_notifications_movie from app.get_providers import get_providers @@ -50,6 +50,7 @@ def _wanted_movie(movie): str(movie.sceneName), movie.title, 'movie', + movie.profileId, check_if_still_required=True): if result: @@ -62,29 +63,41 @@ def _wanted_movie(movie): def wanted_download_subtitles_movie(radarr_id): - movies_details = database.execute( - select(TableMovies.path, - TableMovies.missing_subtitles, - TableMovies.radarrId, - TableMovies.audio_language, - TableMovies.sceneName, - TableMovies.failedAttempts, - TableMovies.title) - .where(TableMovies.radarrId == radarr_id)) \ - .all() - - for movie in movies_details: - providers_list = get_providers() - - if providers_list: - _wanted_movie(movie) - else: - logging.info("BAZARR All providers are throttled") - break + stmt = select(TableMovies.path, + TableMovies.missing_subtitles, + TableMovies.radarrId, + TableMovies.audio_language, + TableMovies.sceneName, + TableMovies.failedAttempts, + TableMovies.title, + TableMovies.profileId, + TableMovies.subtitles) \ + .where(TableMovies.radarrId == radarr_id) + movie = database.execute(stmt).first() + + if not movie: + logging.debug(f"BAZARR no movie with that radarrId can be found in database: {radarr_id}") + return + elif movie.subtitles is None: + # subtitles indexing for this movie is incomplete, we'll do it again + store_subtitles_movie(movie.path, path_mappings.path_replace_movie(movie.path)) + movie = database.execute(stmt).first() + elif movie.missing_subtitles is None: + # missing subtitles calculation for this movie is incomplete, we'll do it again + list_missing_subtitles_movies(no=radarr_id) + movie = database.execute(stmt).first() + + providers_list = get_providers() + + if providers_list: + _wanted_movie(movie) + else: + logging.info("BAZARR All providers are throttled") def wanted_search_missing_subtitles_movies(): - conditions = [(TableMovies.missing_subtitles != '[]')] + conditions = [(TableMovies.missing_subtitles.is_not(None)), + (TableMovies.missing_subtitles != '[]')] conditions += get_exclusion_clause('movie') movies = database.execute( select(TableMovies.radarrId, @@ -109,6 +122,10 @@ def wanted_search_missing_subtitles_movies(): logging.info("BAZARR All providers are throttled") break - hide_progress(id='wanted_movies_progress') + show_progress(id='wanted_movies_progress', + header='Searching subtitles...', + name="", + value=count_movies, + count=count_movies) logging.info('BAZARR Finished searching for missing Movies Subtitles. Check History for more information.') diff --git a/bazarr/subtitles/wanted/series.py b/bazarr/subtitles/wanted/series.py index 4bc687415..7b57cbea9 100644 --- a/bazarr/subtitles/wanted/series.py +++ b/bazarr/subtitles/wanted/series.py @@ -8,6 +8,7 @@ import operator from functools import reduce from utilities.path_mappings import path_mappings +from subtitles.indexer.series import store_subtitles, list_missing_subtitles from subtitles.indexer.series import store_subtitles from sonarr.history import history_log from app.notifier import send_notifications @@ -51,6 +52,7 @@ def _wanted_episode(episode): str(episode.sceneName), episode.title, 'series', + episode.profileId, check_if_still_required=True): if result: if isinstance(result, tuple) and len(result): @@ -63,32 +65,44 @@ def _wanted_episode(episode): def wanted_download_subtitles(sonarr_episode_id): - episodes_details = database.execute( - select(TableEpisodes.path, - TableEpisodes.missing_subtitles, - TableEpisodes.sonarrEpisodeId, - TableEpisodes.sonarrSeriesId, - TableEpisodes.audio_language, - TableEpisodes.sceneName, - TableEpisodes.failedAttempts, - TableShows.title) - .select_from(TableEpisodes) - .join(TableShows) - .where((TableEpisodes.sonarrEpisodeId == sonarr_episode_id))) \ - .all() - - for episode in episodes_details: - providers_list = get_providers() - - if providers_list: - _wanted_episode(episode) - else: - logging.info("BAZARR All providers are throttled") - break + stmt = select(TableEpisodes.path, + TableEpisodes.missing_subtitles, + TableEpisodes.sonarrEpisodeId, + TableEpisodes.sonarrSeriesId, + TableEpisodes.audio_language, + TableEpisodes.sceneName, + TableEpisodes.failedAttempts, + TableShows.title, + TableShows.profileId, + TableEpisodes.subtitles) \ + .select_from(TableEpisodes) \ + .join(TableShows) \ + .where((TableEpisodes.sonarrEpisodeId == sonarr_episode_id)) + episode_details = database.execute(stmt).first() + + if not episode_details: + logging.debug(f"BAZARR no episode with that sonarrId can be found in database: {sonarr_episode_id}") + return + elif episode_details.subtitles is None: + # subtitles indexing for this episode is incomplete, we'll do it again + store_subtitles(episode_details.path, path_mappings.path_replace(episode_details.path)) + episode_details = database.execute(stmt).first() + elif episode_details.missing_subtitles is None: + # missing subtitles calculation for this episode is incomplete, we'll do it again + list_missing_subtitles(epno=sonarr_episode_id) + episode_details = database.execute(stmt).first() + + providers_list = get_providers() + + if providers_list: + _wanted_episode(episode_details) + else: + logging.info("BAZARR All providers are throttled") def wanted_search_missing_subtitles_series(): - conditions = [(TableEpisodes.missing_subtitles != '[]')] + conditions = [(TableEpisodes.missing_subtitles.is_not(None)), + (TableEpisodes.missing_subtitles != '[]')] conditions += get_exclusion_clause('series') episodes = database.execute( select(TableEpisodes.sonarrSeriesId, @@ -120,6 +134,10 @@ def wanted_search_missing_subtitles_series(): logging.info("BAZARR All providers are throttled") break - hide_progress(id='wanted_episodes_progress') + show_progress(id='wanted_episodes_progress', + header='Searching subtitles...', + name='', + value=count_episodes, + count=count_episodes) logging.info('BAZARR Finished searching for missing Series Subtitles. Check History for more information.') diff --git a/bazarr/utilities/health.py b/bazarr/utilities/health.py index c1d3a6a3d..84a313cf0 100644 --- a/bazarr/utilities/health.py +++ b/bazarr/utilities/health.py @@ -2,8 +2,11 @@ import json +from sqlalchemy import func + from app.config import settings -from app.database import TableShowsRootfolder, TableMoviesRootfolder, TableLanguagesProfiles, database, select +from app.database import (TableShowsRootfolder, TableMoviesRootfolder, TableLanguagesProfiles, database, select, + TableShows, TableMovies) from app.event_handler import event_stream from .path_mappings import path_mappings from sonarr.rootfolder import check_sonarr_rootfolder @@ -66,4 +69,19 @@ def get_health_issues(): else: languages_profile_ids.append(items['id']) + # check if there's at least one languages profile created + languages_profiles_count = database.execute(select(func.count(TableLanguagesProfiles.profileId))).scalar() + series_with_profile = database.execute(select(func.count(TableShows.sonarrSeriesId)) + .where(TableShows.profileId.is_not(None))).scalar() + movies_with_profile = database.execute(select(func.count(TableMovies.radarrId)) + .where(TableMovies.profileId.is_not(None))).scalar() + if languages_profiles_count == 0: + health_issues.append({'object': 'Missing languages profile', + 'issue': 'You must create at least one languages profile and assign it to your content.'}) + elif languages_profiles_count > 0 and ((settings.general.use_sonarr and series_with_profile == 0) or + (settings.general.use_radarr and movies_with_profile == 0)): + health_issues.append({'object': 'No assigned languages profile', + 'issue': 'Although you have created at least one languages profile, you must assign it ' + 'to your content.'}) + return health_issues diff --git a/bazarr/utilities/helper.py b/bazarr/utilities/helper.py index b381f2e15..e8378bd84 100644 --- a/bazarr/utilities/helper.py +++ b/bazarr/utilities/helper.py @@ -11,7 +11,9 @@ from app.config import settings def check_credentials(user, pw, request, log_success=True): - ip_addr = request.environ.get('HTTP_X_FORWARDED_FOR', request.remote_addr) + forwarded_for_ip_addr = request.environ.get('HTTP_X_FORWARDED_FOR') + real_ip_addr = request.environ.get('HTTP_X_REAL_IP') + ip_addr = forwarded_for_ip_addr or real_ip_addr or request.remote_addr username = settings.auth.username password = settings.auth.password if hashlib.md5(f"{pw}".encode('utf-8')).hexdigest() == password and user == username: diff --git a/custom_libs/subliminal/providers/napiprojekt.py b/custom_libs/subliminal/providers/napiprojekt.py index 75aba3957..940083b71 100644 --- a/custom_libs/subliminal/providers/napiprojekt.py +++ b/custom_libs/subliminal/providers/napiprojekt.py @@ -67,8 +67,10 @@ class NapiProjektProvider(Provider): server_url = 'http://napiprojekt.pl/unit_napisy/dl.php' subtitle_class = NapiProjektSubtitle - def __init__(self): + def __init__(self, only_authors=None, only_real_names=None): self.session = None + self.only_authors = only_authors + self.only_real_names = only_real_names def initialize(self): self.session = Session() @@ -78,6 +80,8 @@ class NapiProjektProvider(Provider): self.session.close() def query(self, language, hash): + if self.only_authors or self.only_real_names: + return None params = { 'v': 'dreambox', 'kolejka': 'false', diff --git a/custom_libs/subliminal_patch/core.py b/custom_libs/subliminal_patch/core.py index 0fc2ac0a7..760d05bcd 100644 --- a/custom_libs/subliminal_patch/core.py +++ b/custom_libs/subliminal_patch/core.py @@ -524,7 +524,7 @@ class SZProviderPool(ProviderPool): return True def download_best_subtitles(self, subtitles, video, languages, min_score=0, hearing_impaired=False, only_one=False, - compute_score=None): + compute_score=None, use_original_format=False): """Download the best matching subtitles. patch: @@ -543,6 +543,7 @@ class SZProviderPool(ProviderPool): :param bool only_one: download only one subtitle, not one per language. :param compute_score: function that takes `subtitle` and `video` as positional arguments, `hearing_impaired` as keyword argument and returns the score. + :param bool use_original_format: preserve original subtitles format :return: downloaded subtitles. :rtype: list of :class:`~subliminal.subtitle.Subtitle` @@ -620,6 +621,9 @@ class SZProviderPool(ProviderPool): subtitle, score) continue + # make sure to preserve original subtitles format if requested + subtitle.use_original_format = use_original_format + # download logger.debug("%r: Trying to download subtitle with matches %s, score: %s; release(s): %s", subtitle, matches, score, subtitle.release_info) @@ -1213,10 +1217,10 @@ def save_subtitles(file_path, subtitles, single=False, directory=None, chmod=Non continue # create subtitle path - 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): + if (subtitle.text and subtitle.format == 'srt' and (hasattr(subtitle.language, 'hi') and + not subtitle.language.hi) 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_path = get_subtitle_path(file_path, None if single else subtitle.language, forced_tag=subtitle.language.forced, diff --git a/custom_libs/subliminal_patch/core_persistent.py b/custom_libs/subliminal_patch/core_persistent.py index e98914901..31ec61273 100644 --- a/custom_libs/subliminal_patch/core_persistent.py +++ b/custom_libs/subliminal_patch/core_persistent.py @@ -50,6 +50,7 @@ def download_best_subtitles( hearing_impaired=False, only_one=False, compute_score=None, + use_original_format=False, **kwargs ): downloaded_subtitles = defaultdict(list) @@ -77,6 +78,7 @@ def download_best_subtitles( hearing_impaired=hearing_impaired, only_one=only_one, compute_score=compute_score, + use_original_format=use_original_format, ) logger.info("Downloaded %d subtitle(s)", len(subtitles)) downloaded_subtitles[video].extend(subtitles) diff --git a/custom_libs/subliminal_patch/providers/assrt.py b/custom_libs/subliminal_patch/providers/assrt.py index 8058c57cb..a30265a44 100644 --- a/custom_libs/subliminal_patch/providers/assrt.py +++ b/custom_libs/subliminal_patch/providers/assrt.py @@ -11,7 +11,7 @@ from time import sleep from math import ceil from subliminal import Movie, Episode -from subliminal.exceptions import AuthenticationError, ConfigurationError, DownloadLimitExceeded, ProviderError +from subliminal.exceptions import ConfigurationError, ProviderError from subliminal_patch.subtitle import Subtitle, guess_matches from subliminal.subtitle import fix_line_ending from subliminal_patch.providers import Provider @@ -104,7 +104,7 @@ class AssrtSubtitle(Subtitle): if 'subtitle_language' in guess: langs.update(guess['subtitle_language']) if self.language in langs: - self._defail = f + self._detail = f return f # second pass: keyword matching @@ -112,7 +112,7 @@ class AssrtSubtitle(Subtitle): for f in files: langs = set([Language.fromassrt(k) for k in codes if k in f['f']]) if self.language in langs: - self._defail = f + self._detail = f return f # fallback: pick up first file if nothing matches diff --git a/custom_libs/subliminal_patch/providers/embeddedsubtitles.py b/custom_libs/subliminal_patch/providers/embeddedsubtitles.py index 2d8a492c7..8de224729 100644 --- a/custom_libs/subliminal_patch/providers/embeddedsubtitles.py +++ b/custom_libs/subliminal_patch/providers/embeddedsubtitles.py @@ -255,8 +255,6 @@ class EmbeddedSubtitlesProvider(Provider): class _MemoizedFFprobeVideoContainer(FFprobeVideoContainer): - # 128 is the default value for maxsize since Python 3.8. We ste it here for previous versions. - @functools.lru_cache(maxsize=128) def get_subtitles(self, *args, **kwargs): return super().get_subtitles(*args, **kwargs) @@ -287,7 +285,7 @@ def _check_hi_fallback(streams, languages): logger.debug("Checking HI fallback for '%r' language", language) streams_ = [ - stream for stream in streams if stream.language.alpha3 == language.alpha3 + stream for stream in streams if stream.language.alpha3 == language.alpha3 and stream.language.forced == language.forced ] if len(streams_) == 1 and streams_[0].disposition.hearing_impaired: stream_ = streams_[0] diff --git a/custom_libs/subliminal_patch/providers/napiprojekt.py b/custom_libs/subliminal_patch/providers/napiprojekt.py index 7f9a95eb9..b663348d8 100644 --- a/custom_libs/subliminal_patch/providers/napiprojekt.py +++ b/custom_libs/subliminal_patch/providers/napiprojekt.py @@ -1,6 +1,7 @@ # coding=utf-8 from __future__ import absolute_import import logging +import re from subliminal.providers.napiprojekt import NapiProjektProvider as _NapiProjektProvider, \ NapiProjektSubtitle as _NapiProjektSubtitle, get_subhash @@ -40,6 +41,11 @@ class NapiProjektProvider(_NapiProjektProvider): video_types = (Episode, Movie) subtitle_class = NapiProjektSubtitle + def __init__(self, only_authors=None, only_real_names=None): + super().__init__() + self.only_authors = only_authors + self.only_real_names = only_real_names + def query(self, language, hash): params = { 'v': 'dreambox', @@ -66,10 +72,23 @@ class NapiProjektProvider(_NapiProjektProvider): return subtitle def list_subtitles(self, video, languages): - def flatten(l): - return [item for sublist in l for item in sublist] - return [s for s in [self.query(l, video.hashes['napiprojekt']) for l in languages] if s is not None] + \ - flatten([self._scrape(video, l) for l in languages]) + def flatten(nested_list): + """Flatten a nested list.""" + return [item for sublist in nested_list for item in sublist] + + # Determine the source of subtitles based on conditions + hash_subtitles = [] + if not (self.only_authors or self.only_real_names): + hash_subtitles = [ + subtitle + for language in languages + if (subtitle := self.query(language, video.hashes.get('napiprojekt'))) is not None + ] + + # Scrape additional subtitles + scraped_subtitles = flatten([self._scrape(video, language) for language in languages]) + + return hash_subtitles + scraped_subtitles def download_subtitle(self, subtitle): if subtitle.content is not None: @@ -80,7 +99,8 @@ class NapiProjektProvider(_NapiProjektProvider): if language.alpha2 != 'pl': return [] title, matches = self._find_title(video) - if title == None: + + if title is None: return [] episode = f'-s{video.season:02d}e{video.episode:02d}' if isinstance( video, Episode) else '' @@ -89,14 +109,59 @@ class NapiProjektProvider(_NapiProjektProvider): response.raise_for_status() soup = BeautifulSoup(response.content, 'html.parser') subtitles = [] - for link in soup.find_all('a'): - if 'class' in link.attrs and 'tableA' in link.attrs['class']: - hash = link.attrs['href'][len('napiprojekt:'):] - subtitles.append( - NapiProjektSubtitle(language, - hash, - release_info=str(link.contents[0]), - matches=matches | ({'season', 'episode'} if episode else set()))) + + # Find all rows with titles and napiprojekt links + rows = soup.find_all("tr", title=True) + + for row in rows: + for link in row.find_all('a'): + if 'class' in link.attrs and 'tableA' in link.attrs['class']: + title = row['title'] + hash = link.attrs['href'][len('napiprojekt:'):] + + data = row.find_all('p') + + size = data[1].contents[0] if len(data) > 1 and data[1].contents else "" + length = data[3].contents[0] if len(data) > 3 and data[3].contents else "" + author = data[4].contents[0] if len(data) > 4 and data[4].contents else "" + added = data[5].contents[0] if len(data) > 5 and data[5].contents else "" + + if author == "": + match = re.search(r"Autor: (.*?)\(", title) + print(title) + if match: + author = match.group(1).strip() + else: + author = "" + + if self.only_authors: + if author.lower() in ["brak", "automat", "si", "chatgpt", "ai", "robot", "maszynowe", "tłumaczenie maszynowe"]: + continue + + if self.only_real_names: + # Check if `self.only_authors` contains exactly 2 uppercase letters and at least one lowercase letter + if not (re.match(r'^(?=(?:.*[A-Z]){2})(?=.*[a-z]).*$', author) or + re.match(r'^\w+\s\w+$', author)): + continue + + match = re.search(r"Video rozdzielczość: (.*?)<", title) + if match: + resolution = match.group(1).strip() + else: + resolution = "" + + match = re.search(r"Video FPS: (.*?)<", title) + if match: + fps = match.group(1).strip() + else: + fps = "" + + added_lenght = "Autor: " + author + " | " + resolution + " | " + fps + " | " + size + " | " + added + " | " + length + subtitles.append( + NapiProjektSubtitle(language, + hash, + release_info=added_lenght, + matches=matches | ({'season', 'episode'} if episode else set()))) logger.debug(f'Found subtitles {subtitles}') return subtitles @@ -114,15 +179,17 @@ class NapiProjektProvider(_NapiProjektProvider): video, Episode) else video.imdb_id def match_title_tag( - tag): return tag.name == 'a' and 'class' in tag.attrs and 'movieTitleCat' in tag.attrs['class'] and 'href' in tag.attrs + tag): + return tag.name == 'a' and 'class' in tag.attrs and 'movieTitleCat' in tag.attrs[ + 'class'] and 'href' in tag.attrs if imdb_id: for entry in soup.find_all(lambda tag: tag.name == 'div' and 'greyBoxCatcher' in tag['class']): if entry.find_all(href=lambda href: href and href.startswith(f'https://www.imdb.com/title/{imdb_id}')): for link in entry.find_all(match_title_tag): return link.attrs['href'][len('napisy-'):], \ - {'series', 'year', 'series_imdb_id'} if isinstance( - video, Episode) else {'title', 'year', 'imdb_id'} + {'series', 'year', 'series_imdb_id'} if isinstance( + video, Episode) else {'title', 'year', 'imdb_id'} type = 'episode' if isinstance(video, Episode) else 'movie' for link in soup.find_all(match_title_tag): diff --git a/custom_libs/subliminal_patch/providers/opensubtitles.py b/custom_libs/subliminal_patch/providers/opensubtitles.py index 678ec882e..84141757a 100644 --- a/custom_libs/subliminal_patch/providers/opensubtitles.py +++ b/custom_libs/subliminal_patch/providers/opensubtitles.py @@ -3,7 +3,6 @@ from __future__ import absolute_import import base64 import logging import os -import traceback import re import zlib import time @@ -411,6 +410,8 @@ def checked(fn, raise_api_limit=False): except requests.RequestException as e: status_code = e.response.status_code + if status_code == 503 and "Server under maintenance" in e.response.text: + status_code = 506 else: status_code = int(response['status'][:3]) except: @@ -437,6 +438,8 @@ def checked(fn, raise_api_limit=False): raise APIThrottled if status_code == 503: raise ServiceUnavailable(str(status_code)) + if status_code == 506: + raise ServiceUnavailable("Server under maintenance") if status_code != 200: if response and "status" in response: raise OpenSubtitlesError(response['status']) diff --git a/custom_libs/subliminal_patch/providers/opensubtitlescom.py b/custom_libs/subliminal_patch/providers/opensubtitlescom.py index 14289919a..0f0c2eaff 100644 --- a/custom_libs/subliminal_patch/providers/opensubtitlescom.py +++ b/custom_libs/subliminal_patch/providers/opensubtitlescom.py @@ -54,6 +54,7 @@ def fix_movie_naming(title): custom_languages = { 'pt': 'pt-PT', 'zh': 'zh-CN', + 'es-MX': 'ea', } @@ -156,9 +157,10 @@ class OpenSubtitlesComProvider(ProviderRetryMixin, Provider): """OpenSubtitlesCom Provider""" server_hostname = 'api.opensubtitles.com' - languages = {Language.fromopensubtitles(lang) for lang in language_converters['szopensubtitles'].codes} + languages = ({Language.fromietf("es-MX")} | + {Language.fromopensubtitles(lang) for lang in language_converters['szopensubtitles'].codes}) 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(lang, hi=True) for lang in languages)) video_types = (Episode, Movie) diff --git a/custom_libs/subliminal_patch/providers/podnapisi.py b/custom_libs/subliminal_patch/providers/podnapisi.py index d20accb99..5785570e1 100644 --- a/custom_libs/subliminal_patch/providers/podnapisi.py +++ b/custom_libs/subliminal_patch/providers/podnapisi.py @@ -209,7 +209,7 @@ class PodnapisiProvider(_PodnapisiProvider, ProviderSubtitleArchiveMixin): break # exit if no results - if (not xml.find('pagination/results') or not xml.find('pagination/results').text or not + if (xml.find('pagination/results') is None or not xml.find('pagination/results').text or not int(xml.find('pagination/results').text)): logger.debug('No subtitles found') break diff --git a/custom_libs/subliminal_patch/providers/regielive.py b/custom_libs/subliminal_patch/providers/regielive.py index d20972f03..8c7363bf0 100644 --- a/custom_libs/subliminal_patch/providers/regielive.py +++ b/custom_libs/subliminal_patch/providers/regielive.py @@ -92,17 +92,19 @@ class RegieLiveProvider(Provider): data=payload, headers=self.headers) subtitles = [] - if response.json()['cod'] == 200: - results_subs = response.json()['rezultate'] - for film in results_subs: - for sub in results_subs[film]['subtitrari']: - subtitles.append( - RegieLiveSubtitle( - results_subs[film]['subtitrari'][sub]['titlu'], - video, - results_subs[film]['subtitrari'][sub]['url'], - results_subs[film]['subtitrari'][sub]['rating']['nota'], - language)) + if response.status_code == 200: + results = response.json() + if len(results) > 0: + results_subs = results['rezultate'] + for film in results_subs: + for sub in results_subs[film]['subtitrari']: + subtitles.append( + RegieLiveSubtitle( + results_subs[film]['subtitrari'][sub]['titlu'], + video, + results_subs[film]['subtitrari'][sub]['url'], + results_subs[film]['subtitrari'][sub]['rating']['nota'], + language)) return subtitles def list_subtitles(self, video, languages): diff --git a/custom_libs/subliminal_patch/providers/subdivx.py b/custom_libs/subliminal_patch/providers/subdivx.py index 6a69dd37a..c9265f305 100644 --- a/custom_libs/subliminal_patch/providers/subdivx.py +++ b/custom_libs/subliminal_patch/providers/subdivx.py @@ -39,6 +39,7 @@ _SEASON_NUM_RE = re.compile( ) _EPISODE_YEAR_RE = re.compile(r"\((?P(19\d{2}|20[0-2]\d))\)") _UNSUPPORTED_RE = re.compile(r"(extras|forzado(s)?|forced)\s?$", flags=re.IGNORECASE) +_VERSION_RESOLUTION = re.compile(r'id="vs">([^<]+)<\/div>') logger = logging.getLogger(__name__) @@ -161,6 +162,16 @@ class SubdivxSubtitlesProvider(Provider): return subtitles + def _get_vs(self): + # t["buscar" + $("#vs").html().replace(".", "").replace("v", "")] = $("#buscar").val(), + res = self.session.get('https://subdivx.com/') + results = _VERSION_RESOLUTION.findall(res.text) + if results is not None and len(results) == 0: + return -1 + version = results[0] + version = version.replace('.','').replace('v','') + return version + def _query_results(self, query, video): token_link = f"{_SERVER_URL}/inc/gt.php?gt=1" @@ -180,8 +191,8 @@ class SubdivxSubtitlesProvider(Provider): raise ProviderError("Response doesn't include a token") search_link = f"{_SERVER_URL}/inc/ajax.php" - - payload = {"tabla": "resultados", "filtros": "", "buscar393": query, "token": token} + version = self._get_vs() + payload = {"tabla": "resultados", "filtros": "", f"buscar{version}": query, "token": token} logger.debug("Query: %s", query) diff --git a/custom_libs/subliminal_patch/providers/subdl.py b/custom_libs/subliminal_patch/providers/subdl.py index 102125eae..663e18399 100644 --- a/custom_libs/subliminal_patch/providers/subdl.py +++ b/custom_libs/subliminal_patch/providers/subdl.py @@ -188,7 +188,11 @@ class SubdlProvider(ProviderRetryMixin, Provider): if len(result['subtitles']): for item in result['subtitles']: - if item.get('episode_from', False) == item.get('episode_end', False): # ignore season packs + if (isinstance(self.video, Episode) and + item.get('episode_from', False) != item.get('episode_end', False)): + # ignore season packs + continue + else: subtitle = SubdlSubtitle( language=Language.fromsubdl(item['language']), forced=self._is_forced(item), diff --git a/custom_libs/subliminal_patch/providers/subsynchro.py b/custom_libs/subliminal_patch/providers/subsynchro.py index e05e7c4e7..9e3c629ec 100644 --- a/custom_libs/subliminal_patch/providers/subsynchro.py +++ b/custom_libs/subliminal_patch/providers/subsynchro.py @@ -6,6 +6,7 @@ import os from zipfile import ZipFile, is_zipfile from requests import Session from guessit import guessit +from requests.exceptions import JSONDecodeError from subliminal import Movie from subliminal.subtitle import SUBTITLE_EXTENSIONS, fix_line_ending @@ -91,7 +92,11 @@ class SubsynchroProvider(Provider): result.raise_for_status() subtitles = [] - results = result.json() or {} + + try: + results = result.json() + except JSONDecodeError: + results = {} status_ = results.get("status") diff --git a/custom_libs/subliminal_patch/providers/subtitrarinoi.py b/custom_libs/subliminal_patch/providers/subtitrarinoi.py index d9795666a..bc71ab53a 100644 --- a/custom_libs/subliminal_patch/providers/subtitrarinoi.py +++ b/custom_libs/subliminal_patch/providers/subtitrarinoi.py @@ -282,4 +282,7 @@ class SubtitrarinoiProvider(Provider, ProviderSubtitleArchiveMixin): r.raise_for_status() archive = get_archive_from_bytes(r.content) - subtitle.content = get_subtitle_from_archive(archive, episode=subtitle.desired_episode) + if archive: + subtitle.content = get_subtitle_from_archive(archive, episode=subtitle.desired_episode) + else: + subtitle.content = r.content diff --git a/custom_libs/subliminal_patch/providers/titlovi.py b/custom_libs/subliminal_patch/providers/titlovi.py index 88782522c..c7682ec9b 100644 --- a/custom_libs/subliminal_patch/providers/titlovi.py +++ b/custom_libs/subliminal_patch/providers/titlovi.py @@ -56,7 +56,7 @@ class TitloviSubtitle(Subtitle): provider_name = 'titlovi' def __init__(self, language, download_link, sid, releases, title, alt_title=None, season=None, - episode=None, year=None, rating=None, download_count=None, asked_for_release_group=None, asked_for_episode=None): + episode=None, year=None, rating=None, download_count=None, asked_for_release_group=None, asked_for_episode=None, is_pack=False): super(TitloviSubtitle, self).__init__(language) self.sid = sid self.releases = self.release_info = releases @@ -71,6 +71,7 @@ class TitloviSubtitle(Subtitle): self.matches = None self.asked_for_release_group = asked_for_release_group self.asked_for_episode = asked_for_episode + self.is_pack = is_pack def __repr__(self): if self.season and self.episode: @@ -216,7 +217,7 @@ class TitloviProvider(Provider, ProviderSubtitleArchiveMixin): is_episode = False if season and episode: is_episode = True - #search_params['season'] = season + search_params['season'] = season #search_params['episode'] = episode #if year: # search_params['year'] = year @@ -238,6 +239,18 @@ class TitloviProvider(Provider, ProviderSubtitleArchiveMixin): resp_json = response.json() if resp_json['SubtitleResults']: query_results.extend(resp_json['SubtitleResults']) + + # if there are more pages, loop through them. If there is more than 3 pages, stop at 3 + if resp_json['PagesAvailable'] > 1: + for page in range(2, min(4, resp_json['PagesAvailable'] + 1)): + search_params['pg'] = page + response = self.get_result(self.api_search_url, search_params) + resp_json = response.json() + if resp_json['SubtitleResults']: + query_results.extend(resp_json['SubtitleResults']) + else: + break + except TooManyRequests: raise except Exception as e: @@ -258,15 +271,19 @@ class TitloviProvider(Provider, ProviderSubtitleArchiveMixin): # skip if season and episode number does not match if season and season != sub.get('Season'): continue - elif episode and episode != sub.get('Episode'): + elif episode and episode != sub.get('Episode') and sub.get('Episode') != 0: continue + is_pack = False + if sub.get('Episode') == 0: + is_pack = True + subtitle = self.subtitle_class(Language.fromtitlovi(sub.get('Lang')), sub.get('Link'), sub.get('Id'), sub.get('Release'), _title, - alt_title=alt_title, season=sub.get('Season'), episode=sub.get('Episode'), + alt_title=alt_title, season=sub.get('Season'), episode=episode, year=sub.get('Year'), rating=sub.get('Rating'), download_count=sub.get('DownloadCount'), asked_for_release_group=video.release_group, - asked_for_episode=episode) + asked_for_episode=episode, is_pack=is_pack) else: subtitle = self.subtitle_class(Language.fromtitlovi(sub.get('Lang')), sub.get('Link'), sub.get('Id'), sub.get('Release'), _title, alt_title=alt_title, year=sub.get('Year'), rating=sub.get('Rating'), @@ -321,13 +338,25 @@ class TitloviProvider(Provider, ProviderSubtitleArchiveMixin): subs_in_archive = archive.namelist() - # if Serbian lat and cyr versions are packed together, try to find right version - if len(subs_in_archive) > 1 and (subtitle.language == 'sr' or subtitle.language == 'sr-Cyrl'): + if len(subs_in_archive) > 1 and subtitle.is_pack: + # if subtitle is a pack, try to find the right subtitle by format SSxEE or SxxEyy + self.get_subtitle_from_pack(subtitle, subs_in_archive, archive) + elif len(subs_in_archive) > 1 and (subtitle.language == 'sr' or subtitle.language == 'sr-Cyrl'): + # if Serbian lat and cyr versions are packed together, try to find right version self.get_subtitle_from_bundled_archive(subtitle, subs_in_archive, archive) else: # use default method for everything else subtitle.content = self.get_subtitle_from_archive(subtitle, archive) + def get_subtitle_from_pack(self, subtitle, subs_in_archive, archive): + # try to find the right subtitle, it should contain season and episode number in format SSxEE or SxxEyy + format1 = '%.2dx%.2d' % (subtitle.season, subtitle.episode) + format2 = 's%.2de%.2d' % (subtitle.season, subtitle.episode) + for sub_name in subs_in_archive: + if format1 in sub_name.lower() or format2 in sub_name.lower(): + subtitle.content = fix_line_ending(archive.read(sub_name)) + return + def get_subtitle_from_bundled_archive(self, subtitle, subs_in_archive, archive): sr_lat_subs = [] sr_cyr_subs = [] diff --git a/custom_libs/subliminal_patch/providers/titrari.py b/custom_libs/subliminal_patch/providers/titrari.py index 7caed684d..a9976df21 100644 --- a/custom_libs/subliminal_patch/providers/titrari.py +++ b/custom_libs/subliminal_patch/providers/titrari.py @@ -5,18 +5,18 @@ import os import io import logging import re -import rarfile -from random import randint from zipfile import ZipFile, is_zipfile from rarfile import RarFile, is_rarfile from guessit import guessit +from time import sleep + from subliminal_patch.providers import Provider from subliminal_patch.providers.mixins import ProviderSubtitleArchiveMixin from subliminal_patch.subtitle import Subtitle, guess_matches from subliminal_patch.utils import sanitize, fix_inconsistent_naming as _fix_inconsistent_naming -from .utils import FIRST_THOUSAND_OR_SO_USER_AGENTS as AGENT_LIST from subliminal.exceptions import ProviderError +from subliminal_patch.exceptions import TooManyRequests from subliminal.providers import ParserBeautifulSoup from subliminal.video import Episode, Movie from subliminal.subtitle import SUBTITLE_EXTENSIONS @@ -147,6 +147,10 @@ class TitrariProvider(Provider, ProviderSubtitleArchiveMixin): params = self.getQueryParams(imdb_id, title, language) search_response = self.session.get(self.api_url, params=params, timeout=15) + + if search_response.status_code == 404 and 'Too many requests' in search_response.content: + raise TooManyRequests(search_response.content) + search_response.raise_for_status() if not search_response.content: @@ -215,6 +219,8 @@ class TitrariProvider(Provider, ProviderSubtitleArchiveMixin): ordered_subs = self.order(subtitles) + sleep(5) # prevent being blocked for too many requests + return ordered_subs @staticmethod diff --git a/custom_libs/subliminal_patch/providers/titulky.py b/custom_libs/subliminal_patch/providers/titulky.py index 6d2a9aef3..0e8a6b9a7 100644 --- a/custom_libs/subliminal_patch/providers/titulky.py +++ b/custom_libs/subliminal_patch/providers/titulky.py @@ -24,6 +24,8 @@ from subliminal_patch.providers.mixins import ProviderSubtitleArchiveMixin from subliminal_patch.subtitle import Subtitle, guess_matches +from subliminal_patch.score import framerate_equal + from dogpile.cache.api import NO_VALUE from subzero.language import Language @@ -53,6 +55,8 @@ class TitulkySubtitle(Subtitle): approved, page_link, download_link, + fps, + skip_wrong_fps, asked_for_episode=None): super().__init__(language, page_link=page_link) @@ -67,6 +71,8 @@ class TitulkySubtitle(Subtitle): self.page_link = page_link self.uploader = uploader self.download_link = download_link + self.fps = fps if skip_wrong_fps else None # This attribute should be ignored if skip_wrong_fps is false + self.skip_wrong_fps = skip_wrong_fps self.asked_for_episode = asked_for_episode self.matches = None @@ -78,6 +84,10 @@ class TitulkySubtitle(Subtitle): matches = set() media_type = 'movie' if isinstance(video, Movie) else 'episode' + if self.skip_wrong_fps and video.fps and self.fps and not framerate_equal(video.fps, self.fps): + logger.debug(f"Titulky.com: Wrong FPS (expected: {video.fps}, got: {self.fps}, lowering score massively)") + return set() + if media_type == 'episode': # match imdb_id of a series if video.series_imdb_id and video.series_imdb_id == self.imdb_id: @@ -120,16 +130,19 @@ class TitulkyProvider(Provider, ProviderSubtitleArchiveMixin): def __init__(self, username=None, password=None, - approved_only=None): + approved_only=None, + skip_wrong_fps=None): if not all([username, password]): raise ConfigurationError("Username and password must be specified!") - if type(approved_only) is not bool: raise ConfigurationError(f"Approved_only {approved_only} must be a boolean!") + if type(skip_wrong_fps) is not bool: + raise ConfigurationError(f"Skip_wrong_fps {skip_wrong_fps} must be a boolean!") self.username = username self.password = password self.approved_only = approved_only + self.skip_wrong_fps = skip_wrong_fps self.session = None @@ -268,6 +281,48 @@ class TitulkyProvider(Provider, ProviderSubtitleArchiveMixin): return result + # Retrieves the fps value given subtitles id from the details page and caches it. + def retrieve_subtitles_fps(self, subtitles_id): + cache_key = f"titulky_subs-{subtitles_id}_fps" + cached_fps_value = cache.get(cache_key) + + if(cached_fps_value != NO_VALUE): + logger.debug(f"Titulky.com: Reusing cached fps value {cached_fps_value} for subtitles with id {subtitles_id}") + return cached_fps_value + + params = { + 'action': 'detail', + 'id': subtitles_id + } + browse_url = self.build_url(params) + html_src = self.fetch_page(browse_url, allow_redirects=True) + browse_page_soup = ParserBeautifulSoup(html_src, ['lxml', 'html.parser']) + + fps_container = browse_page_soup.select_one("div.ulozil:has(> img[src='img/ico/Movieroll.png'])") + if(fps_container is None): + logger.debug("Titulky.com: Could not manage to find the FPS container in the details page") + cache.set(cache_key, None) + return None + + fps_text_components = fps_container.get_text(strip=True).split() + # Check if the container contains valid fps data + if(len(fps_text_components) < 2 or fps_text_components[1].lower() != "fps"): + logger.debug(f"Titulky.com: Could not determine FPS value for subtitles with id {subtitles_id}") + cache.set(cache_key, None) + return None + + fps_text = fps_text_components[0].replace(",", ".") # Fix decimal comma to decimal point + try: + fps = float(fps_text) + logger.debug(f"Titulky.com: Retrieved FPS value {fps} from details page for subtitles with id {subtitles_id}") + cache.set(cache_key, fps) + return fps + except: + logger.debug(f"Titulky.com: There was an error parsing FPS value string for subtitles with id {subtitles_id}") + cache.set(cache_key, None) + return None + + """ There are multiple ways to find substitles on Titulky.com, however we are going to utilize a page that lists all available subtitles for all episodes in a season @@ -377,7 +432,8 @@ class TitulkyProvider(Provider, ProviderSubtitleArchiveMixin): 'language': sub_language, 'uploader': uploader, 'details_link': details_link, - 'download_link': download_link + 'download_link': download_link, + 'fps': self.retrieve_subtitles_fps(sub_id) if self.skip_wrong_fps else None, } # If this row contains the first subtitles to an episode number, @@ -413,7 +469,9 @@ class TitulkyProvider(Provider, ProviderSubtitleArchiveMixin): sub_info['approved'], sub_info['details_link'], sub_info['download_link'], - asked_for_episode=(media_type is SubtitlesType.EPISODE) + sub_info['fps'], + self.skip_wrong_fps, + asked_for_episode=(media_type is SubtitlesType.EPISODE), ) subtitles.append(subtitle_instance) diff --git a/custom_libs/subliminal_patch/providers/whisperai.py b/custom_libs/subliminal_patch/providers/whisperai.py index 0546717a2..c8535bd4f 100644 --- a/custom_libs/subliminal_patch/providers/whisperai.py +++ b/custom_libs/subliminal_patch/providers/whisperai.py @@ -5,6 +5,7 @@ from datetime import timedelta from requests import Session +from requests.exceptions import JSONDecodeError from subliminal_patch.subtitle import Subtitle from subliminal_patch.providers import Provider from subliminal import __short_version__ @@ -206,7 +207,10 @@ class WhisperAISubtitle(Subtitle): @property def id(self): - return self.video.original_name + # Construct unique id otherwise provider pool will think + # subtitles are all the same and drop all except the first one + # This is important for language profiles with more than one language + return f"{self.video.original_name}_{self.task}_{str(self.language)}" def get_matches(self, video): matches = set() @@ -229,7 +233,7 @@ class WhisperAIProvider(Provider): video_types = (Episode, Movie) - def __init__(self, endpoint=None, response=None, timeout=None, ffmpeg_path=None, loglevel=None): + def __init__(self, endpoint=None, response=None, timeout=None, ffmpeg_path=None, pass_video_name=None, loglevel=None): set_log_level(loglevel) if not endpoint: raise ConfigurationError('Whisper Web Service Endpoint must be provided') @@ -242,12 +246,16 @@ class WhisperAIProvider(Provider): if not ffmpeg_path: raise ConfigurationError("ffmpeg path must be provided") + + if pass_video_name is None: + raise ConfigurationError('Whisper Web Service Pass Video Name option must be provided') self.endpoint = endpoint.rstrip("/") self.response = int(response) self.timeout = int(timeout) self.session = None self.ffmpeg_path = ffmpeg_path + self.pass_video_name = pass_video_name def initialize(self): self.session = Session() @@ -269,10 +277,19 @@ class WhisperAIProvider(Provider): params={'encode': 'false'}, files={'audio_file': out}, timeout=(self.response, self.timeout)) + + try: + results = r.json() + except JSONDecodeError: + results = {} + + if len(results) == 0: + logger.info(f"Whisper returned empty response when detecting language") + return None - logger.debug(f"Whisper detected language of {path} as {r.json()['detected_language']}") + logger.debug(f"Whisper detected language of {path} as {results['detected_language']}") - return whisper_get_language(r.json()["language_code"], r.json()["detected_language"]) + return whisper_get_language(results["language_code"], results["detected_language"]) def query(self, language, video): if language not in self.languages: @@ -356,9 +373,11 @@ class WhisperAIProvider(Provider): logger.info(f'Starting WhisperAI {subtitle.task} to {language_from_alpha3(output_language)} for {subtitle.video.original_path}') startTime = time.time() + video_name = subtitle.video.original_path if self.pass_video_name else None r = self.session.post(f"{self.endpoint}/asr", - params={'task': subtitle.task, 'language': input_language, 'output': 'srt', 'encode': 'false'}, + params={'task': subtitle.task, 'language': input_language, 'output': 'srt', 'encode': 'false', + 'video_file': {video_name}}, files={'audio_file': out}, timeout=(self.response, self.timeout)) diff --git a/custom_libs/subliminal_patch/subtitle.py b/custom_libs/subliminal_patch/subtitle.py index c65f8cdd2..82d5a6895 100644 --- a/custom_libs/subliminal_patch/subtitle.py +++ b/custom_libs/subliminal_patch/subtitle.py @@ -313,13 +313,10 @@ class Subtitle(Subtitle_): logger.info("Got FPS from MicroDVD subtitle: %s", subs.fps) else: logger.info("Got format: %s", subs.format) - self._og_format = subs.format - self._is_valid = True - # if self.use_original_format: - # self.format = subs.format - # self._is_valid = True - # logger.debug("Using original format") - return True + if self.use_original_format: + self._og_format = subs.format + self._is_valid = True + return True except pysubs2.UnknownFPSError: # if parsing failed, use frame rate from provider diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f85352ba1..844959798 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,12 +9,12 @@ "version": "1.0.0", "license": "GPL-3", "dependencies": { - "@mantine/core": "^7.12.2", - "@mantine/dropzone": "^7.12.2", - "@mantine/form": "^7.12.2", - "@mantine/hooks": "^7.12.2", - "@mantine/modals": "^7.12.2", - "@mantine/notifications": "^7.12.2", + "@mantine/core": "^7.14.3", + "@mantine/dropzone": "^7.14.3", + "@mantine/form": "^7.14.3", + "@mantine/hooks": "^7.14.3", + "@mantine/modals": "^7.14.3", + "@mantine/notifications": "^7.14.3", "@tanstack/react-query": "^5.40.1", "@tanstack/react-table": "^8.19.2", "axios": "^1.7.4", @@ -26,10 +26,10 @@ }, "devDependencies": { "@fontsource/roboto": "^5.0.12", - "@fortawesome/fontawesome-svg-core": "^6.6.0", - "@fortawesome/free-brands-svg-icons": "^6.6.0", - "@fortawesome/free-regular-svg-icons": "^6.6.0", - "@fortawesome/free-solid-svg-icons": "^6.6.0", + "@fortawesome/fontawesome-svg-core": "^6.7.1", + "@fortawesome/free-brands-svg-icons": "^6.7.1", + "@fortawesome/free-regular-svg-icons": "^6.7.1", + "@fortawesome/free-solid-svg-icons": "^6.7.1", "@fortawesome/react-fontawesome": "^0.2.2", "@tanstack/react-query-devtools": "^5.40.1", "@testing-library/jest-dom": "^6.4.2", @@ -38,7 +38,7 @@ "@types/jest": "^29.5.12", "@types/lodash": "^4.17.1", "@types/node": "^20.12.6", - "@types/react": "^18.3.5", + "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", "@typescript-eslint/eslint-plugin": "^7.16.0", "@typescript-eslint/parser": "^7.16.0", @@ -63,7 +63,7 @@ "recharts": "^2.12.7", "sass": "^1.74.1", "typescript": "^5.4.4", - "vite": "^5.2.8", + "vite": "^5.4.8", "vite-plugin-checker": "^0.6.4", "vite-plugin-pwa": "^0.20.0", "vitest": "^1.2.2", @@ -1899,13 +1899,14 @@ "license": "MIT" }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", - "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "aix" @@ -1915,13 +1916,14 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", - "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -1931,13 +1933,14 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", - "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -1947,13 +1950,14 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", - "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -1963,13 +1967,14 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", - "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -1979,13 +1984,14 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", - "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -1995,13 +2001,14 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", - "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -2011,13 +2018,14 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", - "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -2027,13 +2035,14 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", - "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -2043,13 +2052,14 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", - "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -2059,13 +2069,14 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", - "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -2075,13 +2086,14 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", - "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", "cpu": [ "loong64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -2091,13 +2103,14 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", - "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", "cpu": [ "mips64el" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -2107,13 +2120,14 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", - "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -2123,13 +2137,14 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", - "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", "cpu": [ "riscv64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -2139,13 +2154,14 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", - "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", "cpu": [ "s390x" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -2155,13 +2171,14 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", - "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -2171,13 +2188,14 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", - "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "netbsd" @@ -2187,13 +2205,14 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", - "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "openbsd" @@ -2203,13 +2222,14 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", - "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "sunos" @@ -2219,13 +2239,14 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", - "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -2235,13 +2256,14 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", - "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -2251,13 +2273,14 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", - "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -2350,29 +2373,29 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.0.tgz", - "integrity": "sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==", + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.8.tgz", + "integrity": "sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==", "dependencies": { - "@floating-ui/utils": "^0.2.1" + "@floating-ui/utils": "^0.2.8" } }, "node_modules/@floating-ui/dom": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.3.tgz", - "integrity": "sha512-RnDthu3mzPlQ31Ss/BTwQ1zjzIhr3lk1gZB1OC56h/1vEtaXkESrOqL5fQVMfXpwGtRwX+YsZBdyHtJMQnkArw==", + "version": "1.6.12", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.12.tgz", + "integrity": "sha512-NP83c0HjokcGVEMeoStg317VD9W7eDlGK7457dMBANbKA6GJZdc7rjujdgqzTaz93jkGgc5P/jeWbaCHnMNc+w==", "dependencies": { - "@floating-ui/core": "^1.0.0", - "@floating-ui/utils": "^0.2.0" + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.8" } }, "node_modules/@floating-ui/react": { - "version": "0.26.12", - "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.12.tgz", - "integrity": "sha512-D09o62HrWdIkstF2kGekIKAC0/N/Dl6wo3CQsnLcOmO3LkW6Ik8uIb3kw8JYkwxNCcg+uJ2bpWUiIijTBep05w==", + "version": "0.26.28", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz", + "integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==", "dependencies": { - "@floating-ui/react-dom": "^2.0.0", - "@floating-ui/utils": "^0.2.0", + "@floating-ui/react-dom": "^2.1.2", + "@floating-ui/utils": "^0.2.8", "tabbable": "^6.0.0" }, "peerDependencies": { @@ -2381,11 +2404,11 @@ } }, "node_modules/@floating-ui/react-dom": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.0.8.tgz", - "integrity": "sha512-HOdqOt3R3OGeTKidaLvJKcgg75S6tibQ3Tif4eyd91QnIJWr0NLvoXFpJA/j8HqkFSL68GDca9AuyWEHlhyClw==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", + "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", "dependencies": { - "@floating-ui/dom": "^1.6.1" + "@floating-ui/dom": "^1.0.0" }, "peerDependencies": { "react": ">=16.8.0", @@ -2393,9 +2416,9 @@ } }, "node_modules/@floating-ui/utils": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.1.tgz", - "integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==" + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz", + "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==" }, "node_modules/@fontsource/roboto": { "version": "5.0.12", @@ -2404,57 +2427,57 @@ "dev": true }, "node_modules/@fortawesome/fontawesome-common-types": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.6.0.tgz", - "integrity": "sha512-xyX0X9mc0kyz9plIyryrRbl7ngsA9jz77mCZJsUkLl+ZKs0KWObgaEBoSgQiYWAsSmjz/yjl0F++Got0Mdp4Rw==", + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.7.1.tgz", + "integrity": "sha512-gbDz3TwRrIPT3i0cDfujhshnXO9z03IT1UKRIVi/VEjpNHtSBIP2o5XSm+e816FzzCFEzAxPw09Z13n20PaQJQ==", "dev": true, "engines": { "node": ">=6" } }, "node_modules/@fortawesome/fontawesome-svg-core": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.6.0.tgz", - "integrity": "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg==", + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.7.1.tgz", + "integrity": "sha512-8dBIHbfsKlCk2jHQ9PoRBg2Z+4TwyE3vZICSnoDlnsHA6SiMlTwfmW6yX0lHsRmWJugkeb92sA0hZdkXJhuz+g==", "dev": true, "dependencies": { - "@fortawesome/fontawesome-common-types": "6.6.0" + "@fortawesome/fontawesome-common-types": "6.7.1" }, "engines": { "node": ">=6" } }, "node_modules/@fortawesome/free-brands-svg-icons": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.6.0.tgz", - "integrity": "sha512-1MPD8lMNW/earme4OQi1IFHtmHUwAKgghXlNwWi9GO7QkTfD+IIaYpIai4m2YJEzqfEji3jFHX1DZI5pbY/biQ==", + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.7.1.tgz", + "integrity": "sha512-nJR76eqPzCnMyhbiGf6X0aclDirZriTPRcFm1YFvuupyJOGwlNF022w3YBqu+yrHRhnKRpzFX+8wJKqiIjWZkA==", "dev": true, "dependencies": { - "@fortawesome/fontawesome-common-types": "6.6.0" + "@fortawesome/fontawesome-common-types": "6.7.1" }, "engines": { "node": ">=6" } }, "node_modules/@fortawesome/free-regular-svg-icons": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.6.0.tgz", - "integrity": "sha512-Yv9hDzL4aI73BEwSEh20clrY8q/uLxawaQ98lekBx6t9dQKDHcDzzV1p2YtBGTtolYtNqcWdniOnhzB+JPnQEQ==", + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.7.1.tgz", + "integrity": "sha512-e13cp+bAx716RZOTQ59DhqikAgETA9u1qTBHO3e3jMQQ+4H/N1NC1ZVeFYt1V0m+Th68BrEL1/X6XplISutbXg==", "dev": true, "dependencies": { - "@fortawesome/fontawesome-common-types": "6.6.0" + "@fortawesome/fontawesome-common-types": "6.7.1" }, "engines": { "node": ">=6" } }, "node_modules/@fortawesome/free-solid-svg-icons": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.6.0.tgz", - "integrity": "sha512-IYv/2skhEDFc2WGUcqvFJkeK39Q+HyPf5GHUrT/l2pKbtgEIv1al1TKd6qStR5OIwQdN1GZP54ci3y4mroJWjA==", + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.7.1.tgz", + "integrity": "sha512-BTKc0b0mgjWZ2UDKVgmwaE0qt0cZs6ITcDgjrti5f/ki7aF5zs+N91V6hitGo3TItCFtnKg6cUVGdTmBFICFRg==", "dev": true, "dependencies": { - "@fortawesome/fontawesome-common-types": "6.6.0" + "@fortawesome/fontawesome-common-types": "6.7.1" }, "engines": { "node": ">=6" @@ -2616,27 +2639,27 @@ } }, "node_modules/@mantine/core": { - "version": "7.12.2", - "resolved": "https://registry.npmjs.org/@mantine/core/-/core-7.12.2.tgz", - "integrity": "sha512-FrMHOKq4s3CiPIxqZ9xnVX7H4PEGNmbtHMvWO/0YlfPgoV0Er/N/DNJOFW1ys4WSnidPTayYeB41riyxxGOpRQ==", + "version": "7.14.3", + "resolved": "https://registry.npmjs.org/@mantine/core/-/core-7.14.3.tgz", + "integrity": "sha512-niAi+ZYBr4KrG+X2Mx+muvEzUOOHc/Rx0vsbIGYeNe7urwHSm/xNEGsaapmCqeRC0CSL4KI6TJOq8QhnSuQZcw==", "dependencies": { - "@floating-ui/react": "^0.26.9", + "@floating-ui/react": "^0.26.28", "clsx": "^2.1.1", - "react-number-format": "^5.3.1", - "react-remove-scroll": "^2.5.7", - "react-textarea-autosize": "8.5.3", - "type-fest": "^4.12.0" + "react-number-format": "^5.4.2", + "react-remove-scroll": "^2.6.0", + "react-textarea-autosize": "8.5.5", + "type-fest": "^4.27.0" }, "peerDependencies": { - "@mantine/hooks": "7.12.2", - "react": "^18.2.0", - "react-dom": "^18.2.0" + "@mantine/hooks": "7.14.3", + "react": "^18.x || ^19.x", + "react-dom": "^18.x || ^19.x" } }, "node_modules/@mantine/core/node_modules/type-fest": { - "version": "4.15.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.15.0.tgz", - "integrity": "sha512-tB9lu0pQpX5KJq54g+oHOLumOx+pMep4RaM6liXh2PKmVRFF+/vAtUP0ZaJ0kOySfVNjF6doBWPHhBhISKdlIA==", + "version": "4.30.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.30.0.tgz", + "integrity": "sha512-G6zXWS1dLj6eagy6sVhOMQiLtJdxQBHIA9Z6HFUNLOlr6MFOgzV8wvmidtPONfPtEUv0uZsy77XJNzTAfwPDaA==", "engines": { "node": ">=16" }, @@ -2645,71 +2668,71 @@ } }, "node_modules/@mantine/dropzone": { - "version": "7.12.2", - "resolved": "https://registry.npmjs.org/@mantine/dropzone/-/dropzone-7.12.2.tgz", - "integrity": "sha512-VXKpgFBfRfci6eQEyrmNSsTR7LdtErDhWloVw7W6YRsCqJxJHg9e3luG+yIk+tokzSyLoLOVZRX/mESDEso3PQ==", + "version": "7.14.3", + "resolved": "https://registry.npmjs.org/@mantine/dropzone/-/dropzone-7.14.3.tgz", + "integrity": "sha512-9ExiWRod5/gBHBd4hsUnPk7Rles0BiJr5FE2Kuq7lqeEXbtYfuSognJD/f5atMgu/5mMEkkyK/Bq5XesZBumBQ==", "dependencies": { - "react-dropzone-esm": "15.0.1" + "react-dropzone-esm": "15.2.0" }, "peerDependencies": { - "@mantine/core": "7.12.2", - "@mantine/hooks": "7.12.2", - "react": "^18.2.0", - "react-dom": "^18.2.0" + "@mantine/core": "7.14.3", + "@mantine/hooks": "7.14.3", + "react": "^18.x || ^19.x", + "react-dom": "^18.x || ^19.x" } }, "node_modules/@mantine/form": { - "version": "7.12.2", - "resolved": "https://registry.npmjs.org/@mantine/form/-/form-7.12.2.tgz", - "integrity": "sha512-MknzDN5F7u/V24wVrL5VIXNvE7/6NMt40K6w3p7wbKFZiLhdh/tDWdMcRN7PkkWF1j2+eoVCBAOCL74U3BzNag==", + "version": "7.14.3", + "resolved": "https://registry.npmjs.org/@mantine/form/-/form-7.14.3.tgz", + "integrity": "sha512-NquXVQz3IRCT5WTWCEdQjQzThMj7FpX/u0PDD+8XydiMPB7zJGPM9IdV88mWDI2ghT9vS6rBn22XWjTYsKa8+A==", "dependencies": { "fast-deep-equal": "^3.1.3", "klona": "^2.0.6" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.x || ^19.x" } }, "node_modules/@mantine/hooks": { - "version": "7.12.2", - "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-7.12.2.tgz", - "integrity": "sha512-dVMw8jpM0hAzc8e7/GNvzkk9N0RN/m+PKycETB3H6lJGuXJJSRR4wzzgQKpEhHwPccktDpvb4rkukKDq2jA8Fg==", + "version": "7.14.3", + "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-7.14.3.tgz", + "integrity": "sha512-cU3R9b8GLs6aCvpsVC56ZOOJCUIoDqX3RcLWkcfpA5a47LjWa/rzegP4YWfNW6/E9vodPJT4AEbYXVffYlyNwA==", "peerDependencies": { - "react": "^18.2.0" + "react": "^18.x || ^19.x" } }, "node_modules/@mantine/modals": { - "version": "7.12.2", - "resolved": "https://registry.npmjs.org/@mantine/modals/-/modals-7.12.2.tgz", - "integrity": "sha512-ffnu9MtUHceoaLlhrwq+J+eojidEPkq3m2Rrt5HfcZv3vAP8RtqPnTfgk99WOB3vyCtdu8r4I9P3ckuYtPRtAg==", + "version": "7.14.3", + "resolved": "https://registry.npmjs.org/@mantine/modals/-/modals-7.14.3.tgz", + "integrity": "sha512-wn2eMSROG7bPbeSH2OnTp8iVv1wH9L9tLeBt88mTEXLg3vIPfQtWD9g/kFrjhoCjygYYtyJeqMQFYPUkHQMXDw==", "peerDependencies": { - "@mantine/core": "7.12.2", - "@mantine/hooks": "7.12.2", - "react": "^18.2.0", - "react-dom": "^18.2.0" + "@mantine/core": "7.14.3", + "@mantine/hooks": "7.14.3", + "react": "^18.x || ^19.x", + "react-dom": "^18.x || ^19.x" } }, "node_modules/@mantine/notifications": { - "version": "7.12.2", - "resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-7.12.2.tgz", - "integrity": "sha512-gTvLHkoAZ42v5bZxibP9A50djp5ndEwumVhHSa7mxQ8oSS23tt3It/6hOqH7M+9kHY0a8s+viMiflUzTByA9qg==", + "version": "7.14.3", + "resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-7.14.3.tgz", + "integrity": "sha512-7N9u4upi1On8TL94UvrUNhpDGxp1sAkbcgiNcu6zhvy4j29TPFapoXB5CRE9zzjAf3CYq3AigE96bXlCDm9xuQ==", "dependencies": { - "@mantine/store": "7.12.2", + "@mantine/store": "7.14.3", "react-transition-group": "4.4.5" }, "peerDependencies": { - "@mantine/core": "7.12.2", - "@mantine/hooks": "7.12.2", - "react": "^18.2.0", - "react-dom": "^18.2.0" + "@mantine/core": "7.14.3", + "@mantine/hooks": "7.14.3", + "react": "^18.x || ^19.x", + "react-dom": "^18.x || ^19.x" } }, "node_modules/@mantine/store": { - "version": "7.12.2", - "resolved": "https://registry.npmjs.org/@mantine/store/-/store-7.12.2.tgz", - "integrity": "sha512-NqL31sO/KcAETEWP/CiXrQOQNoE4168vZsxyXacQHGBueVMJa64WIDQtKLHrCnFRMws3vsXF02/OO4bH4XGcMQ==", + "version": "7.14.3", + "resolved": "https://registry.npmjs.org/@mantine/store/-/store-7.14.3.tgz", + "integrity": "sha512-o15vbTUNlLqD/yLOtEClnc4fY2ONDaCZiaL9REUy0xhCDbVTeeqnu9BV604yuym50ZH5mhMHix1TX3K9vGsWvA==", "peerDependencies": { - "react": "^18.2.0" + "react": "^18.x || ^19.x" } }, "node_modules/@nodelib/fs.scandir": { @@ -2841,195 +2864,224 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.14.1.tgz", - "integrity": "sha512-fH8/o8nSUek8ceQnT7K4EQbSiV7jgkHq81m9lWZFIXjJ7lJzpWXbQFpT/Zh6OZYnpFykvzC3fbEvEAFZu03dPA==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.24.0.tgz", + "integrity": "sha512-Q6HJd7Y6xdB48x8ZNVDOqsbh2uByBhgK8PiQgPhwkIw/HC/YX5Ghq2mQY5sRMZWHb3VsFkWooUVOZHKr7DmDIA==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.14.1.tgz", - "integrity": "sha512-Y/9OHLjzkunF+KGEoJr3heiD5X9OLa8sbT1lm0NYeKyaM3oMhhQFvPB0bNZYJwlq93j8Z6wSxh9+cyKQaxS7PQ==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.24.0.tgz", + "integrity": "sha512-ijLnS1qFId8xhKjT81uBHuuJp2lU4x2yxa4ctFPtG+MqEE6+C5f/+X/bStmxapgmwLwiL3ih122xv8kVARNAZA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.14.1.tgz", - "integrity": "sha512-+kecg3FY84WadgcuSVm6llrABOdQAEbNdnpi5X3UwWiFVhZIZvKgGrF7kmLguvxHNQy+UuRV66cLVl3S+Rkt+Q==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.24.0.tgz", + "integrity": "sha512-bIv+X9xeSs1XCk6DVvkO+S/z8/2AMt/2lMqdQbMrmVpgFvXlmde9mLcbQpztXm1tajC3raFDqegsH18HQPMYtA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.14.1.tgz", - "integrity": "sha512-2pYRzEjVqq2TB/UNv47BV/8vQiXkFGVmPFwJb+1E0IFFZbIX8/jo1olxqqMbo6xCXf8kabANhp5bzCij2tFLUA==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.24.0.tgz", + "integrity": "sha512-X6/nOwoFN7RT2svEQWUsW/5C/fYMBe4fnLK9DQk4SX4mgVBiTA9h64kjUYPvGQ0F/9xwJ5U5UfTbl6BEjaQdBQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.14.1.tgz", - "integrity": "sha512-mS6wQ6Do6/wmrF9aTFVpIJ3/IDXhg1EZcQFYHZLHqw6AzMBjTHWnCG35HxSqUNphh0EHqSM6wRTT8HsL1C0x5g==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.24.0.tgz", + "integrity": "sha512-0KXvIJQMOImLCVCz9uvvdPgfyWo93aHHp8ui3FrtOP57svqrF/roSSR5pjqL2hcMp0ljeGlU4q9o/rQaAQ3AYA==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.24.0.tgz", + "integrity": "sha512-it2BW6kKFVh8xk/BnHfakEeoLPv8STIISekpoF+nBgWM4d55CZKc7T4Dx1pEbTnYm/xEKMgy1MNtYuoA8RFIWw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.14.1.tgz", - "integrity": "sha512-p9rGKYkHdFMzhckOTFubfxgyIO1vw//7IIjBBRVzyZebWlzRLeNhqxuSaZ7kCEKVkm/kuC9fVRW9HkC/zNRG2w==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.24.0.tgz", + "integrity": "sha512-i0xTLXjqap2eRfulFVlSnM5dEbTVque/3Pi4g2y7cxrs7+a9De42z4XxKLYJ7+OhE3IgxvfQM7vQc43bwTgPwA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.14.1.tgz", - "integrity": "sha512-nDY6Yz5xS/Y4M2i9JLQd3Rofh5OR8Bn8qe3Mv/qCVpHFlwtZSBYSPaU4mrGazWkXrdQ98GB//H0BirGR/SKFSw==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.24.0.tgz", + "integrity": "sha512-9E6MKUJhDuDh604Qco5yP/3qn3y7SLXYuiC0Rpr89aMScS2UAmK1wHP2b7KAa1nSjWJc/f/Lc0Wl1L47qjiyQw==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.14.1.tgz", - "integrity": "sha512-im7HE4VBL+aDswvcmfx88Mp1soqL9OBsdDBU8NqDEYtkri0qV0THhQsvZtZeNNlLeCUQ16PZyv7cqutjDF35qw==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.24.0.tgz", + "integrity": "sha512-2XFFPJ2XMEiF5Zi2EBf4h73oR1V/lycirxZxHZNc93SqDN/IWhYYSYj8I9381ikUFXZrz2v7r2tOVk2NBwxrWw==", "cpu": [ - "ppc64le" + "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.14.1.tgz", - "integrity": "sha512-RWdiHuAxWmzPJgaHJdpvUUlDz8sdQz4P2uv367T2JocdDa98iRw2UjIJ4QxSyt077mXZT2X6pKfT2iYtVEvOFw==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.24.0.tgz", + "integrity": "sha512-M3Dg4hlwuntUCdzU7KjYqbbd+BLq3JMAOhCKdBE3TcMGMZbKkDdJ5ivNdehOssMCIokNHFOsv7DO4rlEOfyKpg==", "cpu": [ "riscv64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.14.1.tgz", - "integrity": "sha512-VMgaGQ5zRX6ZqV/fas65/sUGc9cPmsntq2FiGmayW9KMNfWVG/j0BAqImvU4KTeOOgYSf1F+k6at1UfNONuNjA==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.24.0.tgz", + "integrity": "sha512-mjBaoo4ocxJppTorZVKWFpy1bfFj9FeCMJqzlMQGjpNPY9JwQi7OuS1axzNIk0nMX6jSgy6ZURDZ2w0QW6D56g==", "cpu": [ "s390x" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.14.1.tgz", - "integrity": "sha512-9Q7DGjZN+hTdJomaQ3Iub4m6VPu1r94bmK2z3UeWP3dGUecRC54tmVu9vKHTm1bOt3ASoYtEz6JSRLFzrysKlA==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.24.0.tgz", + "integrity": "sha512-ZXFk7M72R0YYFN5q13niV0B7G8/5dcQ9JDp8keJSfr3GoZeXEoMHP/HlvqROA3OMbMdfr19IjCeNAnPUG93b6A==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.14.1.tgz", - "integrity": "sha512-JNEG/Ti55413SsreTguSx0LOVKX902OfXIKVg+TCXO6Gjans/k9O6ww9q3oLGjNDaTLxM+IHFMeXy/0RXL5R/g==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.24.0.tgz", + "integrity": "sha512-w1i+L7kAXZNdYl+vFvzSZy8Y1arS7vMgIy8wusXJzRrPyof5LAb02KGr1PD2EkRcl73kHulIID0M501lN+vobQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.14.1.tgz", - "integrity": "sha512-ryS22I9y0mumlLNwDFYZRDFLwWh3aKaC72CWjFcFvxK0U6v/mOkM5Up1bTbCRAhv3kEIwW2ajROegCIQViUCeA==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.24.0.tgz", + "integrity": "sha512-VXBrnPWgBpVDCVY6XF3LEW0pOU51KbaHhccHw6AS6vBWIC60eqsH19DAeeObl+g8nKAz04QFdl/Cefta0xQtUQ==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.14.1.tgz", - "integrity": "sha512-TdloItiGk+T0mTxKx7Hp279xy30LspMso+GzQvV2maYePMAWdmrzqSNZhUpPj3CGw12aGj57I026PgLCTu8CGg==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.24.0.tgz", + "integrity": "sha512-xrNcGDU0OxVcPTH/8n/ShH4UevZxKIO6HJFK0e15XItZP2UcaiLFd5kiX7hJnqCbSztUF8Qot+JWBC/QXRPYWQ==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.14.1.tgz", - "integrity": "sha512-wQGI+LY/Py20zdUPq+XCem7JcPOyzIJBm3dli+56DJsQOHbnXZFEwgmnC6el1TPAfC8lBT3m+z69RmLykNUbew==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.24.0.tgz", + "integrity": "sha512-fbMkAF7fufku0N2dE5TBXcNlg0pt0cJue4xBRE2Qc5Vqikxr4VCgKj/ht6SMdFcOacVA9rqF70APJ8RN/4vMJw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -3382,10 +3434,11 @@ "dev": true }, "node_modules/@types/estree": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", - "dev": true + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "dev": true, + "license": "MIT" }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", @@ -3481,9 +3534,9 @@ "devOptional": true }, "node_modules/@types/react": { - "version": "18.3.5", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.5.tgz", - "integrity": "sha512-WeqMfGJLGuLCqHGYRGHxnKrXcTitc6L/nBUWfWPcTarG3t9PsquqUMuVeXZeca+mglY4Vo5GZjCi0A3Or2lnxA==", + "version": "18.3.11", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.11.tgz", + "integrity": "sha512-r6QZ069rFTjrEYgFdOck1gK7FLVsgJE7tTz0pQBczlBNUhBNk0MQH4UbnFSwjpQLMkLzgqvBBa+qGpLje16eTQ==", "devOptional": true, "dependencies": { "@types/prop-types": "*", @@ -5240,10 +5293,11 @@ } }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, + "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -5905,11 +5959,12 @@ } }, "node_modules/esbuild": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", - "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", "dev": true, "hasInstallScript": true, + "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, @@ -5917,29 +5972,29 @@ "node": ">=12" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.20.2", - "@esbuild/android-arm": "0.20.2", - "@esbuild/android-arm64": "0.20.2", - "@esbuild/android-x64": "0.20.2", - "@esbuild/darwin-arm64": "0.20.2", - "@esbuild/darwin-x64": "0.20.2", - "@esbuild/freebsd-arm64": "0.20.2", - "@esbuild/freebsd-x64": "0.20.2", - "@esbuild/linux-arm": "0.20.2", - "@esbuild/linux-arm64": "0.20.2", - "@esbuild/linux-ia32": "0.20.2", - "@esbuild/linux-loong64": "0.20.2", - "@esbuild/linux-mips64el": "0.20.2", - "@esbuild/linux-ppc64": "0.20.2", - "@esbuild/linux-riscv64": "0.20.2", - "@esbuild/linux-s390x": "0.20.2", - "@esbuild/linux-x64": "0.20.2", - "@esbuild/netbsd-x64": "0.20.2", - "@esbuild/openbsd-x64": "0.20.2", - "@esbuild/sunos-x64": "0.20.2", - "@esbuild/win32-arm64": "0.20.2", - "@esbuild/win32-ia32": "0.20.2", - "@esbuild/win32-x64": "0.20.2" + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" } }, "node_modules/escalade": { @@ -7979,12 +8034,13 @@ } }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, + "license": "MIT", "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { @@ -8398,10 +8454,11 @@ } }, "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", - "dev": true + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==", + "dev": true, + "license": "ISC" }, "node_modules/picomatch": { "version": "2.3.1", @@ -8436,9 +8493,9 @@ } }, "node_modules/postcss": { - "version": "8.4.38", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", - "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", "dev": true, "funding": [ { @@ -8454,10 +8511,11 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.2.0" + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -8881,9 +8939,9 @@ } }, "node_modules/react-dropzone-esm": { - "version": "15.0.1", - "resolved": "https://registry.npmjs.org/react-dropzone-esm/-/react-dropzone-esm-15.0.1.tgz", - "integrity": "sha512-RdeGpqwHnoV/IlDFpQji7t7pTtlC2O1i/Br0LWkRZ9hYtLyce814S71h5NolnCZXsIN5wrZId6+8eQj2EBnEzg==", + "version": "15.2.0", + "resolved": "https://registry.npmjs.org/react-dropzone-esm/-/react-dropzone-esm-15.2.0.tgz", + "integrity": "sha512-pPwR8xWVL+tFLnbAb8KVH5f6Vtl397tck8dINkZ1cPMxHWH+l9dFmIgRWgbh7V7jbjIcuKXCsVrXbhQz68+dVA==", "dependencies": { "prop-types": "^15.8.1" }, @@ -8900,12 +8958,9 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, "node_modules/react-number-format": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.3.4.tgz", - "integrity": "sha512-2hHN5mbLuCDUx19bv0Q8wet67QqYK6xmtLQeY5xx+h7UXiMmRtaCwqko4mMPoKXLc6xAzwRrutg8XbTRlsfjRg==", - "dependencies": { - "prop-types": "^15.7.2" - }, + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.2.tgz", + "integrity": "sha512-cg//jVdS49PYDgmcYoBnMMHl4XNTMuV723ZnHD2aXYtWWWqbVF3hjQ8iB+UZEuXapLbeA8P8H+1o6ZB1lcw3vg==", "peerDependencies": { "react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0", "react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" @@ -8921,9 +8976,9 @@ } }, "node_modules/react-remove-scroll": { - "version": "2.5.9", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.9.tgz", - "integrity": "sha512-bvHCLBrFfM2OgcrpPY2YW84sPdS2o2HKWJUf1xGyGLnSoEnOTOBpahIarjRuYtN0ryahCeP242yf+5TrBX/pZA==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.0.tgz", + "integrity": "sha512-I2U4JVEsQenxDAKaVa3VZ/JeJZe0/2DxPWL8Tj8yLKctQJQiZM52pn/GWFpSp8dftjM3pSAHVJZscAnC/y+ySQ==", "dependencies": { "react-remove-scroll-bar": "^2.3.6", "react-style-singleton": "^2.2.1", @@ -9033,9 +9088,9 @@ } }, "node_modules/react-textarea-autosize": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.3.tgz", - "integrity": "sha512-XT1024o2pqCuZSuBt9FwHlaDeNtVrtCXu0Rnz88t1jUGheCLa3PhjE1GH8Ctm2axEtvdCl5SUHYschyQ0L5QHQ==", + "version": "8.5.5", + "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.5.tgz", + "integrity": "sha512-CVA94zmfp8m4bSHtWwmANaBR8EPsKy2aZ7KwqhoS4Ftib87F9Kvi7XQhOixypPLMc6kVYgOXvKFuuzZDpHGRPg==", "dependencies": { "@babel/runtime": "^7.20.13", "use-composed-ref": "^1.3.0", @@ -9291,12 +9346,13 @@ } }, "node_modules/rollup": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.14.1.tgz", - "integrity": "sha512-4LnHSdd3QK2pa1J6dFbfm1HN0D7vSK/ZuZTsdyUAlA6Rr1yTouUTL13HaDOGJVgby461AhrNGBS7sCGXXtT+SA==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.24.0.tgz", + "integrity": "sha512-DOmrlGSXNk1DM0ljiQA+i+o0rSLhtii1je5wgk60j49d1jHT5YYttBv1iWOnYSTG+fZZESUOSNiAl89SIet+Cg==", "dev": true, + "license": "MIT", "dependencies": { - "@types/estree": "1.0.5" + "@types/estree": "1.0.6" }, "bin": { "rollup": "dist/bin/rollup" @@ -9306,21 +9362,22 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.14.1", - "@rollup/rollup-android-arm64": "4.14.1", - "@rollup/rollup-darwin-arm64": "4.14.1", - "@rollup/rollup-darwin-x64": "4.14.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.14.1", - "@rollup/rollup-linux-arm64-gnu": "4.14.1", - "@rollup/rollup-linux-arm64-musl": "4.14.1", - "@rollup/rollup-linux-powerpc64le-gnu": "4.14.1", - "@rollup/rollup-linux-riscv64-gnu": "4.14.1", - "@rollup/rollup-linux-s390x-gnu": "4.14.1", - "@rollup/rollup-linux-x64-gnu": "4.14.1", - "@rollup/rollup-linux-x64-musl": "4.14.1", - "@rollup/rollup-win32-arm64-msvc": "4.14.1", - "@rollup/rollup-win32-ia32-msvc": "4.14.1", - "@rollup/rollup-win32-x64-msvc": "4.14.1", + "@rollup/rollup-android-arm-eabi": "4.24.0", + "@rollup/rollup-android-arm64": "4.24.0", + "@rollup/rollup-darwin-arm64": "4.24.0", + "@rollup/rollup-darwin-x64": "4.24.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.24.0", + "@rollup/rollup-linux-arm-musleabihf": "4.24.0", + "@rollup/rollup-linux-arm64-gnu": "4.24.0", + "@rollup/rollup-linux-arm64-musl": "4.24.0", + "@rollup/rollup-linux-powerpc64le-gnu": "4.24.0", + "@rollup/rollup-linux-riscv64-gnu": "4.24.0", + "@rollup/rollup-linux-s390x-gnu": "4.24.0", + "@rollup/rollup-linux-x64-gnu": "4.24.0", + "@rollup/rollup-linux-x64-musl": "4.24.0", + "@rollup/rollup-win32-arm64-msvc": "4.24.0", + "@rollup/rollup-win32-ia32-msvc": "4.24.0", + "@rollup/rollup-win32-x64-msvc": "4.24.0", "fsevents": "~2.3.2" } }, @@ -9737,10 +9794,11 @@ } }, "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -10756,14 +10814,15 @@ } }, "node_modules/vite": { - "version": "5.2.8", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.8.tgz", - "integrity": "sha512-OyZR+c1CE8yeHw5V5t59aXsUPPVTHMDjEZz8MgguLL/Q7NblxhZUlTu9xSPqlsUO/y+X7dlU05jdhvyycD55DA==", + "version": "5.4.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.8.tgz", + "integrity": "sha512-FqrItQ4DT1NC4zCUqMB4c4AZORMKIa0m8/URVCZ77OZ/QSNeJ54bU1vrFADbDsuwfIPcgknRkmqakQcgnL4GiQ==", "dev": true, + "license": "MIT", "dependencies": { - "esbuild": "^0.20.1", - "postcss": "^8.4.38", - "rollup": "^4.13.0" + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" }, "bin": { "vite": "bin/vite.js" @@ -10782,6 +10841,7 @@ "less": "*", "lightningcss": "^1.21.0", "sass": "*", + "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" @@ -10799,6 +10859,9 @@ "sass": { "optional": true }, + "sass-embedded": { + "optional": true + }, "stylus": { "optional": true }, @@ -11597,9 +11660,9 @@ } }, "node_modules/workbox-build/node_modules/rollup": { - "version": "2.79.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.1.tgz", - "integrity": "sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==", + "version": "2.79.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", + "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", "dev": true, "license": "MIT", "bin": { diff --git a/frontend/package.json b/frontend/package.json index 18bef8442..1e4fa0002 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,12 +13,12 @@ }, "private": true, "dependencies": { - "@mantine/core": "^7.12.2", - "@mantine/dropzone": "^7.12.2", - "@mantine/form": "^7.12.2", - "@mantine/hooks": "^7.12.2", - "@mantine/modals": "^7.12.2", - "@mantine/notifications": "^7.12.2", + "@mantine/core": "^7.14.3", + "@mantine/dropzone": "^7.14.3", + "@mantine/form": "^7.14.3", + "@mantine/hooks": "^7.14.3", + "@mantine/modals": "^7.14.3", + "@mantine/notifications": "^7.14.3", "@tanstack/react-query": "^5.40.1", "@tanstack/react-table": "^8.19.2", "axios": "^1.7.4", @@ -30,10 +30,10 @@ }, "devDependencies": { "@fontsource/roboto": "^5.0.12", - "@fortawesome/fontawesome-svg-core": "^6.6.0", - "@fortawesome/free-brands-svg-icons": "^6.6.0", - "@fortawesome/free-regular-svg-icons": "^6.6.0", - "@fortawesome/free-solid-svg-icons": "^6.6.0", + "@fortawesome/fontawesome-svg-core": "^6.7.1", + "@fortawesome/free-brands-svg-icons": "^6.7.1", + "@fortawesome/free-regular-svg-icons": "^6.7.1", + "@fortawesome/free-solid-svg-icons": "^6.7.1", "@fortawesome/react-fontawesome": "^0.2.2", "@tanstack/react-query-devtools": "^5.40.1", "@testing-library/jest-dom": "^6.4.2", @@ -42,7 +42,7 @@ "@types/jest": "^29.5.12", "@types/lodash": "^4.17.1", "@types/node": "^20.12.6", - "@types/react": "^18.3.5", + "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", "@typescript-eslint/eslint-plugin": "^7.16.0", "@typescript-eslint/parser": "^7.16.0", @@ -67,7 +67,7 @@ "recharts": "^2.12.7", "sass": "^1.74.1", "typescript": "^5.4.4", - "vite": "^5.2.8", + "vite": "^5.4.8", "vite-plugin-checker": "^0.6.4", "vite-plugin-pwa": "^0.20.0", "vitest": "^1.2.2", diff --git a/frontend/src/App/Header.tsx b/frontend/src/App/Header.tsx index 29c1d1a8d..987218ef8 100644 --- a/frontend/src/App/Header.tsx +++ b/frontend/src/App/Header.tsx @@ -39,20 +39,20 @@ const AppHeader: FunctionComponent = () => { - - - show(!showed)} size="sm" hiddenFrom="sm" > - + + + + Bazarr diff --git a/frontend/src/components/Search.test.tsx b/frontend/src/components/Search.test.tsx new file mode 100644 index 000000000..bac037c7f --- /dev/null +++ b/frontend/src/components/Search.test.tsx @@ -0,0 +1,9 @@ +import { describe, it } from "vitest"; +import { Search } from "@/components/index"; +import { render } from "@/tests"; + +describe("Search Bar", () => { + it.skip("should render the closed empty state", () => { + render(); + }); +}); diff --git a/frontend/src/components/Search.tsx b/frontend/src/components/Search.tsx index c0dde3bef..d1c559be5 100644 --- a/frontend/src/components/Search.tsx +++ b/frontend/src/components/Search.tsx @@ -1,48 +1,53 @@ import { FunctionComponent, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { Autocomplete, ComboboxItem, OptionsFilter, Text } from "@mantine/core"; +import { + ComboboxItem, + em, + Flex, + Image, + OptionsFilter, + Select, + Text, +} from "@mantine/core"; +import { useMediaQuery } from "@mantine/hooks"; import { faSearch } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { chain, includes } from "lodash"; import { useServerSearch } from "@/apis/hooks"; import { useDebouncedValue } from "@/utilities"; type SearchResultItem = { value: string; + label: string; link: string; + poster: string; + type: string; }; function useSearch(query: string) { const debouncedQuery = useDebouncedValue(query, 500); 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( () => data?.map((v) => { - const { link, displayName } = (() => { - const hasDuplicate = includes(duplicates, `${v.title} (${v.year})`); - + const { link, label, poster, type, value } = (() => { if (v.sonarrSeriesId) { return { + poster: v.poster, link: `/series/${v.sonarrSeriesId}`, - displayName: hasDuplicate - ? `${v.title} (${v.year}) (S)` - : `${v.title} (${v.year})`, + type: "show", + label: `${v.title} (${v.year})`, + value: `s-${v.sonarrSeriesId}`, }; } if (v.radarrId) { return { + poster: v.poster, link: `/movies/${v.radarrId}`, - displayName: hasDuplicate - ? `${v.title} (${v.year}) (M)` - : `${v.title} (${v.year})`, + type: "movie", + value: `m-${v.radarrId}`, + label: `${v.title} (${v.year})`, }; } @@ -50,11 +55,14 @@ function useSearch(query: string) { })(); return { - value: displayName, - link, + value: value, + poster: poster, + label: label, + type: type, + link: link, }; }) ?? [], - [data, duplicates], + [data], ); } @@ -64,8 +72,8 @@ const optionsFilter: OptionsFilter = ({ options, search }) => { return (options as ComboboxItem[]).filter((option) => { return ( - option.value.toLowerCase().includes(lowercaseSearch) || - option.value + option.label.toLowerCase().includes(lowercaseSearch) || + option.label .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") .toLowerCase() @@ -80,23 +88,41 @@ const Search: FunctionComponent = () => { const results = useSearch(query); + const isMobile = useMediaQuery(`(max-width: ${em(750)})`); + return ( - } - renderOption={(input) => {input.option.value}} +